From 46b89ea074e0cd248302cb6ebf543f57386fa257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E4=B8=8D=E8=AF=AD?= <2364309541@qq.com> Date: Wed, 16 Sep 2026 21:01:34 +0800 Subject: [PATCH 1/2] Hold the guards and the build on Windows, and add CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six shipped guards and the release build fail on a Windows checkout, and nothing in the repository says so because no workflow runs them. A Linux-only job cannot see any of these, which is how they reached a release. Path portability - Add `.gitattributes` pinning `* text=auto eol=lf`. Git for Windows defaults to `core.autocrlf=true`, so a clean clone checked every text file out as CRLF and the byte-comparison guards failed on it: LICENSE is pinned by length (11558 vs 11357 — one byte per line) and by SHA-256, NOTICE's copyright line carried a trailing `\r`, and public/_headers, vercel.json, the index.html CSP meta and docs/ARCHITECTURE.md are all compared as text. Ten tests across five files failed on a fresh clone for this reason alone. `licenses/**` is excluded: those files are copied verbatim from the shipped packages and several upstream LICENSE files are CRLF, so normalising them would make the committed copies differ from what `npm run legal:licenses` produces. - `legal-pages-core`: `writeAll` is async and copies `licenses/` with `fs/promises.cp`. The SYNCHRONOUS recursive copy aborts the process (0xC0000409) whenever the SOURCE path holds any non-ASCII character, so `npm run build` could not complete from a checkout under a path like `E:\项目\…` — it exited 127 with an EMPTY `dist/licenses`, dropping all 185 third-party licence files the legal pages link to, and it killed the build-pages worker outright. `build-legal-pages.mts` awaits it; the tests do too. - `generate-headers`: guard the ESA deployment doc on `docs/internal/` existing. The public snapshot does not carry that tree, so `--check` reported permanent drift and `npm run legal:headers:check` failed on every clone. - `operation-purity` exempted three macro bindings by POSIX suffix while building paths with `path.join`, so on Windows the exemption never applied and the three intended files were reported as offenders. Offender paths are reported POSIX-normalised as well. Diagnostics - `vite.config`: drop `dropConsole`. Oxc's transform is all-or-nothing and cannot tell a library's log from a failure report, so it also removed the editor's own error reports and the console banner `console-banner.ts` prints on purpose. Measured on a production build, a session of boot → draw → generate → 3D → open export emits two console messages and both are the app's own; the library call sites that remain in the bundle do not fire in use. Guards that failed for reasons of their own - `compression-compat` asserted a native stream rejects concatenated gzip members. Browsers do; Node's zlib-backed stream does not, and RFC 1952 allows a gzip file to be a sequence of members. It now pins the invariant that holds either way — the output cap — and documents the divergence rather than assuming one runtime's behaviour. - `root-docs` asserted the changelog was still STAGED (`## [Unreleased]`, no released heading) while the repository has published to v1.1.15. It now pins what holds in both states: at most one staged heading, and every released heading well formed. - Budgets the suite's own comments already called tight are now stated rather than inherited: `object-remove-cost` (sprite construction in jsdom), `build-pages` (a real 185-file licence copy, which these tests never reached before because the crash came first), `generate-operation` (21 full generations, several as a determinism pair), `agent/skills`-adjacent Help chunk in `windows.test.tsx`, and the lazy Help chunk's dialog. - `windows.test.tsx`, `chrome/tour-overlay` and `agent/setup-readiness` read an async surface in a single turn: the menu's rows, a sampler's second endpoint, and a panel handover driven by a fixed number of settle turns. Each now waits for what it asserts. CI - `.github/workflows/ci.yml` runs type-check, tests, build and the drift guards on ubuntu and windows, with actions pinned by SHA and the Node floor read from `.node-version`. Windows is in the matrix on purpose: it is the only place the first two fixes above are observable. Verified on the committing machine (Windows, checkout under `E:\项目\PetitMaker`): `npm run lint` clean; `npm run test:run` 8760 passed, no failures, no crashed workers — the same suite reported 17 failures plus a worker crash before; `npm run build` exits 0 with all 185 licences copied into `dist/licenses`, where the identical command exited 127 with an empty `dist/licenses` before; `stamp:check`, `docs:check`, `legal:licenses:check`, `legal:headers:check` and `legal:validate` all pass. Signed-off-by: 天不语 <2364309541@qq.com> --- .gitattributes | 29 ++++++++ .github/workflows/ci.yml | 70 +++++++++++++++++++ .gitignore | 3 + scripts/build-legal-pages.mts | 2 +- scripts/generate-headers.mts | 17 +++-- scripts/legal-pages-core.mts | 14 +++- .../canvas/object-remove-cost.test.ts | 12 +++- src/__tests__/io/compression-compat.test.ts | 23 +++++- src/__tests__/kit/generate-operation.test.ts | 7 +- src/__tests__/kit/operation-purity.test.ts | 11 ++- src/__tests__/legal/build-pages.test.ts | 49 +++++++------ src/__tests__/legal/root-docs.test.ts | 48 +++++++++---- .../ui/agent/setup-readiness.test.tsx | 10 ++- src/__tests__/ui/chrome/tour-overlay.test.tsx | 3 + src/__tests__/ui/shell/windows.test.tsx | 12 +++- vite.config.ts | 11 ++- 16 files changed, 259 insertions(+), 62 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d2e30cc9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,29 @@ +# Line endings are LF everywhere, whatever the platform's `core.autocrlf` says. +# +# Without this file, a Windows checkout with git's default `core.autocrlf=true` rewrites every +# text file to CRLF, and the guards that compare committed bytes then fail on a clean clone: +# LICENSE/NOTICE are pinned by length and SHA-256 (license-files.test.ts), public/_headers, +# vercel.json and the index.html CSP meta are compared as text (headers-policy.test.ts), and +# docs/ARCHITECTURE.md is compared against a fresh render (reference-doc.test.ts). Tooling that +# reads the same files (`generate-headers`, `generate-reference`, `stamp-build`) is line-ending +# agnostic because of this declaration rather than because every caller normalizes by hand. +* text=auto eol=lf + +# Third-party licence text is copied verbatim from the shipped packages by `npm run legal:licenses`, +# and several upstream LICENSE files are CRLF. Normalising them would make the committed copies +# differ from what the extractor produces, so they are excluded from text conversion. +licenses/** -text + +# Binary assets: never inspected, never converted. +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.onnx binary +*.bin binary +*.gz binary +*.wasm binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..83cd7b48 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +# Type-check, test, drift-check and build on every push to the public branch and every pull request. +# +# Windows is in the matrix deliberately. Two shipped guards fail ONLY on a Windows checkout: +# +# * the byte-comparison guards (LICENSE length + SHA-256, public/_headers, vercel.json, the +# index.html CSP meta, docs/ARCHITECTURE.md against a fresh render) compare committed text, and +# a checkout with git's default `core.autocrlf=true` rewrote every file to CRLF before +# `.gitattributes` pinned `eol=lf`. The guards were red on a clean clone and nothing said so; +# * `kit/operation-purity.test.ts` exempts three macro bindings by suffix, and `path.join` gives +# them backslash separators on Windows, so the exemption silently stopped applying. +# +# A Linux-only job cannot observe either, which is how both reached a release. +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify: + name: verify (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + # The repository's own floor, not a copy of it: `.node-version` is what a local clone reads. + node-version-file: .node-version + cache: npm + + - name: Install + run: npm ci + + - name: Type-check + run: npm run lint + + - name: Test + run: npm run test:run + + - name: Build + run: npm run build + + # Generated artefacts carry their own drift guards, and they read the same committed files the + # tests do — so they run after the build has produced `dist/` for the license/bundle closure + # comparison. `legal:validate` is the release-mode config check `npm run build:release` leads + # with; running it here means a broken legal config fails on the pull request rather than at + # publish time. + - name: Generated-artefact drift + shell: bash + run: | + npm run stamp:check + npm run docs:check + npm run legal:licenses:check + npm run legal:headers:check + npm run legal:validate diff --git a/.gitignore b/.gitignore index 0290159e..a8070fdd 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ tsconfig.tsbuildinfo npm-debug.log* coverage/ +# Build-report side product written into the repo root by security/bundle-report-plugin +.bundle-packages.json + # OS files .DS_Store Thumbs.db diff --git a/scripts/build-legal-pages.mts b/scripts/build-legal-pages.mts index d8db72e4..db8b9942 100644 --- a/scripts/build-legal-pages.mts +++ b/scripts/build-legal-pages.mts @@ -35,7 +35,7 @@ async function main(): Promise { ); } - writeAll(distDir, LEGAL, mode); + await writeAll(distDir, LEGAL, mode); // Cloudflare reads `_redirects`; the Chinese edge serves slashed page paths and receives none. if (LEGAL.canonicalOrigin === DEPLOY_TARGETS.global.canonicalOrigin) { diff --git a/scripts/generate-headers.mts b/scripts/generate-headers.mts index 7c644d7c..8664ffb7 100644 --- a/scripts/generate-headers.mts +++ b/scripts/generate-headers.mts @@ -48,11 +48,16 @@ async function main(): Promise { const nextVercel = stringifyVercelJson(toVercelJson(existingVercel, HEADERS_POLICY)); writeIfChanged(vercelPath, nextVercel, drift, check); - // Deployment-operator copy of the ESA headers. + // Deployment-operator copy of the ESA headers. It lives under docs/internal/, a tree the public + // snapshot does not carry, so the guard is the DIRECTORY's presence: where the internal tree + // exists the doc is written and checked (deleting it is drift); where it does not, the file is + // not this checkout's to assert on and reporting it would fail every run. const esaPath = join(rootDir, 'docs', 'internal', 'deployment', 'esa-headers.md'); - writeIfChanged(esaPath, toEsaDoc(HEADERS_POLICY, { - canonicalOrigin: DEPLOY_TARGETS.cn.canonicalOrigin, legacyOrigins: DEPLOY_TARGETS.cn.legacyOrigins, - }), drift, check); + if (existsSync(join(rootDir, 'docs', 'internal'))) { + writeIfChanged(esaPath, toEsaDoc(HEADERS_POLICY, { + canonicalOrigin: DEPLOY_TARGETS.cn.canonicalOrigin, legacyOrigins: DEPLOY_TARGETS.cn.legacyOrigins, + }), drift, check); + } // index.html — surgical CSP (+ comment) rewrite only. const indexPath = join(rootDir, 'index.html'); @@ -66,10 +71,10 @@ async function main(): Promise { for (const p of drift.paths) console.error(` ${p}`); process.exitCode = 1; } else { - console.log('[generate-headers] up to date (public/_headers, vercel.json, docs/internal/deployment/esa-headers.md, index.html).'); + console.log('[generate-headers] up to date (public/_headers, vercel.json, index.html, and the ESA doc where docs/internal/ exists).'); } } else { - console.log('[generate-headers] wrote public/_headers, vercel.json, docs/internal/deployment/esa-headers.md; rewrote index.html CSP meta.'); + console.log('[generate-headers] wrote public/_headers, vercel.json; rewrote index.html CSP meta (plus the ESA doc where docs/internal/ exists).'); } } diff --git a/scripts/legal-pages-core.mts b/scripts/legal-pages-core.mts index 0a947fdd..e60f425e 100644 --- a/scripts/legal-pages-core.mts +++ b/scripts/legal-pages-core.mts @@ -4,7 +4,13 @@ */ // @ts-ignore - node:fs is untyped here (no @types/node) -import { cpSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +// The async `cp` rather than `cpSync`: on Windows the SYNCHRONOUS recursive copy aborts the process +// (0xC0000409) when the SOURCE path contains any non-ASCII character, so `npm run build` could not +// complete from a checkout under e.g. `E:\项目\…` — it exited non-zero with an empty `dist/licenses`. +// The async implementation is unaffected. See the note in docs/ARCHITECTURE.md. +// @ts-ignore - node:fs/promises is untyped here (no @types/node) +import { cp } from 'node:fs/promises'; // @ts-ignore - node:path is untyped here (no @types/node) import { join } from 'node:path'; @@ -311,8 +317,10 @@ function writeTextFile(distDir: string, relPath: string, contents: string): void /** * Writes all static legal artifacts to `distDir`. * Release mode rejects configuration problems; unresolved content tokens always fail; injected `now` makes security.txt deterministic. + * + * ASYNC because the license copy uses the asynchronous recursive copy — see the import note above. */ -export function writeAll(distDir: string, cfg: LegalConfig, mode: 'release' | 'dev', now: Date = new Date()): void { +export async function writeAll(distDir: string, cfg: LegalConfig, mode: 'release' | 'dev', now: Date = new Date()): Promise { const problems = validateLegalConfig(cfg, mode); if (problems.length > 0) { if (mode === 'release') { @@ -342,7 +350,7 @@ export function writeAll(distDir: string, cfg: LegalConfig, mode: 'release' | 'd const licensesDest = join(distDir, 'licenses'); if (existsSync(licensesSrc)) { mkdirSync(licensesDest, { recursive: true }); - cpSync(licensesSrc, licensesDest, { recursive: true }); + await cp(licensesSrc, licensesDest, { recursive: true }); } else { console.warn(`[legal-pages] licenses/ not found at ${licensesSrc} — run "npm run legal:licenses" first.`); } diff --git a/src/__tests__/canvas/object-remove-cost.test.ts b/src/__tests__/canvas/object-remove-cost.test.ts index 556f1573..c7f19406 100644 --- a/src/__tests__/canvas/object-remove-cost.test.ts +++ b/src/__tests__/canvas/object-remove-cost.test.ts @@ -36,6 +36,12 @@ function instrumentLodMap(layer: ObjectLayer): { map: Map; walk return { map, walks: () => walks }; } +/** Building thousands of sprites through Pixi inside jsdom dominates these tests; the assertions + * themselves only count full-collection walks. The default 5s testTimeout sits below what that + * setup costs on a slower machine, and a timed-out SYNC test keeps running — so the duration it + * reports is the machine's, not the assertion's. Allow the scene construction explicitly. */ +const SETUP_TIMEOUT_MS = 180_000; + describe('ObjectLayer.removeObjects costs the removal, not the map', () => { it('drops exactly the removed ids from the LOD-tracked set', () => { const layer = new ObjectLayer(); @@ -48,7 +54,7 @@ describe('ObjectLayer.removeObjects costs the removal, not the map', () => { expect(map.size).toBe(N - 3); expect(map.has('d10')).toBe(false); expect(map.has('d11')).toBe(true); // untouched neighbours survive - }); + }, SETUP_TIMEOUT_MS); it('never walks the whole collection while removing a handful, at any map size', () => { const N = 4000; @@ -61,7 +67,7 @@ describe('ObjectLayer.removeObjects costs the removal, not the map', () => { for (let i = 0; i < 200; i++) layer.removeObjects([`e${i}`]); expect(walks()).toBe(0); // O(removed): no full pass over the other ~3800 survivors - }); + }, SETUP_TIMEOUT_MS); it('re-adding a removed id (idempotent replace) still costs O(1) per id, not O(map)', () => { const N = 2000; @@ -72,5 +78,5 @@ describe('ObjectLayer.removeObjects costs the removal, not the map', () => { layer.addObjects([place('f5', 5)]); // addObjects removes-then-re-adds an existing id expect(walks()).toBe(0); expect(map.size).toBe(N); // replaced, not duplicated or leaked - }); + }, SETUP_TIMEOUT_MS); }); diff --git a/src/__tests__/io/compression-compat.test.ts b/src/__tests__/io/compression-compat.test.ts index 3aa62a36..1e1700d9 100644 --- a/src/__tests__/io/compression-compat.test.ts +++ b/src/__tests__/io/compression-compat.test.ts @@ -23,12 +23,31 @@ it.each([CompressionMethod.Deflate, CompressionMethod.Gzip])('rejects a corrupt expect(() => decompress(packed, method, limits.maxBytes)).toThrow(); }); -it('rejects concatenated gzip members like native streams and preserves output limits', async () => { +it('bounds concatenated gzip members and preserves output limits', async () => { const packed = compress(bytes, CompressionMethod.Gzip); const pair = new Uint8Array(packed.length * 2); pair.set(packed); pair.set(packed, packed.length); + + // Our own decoder refuses multi-member input outright. expect(() => decompress(pair, CompressionMethod.Gzip, limits.maxBytes)).toThrow(); - await expect(inflate(pair, CompressionMethod.Gzip, limits)).rejects.toThrow(); + + // The PLATFORM stream's multi-member behaviour is runtime-specific: browsers reject the trailing + // member, while Node's zlib-backed stream accepts it — which RFC 1952 permits, since a gzip file + // is a sequence of members. The invariant that must hold on every runtime is the OUTPUT CAP: a + // payload whose expansion passes the caller's limit is refused rather than buffered, whether the + // stream errored or the cap caught it. + const tight = { maxBytes: bytes.length * 2 - 1, maxRatio: 10000 }; + await expect(inflate(pair, CompressionMethod.Gzip, tight)).rejects.toThrow(); + + // Under a cap the payload fits into, a runtime that accepts concatenation returns exactly the two + // members and one that refuses resolves nothing — never a partial or unbounded buffer. + const settled = await inflate(pair, CompressionMethod.Gzip, limits).then( + (value) => value.length, + () => -1, + ); + expect([-1, bytes.length * 2]).toContain(settled); + + // The single-member path is unaffected, and the absolute limit still trips. expect(() => decompress(packed, CompressionMethod.Gzip, 100)).toThrow('safety limit'); }); diff --git a/src/__tests__/kit/generate-operation.test.ts b/src/__tests__/kit/generate-operation.test.ts index b9b6eb26..1cfb8596 100644 --- a/src/__tests__/kit/generate-operation.test.ts +++ b/src/__tests__/kit/generate-operation.test.ts @@ -2,7 +2,7 @@ * One generate implementation, three callers. This is the test that fails if a second one appears: * two contexts, one seed, and the results have to be indistinguishable down to the provenance. */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { generateMap, generateCandidate, clearGenerated } from '../../kit/operations'; import { currentKit } from '../../kit/context'; import { newMap } from '../../kit/operations'; @@ -13,6 +13,11 @@ import { stableSerialize } from './_stable-serialize'; import { CommandType, TerrainType } from '../../core/model/types'; import type { GenerateConfig, GridState, MacroCoord, PlacedObject } from '../../core/model/types'; +// A full island generation is seconds of work and this file runs 21 of them, several as a pair whose +// POINT is that both runs produce identical bytes. The default 5s budget covers one on an idle +// machine and not two on a loaded one, so it is stated rather than inherited. +vi.setConfig({ testTimeout: 60_000 }); + const config = (seed: number): GenerateConfig => ({ algorithm: 'designed', mode: 'mixed', corridorWidth: 1, maxElevation: 4, seed, region: null, richness: 1, diff --git a/src/__tests__/kit/operation-purity.test.ts b/src/__tests__/kit/operation-purity.test.ts index aba31880..52d3ff31 100644 --- a/src/__tests__/kit/operation-purity.test.ts +++ b/src/__tests__/kit/operation-purity.test.ts @@ -32,8 +32,13 @@ function filesUnder(dir: string): string[] { return out; } +/** `filesUnder` builds paths with `path.join`, so the separator is the platform's. Report and + * compare on the POSIX form — otherwise a Windows checkout both fails to exempt the bindings and + * prints backslash paths in the failure message. */ +const toPosix = (file: string): string => file.replace(/\\/g, '/'); + /** The pointer binding receives ToolContext and reports outcomes; macro bodies stay silent. */ -const isToolBinding = (file: string): boolean => ['macros/macro-tool.ts', 'macros/drag-tool.ts', 'macros/spray-tool.ts'].some(binding => file.endsWith(binding)); +const isToolBinding = (file: string): boolean => ['macros/macro-tool.ts', 'macros/drag-tool.ts', 'macros/spray-tool.ts'].some(binding => toPosix(file).endsWith(binding)); describe('operations stay silent', () => { it('never narrates its own result', () => { @@ -48,7 +53,7 @@ describe('operations stay silent', () => { if (isToolBinding(file)) continue; const text = readFileSync(file, 'utf8'); for (const { pattern, why } of FORBIDDEN) { - if (pattern.test(text)) offenders.push(`${relative(resolve(__dirname, '../..'), file)} ${why}`); + if (pattern.test(text)) offenders.push(`${toPosix(relative(resolve(__dirname, '../..'), file))} ${why}`); } } } @@ -63,7 +68,7 @@ describe('operations stay silent', () => { for (const file of filesUnder(root)) { if (isToolBinding(file)) continue; const text = readFileSync(file, 'utf8'); - if (/\buseEditorStore\b/.test(text)) offenders.push(relative(resolve(__dirname, '../..'), file)); + if (/\buseEditorStore\b/.test(text)) offenders.push(toPosix(relative(resolve(__dirname, '../..'), file))); } } expect(offenders).toEqual([]); diff --git a/src/__tests__/legal/build-pages.test.ts b/src/__tests__/legal/build-pages.test.ts index 1a65ec8f..6c21cb2c 100644 --- a/src/__tests__/legal/build-pages.test.ts +++ b/src/__tests__/legal/build-pages.test.ts @@ -222,6 +222,11 @@ describe('securityTxt', () => { describe('writeAll', () => { let dir: string; + // These tests genuinely copy `licenses/` (185 files) through `writeAll` — which they never used to + // reach, because the synchronous recursive copy aborted the worker first. That is real I/O now, and + // the default 5s is not enough for it while the rest of the suite runs in parallel. + const COPY_TIMEOUT_MS = 60_000; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'legal-pages-test-')); }); @@ -230,9 +235,11 @@ describe('writeAll', () => { rmSync(dir, { recursive: true, force: true }); }); - it('produces the expected file tree', () => { + // `writeAll` is async because the license copy uses the asynchronous recursive copy (the + // synchronous one aborts the process on Windows when the source path has non-ASCII characters). + it('produces the expected file tree', async () => { const cfg = fixtureCfg(); - writeAll(dir, cfg, 'dev', new Date('2026-07-15T00:00:00.000Z')); + await writeAll(dir, cfg, 'dev', new Date('2026-07-15T00:00:00.000Z')); expect(existsSync(join(dir, 'privacy', 'index.html'))).toBe(true); expect(existsSync(join(dir, 'zh', 'privacy', 'index.html'))).toBe(true); @@ -247,45 +254,45 @@ describe('writeAll', () => { const robots = readFileSync(join(dir, 'robots.txt'), 'utf8'); expect(robots).toContain('Sitemap: https://example.org/sitemap.xml'); - }); + }, COPY_TIMEOUT_MS); - it('every emitted page file is present for every DocId per pagePlan', () => { + it('every emitted page file is present for every DocId per pagePlan', async () => { const cfg = fixtureCfg(); - writeAll(dir, cfg, 'dev', new Date('2026-07-15T00:00:00.000Z')); + await writeAll(dir, cfg, 'dev', new Date('2026-07-15T00:00:00.000Z')); for (const page of pagePlan()) { const file = join(dir, ...page.path.split('/').filter(Boolean), 'index.html'); expect(existsSync(file), `${page.path}/index.html missing`).toBe(true); } - }); + }, COPY_TIMEOUT_MS); - it('copies licenses/ through', () => { + it('copies licenses/ through', async () => { const cfg = fixtureCfg(); - writeAll(dir, cfg, 'dev', new Date('2026-07-15T00:00:00.000Z')); + await writeAll(dir, cfg, 'dev', new Date('2026-07-15T00:00:00.000Z')); expect(existsSync(join(dir, 'licenses'))).toBe(true); const copied = readdirSync(join(dir, 'licenses')); expect(copied.length).toBeGreaterThan(0); - }); + }, COPY_TIMEOUT_MS); - it('release mode throws when cfg is invalid (validateLegalConfig problems)', () => { + it('release mode throws when cfg is invalid (validateLegalConfig problems)', async () => { const invalid = fixtureCfg({ canonicalOrigin: '' }); - expect(() => writeAll(dir, invalid, 'release')).toThrow(); - }); + await expect(writeAll(dir, invalid, 'release')).rejects.toThrow(); + }, COPY_TIMEOUT_MS); - it('release mode builds the real LEGAL config cleanly', () => { - expect(() => writeAll(dir, LEGAL, 'release', new Date('2026-07-15T00:00:00.000Z'))).not.toThrow(); - }); + it('release mode builds the real LEGAL config cleanly', async () => { + await expect(writeAll(dir, LEGAL, 'release', new Date('2026-07-15T00:00:00.000Z'))).resolves.toBeUndefined(); + }, COPY_TIMEOUT_MS); - it('dev mode does NOT throw against the real LEGAL config (warnings only)', () => { - expect(() => writeAll(dir, LEGAL, 'dev', new Date('2026-07-15T00:00:00.000Z'))).not.toThrow(); - }); + it('dev mode does NOT throw against the real LEGAL config (warnings only)', async () => { + await expect(writeAll(dir, LEGAL, 'dev', new Date('2026-07-15T00:00:00.000Z'))).resolves.toBeUndefined(); + }, COPY_TIMEOUT_MS); - it('release mode builds a release-valid fixture cleanly, no token deferred', () => { + it('release mode builds a release-valid fixture cleanly, no token deferred', async () => { // No token is deferred: the deployment facts are authored directly into // privacy.*.md. A release-valid fixture therefore resolves every token, // so writeAll must NOT throw in release mode. const cfg = fixtureCfg(); - expect(() => writeAll(dir, cfg, 'release')).not.toThrow(); - }); + await expect(writeAll(dir, cfg, 'release')).resolves.toBeUndefined(); + }, COPY_TIMEOUT_MS); it('the retired {deployment-facts} token appears on no privacy page', () => { const cfg = fixtureCfg(); diff --git a/src/__tests__/legal/root-docs.test.ts b/src/__tests__/legal/root-docs.test.ts index 91ecb935..c2cd175d 100644 --- a/src/__tests__/legal/root-docs.test.ts +++ b/src/__tests__/legal/root-docs.test.ts @@ -13,6 +13,19 @@ const read = (p: string) => readFileSync(p, 'utf8'); // Count `##`/`###`/… headings in a markdown slice. const headingCount = (md: string) => (md.match(/^#{2,}\s/gm) ?? []).length; +/* + * Changelog state helpers. `scripts/changelog-core.mts` rewrites the staged heading + * (`## [Unreleased]` / `## [未发布]`) into a released one (`## [x.y.z] - date`), so a changelog is + * legitimately in one of two states and moves between them on every publish: STAGED (pending work + * sits under the staged heading) or PUBLISHED (that heading has already become a release). Pin what + * must hold in BOTH — a single staged section at most, and well-formed released headings — rather + * than the repository's current release stage, which the guards below cannot see. + */ +const STAGED_HEADING = /^##\s*\[(?:Unreleased|未发布)\]\s*$/gm; +const RELEASED_HEADING = /^##\s*\[\d+\.\d+\.\d+\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$/gm; +const stagedHeadings = (md: string) => md.match(STAGED_HEADING) ?? []; +const releasedHeadings = (md: string) => md.match(RELEASED_HEADING) ?? []; + describe('SECURITY.md — reporting policy', () => { const SEC = read('SECURITY.md'); @@ -221,10 +234,12 @@ describe('CHANGELOG.zh-CN.md — authored equivalent', () => { }); it('carries the staged section the publish step rewrites into a release', () => { - // changelog-core.mts matches this heading in either language; losing it would publish - // a Chinese changelog with no release notes at all. Its mechanics are pinned in - // scripts/__tests__/release.test.mts. - expect(ZH).toMatch(/^##\s*\[未发布\]\s*$/m); + // changelog-core.mts matches this heading in either language; losing it while staged work + // remains would publish a Chinese changelog with no release notes at all. Its mechanics are + // pinned in scripts/__tests__/release.test.mts. A published changelog has already spent it, so + // the guard is "at most one, and never none of both states". + expect(stagedHeadings(ZH).length).toBeLessThanOrEqual(1); + expect(stagedHeadings(ZH).length + releasedHeadings(ZH).length).toBeGreaterThan(0); }); }); @@ -249,23 +264,26 @@ describe('CONTRIBUTING.md — DCO + inbound=outbound', () => { }); }); -describe('CHANGELOG.md — Keep a Changelog, one unpublished section', () => { +describe('CHANGELOG.md — Keep a Changelog, one staged section', () => { const CL = read('docs/CHANGELOG.md'); it('follows Keep a Changelog and links the format', () => { expect(CL).toContain('Keep a Changelog'); }); - it('carries the Unreleased section the publish step rewrites into a release', () => { - // changelog-core.mts turns this heading into `## [version] - date`, so losing it would - // publish a changelog with no release notes at all. - expect(CL).toMatch(/^##\s*\[Unreleased\]\s*$/m); - }); - - it('holds no released section yet, so a first publish reads as one release', () => { - // Every `## [x.y.z]` here would be carried forward alongside the new release. The public - // repository has none published, so a seeded version would show up as a second entry. - expect(CL.match(/^##\s*\[\d/gm)).toBeNull(); + it('carries the staged section the publish step rewrites into a release', () => { + // changelog-core.mts turns this heading into `## [version] - date`, so a second staged heading + // would be rewritten ambiguously, and a changelog with neither state has no notes to publish. + expect(stagedHeadings(CL).length).toBeLessThanOrEqual(1); + expect(stagedHeadings(CL).length + releasedHeadings(CL).length).toBeGreaterThan(0); + }); + + it('marks every released section with a version and a date', () => { + // The publish rewrite is the only thing that introduces released headings, and it writes the + // date with the version. A numeric heading that is not `## [x.y.z] - YYYY-MM-DD` (a bare + // `## [1.2]`, a missing date) would leave the next publish without a date to order by. + const numericHeadings = CL.match(/^##\s*\[\d[^\]]*\]/gm) ?? []; + expect(numericHeadings.length).toBe(releasedHeadings(CL).length); }); }); diff --git a/src/__tests__/ui/agent/setup-readiness.test.tsx b/src/__tests__/ui/agent/setup-readiness.test.tsx index 32dec1fd..480e97e7 100644 --- a/src/__tests__/ui/agent/setup-readiness.test.tsx +++ b/src/__tests__/ui/agent/setup-readiness.test.tsx @@ -1,7 +1,7 @@ /** Setup validates credentials; management owns every model choice and completion. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { useState } from 'react'; -import { render, fireEvent, act, screen } from '@testing-library/react'; +import { render, fireEvent, act, screen, waitFor } from '@testing-library/react'; import { MotionConfig } from 'framer-motion'; import { I18nProvider } from '../../../i18n/context'; import { useEditorStore } from '../../../state/store'; @@ -637,7 +637,13 @@ describe('the exits', () => { // card that takes a typed id — no press in between. expect(screen.getByTestId('setup-screen').getAttribute('data-phase')).toBe('checking'); await settle(); - expect(screen.queryByTestId('setup-screen'), 'the card has the zone now').toBeNull(); + // `settle` runs a FIXED number of turns, so on a loaded machine it can finish before this chain + // of awaited probes has taken the zone; wait for the handover itself, which is what the + // assertion is about. + await waitFor( + () => expect(screen.queryByTestId('setup-screen'), 'the card has the zone now').toBeNull(), + { timeout: 20_000 }, + ); expect(screen.getByTestId('manage-screen')).toBeTruthy(); // The card's own fallback for an endpoint that lists nothing: the id, typed. diff --git a/src/__tests__/ui/chrome/tour-overlay.test.tsx b/src/__tests__/ui/chrome/tour-overlay.test.tsx index 12fe91d3..33f82022 100644 --- a/src/__tests__/ui/chrome/tour-overlay.test.tsx +++ b/src/__tests__/ui/chrome/tour-overlay.test.tsx @@ -788,6 +788,9 @@ describe('TourOverlay', () => { fireEvent.click(cardButton('Next')); // -> camera, still the same card: copy and height cross over await waitFor(() => expect(shownStep()).toBe(2)); await waitFor(() => expect(box().style.height).toBe('120px')); + // Let the sampler observe the second endpoint: it reads once per frame, and the waitFor above + // can resolve between two frames, in which case stopping here records the first one only. + await frames(2); heightSampler.stop(); copySampler.stop(); // Reduced motion collapses both tweens to duration 0: the height is only ever seen at its two diff --git a/src/__tests__/ui/shell/windows.test.tsx b/src/__tests__/ui/shell/windows.test.tsx index d5b6a40b..115d776c 100644 --- a/src/__tests__/ui/shell/windows.test.tsx +++ b/src/__tests__/ui/shell/windows.test.tsx @@ -68,6 +68,10 @@ describe('the menu', () => { fireEvent.click(screen.getByRole('button', { name: 'Open menu' })); const menu = await screen.findByRole('menu'); expect(menu).toBeTruthy(); + // The container can be read for a frame before its rows are all in place, so wait for the set + // the assertion is about rather than sampling the DOM once. The wait is sized for a worker + // running the whole suite in parallel — the same budget this file's own `vi.setConfig` uses. + await waitFor(() => expect(screen.getAllByRole('menuitem')).toHaveLength(SHEET.length), { timeout: 20_000 }); const items = screen.getAllByRole('menuitem'); expect(items.map((el) => el.textContent)).toEqual(SHEET); }); @@ -79,8 +83,8 @@ describe('the menu', () => { fireEvent.click(screen.getByRole('button', { name: 'Open menu' })); await screen.findByRole('menu'); + await waitFor(() => expect(screen.getAllByRole('menuitem')).toHaveLength(ROWS.length), { timeout: 20_000 }); expect(screen.queryByRole('menuitem', { name: 'Clear generated' })).toBeNull(); - expect(screen.getAllByRole('menuitem')).toHaveLength(ROWS.length); }); it.each(ROWS)('the %s row opens its window', async (label, modal) => { @@ -88,7 +92,7 @@ describe('the menu', () => { fireEvent.click(screen.getByRole('button', { name: 'Open menu' })); await screen.findByRole('menu'); - fireEvent.click(screen.getByRole('menuitem', { name: label })); + fireEvent.click(await screen.findByRole('menuitem', { name: label })); expect(useEditorStore.getState().modals[modal]).toBe(true); // The sheet is a menu, not a window: choosing from it puts it away. await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()); @@ -142,8 +146,10 @@ describe('the shell puts every window on screen', () => { // demanding it in the same commit. // The Help chunk also builds its welcome figures on arrival, which under a loaded suite can // outlast the default query window; the wait is generous rather than the assertion loose. + // Measured: on a machine running the whole suite in parallel this chunk has taken longer than + // 5s to produce its dialog, so the wait is sized for a loaded worker rather than an idle one. for (const [label] of ROWS) { - expect((await screen.findAllByRole('dialog', { name: label }, { timeout: 5000 })).length).toBeGreaterThan(0); + expect((await screen.findAllByRole('dialog', { name: label }, { timeout: 20_000 })).length).toBeGreaterThan(0); } act(() => { for (const [, id] of ROWS) useEditorStore.getState().setModal(id, false); }); }); diff --git a/vite.config.ts b/vite.config.ts index fefb7b1f..561042cb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -185,8 +185,15 @@ export default defineConfig(({ mode }) => { // No inline module-preload polyfill → no inline