Explorar o código

Merge pull request #3790 from deepseek-harness/worktree-ci-0908

fix(CI): update spec timeout and behavior
imccyu hai 1 mes
pai
achega
96a66ba478

+ 2 - 2
.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.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/process/2026-08-18-in-job-partitioned-coverage.md
-2026-08-18-in-job-partitioned-coverage.md: 8a3eb202255ecbf680dfa0af656fd5b89e958001
-2026-08-18-in-job-partitioned-coverage.zh.md: e7ca2692485fa4f9f0135821963e64347c3cdfdd
+2026-08-18-in-job-partitioned-coverage.md: 8ac4635101504419c976a5ab740b691a090e548f
+2026-08-18-in-job-partitioned-coverage.zh.md: 55dee82662c7174d661c253463153ca78e3099b2

+ 2 - 0
.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md

@@ -14,6 +14,8 @@ The optimization must retain every test and the merged per-file 100% thresholds.
 
 The ordinary `pnpm run test:coverage` command remains one Vitest invocation. Linux coverage CI fixes `DSH_COVERAGE_PARTITIONS=4`; native Windows now also fixes it at 4 to reduce process-creation pressure under high self-hosted concurrency. No elapsed-time trigger changes either count while a run is in progress. The [coverage-exempt heavy suite](../../archived/process/2026-07-31-coverage-exempt-heavy-suites.md) remains a separate uninstrumented gate beside the instrumented work.
 
