mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
feat(test-free): --files-from runs exactly the listed planned files
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
c10c139ed6
commit
79058a8fff
3 files changed
+157
-4
No files matched your search
@@ -73,6 +73,7 @@
|
||||
* bun run scripts/test-free-shards.ts --wall-timeout 600 # override the kill deadline
|
||||
* bun run scripts/test-free-shards.ts --verbose # forward the full child stream
|
||||
* bun run scripts/test-free-shards.ts --quick # explicit fast subset, not acceptance
|
||||
* bun run scripts/test-free-shards.ts --files-from list.txt # exactly these planned files (one per line)
|
||||
* bun run scripts/test-free-shards.ts --attribute-home # name real-home writers: each file alone, private HOME
|
||||
* bun run scripts/test-free-shards.ts --ci-plan plan.json --shards 20
|
||||
* bun run scripts/test-free-shards.ts --ci-run plan.json --shard 1 --result result.json
|
||||
@@ -540,6 +541,7 @@ type CliOptions = {
|
||||
ciVerify: string | null;
|
||||
result: string | null;
|
||||
results: string | null;
|
||||
filesFrom: string | null;
|
||||
};
|
||||
|
||||
export function parseCliOptions(argv: string[]): CliOptions {
|
||||
@@ -554,8 +556,8 @@ export function parseCliOptions(argv: string[]): CliOptions {
|
||||
let wallTimeoutExplicit = false;
|
||||
let quick = false;
|
||||
let attributeHome = false;
|
||||
const paths: Record<'ciPlan' | 'ciRun' | 'ciVerify' | 'result' | 'results', string | null> = {
|
||||
ciPlan: null, ciRun: null, ciVerify: null, result: null, results: null,
|
||||
const paths: Record<'ciPlan' | 'ciRun' | 'ciVerify' | 'result' | 'results' | 'filesFrom', string | null> = {
|
||||
ciPlan: null, ciRun: null, ciVerify: null, result: null, results: null, filesFrom: null,
|
||||
};
|
||||
const pathFlag = (flag: string, key: keyof typeof paths) => (next: () => string | undefined) => {
|
||||
const value = next();
|
||||
@@ -576,6 +578,7 @@ export function parseCliOptions(argv: string[]): CliOptions {
|
||||
'--ci-verify': pathFlag('--ci-verify', 'ciVerify'),
|
||||
'--result': pathFlag('--result', 'result'),
|
||||
'--results': pathFlag('--results', 'results'),
|
||||
'--files-from': pathFlag('--files-from', 'filesFrom'),
|
||||
'--shards': (next) => {
|
||||
const value = next();
|
||||
if (!value) throw new Error('Missing value for --shards');
|
||||
@@ -595,6 +598,7 @@ export function parseCliOptions(argv: string[]): CliOptions {
|
||||
});
|
||||
|
||||
const ciModes = [paths.ciPlan, paths.ciRun, paths.ciVerify].filter(Boolean).length;
|
||||
if (paths.filesFrom && (quick || windowsOnly || listOnly || ciModes || recordDurations || attributeHome)) throw new Error('--files-from cannot be combined with --quick, --windows-only, --list, --record-durations, --attribute-home or the --ci-* modes');
|
||||
if (ciModes > 1 || (ciModes && (quick || listOnly || dryRun || recordDurations || attributeHome))) throw new Error('CI modes cannot be combined with other selection modes');
|
||||
if (paths.ciRun && (shardIndex === null || !paths.result)) throw new Error('--ci-run requires --shard and --result');
|
||||
if (paths.ciVerify && !paths.results) throw new Error('--ci-verify requires --results');
|
||||
@@ -602,6 +606,47 @@ export function parseCliOptions(argv: string[]): CliOptions {
|
||||
return { dryRun, listOnly, recordDurations, windowsOnly, verbose, shardCount, shardIndex, wallTimeoutMs, wallTimeoutExplicit, quick, attributeHome, ...paths };
|
||||
}
|
||||
|
||||
const describeLines = (lines: string[]) => `${lines.slice(0, 5).join(', ')}${lines.length > 5 ? ` (+${lines.length - 5} more)` : ''}`;
|
||||
|
||||
/**
|
||||
* `--files-from <path>`: one repo-relative path per line (LF or CRLF), blank
|
||||
* lines ignored. Rejected before any planning: an unreadable file, a malformed
|
||||
* or repeated line, an empty list, or a path outside the free planned set.
|
||||
*/
|
||||
export function readFilesFrom(file: string, planned: readonly string[]): string[] {
|
||||
let text: string;
|
||||
try {
|
||||
text = fs.readFileSync(file, 'utf8');
|
||||
} catch (error) {
|
||||
throw new Error(`--files-from ${file}: cannot read it (${(error as NodeJS.ErrnoException).code ?? String(error)})`);
|
||||
}
|
||||
const files: string[] = [];
|
||||
const malformed: string[] = [];
|
||||
text.split(/\r?\n/).forEach((line, index) => {
|
||||
if (line === '') return;
|
||||
const segments = line.split('/');
|
||||
if (line !== line.trim() || line.includes('\\') || line.startsWith('/') || /^[A-Za-z]:/.test(line)
|
||||
|| segments.some(segment => segment === '' || segment === '.' || segment === '..')) {
|
||||
malformed.push(`line ${index + 1} ${JSON.stringify(line)}`);
|
||||
} else if (files.includes(line)) {
|
||||
malformed.push(`line ${index + 1} repeats ${line}`);
|
||||
} else {
|
||||
files.push(line);
|
||||
}
|
||||
});
|
||||
if (malformed.length) throw new Error(`--files-from ${file}: ${malformed.length} malformed line(s): ${describeLines(malformed)}; fix: one repo-relative path per line (forward slashes, no surrounding spaces, no ./ or ../, no repeats)`);
|
||||
if (!files.length) throw new Error(`--files-from ${file}: the list is empty (blank lines are ignored); nothing to run`);
|
||||
const plannedSet = new Set(planned);
|
||||
const unknown = files.filter(f => !plannedSet.has(f));
|
||||
if (unknown.length) throw new Error(`--files-from ${file}: ${unknown.length} path(s) not in the free planned set: ${describeLines(unknown)}; fix: use paths from \`bun run scripts/test-free-shards.ts --list\``);
|
||||
return files;
|
||||
}
|
||||
|
||||
/** sha256 of the sorted list, one path per line: the digest a remote gate compares against its upload. */
|
||||
export function filesDigest(files: readonly string[]): string {
|
||||
return createHash('sha256').update([...files].sort().map(f => `${f}\n`).join('')).digest('hex');
|
||||
}
|
||||
|
||||
function formatShardSummary(shards: string[][]): string[] {
|
||||
return shards.map((files, index) => {
|
||||
const preview = files.slice(0, 3).join(', ');
|
||||
@@ -1887,6 +1932,10 @@ async function main(): Promise<number> {
|
||||
}
|
||||
|
||||
let files = allFiles;
|
||||
if (options.filesFrom) {
|
||||
files = readFilesFrom(options.filesFrom, allFiles);
|
||||
console.log(`[test:free] files-from ${options.filesFrom}: ${files.length} files, sha256=${filesDigest(files)}`);
|
||||
}
|
||||
if (options.quick) {
|
||||
const missing = QUICK_CORE.filter(file => !allFiles.includes(file));
|
||||
if (missing.length) throw new Error(`Quick core files missing: ${missing.join(', ')}`);
|
||||
@@ -2015,7 +2064,15 @@ async function main(): Promise<number> {
|
||||
outcomes.push(exclusiveOutcome);
|
||||
}
|
||||
|
||||
return Math.max(homeChange ? 1 : 0, (await retryFailedFreeFiles(outcomes, totalShards, options)).exitCode);
|
||||
const exitCode = Math.max(homeChange ? 1 : 0, (await retryFailedFreeFiles(outcomes, totalShards, options)).exitCode);
|
||||
if (options.filesFrom) {
|
||||
// The machine-read result for a remote gate: tests from each shard's own summary; failed counts failing files, plus one per failed shard with none attributed.
|
||||
const tests = outcomes.reduce((sum, outcome) => sum + (outcome.summary?.testsRan ?? 0), 0);
|
||||
const failing = outcomes.filter(outcome => outcome.status !== 'passed');
|
||||
const failed = exitCode === 0 ? 0 : Math.max(1, new Set(failing.flatMap(outcome => outcome.failingFiles)).size + failing.filter(outcome => !outcome.failingFiles.length).length);
|
||||
console.log(`[test:free] FILES-FROM RESULT: files=${files.length} tests=${tests} failed=${failed} exit=${exitCode}`);
|
||||
}
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
/** Dirty generated artifacts (SKILL.md / host outputs) after a failed exclusive shard. */
|
||||
|
||||
+12
-1
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"recordedFrom": "96764e80 (pre-engine base runners); paid lane re-recorded at v1.91.12.0 for the marathon tier and the --slice-budget, --max-parallel, --case and --trials flags (PR 2999); free lane re-recorded for the --attribute-home flag (PR 3014)",
|
||||
"recordedFrom": "96764e80 (pre-engine base runners); paid lane re-recorded at v1.91.12.0 for the marathon tier and the --slice-budget, --max-parallel, --case and --trials flags (PR 2999); free lane re-recorded for the --attribute-home flag (PR 3014) and the --files-from flag (fix wave 3, W2-A3)",
|
||||
"free": [
|
||||
{
|
||||
"argv": [],
|
||||
"result": {
|
||||
"ok": {
|
||||
"dryRun": false,
|
||||
"filesFrom": null,
|
||||
"listOnly": false,
|
||||
"recordDurations": false,
|
||||
"windowsOnly": false,
|
||||
@@ -33,6 +34,7 @@
|
||||
"result": {
|
||||
"ok": {
|
||||
"dryRun": true,
|
||||
"filesFrom": null,
|
||||
"listOnly": true,
|
||||
"recordDurations": false,
|
||||
"windowsOnly": false,
|
||||
@@ -59,6 +61,7 @@
|
||||
"result": {
|
||||
"ok": {
|
||||
"dryRun": false,
|
||||
"filesFrom": null,
|
||||
"listOnly": true,
|
||||
"recordDurations": false,
|
||||
"windowsOnly": true,
|
||||
@@ -84,6 +87,7 @@
|
||||
"result": {
|
||||
"ok": {
|
||||
"dryRun": false,
|
||||
"filesFrom": null,
|
||||
"listOnly": false,
|
||||
"recordDurations": true,
|
||||
"windowsOnly": false,
|
||||
@@ -109,6 +113,7 @@
|
||||
"result": {
|
||||
"ok": {
|
||||
"dryRun": false,
|
||||
"filesFrom": null,
|
||||
"listOnly": false,
|
||||
"recordDurations": false,
|
||||
"windowsOnly": false,
|
||||
@@ -137,6 +142,7 @@
|
||||
"result": {
|
||||
"ok": {
|
||||
"dryRun": false,
|
||||
"filesFrom": null,
|
||||
"listOnly": false,
|
||||
"recordDurations": false,
|
||||
"windowsOnly": false,
|
||||
@@ -163,6 +169,7 @@
|
||||
"result": {
|
||||
"ok": {
|
||||
"dryRun": false,
|
||||
"filesFrom": null,
|
||||
"listOnly": false,
|
||||
"recordDurations": false,
|
||||
"windowsOnly": false,
|
||||
@@ -191,6 +198,7 @@
|
||||
"result": {
|
||||
"ok": {
|
||||
"dryRun": false,
|
||||
"filesFrom": null,
|
||||
"listOnly": false,
|
||||
"recordDurations": false,
|
||||
"windowsOnly": false,
|
||||
@@ -221,6 +229,7 @@
|
||||
"result": {
|
||||
"ok": {
|
||||
"dryRun": false,
|
||||
"filesFrom": null,
|
||||
"listOnly": false,
|
||||
"recordDurations": false,
|
||||
"windowsOnly": false,
|
||||
@@ -249,6 +258,7 @@
|
||||
"result": {
|
||||
"ok": {
|
||||
"dryRun": false,
|
||||
"filesFrom": null,
|
||||
"listOnly": false,
|
||||
"recordDurations": false,
|
||||
"windowsOnly": false,
|
||||
@@ -399,6 +409,7 @@
|
||||
"result": {
|
||||
"ok": {
|
||||
"dryRun": false,
|
||||
"filesFrom": null,
|
||||
"listOnly": false,
|
||||
"recordDurations": false,
|
||||
"windowsOnly": false,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* W2-A3 (fix wave 3): test-free-shards.ts --files-from <path> plans exactly
|
||||
* the listed files from the free planned set and rejects, before planning, an
|
||||
* unknown path, an empty list or a malformed line. The remote gate
|
||||
* (scripts/ubicloud/pregate-remote.sh) compares the printed digest with its
|
||||
* upload; the full run's FILES-FROM RESULT line is covered there.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { collectFreeTestFiles, filesDigest, parseCliOptions, readFilesFrom } from '../scripts/test-free-shards';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'free-files-from-'));
|
||||
afterAll(() => fs.rmSync(DIR, { recursive: true, force: true }));
|
||||
let n = 0;
|
||||
const list = (text: string) => {
|
||||
const file = path.join(DIR, `list-${++n}.txt`);
|
||||
fs.writeFileSync(file, text);
|
||||
return file;
|
||||
};
|
||||
const PLANNED = ['browse/test/a.test.ts', 'test/b.test.ts', 'test/c.test.ts', 'test/d.test.ts', 'test/e.test.ts', 'test/f.test.ts', 'test/g.test.ts'];
|
||||
|
||||
describe('--files-from parsing', () => {
|
||||
test('one path per line; blank lines and CRLF endings are accepted; order is kept', () => {
|
||||
expect(readFilesFrom(list('test/c.test.ts\r\n\r\nbrowse/test/a.test.ts\n\n'), PLANNED)).toEqual(['test/c.test.ts', 'browse/test/a.test.ts']);
|
||||
});
|
||||
|
||||
test('paths outside the free planned set are rejected with the first five, a count and the --list fix', () => {
|
||||
const file = list(['test/b.test.ts', ...['x1', 'x2', 'x3', 'x4', 'x5', 'x6', 'x7'].map(name => `test/${name}.test.ts`)].join('\n'));
|
||||
expect(() => readFilesFrom(file, PLANNED)).toThrow(
|
||||
`--files-from ${file}: 7 path(s) not in the free planned set: test/x1.test.ts, test/x2.test.ts, test/x3.test.ts, test/x4.test.ts, test/x5.test.ts (+2 more); fix: use paths from \`bun run scripts/test-free-shards.ts --list\``);
|
||||
});
|
||||
|
||||
test('an empty list, an unreadable file and every malformed line shape are rejected', () => {
|
||||
const empty = list('\n\r\n\n');
|
||||
expect(() => readFilesFrom(empty, PLANNED)).toThrow(`--files-from ${empty}: the list is empty (blank lines are ignored); nothing to run`);
|
||||
expect(() => readFilesFrom(path.join(DIR, 'absent.txt'), PLANNED)).toThrow('cannot read it (ENOENT)');
|
||||
const bad = [' test/b.test.ts', 'test/c.test.ts ', './test/d.test.ts', 'test/../test/e.test.ts', '/abs/test/f.test.ts', 'test\\g.test.ts', 'C:/x.test.ts', 'test//b.test.ts'];
|
||||
for (const line of bad) {
|
||||
expect(() => readFilesFrom(list(`test/b.test.ts\n${line}\n`), PLANNED), line).toThrow(`1 malformed line(s): line 2 ${JSON.stringify(line)}; fix: one repo-relative path per line`);
|
||||
}
|
||||
expect(() => readFilesFrom(list('test/b.test.ts\ntest/c.test.ts\ntest/b.test.ts\n'), PLANNED)).toThrow('line 3 repeats test/b.test.ts');
|
||||
});
|
||||
|
||||
test('the digest is the sorted list, so a reordered upload still matches', () => {
|
||||
expect(filesDigest(['test/c.test.ts', 'browse/test/a.test.ts'])).toBe(filesDigest(['browse/test/a.test.ts', 'test/c.test.ts']));
|
||||
expect(filesDigest(['test/c.test.ts'])).not.toBe(filesDigest(['test/c.test.ts', 'test/b.test.ts']));
|
||||
});
|
||||
|
||||
test('it conflicts with --quick, --windows-only, --list, --record-durations, --attribute-home and every --ci-* mode', () => {
|
||||
expect(parseCliOptions(['--files-from', 'x.txt', '--dry-run']).filesFrom).toBe('x.txt');
|
||||
expect(() => parseCliOptions(['--files-from'])).toThrow('Missing path for --files-from');
|
||||
for (const extra of [['--quick'], ['--windows-only'], ['--list'], ['--record-durations'], ['--attribute-home'], ['--ci-plan', 'p.json'], ['--ci-run', 'p.json', '--shard', '1', '--result', 'r.json'], ['--ci-verify', 'p.json', '--results', 'r']]) {
|
||||
expect(() => parseCliOptions(['--files-from', 'x.txt', ...extra]), extra[0]).toThrow('--files-from cannot be combined with --quick, --windows-only, --list, --record-durations, --attribute-home or the --ci-* modes');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('--files-from planning through the CLI', () => {
|
||||
const cli = (...args: string[]) => {
|
||||
const r = Bun.spawnSync([process.execPath, path.join(ROOT, 'scripts/test-free-shards.ts'), ...args], { cwd: ROOT, timeout: 60_000 });
|
||||
return { exit: r.exitCode, out: r.stdout.toString() + r.stderr.toString() };
|
||||
};
|
||||
|
||||
test('a dry run plans exactly the listed files and prints their digest', () => {
|
||||
const planned = collectFreeTestFiles(ROOT);
|
||||
const chosen = [planned[3]!, planned[0]!];
|
||||
const file = list(chosen.join('\n') + '\n');
|
||||
const r = cli('--files-from', file, '--dry-run');
|
||||
expect(r.exit, r.out).toBe(0);
|
||||
expect(r.out).toContain(`[test:free] files-from ${file}: 2 files, sha256=${filesDigest(chosen)}`);
|
||||
expect(r.out).toContain('Would run 2 files across');
|
||||
const listed = r.out.split('\n').filter(line => line.startsWith('Shard ') && line.includes('->')).flatMap(line => line.split('-> ')[1]!.split(', '));
|
||||
expect(listed.sort()).toEqual([...chosen].sort());
|
||||
}, 60_000);
|
||||
|
||||
test('a rejected list exits 1 before planning', () => {
|
||||
const r = cli('--files-from', list('test/not-a-real-file.test.ts\n'), '--dry-run');
|
||||
expect(r.exit).toBe(1);
|
||||
expect(r.out).toContain('1 path(s) not in the free planned set: test/not-a-real-file.test.ts');
|
||||
expect(r.out).not.toContain('Would run');
|
||||
}, 60_000);
|
||||
});
|
||||
Reference in new issue
Block a user