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; }; const baseURL = process.env.PLAYWRIGHT_BASE_URL || ""; function liveUrl(assetPath: string) { const normalizedBase = baseURL.endsWith("/") ? baseURL : `${baseURL}/`; 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(request: APIRequestContext, assetPath: string): Promise { 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 }) => { const manifest = await expectJson(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} Vue root`).toContain('
'); } 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?.[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 || "/ping,/master/api/ping,/canary/api/ping,/stable/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, baseURL); await page.goto("/guest/book/wash"); 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(); });