import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; const script = fileURLToPath(new URL("./commitRecords.js", import.meta.url)); const SIGNED = "Signed-off-by: A Person "; /** * Asserts that the page contains the text; a failure names the text. * @param {string} html * @param {string} text */ const has = (html, text) => assert.ok(html.includes(text), `page lacks ${JSON.stringify(text)}`); /** * Asserts that the page lacks the text. * @param {string} html * @param {string} text */ const lacks = (html, text) => assert.ok(!html.includes(text), `page has ${JSON.stringify(text)}`); /** * @param {string} dir * @param {Record} files */ async function write(dir, files) { for (const [name, content] of Object.entries(files)) { await fs.mkdir(path.dirname(path.join(dir, name)), { recursive: true }); await fs.writeFile(path.join(dir, name), content); } } /** * Renders the records of a temporary repository's commits. * @param {(git: (...args: string[]) => string, dir: string) => Promise} setup * @param {string[]} args */ async function render(setup, args) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "commit-records-")); const git = (/** @type {string[]} */ ...a) => execFileSync("git", a, { cwd: dir, encoding: "utf8" }); git("init", "-q", "-b", "main"); git("config", "user.name", "A Person"); git("config", "user.email", "a@example.com"); await setup(git, dir); const out = path.join(dir, "page.html"); const r = spawnSync("node", [script, "--out", out, ...args], { cwd: dir, encoding: "utf8", }); assert.equal(r.status, 0, r.stderr); return fs.readFile(out, "utf8"); } /** * Commits the files with the message. * @param {(...args: string[]) => string} git * @param {string} dir * @param {Record} files * @param {string} message */ async function commit(git, dir, files, message) { await write(dir, files); git("add", "-A", "."); git("commit", "-q", "--allow-empty", "-m", message); } /** @param {string} tree @param {string} indent */ const indented = (tree, indent) => tree .trimEnd() .split("\n") .map(l => indent + l) .join("\n"); const BEFORE_TREE = `[ structure_item Pstr_value Nonrec [ pattern Ppat_var "f" expression Pexp_extension "raw" ] ]`; const AFTER_TREE = BEFORE_TREE.replace( 'Pexp_extension "raw"', 'Pexp_raw "raw"\n Raw_template "(a) => a"', ); const AST_RECORD = `Represent raw extensions as their own node Why: A raw extension carries JavaScript; its own node gives it a type. AST modified (raw extensions): Example: let f = %raw(\`(a) => a\`) Before (parse): ${indented(BEFORE_TREE, " ")} After (parse): ${indented(AFTER_TREE, " ")} Review: Does a PPX still see the extension? Yes: the v0 bridge maps it back, as https://github.com/rescript-lang/rescript/pull/8624 describes. ${SIGNED} `; test("a minimal record draws its identity and subject", async () => { const html = await render( async (git, dir) => { await commit(git, dir, { "README.md": "a\n" }, "base"); await commit( git, dir, { "README.md": "b\n" }, `Fix a typo (#7)\n\nCo-Authored-By: B Person \n${SIGNED}`, ); }, ["HEAD"], ); has(html, "Semantic Diff"); has(html, '

Semantic Diff

'); has(html, "

Fix a typo

"); assert.match( html, /
[0-9a-f]{9}<\/code> · A Person · [A-Z][a-z]{2} \d{2}, \d{4} · #7<\/a><\/div>/, ); has(html, '
with B Person
'); lacks(html, '
'); lacks(html, "Signed-off-by"); lacks(html, "Co-Authored-By"); }); test("an AST block's parse trees are drawn as aligned Before and After rows", async () => { const html = await render( async (git, dir) => { await commit(git, dir, { "a.txt": "a\n" }, "base"); await commit(git, dir, { "compiler/ml/parsetree.ml": "x\n" }, AST_RECORD); }, ["HEAD"], ); has( html, '

modifiedAST raw extensions

', ); has(html, '%raw'); has(html, '
parse
'); has(html, '
'); // the root stands bare; the common lines face each other; // the changed node is a deleted row Before and two added rows After, the // shorter side padded with a gap const nb = "\u00a0"; has( html, 'structure_item', ); has( html, `${nb.repeat(9)}└─${nb}Pexp_extension "raw"`, ); has( html, `${nb.repeat(9)}├─${nb}Pexp_raw "raw"`, ); has(html, ''); has(html, ''); has(html, ''); has(html, '
  • Does a PPX still see the extension?'); has( html, 'as #8624 describes', ); has(html, '
    '); // the example and its forms belong to the object's block assert.equal(html.split('
    { const html = await render( async (git, dir) => { await commit(git, dir, { "a.txt": "a\n" }, "base"); await commit( git, dir, {}, `Add a raw node\n\nAST added (Pexp_raw):\nAfter (parse):\n${indented(AFTER_TREE, " ")}\n\n${SIGNED}\n`, ); }, ["HEAD"], ); has(html, 'added'); has( html, '
    After
    ', ); has(html, 'Pexp_raw'); lacks(html, '
    { const html = await render( async (git, dir) => { await commit(git, dir, { "a.txt": "a\n" }, "base"); await commit(git, dir, {}, AST_RECORD); await commit( git, dir, {}, `Compile raw extensions directly JS modified (raw extension): Example: let f = 1 Before (js): let f = 1; After (js): let f = 2; Diagnostic added (unused raw): After (diagnostic): Warning number 109 This raw extension is unused. ${SIGNED} `, ); }, ["HEAD~2..HEAD"], ); has(html, "

    HEAD~2..HEAD

    "); has(html, 'js'); has( html, 'let f = 1;', ); has( html, 'let f = 2;', ); has(html, 'diagnostic'); has(html, '
    '); has(html, "This raw extension is unused."); assert.equal(html.split('
    ').length - 1, 1); }); test("equal Before and After forms are drawn once", async () => { const html = await render( async (git, dir) => { await commit(git, dir, { "a.txt": "a\n" }, "base"); await commit( git, dir, {}, `Keep the output\n\nJS modified (x):\nBefore (js):\n let x = 1;\nAfter (js):\n let x = 1;\n\n${SIGNED}\n`, ); }, ["HEAD"], ); has(html, "Before and After are equal"); lacks(html, '
    { const message = `Change things\n\nWhy:\n A reason.\n\n${SIGNED}\n`; /** @param {Record} files */ const card = async files => { const html = await render( async (git, dir) => { await commit(git, dir, { "a.txt": "a\n" }, "base"); await commit(git, dir, files, message); }, ["HEAD"], ); // the card with its short hash, which differs between the two commits, // blanked const sha = /id="c-([0-9a-f]+)"/.exec(html)?.[1] ?? ""; return html .slice(html.indexOf("")) .replaceAll(sha, "SHA"); }; const touched = await card({ "tests/tests/src/a_test.res": "let y = 1\n", "tests/tests/src/a_test.mjs": "y\n", "tests/build_tests/super_errors/expected/x.res.expected": "e\n", "changes/x.fix.md": "- Fix a thing.\n", "compiler/ext/config.ml": 'let cmi_magic_number = "BS00MI002"\n', }); assert.equal(touched, await card({})); has(touched, "A reason."); lacks(touched, "a_test"); }); test("--pr and --title head the page, escaped", async () => { const html = await render( async (git, dir) => { await commit(git, dir, { "a.txt": "a\n" }, `Base\n\n${SIGNED}`); }, ["--pr", "42", "--title", "Fix & more", "HEAD"], ); has( html, '

    #42 Fix <output> & more

    ', ); has(html, "#42 Fix <output> & more · Semantic Diff"); }); test("a typing rule draws subscripts, and a matrix its cells and notes", async () => { const html = await render( async (git, dir) => { await commit(git, dir, { "a.txt": "a\n" }, "base"); await commit( git, dir, {}, `Check applications Typing modified (application): Before (rule): Γ ⊢ e_1 : τ_{1} → τ_{2} Γ ⊢ e_2 : τ_{1} ─────────────── (App) Γ ⊢ e_1 e_2 : τ_{2} After (rule): Γ ⊢ e_1 : τ_{1} → τ_{2} Γ ⊢ e_2 : τ_{1}′ ─────────────── (App) Γ ⊢ e_1 e_2 : τ_{2} Matching modified (option split): After (matrix): Some(x) _ e1 None Some e2 order: Some before None ${SIGNED} `, ); }, ["HEAD"], ); has(html, "e1"); has(html, "τ2"); has(html, '(App)'); // only the premise that changes is shaded has( html, '
    Γ ⊢ e1', ); has(html, 'Γ ⊢ e2'); has(html, 'Γ ⊢ e2'); assert.equal(html.split('').length - 1, 1); assert.equal(html.split('').length - 1, 1); has(html, '
    Γ ⊢'); has(html, "grid-template-columns:repeat(3,max-content)"); has(html, 'Some(x)'); has( html, '
    order', ); }); test("a line of another shape is prose, and a version without its pair is drawn across the block", async () => { const html = await render( async (git, dir) => { await commit(git, dir, { "a.txt": "a\n" }, "base"); await commit( git, dir, {}, `Change things Typing rule modified (x): After (js): let x = 1; JS modified: After (js): let y = 2; ${SIGNED} `, ); }, ["HEAD"], ); has( html, '

    Typing rule modified (x):

    ', ); has( html, '

    modifiedJS

    ', ); assert.equal( html.split( '
    After
    ', ).length - 1, 2, ); lacks(html, '
    { const html = await renderOne( `Keep the node\n\nThe fix is simple: the parser keeps the node.\n\n${SIGNED}\n`, ); has(html, "

    The fix is simple: the parser keeps the node.

    "); lacks(html, '

    '); }); test("a character literal with a Unicode escape is one string token", async () => { const html = await renderOne( `Match a range\n\nExample:\n switch c { | '\\u{100}' .. '\\u{1100}' => 1 | _ => 0 }\n\n${SIGNED}\n`, ); has(html, `'\\u{100}'`); has(html, `'\\u{1100}'`); }); test("Breaking and Review sections right above the sign-off are drawn", async () => { const html = await renderOne( `Rename a flag\n\nBreaking: -bs-x is now -x.\nReview: Is -bs-x still read? No.\n${SIGNED}\nCo-Authored-By: B Person \n`, ); has( html, '', ); has(html, '
  • Is -bs-x still read? No.
'); has(html, '
with B Person
'); lacks(html, "Signed-off-by"); }); test("a block ending right above the sign-off keeps the sign-off out", async () => { const html = await renderOne( `Change x\n\nJS modified (x):\nAfter (js):\n let x = 1;\n${SIGNED}\n`, ); has(html, 'let x = 1;'); lacks(html, "Signed-off-by"); lacks(html, "A Person <"); }); test("a pair of rewrites shades each side where it changes", async () => { const html = await renderOne( `Simplify additions\n\nLambda modified (identity):\nBefore (lambda):\n (+ x 0) ⇝ (+ x 0)\nAfter (lambda):\n (+ x 0) ⇝ x\n when: x is an int\n\n${SIGNED}\n`, ); has(html, '
'); // the left-hand sides are equal and unshaded; the right-hand sides differ const lhs = '(+ x 0)'; assert.equal(html.split(lhs).length - 1, 2); has( html, '(+ x 0)', ); has(html, 'x'); has(html, '
when'); }); test("a chain of rewrites draws every term", async () => { const html = await renderOne( `Simplify twice\n\nLambda added (chain):\nAfter (lambda):\n (+ (+ x 0) 0) ⇝ (+ x 0) ⇝ x\n\n${SIGNED}\n`, ); has(html, '
'); assert.equal(html.split('⇝').length - 1, 2); has(html, 'x'); has( html, '(+ x 0)', ); }); test("trailers of any key end the message and are not drawn", async () => { const html = await renderOne( `Fix a thing\n\nCo-Authored-By: Claude Opus 5.5 \nClaude-Session: https://claude.ai/code/session_x\n${SIGNED}\n`, ); has(html, '
with Claude Opus 5.5
'); lacks(html, "Claude-Session"); lacks(html, "session_x"); lacks(html, '
{ const html = await renderOne( `Drop an identity\n\nJS modified (x):\nBefore (js): return x + 0 | 0;\nAfter (js): return x;\n\nJS modified (y):\nBefore (js):\n\treturn y;\nAfter (js):\n\treturn y;\n\n${SIGNED}\n`, ); has( html, 'return x + 0 | 0;', ); has( html, 'return x;', ); lacks(html, ''); assert.equal(html.split("Before and After are equal").length - 1, 1); has(html, 'return y;'); }); test("a blank line ends a section whose text is on its heading line", async () => { const html = await renderOne( `Rename a flag\n\nWhy: the problem.\n\nA separate paragraph.\n\nBreaking: -x is gone.\n\nAnother paragraph.\n\n${SIGNED}\n`, ); has( html, '

Why

the problem.

', ); has(html, '

A separate paragraph.

'); has(html, ''); has(html, '

Another paragraph.

'); }); test("a line indented by 2 spaces continues its paragraph", async () => { const html = await renderOne( `Change things\n\n- first bullet that wraps\n onto a second line\n\n${SIGNED}\n`, ); has(html, "

- first bullet that wraps onto a second line

"); lacks(html, '
');
});

test("a block indented under a paragraph is code", async () => {
  const html = await renderOne(
    `Change output\n\nThe new output for:\n\n    let f = x => x + 1\n    let g = y => y\n\n${SIGNED}\n`,
  );
  has(html, "

The new output for:

"); has( html, '
let f = x => x + 1let g = y => y
', ); }); test("Example: with text on its line is prose; alone, it heads a program", async () => { const html = await renderOne( `Fold constants\n\nExample: when the argument is constant, the call folds.\n\nJS modified (f):\nExample:\n let f = 1\nAfter (js):\n let f = 1;\n\n${SIGNED}\n`, ); has( html, '

Example: when the argument is constant, the call folds.

', ); has( html, '
let f = 1
', ); }); test("a representation, a matrix and captions are drawn on aligned rows", async () => { const html = await renderOne(`Unbox options Representation modified (option): Before (representation): boxed Some(x) ↦ {_0: x} None ↦ undefined After (representation): unboxed Some(x) ↦ x None ↦ undefined note: x is not undefined Matching modified (option split): Before (matrix): Some(x) e1 None e2 After (matrix): Some(x) _ e1 None e2 JS modified (f): Before (js): same output let f = 1; After (js): same output let f = 1; ${SIGNED} `); has( html, '
Before boxed
After unboxed
', ); has( html, 'Some(x) ↦ x', ); has( html, 'None ↦ undefined', ); has(html, '
note'); has( html, 'Some(x) _ e1', ); has( html, 'None e2', ); has( html, '
Before and After are equal same output
', ); assert.equal(html.split('
').length - 1, 2); }); test("typed, expanded and lambda forms, and links in prose", async () => { let first = ""; const html = await render( async (git, dir) => { await commit(git, dir, { "a.txt": "a\n" }, "base"); await commit(git, dir, {}, `Type raw nodes\n\n${SIGNED}\n`); first = git("rev-parse", "--short=9", "HEAD").trim(); await commit( git, dir, {}, `Lower raw nodes Why: ${first} types raw nodes; #1234 lowers them. Typedtree added (raw): After (typed): Texp_raw "x" AST modified (raw): Before (expanded): Pexp_extension "raw" After (expanded): Pexp_raw "raw" Lambda added (raw): After (lambda): (let (f/1002 = (#raw_expr "x")) f/1002) ${SIGNED} `, ); }, ["HEAD~2..HEAD"], ); has( html, `${first} types raw nodes; #1234 lowers them.`, ); has(html, 'typed'); has( html, 'Texp_raw "x"', ); has(html, 'expanded'); has(html, '
'); has(html, 'lambda'); has(html, 'let'); has(html, '/1002'); has(html, '#raw_expr'); }); const DIVIDE = `Exception Analysis Division.res:1:5-10 divide might throw Division_by_zero (Division.res:1:27) and is not annotated with @throws(Division_by_zero)`; const DIVIDE_BY = `Exception Analysis Division.res:2:5-12 divideBy might throw Division_by_zero (Division.res:2:37) and is not annotated with @throws(Division_by_zero)`; const DEAD = `Warning Dead Value DeadTest.res:3:1-10 deadValue is never used`; test("a reanalyze report draws its findings aligned by identity, one analysis tagging the form", async () => { const html = await renderOne(`Skip literal divisors Reanalyze modified (division): Before (reanalyze): ${indented(`\n${DIVIDE}\n\n${DIVIDE_BY}`, " ")} After (reanalyze): ${indented(`\n${DIVIDE_BY}`, " ")} ${SIGNED} `); has( html, '
reanalyze Exception Analysis
', ); has(html, '
'); has( html, '
Division.res:1:5-10
divide might throw Division_by_zero (Division.res:1:27) and is not annotated with @throws(Division_by_zero)
', ); has(html, '
'); has( html, '
1 unchanged finding
Division.res:2:5-12', ); has(html, '
'); assert.equal(html.split('
'); }); test("an added finding is shaded added, and findings of several analyses keep their tags", async () => { const html = await renderOne(`Report dead values Reanalyze modified (dead code): Before (reanalyze): ${indented(`${DIVIDE}\n\nAnalysis reported 1 issues`, " ")} After (reanalyze): ${indented(`${DEAD}\n\n${DIVIDE}\n\nAnalysis reported 2 issues (Warning Dead Value:1)`, " ")} Reanalyze added (termination): After (reanalyze): Error Termination TestCyberTruck.res:29:28-39 Possible infinite loop when calling alwaysLoop CallStack: 1 alwaysLoop (TestCyberTruck.res:29) ${SIGNED} `); has(html, '
reanalyze
'); has( html, '
Warning Dead ValueDeadTest.res:3:1-10
deadValue is never used
', ); has(html, '
'); has( html, '1 unchanged finding
Exception Analysis', ); has( html, '
Analysis reported 1 issues
', ); has( html, '
Analysis reported 2 issues (', ); has( html, '
reanalyze Error Termination
After
TestCyberTruck.res:29:28-39
Possible infinite loop when calling alwaysLoop\nCallStack:\n 1 alwaysLoop (TestCyberTruck.res:29)
', ); }); test("a run of unchanged findings is one row, and a changed count is one row", async () => { const html = await renderOne(`Skip literal divisors Reanalyze modified (division): Before (reanalyze): ${indented(`\n${DIVIDE}\n\n${DIVIDE_BY}\n\n${DIVIDE_BY.replaceAll("divideBy", "divideTo")}\n\nAnalysis reported 3 issues (Exception Analysis:3)`, " ")} After (reanalyze): ${indented(`\n${DIVIDE_BY}\n\n${DIVIDE_BY.replaceAll("divideBy", "divideTo")}\n\nAnalysis reported 2 issues (Exception Analysis:2)`, " ")} ${SIGNED} `); has( html, '
2 unchanged findings', ); has(html, '
'); has(html, '
'); has(html, "divideTo might throw"); assert.equal(html.split('
Analysis reported 3 issues (Exception Analysis:3)
', ); has( html, '
Analysis reported 2 issues (Exception Analysis:2)
', ); lacks(html, '
{ const html = await renderOne(`Skip literal divisors Reanalyze modified (division): Before (reanalyze): ${indented(`${DIVIDE}\n\n… 4 more like this\n\n${DIVIDE_BY}`, " ")} After (reanalyze): ${indented(`${DIVIDE_BY}`, " ")} JS modified (division): Before (js): let a = 1; … After (js): let a = 2; … 3 more lines ${SIGNED} `); has( html, '
… 4 more like this
', ); has(html, '
'); lacks(html, "finding elide del"); has(html, '…'); has(html, '… '); has(html, ''); });