Files
msd-core/tests/bug-622-graphify-optional-graph-html.test.cjs
Tom Boucher 0e259a589c fix(#705): route hardcoded $HOME gsd-tools invocations in agents/commands through gsd_run (#707)
* fix(#705): route hardcoded $HOME gsd-tools invocations in agents/commands through gsd_run

The hardcoded `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" <cmd>` form
(fixed for workflows in #621/#637) survived in agent/command surfaces and
misresolves on global/shim-only installs. Route every agent-executed
invocation through the resolved `gsd_run` launcher in gsd-phase-researcher,
gsd-planner (load_graph_context extracted to a shared reference to stay under
the planner size budget), import, and graphify. Add a regression guard over
agents/ + commands/ + gsd-core/references/ bash blocks. User-facing display
messages and docs are intentionally left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#705): use repo changeset fragment format (type: Fixed, pr: 707)

The hand-written fragment used the standard changesets package format
(package: bump) which lacks the type:/pr: frontmatter the repo's
docs-required lint consumes (fail_malformed_fragment / missing_type).
Regenerated via scripts/changeset/new.cjs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:26:29 -04:00

218 lines
7.8 KiB
JavaScript

// allow-test-rule: source-text-is-the-product
// This test extracts the deployed Step 3 shell block from commands/gsd/graphify.md
// and executes it to prove that a skipped graph.html (due to the graphify HTML viz
// node limit) does not abort the chain (#622). The deployed markdown text IS the
// product surface — the block the runtime executes — so asserting on its execution
// behavior requires reading the source text.
'use strict';
/**
* Regression test for bug #622.
*
* The `/gsd-graphify build` Step 3 shell chain in commands/gsd/graphify.md
* aborted when `graph.html` was intentionally skipped (graph exceeds the HTML
* viz node limit, default 5000). The unconditional `cp graphify-out/graph.html`
* failed with "cannot stat", and the `&&` chain aborted before the
* GRAPH_REPORT.md copy, snapshot, and status steps ran.
*
* Fix: guard the graph.html copy with
* `{ [ -f graphify-out/graph.html ] && cp … || true; }`
* so the chain continues when the file is absent.
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
// Path to the command doc (relative to repo root)
const GRAPHIFY_MD = path.join(__dirname, '..', 'commands', 'gsd', 'graphify.md');
/**
* Extract the Step 3 fenced bash block from graphify.md.
* The block starts with the line `graphify update .` and ends at the next
* closing ``` fence.
*
* Returns the bash source text (without the fence lines themselves).
*/
function extractStep3Block() {
const content = fs.readFileSync(GRAPHIFY_MD, 'utf-8');
// Capture the full body of the ```bash fence that CONTAINS `graphify update .`
// (including any leading preamble line), without crossing into other fences.
const match = content.match(/```bash\r?\n((?:(?!```)[\s\S])*?graphify update \.(?:(?!```)[\s\S])*?)\r?\n```/);
return match ? match[1].trim() : null;
}
// ─── shared sandbox dirs ──────────────────────────────────────────────────────
let sandbox;
let fakeBin;
let fakeHome;
before(() => {
sandbox = createTempDir('gsd-622-sandbox-');
fakeBin = createTempDir('gsd-622-fakebin-');
fakeHome = createTempDir('gsd-622-fakehome-');
});
after(() => {
cleanup(sandbox);
cleanup(fakeBin);
cleanup(fakeHome);
});
// ─── helpers ─────────────────────────────────────────────────────────────────
/**
* Write a minimal fake `graphify` executable into fakeBin.
* It just exits 0 so the `graphify update .` step succeeds.
*/
function writeFakeGraphify() {
const exe = path.join(fakeBin, 'graphify');
fs.writeFileSync(exe, ['#!/bin/sh', 'exit 0'].join('\n'), { mode: 0o755 });
}
/**
* Write a minimal gsd-tools.cjs stub into fakeHome that exits 0 for any
* invocation (covers the `graphify build snapshot` and `graphify status` steps).
*/
function writeFakeGsdTools() {
const binDir = path.join(fakeHome, '.claude', 'gsd-core', 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(
path.join(binDir, 'gsd-tools.cjs'),
['#!/usr/bin/env node', 'process.exit(0);'].join('\n'),
{ mode: 0o755 },
);
}
/**
* Populate the sandbox with the minimal directory structure and output files
* that a real `graphify update .` would produce. `includeHtml` controls
* whether graphify-out/graph.html is created (simulating the node-limit skip
* when false).
*/
function populateSandbox(includeHtml) {
// graphify-out/ — simulates graphify CLI output directory
const outDir = path.join(sandbox, 'graphify-out');
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'graph.json'), '{}');
fs.writeFileSync(path.join(outDir, 'GRAPH_REPORT.md'), '# report');
if (includeHtml) {
fs.writeFileSync(path.join(outDir, 'graph.html'), '<html/>');
}
// .planning/graphs/ — destination directory
const graphsDir = path.join(sandbox, '.planning', 'graphs');
fs.mkdirSync(graphsDir, { recursive: true });
}
/**
* Execute the extracted Step 3 block in the sandbox.
*/
function runBlock(block) {
return spawnSync('bash', ['-c', block], {
cwd: sandbox,
env: {
...process.env,
PATH: fakeBin + ':' + process.env.PATH,
HOME: fakeHome,
},
encoding: 'utf8',
});
}
// ─── tests ───────────────────────────────────────────────────────────────────
describe('bug #622: graph.html absence must not abort the Step 3 shell chain', () => {
let block;
before(() => {
block = extractStep3Block();
});
test('Step 3 bash block is present in graphify.md (sanity gate)', () => {
assert.ok(block !== null, 'Step 3 bash block starting with "graphify update ." was not found in commands/gsd/graphify.md');
assert.ok(block.length > 0, 'Extracted bash block must not be empty');
});
test('graph.html absent: chain exits 0 and all other artifacts are copied (#622 regression)', (t) => {
// Use t.after for per-test cleanup so sandbox is fresh for each test
t.after(() => {
// Remove and recreate sandbox so the next test starts with an empty dir
cleanup(sandbox);
fs.mkdirSync(sandbox, { recursive: true });
});
writeFakeGraphify();
writeFakeGsdTools();
populateSandbox(false); // no graph.html — simulates node-limit skip
const result = runBlock(block);
// Chain must not abort
assert.equal(result.status, 0, [
'Expected exit 0 but got ' + result.status,
'stderr: ' + result.stderr,
'stdout: ' + result.stdout,
].join('\n'));
// graph.json was copied (step before the guarded line)
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.json')),
'.planning/graphs/graph.json must be copied even when graph.html is absent',
);
// GRAPH_REPORT.md was copied (step AFTER the guarded line — key regression assertion)
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'GRAPH_REPORT.md')),
'.planning/graphs/GRAPH_REPORT.md must be copied (the chain must not abort at graph.html)',
);
// graph.html must NOT exist in the destination (correctly skipped)
assert.ok(
!fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.html')),
'.planning/graphs/graph.html must NOT be created when source is absent',
);
});
test('graph.html present: chain exits 0 and graph.html is copied (happy path)', (t) => {
t.after(() => {
cleanup(sandbox);
fs.mkdirSync(sandbox, { recursive: true });
});
writeFakeGraphify();
writeFakeGsdTools();
populateSandbox(true); // include graph.html
const result = runBlock(block);
assert.equal(result.status, 0, [
'Expected exit 0 but got ' + result.status,
'stderr: ' + result.stderr,
'stdout: ' + result.stdout,
].join('\n'));
// graph.html must exist in the destination (normal copy)
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.html')),
'.planning/graphs/graph.html must be copied when the source file is present',
);
// Other artifacts also copied
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'graph.json')),
'.planning/graphs/graph.json must be copied',
);
assert.ok(
fs.existsSync(path.join(sandbox, '.planning', 'graphs', 'GRAPH_REPORT.md')),
'.planning/graphs/GRAPH_REPORT.md must be copied',
);
});
});