验证(08-10-pwa-cross-version-cache-upgrade-validation): 固化PWA跨版本升级回归
这个提交包含在:
@@ -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();
|
||||
}
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户