An architecture diagram inside a model that defines no cards gets one card per drawn shared element with a description, as the extension does. Test covers the resolved JSON; formats.md and SKILL.md describe the rule.
211 lines
14 KiB
JavaScript
211 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
// The skill's own checks. Runs every action against examples/c4-e-shop and
|
|
// a scaffolded temp model. Prints YAML (success first); exit 1 on any failure.
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { toYaml } from '../services/yaml.mjs';
|
|
|
|
const scripts = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
const skill = path.resolve(scripts, '..');
|
|
const example = path.join(skill, 'examples', 'c4-e-shop');
|
|
const cli = path.join(scripts, 'src', 'cli', 'commands.mjs');
|
|
const checks = [];
|
|
|
|
function cliRun(args) {
|
|
const result = spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', cwd: scripts, maxBuffer: 64 * 1024 * 1024 });
|
|
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
}
|
|
|
|
function yamlValue(stdout, key) {
|
|
const match = new RegExp(`^${key}: (.*)$`, 'm').exec(stdout);
|
|
return match ? match[1].trim() : undefined;
|
|
}
|
|
|
|
function check(name, command, expect) {
|
|
const result = cliRun(command);
|
|
let ok = true;
|
|
const notes = [];
|
|
try {
|
|
expect(result, notes);
|
|
} catch (error) {
|
|
ok = false;
|
|
notes.push(error.message);
|
|
}
|
|
checks.push({ name, command: `node src/cli/commands.mjs ${command.join(' ')}`, ok, notes, stdout: ok ? undefined : result.stdout.slice(0, 4000) });
|
|
}
|
|
|
|
const assert = (condition, message) => {
|
|
if (!condition) throw new Error(message);
|
|
};
|
|
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-skill-test-'));
|
|
try {
|
|
check('help prints usage', ['--help'], (r) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
assert(/model-validate/.test(r.stdout) && /diagram-render/.test(r.stdout), 'actions missing from --help');
|
|
});
|
|
|
|
check('model-validate example: 4 diagrams, 0 problems', ['--action', 'model-validate', '--dir', example], (r, notes) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
assert(yamlValue(r.stdout, ' errors') === '0', 'errors are not 0');
|
|
assert(yamlValue(r.stdout, ' warnings') === '0', 'warnings are not 0');
|
|
assert(yamlValue(r.stdout, ' diagrams') === '4', 'expected 4 diagrams');
|
|
assert(yamlValue(r.stdout, ' diagramsOk') === '4', 'expected 4 diagrams ok');
|
|
notes.push('4 diagrams validated with 0 problems');
|
|
});
|
|
|
|
check('model-validate from a file inside the model', ['--action', 'model-validate', '--dir', path.join(example, '0300-components', 'api.archify')], (r) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
assert(yamlValue(r.stdout, 'mode') === 'model', 'root was not found from a file inside');
|
|
});
|
|
|
|
check('model-list example: 3 groups plus root-level main.archify', ['--action', 'model-list', '--dir', example], (r, notes) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
assert(yamlValue(r.stdout, ' groups') === '3', 'expected 3 groups');
|
|
assert(/- file: main\.archify/.test(r.stdout), 'main.archify not listed at root level');
|
|
assert(/folder: 0100-context/.test(r.stdout) && /folder: 0200-containers/.test(r.stdout) && /folder: 0300-components/.test(r.stdout), 'group folders missing');
|
|
assert(/id: api\n(?:.*\n)*?\s+usedIn: 2/.test(r.stdout), 'api should be used in 2 diagrams');
|
|
assert(yamlValue(r.stdout, ' elements') === '11', 'expected 11 registry elements');
|
|
notes.push('3 groups, 11 elements with usage counts');
|
|
});
|
|
|
|
const resolved = path.join(tmp, 'containers.architecture.json');
|
|
check('model-resolve writes Archify JSON', ['--action', 'model-resolve', '--file', path.join(example, '0200-containers', 'online-shop.archify'), '--out', resolved], (r, notes) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
const json = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
|
assert(json.model === undefined, 'model block leaked into the resolved JSON');
|
|
const api = json.components.find((c) => c.id === 'api');
|
|
assert(api && api.type === 'backend' && api.brand === 'node-js' && api.description === undefined && api.diagram === undefined, 'api was not resolved from the registry');
|
|
const cards = json.cards ?? [];
|
|
assert(cards.length === 7 && cards.every((c) => c.dot && c.title && c.items.length === 1), `expected 7 generated cards, got ${JSON.stringify(cards).slice(0, 200)}`);
|
|
assert(cards.find((c) => c.title === 'API')?.dot === 'emerald', 'API card has the backend colour');
|
|
notes.push(`${json.components.length} components, refs expanded, ${cards.length} cards from the registry`);
|
|
});
|
|
|
|
check('model-resolve prints the JSON inline without --out', ['--action', 'model-resolve', '--file', path.join(example, 'main.archify')], (r) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
assert(/^resolved:/m.test(r.stdout) && /label: Online Shop/.test(r.stdout), 'resolved block missing');
|
|
});
|
|
|
|
check('diagram-validate inside the model', ['--action', 'diagram-validate', '--file', path.join(example, '0100-context', 'online-shop.archify')], (r) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
assert(yamlValue(r.stdout, 'archifyOk') === 'true', 'Archify did not accept the diagram');
|
|
});
|
|
|
|
const html = path.join(tmp, 'context.html');
|
|
check('diagram-render writes HTML', ['--action', 'diagram-render', '--file', path.join(example, '0100-context', 'online-shop.archify'), '--out', html], (r, notes) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
assert(fs.existsSync(html) && fs.statSync(html).size > 10000, 'HTML missing or too small');
|
|
assert(/<svg/.test(fs.readFileSync(html, 'utf8')), 'HTML has no SVG');
|
|
notes.push(`${fs.statSync(html).size} bytes`);
|
|
});
|
|
|
|
// Negative cases: a broken copy of the example.
|
|
const broken = path.join(tmp, 'broken');
|
|
fs.cpSync(example, broken, { recursive: true });
|
|
fs.appendFileSync(path.join(broken, 'registry', 'people.archify-registry'), ' api:\n type: backend\n label: Dup\n');
|
|
fs.writeFileSync(
|
|
path.join(broken, '0300-components', 'bad.archify'),
|
|
'schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Bad\n output: bad.html\ncomponents:\n - ref: nobody\n pos: [40, 60]\n size: [130, 60]\n - ref: db\n type: frontend\n pos: [300, 60]\n size: [130, 60]\n diagram: 0900-missing/x.archify\n - id: a\n type: backend\n label: A\n pos: [40, 200]\n size: [130, 60]\n - id: a\n type: backend\n label: A again\n pos: [40, 200]\n size: [130, 60]\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(broken, '0300-components', 'overlap.archify'),
|
|
'schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Overlap\n output: overlap.html\ncomponents:\n - ref: api\n pos: [40, 60]\n size: [130, 60]\n - ref: db\n pos: [60, 70]\n size: [130, 60]\n',
|
|
);
|
|
check('model-validate reports duplicates, unknown refs, overrides, links, ids, layout', ['--action', 'model-validate', '--dir', broken], (r, notes) => {
|
|
assert(r.status === 1, `exit ${r.status}, expected 1`);
|
|
const text = r.stdout.replace(/\\"/g, '"');
|
|
for (const expected of ['defined twice', 'Unknown element "nobody"', '"type" of shared element "db"', 'does not exist under', 'Duplicate id "a"', 'less than 8px apart']) {
|
|
assert(text.includes(expected), `missing problem: ${expected}`);
|
|
}
|
|
assert(/pointer: \/components\/0\/ref/.test(text) && /line: 7/.test(text), 'pointer/line of the unknown ref missing');
|
|
assert(/file: 0300-components\/overlap\.archify\n(?:.*\n)*?\s+line: 7\n\s+pointer: \/components\/0\n\s+code: layout\/constraint/.test(text), 'Archify layout problem not mapped to a component line');
|
|
notes.push('all expected problems reported with file, line and pointer');
|
|
});
|
|
|
|
const single = path.join(tmp, 'single.archify');
|
|
fs.writeFileSync(single, 'schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Single\n output: single.html\ncomponents:\n - { id: a, type: backend, label: A, pos: [40, 60], size: [130, 60] }\n - { id: b, type: database, label: B, pos: [300, 60], size: [130, 60] }\nconnections:\n - { from: a, to: b, label: SQL }\n');
|
|
check('diagram outside a model validates with Archify only', ['--action', 'model-validate', '--dir', single], (r) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
assert(yamlValue(r.stdout, 'mode') === 'single-diagram', 'expected single-diagram mode');
|
|
});
|
|
|
|
const outside = path.join(tmp, 'outside.archify');
|
|
fs.writeFileSync(outside, 'schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Outside\n output: outside.html\ncomponents:\n - { ref: api, pos: [40, 60], size: [130, 60] }\n');
|
|
check('ref outside a model is an error', ['--action', 'diagram-validate', '--file', outside], (r) => {
|
|
assert(r.status === 1, `exit ${r.status}, expected 1`);
|
|
assert(/cannot be resolved: no main\.archify above/.test(r.stdout), 'missing "no main.archify" problem');
|
|
});
|
|
|
|
const scaffold = path.join(tmp, 'scaffolded');
|
|
check('model-scaffold --c4 creates a model', ['--action', 'model-scaffold', '--dir', scaffold, '--id', 'demo-shop', '--name', 'Demo Shop', '--description', 'A demo: scaffolded model', '--c4'], (r, notes) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
for (const file of ['main.archify', 'registry/people.archify-registry', 'registry/systems.archify-registry', 'registry/containers.archify-registry', '0100-context/demo-shop.archify', '0200-containers/demo-shop.archify', '0300-components/api.archify']) {
|
|
assert(fs.existsSync(path.join(scaffold, file)), `${file} not created`);
|
|
}
|
|
notes.push('7 files created');
|
|
});
|
|
check('scaffolded model validates', ['--action', 'model-validate', '--dir', scaffold], (r) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
assert(yamlValue(r.stdout, ' errors') === '0' && yamlValue(r.stdout, ' warnings') === '0', 'scaffolded model has problems');
|
|
assert(yamlValue(r.stdout, ' diagrams') === '4', 'expected 4 diagrams in the scaffolded model');
|
|
});
|
|
check('scaffolded model lists 3 groups', ['--action', 'model-list', '--dir', scaffold], (r) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
assert(yamlValue(r.stdout, ' groups') === '3', 'expected 3 groups');
|
|
});
|
|
check('model-scaffold refuses to overwrite', ['--action', 'model-scaffold', '--dir', scaffold, '--id', 'demo-shop', '--name', 'Demo Shop', '--c4'], (r) => {
|
|
assert(r.status === 1, `exit ${r.status}, expected 1`);
|
|
assert(/Refusing to overwrite/.test(r.stdout), 'no refusal message');
|
|
assert(fs.readFileSync(path.join(scaffold, 'main.archify'), 'utf8').includes('Demo Shop'), 'main.archify was touched');
|
|
});
|
|
const plain = path.join(tmp, 'plain');
|
|
check('model-scaffold without --c4 creates main.archify only', ['--action', 'model-scaffold', '--dir', plain, '--id', 'plain', '--name', 'Plain'], (r) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
assert(fs.readdirSync(plain).join(',') === 'main.archify', `unexpected files: ${fs.readdirSync(plain).join(',')}`);
|
|
});
|
|
check('plain model validates (model block only)', ['--action', 'model-validate', '--dir', plain], (r) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
});
|
|
|
|
// The shipped examples: the same three the extension offers under "Archify | New Model…".
|
|
const examplesDir = path.join(skill, 'examples');
|
|
const exampleNames = fs.readdirSync(examplesDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
check('examples/ holds exactly the three C4 examples', [], () => {
|
|
assert(exampleNames.join(',') === 'c4-e-shop,c4-event-based-integration,c4-standard-web-app', `unexpected examples: ${exampleNames.join(',')}`);
|
|
});
|
|
for (const name of exampleNames) {
|
|
check(`example ${name} validates with 0 problems`, ['--action', 'model-validate', '--dir', path.join(examplesDir, name)], (r) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
assert(yamlValue(r.stdout, ' errors') === '0' && yamlValue(r.stdout, ' warnings') === '0', 'example has problems');
|
|
assert(yamlValue(r.stdout, ' diagrams') === '4', 'expected 4 diagrams');
|
|
});
|
|
}
|
|
const fromExample = path.join(tmp, 'from-example');
|
|
check('model-scaffold --example copies and renames an example', ['--action', 'model-scaffold', '--dir', fromExample, '--example', 'c4-event-based-integration', '--id', 'hub', '--name', 'Integration Hub', '--description', 'Our hub: events between systems'], (r) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
const main = fs.readFileSync(path.join(fromExample, 'main.archify'), 'utf8');
|
|
assert(/^ id: hub$/m.test(main) && /^ name: "Integration Hub"$/m.test(main) && /Our hub: events between systems/.test(main) && /Integration Hub — overview/.test(main), `main.archify not renamed:\n${main}`);
|
|
assert(fs.existsSync(path.join(fromExample, 'registry', 'containers.archify-registry')) && fs.existsSync(path.join(fromExample, '0300-components', 'router.archify')), 'example files missing');
|
|
});
|
|
check('copied example validates', ['--action', 'model-validate', '--dir', fromExample], (r) => {
|
|
assert(r.status === 0, `exit ${r.status}`);
|
|
assert(yamlValue(r.stdout, ' errors') === '0' && yamlValue(r.stdout, ' warnings') === '0', 'copied example has problems');
|
|
});
|
|
check('model-scaffold --example refuses to overwrite and rejects unknown names', ['--action', 'model-scaffold', '--dir', fromExample, '--example', 'c4-e-shop'], (r) => {
|
|
assert(r.status === 1 && /Refusing to overwrite/.test(r.stdout), 'no refusal');
|
|
const unknown = cliRun(['--action', 'model-scaffold', '--dir', path.join(tmp, 'x'), '--example', 'nope']);
|
|
assert(unknown.status === 1 && /Unknown example/.test(unknown.stdout), 'unknown example accepted');
|
|
});
|
|
} finally {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
|
|
const failed = checks.filter((item) => !item.ok).length;
|
|
process.stdout.write(toYaml({ success: failed === 0, summary: { checks: checks.length, passed: checks.length - failed, failed }, checks }));
|
|
process.exit(failed ? 1 : 0);
|