Răsfoiți Sursa

test(ci): observe command readiness and transport settlement

Tianyi Cui 2 săptămâni în urmă
părinte
comite
d85bdd80c1

+ 2 - 2
.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.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 .agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md
-2026-09-08-ci-completion-observations.md: 52deed9cf3e7bdbb7a6a6ad509834bc66b533165
-2026-09-08-ci-completion-observations.zh.md: b909efdd505a59a6344668cd7fc6811601338099
+2026-09-08-ci-completion-observations.md: 116e066f00f3c845d48e1ce71c7a30daae0eb981
+2026-09-08-ci-completion-observations.zh.md: aa888fa1e6fce097a6f47e540dc0109233e6dd9f

+ 4 - 0
.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md

@@ -26,6 +26,10 @@ The [feedback-release test](../../../../apps/web/tests/feedback-release.e2e.ts)
 
 The [wide-table test](../../../../apps/web/tests/markdown-wide-table.e2e.ts) waits for requested and rendered frame tracks to agree and for the conversation ResizeObserver to publish the current rendered width. Two identical intermediate table widths do not prove those inputs settled. Paused native transitions and delayed observer callbacks reproduce the stale breakout reading; an intentionally broken breakout still fails the unchanged geometry assertions.
 
+The [lifecycle command-menu snapshots](../../../../apps/web/tests/lifecycle-chrome.e2e.ts) wait for a catalog option before capturing English or Chinese output; a visible listbox can still contain a stable loading placeholder. The [detached-launch tests](../../../../packages/host/open-in-app/tests/launch-detached.spec.ts) drive watch time and late process events directly through the real launcher, checking that late success, failure, and error events neither unref twice nor kill the child. Real-process environment and early-exit cases remain separate. Sleeping after spawn cannot prove that a late callback ran before coverage collection.
+
+The [LSP backpressure test](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts) corks the real stdin stream at `didOpen`, writes the full document, and observes the pending write before aborting. The test observes query rejection immediately and joins disposal instead of relying on a short startup marker deadline or a sleep to infer backpressure.
+
 ### Built-client import classification
 
 The [master Windows run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34204779455/job/101996934534) also rejects the intentional CSS import exposed by `ui-dockkit`. The [Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts) admits that exact bundle only when Node reports `ERR_UNKNOWN_FILE_EXTENSION` for its `dockkit.module.css`. Other errors at the same entry still fail, and an entry that imports successfully reports a stale exemption. This preserves the import sweep without requiring a browser-only component library to load its stylesheet under bare Node.

+ 4 - 0
.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md

@@ -26,6 +26,10 @@ Status: implemented
 
 [宽表格测试](../../../../apps/web/tests/markdown-wide-table.e2e.ts)等待请求的框架轨道与渲染轨道一致,并等待会话 ResizeObserver 发布当前渲染宽度。两次相同的中间表格宽度不能证明这些输入已稳定。暂停原生过渡和延迟观察回调能够复现过期的溢出读数;故意破坏溢出布局时,未修改的几何断言仍然失败。
 
+[生命周期命令菜单快照](../../../../apps/web/tests/lifecycle-chrome.e2e.ts)在捕获英文或中文输出前等待目录选项;可见列表框仍可能包含稳定的加载占位内容。[独立启动测试](../../../../packages/host/open-in-app/tests/launch-detached.spec.ts)通过真实启动器直接控制观察时间和延迟进程事件,检查延迟成功、失败及错误事件既不会重复 unref,也不会终止子进程。真实进程的环境和提前退出用例仍独立保留。spawn 后 sleep 无法证明延迟回调在覆盖率收集前已执行。
+
+[LSP 背压测试](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts)在 `didOpen` 处 cork 真实 stdin 流,写入完整文档,并在中止前观察待完成写入。测试立即观察查询拒绝并等待释放,不依赖短启动标记期限,也不通过 sleep 推断背压。
+
 ### 已构建客户端的导入分类
 
 [master Windows 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34204779455/job/101996934534)还拒绝了 `ui-dockkit` 有意暴露的 CSS 导入。[Node 导入检查](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts)仅在 Node 针对其 `dockkit.module.css` 报告 `ERR_UNKNOWN_FILE_EXTENSION` 时,才允许这个精确的 bundle。相同入口的其他错误仍然失败,成功导入则报告豁免已过期。这保留了导入检查,同时不要求仅面向浏览器的组件库在裸 Node 中加载样式表。

+ 2 - 2
packages/host/open-in-app/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/host/open-in-app/README.md
-README.md: 0f0c05f48469a03bc115d84e93a174fbfeba0c42
-README.zh.md: 3ac1c72c66ea0008abe621a7ac8fe82c6caead4c
+README.md: 97cadfb0522bc5d119eaa329ab37d6f504743121
+README.zh.md: 8bc0c3f5de464bb76a0c890a56fa4f1b7d5ec050

+ 2 - 0
packages/host/open-in-app/README.md

