Files
gstack/test/test-free-shards-files-from.test.ts
Garry Tanandcapy-ai[bot] a59734fed6 v1.91.132.0 feat(pregate): a remote gate for gstack's own pregate (Ubicloud or keyless local shards); concurrent next-version fetches (#3258)
* 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>

* feat(pregate): declare scripts/ubicloud/pregate-remote.sh as gstack's remote gate (keyed Ubicloud VM, keyless local shards)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* perf(next-version): fetch PR VERSIONs concurrently; a timed-out fetch is an unknown claim

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(pregate): gstack remote-gate example and the small-machine pregate row

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): remote gate leaves paid test files to the paid gate, adds reason codes and the keyless backend line

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(next-version): unknown claims make the pick provisional (complete: false)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(pregate): remote-gate reason codes, paid-file handling and measured keyed/keyless timing

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): remote gate finishes before pregate's deadline

pregate kills the remote gate at --timeout with a 5 s grace, which could cut a
VM teardown short. pregate-remote.sh now reads the enclosing deadline
(PREGATE_TIMEOUT_S, else the gstack-pregate --timeout it runs under, else 600),
reserves 120 s for teardown plus 15 s margin, and tells the VM to stop at the
earlier of that point and the cap's wall; a deadline that leaves under 300 s
refuses before any VM (PREGATE_REMOTE_TIMEOUT_TOO_SHORT). It names the VM up
front and prints the exact ubi-runner.sh down line, and the body is one braced
block so an edit to the script mid-run cannot change what the run executes
(keyed run 5 died after teardown with '$2: unbound variable' for that reason).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(restamp): an incomplete claim queue is never reported as read

gstack-next-version now returns complete: false with unknown_claims when a
PR's VERSION fetch timed out or failed. restamp's claim-at-open path took any
usable JSON as 'read'; it now says 'incomplete', names the unknown PRs and
calls the version provisional, the same warn-not-refuse stance as the
existing 'unverified' fallback.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): only a gstack-pregate process sets the remote gate's deadline

The ancestor walk matched any command line that mentioned gstack-pregate, so a
shell line running pregate beside the tests (bash -c '...; bin/gstack-pregate
...') gave the stub gate a 600 s deadline. It now matches only a process whose
program, or the script its interpreter runs, is gstack-pregate.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): A3 remote gate, --files-from, concurrent next-version

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.132.0 chore: stamp

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-18 02:13:29 +00:00

86 lines
5.3 KiB
TypeScript

/**
* 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);
});