- Added a new Archify skill, enabling users to create polished architecture, workflow, sequence, data-flow, and lifecycle diagrams. - Implemented comprehensive functionality including rendering, validation, and delivery of diagrams in various formats. - Integrated a user-friendly command-line interface for generating and previewing diagrams. - Developed supporting files including package.json, LICENSE, and SKILL.md for documentation and licensing. - Added unit tests to ensure reliability and functionality of the new skill. These changes enhance the application by providing a structured approach to visualizing system architecture and workflows, improving user experience and data representation.
588 lines
23 KiB
JavaScript
588 lines
23 KiB
JavaScript
import { spawn, spawnSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { startPreview } from '../bin/preview.mjs';
|
|
import { loadDiagram, writeDiagram } from '../renderers/shared/cli.mjs';
|
|
import { pathsAlias } from '../renderers/shared/output-path.mjs';
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const skillRoot = path.resolve(here, '..');
|
|
const cli = path.join(skillRoot, 'bin/archify.mjs');
|
|
const workflowFixture = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
|
const baseFixture = path.join(skillRoot, 'examples/checkout-platform.base.architecture.json');
|
|
const headFixture = path.join(skillRoot, 'examples/checkout-platform.head.architecture.json');
|
|
|
|
function run(args, cwd) {
|
|
return spawnSync(process.execPath, [cli, ...args], {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
});
|
|
}
|
|
|
|
function copyInstalledSkill(target) {
|
|
fs.cpSync(skillRoot, target, {
|
|
recursive: true,
|
|
filter(source) {
|
|
const relative = path.relative(skillRoot, source);
|
|
return relative !== 'node_modules'
|
|
&& !relative.startsWith(`node_modules${path.sep}`)
|
|
&& relative !== 'test'
|
|
&& !relative.startsWith(`test${path.sep}`);
|
|
},
|
|
});
|
|
}
|
|
|
|
function directoryAliasesNames(directory, authoredName, lookupName) {
|
|
const authoredPath = path.join(directory, authoredName);
|
|
const lookupPath = path.join(directory, lookupName);
|
|
fs.writeFileSync(authoredPath, 'filesystem semantics probe', { flag: 'wx' });
|
|
try {
|
|
let authored;
|
|
let lookup;
|
|
try {
|
|
authored = fs.statSync(authoredPath);
|
|
lookup = fs.statSync(lookupPath);
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return false;
|
|
throw error;
|
|
}
|
|
return authored.dev === lookup.dev && authored.ino === lookup.ino;
|
|
} finally {
|
|
fs.unlinkSync(authoredPath);
|
|
}
|
|
}
|
|
|
|
test('future-path aliases follow the containing directory case and Unicode semantics', () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-semantics-'));
|
|
const caseInsensitive = directoryAliasesNames(cwd, 'ArchifyCaseProbe', 'archifycaseprobe');
|
|
const normalizationInsensitive = directoryAliasesNames(
|
|
cwd,
|
|
'archify-norm-\u00e9-probe',
|
|
'archify-norm-e\u0301-probe',
|
|
);
|
|
|
|
assert.equal(
|
|
pathsAlias(path.join(cwd, 'Future.HTML'), path.join(cwd, 'future.html')),
|
|
caseInsensitive,
|
|
);
|
|
assert.equal(
|
|
pathsAlias(path.join(cwd, 'Caf\u00e9.html'), path.join(cwd, 'Cafe\u0301.html')),
|
|
normalizationInsensitive,
|
|
);
|
|
});
|
|
|
|
test('compare rejects case-only future targets before input work when the directory aliases case', () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-compare-case-'));
|
|
const caseInsensitive = directoryAliasesNames(cwd, 'ArchifyCaseProbe', 'archifycaseprobe');
|
|
const output = path.join(cwd, 'Future.HTML');
|
|
const receiptPath = path.join(cwd, 'future.html');
|
|
|
|
const result = run([
|
|
'compare', 'architecture',
|
|
path.join(cwd, 'missing-base.json'),
|
|
path.join(cwd, 'missing-head.json'),
|
|
output,
|
|
'--receipt', receiptPath,
|
|
'--json',
|
|
], cwd);
|
|
|
|
assert.equal(result.status, 1);
|
|
const receipt = JSON.parse(result.stdout);
|
|
assert.equal(
|
|
receipt.diagnostics[0].code,
|
|
caseInsensitive ? 'output/target-alias' : 'delta/base-input',
|
|
);
|
|
assert.equal(receipt.stage, caseInsensitive ? 'prepare' : 'input');
|
|
});
|
|
|
|
test('render reports an output symlink cycle as a structured output diagnostic', () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-cycle-'));
|
|
const input = path.join(cwd, 'diagram.workflow.json');
|
|
const output = path.join(cwd, 'cycle-a.html');
|
|
const otherLink = path.join(cwd, 'cycle-b.html');
|
|
fs.copyFileSync(workflowFixture, input);
|
|
fs.symlinkSync(otherLink, output, 'file');
|
|
fs.symlinkSync(output, otherLink, 'file');
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(skillRoot, 'renderers/workflow/render-workflow.mjs'), input, output],
|
|
{
|
|
cwd,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, ARCHIFY_DIAGNOSTIC_FORMAT: 'json' },
|
|
},
|
|
);
|
|
|
|
assert.equal(result.status, 1);
|
|
const failure = JSON.parse(result.stderr);
|
|
assert.equal(failure.diagnostics[0].code, 'output/symlink-cycle');
|
|
assert.equal(failure.diagnostics[0].subject.output, output);
|
|
assert.ok(failure.diagnostics[0].supportedFixes.length > 0);
|
|
});
|
|
|
|
test('render rejects an output symlink that aliases its JSON input', () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-render-'));
|
|
const input = path.join(cwd, 'diagram.workflow.json');
|
|
const output = path.join(cwd, 'diagram.html');
|
|
const source = fs.readFileSync(workflowFixture);
|
|
fs.writeFileSync(input, source);
|
|
fs.symlinkSync(input, output, 'file');
|
|
|
|
const result = run(['render', 'workflow', input, output], cwd);
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stderr, /output must not replace an input/i);
|
|
assert.deepEqual(fs.readFileSync(input), source);
|
|
});
|
|
|
|
test('render rejects an existing output hard link to its JSON input', () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-render-hardlink-'));
|
|
const input = path.join(cwd, 'diagram.workflow.json');
|
|
const output = path.join(cwd, 'diagram.html');
|
|
const source = fs.readFileSync(workflowFixture);
|
|
fs.writeFileSync(input, source);
|
|
fs.linkSync(input, output);
|
|
|
|
const result = run(['render', 'workflow', input, output], cwd);
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stderr, /output must not replace an input/i);
|
|
assert.deepEqual(fs.readFileSync(input), source);
|
|
});
|
|
|
|
test('render rejects an absolute meta.output when no CLI output is provided', () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-meta-absolute-'));
|
|
const input = path.join(cwd, 'diagram.workflow.json');
|
|
const output = path.join(cwd, 'authored.html');
|
|
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
|
|
source.meta.output = output;
|
|
fs.writeFileSync(input, JSON.stringify(source));
|
|
|
|
const result = run(['render', 'workflow', input], cwd);
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stderr, /meta\.output must be a relative path/i);
|
|
assert.equal(fs.existsSync(output), false);
|
|
});
|
|
|
|
test('render rejects a relative meta.output that escapes the working directory', () => {
|
|
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-meta-parent-'));
|
|
const cwd = path.join(parent, 'work');
|
|
fs.mkdirSync(cwd);
|
|
const input = path.join(cwd, 'diagram.workflow.json');
|
|
const output = path.join(parent, 'escaped.html');
|
|
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
|
|
source.meta.output = '../escaped.html';
|
|
fs.writeFileSync(input, JSON.stringify(source));
|
|
|
|
const result = run(['render', 'workflow', input], cwd);
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stderr, /meta\.output must stay inside the current working directory/i);
|
|
assert.equal(fs.existsSync(output), false);
|
|
});
|
|
|
|
test('render rejects a meta.output that escapes through a directory symlink', () => {
|
|
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-meta-link-'));
|
|
const cwd = path.join(parent, 'work');
|
|
const outside = path.join(parent, 'outside');
|
|
fs.mkdirSync(cwd);
|
|
fs.mkdirSync(outside);
|
|
fs.symlinkSync(outside, path.join(cwd, 'linked'), 'dir');
|
|
const input = path.join(cwd, 'diagram.workflow.json');
|
|
const output = path.join(outside, 'authored.html');
|
|
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
|
|
source.meta.output = 'linked/authored.html';
|
|
fs.writeFileSync(input, JSON.stringify(source));
|
|
|
|
const result = run(['render', 'workflow', input], cwd);
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stderr, /meta\.output must stay inside the current working directory/i);
|
|
assert.equal(fs.existsSync(output), false);
|
|
});
|
|
|
|
test('render requires a meta.output target with an html extension', () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-meta-extension-'));
|
|
const input = path.join(cwd, 'diagram.workflow.json');
|
|
const output = path.join(cwd, 'authored.json');
|
|
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
|
|
source.meta.output = 'authored.json';
|
|
fs.writeFileSync(input, JSON.stringify(source));
|
|
|
|
const result = run(['render', 'workflow', input], cwd);
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stderr, /meta\.output must target an? \.html file/i);
|
|
assert.equal(fs.existsSync(output), false);
|
|
});
|
|
|
|
test('render rejects a meta.output symlink that resolves to a non-html target', () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-meta-extension-link-'));
|
|
const input = path.join(cwd, 'diagram.workflow.json');
|
|
const target = path.join(cwd, 'authored.json');
|
|
const output = path.join(cwd, 'authored.html');
|
|
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
|
|
source.meta.output = 'authored.html';
|
|
fs.writeFileSync(input, JSON.stringify(source));
|
|
fs.writeFileSync(target, 'trusted target');
|
|
fs.symlinkSync(target, output, 'file');
|
|
|
|
const result = run(['render', 'workflow', input], cwd);
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stderr, /meta\.output must resolve to an? \.html file/i);
|
|
assert.equal(fs.readFileSync(target, 'utf8'), 'trusted target');
|
|
});
|
|
|
|
test('deliver rejects a future-path alias of its JSON input with a structured diagnostic', () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-deliver-'));
|
|
const realDirectory = path.join(cwd, 'real');
|
|
const linkedDirectory = path.join(cwd, 'linked');
|
|
fs.mkdirSync(realDirectory);
|
|
fs.symlinkSync(realDirectory, linkedDirectory, 'dir');
|
|
const input = path.join(realDirectory, 'diagram.workflow.json');
|
|
const output = path.join(linkedDirectory, 'diagram.workflow.json');
|
|
const source = fs.readFileSync(workflowFixture);
|
|
fs.writeFileSync(input, source);
|
|
|
|
const result = run(['deliver', 'workflow', input, output, '--json'], cwd);
|
|
|
|
assert.equal(result.status, 1);
|
|
const receipt = JSON.parse(result.stdout);
|
|
assert.equal(receipt.stage, 'prepare');
|
|
assert.equal(receipt.diagnostics[0].code, 'output/input-alias');
|
|
assert.deepEqual(fs.readFileSync(input), source);
|
|
});
|
|
|
|
test('deliver rechecks aliases immediately before committing a verified candidate', { timeout: 10000 }, async () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-deliver-race-'));
|
|
const installedRoot = path.join(cwd, 'skill');
|
|
const installedBin = path.join(installedRoot, 'bin');
|
|
const installedShared = path.join(installedRoot, 'renderers/shared');
|
|
const installedRenderer = path.join(installedRoot, 'renderers/workflow');
|
|
const installedScripts = path.join(installedRoot, 'scripts');
|
|
fs.mkdirSync(installedBin, { recursive: true });
|
|
fs.mkdirSync(installedShared, { recursive: true });
|
|
fs.mkdirSync(installedRenderer, { recursive: true });
|
|
fs.mkdirSync(installedScripts, { recursive: true });
|
|
fs.copyFileSync(cli, path.join(installedBin, 'archify.mjs'));
|
|
fs.copyFileSync(
|
|
path.join(skillRoot, 'renderers/shared/output-path.mjs'),
|
|
path.join(installedShared, 'output-path.mjs'),
|
|
);
|
|
fs.writeFileSync(path.join(installedRenderer, 'render-workflow.mjs'), `
|
|
import fs from 'node:fs';
|
|
const [, output] = process.argv.slice(2);
|
|
fs.writeFileSync(process.env.ARCHIFY_TEST_RENDER_STARTED, output);
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
fs.writeFileSync(output, '<!doctype html><title>verified candidate</title><svg></svg>');
|
|
`);
|
|
fs.writeFileSync(path.join(installedScripts, 'check-render-output.mjs'), `
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
checks: [{ name: 'single_svg', ok: true }],
|
|
composition: {
|
|
profile: 'showcase',
|
|
status: 'pass',
|
|
summary: { errors: 0, warnings: 0 }
|
|
}
|
|
}));
|
|
`);
|
|
|
|
const inputDirectory = path.join(cwd, 'input');
|
|
const initialOutputDirectory = path.join(cwd, 'safe-output');
|
|
const linkedDirectory = path.join(cwd, 'linked-output');
|
|
fs.mkdirSync(inputDirectory);
|
|
fs.mkdirSync(initialOutputDirectory);
|
|
fs.symlinkSync(initialOutputDirectory, linkedDirectory, 'dir');
|
|
const input = path.join(inputDirectory, 'diagram.json');
|
|
const output = path.join(linkedDirectory, 'diagram.json');
|
|
const source = Buffer.from('{"meta":{"title":"race input"}}');
|
|
fs.writeFileSync(input, source);
|
|
const marker = path.join(cwd, 'renderer-started');
|
|
|
|
const child = spawn(process.execPath, [
|
|
path.join(installedBin, 'archify.mjs'),
|
|
'deliver', 'workflow', input, output, '--json',
|
|
], {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, ARCHIFY_TEST_RENDER_STARTED: marker },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.setEncoding('utf8');
|
|
child.stderr.setEncoding('utf8');
|
|
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
|
|
const started = Date.now();
|
|
while (!fs.existsSync(marker) && Date.now() - started < 3000) {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}
|
|
assert.equal(fs.existsSync(marker), true, `renderer did not start; stderr=${stderr}`);
|
|
const candidatePath = fs.readFileSync(marker, 'utf8');
|
|
const candidateRelative = path.relative(linkedDirectory, candidatePath);
|
|
fs.mkdirSync(path.dirname(path.join(inputDirectory, candidateRelative)), { recursive: true });
|
|
fs.unlinkSync(linkedDirectory);
|
|
fs.symlinkSync(inputDirectory, linkedDirectory, 'dir');
|
|
|
|
const status = await new Promise((resolve) => child.once('close', resolve));
|
|
|
|
assert.equal(status, 1, stderr);
|
|
const receipt = JSON.parse(stdout);
|
|
assert.equal(receipt.stage, 'commit');
|
|
assert.equal(receipt.diagnostics[0].code, 'output/input-alias');
|
|
assert.deepEqual(fs.readFileSync(input), source);
|
|
});
|
|
|
|
test('compare rejects an artifact path that aliases either architecture input', () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-compare-'));
|
|
const realDirectory = path.join(cwd, 'real');
|
|
const linkedDirectory = path.join(cwd, 'linked');
|
|
fs.mkdirSync(realDirectory);
|
|
fs.symlinkSync(realDirectory, linkedDirectory, 'dir');
|
|
const base = path.join(realDirectory, 'review.html');
|
|
const output = path.join(linkedDirectory, 'review.html');
|
|
const baseSource = fs.readFileSync(baseFixture);
|
|
fs.writeFileSync(base, baseSource);
|
|
|
|
const result = run(['compare', 'architecture', base, headFixture, output, '--json'], cwd);
|
|
|
|
assert.equal(result.status, 1);
|
|
const receipt = JSON.parse(result.stdout);
|
|
assert.equal(receipt.stage, 'prepare');
|
|
assert.equal(receipt.diagnostics[0].code, 'output/input-alias');
|
|
assert.deepEqual(fs.readFileSync(base), baseSource);
|
|
});
|
|
|
|
test('compare rejects a receipt path that aliases either architecture input', () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-compare-receipt-'));
|
|
const base = path.join(cwd, 'base.json');
|
|
const output = path.join(cwd, 'delta.html');
|
|
const baseSource = fs.readFileSync(baseFixture);
|
|
fs.writeFileSync(base, baseSource);
|
|
|
|
const result = run([
|
|
'compare', 'architecture', base, headFixture, output,
|
|
'--receipt', base, '--json',
|
|
], cwd);
|
|
|
|
assert.equal(result.status, 1);
|
|
const receipt = JSON.parse(result.stdout);
|
|
assert.equal(receipt.stage, 'prepare');
|
|
assert.equal(receipt.diagnostics[0].code, 'output/input-alias');
|
|
assert.deepEqual(fs.readFileSync(base), baseSource);
|
|
assert.equal(fs.existsSync(output), false);
|
|
});
|
|
|
|
test('compare rejects a dangling receipt symlink to the future artifact path', () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-compare-pair-'));
|
|
const output = path.join(cwd, 'delta.html');
|
|
const receiptPath = path.join(cwd, 'delta.receipt.json');
|
|
fs.symlinkSync(output, receiptPath, 'file');
|
|
|
|
const result = run([
|
|
'compare', 'architecture', baseFixture, headFixture, output,
|
|
'--receipt', receiptPath, '--json',
|
|
], cwd);
|
|
|
|
assert.equal(result.status, 1);
|
|
const receipt = JSON.parse(result.stdout);
|
|
assert.equal(receipt.stage, 'prepare');
|
|
assert.equal(receipt.diagnostics[0].code, 'output/target-alias');
|
|
assert.equal(fs.lstatSync(receiptPath).isSymbolicLink(), true);
|
|
assert.equal(fs.existsSync(output), false);
|
|
});
|
|
|
|
test('preview applies the meta.output relative-path boundary before starting a server', async () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-preview-meta-'));
|
|
const input = path.join(cwd, 'diagram.workflow.json');
|
|
const output = path.join(cwd, 'authored.html');
|
|
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
|
|
source.meta.output = output;
|
|
fs.writeFileSync(input, JSON.stringify(source));
|
|
|
|
const failure = await startPreview({
|
|
type: 'workflow',
|
|
input,
|
|
open: false,
|
|
watch: false,
|
|
cwd,
|
|
}).then(async (preview) => {
|
|
await preview.stop();
|
|
return null;
|
|
}, (error) => error);
|
|
|
|
assert.ok(failure instanceof Error);
|
|
assert.match(failure.message, /meta\.output must be a relative path/i);
|
|
assert.equal(fs.existsSync(output), false);
|
|
});
|
|
|
|
test('the shared renderer rechecks its guarded output immediately before writing', () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-render-race-'));
|
|
const inputDirectory = path.join(cwd, 'input');
|
|
const initialOutputDirectory = path.join(cwd, 'safe-output');
|
|
const linkedDirectory = path.join(cwd, 'linked-output');
|
|
fs.mkdirSync(inputDirectory);
|
|
fs.mkdirSync(initialOutputDirectory);
|
|
fs.symlinkSync(initialOutputDirectory, linkedDirectory, 'dir');
|
|
const input = path.join(inputDirectory, 'diagram.workflow.json');
|
|
const output = path.join(linkedDirectory, 'diagram.workflow.json');
|
|
const source = fs.readFileSync(workflowFixture);
|
|
fs.writeFileSync(input, source);
|
|
|
|
const loaded = loadDiagram({
|
|
rendererDir: path.join(skillRoot, 'renderers/workflow'),
|
|
diagramType: 'workflow',
|
|
defaultExample: 'agent-tool-call.workflow.json',
|
|
argv: ['node', 'render-workflow.mjs', input, output],
|
|
});
|
|
fs.unlinkSync(linkedDirectory);
|
|
fs.symlinkSync(inputDirectory, linkedDirectory, 'dir');
|
|
|
|
assert.throws(
|
|
() => writeDiagram({
|
|
outPath: loaded.outPath,
|
|
template: loaded.template,
|
|
diagramType: 'workflow',
|
|
meta: loaded.diagram.meta,
|
|
svg: '<svg role="img"></svg>',
|
|
cards: [],
|
|
}),
|
|
/output must not replace an input/i,
|
|
);
|
|
assert.deepEqual(fs.readFileSync(input), source);
|
|
});
|
|
|
|
test('compare rechecks every target immediately before committing the artifact pair', { timeout: 10000 }, async () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-compare-race-'));
|
|
const installedRoot = path.join(cwd, 'skill');
|
|
const installedBin = path.join(installedRoot, 'bin');
|
|
const installedShared = path.join(installedRoot, 'renderers/shared');
|
|
const installedRenderer = path.join(installedRoot, 'renderers/architecture');
|
|
const installedScripts = path.join(installedRoot, 'scripts');
|
|
const installedDelta = path.join(installedRoot, 'delta');
|
|
for (const directory of [installedBin, installedShared, installedRenderer, installedScripts, installedDelta]) {
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
}
|
|
fs.copyFileSync(cli, path.join(installedBin, 'archify.mjs'));
|
|
fs.copyFileSync(
|
|
path.join(skillRoot, 'renderers/shared/output-path.mjs'),
|
|
path.join(installedShared, 'output-path.mjs'),
|
|
);
|
|
fs.writeFileSync(path.join(installedRenderer, 'render-architecture.mjs'), `
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
const [, output] = process.argv.slice(2);
|
|
if (path.basename(output) === 'head.html') {
|
|
const marker = process.env.ARCHIFY_TEST_RENDER_STARTED;
|
|
const markerCandidate = marker + '.tmp';
|
|
fs.writeFileSync(markerCandidate, output);
|
|
fs.renameSync(markerCandidate, marker);
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
}
|
|
fs.writeFileSync(output, '<!doctype html><svg role="img"></svg>');
|
|
`);
|
|
fs.writeFileSync(path.join(installedScripts, 'check-render-output.mjs'), `
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
checks: [{ name: 'single_svg', ok: true }],
|
|
composition: {
|
|
profile: 'showcase',
|
|
status: 'pass',
|
|
summary: { errors: 0, warnings: 0 }
|
|
}
|
|
}));
|
|
`);
|
|
fs.writeFileSync(path.join(installedDelta, 'architecture-delta.mjs'), `
|
|
export class ArchitectureDeltaError extends Error {}
|
|
export const annotateArchitectureSideSvg = (svg) => svg;
|
|
export const buildDeltaSvg = () => '<svg role="img"></svg>';
|
|
export const canonicalArchitecture = (value) => value;
|
|
export const canonicalArchitectureJson = (value) => JSON.stringify(value);
|
|
export const compareArchitecture = () => ({
|
|
command: 'compare',
|
|
base: {},
|
|
head: {},
|
|
completeness: 'complete',
|
|
proofLevel: 'authored'
|
|
});
|
|
export const extractArchitectureSvg = () => '<svg role="img"></svg>';
|
|
export const extractArtifactCss = () => '';
|
|
export const renderArchitectureDeltaHtml = () => '<!doctype html><svg role="img"></svg>';
|
|
export const validateArchitectureDeltaHtml = () => ({ checksPassed: 1, checkCount: 1 });
|
|
`);
|
|
|
|
const inputDirectory = path.join(cwd, 'input');
|
|
const initialOutputDirectory = path.join(cwd, 'safe-output');
|
|
const linkedDirectory = path.join(cwd, 'linked-output');
|
|
fs.mkdirSync(inputDirectory);
|
|
fs.mkdirSync(initialOutputDirectory);
|
|
fs.symlinkSync(initialOutputDirectory, linkedDirectory, 'dir');
|
|
const base = path.join(inputDirectory, 'diagram.json');
|
|
const head = path.join(cwd, 'head.json');
|
|
const output = path.join(linkedDirectory, 'diagram.json');
|
|
const source = Buffer.from('{"side":"base"}');
|
|
fs.writeFileSync(base, source);
|
|
fs.writeFileSync(head, '{"side":"head"}');
|
|
const marker = path.join(cwd, 'renderer-started');
|
|
|
|
const child = spawn(process.execPath, [
|
|
path.join(installedBin, 'archify.mjs'),
|
|
'compare', 'architecture', base, head, output, '--json',
|
|
], {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, ARCHIFY_TEST_RENDER_STARTED: marker },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.setEncoding('utf8');
|
|
child.stderr.setEncoding('utf8');
|
|
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
|
|
const started = Date.now();
|
|
while (!fs.existsSync(marker) && Date.now() - started < 3000) {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}
|
|
assert.equal(fs.existsSync(marker), true, `renderer did not start; stderr=${stderr}`);
|
|
const candidatePath = fs.readFileSync(marker, 'utf8');
|
|
const candidateRelative = path.relative(linkedDirectory, candidatePath);
|
|
fs.mkdirSync(path.dirname(path.join(inputDirectory, candidateRelative)), { recursive: true });
|
|
fs.unlinkSync(linkedDirectory);
|
|
fs.symlinkSync(inputDirectory, linkedDirectory, 'dir');
|
|
|
|
const status = await new Promise((resolve) => child.once('close', resolve));
|
|
|
|
assert.equal(status, 1, stderr);
|
|
const receipt = JSON.parse(stdout);
|
|
assert.equal(receipt.stage, 'commit');
|
|
assert.equal(receipt.diagnostics[0].code, 'output/input-alias');
|
|
assert.deepEqual(fs.readFileSync(base), source);
|
|
});
|
|
|
|
test('doctor reports a missing output-path safety runtime in an installed skill', () => {
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-doctor-'));
|
|
const installedRoot = path.join(cwd, 'skill');
|
|
copyInstalledSkill(installedRoot);
|
|
fs.rmSync(path.join(installedRoot, 'renderers/shared/output-path.mjs'));
|
|
|
|
const result = spawnSync(process.execPath, [path.join(installedRoot, 'bin/archify.mjs'), 'doctor'], {
|
|
cwd: installedRoot,
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stdout, /\[missing\] Output path safety runtime/);
|
|
});
|