@@ -79,6 +79,8 @@ The package splits into a data table and three roles. [`src/catalog.ts`](src/cat
 
 [`src/index.ts`](src/index.ts) registers the three routes on `ctx.webServer`: `GET /open-in-app/apps` (the resolution map's keys), `GET /open-in-app/icon/<id>` (the extracted icon, cached in memory per process), and `POST /open-in-app/open` (launches the map's verified launcher directly — never a re-detection). Every route asks the composition's `connection` service for a rejection first; the complete trust story — the Host/Origin fence and browser authentication — has one home in the [`src/index.ts`](src/index.ts) module comment. On top of that fence the open route validates its body at the wire: an `application/json` media type, a 64 KiB ceiling, a resolved-available catalog id, and an absolute path naming an existing directory. Resolution and icon commands run through [`@deepseek-ai/dsh-native-command`](../../util/native-command/README.md) (argv, never a shell) under their respective deadlines; PATH names go through `ctx.subprocess.resolveExecutable()` in-process.
 
+[Launch settlement tests](tests/launch-detached.spec.ts) use controlled watch time and late process events to verify one settlement without relying on child startup speed; real-process launch cases remain in [resolver tests](tests/resolver.spec.ts).
+
 </details>
 
 -----

+ 2 - 0
packages/host/open-in-app/README.zh.md

@@ -79,6 +79,8 @@ kind: "package-reference"
 
 [`src/index.ts`](src/index.ts) 在 `ctx.webServer` 上注册三条路由:`GET /open-in-app/apps`(解析映射的 keys)、`GET /open-in-app/icon/<id>`(提取的图标,进程内内存缓存)、`POST /open-in-app/open`(直接使用映射中已验证的启动器——绝不重新检测)。每条路由都先向组合的 `connection` 服务询问是否拒绝;完整的信任叙述——Host/Origin 栅栏与浏览器认证——唯一的出处在 [`src/index.ts`](src/index.ts) 的模块注释。在该栅栏之上,open 路由在 wire 边界校验请求体:`application/json` 媒体类型、64 KiB 上限、解析为可用的目录 id、指向现存目录的绝对路径。解析与图标命令经 [`@deepseek-ai/dsh-native-command`](../../util/native-command/README.zh.md)(argv,绝不走 shell)在各自期限内执行;PATH 名称走 `ctx.subprocess.resolveExecutable()` 进程内解析。
 
+[启动结算测试](tests/launch-detached.spec.ts)使用受控观察时间和延迟进程事件验证仅结算一次,不依赖子进程启动速度;真实进程启动用例仍保留在[解析器测试](tests/resolver.spec.ts)中。
+
 </details>
 
 -----

+ 56 - 0
packages/host/open-in-app/tests/launch-detached.spec.ts

@@ -0,0 +1,56 @@
+/** Detached launch settlement with controlled process events and watch time. */
+import { ChildProcess, spawn } from 'node:child_process'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { launchDetachedApp } from '../src/resolver.ts'
+
+vi.mock('node:child_process', async importOriginal => ({
+  ...await importOriginal<typeof import('node:child_process')>(),
+  spawn: vi.fn(),
+}))
+
+afterEach(() => {
+  vi.restoreAllMocks()
+  vi.mocked(spawn).mockReset()
+  vi.useRealTimers()
+})
+
+describe('launchDetachedApp watch window', () => {
+  it.each(['exit 0', 'exit 3', 'error'] as const)('ignores late %s after unref without killing the child', async (event) => {
+    vi.useFakeTimers()
+    const child = new ChildProcess()
+    const unref = vi.spyOn(child, 'unref')
+    const kill = vi.spyOn(child, 'kill')
+    vi.mocked(spawn).mockReturnValueOnce(child)
+    try {
+      const launched = launchDetachedApp('fixture-app', [], { watchMs: 100 })
+      const fulfilled = vi.fn()
+      const rejected = vi.fn()
+      const observed = launched.then(fulfilled, rejected)
+
+      await vi.advanceTimersByTimeAsync(99)
+      expect(fulfilled).not.toHaveBeenCalled()
+      expect(unref).not.toHaveBeenCalled()
+      await vi.advanceTimersByTimeAsync(1)
+      await expect(launched).resolves.toBeUndefined()
+      await observed
+      expect(fulfilled).toHaveBeenCalledExactlyOnceWith(undefined)
+      expect(unref).toHaveBeenCalledTimes(1)
+      expect(kill).not.toHaveBeenCalled()
+
+      // emit() invokes the resolver's registered callback before returning.
+      const handled = event === 'error'
+        ? child.emit('error', new Error('late launcher error'))
+        : child.emit('exit', event === 'exit 0' ? 0 : 3, null)
+      expect(handled).toBe(true)
+      expect(unref).toHaveBeenCalledTimes(1)
+      expect(kill).not.toHaveBeenCalled()
+      expect(rejected).not.toHaveBeenCalled()
+      expect(vi.getTimerCount()).toBe(0)
+    } finally {
+      child.removeAllListeners()
+      vi.restoreAllMocks()
+      vi.mocked(spawn).mockReset()
+      vi.useRealTimers()
+    }
+  })
+})

+ 0 - 12
packages/host/open-in-app/tests/resolver.spec.ts

@@ -664,18 +664,6 @@ describe('launchDetachedApp', () => {
       .rejects.toMatchObject({ code: 'ENOENT' })
   })
 
-  it('counts a child that outlives the watch window as launched without killing it', async () => {
-    // The child exits on its own shortly after; the launch settles at the
-    // window, long before that, and never awaits or kills the process.
-    const started = Date.now()
-    await expect(launchDetachedApp(
-      node, ['-e', 'setTimeout(() => {}, 1500)'], { watchMs: 100 },
-    )).resolves.toBeUndefined()
-    expect(Date.now() - started).toBeLessThan(1_400)
-    // A late exit after the settled window changes nothing.
-    await new Promise(resolve => setTimeout(resolve, 1_600))
-  })
-
   it('hands the child a credential-scrubbed environment with explicit adapter entries', async () => {
     const root = await tempRoot()
     const witness = join(root, 'env.json')