Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions packages/spec/scripts/render-projection-diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ import path from 'node:path';

import {
ARTIFACT_NAME,
BASE_REGISTRY,
SUMMARY_DIFF_CAP,
describeDeltas,
fenceFor,
generateBaseRegistry,
idDeltas,
renderProjectionDiff,
renderSummary,
type Generate,
type RunScript,
} from './render-projection-diff';

let tmp: string;
Expand Down Expand Up @@ -149,6 +152,113 @@ describe('render-projection-diff — the generated diff on a pull request', () =
});
});

describe('render-projection-diff — a base whose migration registry is git-ignored', () => {
/** A base `packages/spec` laid out as its archive would be: the generator and/or the registry, or neither. */
function baseSpecDir(has: { generator: boolean; registry: boolean }): string {
const dir = path.join(tmp, 'base-tree', 'packages', 'spec');
fs.mkdirSync(path.join(dir, 'scripts'), { recursive: true });
if (has.generator) fs.writeFileSync(path.join(dir, 'scripts', BASE_REGISTRY.generator), '');
if (has.registry) {
fs.mkdirSync(path.dirname(path.join(dir, BASE_REGISTRY.file)), { recursive: true });
fs.writeFileSync(path.join(dir, BASE_REGISTRY.file), '// committed\n');
}
return dir;
}

/** A fake runner that records each call and, unless told otherwise, writes the registry the way the generator does. */
function recorder(result: { status: number; write: boolean; output?: string }) {
const calls: [string, string][] = [];
const run: RunScript = (dir, script) => {
calls.push([dir, script]);
if (result.write) fs.writeFileSync(path.join(dir, BASE_REGISTRY.file), '// generated\n');
return { status: result.status, output: result.output ?? '' };
};
return { calls, run };
}

it('an archive with the generator but no registry runs the BASE generator in the base tree', () => {
const dir = baseSpecDir({ generator: true, registry: false });
fs.mkdirSync(path.dirname(path.join(dir, BASE_REGISTRY.file)), { recursive: true });
const { calls, run } = recorder({ status: 0, write: true });

expect(generateBaseRegistry(dir, run)).toBe(true);
expect(calls).toEqual([[dir, 'build-migration-registry.ts']]);
expect(fs.readFileSync(path.join(dir, BASE_REGISTRY.file), 'utf8')).toBe('// generated\n');
});

it('a base that committed its registry keeps it, and a base without the generator is left as it is', () => {
for (const has of [{ generator: true, registry: true }, { generator: false, registry: true }, { generator: false, registry: false }]) {
fs.rmSync(path.join(tmp, 'base-tree'), { recursive: true, force: true });
const dir = baseSpecDir(has);
const { calls, run } = recorder({ status: 0, write: true });

expect(generateBaseRegistry(dir, run)).toBe(false);
expect(calls).toEqual([]);
if (has.registry) expect(fs.readFileSync(path.join(dir, BASE_REGISTRY.file), 'utf8')).toBe('// committed\n');
}
});

it('a base registry generator that fails, or exits 0 without writing, is thrown with its output', () => {
const dir = baseSpecDir({ generator: true, registry: false });

// First line names the generator and its exit; the generator's output follows it — the
// shape the failure summary splits on.
expect(() => generateBaseRegistry(dir, recorder({ status: 1, write: false, output: 'entry failed to parse' }).run)).toThrow(
/^build-migration-registry\.ts exited 1\b[^\n]*\nentry failed to parse$/,
);
expect(() => generateBaseRegistry(dir, recorder({ status: 0, write: false }).run)).toThrow(
/^build-migration-registry\.ts exited 0\b[^\n]*src\/migrations\/registry\.ts/,
);
});

it('a base whose registry cannot be generated is RED, named as the base', () => {
const dir = baseSpecDir({ generator: true, registry: false });
const generate: Generate = (_dir, script, out) => {
fs.writeFileSync(out, script === 'build-spec-changes.ts' ? BASE.json : BASE.guide);
return { status: 0, output: '' };
};
const head = path.join(tmp, 'head-tree', 'packages', 'spec');
fs.mkdirSync(head, { recursive: true });
const outDir = path.join(tmp, 'out');
const result = renderProjectionDiff({
repoRoot: tmp,
baseSha: 'b'.repeat(40),
baseLabel: 'HEAD^1 = bbbbbbbbbb',
outDir,
headSpecDir: head,
generate,
materializeBase: () => {
generateBaseRegistry(dir, recorder({ status: 1, write: false, output: 'entry failed to parse' }).run);
return dir;
},
});

expect(result.code).toBe(1);
expect(result.headline).toContain('base HEAD^1 = bbbbbbbbbb: build-migration-registry.ts exited 1');
expect(result.summary).toContain('entry failed to parse');
});

it('the default runner executes the base generator by its own path, with the base package as cwd', () => {
const dir = baseSpecDir({ generator: true, registry: false });
// Resolves its package root from its own path, as the real generator does.
fs.writeFileSync(
path.join(dir, 'scripts', BASE_REGISTRY.generator),
[
"import { mkdirSync, writeFileSync } from 'node:fs';",
"import { dirname, join } from 'node:path';",
"import { fileURLToPath } from 'node:url';",
"const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');",
"mkdirSync(join(pkgRoot, 'src', 'migrations'), { recursive: true });",
"writeFileSync(join(pkgRoot, 'src', 'migrations', 'registry.ts'), `// cwd ${process.cwd()}\\n`);",
'',
].join('\n'),
);

expect(generateBaseRegistry(dir)).toBe(true);
expect(fs.readFileSync(path.join(dir, BASE_REGISTRY.file), 'utf8')).toBe(`// cwd ${fs.realpathSync(dir)}\n`);
});
});

