N3 第二轮候选 18315d11:浏览器 371 通过、1 失败;报销付款主链路通过。 PWA 自动导航会销毁旧执行环境,原 expect.poll 回调直接 page.evaluate 因此提前抛错。 改为当前 html 的版本属性断言,保持 45 秒期限、同会话 A/B 联合见证及全部否定断言。 受管定向 1 项通过,无失败、跳过或重试,环境及清理通过;任务/治理检查通过。 应用、资金规则、迁移、门禁范围和并发/重试配置未改。两个失败回执完整保留。 第三轮完整候选验证以新回执为准;独立代码复核及真实还款待验。 仅本地提交,不推送、不部署,不访问生产或真实 OCR。
687 行
24 KiB
TypeScript
687 行
24 KiB
TypeScript
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;
|
|
time: number;
|
|
};
|
|
|
|
type WorkerEvent = { time: number; event: string; state: string | null; documentSha: string | null };
|
|
|
|
type BrowserWitnessState = {
|
|
events: WorkerEvent[];
|
|
controllerChanges: number;
|
|
updateReadyEvents: number;
|
|
};
|
|
|
|
type CacheSnapshot = {
|
|
names: string[];
|
|
paths: string[];
|
|
indexSha256: 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,
|
|
time: Date.now(),
|
|
});
|
|
}
|
|
|
|
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, events: [] };
|
|
} catch {
|
|
return { controllerChanges: 0, updateReadyEvents: 0, events: [] };
|
|
}
|
|
};
|
|
const writeState = (state: BrowserWitnessState) => {
|
|
window.sessionStorage.setItem(storageKey, JSON.stringify(state));
|
|
};
|
|
writeState(readState());
|
|
const record = (event: string, worker?: ServiceWorker | null) => {
|
|
const state = readState();
|
|
state.events.push({ time: Date.now(), event, state: worker?.state ?? null,
|
|
documentSha: document.documentElement?.dataset.buildSha ?? null });
|
|
state.events = state.events.slice(-100);
|
|
writeState(state);
|
|
};
|
|
record("document-start", window.navigator.serviceWorker.controller);
|
|
document.addEventListener("DOMContentLoaded", () => record("document-ready", window.navigator.serviceWorker.controller));
|
|
const watched = new WeakSet<ServiceWorker>();
|
|
const watch = (worker: ServiceWorker | null, role: string) => {
|
|
if (!worker || watched.has(worker)) return;
|
|
watched.add(worker);
|
|
record(role, worker);
|
|
worker.addEventListener("statechange", () => record("worker-state", worker));
|
|
};
|
|
void window.navigator.serviceWorker.getRegistration().then((registration) => {
|
|
if (!registration) return;
|
|
watch(registration.active, "active-observed");
|
|
watch(registration.installing, "installing-observed");
|
|
registration.addEventListener("updatefound", () => watch(registration.installing, "update-found"));
|
|
});
|
|
window.navigator.serviceWorker.addEventListener("controllerchange", () => {
|
|
const state = readState();
|
|
state.controllerChanges += 1;
|
|
writeState(state);
|
|
record("controller-change", window.navigator.serviceWorker.controller);
|
|
});
|
|
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, events: [] };
|
|
}, 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>();
|
|
const indexSha256: string[] = [];
|
|
for (const name of names) {
|
|
const cache = await window.caches.open(name);
|
|
for (const request of await cache.keys()) {
|
|
const pathname = new URL(request.url).pathname;
|
|
paths.add(pathname);
|
|
if (pathname === "/index.html") {
|
|
const response = await cache.match(request);
|
|
if (response) {
|
|
const digest = await window.crypto.subtle.digest("SHA-256", await response.arrayBuffer());
|
|
indexSha256.push(Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { names, paths: [...paths].sort(), indexSha256 };
|
|
});
|
|
}
|
|
|
|
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.getRegistration();
|
|
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(""),
|
|
};
|
|
});
|
|
}
|
|
|
|
async function loadedAssets(page: Page) {
|
|
return page.evaluate(() => performance.getEntriesByType("resource")
|
|
.filter((entry) => (entry as PerformanceResourceTiming).responseEnd > 0)
|
|
.map((entry) => new URL(entry.name).pathname));
|
|
}
|
|
|
|
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 | null;
|
|
bOnlyCached: number | null;
|
|
bOnlyLoaded: number | null;
|
|
navigationCount: number;
|
|
witnessState: BrowserWitnessState | null;
|
|
serviceWorker: ServiceWorkerSnapshot | null;
|
|
}) {
|
|
return {
|
|
phase: input.phase,
|
|
workerEvents: input.witnessState?.events ?? null,
|
|
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 ?? null,
|
|
controllerIsActive: input.serviceWorker?.controllerIsActive ?? null,
|
|
controllerState: input.serviceWorker?.controllerState ?? null,
|
|
updateReadyEvents: input.witnessState?.updateReadyEvents ?? null,
|
|
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 ? input.serviceWorker.fetchedSha256 === activeBuild.swSha256 : null,
|
|
};
|
|
}
|
|
|
|
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" });
|
|
// The application may replace the document while applying the new worker.
|
|
await expect(pageB.locator("html")).toHaveAttribute("data-build-sha", BUILD_B_SHA, { timeout: 45_000 });
|
|
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 loadedAssets(pageB);
|
|
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).toBeGreaterThan(0);
|
|
expect(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(() => null);
|
|
const stateB = await readWitnessState(pageB).catch(() => null);
|
|
const frozenWorkerB = await serviceWorkerSnapshot(pageB).catch(() => null);
|
|
const loadedPaths = await loadedAssets(pageB).catch(() => null);
|
|
console.log("[pwa-cross-version-diagnostic]", JSON.stringify({
|
|
time: Date.now(), pageErrors, chunkErrors,
|
|
cacheObserved: cacheB !== null,
|
|
cachedIndexBuilds: cacheB?.indexSha256.map((digest) => digest === sha256(readFileSync(path.join(buildA.root, "index.html"))) ? "A" : digest === sha256(readFileSync(path.join(buildB.root, "index.html"))) ? "B" : "unknown") ?? null,
|
|
documentResponses: requestLedger.filter((entry) => ["/portal", "/index.html", "/sw.js"].includes(entry.path)),
|
|
}));
|
|
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: cacheB ? [...aOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length : null,
|
|
bOnlyCached: cacheB ? [...bOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length : null,
|
|
bOnlyLoaded: loadedPaths ? [...bOnlyAssets].filter((assetPath) => loadedPaths.includes(assetPath)).length : null,
|
|
navigationCount: bNavigations,
|
|
witnessState: stateB,
|
|
serviceWorker: frozenWorkerB,
|
|
})));
|
|
throw error;
|
|
} finally {
|
|
await pageB.close();
|
|
}
|
|
});
|