1
0
Эх сурвалжийг харах

test: stabilize timing-sensitive terminal checks

Tianyi Cui 2 сар өмнө
parent
commit
9c53cd7d07

+ 3 - 3
.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.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-16-persistent-pty-sessions.md: 148d4a2f47689e38a3ec83a7a41e4f75c4b73d95
-2026-07-16-persistent-pty-sessions.zh.md: 9a9d9cd4b0f61e8abaf011996ecd8739d13851f8
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
+2026-07-16-persistent-pty-sessions.md: 43c87bb159cfe1ab9f8d3a80c2adf25a57ae6e3b
+2026-07-16-persistent-pty-sessions.zh.md: 8afc2103447cc58b1fcbc1062b9564e8ed643477

+ 1 - 1
.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md

@@ -154,7 +154,7 @@ The package ships concise tool guidance explaining persistent state, owner isola
 
 - Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents.
 - Linux process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite.
-- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
+- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT` after deliberately delayed child readiness under scenario-owned timing bounds, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
 - A Loader-driven `cordis.yml` test mounts the real three-package composition. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation.
 - Package contracts, the architecture map, core data structures, generated catalogs, and the website API describe the same shipped surface.
 - The repository CI-equivalent sequence owns type, lint, coverage, snapshot, documentation, build, hygiene, demo, and built-entry verification.

+ 1 - 1
.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md

@@ -154,7 +154,7 @@ plugins:
 
 - 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。
 - Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程静止性、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。
-- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、raw mode 下的前台 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即静默。
+- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、在由场景掌控的时间界限内先有意延迟子进程就绪,再对 raw mode 前台进程发送 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即完全停稳。
 - Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。
 - 包契约、架构图、核心数据结构、生成目录和 website API 描述同一个已发布接口。
 - 仓库 CI 等价序列负责类型、lint、覆盖率、快照、文档、构建、hygiene、demo 和 built-entry 验证。

+ 3 - 3
.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.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-18-tui-terminal-state-snapshots.md: 8e86588f69fdb9d615232252ecf57309d440f1cd
-2026-07-18-tui-terminal-state-snapshots.zh.md: b70a46830f44e9da663e30745fcdb7ad281592da
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md
+2026-07-18-tui-terminal-state-snapshots.md: 18c79bc2d0dabf4d78887354f30a2cdc083899e1
+2026-07-18-tui-terminal-state-snapshots.zh.md: d1d4a6ca859e94a153e0bf645a17f03c0dac234b

+ 1 - 1
.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md

@@ -33,7 +33,7 @@ The live-model fixtures use `DSH_SNAPSHOT=record`; record mode rewrites their pr
 
 ### Semantic terminal projection
 
-The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. Snapshot code waits for synchronized frames to quiesce before reading state, so a checkpoint represents a completed screen rather than a timer-dependent write prefix.
+The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. Snapshot code waits for synchronized frames to quiesce before reading state. The streaming checkpoint freezes the loader interval while allowing real wall-clock delay across one animation tick, so it pins semantic status rather than whichever spinner glyph the scheduler happened to render.
 
 Each expected output projects dimensions, active-buffer and viewport coordinates, lifecycle and cursor state, rows, wrap markers, and non-default style ranges into text. Scroll-heavy cards capture the used buffer; overlays capture the visible viewport. Text and style remain separate so a reviewer can distinguish content changes from presentation changes without decoding ANSI bytes.
 

+ 1 - 1
.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md

@@ -33,7 +33,7 @@ TUI 覆盖分为四个互补层次:
 
 ### 语义终端投影
 
-包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定,因此每个检查点表示已经完成的画面,而不是依赖计时的写入前缀。
+包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定。流式输出检查点会冻结 loader 的 interval,同时保留跨过一次动画 tick 的真实墙钟等待,从而固定语义状态,而非调度器碰巧渲染出的某个加载动画字形。
 
 每份预期输出把终端尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记以及非默认样式区间投影为文本。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。
 

+ 18 - 10
packages/pty/pty-local/tests/local.spec.ts

@@ -39,7 +39,10 @@ function stubAgent(ctx: Context, rawId: string): Agent {
   }
 }
 
-async function harness(mode: 'danger-full-access' | 'workspace-write') {
+async function harness(
+  mode: 'danger-full-access' | 'workspace-write',
+  timing: { idleSilenceMs?: number; timeoutMs?: number } = {},
+) {
   const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
   roots.push(root)
   const ctx = new Context()
@@ -51,8 +54,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
   const fiber = await ctx.plugin(ptyLocal, {
     pollIntervalMs: 10,
     exactProbeAfterMs: 20,
-    idleSilenceMs: 250,
-    timeoutMs: 2000,
+    idleSilenceMs: timing.idleSilenceMs ?? 250,
+    timeoutMs: timing.timeoutMs ?? 2_000,
     disposeGraceMs: 500,
     scrollbackLines: 100,
     scrollbackMaxBytes: 32_768,
@@ -63,8 +66,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
   return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
 }
 
-async function waitForOutput(operation: PtySendOperation, expected: string): Promise<void> {
-  const deadline = Date.now() + 2_000
+async function waitForOutput(operation: PtySendOperation, expected: string, timeoutMs = 2_000): Promise<void> {
+  const deadline = Date.now() + timeoutMs
   let output = ''
   while (!output.includes(expected) && Date.now() < deadline) {
     output += operation.readOutput().delta
@@ -131,20 +134,25 @@ describe('pty-local real shell', () => {
     expect(() => process.kill(pid, 0)).toThrow()
   }, 10_000)
 
-  it('cancels a raw-mode foreground process with a real SIGINT', async () => {
-    const { ctx, agent } = await harness('danger-full-access')
+  it('cancels a slow-starting raw-mode foreground process with a real SIGINT', async () => {
+    const { ctx, agent } = await harness('danger-full-access', {
+      idleSilenceMs: 10_000,
+      timeoutMs: 15_000,
+    })
     const created = await ctx.pty.spawn(agent, { type: 'shell' })
     const controller = new AbortController()
     const ready = 'RAW_READY'
+    // Delay readiness beyond the shared harness's short send bound so this
+    // process test owns enough slack for loaded macOS startup and shell echo.
     // The interactive shell echoes the command, so only child output may contain the readiness marker.
-    const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); print("RAW_" + "READY", flush=True); time.sleep(60)\''
+    const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); time.sleep(2.1); print("RAW_" + "READY", flush=True); time.sleep(60)\''
     expect(command).not.toContain(ready)
     const foreground = ctx.pty.startSend(agent, created.sessionId, {
       text: command,
       submit: true,
       signal: controller.signal,
     })
-    await waitForOutput(foreground, ready)
+    await waitForOutput(foreground, ready, 15_000)
     controller.abort()
     const result = await foreground.done
     expect(result.waitReason).toBe('stdin_read')
@@ -155,5 +163,5 @@ describe('pty-local real shell', () => {
     expect(after.viewport).toContain('AFTER_SIGINT')
     expect(after.waitReason).toBe('stdin_read')
     await ctx.pty.kill(agent, created.sessionId)
-  }, 10_000)
+  }, 20_000)
 })

+ 38 - 26
packages/ui/tui/tests/tui.snapshot.ts

@@ -228,33 +228,45 @@ const DISPLAYED_CONTROL_PROBE = String.raw`\x1b]2;snapshot-controlled\x07\x09\x7
 describe('TUI terminal-state snapshots', () => {
   it('pins an in-flight reasoning and Markdown stream', async () => {
     const harness = await setupSnapshot()
-    await renderAfter(harness, () => {
-      harness.agent.status = 'running'
-      harness.ctx.emit('agent/status', harness.agent, 'running')
-      appendUser(harness.session, 'Show the live update.')
-      harness.session.append('assistant/chunk', {
-        turn: 1,
-        step: 1,
-        chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
-      })
-      harness.session.append('assistant/chunk', {
-        turn: 1,
-        step: 1,
-        chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
-      })
-      harness.session.append('assistant/chunk', {
-        turn: 1,
-        step: 1,
-        chunk: { type: 'block-start', index: 1, blockType: 'text' },
-      })
-      harness.session.append('assistant/chunk', {
-        turn: 1,
-        step: 1,
-        chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
+    // Freeze the loader's first animation interval so this semantic snapshot
+    // cannot select a different spinner frame under scheduler contention.
+    const frozenLoaderTimer = setInterval(() => {}, 60_000)
+    const intervals = vi.spyOn(globalThis, 'setInterval').mockImplementationOnce(() => frozenLoaderTimer)
+    try {
+      await renderAfter(harness, () => {
+        harness.agent.status = 'running'
+        harness.ctx.emit('agent/status', harness.agent, 'running')
+        appendUser(harness.session, 'Show the live update.')
+        harness.session.append('assistant/chunk', {
+          turn: 1,
+          step: 1,
+          chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
+        })
+        harness.session.append('assistant/chunk', {
+          turn: 1,
+          step: 1,
+          chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
+        })
+        harness.session.append('assistant/chunk', {
+          turn: 1,
+          step: 1,
+          chunk: { type: 'block-start', index: 1, blockType: 'text' },
+        })
+        harness.session.append('assistant/chunk', {
+          turn: 1,
+          step: 1,
+          chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
+        })
       })
-    })
-    await checkpoint('conversation-streaming', harness.terminal)
-    await disposeSnapshot(harness)
+      const loaderIntervalMs = intervals.mock.calls[0]?.[1]
+      if (typeof loaderIntervalMs !== 'number') throw new Error('TUI loader did not register an animation interval')
+      await new Promise(resolve => setTimeout(resolve, loaderIntervalMs + 5))
+      await checkpoint('conversation-streaming', harness.terminal)
+    } finally {
+      intervals.mockRestore()
+      clearInterval(frozenLoaderTimer)
+      await disposeSnapshot(harness)
+    }
   })
 
   it('pins failed-stream retraction, scheduled retry, and eventual success', async () => {