Răsfoiți Sursa

fix(test): isolate jsdom storage on Node 26

Turtle 1 lună în urmă
părinte
comite
f04b35c6a4

+ 3 - 3
.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write
-2026-07-06-node-engine-floor.md: f1754ea7ca32452a04c6cd8a0599568f602e47dd
-2026-07-06-node-engine-floor.zh.md: 9d376a639378d3a0b9b645aa36c1a5d320d1d147
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-06-node-engine-floor.md
+2026-07-06-node-engine-floor.md: ef047d885a442106a35922f4716d2996d8a98ca7
+2026-07-06-node-engine-floor.zh.md: a0281addf7d4327d7f6ea30e3a3f0f40d6782bd0

+ 2 - 2
.agents/notes/implemented/process/2026-07-06-node-engine-floor.md

@@ -10,7 +10,7 @@ The Node 22 branch of the root `engines.node` range is a contract for the instal
 
 ## Decision
 
-Set `engines.node` to `^22.19.0 || >=24.0.0` and test the keyless CI compatibility matrix on `['22.19', 24, 26]`. Every matrix leg runs the TypeScript typecheck plus a keyless source-mode worker smoke, so the floor is exercised through both a complete source typecheck and a real unbuilt runtime path. The real-API e2e workflow stays on Node 24 because it exercises API integration rather than the runtime floor.
+Set `engines.node` to `^22.19.0 || >=24.0.0` and test keyless CI on `['22.19', 24, 26]`. The primary Node 24 jobs own the complete typecheck and unit coverage inventory; every version runs focused source-worker, Zstandard, source-launch, and [jsdom storage](../testing/2026-07-30-vitest-jsdom-webstorage-ownership.md) smokes without repeating that inventory. The real-API e2e workflow stays on Node 24 because it exercises API integration rather than the runtime floor.
 
 Two Node features gate the source runtime:
 
@@ -24,7 +24,7 @@ Those source features clear on the 22.x line at **22.18**, but the installed Pi
 ## Consequences
 
 - The advertised LTS branch no longer undercuts the Pi adapter dependency floor.
-- CI proves the Node 22 LTS floor directly with Node 22.19, keeps the Node 24 branch on `node: 24`, and keeps Node 26 for the next even line; each leg typechecks the source graph and launches the unbuilt workflow worker for real.
+- CI proves the Node 22 LTS floor directly with Node 22.19, keeps primary coverage on `node: 24`, and exercises Node 26 as the next even line; focused compatibility smokes run on all three versions.
 - The built-bin smoke needs no version-conditional flag: at 22.19 type-stripping is already the default, so the test stays the plain `node lib/bin.js` path it documents.
 - A future dependency or source API that raises the runtime floor must move `engines.node`, the compatibility matrix, and this Agent Note in the same change.
 

+ 2 - 2
.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## 决策
 
-将 `engines.node` 设为 `^22.19.0 || >=24.0.0`,并在 keyless CI 兼容性矩阵中测试 `['22.19', 24, 26]`。每条矩阵分支都运行 TypeScript 类型检查加一次 keyless 的源码模式 worker 冒烟测试,因此引擎下限通过完整的源码类型检查和真实的未构建运行时路径两条路径得到验证。真实 API 的 e2e 工作流保持在 Node 24 上,因为它验证的是 API 集成而非运行时下限。
+将 `engines.node` 设为 `^22.19.0 || >=24.0.0`,并在 `['22.19', 24, 26]` 上运行 keyless CI。主要的 Node 24 任务负责整套类型检查和单元测试覆盖率任务;三个版本均运行 source-worker、Zstandard、source-launch 和 [jsdom 存储](../testing/2026-07-30-vitest-jsdom-webstorage-ownership.md) 专项冒烟测试,不重复这套类型检查和覆盖率任务。真实 API 的 e2e 工作流保持在 Node 24 上,因为它验证的是 API 集成而非运行时下限。
 
 两个 Node 特性决定了源码运行时的门槛:
 
@@ -24,7 +24,7 @@ Status: implemented
 ## 后果
 
 - 宣传的 LTS 分支不再低于 Pi 适配器依赖的下限。
