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:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user