Every Archify page can show its file's YAML in place. Diagrams get Diagram | Graph | Source (Graph only inside a model), main.archify Model | Graph | Source, registry and decision pages View | Source, and the Graph page a Source segment. 2D | 3D moves left of that switch and hides while Source shows. The YAML opens in a CodeMirror editor inside the page (dist/source.js, loaded on first use), themed with the page's colours. Edits go to the VS Code document as WorkspaceEdits; outside changes come back to the page. Undo and redo are the editor's own, since VS Code sends ⌘Z in a focused webview to the webview; save and find stay VS Code's. Problems come from the host only (what the Problems panel holds for the file) and are counted on the Source segment; completion and hover come from the schema, folded into one draft-07 document per diagram type. The host now checks .archify files against the extension's schema with ajv on the YAML as written, before archify validate, and checks .archify-registry files against the registry schema for the first time. Registry and decision pages become text editor providers. While Source shows, new renders wait and appear when the picture comes back. Show Source switches the page to Source; Open in Text Editor (the editor title button) opens the text beside it, as Show Source did. The decision schema is no longer given to the YAML extension. Fixed: YAML errors in registry and decision files were reported on line 1; "Webview is disposed" when a diagram closed mid-render; the model heading under the toolbar in narrow editors.
151 lines
7.5 KiB
TypeScript
151 lines
7.5 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { test } from 'node:test';
|
|
import {
|
|
groupOf,
|
|
hasDiagramBody,
|
|
isMainFile,
|
|
isRegistryFile,
|
|
mergeRegistries,
|
|
parseRegistry,
|
|
readModelMeta,
|
|
resolveRefs,
|
|
sortedGroups,
|
|
summarizeDiagram,
|
|
} from '../src/model/format';
|
|
import { parse } from 'yaml';
|
|
|
|
const model = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'model');
|
|
const read = (rel: string) => fs.readFileSync(path.join(model, rel), 'utf8');
|
|
|
|
function registryOfModel() {
|
|
const files = ['registry/people.archify-registry', 'registry/systems.archify-registry', 'registry/containers.archify-registry'];
|
|
return mergeRegistries(files.map((file) => ({ file, ...parseRegistry(read(file), file) })));
|
|
}
|
|
|
|
test('file kinds are told apart by name', () => {
|
|
assert.ok(isMainFile('/m/online-shop/main.archify'));
|
|
assert.ok(!isMainFile('/m/online-shop/0100-context/main-flow.archify'));
|
|
assert.ok(isRegistryFile('registry/people.archify-registry'));
|
|
assert.ok(!isRegistryFile('people.archify'));
|
|
});
|
|
|
|
test('main.archify: model block and optional diagram body', () => {
|
|
const doc = parse(read('main.archify'));
|
|
assert.deepEqual(readModelMeta(doc), {
|
|
id: 'online-shop',
|
|
name: 'Online Shop',
|
|
description: 'The web shop, its API and workers, and the stores behind them. Context, containers and components, C4 style.\n',
|
|
});
|
|
assert.ok(hasDiagramBody(doc));
|
|
assert.equal(readModelMeta(parse(read('0100-context/online-shop.archify'))), undefined);
|
|
assert.ok(!hasDiagramBody({ schema_version: 1, model: { id: 'x' } }));
|
|
});
|
|
|
|
test('registry: elements with line numbers, duplicates reported once', () => {
|
|
const people = parseRegistry(read('registry/people.archify-registry'), 'people');
|
|
assert.deepEqual(people.problems, []);
|
|
assert.equal(people.elements.length, 2);
|
|
const customer = people.elements[0];
|
|
assert.equal(customer.id, 'customer');
|
|
assert.equal(customer.type, 'external');
|
|
assert.equal(customer.line, 2);
|
|
const merged = mergeRegistries([
|
|
{ file: 'a', ...parseRegistry('elements:\n api: { type: backend, label: API }\n', 'a') },
|
|
{ file: 'b', ...parseRegistry('elements:\n api: { type: backend, label: API again }\n db: { type: database, label: DB }\n', 'b') },
|
|
]);
|
|
assert.equal(merged.elements.size, 2);
|
|
assert.equal(merged.problems.length, 1);
|
|
assert.match(merged.problems[0].message, /"api" is defined twice/);
|
|
assert.equal(merged.problems[0].file, 'b');
|
|
});
|
|
|
|
test('registry: broken files are reported, not thrown', () => {
|
|
const bad = parseRegistry('elements:\n api: {\n', 'bad');
|
|
assert.equal(bad.elements.length, 0);
|
|
assert.equal(bad.problems[0].severity, 'error');
|
|
const noMap = parseRegistry('- just\n- a list\n', 'list');
|
|
assert.match(noMap.problems[0].message, /mapping/);
|
|
});
|
|
|
|
test('resolve: refs become full components, links and usage are collected', () => {
|
|
const registry = registryOfModel();
|
|
const doc = parse(read('0200-containers/online-shop.archify'));
|
|
const r = resolveRefs(doc, { registry, fileName: 'containers.archify' });
|
|
assert.deepEqual(r.problems, []);
|
|
const api = (r.diagram.components as Record<string, unknown>[]).find((c) => c.id === 'api')!;
|
|
assert.deepEqual(api, { id: 'api', type: 'backend', label: 'API', sublabel: 'Node.js', brand: 'node-js', pos: [460, 160], size: [130, 60] });
|
|
assert.equal(r.links.get('api'), '0300-components/api.archify');
|
|
assert.ok(!r.links.has('db'));
|
|
assert.deepEqual([...r.used].sort(), ['api', 'cache', 'customer', 'db', 'queue', 'web', 'worker']);
|
|
assert.equal(r.refs.get(2)?.id, 'api');
|
|
assert.ok(!('model' in r.diagram));
|
|
// Descriptions travel with the node ids, for the Elements panel; nothing is added to the diagram itself.
|
|
assert.ok(!('cards' in r.diagram));
|
|
assert.equal(r.descriptions.get('customer'), 'Buys products; signs in with email or a social account.');
|
|
assert.equal(r.descriptions.size, 7);
|
|
const own = resolveRefs({ components: [{ ref: 'api', pos: [0, 0] }], cards: [{ dot: 'rose', title: 'Mine', items: ['kept'] }] }, { registry, fileName: 'x.archify' });
|
|
assert.deepEqual(own.diagram.cards, [{ dot: 'rose', title: 'Mine', items: ['kept'] }], 'hand-written cards pass through');
|
|
});
|
|
|
|
test('resolve: overrides, second id for the same element, inline elements and links', () => {
|
|
const registry = registryOfModel();
|
|
const doc = {
|
|
components: [
|
|
{ ref: 'db', label: 'Orders DB', pos: [0, 0] },
|
|
{ ref: 'db', id: 'db-replica', sublabel: 'replica', pos: [0, 0] },
|
|
{ id: 'cron', type: 'backend', label: 'Nightly job', diagram: 'jobs.archify', pos: [0, 0] },
|
|
{ ref: 'api', diagram: 'other.archify', pos: [0, 0] },
|
|
],
|
|
};
|
|
const r = resolveRefs(doc, { registry, fileName: 'x.archify' });
|
|
const components = r.diagram.components as Record<string, unknown>[];
|
|
assert.equal(components[0].label, 'Orders DB');
|
|
assert.equal(components[0].brand, 'postgresql');
|
|
assert.equal(components[1].id, 'db-replica');
|
|
assert.equal(components[1].sublabel, 'replica');
|
|
assert.deepEqual(components[2], { id: 'cron', type: 'backend', label: 'Nightly job', pos: [0, 0] });
|
|
assert.equal(r.links.get('cron'), 'jobs.archify');
|
|
assert.equal(r.links.get('api'), 'other.archify', 'the component link wins over the registry link');
|
|
assert.deepEqual(r.problems, []);
|
|
});
|
|
|
|
test('resolve: unknown refs, forbidden overrides, outside a model', () => {
|
|
const registry = registryOfModel();
|
|
const r = resolveRefs({ components: [{ ref: 'ghost', pos: [0, 0] }, { ref: 'api', type: 'frontend', brand: 'react', pos: [0, 0] }] }, { registry, fileName: 'x.archify' });
|
|
assert.equal(r.problems.length, 3);
|
|
assert.equal(r.problems[0].pointer, '/components/0/ref');
|
|
assert.match(r.problems[0].message, /Unknown element "ghost"/);
|
|
assert.deepEqual(r.problems.slice(1).map((p) => p.pointer), ['/components/1/type', '/components/1/brand']);
|
|
const ghost = (r.diagram.components as Record<string, unknown>[])[0];
|
|
assert.deepEqual(ghost, { type: 'external', label: 'ghost', pos: [0, 0], id: 'ghost' });
|
|
const outside = resolveRefs({ components: [{ ref: 'api', pos: [0, 0] }] }, { fileName: '/w/loose.archify' });
|
|
assert.match(outside.problems[0].message, /not inside a model/);
|
|
});
|
|
|
|
test('summaries and groups: root first, then folders', () => {
|
|
const summaries = [
|
|
summarizeDiagram(read('0300-components/api.archify'), '0300-components/api.archify'),
|
|
summarizeDiagram(read('0100-context/online-shop.archify'), '0100-context/online-shop.archify'),
|
|
summarizeDiagram('schema_version: 1\ndiagram_type: workflow\nmeta: { title: Checkout }\n', 'checkout.archify'),
|
|
summarizeDiagram('oops: [', 'broken.archify'),
|
|
];
|
|
assert.equal(summaries[0].title, 'API components');
|
|
assert.equal(summaries[0].kind, 'architecture');
|
|
assert.deepEqual(summaries[0].used, ['web', 'db', 'queue']);
|
|
assert.ok(summaries[3].parseError);
|
|
assert.equal(groupOf('a/b/c.archify'), 'a/b');
|
|
assert.equal(groupOf('c.archify'), '');
|
|
const groups = sortedGroups(summaries);
|
|
assert.deepEqual(groups.map((g) => g.group), ['', '0100-context', '0300-components']);
|
|
assert.deepEqual(groups[0].diagrams.map((d) => d.relativePath), ['broken.archify', 'checkout.archify']);
|
|
});
|
|
|
|
test('a registry YAML error is reported at its line, not the first', () => {
|
|
const { problems } = parseRegistry('schema_version: 1\nelements:\n api:\n type: backend\n sub\n label: API\n', 'r.archify-registry');
|
|
const yaml = problems.find((p) => /not valid YAML/.test(p.message));
|
|
assert.ok(yaml, 'a YAML problem');
|
|
assert.ok((yaml.line ?? 0) >= 3, `reported on line ${(yaml.line ?? 0) + 1}`);
|
|
});
|