-- CI 通过 Node 22.19 直接验证 Node 22 LTS 下限,Node 24 分支保持 `node: 24`,Node 26 用于下一个偶数线;每条分支都对源码图执行类型检查,并实际启动未构建的工作流 worker
+- CI 通过 Node 22.19 直接验证 Node 22 LTS 下限,将主要覆盖率任务保留在 `node: 24`,并用 Node 26 验证下一个偶数线;三个版本均运行聚焦的兼容性冒烟测试
 - built-bin 冒烟测试无需版本条件标志:在 22.19 上类型剥离已是默认行为,因此测试保持其文档所述的纯 `node lib/bin.js` 路径。
 - 未来若依赖或源码 API 提高运行时下限,必须在同一变更中同步调整 `engines.node`、兼容性矩阵和本 Agent Note(agent 决策记录)。
 

+ 6 - 0
.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md
+2026-07-30-vitest-jsdom-webstorage-ownership.md: 3956a7566fa1c79a767636bce9a19f16588126e2
+2026-07-30-vitest-jsdom-webstorage-ownership.zh.md: 9080ee2762b74bf2efdaccd7a5905672001bc0e8

+ 26 - 0
.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md

@@ -0,0 +1,26 @@
+# Agent Note: Keep browser storage owned by jsdom in Vitest
+
+Status: implemented
+
+English | [中文](2026-07-30-vitest-jsdom-webstorage-ownership.zh.md)
+
+## Problem
+
+The supported Node range includes releases that reserve a process-wide `globalThis.localStorage`. Node 26 exposes that property as `undefined` without `--localstorage-file`; Vitest sees the reserved key and does not project jsdom's isolated `Storage` object over it. Component suites then fail before exercising product behavior, while the primary Node 24 coverage lane remains green because that runtime does not reserve the key by default.
+
+## Decision
+
+Vitest workers disable Node's process-wide Web Storage when the runtime advertises the `--webstorage` flag. The configuration passes `--no-webstorage` through each test project's `execArgv`; runtimes without that flag receive no argument. Node-environment suites therefore stay browser-free, and files selecting jsdom through `@vitest-environment jsdom` receive jsdom's isolated `localStorage`.
+
+The Node compatibility aggregate runs a dedicated jsdom smoke on every advertised compatibility line. It asserts both the conditional worker argument and usable storage, so a future Node or Vitest change cannot leave the primary Node 24 suite as the only signal.
+
+## Alternatives considered
+
+- **Set `NODE_OPTIONS=--no-webstorage` in package scripts or CI.** Rejected because it leaks test-runner policy into subprocesses and misses direct `pnpm exec vitest` invocations.
+- **Pass `--localstorage-file` to Node.** Rejected because one process-wide persistent store has different ownership and isolation semantics from browser storage created per jsdom environment.
+- **Patch `globalThis.localStorage` in setup code or guard every component test.** Rejected because setup would depend on Vitest's private jsdom projection details, while per-test guards hide a broken browser environment and duplicate policy across suites.
+- **Pin tests to Node 24.** Rejected because the package engine advertises newer even Node lines and the compatibility matrix exists to expose their runtime changes.
+
+## Consequences
+
+The same `pnpm test` command works on Node releases with and without built-in Web Storage. Test workers deliberately cannot exercise Node's process-wide Web Storage; a future product need for that API requires a separate explicit test configuration rather than weakening jsdom isolation. The compatibility lane adds one focused Vitest process instead of duplicating the complete unit inventory on every Node version.

+ 26 - 0
.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.zh.md

