feat: introduce archify skill for generating architecture diagrams
- 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.
This commit is contained in:
@@ -0,0 +1,785 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { extractSvgs, parseXml } from './helpers/xml.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-cli-'));
|
||||
const cli = path.join(skillRoot, 'bin/archify.mjs');
|
||||
|
||||
function run(args, options = {}) {
|
||||
return spawnSync(process.execPath, [cli, ...args], {
|
||||
cwd: options.cwd || skillRoot,
|
||||
encoding: 'utf8',
|
||||
env: options.env || process.env,
|
||||
});
|
||||
}
|
||||
|
||||
function sha256(file) {
|
||||
return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
function makeFakeOpeners(name, { exitCode = 0 } = {}) {
|
||||
const bin = path.join(tmp, name);
|
||||
const log = path.join(bin, 'open-log.json');
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
const source = `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const target = process.argv[process.argv.length - 1];
|
||||
fs.writeFileSync(process.env.ARCHIFY_TEST_OPEN_LOG, JSON.stringify({
|
||||
argv: process.argv.slice(2),
|
||||
target,
|
||||
existed: fs.existsSync(target),
|
||||
}));
|
||||
process.exit(${exitCode});
|
||||
`;
|
||||
for (const command of ['open', 'xdg-open']) {
|
||||
const executable = path.join(bin, command);
|
||||
fs.writeFileSync(executable, source);
|
||||
fs.chmodSync(executable, 0o755);
|
||||
}
|
||||
return {
|
||||
log,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}${path.delimiter}${process.env.PATH || ''}`,
|
||||
ARCHIFY_TEST_OPEN_LOG: log,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function copyInstalledSkill(target) {
|
||||
fs.cpSync(skillRoot, target, {
|
||||
recursive: true,
|
||||
filter(source) {
|
||||
const rel = path.relative(skillRoot, source);
|
||||
return rel !== 'node_modules' && !rel.startsWith(`node_modules${path.sep}`)
|
||||
&& rel !== 'test' && !rel.startsWith(`test${path.sep}`)
|
||||
// Another test creates this short-lived directory under skillRoot so
|
||||
// Ajv resolves from the checkout. Never copy a concurrently removed
|
||||
// test fixture into an installed-skill simulation.
|
||||
&& !rel.startsWith('.validator-check-');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('cli: help lists commands and diagram types', () => {
|
||||
const result = run(['--help']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /archify render <type>/);
|
||||
assert.match(result.stdout, /archify compare architecture <base\.json> <head\.json>/);
|
||||
assert.match(result.stdout, /archify deliver <type>/);
|
||||
assert.match(result.stdout, /archify preview <type>/);
|
||||
assert.match(result.stdout, /archify visual-check <output\.html>/);
|
||||
assert.match(result.stdout, /--open/);
|
||||
assert.match(result.stdout, /--repo-root path \(architecture only\)/);
|
||||
assert.match(result.stdout, /archify guide \[scenario or question\]/);
|
||||
assert.match(result.stdout, /archify doctor/);
|
||||
assert.match(result.stdout, /archify demo \[output-directory\]/);
|
||||
assert.match(result.stdout, /architecture, workflow, sequence, dataflow, lifecycle/);
|
||||
});
|
||||
|
||||
test('cli: doctor reports a complete installation is ready', () => {
|
||||
const result = run(['doctor']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /\[ok\] Node\.js v\d+/);
|
||||
assert.match(result.stdout, /\[ok\] Core template/);
|
||||
assert.match(result.stdout, /\[ok\] Example renderer/);
|
||||
assert.match(result.stdout, /\[ok\] Live preview runtime/);
|
||||
assert.match(result.stdout, /\[ok\] Scenario recipe guide/);
|
||||
assert.match(result.stdout, /\[ok\] Progressive authoring references/);
|
||||
assert.match(result.stdout, /\[ok\] Architecture compare runtime and proof fixtures/);
|
||||
assert.match(result.stdout, /\[ok\] Standalone schema validators/);
|
||||
assert.match(result.stdout, /\[ok\] architecture renderer, schema, and example/);
|
||||
assert.match(result.stdout, /\[ok\] lifecycle renderer, schema, and example/);
|
||||
assert.match(result.stdout, /Archify is ready\./);
|
||||
});
|
||||
|
||||
test('cli: doctor identifies an incomplete installation', () => {
|
||||
const incompleteRoot = path.join(tmp, 'incomplete-skill');
|
||||
const incompleteBin = path.join(incompleteRoot, 'bin');
|
||||
fs.mkdirSync(incompleteBin, { recursive: true });
|
||||
fs.copyFileSync(cli, path.join(incompleteBin, 'archify.mjs'));
|
||||
|
||||
const result = spawnSync(process.execPath, [path.join(incompleteBin, 'archify.mjs'), 'doctor'], {
|
||||
cwd: incompleteRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stdout, /\[missing\] Core template/);
|
||||
assert.match(result.stdout, /\[missing\] Scenario recipe guide/);
|
||||
assert.match(result.stdout, /\[missing\] workflow renderer, schema, and example/);
|
||||
assert.match(result.stderr, /Archify is not ready: \d+ required files? missing\./);
|
||||
});
|
||||
|
||||
test('cli: doctor rejects a corrupt standalone validator', () => {
|
||||
const corruptRoot = path.join(tmp, 'corrupt-skill');
|
||||
copyInstalledSkill(corruptRoot);
|
||||
fs.writeFileSync(path.join(corruptRoot, 'renderers/shared/generated-validators.mjs'), 'export const workflow = ;\n');
|
||||
|
||||
const result = spawnSync(process.execPath, [path.join(corruptRoot, 'bin/archify.mjs'), 'doctor'], {
|
||||
cwd: corruptRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stdout, /\[invalid\] Standalone schema validators/);
|
||||
assert.match(result.stderr, /Archify is not ready: 1 runtime check failed\./);
|
||||
});
|
||||
|
||||
test('cli: examples renders from an installed skill', () => {
|
||||
const installedRoot = path.join(tmp, 'installed-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
|
||||
const result = spawnSync(process.execPath, [path.join(installedRoot, 'bin/archify.mjs'), 'examples'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
for (const output of [
|
||||
'workflow-agent-tool-call-rendered.html',
|
||||
'sequence-cache-miss-request.html',
|
||||
'dataflow-product-analytics.html',
|
||||
'lifecycle-agent-run.html',
|
||||
'web-app-rendered.html',
|
||||
]) {
|
||||
assert.equal(fs.existsSync(path.join(installedRoot, 'examples', output)), true, output);
|
||||
}
|
||||
});
|
||||
|
||||
test('cli: guide lists all scenario recipes by diagram type', () => {
|
||||
const result = run(['guide']);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Archify scenario recipes \(11\)/);
|
||||
for (const type of ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']) {
|
||||
assert.match(result.stdout, new RegExp(`\\[${type}\\]`));
|
||||
}
|
||||
});
|
||||
|
||||
test('cli: guide recommends a scenario as structured json', () => {
|
||||
const result = run(['guide', 'Show an API request with Redis cache miss', '--json']);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.lang, 'en');
|
||||
assert.equal(parsed.confidence, 'high');
|
||||
assert.equal(parsed.recommendation.id, 'api-request');
|
||||
assert.equal(parsed.recommendation.type, 'sequence');
|
||||
});
|
||||
|
||||
test('cli: guide detects Chinese and explains the recommendation boundary', () => {
|
||||
const result = run(['guide', '展示 Kafka topic 消费者组和死信队列']);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /推荐: 事件流拓扑 \[dataflow\]/);
|
||||
assert.match(result.stdout, /不要这样用:/);
|
||||
assert.match(result.stdout, /必须包含:/);
|
||||
assert.match(result.stdout, /可直接复制的提示词:/);
|
||||
});
|
||||
|
||||
test('cli: guide works from an installed skill without node_modules', () => {
|
||||
const installedRoot = path.join(tmp, 'installed-guide-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
const installedCli = path.join(installedRoot, 'bin/archify.mjs');
|
||||
|
||||
const result = spawnSync(process.execPath, [installedCli, 'guide', 'incident-runbook', '--json'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(JSON.parse(result.stdout).recommendation.id, 'incident-runbook');
|
||||
});
|
||||
|
||||
test('cli: demo creates a ready-to-open diagram in a chosen directory', () => {
|
||||
const outputDirectory = path.join(tmp, 'my-demo');
|
||||
const output = path.join(outputDirectory, 'archify-demo.html');
|
||||
const result = run(['demo', outputDirectory]);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.existsSync(output), true);
|
||||
assert.match(fs.readFileSync(output, 'utf8'), /Sample Web App Diagram/);
|
||||
assert.match(result.stdout, new RegExp(`Demo ready: ${output.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
|
||||
assert.match(result.stdout, /Next: open the HTML in your browser/);
|
||||
assert.match(result.stdout, /archify render architecture/);
|
||||
});
|
||||
|
||||
test('cli: demo defaults to the current directory', () => {
|
||||
const workingDirectory = path.join(tmp, 'default-demo');
|
||||
fs.mkdirSync(workingDirectory);
|
||||
const result = run(['demo'], { cwd: workingDirectory });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.existsSync(path.join(workingDirectory, 'archify-demo.html')), true);
|
||||
});
|
||||
|
||||
test('cli: render writes a diagram html file', () => {
|
||||
const out = path.join(tmp, 'workflow.html');
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const result = run(['render', 'workflow', input, out]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.existsSync(out), true);
|
||||
assert.match(fs.readFileSync(out, 'utf8'), /Agent Tool Call Workflow/);
|
||||
});
|
||||
|
||||
test('cli: visual-check returns a skipped receipt with exit 2 when Chrome is unavailable', () => {
|
||||
const out = path.join(tmp, 'visual-check-skipped.html');
|
||||
fs.writeFileSync(out, '<!doctype html><html><body>delivered</body></html>');
|
||||
const missingChrome = path.join(tmp, 'missing-chrome');
|
||||
const result = run(['visual-check', out, '--json'], {
|
||||
env: { ...process.env, ARCHIFY_CHROME: missingChrome },
|
||||
});
|
||||
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.status, 'skipped');
|
||||
assert.equal(receipt.visualReview, 'pending');
|
||||
assert.equal(receipt.chrome.status, 'unavailable');
|
||||
assert.equal(fs.existsSync(out.replace(/\.html$/, '.visual-check.json')), true);
|
||||
});
|
||||
|
||||
test('cli: deliver atomically writes a checked artifact and structured receipt', () => {
|
||||
const out = path.join(tmp, 'delivered-workflow.html');
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const result = run(['deliver', 'workflow', input, out, '--quality', 'showcase', '--json']);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.existsSync(out), true);
|
||||
assert.match(fs.readFileSync(out, 'utf8'), /Agent Tool Call Workflow/);
|
||||
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.schemaVersion, 1);
|
||||
assert.equal(receipt.ok, true);
|
||||
assert.equal(receipt.command, 'deliver');
|
||||
assert.equal(receipt.type, 'workflow');
|
||||
assert.equal(receipt.input, input);
|
||||
assert.equal(receipt.output, out);
|
||||
assert.deepEqual(receipt.specification, {
|
||||
sha256: sha256(input),
|
||||
bytes: fs.statSync(input).size,
|
||||
});
|
||||
assert.match(receipt.artifact.sha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(receipt.artifact.sha256, sha256(out));
|
||||
assert.equal(receipt.artifact.bytes, fs.statSync(out).size);
|
||||
assert.deepEqual(receipt.validation, {
|
||||
checksPassed: 9,
|
||||
checkCount: 9,
|
||||
compositionProfile: 'showcase',
|
||||
compositionStatus: 'pass',
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
});
|
||||
assert.equal('open' in receipt, false);
|
||||
});
|
||||
|
||||
test('cli: deliver --open launches only the committed absolute artifact as one argument', {
|
||||
skip: process.platform === 'win32',
|
||||
}, () => {
|
||||
const fake = makeFakeOpeners('successful-open');
|
||||
const out = path.join(tmp, `-复杂 path 'quoted'`, 'verified diagram.html');
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const result = run(['deliver', 'workflow', input, out, '--open', '--json'], { env: fake.env });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.deepEqual(receipt.open, {
|
||||
requested: true,
|
||||
status: 'opened',
|
||||
target: out,
|
||||
method: process.platform === 'darwin' ? 'open' : 'xdg-open',
|
||||
});
|
||||
const invocation = JSON.parse(fs.readFileSync(fake.log, 'utf8'));
|
||||
assert.equal(invocation.existed, true, 'the opener must run after the atomic commit');
|
||||
assert.deepEqual(invocation.argv, [out]);
|
||||
assert.equal(invocation.target, out);
|
||||
assert.equal(fs.existsSync(out), true);
|
||||
});
|
||||
|
||||
test('cli: opener failure does not invalidate a verified delivery or pollute json stdout', {
|
||||
skip: process.platform === 'win32',
|
||||
}, () => {
|
||||
const fake = makeFakeOpeners('failed-open', { exitCode: 17 });
|
||||
const out = path.join(tmp, 'open-failure-preserves-delivery.html');
|
||||
const input = path.join(skillRoot, 'examples/web-app.architecture.json');
|
||||
const result = run(['deliver', 'architecture', input, out, '--open', '--json'], { env: fake.env });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, true);
|
||||
assert.equal(receipt.open.status, 'failed');
|
||||
assert.equal(receipt.open.target, out);
|
||||
assert.match(result.stderr, /Could not open the verified artifact/);
|
||||
assert.match(result.stderr, new RegExp(out.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.equal(fs.existsSync(out), true);
|
||||
assert.equal(receipt.artifact.sha256, sha256(out));
|
||||
});
|
||||
|
||||
test('cli: deliver failure never invokes the optional opener', {
|
||||
skip: process.platform === 'win32',
|
||||
}, () => {
|
||||
const fake = makeFakeOpeners('never-open');
|
||||
const input = path.join(tmp, 'invalid-open-delivery.json');
|
||||
fs.writeFileSync(input, '{broken json');
|
||||
const out = path.join(tmp, 'must-not-open.html');
|
||||
const result = run(['deliver', 'architecture', input, out, '--open', '--json'], { env: fake.env });
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(JSON.parse(result.stdout).stage, 'input');
|
||||
assert.equal(fs.existsSync(fake.log), false);
|
||||
assert.equal(fs.existsSync(out), false);
|
||||
});
|
||||
|
||||
test('cli: a missing optional opener module preserves verified delivery with a fallback receipt', () => {
|
||||
const installedRoot = path.join(tmp, 'missing-open-module-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
const installedCli = path.join(installedRoot, 'bin/archify.mjs');
|
||||
fs.rmSync(path.join(installedRoot, 'bin/open-artifact.mjs'));
|
||||
const input = path.join(installedRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const out = path.join(tmp, 'missing-open-module-delivery.html');
|
||||
|
||||
const result = spawnSync(process.execPath, [installedCli, 'deliver', 'workflow', input, out, '--open', '--json'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, true);
|
||||
assert.deepEqual(receipt.open, {
|
||||
requested: true,
|
||||
status: 'unsupported',
|
||||
target: out,
|
||||
method: null,
|
||||
});
|
||||
assert.match(result.stderr, /Open it manually/);
|
||||
assert.equal(receipt.artifact.sha256, sha256(out));
|
||||
});
|
||||
|
||||
test('cli: deliver preserves the renderer default output contract', () => {
|
||||
const workingDirectory = path.join(tmp, 'delivery-default-output');
|
||||
fs.mkdirSync(workingDirectory, { recursive: true });
|
||||
const input = path.join(workingDirectory, 'source.architecture.json');
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/web-app.architecture.json'), 'utf8'));
|
||||
source.meta.output = 'verified-default.html';
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
|
||||
const result = run(['deliver', 'architecture', input, '--json'], { cwd: workingDirectory });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.output, path.join(fs.realpathSync(workingDirectory), 'verified-default.html'));
|
||||
assert.equal(fs.existsSync(receipt.output), true);
|
||||
});
|
||||
|
||||
test('cli: deliver works from an installed skill without node_modules', () => {
|
||||
const installedRoot = path.join(tmp, 'installed-deliver-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
const installedCli = path.join(installedRoot, 'bin/archify.mjs');
|
||||
const cases = [
|
||||
['architecture-boundaries', 'architecture', 'production-deployment.architecture.json'],
|
||||
['architecture-issue-110', 'architecture', 'brand-aware-delivery.architecture.json'],
|
||||
['workflow', 'workflow', 'agent-tool-call.workflow.json'],
|
||||
['sequence', 'sequence', 'cache-miss-request.sequence.json'],
|
||||
['dataflow', 'dataflow', 'product-analytics.dataflow.json'],
|
||||
['lifecycle', 'lifecycle', 'agent-run.lifecycle.json'],
|
||||
];
|
||||
|
||||
for (const [label, type, example] of cases) {
|
||||
const input = path.join(installedRoot, 'examples', example);
|
||||
const out = path.join(tmp, `installed-${label}-delivery.html`);
|
||||
const result = spawnSync(process.execPath, [installedCli, 'deliver', type, input, out, '--json'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, `${label}: ${result.stderr}`);
|
||||
assert.equal(JSON.parse(result.stdout).validation.checkCount, 9, label);
|
||||
assert.equal(fs.existsSync(out), true, label);
|
||||
const extracted = extractSvgs(fs.readFileSync(out, 'utf8'));
|
||||
assert.equal(extracted.direct.length, 1, `${label}: expected one delivered SVG`);
|
||||
assert.doesNotThrow(
|
||||
() => parseXml(extracted.direct[0]),
|
||||
`${label}: delivered SVG must be well-formed XML`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('cli: deliver XML guard parses markup instead of scanning attribute-like text', () => {
|
||||
assert.doesNotThrow(() => parseXml(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" aria-label="mentions data-node-label safely"/>',
|
||||
));
|
||||
assert.throws(
|
||||
() => parseXml('<svg xmlns="http://www.w3.org/2000/svg" data-node-label></svg>'),
|
||||
/attribute without value/i,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseXml('<svg xmlns="http://www.w3.org/2000/svg"><g></svg>'),
|
||||
/unexpected close tag/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('cli: preview runs from an installed skill without node_modules and exits cleanly', { timeout: 30000 }, async () => {
|
||||
const installedRoot = path.join(tmp, 'installed-preview-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
const installedCli = path.join(installedRoot, 'bin/archify.mjs');
|
||||
const input = path.join(installedRoot, 'examples/web-app.architecture.json');
|
||||
const output = path.join(tmp, 'installed-preview.html');
|
||||
const child = spawn(process.execPath, [installedCli, 'preview', 'architecture', input, output, '--quality', 'showcase', '--no-open'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
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; });
|
||||
|
||||
let previewUrl;
|
||||
const started = Date.now();
|
||||
while (!previewUrl && Date.now() - started < 8000) {
|
||||
previewUrl = stdout.match(/preview (http:\/\/127\.0\.0\.1:\d+\/)/)?.[1];
|
||||
if (!previewUrl) await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
assert.ok(previewUrl, `preview URL missing; stdout=${stdout}; stderr=${stderr}`);
|
||||
|
||||
let state;
|
||||
while (Date.now() - started < 15000) {
|
||||
state = await fetch(new URL('/state', previewUrl)).then((response) => response.json());
|
||||
if (state.status === 'verified') break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
assert.equal(state?.status, 'verified', `preview did not verify; stdout=${stdout}; stderr=${stderr}`);
|
||||
assert.equal(state.revision, 1);
|
||||
assert.equal(fs.existsSync(output), true);
|
||||
|
||||
child.kill('SIGTERM');
|
||||
const exit = await new Promise((resolve) => child.once('close', (code, signal) => resolve({ code, signal })));
|
||||
assert.deepEqual(exit, { code: 0, signal: null });
|
||||
assert.match(stdout, /stopping preview/);
|
||||
await assert.rejects(fetch(previewUrl));
|
||||
assert.deepEqual(fs.readdirSync(path.dirname(output)).filter((name) => name.startsWith('.archify-preview-')), []);
|
||||
});
|
||||
|
||||
test('cli: deliver preserves the previous artifact when the final check fails', () => {
|
||||
const installedRoot = path.join(tmp, 'broken-deliver-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
const installedCli = path.join(installedRoot, 'bin/archify.mjs');
|
||||
const templatePath = path.join(installedRoot, 'assets/template.html');
|
||||
const template = fs.readFileSync(templatePath, 'utf8');
|
||||
fs.writeFileSync(templatePath, template.replace('</body>', '<svg aria-label="accidental second svg"></svg>\n</body>'));
|
||||
|
||||
const input = path.join(installedRoot, 'examples/web-app.architecture.json');
|
||||
const out = path.join(tmp, 'preserved-delivery.html');
|
||||
const trustedPriorArtifact = '<!doctype html><title>trusted prior artifact</title>\n';
|
||||
fs.writeFileSync(out, trustedPriorArtifact);
|
||||
|
||||
const result = spawnSync(process.execPath, [installedCli, 'deliver', 'architecture', input, out, '--json'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'check');
|
||||
assert.equal(failure.diagnostics[0].code, 'artifact/single-svg');
|
||||
assert.equal(failure.diagnostics[0].subject.check, 'single_svg');
|
||||
assert.ok(failure.diagnostics[0].supportedFixes.some((fix) => fix.includes('exactly one diagram SVG')));
|
||||
assert.equal(failure.checker.checks.find((entry) => entry.name === 'single_svg').ok, false);
|
||||
assert.equal(fs.readFileSync(out, 'utf8'), trustedPriorArtifact);
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(path.dirname(out)).filter((name) => name.includes('.archify-delivery-')),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('cli: deliver reports renderer failure as json and preserves the previous artifact', () => {
|
||||
const input = path.join(tmp, 'invalid-delivery.workflow.json');
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/agent-tool-call.workflow.json'), 'utf8'));
|
||||
source.nodes[0].unexpected = true;
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
|
||||
const out = path.join(tmp, 'renderer-failure-preserved.html');
|
||||
const trustedPriorArtifact = '<!doctype html><title>last known good</title>\n';
|
||||
fs.writeFileSync(out, trustedPriorArtifact);
|
||||
|
||||
const result = run(['deliver', 'workflow', input, out, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'render');
|
||||
assert.match(failure.error, /schema validation failed/i);
|
||||
assert.equal(fs.readFileSync(out, 'utf8'), trustedPriorArtifact);
|
||||
});
|
||||
|
||||
test('cli: deliver reports unreadable input as json without touching the target', () => {
|
||||
const input = path.join(tmp, 'malformed-delivery.json');
|
||||
fs.writeFileSync(input, '{not valid json');
|
||||
const out = path.join(tmp, 'malformed-input-preserved.html');
|
||||
const trustedPriorArtifact = '<!doctype html><title>still trusted</title>\n';
|
||||
fs.writeFileSync(out, trustedPriorArtifact);
|
||||
|
||||
const result = run(['deliver', 'architecture', input, out, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'input');
|
||||
assert.match(failure.error, /Could not read delivery input/);
|
||||
assert.equal(fs.readFileSync(out, 'utf8'), trustedPriorArtifact);
|
||||
});
|
||||
|
||||
test('cli: invalid source output metadata still fails inside the renderer', () => {
|
||||
const workingDirectory = path.join(tmp, 'invalid-output-metadata');
|
||||
fs.mkdirSync(workingDirectory, { recursive: true });
|
||||
const input = path.join(workingDirectory, 'source.architecture.json');
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/web-app.architecture.json'), 'utf8'));
|
||||
source.meta.output = 17;
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
const out = path.join(workingDirectory, 'architecture.html');
|
||||
const trustedPriorArtifact = '<!doctype html><title>metadata did not replace me</title>\n';
|
||||
fs.writeFileSync(out, trustedPriorArtifact);
|
||||
|
||||
const result = run(['deliver', 'architecture', input, '--json'], { cwd: workingDirectory });
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.stage, 'render');
|
||||
assert.match(failure.error, /schema validation failed/i);
|
||||
assert.equal(fs.readFileSync(out, 'utf8'), trustedPriorArtifact);
|
||||
});
|
||||
|
||||
test('cli: deliver reports commit failure without a false success receipt', () => {
|
||||
const input = path.join(skillRoot, 'examples/web-app.architecture.json');
|
||||
const outputDirectory = path.join(tmp, 'commit-target-is-a-directory');
|
||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||
|
||||
const result = run(['deliver', 'architecture', input, outputDirectory, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'commit');
|
||||
assert.match(failure.error, /Could not commit verified delivery/);
|
||||
assert.equal(fs.statSync(outputDirectory).isDirectory(), true);
|
||||
assert.equal(fs.readdirSync(outputDirectory).length, 0);
|
||||
});
|
||||
|
||||
test('cli: deliver reports preparation failure as json without touching the blocker', () => {
|
||||
const input = path.join(skillRoot, 'examples/web-app.architecture.json');
|
||||
const blockingFile = path.join(tmp, 'delivery-parent-is-a-file');
|
||||
fs.writeFileSync(blockingFile, 'do not replace me');
|
||||
const out = path.join(blockingFile, 'cannot-write.html');
|
||||
|
||||
const result = run(['deliver', 'architecture', input, out, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'prepare');
|
||||
assert.match(failure.error, /Could not create delivery directory/);
|
||||
assert.equal(fs.readFileSync(blockingFile, 'utf8'), 'do not replace me');
|
||||
});
|
||||
|
||||
test('cli: check validates rendered html', () => {
|
||||
const out = path.join(tmp, 'workflow-check.html');
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
assert.equal(run(['render', 'workflow', input, out]).status, 0);
|
||||
|
||||
const result = run(['check', out]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /"ok": true/);
|
||||
});
|
||||
|
||||
test('cli: validate emits structured json without keeping html output', () => {
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const before = new Set(fs.readdirSync(tmp));
|
||||
const result = run(['validate', 'workflow', input, '--json']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.type, 'workflow');
|
||||
assert.equal(parsed.checks.length, 9);
|
||||
assert.equal(parsed.composition.profile, 'showcase');
|
||||
assert.deepEqual(parsed.composition.summary, { errors: 0, warnings: 0 });
|
||||
assert.equal(parsed.composition.metrics.containerBorderRuns, 0);
|
||||
assert.equal(parsed.composition.metrics.ambiguousCorridors, 0);
|
||||
assert.deepEqual(new Set(fs.readdirSync(tmp)), before);
|
||||
});
|
||||
|
||||
test('cli: validate JSON exposes only the primary v1 column-capacity diagnostic', () => {
|
||||
const input = path.join(tmp, 'pinned-column-capacity.workflow.json');
|
||||
fs.writeFileSync(input, `${JSON.stringify({
|
||||
schema_version: 1,
|
||||
diagram_type: 'workflow',
|
||||
meta: {
|
||||
title: 'Pinned issue 126 diagnostic boundary',
|
||||
viewBox: [720, 400],
|
||||
legend: { mode: 'hidden' },
|
||||
},
|
||||
lanes: [{ id: 'main', label: 'Main' }],
|
||||
nodes: [
|
||||
{ id: 'a', lane: 'main', col: 1, type: 'backend', label: 'A' },
|
||||
{ id: 'b', lane: 'main', col: 2, type: 'backend', label: 'B' },
|
||||
],
|
||||
edges: [{
|
||||
id: 'ab',
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
fromSide: 'top',
|
||||
toSide: 'top',
|
||||
via: [[220, 60], [300, 60]],
|
||||
}],
|
||||
}, null, 2)}\n`);
|
||||
|
||||
const result = run(['validate', 'workflow', input, '--json'], {
|
||||
env: { ...process.env, ARCHIFY_DIAGNOSTIC_FORMAT: 'json' },
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||
assert.equal(result.stderr, '');
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.command, 'validate');
|
||||
assert.equal(failure.stage, 'render');
|
||||
assert.equal(failure.type, 'workflow');
|
||||
assert.equal(failure.diagnostics.length, 1, JSON.stringify(failure.diagnostics, null, 2));
|
||||
const [primary] = failure.diagnostics;
|
||||
assert.equal(primary.code, 'workflow/column-capacity');
|
||||
assert.equal(primary.subject.edge, 'ab');
|
||||
assert.equal(primary.subject.fromCol, 1);
|
||||
assert.equal(primary.subject.toCol, 2);
|
||||
assert.ok(primary.supportedFixes.length > 0);
|
||||
assert.ok(failure.diagnostics.every(({ code }) => (
|
||||
code !== 'workflow/explicit-pin-conflict' && code !== 'workflow/viewbox-capacity'
|
||||
)));
|
||||
});
|
||||
|
||||
test('cli: --quality overrides the source profile for render, validate, and deliver', () => {
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const out = path.join(tmp, 'workflow-standard.html');
|
||||
const rendered = run(['render', 'workflow', input, out, '--quality', 'standard']);
|
||||
assert.equal(rendered.status, 0, rendered.stderr);
|
||||
assert.match(fs.readFileSync(out, 'utf8'), /data-quality-profile="standard"/);
|
||||
|
||||
const validated = run(['validate', 'workflow', input, '--quality=standard', '--json']);
|
||||
assert.equal(validated.status, 0, validated.stderr);
|
||||
assert.equal(JSON.parse(validated.stdout).composition.profile, 'standard');
|
||||
|
||||
const deliveredOut = path.join(tmp, 'workflow-delivered-standard.html');
|
||||
const delivered = run(['deliver', 'workflow', input, deliveredOut, '--quality=standard', '--json']);
|
||||
assert.equal(delivered.status, 0, delivered.stderr);
|
||||
assert.equal(JSON.parse(delivered.stdout).validation.compositionProfile, 'standard');
|
||||
});
|
||||
|
||||
test('cli: rejects an unknown quality profile', () => {
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const result = run(['validate', 'workflow', input, '--quality', 'hero']);
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /Expected standard or showcase/);
|
||||
});
|
||||
|
||||
test('cli: rejects a quality flag without a value', () => {
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
for (const args of [
|
||||
['validate', 'workflow', input, '--json', '--quality'],
|
||||
['validate', 'workflow', input, '--quality', '--json'],
|
||||
['validate', 'workflow', input, '--quality='],
|
||||
]) {
|
||||
const result = run(args);
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /--quality requires standard or showcase/);
|
||||
}
|
||||
});
|
||||
|
||||
test('cli: validate rejects unknown flags, layout-json assignment typos, and extra positionals', () => {
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const cases = [
|
||||
{
|
||||
args: ['validate', 'workflow', input, '--layout-json', '--bogus'],
|
||||
pattern: /Unknown validate option "--bogus"/,
|
||||
},
|
||||
{
|
||||
args: ['validate', 'workflow', input, '--layout-json=true'],
|
||||
pattern: /Unknown validate option "--layout-json=true"/,
|
||||
},
|
||||
{
|
||||
args: ['validate', 'workflow', input, '--layout-json=true', '--json'],
|
||||
pattern: /Unknown validate option "--layout-json=true"/,
|
||||
},
|
||||
{
|
||||
args: ['validate', 'workflow', input, 'unexpected-output.html', '--layout-json'],
|
||||
pattern: /Usage:/,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { args, pattern } of cases) {
|
||||
const result = run(args);
|
||||
assert.equal(result.status, 2, `${args.join(' ')}\n${result.stderr}\n${result.stdout}`);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.match(result.stderr, pattern);
|
||||
}
|
||||
});
|
||||
|
||||
test('cli: inspect emits architecture layout json', () => {
|
||||
const input = path.resolve(skillRoot, '../examples/archify-repo-grid.architecture.json');
|
||||
const result = run(['inspect', 'architecture', input]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.diagram_type, 'architecture');
|
||||
assert.equal(parsed.layout.mode, 'grid');
|
||||
assert.ok(parsed.components.length >= 5);
|
||||
assert.ok(parsed.connections.length >= 1);
|
||||
});
|
||||
|
||||
test('cli: inspect remains architecture-only while workflow uses validate --layout-json', () => {
|
||||
const input = path.join(skillRoot, 'examples', 'agent-tool-call.workflow.json');
|
||||
const result = run(['inspect', 'workflow', input]);
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /inspect is currently supported for architecture diagrams only/);
|
||||
assert.equal(result.stdout, '');
|
||||
});
|
||||
|
||||
test('cli: validate returns renderer errors for bad input', () => {
|
||||
const input = path.join(tmp, 'bad.workflow.json');
|
||||
const validateTmp = path.join(tmp, 'validate-failure-tmp');
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/agent-tool-call.workflow.json'), 'utf8'));
|
||||
doc.edges[0].to = 'ghost';
|
||||
fs.writeFileSync(input, JSON.stringify(doc));
|
||||
fs.mkdirSync(validateTmp);
|
||||
|
||||
const result = run(['validate', 'workflow', input], {
|
||||
env: { ...process.env, TMPDIR: validateTmp },
|
||||
});
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /unknown target "ghost"/);
|
||||
assert.deepEqual(fs.readdirSync(validateTmp), []);
|
||||
});
|
||||
|
||||
test('cli: validate rejects an unknown type without leaking a temp directory', () => {
|
||||
const validateTmp = path.join(tmp, 'validate-unknown-type-tmp');
|
||||
fs.mkdirSync(validateTmp);
|
||||
|
||||
const result = run(['validate', 'unknown', 'ignored.json'], {
|
||||
env: {
|
||||
...process.env,
|
||||
TMPDIR: validateTmp,
|
||||
TMP: validateTmp,
|
||||
TEMP: validateTmp,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /Unknown diagram type "unknown"/);
|
||||
assert.deepEqual(fs.readdirSync(validateTmp), []);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
Reference in New Issue
Block a user