fix(windows): normalize native coverage tests

This commit is contained in:
Tianyi Cui
2026-08-08 20:01:46 +08:00
parent 331ffd8381
commit ed1ea95176
9 changed files with 27 additions and 12 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
2026-08-08-native-windows-pull-request-ci.md: ab4f9ec7a9c18b9cf3303ad705145f51ccc4f08d 2026-08-08-native-windows-pull-request-ci.md: d62858106505e9b6ae79c6ad6c4854b1f3270ae0
2026-08-08-native-windows-pull-request-ci.zh.md: e569371647e948b245b1a0db5b7c54eb51238cbf 2026-08-08-native-windows-pull-request-ci.zh.md: c428e3be028e4201fc178142e54f5a3d02aac742
@@ -18,6 +18,8 @@ The aggregate keeps workspace build, production-site, and 100%-per-file coverage
The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path.
The coverage follow-up then exercised the serial heavy suites on the native host and removed their remaining path-spelling assumptions. Filesystem identity assertions compare native real paths instead of Git's slash convention with Node's temporary-directory spelling; quoted diagnostics are matched in their escaped form; TypeScript-owned file names are compared after separator normalization; and Typert passes a slash-normalized config name consistently across TypeScript's read and parse boundary so malformed Windows configs produce the owned analysis error instead of a compiler debug failure. The Oxlint subprocess contract also uses the same explicit twenty-second budget as its neighboring executable probes. These are portability repairs to supported tests and parser behavior, not platform skips or coverage exclusions.
Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path.
## Alternatives considered ## Alternatives considered
@@ -18,6 +18,8 @@ Status: implemented
首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。
随后,覆盖率后续工作在原生宿主上运行了串行的高负载测试套件,并移除了其中残留的路径拼写假设。文件系统标识断言改为比较原生真实路径,不再直接比较遵循 Git 斜杠约定的路径与 Node 的临时目录拼写;带引号的诊断文本按 JSON 转义后的形式匹配;TypeScript 提供的文件名在统一分隔符后再比较;Typert 则让经过斜杠归一化的配置名称一致贯穿 TypeScript 的读取与解析边界,使格式错误的 Windows 配置产生 Typert 自有的分析错误,而非编译器的调试故障。Oxlint 子进程契约也采用与相邻可执行文件探测相同的显式 20 秒预算。这些都是针对受支持测试与解析器行为的可移植性修复,不是按平台跳过测试或设置覆盖率排除项。
受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。
## 曾考虑的替代方案 ## 曾考虑的替代方案
+3 -2
View File
@@ -2507,9 +2507,10 @@ function mergeWorkspaceModels(models: readonly WorkspaceModel[]): WorkspaceModel
} }
function parseConfig(path: string): ParsedConfig { function parseConfig(path: string): ParsedConfig {
const read = ts.readConfigFile(path, file => ts.sys.readFile(file)) const compilerPath = path.split(sep).join('/')
const read = ts.readConfigFile(compilerPath, file => ts.sys.readFile(file))
if (read.error !== undefined) throw new TypertAnalysisError(formatDiagnostic(read.error)) if (read.error !== undefined) throw new TypertAnalysisError(formatDiagnostic(read.error))
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(path), undefined, path) const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(compilerPath), undefined, compilerPath)
if (parsed.errors.length > 0) throw new TypertAnalysisError(parsed.errors.map(formatDiagnostic).join('\n')) if (parsed.errors.length > 0) throw new TypertAnalysisError(parsed.errors.map(formatDiagnostic).join('\n'))
return { path, parsed } return { path, parsed }
} }
@@ -10,6 +10,10 @@ import { WorkspaceTypertGenerator } from '../src/workspace.ts'
const fixtureRoot = resolve(import.meta.dirname, 'fixtures/remote-model') const fixtureRoot = resolve(import.meta.dirname, 'fixtures/remote-model')
const temporaryRoots: string[] = [] const temporaryRoots: string[] = []
function normalizedPath(path: string): string {
return path.replaceAll('\\', '/')
}
interface RuntimeSchema { interface RuntimeSchema {
safeParse(value: unknown): { readonly success: boolean } safeParse(value: unknown): { readonly success: boolean }
} }
@@ -646,7 +650,8 @@ void navigated
const navigation = 'ctx.remote.goals.create' const navigation = 'ctx.remote.goals.create'
const position = consumerSource.indexOf(navigation) + navigation.lastIndexOf('create') + 1 const position = consumerSource.indexOf(navigation) + navigation.lastIndexOf('create') + 1
const definitions = languageService.getDefinitionAtPosition(consumerPath, position) const definitions = languageService.getDefinitionAtPosition(consumerPath, position)
const generatedDefinition = definitions?.find(candidate => candidate.fileName === declarationPath) const generatedDefinition = definitions?.find(candidate =>
normalizedPath(candidate.fileName) === normalizedPath(declarationPath))
if (generatedDefinition === undefined) { if (generatedDefinition === undefined) {
throw new Error(`generated Remote definition not found: ${JSON.stringify(definitions, null, 2)}`) throw new Error(`generated Remote definition not found: ${JSON.stringify(definitions, null, 2)}`)
} }
@@ -661,7 +666,7 @@ void navigated
pos: generatedDefinition.textSpan.start, pos: generatedDefinition.textSpan.start,
}) })
languageService.dispose() languageService.dispose()
if (definition === undefined || !definition.fileName.endsWith('/packages/remote/src/index.ts')) { if (definition === undefined || !normalizedPath(definition.fileName).endsWith('/packages/remote/src/index.ts')) {
throw new Error(`generated Remote definition did not map to its Host source: ${JSON.stringify(definition)}`) throw new Error(`generated Remote definition did not map to its Host source: ${JSON.stringify(definition)}`)
} }
const hostSource = readFileSync(join(consumerRoot, 'packages/remote/src/index.ts'), 'utf8') const hostSource = readFileSync(join(consumerRoot, 'packages/remote/src/index.ts'), 'utf8')
@@ -18,6 +18,11 @@ import { WorkspaceTypertGenerator } from '../src/workspace.ts'
const fixtureRoot = resolve(import.meta.dirname, 'fixtures/type-model') const fixtureRoot = resolve(import.meta.dirname, 'fixtures/type-model')
const temporaryRoots: string[] = [] const temporaryRoots: string[] = []
function normalizedPath(path: string): string {
return path.replaceAll('\\', '/')
}
const parseConfigHost: ts.ParseConfigFileHost = { const parseConfigHost: ts.ParseConfigFileHost = {
...ts.sys, ...ts.sys,
onUnRecoverableConfigFileDiagnostic(diagnostic) { onUnRecoverableConfigFileDiagnostic(diagnostic) {
@@ -733,8 +738,8 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => {
rootNames: packageConfig.fileNames, rootNames: packageConfig.fileNames,
options: aggregateConfig.options, options: aggregateConfig.options,
}) })
expect(diagnosticProgram.getSourceFiles().map(source => source.fileName)) expect(diagnosticProgram.getSourceFiles().map(source => normalizedPath(source.fileName)))
.toContain(join(externalRoot, 'index.d.ts')) .toContain(normalizedPath(join(externalRoot, 'index.d.ts')))
const targets = new WorkspaceAnalyzer({ root }).analyze().faces const targets = new WorkspaceAnalyzer({ root }).analyze().faces
.flatMap(face => face.graph.nodes) .flatMap(face => face.graph.nodes)
+2 -2
View File
@@ -104,7 +104,7 @@ describe('change-scope', () => {
const headSha = commit(root, 'feature.txt', 'feature\n') const headSha = commit(root, 'feature.txt', 'feature\n')
const fresh = jsonReport(root, 'origin/master') const fresh = jsonReport(root, 'origin/master')
expect(fresh.repositoryRoot).toBe(realpathSync(root)) expect(realpathSync.native(fresh.repositoryRoot)).toBe(realpathSync.native(root))
expect(fresh.resolved).toEqual({ expect(fresh.resolved).toEqual({
baseSha: git(root, ['rev-parse', 'origin/master']), baseSha: git(root, ['rev-parse', 'origin/master']),
headSha, headSha,
@@ -122,7 +122,7 @@ describe('change-scope', () => {
const { root } = fixture('worktree ') const { root } = fixture('worktree ')
const report = jsonReport(root, 'HEAD') const report = jsonReport(root, 'HEAD')
expect(report.repositoryRoot).toBe(realpathSync(root)) expect(realpathSync.native(report.repositoryRoot)).toBe(realpathSync.native(root))
expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] }) expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
}) })
+1 -1
View File
@@ -587,7 +587,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
expect(result.status).toBe(1) expect(result.status).toBe(1)
expect(result.stderr).toContain('sibling dormant worktree config') expect(result.stderr).toContain('sibling dormant worktree config')
expect(result.stderr).toContain(linkedConfig) expect(result.stderr).toContain(JSON.stringify(linkedConfig))
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1) expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1)
expect(git(fixture, fixture.main, ['config', '--file', linkedConfig, '--get', 'core.hooksPath'])).toBe(linkedHooks) expect(git(fixture, fixture.main, ['config', '--file', linkedConfig, '--get', 'core.hooksPath'])).toBe(linkedHooks)
+1 -1
View File
@@ -208,7 +208,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
rm(configPath, { force: true }), rm(configPath, { force: true }),
]) ])
} }
}) }, 20_000)
it('accepts an ignored-only staged selection', () => { it('accepts an ignored-only staged selection', () => {
const result = runOxlint([ const result = runOxlint([