@@ -0,0 +1,26 @@
+# Agent Note: 在 Vitest 中将浏览器存储交由 jsdom 管理
+
+Status: implemented
+
+[English](2026-07-30-vitest-jsdom-webstorage-ownership.md) | 中文
+
+## 问题
+
+受支持的 Node 版本范围包含会预留进程级 `globalThis.localStorage` 的版本。未设置 `--localstorage-file` 时,Node 26 将该属性暴露为 `undefined`;Vitest 检测到这个预留键后,不会用 jsdom 的隔离 `Storage` 对象覆盖该属性。因此,组件测试套件尚未验证产品行为便会失败,而主要的 Node 24 覆盖率分支仍能通过,因为该运行时默认不会预留此键。
+
+## 决策
+
+当运行时声明支持 `--webstorage` 标志时,Vitest worker 会禁用 Node 的进程级 Web Storage。配置通过每个测试项目的 `execArgv` 传入 `--no-webstorage`;未声明该标志的运行时则不传入此参数。因此,Node 环境测试套件不加载浏览器环境,而通过 `@vitest-environment jsdom` 选择 jsdom 的文件会获得 jsdom 隔离的 `localStorage`。
+
+Node 兼容性汇总任务会在每条声明支持的兼容版本线上运行专用的 jsdom 冒烟测试。该测试同时断言 worker 参数按条件传入且存储可用,因此未来 Node 或 Vitest 的变化不会让主要的 Node 24 测试套件成为唯一检测信号。
+
+## 曾考虑的替代方案
+
+- **在包脚本或 CI 中设置 `NODE_OPTIONS=--no-webstorage`。** 否决:这会将测试运行器策略传播到子进程,也无法覆盖直接调用 `pnpm exec vitest` 的情况。
+- **向 Node 传入 `--localstorage-file`。** 否决:单个进程级持久化存储与每个 jsdom 环境分别创建的浏览器存储具有不同的归属和隔离语义。
+- **在初始化代码中修改 `globalThis.localStorage`,或为每个组件测试增加保护逻辑。** 否决:初始化逻辑会依赖 Vitest 私有的 jsdom 映射细节,而逐测试添加的保护逻辑会掩盖浏览器环境损坏,并在多个测试套件中重复该策略。
+- **将测试固定在 Node 24。** 否决:包的引擎范围声明支持更新的偶数 Node 版本线,而兼容性矩阵正是为了暴露这些版本的运行时变化。
+
+## 后果
+
+同一条 `pnpm test` 命令在有无内置 Web Storage 的 Node 版本上均可运行。测试 worker 被有意禁止使用 Node 的进程级 Web Storage;未来若产品需要该 API,必须使用独立且显式的测试配置,而不能削弱 jsdom 隔离。兼容性分支只增加一个专项 Vitest 进程,无需在每个 Node 版本上重复整套单元测试。

+ 1 - 3
packages/client/ui-trajectory/tests/views.spec.tsx

@@ -35,9 +35,7 @@ afterEach(cleanup)
 // The chat store persists under its declared key; clear so one case's active
 // view cannot rehydrate into the next.
 beforeEach(() => {
-  // Node 22+ exposes an experimental localStorage global that is undefined
-  // without --localstorage-file; only clear when a real Storage is present.
-  if (typeof localStorage !== 'undefined') localStorage.clear()
+  localStorage.clear()
 })
 
 /** Node fixture: user prologue, two turns, one tool result inside turn 1. */

+ 17 - 0
scripts/run-gates.spec.ts

@@ -135,6 +135,23 @@ describe('Oxlint gate', () => {
   })
 })
 
+describe('Node compatibility graph', () => {
+  it('runs the jsdom environment smoke on every advertised Node line', () => {
+    const subject = withPnpmEntrypoint(() => gatesForMode('node-compat'))
+
+    expect(subject.find(item => item.id === 'vitest-jsdom-smoke')).toMatchObject({
+      label: 'Vitest jsdom smoke',
+      args: [
+        '/private/pnpm.cjs',
+        'exec',
+        'vitest',
+        'run',
+        'scripts/vitest-environment.compat.spec.ts',
+      ],
+    })
+  })
+})
+
 describe('Node 24 consumer graph', () => {
   it('owns the eight-command pool and orders restored-artifact consumers', () => {
     const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))

+ 5 - 0
scripts/run-gates.ts

@@ -292,6 +292,11 @@ function nodeCompatSmokeGates(): Gate[] {
       'run',
       'apps/cli/tests/source-launch.compat.spec.ts',
     ], { label: 'dsh source-launch smoke' }),
+    pnpmExec('vitest-jsdom-smoke', [
+      'vitest',
+      'run',
+      'scripts/vitest-environment.compat.spec.ts',
+    ], { label: 'Vitest jsdom smoke' }),
   ]
 }
 

+ 14 - 0
scripts/vitest-environment.compat.spec.ts

