Browse Source

test(app-boot): control user-patch HMR event delivery

Tianyi Cui 4 days ago
parent
commit
bd24f8a17f

+ 6 - 0
.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.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-09-09-user-patch-hmr-test-delivery.md
+2026-09-09-user-patch-hmr-test-delivery.md: 427cf938d38eaf5c351fc0334bfd7df766d5663c
+2026-09-09-user-patch-hmr-test-delivery.zh.md: c2a3a14c9f322e748dbfd131a98138b13c5d47a6

+ 27 - 0
.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md

@@ -0,0 +1,27 @@
+# Agent Note: User-patch transactions control filesystem event delivery
+
+Status: implemented
+
+English | [中文](2026-09-09-user-patch-hmr-test-delivery.zh.md)
+
+## Problem
+
+The [macOS Sandbox run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) times out while waiting for the first user-patch addition. Concurrent local reproductions show no filesystem notification reaching HMR. A polling variant also misses a subsequent edit while HMR has no pending refresh. These failures prevent the transaction assertions from exercising the parser, activation, and rollback behavior they own.
+
+## Decision
+
+The [user-patch transaction test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. HMR registration, refresh serialization, Include recomposition, plugin activation, failure broadcasting, rollback, and recovery remain real. The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block.
+
+The separate [HMR config tests](../../../../packages/boot/app-boot/tests/hmr-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The transaction test does not establish operating-system delivery guarantees.
+
+## Alternatives considered
+
+**Native notifications for every transaction assertion.** Rejected because it repeats the native delivery dependency across each parser and activation state transition. A missing event obscures which downstream behavior is broken.
+
+**Polling and fixed settling delays.** Rejected because neither acknowledges delivery of the next edit. Chokidar readiness does not expose completion of Node's asynchronous initial polling baseline; a local polling reproduction still misses changes. Increasing the test deadline cannot recover an event that was never emitted.
+
+**Mock HMR registration or Include.** Rejected because the test must retain transactional recomposition and last-good-state assertions after activation and parse failures.
+
+## Consequences
+
+The transaction sequence retains every semantic assertion and removes fixed change-throttle sleeps. Independent concurrent processes exercise isolation, and a forced setup failure verifies watcher closure and factory restoration before the next case. Native watcher failures remain visible in their owning tests and require their own diagnosis.

+ 27 - 0
.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 用户 patch 事务控制文件系统事件投递
+
+Status: implemented
+
+[English](2026-09-09-user-patch-hmr-test-delivery.md) | 中文
+
+## 问题
+
+[macOS Sandbox 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) 在等待首次用户 patch 新增时超时。本地并发复现表明,没有文件系统通知到达 HMR。轮询变体也会遗漏后续修改,此时 HMR 没有待执行的刷新。这些失败阻止事务断言执行其负责验证的解析、激活与回滚行为。
+
+## 决策
+
+[用户 patch 事务测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。HMR 注册、刷新串行化、Include 重组、插件激活、失败广播、回滚与恢复仍使用真实实现。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。
+
+独立的 [HMR 配置测试](../../../../packages/boot/app-boot/tests/hmr-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。事务测试不验证操作系统的投递保证。
+
+## 考虑过的替代方案
+
+**每个事务断言都使用原生通知。** 不采用,因为这会让每次解析器与激活状态转换都重复依赖原生投递。事件缺失会掩盖下游究竟哪个行为出现问题。
+
+**轮询与固定等待。** 不采用,因为两者都不能确认下一次修改已经投递。Chokidar 就绪状态不暴露 Node 异步初始轮询基线的完成时刻;本地轮询复现仍会遗漏修改。延长测试期限无法恢复从未发出的事件。
+
+**Mock HMR 注册或 Include。** 不采用,因为测试必须保留事务重组,以及激活和解析失败后的最后有效状态断言。
+
+## 影响
+
+事务序列保留所有语义断言,并移除固定的 change 节流等待。独立并发进程验证隔离性,强制初始化失败则验证 watcher 在下一用例前关闭、工厂在下一用例前恢复。原生 watcher 失败仍在其所属测试中可见,需要单独诊断。

+ 2 - 2
packages/boot/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 packages/boot/app-boot/README.md
-README.md: 197ed81c1025f7211c3fb7a694547c52fb058ee8
-README.zh.md: 5e465a86f5e624380710cc8c73d5356c649522e4
+README.md: df386b64089f962960b09538381e9d4b4905feb0
+README.zh.md: 5a7246c52ea5bcc8d5322d9fac4a87ec2e2ef4cc

+ 1 - 0
packages/boot/app-boot/README.md

@@ -118,6 +118,7 @@ Read these pages when the package-level contract is not enough. They move from t
 - [dsh-home-paths](../../util/home-paths/README.md) — the Harness-home resolver (`resolveDshHome`).
 - [Configuration source ownership](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md) — why a discovered file may not decide bootstrap behavior.
 - [Profile plugin bundles](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md) — the profile and bundle composition design.
+- [User-patch HMR tests](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md) — ownership of transaction behavior and native filesystem delivery.
 
 -----
 

+ 1 - 0
packages/boot/app-boot/README.zh.md

@@ -118,6 +118,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 - [dsh-home-paths](../../util/home-paths/README.zh.md)——harness home 解析器(`resolveDshHome`)。
 - [配置来源归属](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md)——被发现的文件为何不得决定 bootstrap 行为。
 - [Profile 插件组合包](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md)——profile 与组合包组合设计。
+- [用户 patch HMR 测试](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)——事务行为与原生文件系统投递的验证归属。
 
 -----
 

+ 2 - 1
packages/boot/app-boot/package.json

@@ -57,6 +57,7 @@
     "@deepseek-ai/dsh-home-paths": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@types/js-yaml": "^4.0.9",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "chokidar": "4.0.3"
   }
 }

+ 37 - 8
packages/boot/app-boot/tests/user-patches.spec.ts

@@ -8,7 +8,8 @@ import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
-import { afterAll, afterEach, describe, expect, it } from 'vitest'
+import { afterAll, afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
+import { FSWatcher, type ChokidarOptions } from 'chokidar'
 import { Context } from '@deepseek-ai/cordis'
 import Hmr from '@deepseek-ai/cordis-plugin-hmr'
 import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
@@ -24,6 +25,20 @@ import {
 
 const NAME = 'dsh-test-bin'
 
+const configWatch = vi.hoisted(() => ({
+  create: undefined as ((options?: ChokidarOptions) => FSWatcher) | undefined,
+}))
+
+vi.mock('chokidar', async (importOriginal) => {
+  const native = await importOriginal<typeof import('chokidar')>()
+  return {
+    ...native,
+    watch: (paths: string | string[], options?: ChokidarOptions) => configWatch.create === undefined
+      ? native.watch(paths, options)
+      : configWatch.create(options),
+  }
+})
+
 const tempRoots: string[] = []
 afterAll(() => {
   for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
@@ -43,8 +58,6 @@ async function eventually(test: () => boolean, message: string): Promise<void> {
   }
 }
 
-const settleChokidarChangeThrottle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 75))
-
 describe('loadOptionalPatches', () => {
   afterEach(() => {
     delete process.env.DSH_HOME
@@ -396,8 +409,20 @@ describe('boot with user patches', () => {
     const filename = join(userDir, PROFILE_PATCH_FILENAME)
     const basePatches = [{ id: 'noop', config: { value: 'generated' } }]
     const ctx = await boot(NAME, writeTree(dir), basePatches)
+    onTestFinished(() => ctx.fiber.dispose())
     await ctx.plugin(Timer)
     await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
+    // Native notifications belong to hmr-config.spec.ts; this case owns the
+    // real HMR/Include transaction after each delivered filesystem event.
+    const watchers: FSWatcher[] = []
+    const previousFactory = configWatch.create
+    onTestFinished(() => { configWatch.create = previousFactory })
+    configWatch.create = (options) => {
+      const watcher = new FSWatcher(options)
+      watchers.push(watcher)
+      queueMicrotask(() => { watcher.emit('ready') })
+      return watcher
+    }
     const failures: Array<{ filename: string; error: Error }> = []
     ctx.on('hmr/config-update-failed', (failedFilename, error) => {
       failures.push({ filename: failedFilename, error })
@@ -407,45 +432,49 @@ describe('boot with user patches', () => {
       filename,
       compose: userPatches => [...basePatches, ...userPatches],
     })
+    expect(watchers).toHaveLength(1)
+    const watcher = watchers[0]!
     try {
       writeFileSync(filename, '- id: noop\n  config:\n    value: live\n')
+      watcher.emit('add', filename)
       await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'user patch addition was not applied')
 
       writeFileSync(filename, '- id: noop\n  config:\n    fail: true\n')
+      watcher.emit('change', filename)
       await eventually(() => failures.length === 1, 'failed candidate was not broadcast')
       expect(failures[0]).toMatchObject({ filename })
       expect(failures[0]?.error).toBeInstanceOf(Error)
       expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
-      await settleChokidarChangeThrottle()
 
       writeFileSync(filename, 'invalid: [unclosed\n')
+      watcher.emit('change', filename)
       await eventually(() => failures.length === 2, 'parse failure was not broadcast')
       expect(failures[1]?.error).toBeInstanceOf(Error)
       expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
-      await settleChokidarChangeThrottle()
 
       writeFileSync(filename, '- id: noop\n  config:\n    value: recovered\n')
+      watcher.emit('change', filename)
       await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'recovered', 'valid recovery was not applied')
-      await settleChokidarChangeThrottle()
 
       unlinkSync(filename)
+      watcher.emit('unlink', filename)
       await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch')
       expect(failures).toHaveLength(2)
-      await settleChokidarChangeThrottle()
 
       // Default compose: the user layer IS the whole patch list, so a
       // fresh generation replaces the app-owned layer instead of stacking on it.
       await dispose()
       const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename })
+      expect(watchers).toHaveLength(2)
       try {
         writeFileSync(filename, '- id: noop\n  config:\n    value: identity\n')
+        watchers[1]!.emit('add', filename)
         await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user patch was not applied')
       } finally {
         await disposeDefault()
       }
     } finally {
       await dispose()
-      await ctx.fiber.dispose()
     }
   })
 

+ 3 - 0
pnpm-lock.yaml

@@ -1289,6 +1289,9 @@ importers:
       '@types/js-yaml':
         specifier: ^4.0.9
         version: 4.0.9
+      chokidar:
+        specifier: 4.0.3
+        version: 4.0.3
 
   packages/boot/cmdline:
     devDependencies: