Files
sprites/tools/build/test/executor.test.ts
Christopher Monsanto 6ba5c04b5e Add the demand-driven executor and build driver
Keys resolve after producers finish, so there is no upfront plan; identical
keys share one execution, failures block dependents, and GC (union builds
only) drops undeclared keys then sweeps the CAS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 01:13:12 -04:00

241 lines
8.8 KiB
TypeScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import pathlib from 'node:path';
import {beforeEach, test} from 'node:test';
import {type Artifact, type CmdSpec, getDecls, resetDecls, rule} from '../artifact.ts';
import {casPath} from '../cas.ts';
import {type DriveResult, build} from '../driver.ts';
import {type RuleOutcome} from '../executor.ts';
import {Store} from '../store.ts';
beforeEach(resetDecls);
interface Env {
root : string;
dbPath : string;
casDir : string;
tmpDir : string;
execLog : string;
}
function setup() : Env {
const root = fs.mkdtempSync(pathlib.join(os.tmpdir(), 'executor-test-'));
fs.mkdirSync(pathlib.join(root, 'src'));
return {
root,
dbPath: pathlib.join(root, '.build/db.sqlite'),
casDir: pathlib.join(root, '.build/cas'),
tmpDir: pathlib.join(root, '.build/tmp'),
execLog: pathlib.join(root, 'exec.log'),
};
}
function src(env : Env, name : string, content : string) : string {
const p = pathlib.join(env.root, 'src', name);
fs.writeFileSync(p, content);
return p;
}
// A copy rule that also counts its executions in env.execLog.
function copyRule(env : Env, input : string | Artifact, out : string,
extra : Partial<CmdSpec> = {}) : Artifact {
return rule(input, {cmds: [`cat %f > %o && echo x >> ${env.execLog}`], ...extra}, [out])[0]!;
}
function execCount(env : Env) : number {
try {
return fs.readFileSync(env.execLog, 'utf8').split('\n').filter(l => l !== '').length;
} catch {
return 0;
}
}
async function runBuild(env : Env, opts : {dryRun? : boolean, gc? : boolean} = {}) : Promise<DriveResult> {
const store = new Store(env.dbPath);
try {
return await build(getDecls(), {
root: env.root,
store,
casDir: env.casDir,
tmpDir: env.tmpDir,
jobs: 2,
dryRun: opts.dryRun ?? false,
failFast: false,
verbose: false,
gc: opts.gc ?? false,
signal: new AbortController().signal,
log: () => {},
logError: () => {},
});
} finally {
store.close();
}
}
function statuses(result : DriveResult) : RuleOutcome['status'][] {
return getDecls().map(d => result.outcomes.get(d)!.status);
}
test('second build is clean and executes nothing', async () => {
const env = setup();
const out = copyRule(env, src(env, 'a.txt', 'hello'), 'out.txt');
const first = await runBuild(env);
assert.ok(first.ok);
assert.deepEqual(statuses(first), ['ran']);
assert.equal(fs.readFileSync(casPath(env.casDir, out.hash, 'txt'), 'utf8'), 'hello');
resetDecls();
copyRule(env, pathlib.join(env.root, 'src/a.txt'), 'out.txt');
const second = await runBuild(env);
assert.deepEqual(statuses(second), ['clean']);
assert.equal(execCount(env), 1);
});
test('input content change reruns; gc removes the old rule and object', async () => {
const env = setup();
const a = src(env, 'a.txt', 'v1');
const out1 = copyRule(env, a, 'out.txt');
await runBuild(env, {gc: true});
const oldObject = casPath(env.casDir, out1.hash, 'txt');
resetDecls();
fs.writeFileSync(a, 'v2');
copyRule(env, a, 'out.txt');
const result = await runBuild(env, {gc: true});
assert.deepEqual(statuses(result), ['ran']);
assert.equal(execCount(env), 2);
assert.ok(!fs.existsSync(oldObject));
const store = new Store(env.dbPath);
assert.deepEqual(store.liveObjects().size, 1);
store.close();
});
test('same-bytes source rename executes nothing; nameSensitive reruns', async () => {
const env = setup();
copyRule(env, src(env, 'a.txt', 'stable'), 'out.txt');
copyRule(env, src(env, 'ns.txt', 'stable'), 'ns-out.txt', {nameSensitive: true});
await runBuild(env);
assert.equal(execCount(env), 2);
resetDecls();
fs.renameSync(pathlib.join(env.root, 'src/a.txt'), pathlib.join(env.root, 'src/moved.txt'));
fs.renameSync(pathlib.join(env.root, 'src/ns.txt'), pathlib.join(env.root, 'src/ns2.txt'));
copyRule(env, pathlib.join(env.root, 'src/moved.txt'), 'out.txt');
copyRule(env, pathlib.join(env.root, 'src/ns2.txt'), 'ns-out.txt', {nameSensitive: true});
const result = await runBuild(env);
assert.deepEqual(statuses(result), ['clean', 'ran']);
assert.equal(execCount(env), 3);
});
test('byte-identical inputs share one execution across two declarations', async () => {
const env = setup();
const one = copyRule(env, src(env, 'one.txt', 'same-bytes'), 'one.txt');
const two = copyRule(env, src(env, 'two.txt', 'same-bytes'), 'two.txt');
const result = await runBuild(env);
assert.ok(result.ok);
assert.equal(execCount(env), 1);
assert.deepEqual(statuses(result).sort(), ['clean', 'ran']);
assert.equal(one.hash, two.hash);
assert.equal(one.filename, 'one.txt');
assert.equal(two.filename, 'two.txt');
});
// The consumer must be a *different* computation: an identical command over
// identical bytes would share the producer's key (by design).
function upcaseRule(env : Env, input : Artifact, out : string) : Artifact {
return rule(input, [`tr a-z A-Z < %f > %o && echo x >> ${env.execLog}`], [out])[0]!;
}
test('chained rules: consumer follows producer, cas-missing reruns alone', async () => {
const env = setup();
const mid = copyRule(env, src(env, 'a.txt', 'chain'), 'mid.txt');
upcaseRule(env, mid, 'final.txt');
const first = await runBuild(env);
assert.ok(first.ok);
assert.equal(execCount(env), 2);
resetDecls();
const mid2 = copyRule(env, pathlib.join(env.root, 'src/a.txt'), 'mid.txt');
const final2 = upcaseRule(env, mid2, 'final.txt');
const clean = await runBuild(env);
assert.deepEqual(statuses(clean), ['clean', 'clean']);
assert.equal(fs.readFileSync(casPath(env.casDir, final2.hash, 'txt'), 'utf8'), 'CHAIN');
resetDecls();
fs.rmSync(casPath(env.casDir, final2.hash, 'txt'));
const mid3 = copyRule(env, pathlib.join(env.root, 'src/a.txt'), 'mid.txt');
upcaseRule(env, mid3, 'final.txt');
const rerun = await runBuild(env);
assert.deepEqual(statuses(rerun), ['clean', 'ran']);
const outcome = rerun.outcomes.get(getDecls()[1]!)!;
assert.deepEqual(outcome, {status: 'ran', reason: 'cas-missing'});
});
test('failed producer blocks the consumer; nothing is recorded', async () => {
const env = setup();
const bad = rule(src(env, 'a.txt', 'x'), ['false'], ['mid.txt'])[0]!;
copyRule(env, bad, 'final.txt');
const result = await runBuild(env);
assert.ok(!result.ok);
assert.deepEqual(statuses(result), ['failed', 'blocked']);
const store = new Store(env.dbPath);
assert.equal(store.liveObjects().size, 0);
store.close();
});
test('multi-output rules route %oN and skip all-or-nothing', async () => {
const env = setup();
const declare = () => rule(src(env, 'a.txt', 'multi'),
[`printf one > %o1 && printf two > %o2 && echo x >> ${env.execLog}`],
['x.txt', 'y.css']);
const [x1, y1] = declare();
await runBuild(env);
assert.equal(fs.readFileSync(casPath(env.casDir, x1!.hash, 'txt'), 'utf8'), 'one');
assert.equal(fs.readFileSync(casPath(env.casDir, y1!.hash, 'css'), 'utf8'), 'two');
resetDecls();
fs.rmSync(casPath(env.casDir, y1!.hash, 'css'));
declare();
const rerun = await runBuild(env);
assert.deepEqual(statuses(rerun), ['ran']);
assert.equal(execCount(env), 2);
});
test('a missing declared output fails the rule', async () => {
const env = setup();
rule(src(env, 'a.txt', 'x'), ['printf one > %o1'], ['x.txt', 'missing.css']);
const result = await runBuild(env);
assert.ok(!result.ok);
const outcome = result.outcomes.get(getDecls()[0]!)!;
assert.equal(outcome.status, 'failed');
assert.match((outcome as {message : string}).message, /did not produce: missing.css/);
const store = new Store(env.dbPath);
assert.equal(store.liveObjects().size, 0);
store.close();
});
test('dry run reports without writing state', async () => {
const env = setup();
const mid = copyRule(env, src(env, 'a.txt', 'dry'), 'mid.txt');
copyRule(env, mid, 'final.txt');
const result = await runBuild(env, {dryRun: true});
assert.ok(!result.ok);
assert.deepEqual(result.outcomes.get(getDecls()[0]!), {status: 'would-run', reason: 'new'});
assert.deepEqual(result.outcomes.get(getDecls()[1]!), {status: 'would-run', reason: 'blocked'});
assert.equal(execCount(env), 0);
assert.ok(!fs.existsSync(env.casDir));
const store = new Store(env.dbPath);
assert.equal(store.liveObjects().size, 0);
store.close();
});
test('missing sources fail upfront', async () => {
const env = setup();
copyRule(env, pathlib.join(env.root, 'src/nope.txt'), 'out.txt');
await assert.rejects(() => runBuild(env), /Missing input files/);
});