import { createHash } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import { expect, test, type BrowserContext, type Page } from "@playwright/test"; type BuildLabel = "A" | "B"; type BuildMetadata = { label: BuildLabel; root: string; buildSha: string; swSha256: string; assets: Set; }; type RequestLedgerEntry = { build: BuildLabel; method: string; path: string; status: number; }; type BrowserWitnessState = { controllerChanges: number; updateReadyEvents: number; }; type CacheSnapshot = { names: string[]; paths: string[]; }; type ServiceWorkerSnapshot = { controllerIsActive: boolean; controllerState: string | null; fetchedSha256: string; }; const BUILD_A_DIR = requiredEnvironmentValue("PWA_BUILD_A_DIR"); const BUILD_B_DIR = requiredEnvironmentValue("PWA_BUILD_B_DIR"); const BUILD_A_SHA = requiredBuildSha("PWA_BUILD_A_SHA"); const BUILD_B_SHA = requiredBuildSha("PWA_BUILD_B_SHA"); const WITNESS_STATE_KEY = "hyb-oa.pwa-upgrade-witness"; let activeBuild: BuildMetadata; let buildA: BuildMetadata; let buildB: BuildMetadata; let server: Server; let origin: string; const requestLedger: RequestLedgerEntry[] = []; test.use({ serviceWorkers: "allow", screenshot: "off", trace: "off", video: "off", }); test.describe.configure({ mode: "serial" }); test.setTimeout(120_000); function requiredEnvironmentValue(name: string) { const value = process.env[name]?.trim(); if (!value) { throw new Error(`${name}_missing`); } return value; } function requiredBuildSha(name: string) { const value = requiredEnvironmentValue(name).toLowerCase(); if (!/^[0-9a-f]{12}$/u.test(value)) { throw new Error(`${name}_invalid`); } return value; } function sha256(value: Buffer) { return createHash("sha256").update(value).digest("hex"); } function buildMetadata(label: BuildLabel, root: string, buildSha: string): BuildMetadata { const indexPath = path.join(root, "index.html"); const swPath = path.join(root, "sw.js"); if (!statSync(indexPath).isFile() || !statSync(swPath).isFile()) { throw new Error(`build_${label}_artifact_missing`); } const assetsRoot = path.join(root, "assets"); const assets = new Set( readdirSync(assetsRoot) .filter((name) => /\.(?:css|js)$/u.test(name)) .map((name) => `/assets/${name}`), ); const buildShaHits = [...assets].filter((assetPath) => readFileSync(path.join(root, assetPath.slice(1))).includes(Buffer.from(buildSha)), ); if (buildShaHits.length === 0) { throw new Error(`build_${label}_sha_not_embedded`); } return { label, root, buildSha, swSha256: sha256(readFileSync(swPath)), assets, }; } function setSafeHeaders(response: ServerResponse, status: number, contentType: string, cacheControl: string) { response.statusCode = status; response.setHeader("content-type", contentType); response.setHeader("cache-control", cacheControl); response.setHeader("x-content-type-options", "nosniff"); } function recordResponse(request: IncomingMessage, pathname: string, status: number) { requestLedger.push({ build: activeBuild.label, method: request.method ?? "GET", path: pathname, status, }); } function sendJson(request: IncomingMessage, response: ServerResponse, pathname: string, data: unknown, status = 200) { setSafeHeaders(response, status, "application/json; charset=utf-8", "no-store"); recordResponse(request, pathname, status); response.end(JSON.stringify({ requestId: "pwa-upgrade-witness", data })); } function syntheticEmployee() { return { id: "employee-pwa-upgrade", employeeNo: "SYNTHETIC001", name: "合成员工", phone: null, email: "synthetic@example.invalid", departmentId: "department-pwa-upgrade", directManagerId: null, status: "active", notificationPushEnabled: true, roleCodes: ["employee"], profileCompleteness: { hasEmergencyContact: true, hasBankAccount: false, hasAvatar: false, hasIdNumber: false, score: 25, }, wecomBinding: null, }; } function syntheticNavigation() { return { workspace: { id: "workspace-pwa-upgrade", persisted: true, profile: "small_team_internal", orgMode: "single_company", primaryEntryMode: "password", notificationStrategy: "in_app", devicePriority: "desktop", moduleVisibility: {}, createdAt: null, updatedAt: null, updatedBy: null, }, sections: { main: [ { key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null, }, ], admin: [], backup: [], }, recommendedRolePresets: [], }; } function syntheticPortalHome() { return { workspace: { id: "workspace-pwa-upgrade", persisted: true, profile: "small_team_internal" }, greeting: { name: "合成员工" }, cards: [], pendingTasks: { count: 0, href: "/workflow/tasks" }, myApplications: [], riskSummary: null, announcements: [], quickLinks: [], todayPresence: null, teamPresence: null, weeklyReport: null, weeklySummary: null, todoSummary: null, projectSummary: null, peopleLifecycle: null, adoptionSummary: null, recentActivities: [], teamStatusBoard: [], roleCapabilities: { canSubmitApplications: true, canApproveTasks: false, canUseFinance: false, canManageSystem: false, canViewTeam: false, isAdminOnly: false, }, }; } function handleApi(request: IncomingMessage, response: ServerResponse, pathname: string) { if (pathname === "/ready") { sendJson(request, response, pathname, { ok: true, buildSha: activeBuild.buildSha }); return true; } if (pathname === "/api/auth/refresh") { sendJson(request, response, pathname, { accessToken: "synthetic-pwa-upgrade-token", expiresIn: 3600, mustChangePassword: false, }); return true; } if (pathname === "/api/me") { sendJson(request, response, pathname, syntheticEmployee()); return true; } if (pathname === "/api/system/navigation") { sendJson(request, response, pathname, syntheticNavigation()); return true; } if (pathname === "/api/portal/home") { sendJson(request, response, pathname, syntheticPortalHome()); return true; } if (pathname === "/api/attendance/me/today") { sendJson(request, response, pathname, { workDate: "2026-08-10", needClockIn: false, exemptionReason: "synthetic_witness", item: null, }); return true; } if (pathname === "/api/notifications") { sendJson(request, response, pathname, { page: 1, pageSize: 1, total: 0, unreadOnly: true, unreadCount: 0, items: [], }); return true; } if (pathname.startsWith("/api/")) { sendJson(request, response, pathname, {}); return true; } return false; } function contentTypeFor(pathname: string) { if (pathname.endsWith(".js")) return "text/javascript; charset=utf-8"; if (pathname.endsWith(".css")) return "text/css; charset=utf-8"; if (pathname.endsWith(".html")) return "text/html; charset=utf-8"; if (pathname.endsWith(".json") || pathname.endsWith(".webmanifest")) return "application/json; charset=utf-8"; if (pathname.endsWith(".svg")) return "image/svg+xml"; if (pathname.endsWith(".png")) return "image/png"; if (pathname.endsWith(".ico")) return "image/x-icon"; if (pathname.endsWith(".woff2")) return "font/woff2"; return "application/octet-stream"; } function safeStaticPath(root: string, pathname: string) { const decoded = decodeURIComponent(pathname); const relative = decoded.replace(/^\/+/, ""); if (!relative || relative.split("/").includes("..")) { return null; } const candidate = path.resolve(root, relative); const rootPrefix = `${path.resolve(root)}${path.sep}`; return candidate.startsWith(rootPrefix) ? candidate : null; } function serveStatic(request: IncomingMessage, response: ServerResponse, pathname: string) { const requestedPath = safeStaticPath(activeBuild.root, pathname); const isNavigation = request.headers.accept?.includes("text/html") ?? false; let filePath = requestedPath; if (!filePath || !statExists(filePath)) { if (!isNavigation) { setSafeHeaders(response, 404, "text/plain; charset=utf-8", "no-store"); recordResponse(request, pathname, 404); response.end("not found"); return; } filePath = path.join(activeBuild.root, "index.html"); } const body = readFileSync(filePath); const relativePath = path.relative(activeBuild.root, filePath).split(path.sep).join("/"); const noCache = relativePath === "index.html" || relativePath === "sw.js"; setSafeHeaders( response, 200, contentTypeFor(relativePath), noCache ? "no-cache" : "public, max-age=31536000, immutable", ); if (relativePath === "sw.js") { response.setHeader("service-worker-allowed", "/"); } recordResponse(request, pathname, 200); response.end(body); } function statExists(filePath: string) { try { return statSync(filePath).isFile(); } catch { return false; } } async function handleRequest(request: IncomingMessage, response: ServerResponse) { const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); if (handleApi(request, response, requestUrl.pathname)) { return; } serveStatic(request, response, requestUrl.pathname); } async function startHarnessServer() { server = createServer((request, response) => { void handleRequest(request, response).catch(() => { if (!response.headersSent) { setSafeHeaders(response, 500, "text/plain; charset=utf-8", "no-store"); recordResponse(request, new URL(request.url ?? "/", "http://127.0.0.1").pathname, 500); } response.end("internal error"); }); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => resolve()); }); const address = server.address(); if (!address || typeof address === "string" || address.address !== "127.0.0.1") { throw new Error("loopback_server_address_invalid"); } origin = `http://127.0.0.1:${address.port}`; } async function closeHarnessServer() { if (!server) return; await new Promise((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()); }); } function setDifference(left: Set, right: Set) { return new Set([...left].filter((item) => !right.has(item))); } async function installWitnessObservers(context: BrowserContext) { await context.addInitScript((storageKey) => { const readState = (): BrowserWitnessState => { try { const current = window.sessionStorage.getItem(storageKey); return current ? JSON.parse(current) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0 }; } catch { return { controllerChanges: 0, updateReadyEvents: 0 }; } }; const writeState = (state: BrowserWitnessState) => { window.sessionStorage.setItem(storageKey, JSON.stringify(state)); }; writeState(readState()); window.navigator.serviceWorker.addEventListener("controllerchange", () => { const state = readState(); state.controllerChanges += 1; writeState(state); }); window.addEventListener("hyb-oa:pwa-update-ready", () => { const state = readState(); state.updateReadyEvents += 1; writeState(state); }); }, WITNESS_STATE_KEY); } async function readWitnessState(page: Page): Promise { return page.evaluate((storageKey) => { const value = window.sessionStorage.getItem(storageKey); return value ? JSON.parse(value) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0 }; }, WITNESS_STATE_KEY); } async function readCacheSnapshot(page: Page): Promise { return page.evaluate(async () => { const names = await window.caches.keys(); const paths = new Set(); for (const name of names) { const cache = await window.caches.open(name); for (const request of await cache.keys()) { paths.add(new URL(request.url).pathname); } } return { names, paths: [...paths].sort() }; }); } async function documentBuildSha(page: Page) { return page.evaluate(() => document.documentElement.dataset.buildSha ?? null); } async function controllerPath(page: Page) { return page.evaluate(() => { const scriptUrl = window.navigator.serviceWorker.controller?.scriptURL; return scriptUrl ? new URL(scriptUrl).pathname : null; }); } async function serviceWorkerSnapshot(page: Page): Promise { return page.evaluate(async () => { const registration = await window.navigator.serviceWorker.ready; const controller = window.navigator.serviceWorker.controller; const response = await window.fetch("/sw.js", { cache: "no-store" }); if (!response.ok) { throw new Error(`service_worker_fetch_failed_${response.status}`); } const digest = await window.crypto.subtle.digest("SHA-256", await response.arrayBuffer()); return { controllerIsActive: controller !== null && registration.active === controller, controllerState: controller?.state ?? null, fetchedSha256: Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""), }; }); } function requestCount(build: BuildLabel, pathname: string, status = 200) { return requestLedger.filter((entry) => entry.build === build && entry.path === pathname && entry.status === status).length; } function safeLedger(input: { phase: string; documentSha: string | null; controller: string | null; cacheNames: string[]; aOnlyCached: number; bOnlyCached: number; bOnlyLoaded: number; navigationCount: number; witnessState: BrowserWitnessState; serviceWorker: ServiceWorkerSnapshot; }) { return { phase: input.phase, documentSha: input.documentSha, controller: input.controller, cacheNames: input.cacheNames.sort(), aOnlyCached: input.aOnlyCached, bOnlyCached: input.bOnlyCached, bOnlyLoaded: input.bOnlyLoaded, navigationCount: input.navigationCount, controllerChanges: input.witnessState.controllerChanges, controllerIsActive: input.serviceWorker.controllerIsActive, controllerState: input.serviceWorker.controllerState, updateReadyEvents: input.witnessState.updateReadyEvents, refresh200: requestCount(activeBuild.label, "/api/auth/refresh"), me200: requestCount(activeBuild.label, "/api/me"), loginNavigations: requestLedger.filter((entry) => entry.path === "/login").length, static404: requestLedger.filter((entry) => entry.status === 404 && /\.(?:css|js)$/u.test(entry.path)).length, swSha256Prefix: activeBuild.swSha256.slice(0, 12), fetchedSwSha256Matches: input.serviceWorker.fetchedSha256 === activeBuild.swSha256, }; } test.beforeAll(async () => { buildA = buildMetadata("A", BUILD_A_DIR, BUILD_A_SHA); buildB = buildMetadata("B", BUILD_B_DIR, BUILD_B_SHA); const aOnlyAssets = setDifference(buildA.assets, buildB.assets); const bOnlyAssets = setDifference(buildB.assets, buildA.assets); if (aOnlyAssets.size === 0 || bOnlyAssets.size === 0 || buildA.swSha256 === buildB.swSha256) { throw new Error("build_version_artifacts_not_distinct"); } activeBuild = buildA; await startHarnessServer(); }); test.afterAll(async () => { await closeHarnessServer(); }); test("production PWA 在同 profile 中从旧 build 自动收敛到新 build", async ({ context, page }) => { const aOnlyAssets = setDifference(buildA.assets, buildB.assets); const bOnlyAssets = setDifference(buildB.assets, buildA.assets); await installWitnessObservers(context); await context.addCookies([{ name: "has_session", value: "1", url: origin, sameSite: "Lax" }]); let aNavigations = 0; page.on("framenavigated", (frame) => { if (frame === page.mainFrame()) aNavigations += 1; }); await page.goto(`${origin}/portal`, { waitUntil: "domcontentloaded" }); await expect.poll(() => documentBuildSha(page)).toBe(BUILD_A_SHA); await expect(page).toHaveURL(`${origin}/portal`); await page.evaluate(() => window.navigator.serviceWorker.ready.then(() => undefined)); await page.reload({ waitUntil: "domcontentloaded" }); await expect.poll(() => controllerPath(page)).toBe("/sw.js"); await expect.poll(() => documentBuildSha(page)).toBe(BUILD_A_SHA); await expect(page).toHaveURL(`${origin}/portal`); const cacheA = await readCacheSnapshot(page); const cachedAOnly = [...aOnlyAssets].filter((assetPath) => cacheA.paths.includes(assetPath)); expect(cacheA.names.some((name) => name.startsWith("workbox-precache"))).toBe(true); expect(cachedAOnly.length).toBeGreaterThan(0); const stateA = await readWitnessState(page); const workerA = await serviceWorkerSnapshot(page); expect(workerA).toEqual({ controllerIsActive: true, controllerState: "activated", fetchedSha256: buildA.swSha256, }); console.log("[pwa-cross-version]", JSON.stringify(safeLedger({ phase: "A_CONTROLLED", documentSha: await documentBuildSha(page), controller: await controllerPath(page), cacheNames: cacheA.names, aOnlyCached: cachedAOnly.length, bOnlyCached: [...bOnlyAssets].filter((assetPath) => cacheA.paths.includes(assetPath)).length, bOnlyLoaded: 0, navigationCount: aNavigations, witnessState: stateA, serviceWorker: workerA, }))); await page.close(); activeBuild = buildB; const pageB = await context.newPage(); let bNavigations = 0; const pageErrors: string[] = []; const chunkErrors: string[] = []; pageB.on("framenavigated", (frame) => { if (frame === pageB.mainFrame()) bNavigations += 1; }); pageB.on("pageerror", (error) => { pageErrors.push(error.name); }); pageB.on("console", (message) => { if (message.type() === "error" && /chunk|preload/iu.test(message.text())) { chunkErrors.push("console_chunk_or_preload_error"); } }); let workerB: ServiceWorkerSnapshot | null = null; try { await pageB.goto(`${origin}/portal`, { waitUntil: "domcontentloaded" }); await expect.poll(() => documentBuildSha(pageB), { timeout: 45_000 }).toBe(BUILD_B_SHA); await expect(pageB).toHaveURL(`${origin}/portal`); await expect.poll(() => controllerPath(pageB)).toBe("/sw.js"); await expect.poll(() => requestCount("B", "/api/auth/refresh")).toBeGreaterThan(0); await expect.poll(() => requestCount("B", "/api/me")).toBeGreaterThan(0); const cacheB = await readCacheSnapshot(pageB); const finalAOnly = [...aOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)); const finalBOnly = [...bOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)); const loadedAssetPaths = await pageB.evaluate(() => [ ...Array.from(document.querySelectorAll("script[src]"), (element) => new URL(element.src).pathname), ...Array.from(document.querySelectorAll("link[href]"), (element) => new URL(element.href).pathname), ...performance.getEntriesByType("resource").map((entry) => new URL(entry.name).pathname), ]); const loadedBOnly = [...bOnlyAssets].filter((assetPath) => loadedAssetPaths.includes(assetPath)); const stateB = await readWitnessState(pageB); workerB = await serviceWorkerSnapshot(pageB); const bSwRequests = requestLedger.filter((entry) => entry.build === "B" && entry.path === "/sw.js" && entry.status === 200); expect(bSwRequests.length).toBeGreaterThan(0); expect(workerB).toEqual({ controllerIsActive: true, controllerState: "activated", fetchedSha256: buildB.swSha256, }); expect(finalAOnly).toEqual([]); expect(finalBOnly.length + loadedBOnly.length).toBeGreaterThan(0); expect(pageB.url()).not.toContain("/login"); expect(requestLedger.filter((entry) => entry.status === 404 && /\.(?:css|js)$/u.test(entry.path))).toEqual([]); expect(pageErrors).toEqual([]); expect(chunkErrors).toEqual([]); expect(bNavigations).toBeLessThanOrEqual(3); expect(stateB.controllerChanges).toBeGreaterThan(0); console.log("[pwa-cross-version]", JSON.stringify(safeLedger({ phase: "B_CONVERGED", documentSha: await documentBuildSha(pageB), controller: await controllerPath(pageB), cacheNames: cacheB.names, aOnlyCached: finalAOnly.length, bOnlyCached: finalBOnly.length, bOnlyLoaded: loadedBOnly.length, navigationCount: bNavigations, witnessState: stateB, serviceWorker: workerB, }))); } catch (error) { const cacheB = await readCacheSnapshot(pageB).catch(() => ({ names: [], paths: [] })); const stateB = await readWitnessState(pageB).catch(() => ({ controllerChanges: 0, updateReadyEvents: 0 })); const frozenWorkerB = workerB ?? { controllerIsActive: false, controllerState: null, fetchedSha256: "", }; console.log("[pwa-cross-version]", JSON.stringify(safeLedger({ phase: "B_FIRST_FAILURE_FROZEN", documentSha: await documentBuildSha(pageB).catch(() => null), controller: await controllerPath(pageB).catch(() => null), cacheNames: cacheB.names, aOnlyCached: [...aOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length, bOnlyCached: [...bOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length, bOnlyLoaded: 0, navigationCount: bNavigations, witnessState: stateB, serviceWorker: frozenWorkerB, }))); throw error; } finally { await pageB.close(); } });