验证(08-10-pwa-cross-version-cache-upgrade-validation): 固化PWA跨版本升级回归

这个提交包含在:
2026-08-10 18:05:15 +08:00
父节点 44737697e5
当前提交 6923f4d1f7
共修改 11 个文件,包含 1095 行新增和 0 行删除
@@ -0,0 +1,640 @@
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<string>;
};
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: "[email protected]",
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<void>((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<void>((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
});
}
function setDifference(left: Set<string>, right: Set<string>) {
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<BrowserWitnessState> {
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<CacheSnapshot> {
return page.evaluate(async () => {
const names = await window.caches.keys();
const paths = new Set<string>();
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<ServiceWorkerSnapshot> {
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<HTMLScriptElement>("script[src]"), (element) => new URL(element.src).pathname),
...Array.from(document.querySelectorAll<HTMLLinkElement>("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();
}
});