describe('render-projection-diff — the pieces', () => {
it('idDeltas names ids gained and lost per record, and skips records that did not move', () => {
const deltas = idDeltas(manifest(['a', 'b'], ['c1']), manifest(['b', 'z'], ['c1', 'c2']));
Expand Down
51 changes: 48 additions & 3 deletions packages/spec/scripts/render-projection-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
* base `<ref>`'s own generators, run in a `git archive` of `<ref>` that
* borrows this checkout's `node_modules` — so the base side is what the
* base's code generated, never this change's generator applied to it.
* A base whose migration registry is git-ignored gets it written first,
* by its own generator (`generateBaseRegistry`).
*
* ⚠️ A base whose generators predate `--out` writes its committed path instead
* (inside the throwaway archive, never this checkout). That is read in its
Expand Down Expand Up @@ -86,8 +88,11 @@ export interface GeneratorRun {
/** Runs `<specDir>/scripts/<script> --out <out>` — injected by the tests. */
export type Generate = (specDir: string, script: string, out: string) => GeneratorRun;

function realGenerate(specDir: string, script: string, out: string): GeneratorRun {
const r = spawnSync(TSX, [join(specDir, 'scripts', script), '--out', out], {
/** Runs `<specDir>/scripts/<script>` with no arguments — injected by the tests. */
export type RunScript = (specDir: string, script: string) => GeneratorRun;

function runTsx(specDir: string, args: string[]): GeneratorRun {
const r = spawnSync(TSX, args, {
cwd: specDir,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
Expand All @@ -96,6 +101,44 @@ function realGenerate(specDir: string, script: string, out: string): GeneratorRu
return { status: r.status ?? -1, output: `${r.stdout ?? ''}${r.stderr ?? ''}${r.error ? String(r.error) : ''}` };
}

function realGenerate(specDir: string, script: string, out: string): GeneratorRun {
return runTsx(specDir, [join(specDir, 'scripts', script), '--out', out]);
}

function realRunScript(specDir: string, script: string): GeneratorRun {
return runTsx(specDir, [join(specDir, 'scripts', script)]);
}

/** The base's git-ignored migration registry and the generator that writes it, relative to its `packages/spec`. */
export const BASE_REGISTRY = { file: 'src/migrations/registry.ts', generator: 'build-migration-registry.ts' } as const;

/**
* Write a base tree's migration registry with the BASE's own generator, when the
* archive carries the generator but not the registry. Returns whether it ran.
*
* Both projection generators import `../src/migrations/registry`. Since #22554
* ruling B that file is git-ignored and generated at install and build, so a
* `git archive` of a base at or after that change has only its template and
* entries — the base generators would die on a missing module. The generator
* resolves its package root from its own path, so run from the base tree it
* writes the base's registry from the base's entries, never this checkout's.
* A base that committed the registry keeps it untouched. A generator that fails,
* or exits 0 without writing the file, is thrown: the caller reports the base red.
*/
export function generateBaseRegistry(specDir: string, run: RunScript = realRunScript): boolean {
const registry = join(specDir, BASE_REGISTRY.file);
if (existsSync(registry)) return false;
if (!existsSync(join(specDir, 'scripts', BASE_REGISTRY.generator))) return false;
const r = run(specDir, BASE_REGISTRY.generator);
if (r.status !== 0) {
throw new Error(`${BASE_REGISTRY.generator} exited ${r.status}, so the base has no ${BASE_REGISTRY.file}:\n${r.output.trim()}`);
}
if (!existsSync(registry)) {
throw new Error(`${BASE_REGISTRY.generator} exited 0 but wrote no ${BASE_REGISTRY.file}:\n${r.output.trim()}`);
}
return true;
}

function git(args: string[], cwd: string): { status: number; stdout: string; stderr: string } {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: `${r.stderr ?? ''}${r.error ? String(r.error) : ''}` };
Expand Down Expand Up @@ -130,7 +173,9 @@ function realMaterializeBase(repoRoot: string, sha: string, dest: string): strin
mkdirSync(join(tree, 'docs'), { recursive: true });
symlinkSync(join(repoRoot, 'node_modules'), join(tree, 'node_modules'));
symlinkSync(join(repoRoot, 'packages', 'spec', 'node_modules'), join(tree, 'packages', 'spec', 'node_modules'));
return join(tree, 'packages', 'spec');
const specDir = join(tree, 'packages', 'spec');
generateBaseRegistry(specDir);
return specDir;
}

/** One side's generated file for one projection, or why there is none. */
Expand Down
Loading