Add release management components and update routing logic. Introduce keyboard shortcuts, enhance release data grid, and improve asset handling in Nginx configuration.

This commit is contained in:
Jeppe Bundgaard
2026-05-26 14:14:13 +02:00
parent a952dd2c61
commit 8ab8915429
77 changed files with 8353 additions and 1143 deletions
+145 -87
View File
@@ -1,99 +1,157 @@
import { expect, test } from "@playwright/test";
import { loginAsOperator, loginAsUser } from "../fixtures/authHelpers";
import { attachPageHealthGuards, expectBodyHasContent, expectOneVisible, requiredEnv, settlePage } from "./helpers";
import { expect, test, type APIRequestContext } from "@playwright/test";
import crypto from "node:crypto";
import { attachPageHealthGuards, expectBodyHasContent, settlePage } from "./helpers";
const liveSmokeEnabled = Boolean(
process.env.PLAYWRIGHT_BASE_URL &&
process.env.PLAYWRIGHT_USER_CUSTOMER_NUMBER &&
process.env.PLAYWRIGHT_USER_PASSWORD &&
process.env.PLAYWRIGHT_OPERATOR_USER_ID &&
process.env.PLAYWRIGHT_OPERATOR_PASSWORD
);
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 }>;
};
function getLiveSettings() {
return {
baseURL: requiredEnv("PLAYWRIGHT_BASE_URL"),
customerCredentials: {
customerNumber: requiredEnv("PLAYWRIGHT_USER_CUSTOMER_NUMBER"),
password: requiredEnv("PLAYWRIGHT_USER_PASSWORD"),
otpSecret: process.env.PLAYWRIGHT_USER_OTP_SECRET || "",
twoFactorAuthentication: Boolean(process.env.PLAYWRIGHT_USER_OTP_SECRET),
},
operatorCredentials: {
userId: requiredEnv("PLAYWRIGHT_OPERATOR_USER_ID"),
password: requiredEnv("PLAYWRIGHT_OPERATOR_PASSWORD"),
},
departmentId: Number.parseInt(process.env.PLAYWRIGHT_DEPARTMENT_ID || "12", 10),
};
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "";
function liveUrl(assetPath: string) {
const normalizedBase = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
return new URL(assetPath.replace(/^\/+/, ""), normalizedBase).toString();
}
test.describe.configure({ mode: "serial" });
function unique(values: string[]) {
return Array.from(new Set(values.filter(Boolean)));
}
test("api-v2 gateway ping serves a trusted TLS API response", async ({ request }) => {
const response = await request.get("https://api-v2.truckwash.io/ping");
const body = await response.json();
function sha256(bytes: Buffer) {
return crypto.createHash("sha256").update(bytes).digest("hex");
}
expect(response.ok()).toBeTruthy();
expect(body).toMatchObject({
success: true,
data: {
message: "pong",
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 }) => {
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} Vue root`).toContain('<div id="app"></div>');
}
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.describe("Live smoke release gate", () => {
test.skip(!liveSmokeEnabled, "Set PLAYWRIGHT_BASE_URL and seeded live credentials to run the live smoke gate.");
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())
);
test("guest flow renders on the deployed environment", async ({ page }) => {
const { baseURL } = getLiveSettings();
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();
});
test("customer login reaches dashboard, profile, and bookings", async ({ page }) => {
const { baseURL, customerCredentials } = getLiveSettings();
const guards = attachPageHealthGuards(page, baseURL);
await loginAsUser(page, customerCredentials);
await expect(page.locator("#book-wash-button")).toBeVisible();
await expect(page.locator("#download-invoices-button")).toBeVisible();
await page.goto("/user/profile");
await settlePage(page);
await expect(page).toHaveURL(/\/user\/profile(?:\?.*)?$/);
await expect(page.locator(".card-header").first()).toBeVisible();
await page.goto("/user/bookings");
await settlePage(page);
await expect(page).toHaveURL(/\/user\/bookings(?:\?.*)?$/);
await expect(page.locator(".title").first()).toBeVisible();
await guards.expectHealthy();
guards.dispose();
});
test("operator login reaches the seeded department route", async ({ page }) => {
const { baseURL, operatorCredentials, departmentId } = getLiveSettings();
const guards = attachPageHealthGuards(page, baseURL);
await loginAsOperator(page, operatorCredentials);
await page.goto(`/admin/${departmentId}/modules/pos`);
await settlePage(page);
await expect(page).toHaveURL(new RegExp(`/admin/${departmentId}/modules/pos(?:\\?.*)?$`));
await expectOneVisible([page.getByTestId("pos-step-1"), page.getByTestId("pos-mobile-step-1-shell")]);
await guards.expectHealthy();
guards.dispose();
});
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();
});