Quellcode durchsuchen

test: bind publint subprocess lifetime to lane cancellation

Tianyi Cui vor 3 Wochen
Ursprung
Commit
d792a694ef

+ 6 - 0
.agents/notes/implemented/testing/2026-09-07-publint-test-subprocess-lifetime.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-07-publint-test-subprocess-lifetime.md
+2026-09-07-publint-test-subprocess-lifetime.md: f8b0e0455f44e6cfc7dd1ce120b0edc07e88180a
+2026-09-07-publint-test-subprocess-lifetime.zh.md: dd0533752b13f337e5f2453f01ca87f17d6ec3b2

+ 25 - 0
.agents/notes/implemented/testing/2026-09-07-publint-test-subprocess-lifetime.md

@@ -0,0 +1,25 @@
+# Agent Note: Publint test subprocesses inherit the execution lane deadline
+
+Status: implemented
+
+English | [中文](2026-09-07-publint-test-subprocess-lifetime.zh.md)
+
+## Problem
+
+The publint script tests have a five-second synchronous subprocess deadline below the Windows coverage lane's existing 90-second test and hook budgets. Captured Windows failures report null status in both the valid and invalid JavaScript/CSS cases; the valid case takes 5028 ms. Those logs omit the subprocess error and signal, so they do not establish ETIMEDOUT. The deadline mismatch is a shared test defect, not evidence that a product change caused the failures.
+
+## Decision
+
+The [publint spec](../../../../scripts/publint-all.spec.ts) uses the existing Execa dependency with Vitest's test-context signal. There is no independent subprocess timeout. The [workflow](../../../../.github/workflows/ci.yml) and [coverage argument owner](../../../../scripts/coverage-partitions.ts) remain responsible for budgets. This applies the same lane-ownership rule as the [subagent teardown tests](2026-09-07-subagent-teardown-test-budgets.md) without changing their cleanup.
+
+Each direct Node child is registered immediately, cancellation requests SIGKILL, and teardown awaits every owned child's result and close event before removing private package roots. Process errors, cancellation, timeout flags, signals, and captured streams are diagnosed before expected exit codes. An ordinary exit code of one remains valid for negative publint cases. All five cases invoke the real script with isolated publication fixtures.
+
+## Alternatives considered
+
+- Increase the five-second constant: another local constant would still override the execution lane's budget.
+- Preload or replace publint: neither exercises cold script imports and the real publication checks.
+- Return after kill: process and pipe closure must precede fixture removal.
+
+## Consequences
+
+A readiness-gated deadline regression cancels two live children and checks both closure events, dead PIDs, and informative diagnostics. A missing working directory verifies spawn-error diagnostics. Independent concurrent spec processes exercise temporary-directory isolation and subprocess scheduling. Native Windows CI remains the owner of Windows termination and filesystem evidence; macOS results do not establish those guarantees. Product code, workflow budgets, and snapshot output remain unchanged.

+ 25 - 0
.agents/notes/implemented/testing/2026-09-07-publint-test-subprocess-lifetime.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: Publint 测试子进程继承执行通道期限
+
+Status: implemented
+
+[English](2026-09-07-publint-test-subprocess-lifetime.md) | 中文
+
+## 问题
+
+publint 脚本测试自设五秒同步子进程期限,低于 Windows 覆盖率通道现有的 90 秒测试与钩子预算。已捕获的 Windows 失败在有效和无效 JavaScript/CSS 用例中都报告空退出状态;有效用例耗时 5028 毫秒。这些日志没有记录子进程错误和信号,因此不能证明 ETIMEDOUT。期限不匹配是共享测试缺陷,不是产品改动导致失败的证据。
+
+## 决策
+
+[publint 测试](../../../../scripts/publint-all.spec.ts) 使用现有 Execa 依赖和 Vitest 测试上下文信号,不设独立子进程超时。[工作流](../../../../.github/workflows/ci.yml) 和[覆盖率参数所有者](../../../../scripts/coverage-partitions.ts) 继续负责预算。这与[子代理清理测试](2026-09-07-subagent-teardown-test-budgets.zh.md) 采用相同的通道所有权规则,但不改变其清理逻辑。
+
+每个直接 Node 子进程创建后立即登记,取消请求发送 SIGKILL;清理先等待所有已登记子进程的结果与 close 事件,再删除私有包根目录。预期退出码断言之前先诊断进程错误、取消、超时标记、信号及捕获的输出流。正常退出码一仍是 publint 负例的有效结果。五个用例均通过隔离的发布夹具调用真实脚本。
+
+## 曾考虑的替代方案
+
+- 增大五秒常量:另一个局部常量仍会覆盖执行通道预算。
+- 预加载或替换 publint:两者都不能验证冷启动脚本导入与真实发布检查。
+- 发送终止信号后立即返回:进程与管道关闭必须先于夹具删除。
+
+## 后果
+
+以就绪状态为前提的期限回归测试取消两个存活子进程,并检查两个关闭事件、PID 已不存在及有用的诊断信息。缺失工作目录验证启动错误诊断。独立并发测试进程验证临时目录隔离和子进程调度。Windows 终止与文件系统证据仍由原生 Windows CI 负责;macOS 结果不能证明这些保证。产品代码、工作流预算和快照输出均不变。