@@ -0,0 +1,14 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest'
+
+describe('Vitest jsdom compatibility', () => {
+  it('provides isolated browser storage instead of Node process storage', () => {
+    if (process.allowedNodeEnvironmentFlags.has('--webstorage')) {
+      expect(process.execArgv.filter(argument => argument === '--no-webstorage')).toHaveLength(1)
+    }
+    localStorage.setItem('dsh-vitest-storage-probe', 'available')
+
+    expect(localStorage.getItem('dsh-vitest-storage-probe')).toBe('available')
+    localStorage.removeItem('dsh-vitest-storage-probe')
+  })
+})

+ 3 - 0
vitest.config.ts

@@ -1,5 +1,6 @@
 import tsconfigPaths from 'vite-tsconfig-paths'
 import { defineConfig } from 'vitest/config'
+import { vitestExecArgv } from './vitest.shared.ts'
 
 // Resolution facade shared by every plugin instance below: tsconfig.base.json
 // has no include, which vite-tsconfig-paths treats as match-all, so its paths
@@ -62,6 +63,7 @@ export default defineConfig({
         plugins: [pathsPlugin()],
         test: {
           name: 'thread-safe',
+          execArgv: vitestExecArgv,
           // Node 24 has aborted in its CJS lexer from a macOS arm64 worker
           // thread. A fork contains that external runtime failure to the test
           // process; other hosts retain the lower-overhead thread pool.
@@ -78,6 +80,7 @@ export default defineConfig({
         plugins: [pathsPlugin()],
         test: {
           name: 'process-bound',
+          execArgv: vitestExecArgv,
           pool: 'forks',
           setupFiles: ['./scripts/test-invariants.ts'],
           include: processBoundTests,

+ 2 - 0
vitest.e2e.config.ts

@@ -1,5 +1,6 @@
 import tsconfigPaths from 'vite-tsconfig-paths'
 import { defineConfig } from 'vitest/config'
+import { vitestExecArgv } from './vitest.shared.ts'
 
 // Real-API suite, separate because it spends tokens. Each test self-skips without
 // its provider credential for keyless CI; credentialed workflows preflight the
@@ -37,6 +38,7 @@ export default defineConfig({
   // entirely.
   plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })],
   test: {
+    execArgv: vitestExecArgv,
     setupFiles: ['./scripts/test-invariants.ts'],
     // apps/cli only, not apps/*: apps/web/tests/*.e2e.ts needs the built
     // frontend dist and runs under vitest.web.config.ts (the test:web job).

+ 5 - 0
vitest.shared.ts

@@ -0,0 +1,5 @@
+/**
+ * Worker arguments that keep process-wide Web Storage from shadowing jsdom storage.
+ * Node lists the positive spelling in `allowedNodeEnvironmentFlags` for this negatable flag.
+ */
+export const vitestExecArgv = process.allowedNodeEnvironmentFlags.has('--webstorage') ? ['--no-webstorage'] : []

+ 2 - 0
vitest.snapshot.config.ts

@@ -1,6 +1,7 @@
 import { availableParallelism } from 'node:os'
 import tsconfigPaths from 'vite-tsconfig-paths'
 import { defineConfig } from 'vitest/config'
+import { vitestExecArgv } from './vitest.shared.ts'
 
 const DEFAULT_SNAPSHOT_MAX_CONCURRENCY = 5
 
@@ -41,6 +42,7 @@ export default defineConfig({
   // this (the root tsconfig is a solution file with no paths).
   plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })],
   test: {
+    execArgv: vitestExecArgv,
     setupFiles: ['./scripts/test-invariants.ts'],
     include: [
       'scripts/**/*.snapshot.ts',

+ 2 - 0
vitest.web.config.ts

@@ -1,5 +1,6 @@
 import tsconfigPaths from 'vite-tsconfig-paths'
 import { defineConfig } from 'vitest/config'
+import { vitestExecArgv } from './vitest.shared.ts'
 
 // Web browser lane: real host entry points, built-client interaction snapshots,
 // and replayed keyless e2e scenarios outside the unit/e2e includes. Linux PR CI
@@ -18,6 +19,7 @@ export default defineConfig({
   // workspace imports to source like every other lane.
   plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })],
   test: {
+    execArgv: vitestExecArgv,
     include: [
       'apps/web/tests/**/*.e2e.ts',
       'apps/web/tests/**/*.snapshot.ts',