From 6923f4d1f7a68cf72f29735827ff857bbdd3b146 Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 10 Aug 2026 18:05:15 +0800 Subject: [PATCH] =?UTF-8?q?=E9=AA=8C=E8=AF=81(08-10-pwa-cross-version-cach?= =?UTF-8?q?e-upgrade-validation):=20=E5=9B=BA=E5=8C=96PWA=E8=B7=A8?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8D=87=E7=BA=A7=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/hyb-oa/frontend-react-vite-design.md | 67 ++ .../check.jsonl | 1 + .../design.md | 85 +++ .../implement.jsonl | 1 + .../implement.md | 47 ++ .../prd.md | 91 +++ .../research/current-evidence.md | 25 + .../research/verification-ledger.md | 39 ++ .../task.json | 33 + .../e2e/pwa-cross-version-upgrade.spec.ts | 640 ++++++++++++++++++ scripts/qa/run-pwa-cross-version-upgrade.sh | 66 ++ 11 files changed, 1095 insertions(+) create mode 100644 .trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/check.jsonl create mode 100644 .trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/design.md create mode 100644 .trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/implement.jsonl create mode 100644 .trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/implement.md create mode 100644 .trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/prd.md create mode 100644 .trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/research/current-evidence.md create mode 100644 .trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/research/verification-ledger.md create mode 100644 .trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/task.json create mode 100644 client/tests/e2e/pwa-cross-version-upgrade.spec.ts create mode 100755 scripts/qa/run-pwa-cross-version-upgrade.sh diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index e88f362f..4dd0ad3f 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -187,6 +187,73 @@ test.beforeEach(async ({ page }) => { }); ``` +## Scenario: Production PWA Cross-Version Upgrade Witness + +### 1. Scope / Trigger + +- Trigger: a deployment, PWA change or cache-related incident needs evidence that an existing browser profile can move from an old production build to a new build. +- Purpose: verify the real cross-version path; a fresh context that opens only the new build is not upgrade evidence. + +### 2. Signatures + +- Runner: `bash scripts/qa/run-pwa-cross-version-upgrade.sh`. +- Build inputs: two distinct 12-character synthetic `VITE_BUILD_SHA` values, written to temporary outDirs rather than `client/dist`. +- Browser contract: `serviceWorkers: "allow"`, one loopback HTTP origin, one BrowserContext, single worker and zero retries. + +### 3. Contracts + +- Build A must first prove its document SHA, active Service Worker controller, Workbox precache and at least one cached A-only hashed JS/CSS asset. If that precondition is missing, stop as `PAUSED`; do not switch to B. +- Synthetic APIs must be served by a loopback HTTP harness. Do not use `page.route` or `context.route`, because a real Service Worker owns worker-originated requests. +- Switch the same server/origin from build A to B, close the A page while retaining the BrowserContext, then revisit the same route. Do not change port, hostname or profile. +- After switching to B, test code must not reload, clear cookie/cache, unregister a worker, create another BrowserContext or emulate hard refresh. Application-owned update behavior is the subject under test. +- Prove B with a joint witness: controller change or worker version change, controller equals the active registration, browser-fetched no-store `sw.js` fingerprint matches build B, document SHA is B and a B-only hashed asset loaded successfully. The constant `/sw.js` URL alone is not version evidence. +- Assert the business route and synthetic session survive, old-only hashed assets leave Cache Storage, and there are no login fallbacks, JS/CSS 404s, chunk/preload errors, page errors or reload loops. +- If the first B witness fails, freeze the safe ledger and stop. Do not add a second navigation or reload to turn the first failure into an intermittent result. + +### 4. Validation & Error Matrix + +- A build artifacts are not distinct -> `PAUSED`; the harness cannot prove an upgrade. +- A controller or precache is absent -> `PAUSED`; B would be a fresh-install observation. +- A is established and B does not converge without a prohibited action -> `PRODUCT_FINDING`; open a separate PWA product task before changing runtime code. +- Same-profile B witness converges with the complete joint evidence and all negative assertions -> `PASS`. +- Fresh B context passes while the same-profile witness fails -> still `PRODUCT_FINDING`; fresh install does not override upgrade failure. + +### 5. Good/Base/Bad Cases + +- Good: A controls and caches the page, the same context revisits after an in-place B switch, and auto-update converges to B with zero forced actions. +- Base: a fresh B context is used only as an explicitly labelled harness sanity check and does not satisfy acceptance. +- Bad: start preview on B in a new browser profile, observe the latest shell and report that old users will upgrade correctly. +- Bad: B initially remains on A, then the test calls `page.reload()` once more and reports the recovered state as PASS. + +### 6. Tests Required + +- Run `bash scripts/qa/run-pwa-cross-version-upgrade.sh`; it must use temporary A/B production builds, loopback-only networking, single worker and zero retries. +- A ledger assertions: document SHA A, active controller, fetched A worker fingerprint match, Workbox precache and cached A-only asset count greater than zero. +- B ledger assertions: controller change, active B controller, fetched B worker fingerprint match, document SHA B, B-only loaded asset count greater than zero and A-only cache count zero. +- Negative assertions: zero login fallback, static 404, page/chunk/preload error and prohibited B-stage test actions; route and synthetic refresh/session remain valid. +- Run `cd client && npm run typecheck && npm run build` plus the current Trellis task/process/governance/sensitive gates. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +const context = await browser.newContext(); +await context.newPage().then((page) => page.goto(buildBUrl)); +// Latest build loaded, therefore upgrade passes. +``` + +#### Correct + +```ts +// One context and one origin: establish A controller/precache first. +await pageA.close(); +switchSameOriginToBuildB(); +const pageB = await context.newPage(); +await pageB.goto(`${origin}/portal`); +// Observe application-owned convergence; do not reload or clear browser state. +``` + ## Scenario: UTC Date-Time Display In The Company Time Zone ### 1. Scope / Trigger diff --git a/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/check.jsonl b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/design.md b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/design.md new file mode 100644 index 00000000..0e47dbef --- /dev/null +++ b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/design.md @@ -0,0 +1,85 @@ +# PWA 跨版本缓存升级验证:技术设计 + +## 1. 判定对象 + +本任务验证的是一个状态迁移,不是两个独立页面截图: + +```text +same loopback origin + same BrowserContext + -> serve build A + -> A page loads + -> A SW ready + controller + precache proven + -> close A page, retain context/profile + -> atomically switch origin to build B + -> revisit same protected route + -> real SW update code runs + -> document/controller/cache/session converge to B +``` + +任何直接从空 context 打开 B 的路径都只能是 harness sanity,不参与最终判定。 + +## 2. 产物生成 + +- runner 在 OS 临时目录创建 `build-a/`、`build-b/`,不覆盖 `client/dist`。 +- 两次均从当前 checkout 构建,只注入不同的 synthetic 12 位 SHA。构建后读取 `index.html` 与生成资产,断言: + - document/runtime 可观察到各自 SHA; + - `sw.js` 存在; + - A/B hashed JS/CSS 至少有一个差集。 +- runner 使用 `trap`/finally 精确清理本轮临时目录;失败日志只输出相对路径和计数。 + +选择同源码不同 SHA,而不是 checkout 历史 commit,原因是本轮要隔离“SW 缓存版本切换”这个变量。build SHA 会进入 core bundle 并改变 Workbox revision;历史代码差异会同时引入 API、路由、fixture 与依赖变量。 + +## 3. Loopback HTTP harness + +Playwright spec 进程内启动只监听 `127.0.0.1` 的 ephemeral HTTP server,持有一个 `activeBuild=A|B` 状态: + +- `/sw.js`、`/index.html` 与 SPA navigation:从 active dist 读取;HTML/SW 使用 `Cache-Control: no-cache`。 +- hashed JS/CSS/image:从 active dist 读取并使用 immutable cache header。 +- `/ready`:返回 active synthetic build SHA,`Cache-Control: no-store`。 +- `/api/auth/refresh`、`/api/me`、navigation、notifications 与 portal 最小接口:返回合成 envelope,不触碰数据库。 +- 路径解析必须防止越出 active dist;未知静态文件 404,未知 API 返回受控合成结果。 +- `switchToB()` 为测试进程内同步状态切换,不暴露公网管理端点;同一 server/socket/origin 始终不变。 + +请求 ledger 只保留 `method/path/status/build`,并单独统计 JS/CSS 404、refresh/me 与 main-frame navigation。 + +## 4. 浏览器 witness + +### Phase A:建立旧 profile + +1. 创建唯一 BrowserContext,显式 `serviceWorkers: "allow"`。 +2. 安装 synthetic `has_session` cookie;API refresh 返回合成 token,`/api/me` 返回合成员工。 +3. 访问 `/portal`,确认 document SHA=A、route 保持 `/portal`。 +4. 等待 `navigator.serviceWorker.ready`;A 阶段允许且仅允许一次显式 `page.reload()`,使首次安装 worker 成为 controller。 +5. 断言 controller 存在、A precache/cache entry 存在;保存 A-only hashed asset 相对路径集合。 +6. 关闭 A page,但不关闭 context。 + +### Phase B:部署后重访 + +1. server 原位 `switchToB()`,同时切换 `/ready` 和所有静态文件。 +2. 在同一 context 创建新 page,重新访问 `/portal`。 +3. 从这一刻起禁止测试代码调用 reload/clear/unregister;只等待应用真实 autoUpdate/clientsClaim/更新回调。 +4. 记录 `controllerchange`,必要时通过 Chromium CDP ServiceWorker domain 对比 A/B active worker versionId;`scriptURL` 预期始终为同 origin `/sw.js`,不得把恒定 URL 误写成版本证明。 +5. 将 B 阶段 no-cache 获取到的 `/sw.js` 内容 SHA-256 与 build B 文件指纹对账,并联合断言 document SHA=B、B-only chunk 已成功加载、route 仍为 `/portal`、refresh/me 成功、controller 存在。 +6. 安全记录 `hyb-oa:pwa-update-ready` 触发次数;若 autoUpdate 流程实际进入 `onNeedRefresh`,保留阶段与后续行为证据。 +7. 读取 Cache Storage 的 URL pathname 白名单,断言 A-only JS/CSS 消失,B asset 已进入或已成功请求。 +8. 断言无 `/login`、无 JS/CSS 404、无 chunk/preload/page error、导航次数有上限。 + +## 5. 不使用 Playwright route mocking + +本 witness 的核心就是观察 Service Worker 拥有网络请求时的真实行为。若用 `page.route` mock API,会重新引入上一任务已证明的 fixture 冲突。因此所有 synthetic API 都由 loopback HTTP harness 提供,浏览器和 worker看到的是同一真实 origin 响应。 + +## 6. 结果状态 + +| 状态 | 判据 | 后续 | +| --- | --- | --- | +| `PASS` | A 前置成立,B 在禁止强刷条件下完整收敛,所有负向断言通过 | 固化 test-only harness | +| `PRODUCT_FINDING` | A 前置成立,但 B 长期旧 shell、回登录、旧 chunk 404/残留或 reload loop | 停止,另建 PWA 产品修复 task | +| `PAUSED` | A controller/precache 无法稳定建立、构建产物不可区分或环境不支持 SW | 记录恢复条件,不猜测产品结论 | + +## 7. 风险与回滚 + +- 只新增/修改 QA runner、E2E spec 与当前 task;`client/src/pwa.ts`、`vite.config.ts` 在验证阶段只读。 +- 所有 server、BrowserContext 和临时目录用 finally/trap 清理;失败后不得留下监听端口或 build 目录。 +- harness 若证明不了旧 profile 前置,删除/回滚本轮 test-side probe,但保留 PAUSED 证据。 +- 第一次 B witness 失败即为冻结点;不得通过手工或自动追加 reload/重访改变失败性质。只有先保存安全 ledger、回到规划并获批后,才允许开展新的诊断实验。 +- 不接触生产、数据库、部署脚本或真实浏览器 profile。 diff --git a/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/implement.jsonl b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/implement.md b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/implement.md new file mode 100644 index 00000000..6ca760b5 --- /dev/null +++ b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/implement.md @@ -0,0 +1,47 @@ +# PWA 跨版本缓存升级验证:执行计划 + +## Phase A:Harness 与旧版本前置 + +- [x] 在 `scripts/qa/` 增加 loopback-only wrapper:生成两个临时 production outDir,注入不同 synthetic SHA,失败/退出时清理。 +- [x] 在专用 Playwright spec 中实现进程内静态/API server;同一 origin 支持 A→B 原子切换,禁止 path traversal,缓存头贴近生产。 +- [x] 显式使用 `serviceWorkers: "allow"`,同一 BrowserContext 建立 synthetic session。 +- [x] A 阶段断言 SHA、SW ready、controller、precache 与 A-only asset;未满足即 PAUSED,不进入 B。 + +## Phase B:同 profile 跨版本重访 + +- [x] 关闭 A page但保留 context,原位切 B,再创建 page 重访同一路由。 +- [x] B 阶段不调用测试侧 reload、clear cookie/cache、unregister 或新 context。 +- [x] 等待应用自身 SW 更新,断言最终 SHA=B、route/session 保持、refresh/me 成功。 +- [x] 以 controllerchange/CDP worker version、B `sw.js` 指纹、B document SHA 与 B-only chunk 联合证明接管;恒定 `/sw.js` URL 只作 scope 证明。 +- [x] 记录 `hyb-oa:pwa-update-ready` 触发次数与阶段;检查最终 cache、asset 请求、404、console/page error 与导航次数,旧-only chunk 不得残留。 +- [ ] 加一个明确标注的 fresh-B sanity(如确有诊断价值),但不得把它计入 A→B AC。 + +## Phase C:结果分叉 + +- [x] PASS:固化最小 test-only runner/spec,写入脱敏 ledger,并复跑至少两次确认不是 worker 时序偶发。 +- [ ] PRODUCT_FINDING:停止,不修改 `pwa.ts`/`vite.config.ts`;记录最短失败阶段和独立修复任务建议。 +- [ ] 第一次 B 失败:立即冻结现场并停止,不追加 reload、重访或第二次升级尝试。 +- [ ] PAUSED:记录环境/前置缺口和恢复条件,不声称真实用户升级正常或异常。 + +## Phase D:验证与收口 + +- [x] focused runner 单 worker、zero retry PASS;重复执行保持同结论。 +- [x] 若新增 TS test:`cd client && npm run typecheck`。 +- [x] task/process/governance/sensitive/`git diff --check`。 +- [x] 评估是否需要扩展 frontend PWA spec;仅在真实 witness 得到稳定结论后沉淀。 +- [ ] 中文 commit 引用 `08-10-pwa-cross-version-cache-upgrade-validation`;不推送、不部署。 + +## Risky Files / Stop Points + +- `client/src/pwa.ts`、`client/vite.config.ts`:本任务验证阶段只读;如需改动,按 PRODUCT_FINDING 停止并回来批准。 +- 新 QA wrapper:不得覆盖 `client/dist`,不得接受非 loopback origin,不得泄露完整临时路径。 +- 新 Playwright spec:不得使用 `page.route`/`context.route`,不得 block Service Worker,不得在 B 阶段强制刷新或清缓存。 +- build A 前置不成立、A/B asset 无差集、server origin 变化或 cleanup 失败:立即 PAUSED。 + +## Expected Evidence + +- A/B build SHA、asset 差集与 SW 存在性(仅 synthetic 标签/相对路径)。 +- A 阶段 controller + precache 证明。 +- B 阶段同 context 重访、应用驱动更新、最终 document/controller/cache/session 证明。 +- `/login` 次数、refresh/me status、静态 404、page/chunk error、导航次数的安全 ledger。 +- 单一最终状态与失败分叉,不用 fresh install 替代 upgrade。 diff --git a/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/prd.md b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/prd.md new file mode 100644 index 00000000..5cef3cbf --- /dev/null +++ b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/prd.md @@ -0,0 +1,91 @@ +# PWA 跨版本缓存升级验证 + +## Goal + +在纯本地、同一 origin、同一浏览器 profile 的真实 production PWA 路径中验证跨版本升级:旧 build A 必须先完成 Service Worker 安装、控制与预缓存,随后服务端原位切换为 build B,浏览器再次访问时应通过应用自身更新机制收敛到 B,而不是依赖测试强刷、清缓存或新建浏览器上下文。目标是回答“老用户跨部署是否会长期停留旧 shell、出现旧 chunk/新服务混用或意外回到登录页”,不是证明新装用户能打开最新版本。 + +## Confirmed Facts + +- `client/vite.config.ts` 当前使用 `VitePWA`,配置为 `registerType: "autoUpdate"`、`skipWaiting: true`、`clientsClaim: true`、`cleanupOutdatedCaches: true`;navigation fallback 排除 `/api/`,静态 runtime cache 使用 `StaleWhileRevalidate`。 +- `client/src/pwa.ts` 在新 worker installed 且已有 controller 时清理 runtime/precache cache;非认证页 `onNeedRefresh` 自动应用更新,认证输入页改为用户确认刷新;build SHA 不一致时还有 `/ready` freshness prompt。 +- 现有 `login-portal-ux.spec.ts` 只通过 synthetic event 和 mocked `/ready` 验证提示、输入保持与刷新回调,没有让真实旧 worker 控制页面后切换新产物。 +- 08-10 会话回落任务证明 production build 的 Service Worker 会改变 `page.route` mock 的网络所有权;那项 `FIXTURE` 结论不等于真实用户跨版本更新已验证。 +- Vite dev 不生成等价 PWA worker;直接启动一个新 preview、新 context 访问 B 只能证明 fresh install,不能作为本任务证据。 + +## Scope + +- 新增一个 test-only 本地跨版本 runner/harness,生成 A/B 两份 production build 并在同一 loopback origin 原位切换。 +- 使用真实 Service Worker、Cache Storage、页面重访和应用更新代码;API 由 loopback HTTP harness 返回合成响应,不使用 Playwright route mocking。 +- 记录 build、controller、cache、导航、refresh/me、静态资源状态的脱敏证据。 +- 对当前行为给出 `PASS`、`PRODUCT_FINDING` 或 `PAUSED`;仅在 PASS 时把 harness 固化为回归资产。 + +## Requirements + +### R1 旧版本前置必须真实成立 + +- build A 与 build B 必须由同一候选源码、两个不同且合法的 12 位 synthetic `VITE_BUILD_SHA` 生成;两份 `index.html`、`sw.js` 与至少一个 hashed JS/CSS 资产集合必须可区分。 +- A 阶段必须在 loopback origin 上访问 production build,等待 `navigator.serviceWorker.ready`,并在一次 A 阶段 reload 后断言 `navigator.serviceWorker.controller` 存在、document build SHA 为 A、Workbox precache 含 A 资产。 +- 未满足 controller + A precache 时立即 `PAUSED`;不得继续切 B 后把 fresh install 当升级证据。 + +### R2 原位部署切换与同 profile 重访 + +- harness 只能在同一 HTTP origin 内原子切换 active dist 与 `/ready` build SHA;不得换端口、换 hostname 或启动第二个 origin。 +- A 已受控后关闭旧页面但保留同一 BrowserContext,再切换 B 并新开页面访问同一路由,模拟老用户重访。 +- B 阶段测试代码禁止调用 `page.reload()`、`context.clearCookies()`、`caches.delete()`、`registration.unregister()`、新建 BrowserContext 或 DevTools hard reload;更新/刷新只能由应用的真实 SW 回调触发。 +- BrowserContext 必须显式 `serviceWorkers: "allow"`;禁止用上一任务的 `serviceWorkers: "block"`。 + +### R3 会话、shell 与缓存收敛 + +- 使用 synthetic `has_session` 与 loopback HTTP API 合同维持一个合成已登录 portal;不使用真实账号、真实密码、生产 cookie 或生产 API。 +- B 重访必须在有限时间内达到:document build SHA 为 B、仍位于原业务路由、refresh 与 `/api/me` 成功、Service Worker controller 存在。 +- `/sw.js` URL 在 A/B 间恒定,不能单独证明 B 已接管;必须联合证明 active worker version/controllerchange 已变化、B 阶段获取的 SW 内容指纹等于 build B 产物、document 使用 B SHA 且加载 B-only chunk。 +- 不得出现 `/login` 回落、无限 reload、JS/CSS 404、`ChunkLoadError`、`vite:preloadError` 或未处理 page error。 +- 最终 Workbox precache/runtime cache 不得保留 A-only hashed JS/CSS;必须包含 B 资产或由 B shell 正常请求证明新产物已接管。 + +### R4 证据真实性与失败边界 + +- 安全 ledger 只记录 synthetic build 标签、pathname、请求类型/status、controller/cache 布尔状态、相对 hashed asset path 与导航次数;不记录 cookie 值、token、完整账号、原始 payload 或完整本地路径。 +- 必须明确区分“fresh B context sanity”与“same-profile A→B witness”;前者即使通过也不能满足 AC。 +- 若当前产品路径失败,先输出 `PRODUCT_FINDING` 与最短失败阶段,停止在独立 PWA 修复任务批准前;本任务不得顺手修改 `client/src/pwa.ts` 或 `client/vite.config.ts` 来让 witness 通过。 +- 第一次 B witness 失败后立即冻结 ledger、controller/cache/request 现场并停止;禁止追加一次 reload、再次访问或第二次升级尝试来“看看会不会恢复”。 +- 安全 ledger 额外记录 `hyb-oa:pwa-update-ready` 是否触发及次数;在 `autoUpdate` 路径中若出现该回调,必须解释触发阶段,不能静默忽略或直接判缺陷。 +- 若构建、浏览器或 worker 生命周期不稳定而无法定性,输出 `PAUSED`,不得把未复现写成 PASS。 + +## Acceptance Criteria + +- [x] AC1:A/B 两份 production build 的 synthetic SHA、SW 与 hashed asset 集合经 runner 独立核验,且不改写仓库 `client/dist`。 +- [x] AC2:A 阶段现场证据同时证明 build A、active controller 与 A precache;缺任何一项均不进入 B。 +- [x] AC3:同 origin、同 BrowserContext、关闭旧页后重访 B;B 阶段没有测试侧 reload、清 cookie/cache、注销 SW 或新 context。 +- [x] AC4:B 重访自动收敛到 build B;新 active worker version/controllerchange、B SW 指纹、B document SHA 与 B-only chunk 形成联合证据,同时保持原业务路由和 synthetic 登录态;refresh/me 成功,零 `/login` 回落、零 chunk/static 404、零 page error、零 reload loop。 +- [x] AC5:最终 cache ledger 无 A-only hashed JS/CSS,并有 B shell/asset 接管证据。 +- [x] AC6:仅输出一个最终状态 `PASS`、`PRODUCT_FINDING` 或 `PAUSED`;若非 PASS,不修改 PWA 产品代码并明确恢复条件或独立修复任务边界。 +- [x] AC7:focused runner、typecheck(若 TS test 有改动)、task/process/governance/sensitive 与 `git diff --check` 全部通过;未连接生产、未部署、未使用真实凭据。 + +## Out of Scope + +- 公网、生产部署、真实设备/企微 WebView、真实用户账号与生产 Service Worker 清理。 +- 网络中断、弱网、CDN 多节点不一致、非原子文件同步或发布中间态;本轮只验证已完成部署后的缓存升级。 +- iOS Safari/企业微信内核专项兼容;本地 Chromium PASS 不替代真机验收。 +- 离线业务队列、IndexedDB 数据迁移、账号切换数据生命周期和一般 PWA 性能优化。 +- 验证失败时直接修复更新策略、改 cache handler 或放宽断言。 + +## Risks and Deferred Items + +- 同源码只改变 build SHA 仍会生成不同的核心 chunk/revision,足以验证 SW 版本接管;它不覆盖真实业务代码大改造成的所有 chunk graph 变化,后续可增加历史 commit 对照,但不作为本轮必要复杂度。 +- `skipWaiting + clientsClaim` 可能让 controller 先更新而旧 document 仍执行 A bundle,因此最终必须同时看 document SHA、route、asset/cache,不以 controller 单点判 PASS。 +- custom loopback HTTP harness 需模拟生产缓存头:`sw.js`/`index.html` no-cache,hashed asset immutable;若偏离会使结论失真。 + +## Test Plan + +- build/runner:任务新增的跨版本 QA runner(两个临时 outDir,trap 清理,loopback-only)。 +- focused Playwright:真实 SW allow 的 A→B single-worker、zero-retry witness。 +- `cd client && npm run typecheck`(若新增 TS spec)。 +- `python3 ./.trellis/scripts/task.py validate 08-10-pwa-cross-version-cache-upgrade-validation` +- `node scripts/qa/check-process-contract.mjs --baseline --json` +- `node scripts/qa/check-trellis-governance.mjs --json` +- `node scripts/qa/check-sensitive-content.mjs` +- `git diff --check` + +## Open Questions + +无。用户已明确升级路径必须是“旧 build 建立缓存 → 部署新 build → 同 profile 再访问”;失败后的产品修改另行批准。 diff --git a/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/research/current-evidence.md b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/research/current-evidence.md new file mode 100644 index 00000000..1b1f4fac --- /dev/null +++ b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/research/current-evidence.md @@ -0,0 +1,25 @@ +# 当前证据:PWA 跨版本升级缺口 + +## 已有保护 + +- VitePWA:`autoUpdate`、`skipWaiting`、`clientsClaim`、`cleanupOutdatedCaches`。 +- 应用更新:新 worker installed 时清理 known runtime/precache;非 auth 页面自动 apply,auth 页面提示用户确认。 +- 旧包检测:页面回前台时用 `cache: no-store` 请求 `/ready`,build SHA 不一致则提示刷新。 +- chunk 恢复:`vite:preloadError`、window error 和 unhandled rejection 命中 chunk load 模式时,清 cache 后最多 reload 一次。 + +## 现有测试证明范围 + +- `login-portal-ux.spec.ts` 证明 synthetic update event 和 `/ready` mismatch 时 UI/刷新回调正确。 +- production build 会生成真实 `sw.js` 与 Workbox precache。 +- 08-10 会话 fixture 任务证明 Service Worker 在 production runner 中确实取得网络所有权。 + +## 未证明范围 + +- 没有测试先让 build A 的真实 worker 控制页面并建立 cache,再在同一 origin/profile 切换 build B。 +- 没有证明重访后 document bundle、controller、cache 与 session 同时收敛到 B。 +- 没有证明旧-only hashed chunk 被清理、不会产生 chunk 404、reload loop 或 `/login` 回落。 +- fresh profile 直接打开 B 与上述问题无关,不能填补证据缺口。 + +## 规划结论 + +需要一个不使用 Playwright route mock 的 loopback production-build harness。验证先于修复;失败时应输出产品 finding,而不是在同一任务中边改 PWA 策略边把测试修绿。 diff --git a/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/research/verification-ledger.md b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/research/verification-ledger.md new file mode 100644 index 00000000..59aed953 --- /dev/null +++ b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/research/verification-ledger.md @@ -0,0 +1,39 @@ +# 跨版本升级验证账本 + +## 最终结论 + +`PASS`。本地 Chromium 在同一 loopback origin、同一 BrowserContext 中完成了真实的 build A → build B 升级;没有用 fresh context、新装路径或测试侧强刷替代升级证据。 + +## 执行边界 + +- 三次执行均由 `scripts/qa/run-pwa-cross-version-upgrade.sh` 生成临时 production build;仓库 `client/dist` 未被覆盖。 +- synthetic build A/B 分别使用 `111111111111` 与 `222222222222`;两份 `sw.js` 指纹不同,hashed JS/CSS 集合存在双向差集。 +- HTTP harness 只监听 loopback,同一个 socket/origin 原位切换 active dist;API 响应全部为合成数据,没有连接生产、数据库或真实账号。 +- Playwright 使用 `serviceWorkers: "allow"`、single worker、zero retry。B 切换后源码没有 `page.reload()`、清 cookie/cache、注销 worker、hard reload 或新建 BrowserContext;唯一一次显式 reload 位于 A 阶段,用于首次安装后取得 controller。 +- B 首次 witness 直接 PASS,因此未进入失败后重访或“多刷一次”路径。后续执行只在首次 PASS 后用于增强证据与最终质量确认。 + +## A 阶段证据 + +- document build SHA:A。 +- controller scope:`/sw.js`;controller 等于 registration active worker,状态为 `activated`。 +- 浏览器 `no-store` 获取的 `sw.js` SHA-256 与 build A 文件逐字节对账通过。 +- Workbox precache 存在;A-only hashed JS/CSS 缓存数为 64,B-only 为 0。 +- `/login` 回落 0,JS/CSS 404 为 0。 + +## B 阶段收敛证据 + +- 关闭 A page 后保留同一 BrowserContext,再由同一 origin 重访 `/portal`。 +- document build SHA:B;业务 route 保持 `/portal`。 +- `controllerchange=1`;最终 controller 等于 registration active worker,状态为 `activated`。 +- 浏览器 `no-store` 获取的 `sw.js` SHA-256 与 build B 文件逐字节对账通过;A/B 指纹前缀不同。 +- 加载到 2 个 B-only hashed asset;最终 A-only cache 数为 0。 +- refresh 200 次数 2,`/api/me` 200 次数 2;`/login` 回落 0。 +- JS/CSS 404 为 0,page error 为 0,chunk/preload error 为 0;主 frame navigation 次数 3,未形成 reload loop。 +- `hyb-oa:pwa-update-ready` 触发次数为 0,符合非认证页 auto-update 直接应用新 worker 的预期。 + +## 稳定性 + +- 首次原始 witness:PASS。 +- 加强实际 SW 内容指纹、active-controller 与 B-only loaded ledger 后,第二次 witness:PASS。 +- 将失败冻结路径改为只读既有快照、不在 catch 中追加 SW fetch 后,最终质量执行:PASS。 +- 三次均在同一测试合同下使用 single worker、zero retry;没有失败后追加刷新。 diff --git a/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/task.json b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/task.json new file mode 100644 index 00000000..af0ce8cc --- /dev/null +++ b/.trellis/tasks/08-10-pwa-cross-version-cache-upgrade-validation/task.json @@ -0,0 +1,33 @@ +{ + "id": "pwa-cross-version-cache-upgrade-validation", + "name": "pwa-cross-version-cache-upgrade-validation", + "title": "PWA 跨版本缓存升级验证", + "description": "在同一 loopback origin 与同一浏览器上下文中,让旧 production build 的真实 Service Worker 先取得控制权并建立缓存,再原位切换新 build,验证重访后无需强刷即可收敛到新 shell、保持会话且不残留旧 chunk。", + "status": "in_progress", + "dev_type": null, + "scope": "client PWA production-build cross-version upgrade test harness and local QA runner; product PWA runtime remains read-only unless a separate finding task is approved", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-08-10", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "client/vite.config.ts", + "client/src/pwa.ts", + "client/tests/e2e/login-portal-ux.spec.ts", + "client/tests/e2e/pwa-cross-version-upgrade.spec.ts", + "scripts/qa/run-pwa-cross-version-upgrade.sh", + ".trellis/spec/hyb-oa/frontend-react-vite-design.md" + ], + "notes": "本地同 origin、同 BrowserContext 的 production PWA A→B 跨版本 witness 三次 PASS;A controller/precache、B active worker 指纹/document/chunk、会话保持与旧缓存清理均有联合证据,未连接生产或修改产品 PWA runtime。", + "meta": {} +} diff --git a/client/tests/e2e/pwa-cross-version-upgrade.spec.ts b/client/tests/e2e/pwa-cross-version-upgrade.spec.ts new file mode 100644 index 00000000..6f07adca --- /dev/null +++ b/client/tests/e2e/pwa-cross-version-upgrade.spec.ts @@ -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; +}; + +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(); + } +}); diff --git a/scripts/qa/run-pwa-cross-version-upgrade.sh b/scripts/qa/run-pwa-cross-version-upgrade.sh new file mode 100755 index 00000000..1ec2c53b --- /dev/null +++ b/scripts/qa/run-pwa-cross-version-upgrade.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CLIENT_DIR="$ROOT_DIR/client" +TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/hyb-pwa-cross-version.XXXXXX")" +BUILD_A_DIR="$TEMP_ROOT/build-a" +BUILD_B_DIR="$TEMP_ROOT/build-b" +BUILD_A_LOG="$TEMP_ROOT/build-a.log" +BUILD_B_LOG="$TEMP_ROOT/build-b.log" +BUILD_A_SHA="111111111111" +BUILD_B_SHA="222222222222" + +cleanup() { + local original_status="$?" + trap - EXIT + if [[ -d "$TEMP_ROOT" ]]; then + find "$TEMP_ROOT" -depth -delete + fi + exit "$original_status" +} +trap cleanup EXIT + +build_candidate() { + local label="$1" + local build_sha="$2" + local output_dir="$3" + local log_file="$4" + local build_status=0 + + ( + cd "$CLIENT_DIR" + VITE_BUILD_SHA="$build_sha" npx vite build --outDir "$output_dir" --emptyOutDir + ) >"$log_file" 2>&1 || build_status="$?" + + if [[ "$build_status" -ne 0 ]]; then + local log_lines=0 + log_lines="$(wc -l <"$log_file" | tr -d ' ')" + echo "[pwa-cross-version][ERROR] build=${label} exit=${build_status} log_lines=${log_lines}" >&2 + exit "$build_status" + fi + + if [[ ! -f "$output_dir/index.html" || ! -f "$output_dir/sw.js" ]]; then + echo "[pwa-cross-version][ERROR] build=${label} required_artifact_missing" >&2 + exit 1 + fi + + echo "[pwa-cross-version] build=${label} status=PASS" +} + +build_candidate "A" "$BUILD_A_SHA" "$BUILD_A_DIR" "$BUILD_A_LOG" +build_candidate "B" "$BUILD_B_SHA" "$BUILD_B_DIR" "$BUILD_B_LOG" + +( + cd "$CLIENT_DIR" + PWA_BUILD_A_DIR="$BUILD_A_DIR" \ + PWA_BUILD_B_DIR="$BUILD_B_DIR" \ + PWA_BUILD_A_SHA="$BUILD_A_SHA" \ + PWA_BUILD_B_SHA="$BUILD_B_SHA" \ + npx playwright test tests/e2e/pwa-cross-version-upgrade.spec.ts \ + --workers=1 \ + --retries=0 \ + --reporter=list +) + +echo "[pwa-cross-version] status=PASS path=A_TO_B_SAME_CONTEXT"