## Summary - give the complete public asset inventory gate a five-minute test budget - keep the API and rendered guest-flow smoke tests on the existing default timeout ## Rationale The production manifest contains the full release inventory. Sequentially downloading and hashing it takes longer than the global 60-second Playwright timeout from GitHub-hosted runners, causing false failures even though release identity, API, and guest-flow checks pass. ## Verification - ESLint on the changed spec - Playwright live configuration test discovery --------- Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
161 lines
6.0 KiB
TypeScript
161 lines
6.0 KiB
TypeScript
import { expect, test, type APIRequestContext } from "@playwright/test";
|
|
import crypto from "node:crypto";
|
|
import { attachPageHealthGuards, expectBodyHasContent, settlePage } from "./helpers";
|
|
|
|
type ReleaseManifest = {
|
|
build_id?: string;
|
|
commit_sha?: string;
|
|
entry?: string;
|
|
css?: string[];
|
|
index_asset_urls?: string[];
|
|
pwa_asset_urls?: string[];
|
|
asset_urls?: string[];
|
|
asset_hashes?: Record<string, { sha256?: string; bytes?: number }>;
|
|
};
|
|
|
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "";
|
|
const staticBaseURL = process.env.PLAYWRIGHT_RELEASE_STATIC_BASE_URL || process.env.RELEASE_BASE_URL || baseURL;
|
|
const appBaseURL = process.env.PLAYWRIGHT_APP_BASE_URL || baseURL;
|
|
const releaseAssetTestTimeoutMs = 300_000;
|
|
|
|
function liveUrl(assetPath: string, base = staticBaseURL) {
|
|
const normalizedBase = base.endsWith("/") ? base : `${base}/`;
|
|
return new URL(assetPath.replace(/^\/+/, ""), normalizedBase).toString();
|
|
}
|
|
|
|
function unique(values: string[]) {
|
|
return Array.from(new Set(values.filter(Boolean)));
|
|
}
|
|
|
|
function sha256(bytes: Buffer) {
|
|
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
function rejectsHtml(assetPath: string) {
|
|
return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath);
|
|
}
|
|
|
|
async function expectJson<T>(request: APIRequestContext, assetPath: string): Promise<T> {
|
|
const response = await request.get(liveUrl(assetPath), {
|
|
headers: {
|
|
"Cache-Control": "no-cache",
|
|
Pragma: "no-cache",
|
|
},
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.status(), `${assetPath} status`).toBe(200);
|
|
expect(response.headers()["content-type"] || "", `${assetPath} content-type`).not.toContain("text/html");
|
|
expect(body.trim().length, `${assetPath} body`).toBeGreaterThan(0);
|
|
|
|
return JSON.parse(body) as T;
|
|
}
|
|
|
|
async function expectStaticAsset(
|
|
request: APIRequestContext,
|
|
assetPath: string,
|
|
expectedHash?: { sha256?: string; bytes?: number }
|
|
) {
|
|
const response = await request.get(liveUrl(assetPath), {
|
|
headers: {
|
|
"Cache-Control": "no-cache",
|
|
Pragma: "no-cache",
|
|
},
|
|
});
|
|
const body = await response.body();
|
|
const contentType = response.headers()["content-type"] || "";
|
|
|
|
expect(response.status(), `${assetPath} status`).toBe(200);
|
|
expect(body.length, `${assetPath} body`).toBeGreaterThan(0);
|
|
if (rejectsHtml(assetPath)) {
|
|
expect(contentType, `${assetPath} content-type`).not.toContain("text/html");
|
|
}
|
|
if (expectedHash?.bytes !== undefined) {
|
|
expect(body.length, `${assetPath} bytes`).toBe(expectedHash.bytes);
|
|
}
|
|
if (expectedHash?.sha256) {
|
|
expect(sha256(body), `${assetPath} sha256`).toBe(expectedHash.sha256);
|
|
}
|
|
}
|
|
|
|
async function expectApiPing(request: APIRequestContext, apiBaseUrl: string, path: string) {
|
|
const url = new URL(path.replace(/^\/+/, ""), apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`).toString();
|
|
const response = await request.get(url, {
|
|
headers: {
|
|
"Cache-Control": "no-cache",
|
|
Pragma: "no-cache",
|
|
},
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.status(), `${url} status`).toBe(200);
|
|
expect(response.headers()["content-type"] || "", `${url} content-type`).toMatch(/json/i);
|
|
expect(body.trim().length, `${url} body`).toBeGreaterThan(0);
|
|
expect(JSON.parse(body), `${url} JSON`).toMatchObject({ success: true });
|
|
}
|
|
|
|
test("@public-live release manifest, shell, and static assets are available", async ({ request }) => {
|
|
test.setTimeout(releaseAssetTestTimeoutMs);
|
|
|
|
const manifest = await expectJson<ReleaseManifest>(request, "release-manifest.json");
|
|
const releaseEntry = await expectJson<{ entry?: string; css?: string[] }>(request, "release-entry.json");
|
|
|
|
expect(manifest.build_id, "release-manifest.json build_id").toBeTruthy();
|
|
expect(manifest.commit_sha, "release-manifest.json commit_sha").toBeTruthy();
|
|
expect(releaseEntry.entry, "release-entry.json entry").toBe(manifest.entry);
|
|
expect(releaseEntry.css || [], "release-entry.json css").toEqual(manifest.css || []);
|
|
|
|
for (const shellPath of ["/", "/guest/book/wash"]) {
|
|
const response = await request.get(liveUrl(shellPath), {
|
|
headers: {
|
|
"Cache-Control": "no-cache",
|
|
Pragma: "no-cache",
|
|
},
|
|
});
|
|
const body = await response.text();
|
|
expect(response.status(), `${shellPath} status`).toBe(200);
|
|
expect(response.headers()["content-type"] || "", `${shellPath} content-type`).toContain("text/html");
|
|
expect(body.replace(/\s+/g, "").length, `${shellPath} body`).toBeGreaterThan(40);
|
|
expect(body, `${shellPath} bootstrap loader`).toContain('data-testid="app-bootstrap-loading"');
|
|
}
|
|
|
|
const assetPaths = unique([
|
|
"release-manifest.json",
|
|
"release-entry.json",
|
|
manifest.entry || "",
|
|
...(manifest.css || []),
|
|
...(manifest.index_asset_urls || []),
|
|
...(manifest.pwa_asset_urls || []),
|
|
...(manifest.asset_urls || []),
|
|
]).filter((assetPath) => assetPath !== "/index.html");
|
|
|
|
for (const assetPath of assetPaths) {
|
|
const hashKey = assetPath.startsWith("/") ? assetPath : `/${assetPath}`;
|
|
await expectStaticAsset(request, assetPath, manifest.asset_hashes?.[assetPath] || manifest.asset_hashes?.[hashKey]);
|
|
}
|
|
});
|
|
|
|
test("@public-live api-v2 gateway and channel API prefixes serve JSON ping responses", async ({ request }) => {
|
|
const apiBaseUrl = process.env.PLAYWRIGHT_RELEASE_API_BASE_URL || "https://api-v2.truckwash.io";
|
|
const apiPingPaths = unique(
|
|
(process.env.PLAYWRIGHT_RELEASE_API_PING_PATHS || "/master/api/ping").split(",").map((value) => value.trim())
|
|
);
|
|
|
|
for (const apiPingPath of apiPingPaths) {
|
|
await expectApiPing(request, apiBaseUrl, apiPingPath);
|
|
}
|
|
});
|
|
|
|
test("@public-live guest flow renders on the deployed environment", async ({ page }) => {
|
|
const guards = attachPageHealthGuards(page, appBaseURL);
|
|
|
|
await page.goto(liveUrl("/guest/book/wash", appBaseURL));
|
|
await settlePage(page);
|
|
await expect(page).toHaveURL(/\/guest\/book\/wash(?:\?.*)?$/);
|
|
await expectBodyHasContent(page);
|
|
await expect(page.locator("body")).not.toContainText(/404/i);
|
|
|
|
await guards.expectHealthy();
|
|
guards.dispose();
|
|
});
|