完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
@@ -19,9 +19,13 @@ type RequestLedgerEntry = {
|
||||
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;
|
||||
};
|
||||
@@ -29,6 +33,7 @@ type BrowserWitnessState = {
|
||||
type CacheSnapshot = {
|
||||
names: string[];
|
||||
paths: string[];
|
||||
indexSha256: string[];
|
||||
};
|
||||
|
||||
type ServiceWorkerSnapshot = {
|
||||
@@ -122,6 +127,7 @@ function recordResponse(request: IncomingMessage, pathname: string, status: numb
|
||||
method: request.method ?? "GET",
|
||||
path: pathname,
|
||||
status,
|
||||
time: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -377,19 +383,42 @@ async function installWitnessObservers(context: BrowserContext) {
|
||||
const readState = (): BrowserWitnessState => {
|
||||
try {
|
||||
const current = window.sessionStorage.getItem(storageKey);
|
||||
return current ? JSON.parse(current) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0 };
|
||||
return current ? JSON.parse(current) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0, events: [] };
|
||||
} catch {
|
||||
return { controllerChanges: 0, updateReadyEvents: 0 };
|
||||
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();
|
||||
@@ -402,7 +431,7 @@ async function installWitnessObservers(context: BrowserContext) {
|
||||
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 };
|
||||
return value ? JSON.parse(value) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0, events: [] };
|
||||
}, WITNESS_STATE_KEY);
|
||||
}
|
||||
|
||||
@@ -410,13 +439,22 @@ 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()) {
|
||||
paths.add(new URL(request.url).pathname);
|
||||
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() };
|
||||
return { names, paths: [...paths].sort(), indexSha256 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -433,7 +471,7 @@ async function controllerPath(page: Page) {
|
||||
|
||||
async function serviceWorkerSnapshot(page: Page): Promise<ServiceWorkerSnapshot> {
|
||||
return page.evaluate(async () => {
|
||||
const registration = await window.navigator.serviceWorker.ready;
|
||||
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) {
|
||||
@@ -441,13 +479,19 @@ async function serviceWorkerSnapshot(page: Page): Promise<ServiceWorkerSnapshot>
|
||||
}
|
||||
const digest = await window.crypto.subtle.digest("SHA-256", await response.arrayBuffer());
|
||||
return {
|
||||
controllerIsActive: controller !== null && registration.active === controller,
|
||||
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;
|
||||
}
|
||||
@@ -457,15 +501,16 @@ function safeLedger(input: {
|
||||
documentSha: string | null;
|
||||
controller: string | null;
|
||||
cacheNames: string[];
|
||||
aOnlyCached: number;
|
||||
bOnlyCached: number;
|
||||
bOnlyLoaded: number;
|
||||
aOnlyCached: number | null;
|
||||
bOnlyCached: number | null;
|
||||
bOnlyLoaded: number | null;
|
||||
navigationCount: number;
|
||||
witnessState: BrowserWitnessState;
|
||||
serviceWorker: ServiceWorkerSnapshot;
|
||||
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(),
|
||||
@@ -473,16 +518,16 @@ function safeLedger(input: {
|
||||
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,
|
||||
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.fetchedSha256 === activeBuild.swSha256,
|
||||
fetchedSwSha256Matches: input.serviceWorker ? input.serviceWorker.fetchedSha256 === activeBuild.swSha256 : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -576,11 +621,7 @@ test("production PWA 在同 profile 中从旧 build 自动收敛到新 build", a
|
||||
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 loadedAssetPaths = await loadedAssets(pageB);
|
||||
const loadedBOnly = [...bOnlyAssets].filter((assetPath) => loadedAssetPaths.includes(assetPath));
|
||||
const stateB = await readWitnessState(pageB);
|
||||
workerB = await serviceWorkerSnapshot(pageB);
|
||||
@@ -593,7 +634,8 @@ test("production PWA 在同 profile 中从旧 build 自动收敛到新 build", a
|
||||
fetchedSha256: buildB.swSha256,
|
||||
});
|
||||
expect(finalAOnly).toEqual([]);
|
||||
expect(finalBOnly.length + loadedBOnly.length).toBeGreaterThan(0);
|
||||
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([]);
|
||||
@@ -614,21 +656,24 @@ test("production PWA 在同 profile 中从旧 build 自动收敛到新 build", a
|
||||
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: "",
|
||||
};
|
||||
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: [...aOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length,
|
||||
bOnlyCached: [...bOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length,
|
||||
bOnlyLoaded: 0,
|
||||
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,
|
||||
|
||||
在新工单中引用
屏蔽一个用户