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..f1a32f86 100644 --- a/src/__tests__/canvas/object-remove-cost.test.ts +++ b/src/__tests__/canvas/object-remove-cost.test.ts @@ -11,7 +11,7 @@ * at zero, however many decorations sit on the map. */ import './_pixi-env'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { ObjectLayer } from '../../canvas/map2d/layers/object-layer'; import { type PlacedObject } from '../../core/model/types'; import { getCatalogItem, registerCatalogItem } from '../../state/catalog'; @@ -36,6 +36,14 @@ 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. Measured on an idle machine the three cases take + * 117ms, 823ms and 396ms, so the default 5s is not the problem — a LOADED worker is, where all + * three timed out while the whole suite ran in parallel. Stated rather than inherited, at the + * budget the repository's other heavy suites already use (`vi.setConfig({ testTimeout: 60_000 })` + * in the generation suites). */ +vi.setConfig({ testTimeout: 60_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(); diff --git a/src/__tests__/io/compression-compat.test.ts b/src/__tests__/io/compression-compat.test.ts index 3aa62a36..2cdec666 100644 --- a/src/__tests__/io/compression-compat.test.ts +++ b/src/__tests__/io/compression-compat.test.ts @@ -23,12 +23,38 @@ 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(); + + // The PLATFORM decoder answers differently per runtime, and both answers are legal: RFC 1952 makes + // a gzip file a SEQUENCE of members, so Node's zlib-backed stream reads the whole sequence and + // returns both members (measured: Node 22.23.1 returns 2x the payload), while the browser's stream + // stops at the first member and errors on the trailing bytes. This suite runs under Node, so that + // is the expectation pinned here — and the block below drives the no-platform path, which is the + // one a browser without Compression Streams takes, and pins its answer too. + const both = await inflate(pair, CompressionMethod.Gzip, limits); + expect(both.length).toBe(bytes.length * 2); + expect(both.subarray(0, bytes.length)).toEqual(bytes); + expect(both.subarray(bytes.length)).toEqual(bytes); + + // No platform streams: the fallback decoder answers, and it must refuse rather than hand back a + // partial buffer. + vi.stubGlobal('CompressionStream', undefined); + vi.stubGlobal('DecompressionStream', undefined); await expect(inflate(pair, CompressionMethod.Gzip, limits)).rejects.toThrow(); + vi.unstubAllGlobals(); + + // A payload whose expansion passes the caller's cap is refused instead of buffered. One member + // large enough to trip it, so the check holds on either path. + const oversized = compress(new Uint8Array(limits.maxBytes + 1), CompressionMethod.Gzip); + await expect(inflate(oversized, CompressionMethod.Gzip, limits)).rejects.toThrow(); + + // 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..af0fad7c 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,12 @@ 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 the expensive operation in this file and it runs 21 of them, several +// as a pair whose POINT is that both runs produce identical bytes. Measured: one case here takes +// 584ms-1246ms on an idle machine, so it is a pair on a loaded worker that passes the default 5s +// rather than a single run. 60s is the budget the repository's other generation suites already use. +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..307c7324 100644 --- a/src/__tests__/legal/build-pages.test.ts +++ b/src/__tests__/legal/build-pages.test.ts @@ -1,12 +1,12 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // @ts-ignore - node:fs is untyped here (no @types/node) -import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; // @ts-ignore - node:os is untyped here (no @types/node) import { tmpdir } from 'node:os'; // @ts-ignore - node:path is untyped here (no @types/node) import { join } from 'node:path'; // @ts-ignore -declare const process: { cwd(): string; env: Record }; +declare const process: { cwd(): string; chdir(dir: string): void; env: Record }; import type { LegalConfig } from '../../legal/config'; import { LEGAL } from '../../legal/config'; @@ -26,6 +26,13 @@ import { resolveMode, } from '../../../scripts/legal-pages-core.mts'; +// `writeAll` genuinely drives the license copy (`licenses/`, 185 files), which these tests never +// used to reach — the synchronous recursive copy aborted the worker before it got there. The +// complete-tree check measures ~1.4s on an idle machine; the budget is stated rather than inherited +// because that copy is real file I/O competing with every other file in a parallel run, and 60s is +// the value this repository's other heavy suites already use. +vi.setConfig({ testTimeout: 60_000 }); + // The static legal-page generator (crawlable zero-JS pages + // sitemap/robots/security.txt). @@ -230,9 +237,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); @@ -249,42 +258,86 @@ describe('writeAll', () => { expect(robots).toContain('Sitemap: https://example.org/sitemap.xml'); }); - 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); } }); - it('copies licenses/ through', () => { + /** Every file under `root`, as slash-joined relative paths, sorted — what "the copy is complete" + * means, as opposed to "the destination exists". */ + function filesUnder(root: string, prefix = ''): string[] { + const out: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) out.push(...filesUnder(join(root, entry.name), rel)); + else out.push(rel); + } + return out.sort(); + } + + it('copies licenses/ through, in full', 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); + // The same tree with the same bytes, not just a non-empty directory. + const source = join(process.cwd(), 'licenses'); + const relative = filesUnder(source); + expect(relative.length).toBeGreaterThan(0); + expect(filesUnder(join(dir, 'licenses'))).toEqual(relative); + for (const rel of relative) { + expect(readFileSync(join(dir, 'licenses', rel)), rel).toEqual(readFileSync(join(source, rel))); + } + }); + + it('copies licenses/ completely when the checkout path is not ASCII', async () => { + // The synchronous recursive copy this used to run ABORTS the process (0xC0000409) on Windows + // when the SOURCE path holds a non-ASCII character, so `npm run build` from a checkout under + // e.g. `E:\项目\...` exited non-zero with an empty `dist/licenses` and no error message. The + // copy below is driven from a non-ASCII working directory, which is the condition; the + // assertions are about the complete result — same tree, same bytes — rather than that the call + // returned. Windows + Node 24 in CI runs this, as does any local checkout under such a path. + const root = mkdtempSync(join(tmpdir(), 'legal-pages-nonascii-')); + const checkout = join(root, '项目', 'petit-星布谷地'); + const src = join(checkout, 'licenses'); + mkdirSync(join(src, 'nested'), { recursive: true }); + writeFileSync(join(src, 'MIT.txt'), 'MIT — 麻省理工'); + writeFileSync(join(src, 'nested', 'Apache-2.0.txt'), 'Apache-2.0'); + const cwd = process.cwd(); + try { + process.chdir(checkout); // writeAll resolves the license source against the working directory + await writeAll(dir, fixtureCfg(), 'dev', new Date('2026-07-15T00:00:00.000Z')); + } finally { + process.chdir(cwd); // restored before the temp tree goes, so nothing holds it open + rmSync(root, { recursive: true, force: true }); + } + expect(filesUnder(join(dir, 'licenses'))).toEqual(['MIT.txt', 'nested/Apache-2.0.txt']); + expect(readFileSync(join(dir, 'licenses', 'MIT.txt'), 'utf8')).toBe('MIT — 麻省理工'); + expect(readFileSync(join(dir, 'licenses', 'nested', 'Apache-2.0.txt'), 'utf8')).toBe('Apache-2.0'); }); - 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(); }); - 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(); }); - 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(); }); - 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(); }); it('the retired {deployment-facts} token appears on no privacy page', () => { 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..09ccbb81 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,15 @@ 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. Measured: this case takes ~2.3s on an idle machine, so the fixed turn + // count is already near the wall before any load, and 20s matches the waits this repository's + // other UI suites use. + 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..11949559 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,11 @@ 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. + // Observed: at the default 5s this row timed out while the suite ran in parallel, in a run where + // a neighbouring case in this same file measured 8450ms. 20s matches the waits this repository's + // other UI suites already use. 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); }); });