+ 99 - 27
scripts/publint-all.spec.ts

@@ -2,15 +2,23 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import { spawnSync } from 'node:child_process'
+import { once } from 'node:events'
+import { execa } from 'execa'
 import { afterEach, describe, expect, it } from 'vitest'
 
 const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
 const runner = fileURLToPath(new URL('./publint-all.ts', import.meta.url))
 const roots: string[] = []
+const children: Array<{ kill: () => void; closed: Promise<unknown> }> = []
 
-afterEach(() => {
-  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+afterEach(async () => {
+  // A test timeout can reach teardown before the test's pending await settles.
+  const ownedRoots = roots.splice(0)
+  await Promise.all(children.splice(0).map(async ({ kill, closed }) => {
+    kill()
+    await closed
+  }))
+  for (const root of ownedRoots) rmSync(root, { recursive: true, force: true })
 })
 
 function fixture(options: {
@@ -42,53 +50,117 @@ function fixture(options: {
   return root
 }
 
-function run(root: string) {
-  return spawnSync(process.execPath, [
+/** Own direct Node children until close; Vitest's signal supplies the lane deadline. */
+function start(args: string[], signal: AbortSignal, cwd = repositoryRoot) {
+  const child = execa(process.execPath, args, {
+    cwd,
+    cancelSignal: signal,
+    killSignal: 'SIGKILL',
+    reject: false,
+    stdin: 'ignore',
+    stripFinalNewline: false,
+  })
+  // `error` is an outcome, not the completion edge for the process and its pipes.
+  const closed = new Promise<void>(resolve => child.nodeChildProcess.once('close', () => resolve()))
+  const result = Promise.all([child, closed]).then(([result]) => result)
+  children.push({ kill: () => { child.kill('SIGKILL') }, closed: result })
+  return { child, result }
+}
+
+function expectCompleted(result: Awaited<ReturnType<typeof start>['result']>) {
+  const diagnostics = [
+    `publint subprocess: error=${String(result.cause)}; signal=${String(result.signal)}; exitCode=${String(result.exitCode)}`,
+    `canceled=${result.isCanceled}; timedOut=${result.timedOut}`,
+    result.shortMessage ?? '',
+    `stdout:\n${result.stdout}`,
+    `stderr:\n${result.stderr}`,
+  ].join('\n')
+  expect(result.cause, diagnostics).toBeUndefined()
+  expect(result.isCanceled, diagnostics).toBe(false)
+  expect(result.timedOut, diagnostics).toBe(false)
+  expect(result.signal, diagnostics).toBeUndefined()
+}
+
+async function run(root: string, signal: AbortSignal) {
+  const { result } = start([
     '--import', 'tsx', runner,
     '--packages-root', root,
-  ], {
-    cwd: repositoryRoot,
-    encoding: 'utf8',
-    timeout: 5_000,
-  })
+  ], signal)
+  const completed = await result
+  expectCompleted(completed)
+  return completed
 }
 
 describe('publint package runner', () => {
-  it('lints recursively declared files from an in-memory publication view', () => {
-    const result = run(fixture())
-    expect(result.status, result.stderr).toBe(0)
+  it('reports deadline cancellation after every owned child closes', async ({ signal }) => {
+    const deadline = new AbortController()
+    const active = [0, 1].map(() => start([
+      '-e', "process.stderr.write('probe stderr\\n'); process.stdout.write('ready\\n'); setInterval(() => {}, 1000)",
+    ], AbortSignal.any([signal, deadline.signal])))
+    const closed = active.map(() => false)
+    active.forEach(({ child }, index) => child.nodeChildProcess.once('close', () => { closed[index] = true }))
+    await Promise.all(active.map(({ child }) => once(child.stdout, 'data', { signal })))
+    expect(closed).toEqual([false, false])
+    // Start the deadline only after both children announce readiness; startup speed is not the oracle.
+    const timer = setTimeout(() => deadline.abort(new DOMException('fixture deadline expired', 'TimeoutError')), 0)
+    try {
+      const results = await Promise.all(active.map(({ result }) => result))
+      expect(closed).toEqual([true, true])
+      for (const [index, result] of results.entries()) {
+        expect(result.isCanceled).toBe(true)
+        expect(() => expectCompleted(result)).toThrow(/publint subprocess: error=TimeoutError: fixture deadline expired; signal=/)
+        expect(() => expectCompleted(result)).toThrow(/canceled=true/)
+        expect(() => expectCompleted(result)).toThrow(/ready/)
+        expect(() => expectCompleted(result)).toThrow(/probe stderr/)
+        expect(() => process.kill(active[index]!.child.pid!, 0)).toThrow(/ESRCH/)
+      }
+    } finally {
+      clearTimeout(timer)
+    }
+  })
+
+  it('reports spawn errors before checking the expected exit code', async ({ signal }) => {
+    const { result } = start(['-e', ''], signal, join(fixture(), 'missing-cwd'))
+    const completed = await result
+    expect(completed.cause).toMatchObject({ code: 'ENOENT' })
+    expect(() => expectCompleted(completed)).toThrow(/publint subprocess: error=.*ENOENT.*; signal=undefined/)
+  })
+
+  it('lints recursively declared files from an in-memory publication view', async ({ signal }) => {
+    const result = await run(fixture(), signal)
+    expect(result.exitCode, result.stderr).toBe(0)
     expect(result.stdout).toContain('linting 1 package(s)')
     expect(result.stdout).toContain('All good!')
   })
 
-  it('rejects an export that exists in the workspace but is not published', () => {
-    const result = run(fixture({ exportPath: './unpublished.js' }))
-    expect(result.status).toBe(1)
+  it('rejects an export that exists in the workspace but is not published', async ({ signal }) => {
+    const result = await run(fixture({ exportPath: './unpublished.js' }), signal)
+    expect(result.exitCode).toBe(1)
     expect(result.stdout).toContain('unpublished.js')
   })
 
-  it('rejects a public export whose built file is missing', () => {
-    const result = run(fixture({ exportPath: './lib/missing.js' }))
-    expect(result.status).toBe(1)
+  it('rejects a public export whose built file is missing', async ({ signal }) => {
+    const result = await run(fixture({ exportPath: './lib/missing.js' }), signal)
+    expect(result.exitCode).toBe(1)
     expect(result.stdout).toContain('missing.js')
   })
 
-  it('accepts published relative JavaScript and CSS targets', () => {
-    const result = run(fixture({
+  it('accepts published relative JavaScript and CSS targets', async ({ signal }) => {
+    const result = await run(fixture({
       indexSource: "export { helper } from './helper.js'\nimport './theme.css'\n",
       files: {
         'lib/helper.js': 'export const helper = true\n',
         'lib/theme.css': ':root {}\n',
       },
-    }))
-    expect(result.status, result.stderr).toBe(0)
+    }), signal)
+    expect(result.exitCode, result.stderr).toBe(0)
   })
 
-  it('rejects unpublished relative JavaScript and CSS targets', () => {
-    const result = run(fixture({
+  it('rejects unpublished relative JavaScript and CSS targets', async ({ signal }) => {
+    const result = await run(fixture({
       indexSource: "export { helper } from './missing.js'\nimport './missing.css'\n",
-    }))
-    expect(result.status).toBe(1)
+    }), signal)
+    expect(result.exitCode).toBe(1)
     expect(result.stderr).toContain('imports "./missing.js"')
     expect(result.stderr).toContain('imports "./missing.css"')
   })