+The entire `packages/typert/` group is exempt from source coverage and runs in the uninstrumented gate. Its compiler fixtures, catalog reproduction, loader, protocol, and registry assertions remain required. The shared [exempt roster](../../../../scripts/coverage-exempt.ts) selects every Typert package, including nested tests; both project exclusions and partition inventory consume that roster. Exemption removes coverage collection, not test or hook failures.
+
 When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:coverage:partitioned` for the instrumented gate. `scripts/coverage-partitions.ts` starts the configured Vitest children concurrently, each with one worker. The coordinator collects the instrumented inventory from a `vitest list --filesOnly` run (caller filters narrow it; exempt heavy suites are removed because list does not apply their exclusion), reads recorded per-file durations from a coordinator-persisted gitignored file (restored and saved through the GitHub cache on the windows-coverage job, because checkout removes it and Vitest's own cache never survives CI), and assigns files to partitions by longest-processing-time by way of a min-heap, so the heavy subprocess-bound suites spread across children instead of piling into whichever shard a path hash lands them in. Each partition receives a temporary Vitest config whose include is its file list per project (command-line files exceeded the Windows CreateProcess limit; the mutually exclusive thread-safe and process-bound projects keep only their own files so nothing runs twice), an empty partition is rejected before any child starts, the heaviest partition starts first so its verdict lands earliest (fail-fast), and the duration history is restored and saved through the GitHub cache with per-run keys (cache entries are immutable). Partition mode suppresses thresholds and coverage reporters in each child, gives every child a separate report directory, and writes one blob report per process.
 
 The coordinator waits for every child, validates that the blob directory contains exactly the expected files, and then runs one `vitest --merge-reports ... --coverage` command. Only that merged command applies the repository's per-file statement, branch, function, and line thresholds, so a partition is never judged against an intentionally partial inventory.

+ 3 - 1
.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md

@@ -14,6 +14,8 @@ Status: implemented
 
 普通的 `pnpm run test:coverage` 命令仍只启动一次 Vitest。Linux 覆盖率 CI 将 `DSH_COVERAGE_PARTITIONS` 固定为 4;原生 Windows 现在也固定为 4,以降低自托管高并发下的进程创建压力。运行期间不会由任何耗时触发器改变这两个数量。[覆盖率豁免重型套件](../../archived/process/2026-07-31-coverage-exempt-heavy-suites.md)仍作为独立的无插桩门禁与插桩工作并排运行。
 
+整个 `packages/typert/` 组豁免源码覆盖率,并在无插桩门禁中运行。其编译器 fixture(测试前置数据)、目录复现、loader、协议及注册表断言仍为必需检查。共享的[豁免清单](../../../../scripts/coverage-exempt.ts)选择每个 Typert 包及其嵌套测试;project 排除规则与分区清单都使用该清单。豁免只移除覆盖率采集,不忽略测试或钩子失败。
+
 启用分区后,`scripts/run-gates.ts` 为插桩门禁选择 `pnpm run test:coverage:partitioned`。`scripts/coverage-partitions.ts` 按配置数量并发启动 Vitest 子进程,每个进程只用 1 个 worker。协调器通过 `vitest list --filesOnly` 收集插桩清单(调用方过滤器会先收窄清单;exempt 重型套件需在此剔除,因为 list 不应用其排除),从协调器持久化的 gitignore 文件读取逐文件耗时(windows-coverage job 通过 GitHub cache 恢复并保存该文件,因为 checkout 会删除它且 Vitest 自身缓存无法在 CI 存活),并借最小堆按最长处理时间把文件分配到各分区,使重量级子进程密集型套件分散到不同子进程,而不是全部落入路径 hash 恰好命中的那一个分片。每个分区获得一个临时 Vitest 配置,其 include 按 project 拆分(命令行传文件会超过 Windows CreateProcess 上限;互斥的 thread-safe 与 process-bound project 只保留各自的文件,避免任何文件跑两次);空分区会在任何子进程启动前被拒绝,最重的分区最先启动使其结论最早落地(fail-fast),耗时历史通过 GitHub cache 以每 run 唯一键恢复与保存(cache 条目不可变)。分区模式会在各子进程中关闭阈值与覆盖率报告器,为每个子进程分配独立报告目录,并让每个进程写出 1 份 blob 报告。
 
 协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。
@@ -36,7 +38,7 @@ Status: implemented
 
 **使用工作流级分片。** 不予采用,因为多个 job 会重复设置工作,并需要上传、下载产物以及合并依赖。所选分区方案只在同一个 job 和工作区内使用多个进程。
 
-**提高单个插桩进程内的 Vitest worker 数。** 不予采用,因为已完成的 Windows 高扇出试验暴露了 worker 退出、fixture(测试前置数据)不稳定和 Node 24 CJS lexer 故障。相互独立的单 worker 进程既保留隔离,也能让所选分区并发执行。
+**提高单个插桩进程内的 Vitest worker 数。** 不予采用,因为已完成的 Windows 高扇出试验暴露了 worker 退出、fixture 不稳定和 Node 24 CJS lexer 故障。相互独立的单 worker 进程既保留隔离,也能让所选分区并发执行。
 
 **在每种宿主上使用相同的分区数量。** 先前不予采用,因为 Linux 的 4 进程运行与 Windows 的 8 进程运行具有不同的启动成本与资源上限。本次变更在 Windows 高并发运行暴露 8 分片 worker 启动失败后,将两者统一为 4 分区。
 

+ 45 - 5
apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts

@@ -81,7 +81,7 @@ async function expectHeadlessStream(normalized: string, expectedPath: string): P
 }
 
 /** Serve one deterministic DeepSeek-compatible response while retaining its request body. */
-async function deepseekDefaultsServer(): Promise<DeepSeekDefaultsServer> {
+async function deepseekDefaultsServer(options: { waitForTitleRequest?: boolean } = {}): Promise<DeepSeekDefaultsServer> {
   const requests: JsonObject[] = []
   const server = createServer((request: IncomingMessage, response: ServerResponse) => {
     let body = ''
@@ -92,9 +92,11 @@ async function deepseekDefaultsServer(): Promise<DeepSeekDefaultsServer> {
       response.writeHead(200, { 'content-type': 'text/event-stream' })
       let keepAlives = 3
       const write = (): void => {
-        if (keepAlives-- > 0) {
+        // One-shot teardown may cancel background title work after the main response.
+        if (keepAlives-- > 0
+          || (options.waitForTitleRequest === true && !requests.some(request => request.max_tokens === 64))) {
           response.write(': keep-alive\n\n')
-          setTimeout(write, 60)
+          timer = setTimeout(write, 60)
           return
         }
         response.end([
@@ -104,7 +106,8 @@ async function deepseekDefaultsServer(): Promise<DeepSeekDefaultsServer> {
           '',
         ].join('\n\n'))
       }
-      setTimeout(write, 60)
+      let timer = setTimeout(write, 60)
+      response.once('close', () => { clearTimeout(timer) })
     })
   })
   await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
@@ -496,8 +499,45 @@ describe('headless stream-json snapshots', () => {
     }
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
+  it('keeps the compatibility stream open until the title request arrives', async () => {
+    const server = await deepseekDefaultsServer({ waitForTitleRequest: true })
+    try {
+      const response = await fetch(server.url, {
+        method: 'POST',
+        body: JSON.stringify({ max_tokens: 1024 }),
+      })
+      const reader = response.body!.getReader()
+      try {
+        const decoder = new TextDecoder()
+        let body = ''
+        // Four heartbeats cross the ordinary fixture's three-heartbeat response.
+        while (body.split(': keep-alive\n\n').length < 5) {
+          const chunk = await reader.read()
+          expect(chunk.done).toBe(false)
+          body += decoder.decode(chunk.value)
+          expect(body).not.toContain('data:')
+        }
+        const title = await fetch(server.url, {
+          method: 'POST',
+          body: JSON.stringify({ max_tokens: 64 }),
+        })
+        for (;;) {
+          const chunk = await reader.read()
+          if (chunk.done) break
+          body += decoder.decode(chunk.value)
+        }
+        expect(body).toContain('data: [DONE]')
+        expect(await title.text()).toContain('data: [DONE]')
+      } finally {
+        await reader.cancel()
+      }
+    } finally {
+      await server.close()
+    }
+  })
+
   it('sends pi-ai DeepSeek compatibility through the one-shot app', async () => {
-    const server = await deepseekDefaultsServer()
+    const server = await deepseekDefaultsServer({ waitForTitleRequest: true })
     try {
       const result = await runLoaderSmoke({
         label: 'pi-ai DeepSeek compatibility headless stream-json snapshot',

+ 1 - 1
apps/desktop/tests/seed-store.spec.ts

@@ -63,7 +63,7 @@ describe('desktop seed store cleanup', () => {
 })
 
 describe('desktop seed store merge', () => {
-  it('preserves installed package records while the verified seed replaces matching records and files', () => {
+  it('preserves installed package records while the verified seed replaces matching records and files', { timeout: 30_000 }, () => {
     const root = temporaryRoot()
     const source = join(root, 'source')
     const destination = join(root, 'destination')

+ 4 - 0
apps/web/tests/details-session-lifecycle.e2e.ts

@@ -226,11 +226,15 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
       await page.locator('[data-sidebar-right-expand]').click()
       await expect.poll(() => column.locator('[data-sidebar-right-open]').count()).toBe(1)
       await expect.poll(() => columns(page)).toEqual(normalColumns)
+      // The panel's slide completes independently of the frame's grid tracks.
+      await expect.poll(() => panel.evaluate(element => getComputedStyle(element).transform))
+        .toBe('none')
     }
     const close = async (): Promise<void> => {
       await column.locator('[data-sidebar-right-toggle]').click()
       await expect.poll(() => column.locator('[data-sidebar-right-open]').count()).toBe(0)
       await expect.poll(() => detailsTrack(page)).toBe(0)
+      await panel.waitFor({ state: 'hidden' })
     }
 
     await select(original, 'LIGHTHOUSE')

+ 20 - 26
apps/web/tests/markdown-wide-table.e2e.ts

@@ -197,13 +197,24 @@ interface TableStop {
 }
 
 /**
- * Wait for the right column to sit at its rail. Expanded, it would pin the
- * transcript to exactly the message column and every breakout relation would
- * go vacuous; the frame's collapse marker is the settled signal.
+ * Wait for collapsed columns, completed grid transitions, and the
+ * ConversationRoot ResizeObserver's width publication before measuring tables.
  * @param target - the page whose frame to read.
  */
-async function awaitRightRail(target: Page): Promise<void> {
-  await target.waitForSelector('[data-rightbar-collapsed]', { timeout: 5_000 })
+async function awaitTableLayout(target: Page): Promise<void> {
+  await target.evaluate(async () => { await document.fonts.ready })
+  await target.waitForFunction(() => {
+    const element = document.querySelector('[data-sidebar-collapsed][data-rightbar-collapsed]')
+    if (element === null) return false
+    const tracks = getComputedStyle(element).gridTemplateColumns.split(' ').map(Number.parseFloat)
+    const root = element.querySelector<HTMLElement>('div[data-phase]')
+    // Mirrored from ui-layout's SIDEBAR_COLLAPSED; these tests use the Host compiler face.
+    return tracks[0] === 56 && tracks.at(-1) === 0
+      && element.getAnimations().every(animation =>
+        animation.playState === 'finished' || animation.playState === 'idle')
+      && root !== null
+      && root.style.getPropertyValue('--dsh-conversation-column-width') === `${String(root.offsetWidth)}px`
+  }, undefined, { timeout: 10_000 })
 }
 
 /**
@@ -268,7 +279,7 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro
     // viewport identically on every platform, which is what keeps one
     // committed golden true for all lanes.
     await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
-    await awaitRightRail(page)
+    await awaitTableLayout(page)
   }, 180_000)
 
   afterAll(async () => {
@@ -285,15 +296,7 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro
    */
   const settleAt = async (width: number): Promise<TableReading[]> => {
     await page.setViewportSize({ width, height: 900 })
-    // The wide wrapper follows the transcript width (the fill wrapper caps
-    // at the message column and would report "settled" mid-transition).
-    let previousWidth = -1
-    await expect.poll(async () => {
-      const current = (await readTables(page))[1]!.clientWidth
-      const settled = current === previousWidth
-      previousWidth = current
-      return settled
-    }, { timeout: 10_000 }).toBe(true)
+    await awaitTableLayout(page)
     return readTables(page)
   }
 
@@ -425,17 +428,8 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro
       await sessionRow.click()
       await hidpiPage.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 })
       await hidpiPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
-      await awaitRightRail(hidpiPage)
-      // The pane collapses ease over the layout transition: compare only a
-      // settled reading (two consecutive equal wide-wrapper widths).
-      let readings: TableReading[] = []
-      let previousWide = -1
-      await expect.poll(async () => {
-        readings = await readTables(hidpiPage)
-        const settled = readings[1]!.clientWidth === previousWide
-        previousWide = readings[1]!.clientWidth
-        return settled
-      }, { timeout: 10_000 }).toBe(true)
+      await awaitTableLayout(hidpiPage)
+      const readings = await readTables(hidpiPage)
       const baseline = (await sweep()).find(stop => stop.width === 1100)!
       const relations = (tables: TableReading[]) => tables.map(table => ({
         marker: table.marker,

+ 9 - 3
apps/web/tests/workspace-management.e2e.ts

@@ -88,9 +88,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
   }
 
   /**
-   * Adopt an existing directory, waiting for the adoption to settle host-side
-   * (workspace registered + the flow's New-Session agent up), so later test
-   * steps can't race the in-flight blank-session attach.
+   * Adopt an existing directory. Fresh-agent callers also wait for the
+   * browser's Session switch and composer focus before starting another flow.
    */
   async function adoptDirectory(path: string, options: { waitForAgent?: boolean } = {}): Promise<void> {
     const agentsBefore = scaffold.ctx.agents.list().length
@@ -108,6 +107,13 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     if (options.waitForAgent === true) {
       await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
         .toBeGreaterThan(agentsBefore)
+      // Host publication precedes the create RPC response. A late Session
+      // switch focuses the composer and cancels an open path editor on blur.
+      await expect.poll(
+        () => page.locator('[data-composer-input][contenteditable="true"]')
+          .evaluate(element => element === document.activeElement),
+        { timeout: 10_000 },
+      ).toBe(true)
     }
   }
 

+ 1 - 1
packages/experimental/agent-team/tests/team.spec.ts

@@ -1697,7 +1697,7 @@ describe('Team mailbox and waiting', () => {
     flushSpy.mockRestore()
   })
 
-  it('bounds Team runtime disposal when a continuation drain never settles', async () => {
+  it('bounds Team runtime disposal when a continuation drain never settles', { timeout: 30_000 }, async () => {
     const { ctx, lead, teamFiber } = await setup(['hang'], { disposalTimeoutMs: 25 })
     const started = await spawn(ctx, lead, 'stuck-worker')
     await waitRunning(ctx, started.member.id)

+ 8 - 0
scripts/coverage-exempt.spec.ts

@@ -32,6 +32,14 @@ function filterMatches(filter: string): string[] {
 }
 
 describe('coverage-exempt roster', () => {
+  it('selects every Typert suite for the uninstrumented gate', () => {
+    const typertSpecs = filterMatches('packages/typert/')
+    const exemptSpecs = coverageExemptHeavySuites.flatMap(suite => excludeMatches(suite.exclude))
+      .filter(spec => spec.startsWith('packages/typert/')).sort()
+    expect(typertSpecs.length).toBeGreaterThan(0)
+    expect(exemptSpecs).toEqual(typertSpecs)
+  })
+
   it.each(coverageExemptHeavySuites.map(suite => [suite.filter, suite] as const))(
     'filter and exclude select the same non-empty spec set for %s',
     (_filter, suite) => {

+ 5 - 5
scripts/coverage-exempt.ts

@@ -2,7 +2,7 @@
  * Heavy suites the coverage aggregate runs uninstrumented in a parallel gate.
  * Membership rule: a suite qualifies only when every coverage-measured
  * file it executes in-process (`coverage.include` spans package src trees;
- * typert generator src is threshold-excluded in vitest.config.ts) is already
+ * Typert src is threshold-excluded in vitest.config.ts) is already
  * fully covered by other suites, so removing it from the instrumented run
  * changes no threshold outcome. The aggregate still runs every listed suite
  * plain beside the instrumented gate, so correctness signal is unchanged —
@@ -27,12 +27,12 @@ export const COVERAGE_EXEMPT_ENV = 'DSH_COVERAGE_EXEMPT_HEAVY'
 
 /** Coverage-exempt heavy suites; keep filter and exclude selecting the same files. */
 export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [
-  // Whole-workspace compiler analysis per case — the lane's longest tail.
-  // Generator src is threshold-excluded; tools-catalog's registry and
+  // Typert src is threshold-excluded; compiler analysis runs uninstrumented.
+  // The generator's tools-catalog registry and
   // tool-cordis imports are fully covered by those packages' own tests.
   {
-    filter: 'packages/typert/generator/tests/',
-    exclude: 'packages/typert/generator/tests/**',
+    filter: 'packages/typert/',
+    exclude: 'packages/typert/*/tests/**',
   },
   // The webworker-runtime package is outside the coverage requirement by
   // decision: vitest.config.ts threshold-excludes its src, so every suite

+ 22 - 0
scripts/coverage-partitions.spec.ts

@@ -206,6 +206,28 @@ describe('coverage file inventory', () => {
     expect(inventory.projectOf.get('packages/b/tests/b.spec.ts')).toBe('process-bound')
   })
 
+  it('removes every Typert package from instrumented files and project assignments', async () => {
+    const root = await temporaryRoot()
+    const typertFiles = [
+      'packages/typert/generator/tests/type-model.spec.ts',
+      'packages/typert/loader/tests/loader.spec.ts',
+      'packages/typert/protocol/tests/protocol.spec.ts',
+      'packages/typert/registry/tests/typert.spec.ts',
+      'packages/typert/future/tests/nested/client.spec.tsx',
+    ]
+    for (const file of typertFiles) {
+      await mkdir(dirname(join(root, file)), { recursive: true })
+      await writeFile(join(root, file), '')
+    }
+    const retained = 'packages/api/gateway/tests/rpc.spec.ts'
+    const inventory = parseListOutput(
+      [...typertFiles, retained].map(file => `[thread-safe] ${file}`).join('\n'),
+      root,
+    )
+    expect(inventory.files).toEqual([retained])
+    expect([...inventory.projectOf]).toEqual([[retained, 'thread-safe']])
+  })
+
   it('averages recorded durations per file from the results cache', async () => {
     const root = await temporaryRoot()
     await writeVitestCache(root, [

+ 3 - 5
vitest.config.ts

@@ -330,11 +330,9 @@ export default defineConfig({
         'packages/client/ui-settings-models/src/client/welcome-store.ts',
         'packages/extensions/*/src/**/*.ts',
         'packages/extensions/*/src/**/*.tsx',
-        // Typert generator: correctness is pinned by its fixture suites and
-        // the byte-for-byte catalog reproduction test; per-file coverage
-        // would put whole-workspace compiler analysis under v8
-        // instrumentation — the coverage lane's longest tail.
-        'packages/typert/generator/src/*.ts',
+        // Typert correctness is checked by its uninstrumented suites,
+        // including compiler fixtures and byte-for-byte catalog reproduction.
+        'packages/typert/*/src/**/*.{ts,tsx}',
         // Experimental webworker-runtime is outside the coverage requirement
         // by decision: its correctness signal is its uninstrumented suite and
         // the packer's end-to-end image spec.