storebound/tools/ci-plan.ts
CRT 8ffc83bb39 console fill: walk the whole form; the owner only reviews and presses the final button
App Privacy stopped at "now click Get Started yourself" and every page
asked the owner to click Next. The owner should not click what
storebound can safely do.

- Every button of every console form is classified (navigation,
  draft-save, final-submit) in FORM_BUTTONS (cli/lib/console-forms.ts),
  citing Apple's and Google's help pages. A scoped flow gate
  (flowAllowed) lets navigation through; the generic click gate stays
  absolute for field clicks.
- App Privacy: Get Started, Edit next to Data Types (never the Privacy
  Policy Edit), each data type / "Set Up <type>", Next, and the Save
  inside the questionnaire dialogs (a draft: Apple publishes only after
  Publish, per "Manage app privacy"). Each type's dialog may be one page
  or purposes > Next > linked > Next > tracking > Save. It ends with
  "review the summary, then press Publish yourself". Publish is never
  clicked.
- Play content rating and target audience: Next by itself. Save draft,
  Submit, the final Save, Create and Create app stay the owner's.
- An uncertain answer stops the walk on its page (outlined, "answer
  needed on ...") until the owner has set it; then it goes on.

Fixture apple-app-privacy-flow.html (first time, ?existing=1, decoys);
tests/console-flow.test.ts with the guardrail that no log line asks the
owner to click a navigation control.
2026-10-12 06:51:01 +08:00

194 lines
9.5 KiB
TypeScript

// The planning half of tools/ci.ts: which test files exist, how they are split into shards, which tests a change
// touches (ci:quick) and whether the desktop Rust host needs to run. Pure functions plus small file / git reads;
// tests/ci-plan.test.ts covers them.
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { cpus } from 'node:os';
import { dirname, join, normalize, relative } from 'node:path';
export const ROOT = join(import.meta.dir, '..');
/** Recorded per-file test durations (seconds), written after every run. Gitignored (node_modules/). */
export const TIMINGS = join(ROOT, 'node_modules/.cache/storebound-ci/test-timings.json');
/** Suites that drive a real Chromium (or a heavy child process): each gets a shard of its own, never another heavy
* suite next to it, so they don't fight for CPU and time out. */
export const HEAVY = [
'tests/console-fill.test.ts', 'tests/site-overflow.test.ts', 'tests/local-fonts.test.ts', 'tests/glyphs.test.ts',
'tests/capture-web.test.ts', 'tests/console-session.test.ts', 'tests/console-persist.test.ts', 'tests/console-flow.test.ts',
];
/** Changes that can affect every test: run the whole suite. */
export const GLOBAL = /^(package\.json|bun\.lock|bunfig\.toml|tsconfig\.json|tests\/helpers\/preload\.ts)$/;
const SKIP_DIRS = new Set(['node_modules', '.git', '.scratch', 'dist', 'target', 'fixtures', '.claude']);
/** Every *.test.ts / *.test.tsx `bun test` would pick up, relative to the root, sorted. */
export function listTests(root = ROOT): string[] {
const out: string[] = [];
const walk = (dir: string) => {
for (const e of readdirSync(join(root, dir), { withFileTypes: true })) {
const p = dir ? `${dir}/${e.name}` : e.name;
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) walk(p); }
else if (/\.test\.tsx?$/.test(e.name)) out.push(p);
}
};
walk('');
return out.sort();
}
/** Shard count: CPU cores - 1, at least 1, at most 8 (STOREBOUND_CI_SHARDS overrides). */
export function shardCount(env = process.env, cores = cpus().length): number {
const n = Number(env.STOREBOUND_CI_SHARDS);
if (Number.isInteger(n) && n > 0) return n;
return Math.max(1, Math.min(8, cores - 1));
}
export function readTimings(file = TIMINGS): Record<string, number> {
try { return JSON.parse(readFileSync(file, 'utf8')) as Record<string, number>; } catch { return {}; }
}
export function writeTimings(t: Record<string, number>, file = TIMINGS): void {
try { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, `${JSON.stringify(t, null, 1)}\n`); } catch { /* cache only */ }
}
/** Per-file seconds from bun's JUnit report (the top-level <testsuite> of each file). */
export function junitTimes(xml: string): Record<string, number> {
const out: Record<string, number> = {};
for (const m of xml.matchAll(/<testsuite name="([^"]+)" file="([^"]+)"[^>]*?time="([\d.]+)"/g)) {
if (m[1] === m[2]) out[m[2]!] = Number(m[3]);
}
return out;
}
/** Estimated cost of a file: its recorded time, else its size (about 1 s per 40 KB, 0.2 s minimum). */
export function cost(file: string, timings: Record<string, number>, size: (f: string) => number): number {
return timings[file] ?? Math.max(0.2, size(file) / 40_000);
}
/**
* Longest-processing-time-first split into at most n shards. Heavy files each open a shard of their own and never
* share it with another heavy file; light files fill the least loaded shard. Empty shards are dropped.
*/
export function balance(files: string[], n: number, costOf: (f: string) => number, heavy: string[] = HEAVY): string[][] {
const shards = Array.from({ length: Math.max(1, n) }, () => ({ files: [] as string[], load: 0, heavy: false }));
const sorted = [...files].sort((a, b) => costOf(b) - costOf(a) || a.localeCompare(b));
const isHeavy = (f: string) => heavy.includes(f);
for (const f of sorted.filter(isHeavy)) {
const free = shards.filter(s => !s.heavy);
const s = (free.length ? free : shards).reduce((a, b) => (b.load < a.load ? b : a));
s.files.push(f); s.load += costOf(f); s.heavy = true;
}
for (const f of sorted.filter(f => !isHeavy(f))) {
const s = shards.reduce((a, b) => (b.load < a.load ? b : a));
s.files.push(f); s.load += costOf(f);
}
return shards.filter(s => s.files.length).map(s => s.files);
}
export function planShards(files: string[], n = shardCount(), root = ROOT): string[][] {
const t = readTimings();
return balance(files, n, f => cost(f, t, p => { try { return statSync(join(root, p)).size; } catch { return 0; } }));
}
// ---- which tests a change touches ----
const IMPORT_RE = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)['"](\.{1,2}\/[^'"]+)['"]/gm;
const JOIN_RE = /join\(\s*(?:import\.meta\.dir|__dirname|ROOT|root|REPO)\s*,((?:\s*'[^']*'\s*,?)+)\)/g;
function resolveFile(p: string): string | null {
for (const c of [p, `${p}.ts`, `${p}.tsx`, join(p, 'index.ts'), join(p, 'index.tsx')]) {
try { if (statSync(c).isFile()) return c; } catch { /* next */ }
}
return null;
}
/** Repo files a source file depends on: relative imports, plus `join(import.meta.dir, '..', 'cli', 'index.ts')`
* style paths (a test that spawns the CLI depends on it). */
export function depsOf(file: string, root = ROOT, src = readFileSync(join(root, file), 'utf8')): string[] {
const dir = dirname(join(root, file));
const out = new Set<string>();
for (const m of src.matchAll(IMPORT_RE)) {
const r = resolveFile(normalize(join(dir, m[1]!)));
if (r) out.add(relative(root, r));
}
for (const m of src.matchAll(JOIN_RE)) {
const parts = [...m[1]!.matchAll(/'([^']*)'/g)].map(x => x[1]!);
for (const base of [dir, root]) {
const r = resolveFile(normalize(join(base, ...parts)));
if (r && r.startsWith(root)) { out.add(relative(root, r)); break; }
}
}
return [...out];
}
/**
* Tests related to the changed files: a changed test itself, every test that imports a changed file (transitively),
* and every test that names a changed non-code file (fixture, page, skill) by its file name or its fixture folder.
* A change to a GLOBAL file returns every test. Files in `near` (the browser suites, by default none) count as
* related only through imports at most NEAR_DEPTH steps away: nearly every module reaches cli/lib/config.ts and the
* store clients, and ci:quick should not start a browser for every change there (the full ci runs them).
*/
export const NEAR_DEPTH = 2;
export function relatedTests(changed: string[], tests: string[], root = ROOT, near: string[] = []): string[] {
if (changed.some(f => GLOBAL.test(f))) return tests;
const changedSet = new Set(changed);
const direct = new Map<string, string[]>();
const depsCached = (f: string) => {
let d = direct.get(f);
if (!d) { try { d = depsOf(f, root); } catch { d = []; } direct.set(f, d); }
return d;
};
/** Every file a test reaches, with its import distance. */
const closure = (f: string): Map<string, number> => {
const depth = new Map<string, number>([[f, 0]]);
const queue = [f];
while (queue.length) {
const cur = queue.shift()!;
if (!/\.(ts|tsx)$/.test(cur)) continue;
for (const d of depsCached(cur)) if (!depth.has(d)) { depth.set(d, depth.get(cur)! + 1); queue.push(d); }
}
return depth;
};
const names = changed.filter(f => !/\.(ts|tsx)$/.test(f) || f.includes('/fixtures/')).flatMap(f => {
const base = f.split('/').pop()!;
const fx = /(?:^|\/)fixtures\/([^/]+)/.exec(f)?.[1];
return [base, ...(fx ? [`fixtures/${fx}`, `'${fx}'`] : [])].filter(s => s.length > 3);
});
return tests.filter(t => {
if (changedSet.has(t)) return true;
const all = closure(t);
const max = near.includes(t) ? NEAR_DEPTH : Infinity;
if (changed.some(c => all.has(c) && all.get(c)! <= max)) return true;
if (!names.length) return false;
const srcs = [...all.keys()].filter(d => d.startsWith('tests/') || d.startsWith('apps/desktop/tests/')).map(d => {
try { return readFileSync(join(root, d), 'utf8'); } catch { return ''; }
}).join('\n');
return names.some(n => srcs.includes(n));
});
}
// ---- git ----
function git(args: string[], root = ROOT): string | null {
const r = Bun.spawnSync(['git', ...args], { cwd: root, stdout: 'pipe', stderr: 'ignore' });
return r.exitCode === 0 ? r.stdout.toString() : null;
}
/** Files changed against origin/main (committed since the merge base, staged, unstaged and untracked), or null
* when that cannot be told (no git, no origin/main). */
export function changedFiles(base = process.env.STOREBOUND_CI_BASE ?? 'origin/main', root = ROOT): string[] | null {
const mb = git(['merge-base', base, 'HEAD'], root)?.trim();
if (!mb) return null;
const lists = [git(['diff', '--name-only', mb], root), git(['ls-files', '--others', '--exclude-standard'], root)];
if (lists.some(l => l === null)) return null;
return [...new Set(lists.join('\n').split('\n').map(s => s.trim()).filter(Boolean))].sort();
}
/** Desktop Rust host inputs: the crate, and the sidecar build its build script needs (cargo test / clippy never run
* the sidecar itself, so a CLI change does not need them; tools/ci.ts rebuilds the sidecar on CLI changes). */
export const RUST_INPUTS = /^(apps\/desktop\/src-tauri\/|tools\/build-sidecars\.ts$)/;
/** Run cargo test / clippy? Always with CI=full or when the change set is unknown; else only when a Rust input changed. */
export function rustNeeded(changed: string[] | null, env = process.env): boolean {
if (env.CI === 'full' || changed === null) return true;
return changed.some(f => RUST_INPUTS.test(f));
}