Six example models next to the three C4 ones: arc42 (Cinema Tickets, IoT Telemetry, Team Wiki) by section, ArchiMate (Insurance Claims, HR Onboarding, Logistics Tracking) by layer, each validated with 0 errors and 0 warnings. resolveRefs and the editor schemas now take ref: in workflow and dataflow nodes, sequence participants and lifecycle states, as the skill already did. Example tests check each framework's folders.
683 lines
40 KiB
TypeScript
683 lines
40 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import path from 'node:path';
|
|
import * as vscode from 'vscode';
|
|
import * as Y from 'yjs';
|
|
import { FileBridge } from '../../src/sync/fileBridge';
|
|
import { LiveSpace } from '../../src/sync/liveSpace';
|
|
import type { SyncAuth } from '../../src/sync/auth';
|
|
|
|
const EXTENSION_ID = 'root-at-skic.archify-vscode-ext';
|
|
|
|
function workspaceFile(name: string): vscode.Uri {
|
|
const folder = vscode.workspace.workspaceFolders?.[0];
|
|
assert.ok(folder, 'a workspace folder is open');
|
|
return vscode.Uri.joinPath(folder.uri, name);
|
|
}
|
|
|
|
async function waitFor<T>(what: string, probe: () => T | undefined | false, timeoutMs = 20_000): Promise<T> {
|
|
const start = Date.now();
|
|
for (;;) {
|
|
const value = probe();
|
|
if (value) return value;
|
|
if (Date.now() - start > timeoutMs) throw new Error(`Timed out waiting for ${what}`);
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
}
|
|
}
|
|
|
|
function webviewTabs(viewTypeSuffix: string): vscode.Tab[] {
|
|
return vscode.window.tabGroups.all
|
|
.flatMap((group) => group.tabs)
|
|
.filter((tab) =>
|
|
(tab.input instanceof vscode.TabInputWebview && tab.input.viewType.endsWith(viewTypeSuffix)) ||
|
|
(tab.input instanceof vscode.TabInputCustom && tab.input.viewType === viewTypeSuffix));
|
|
}
|
|
|
|
async function check(name: string, body: () => Promise<void>): Promise<void> {
|
|
try {
|
|
await body();
|
|
console.log(` ✔ ${name}`);
|
|
} catch (error) {
|
|
console.log(` ✖ ${name}\n ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function run(): Promise<void> {
|
|
console.log('Archify extension integration suite');
|
|
|
|
await check('activates on a diagram source and sets the context key', async () => {
|
|
const doc = await vscode.workspace.openTextDocument(workspaceFile('production-deployment.architecture.json'));
|
|
await vscode.window.showTextDocument(doc);
|
|
const extension = vscode.extensions.getExtension(EXTENSION_ID);
|
|
assert.ok(extension, 'extension is installed');
|
|
await waitFor('activation', () => extension.isActive);
|
|
});
|
|
|
|
await check('clicking a .archify file opens the diagram, not the text', async () => {
|
|
const uri = workspaceFile('web-platform.archify');
|
|
await vscode.commands.executeCommand('vscode.open', uri);
|
|
const tab = await waitFor('diagram editor tab', () =>
|
|
vscode.window.tabGroups.all
|
|
.flatMap((group) => group.tabs)
|
|
.find((t) => t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.diagram' && t.input.uri.toString() === uri.toString()),
|
|
);
|
|
assert.ok(tab.isActive, 'the diagram tab is active');
|
|
assert.equal(vscode.window.activeTextEditor?.document.uri.toString() === uri.toString(), false, 'no text editor was opened for it');
|
|
});
|
|
|
|
await check('Show Source keeps the diagram in its one tab (the page switches to Source)', async () => {
|
|
const uri = workspaceFile('web-platform.archify');
|
|
await vscode.commands.executeCommand('archify.showSource');
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
assert.equal(vscode.window.visibleTextEditors.some((e) => e.document.uri.toString() === uri.toString()), false, 'no text editor was opened');
|
|
const tabs = vscode.window.tabGroups.all.flatMap((g) => g.tabs).filter((t) => t.input instanceof vscode.TabInputCustom && t.input.uri.toString() === uri.toString());
|
|
assert.equal(tabs.length, 1);
|
|
});
|
|
|
|
await check('.archify files are YAML and Open in Text Editor opens the text beside the diagram', async () => {
|
|
const uri = workspaceFile('web-platform.archify');
|
|
await vscode.commands.executeCommand('archify.openInTextEditor');
|
|
const editor = await waitFor('source text editor', () =>
|
|
vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri.toString()),
|
|
);
|
|
assert.equal(editor.document.languageId, 'yaml');
|
|
assert.match(editor.document.getText(), /^schema_version: 1/m);
|
|
});
|
|
|
|
await check('.archify validation problems are reported against the YAML', async () => {
|
|
const good = await vscode.workspace.openTextDocument(workspaceFile('cache-miss.archify'));
|
|
const broken = good.getText().replace(/^participants:/m, 'actors:');
|
|
const target = workspaceFile('broken.archify');
|
|
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(broken));
|
|
const doc = await vscode.workspace.openTextDocument(target);
|
|
await vscode.window.showTextDocument(doc);
|
|
const diagnostics = await waitFor('archify diagnostics on broken.archify', () => {
|
|
const found = vscode.languages.getDiagnostics(doc.uri).filter((d) => d.source === 'archify');
|
|
return found.length > 0 && found;
|
|
});
|
|
assert.ok(diagnostics.some((d) => d.severity === vscode.DiagnosticSeverity.Error));
|
|
});
|
|
|
|
await check('.archify YAML syntax errors are reported at their line', async () => {
|
|
const target = workspaceFile('syntax.archify');
|
|
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode('schema_version: 1\ndiagram_type: architecture\nmeta: [unclosed\n'));
|
|
const doc = await vscode.workspace.openTextDocument(target);
|
|
await vscode.window.showTextDocument(doc);
|
|
const diagnostics = await waitFor('yaml syntax diagnostics', () => {
|
|
const found = vscode.languages.getDiagnostics(doc.uri).filter((d) => d.source === 'archify' && d.message.startsWith('YAML:'));
|
|
return found.length > 0 && found;
|
|
});
|
|
assert.ok(diagnostics[0].range.start.line >= 2, `reported on line ${diagnostics[0].range.start.line + 1}`);
|
|
});
|
|
|
|
await check('Archify | New Diagram creates a .archify file from a template and opens the diagram', async () => {
|
|
// Menus show the bare title, so it must name Archify by itself.
|
|
const commands = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.contributes.commands as { command: string; title: string; category?: string }[];
|
|
const newDiagram = commands.find((item) => item.command === 'archify.newDiagram');
|
|
assert.equal(newDiagram?.title, 'Archify | New Diagram…');
|
|
assert.equal(newDiagram?.category, undefined);
|
|
const folder = workspaceFile('.');
|
|
// Stub the template pick and the name prompt; check the name validation on the way.
|
|
const window = vscode.window as unknown as Record<string, unknown>;
|
|
const originalPick = window.showQuickPick;
|
|
const originalInput = window.showInputBox;
|
|
let existingNameMessage: unknown;
|
|
window.showQuickPick = async (items: readonly { label: string }[]) => (await items).find((item) => item.label === 'Everyday Plan');
|
|
window.showInputBox = async (options: vscode.InputBoxOptions) => {
|
|
existingNameMessage = await options.validateInput?.('web-platform');
|
|
return 'summer-plan';
|
|
};
|
|
try {
|
|
await vscode.commands.executeCommand('archify.newDiagram', folder);
|
|
} finally {
|
|
window.showQuickPick = originalPick;
|
|
window.showInputBox = originalInput;
|
|
}
|
|
assert.match(String(existingNameMessage), /already exists/);
|
|
const uri = vscode.Uri.joinPath(folder, 'summer-plan.archify');
|
|
const text = new TextDecoder().decode(await vscode.workspace.fs.readFile(uri));
|
|
assert.match(text, /^diagram_type: workflow$/m);
|
|
assert.match(text, /^ {2}title: Summer plan$/m);
|
|
assert.match(text, /^ {2}output: summer-plan\.html$/m);
|
|
await waitFor('diagram editor for the new file', () =>
|
|
webviewTabs('archify.diagram').some((tab) => tab.input instanceof vscode.TabInputCustom && tab.input.uri.toString() === uri.toString()),
|
|
);
|
|
});
|
|
|
|
await check('suggests brand IDs after brand: in .archify files', async () => {
|
|
const target = workspaceFile('brands.archify');
|
|
const text = [
|
|
'schema_version: 1',
|
|
'diagram_type: architecture',
|
|
'meta:',
|
|
' title: Brands',
|
|
'components:',
|
|
' - id: db',
|
|
' type: database',
|
|
' label: DB',
|
|
' brand: ',
|
|
' - id: bus',
|
|
' type: messagebus',
|
|
' label: Bus',
|
|
' brand: kafka',
|
|
'',
|
|
].join('\n');
|
|
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(text));
|
|
const doc = await vscode.workspace.openTextDocument(target);
|
|
const labels = async (line: number) => {
|
|
const list = await vscode.commands.executeCommand<vscode.CompletionList>(
|
|
'vscode.executeCompletionItemProvider',
|
|
doc.uri,
|
|
new vscode.Position(line, doc.lineAt(line).text.length),
|
|
);
|
|
return list.items.map((item) => (typeof item.label === 'string' ? item.label : item.label.label));
|
|
};
|
|
const empty = await labels(8);
|
|
assert.ok(empty.includes('postgresql') && empty.includes('redis'), 'brand IDs offered after "brand: "');
|
|
const kafka = await labels(12);
|
|
assert.ok(kafka.includes('apache-kafka'), 'the alias "kafka" finds apache-kafka');
|
|
assert.equal((await labels(7)).includes('postgresql'), false, 'nothing offered for other fields');
|
|
});
|
|
|
|
await check('an unknown brand is reported on its own line', async () => {
|
|
const target = workspaceFile('unknown-brand.archify');
|
|
const text = new TextDecoder().decode(await vscode.workspace.fs.readFile(workspaceFile('web-platform.archify')))
|
|
.replace(/^( {2}- id: [^\n]+\n)/m, '$1 brand: not-a-brand\n');
|
|
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(text));
|
|
const doc = await vscode.workspace.openTextDocument(target);
|
|
await vscode.window.showTextDocument(doc);
|
|
const diagnostic = await waitFor('brand/unknown diagnostic', () =>
|
|
vscode.languages.getDiagnostics(doc.uri).find((d) => d.source === 'archify' && d.code === 'brand/unknown'),
|
|
);
|
|
assert.match(doc.lineAt(diagnostic.range.start.line).text, /brand: not-a-brand/);
|
|
});
|
|
|
|
await check('opens a live preview beside the source', async () => {
|
|
await vscode.commands.executeCommand('archify.showPreviewToSide', workspaceFile('production-deployment.architecture.json'));
|
|
const tabs = await waitFor('preview tab', () => {
|
|
const found = webviewTabs('archify.preview').filter((tab) => /production-deployment/.test(tab.label));
|
|
return found.length > 0 && found;
|
|
});
|
|
assert.equal(tabs.length, 1);
|
|
assert.match(tabs[0].label, /Preview production-deployment\.architecture\.json/);
|
|
});
|
|
|
|
await check('reports validation problems in the Problems panel with JSON ranges', async () => {
|
|
const doc = await vscode.workspace.openTextDocument({
|
|
language: 'json',
|
|
content: JSON.stringify({ schema_version: 1, diagram_type: 'architecture', meta: { title: 'x' }, components: [{ id: 'a' }] }, null, 2),
|
|
});
|
|
await vscode.window.showTextDocument(doc);
|
|
const diagnostics = await waitFor('archify diagnostics', () => {
|
|
const found = vscode.languages.getDiagnostics(doc.uri).filter((d) => d.source === 'archify');
|
|
return found.length > 0 && found;
|
|
});
|
|
const component = diagnostics.find((d) => /components\/0/.test(d.message));
|
|
assert.ok(component, 'a diagnostic for /components/0');
|
|
assert.equal(doc.getText(component.range), '{', 'range points at the component object');
|
|
assert.equal(component.severity, vscode.DiagnosticSeverity.Error);
|
|
});
|
|
|
|
await check('clears problems once the document is fixed', async () => {
|
|
const good = await vscode.workspace.openTextDocument(workspaceFile('event-stream.dataflow.json'));
|
|
const doc = await vscode.workspace.openTextDocument({ language: 'json', content: good.getText() });
|
|
await vscode.window.showTextDocument(doc);
|
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
const errors = vscode.languages.getDiagnostics(doc.uri).filter((d) => d.source === 'archify' && d.severity === vscode.DiagnosticSeverity.Error);
|
|
assert.deepEqual(errors.map((d) => d.message), []);
|
|
});
|
|
|
|
await check('renders to an HTML file and opens it in the Archify viewer', async () => {
|
|
const source = workspaceFile('cache-miss-request.sequence.json');
|
|
const output = vscode.Uri.file(path.join(path.dirname(source.fsPath), 'cache-miss-request.sequence.html'));
|
|
// Stub the save dialog and the follow-up notification.
|
|
const window = vscode.window as unknown as Record<string, unknown>;
|
|
const originalSave = window.showSaveDialog;
|
|
const originalInfo = window.showInformationMessage;
|
|
window.showSaveDialog = async () => output;
|
|
window.showInformationMessage = async () => 'Open in Viewer';
|
|
try {
|
|
await vscode.commands.executeCommand('archify.renderToHtml', source);
|
|
} finally {
|
|
window.showSaveDialog = originalSave;
|
|
window.showInformationMessage = originalInfo;
|
|
}
|
|
const html = new TextDecoder().decode(await vscode.workspace.fs.readFile(output));
|
|
assert.match(html, /<meta name="generator" content="archify/);
|
|
await waitFor('custom editor tab', () => webviewTabs('archify.htmlViewer').length > 0);
|
|
});
|
|
|
|
await check('source-backed diagram previews with the evidence fallback', async () => {
|
|
const uri = workspaceFile('mco-runtime.architecture.json');
|
|
await vscode.commands.executeCommand('archify.showPreview', uri);
|
|
await waitFor('mco preview tab', () => webviewTabs('archify.preview').some((tab) => /mco-runtime/.test(tab.label)));
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
const diagnostics = await waitFor('evidence diagnostics', () => {
|
|
const found = vscode.languages.getDiagnostics(doc.uri).filter((d) => d.source === 'archify');
|
|
return found.length > 0 && found;
|
|
});
|
|
assert.ok(
|
|
diagnostics.every((d) => d.severity !== vscode.DiagnosticSeverity.Error),
|
|
'unverifiable evidence is a warning in fallback mode',
|
|
);
|
|
});
|
|
|
|
// --- models: a folder with main.archify, registries and diagrams that use `ref`
|
|
|
|
await check('a diagram inside a model resolves registry refs and validates clean', async () => {
|
|
const uri = workspaceFile('model/0200-containers/online-shop.archify');
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
await vscode.window.showTextDocument(doc);
|
|
// Validation runs through the Archify CLI; give it time, then expect no errors at all.
|
|
await new Promise((resolve) => setTimeout(resolve, 6000));
|
|
const errors = vscode.languages.getDiagnostics(doc.uri).filter((d) => d.source === 'archify' && d.severity === vscode.DiagnosticSeverity.Error);
|
|
assert.deepEqual(errors.map((d) => d.message), []);
|
|
});
|
|
|
|
await check('an unknown ref is reported at its line', async () => {
|
|
const target = workspaceFile('model/0300-components/ghost.archify');
|
|
await vscode.workspace.fs.writeFile(
|
|
target,
|
|
new TextEncoder().encode('schema_version: 1\ndiagram_type: architecture\nmeta: { title: Ghost, output: ghost.html }\ncomponents:\n - ref: ghost\n pos: [40, 40]\n size: [130, 60]\n'),
|
|
);
|
|
const doc = await vscode.workspace.openTextDocument(target);
|
|
await vscode.window.showTextDocument(doc);
|
|
const diagnostic = await waitFor('unknown ref diagnostic', () =>
|
|
vscode.languages.getDiagnostics(doc.uri).find((d) => d.source === 'archify' && /Unknown element "ghost"/.test(d.message)),
|
|
);
|
|
assert.equal(diagnostic.range.start.line, 4, 'reported on the ref line');
|
|
});
|
|
|
|
await check('hover and completion on ref come from the registry', async () => {
|
|
const uri = workspaceFile('model/0200-containers/online-shop.archify');
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
const line = doc.getText().split('\n').findIndex((l) => /ref: api$/.test(l));
|
|
assert.ok(line >= 0, 'the containers diagram refers to api');
|
|
const hovers = await vscode.commands.executeCommand<vscode.Hover[]>('vscode.executeHoverProvider', uri, new vscode.Position(line, 10));
|
|
const text = hovers.flatMap((h) => h.contents).map((c) => (typeof c === 'string' ? c : c.value)).join('\n');
|
|
assert.match(text, /Owns authentication/);
|
|
const defs = await vscode.commands.executeCommand<vscode.Location[]>('vscode.executeDefinitionProvider', uri, new vscode.Position(line, 10));
|
|
assert.match(defs[0].uri.path, /registry\/containers\.archify-registry$/);
|
|
const completions = await vscode.commands.executeCommand<vscode.CompletionList>('vscode.executeCompletionItemProvider', uri, new vscode.Position(line, 9));
|
|
const labels = completions.items.map((i) => (typeof i.label === 'string' ? i.label : i.label.label));
|
|
assert.ok(labels.includes('api') && labels.includes('queue'), `completion lists registry elements: ${labels.join(', ')}`);
|
|
});
|
|
|
|
await check('a registry file reports duplicate ids and parse problems', async () => {
|
|
const target = workspaceFile('model/registry/extra.archify-registry');
|
|
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode('schema_version: 1\nelements:\n api:\n type: backend\n label: API again\n'));
|
|
const doc = await vscode.workspace.openTextDocument(target);
|
|
await vscode.window.showTextDocument(doc);
|
|
const diagnostic = await waitFor('duplicate id diagnostic', () =>
|
|
vscode.languages.getDiagnostics(doc.uri).find((d) => d.source === 'archify' && /defined twice/.test(d.message)),
|
|
);
|
|
assert.equal(diagnostic.range.start.line, 2);
|
|
await vscode.workspace.fs.delete(target);
|
|
});
|
|
|
|
await check('main.archify opens as the model page and Open Model finds it from a diagram', async () => {
|
|
const main = workspaceFile('model/main.archify');
|
|
await vscode.commands.executeCommand('vscode.open', main);
|
|
await waitFor('model page tab', () =>
|
|
vscode.window.tabGroups.all.flatMap((g) => g.tabs).some((t) => t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.diagram' && t.input.uri.toString() === main.toString()),
|
|
);
|
|
const diagram = workspaceFile('model/0100-context/online-shop.archify');
|
|
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(diagram));
|
|
await vscode.commands.executeCommand('archify.openModel', diagram);
|
|
const tab = await waitFor('model page active', () =>
|
|
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.uri.toString() === main.toString()),
|
|
);
|
|
assert.ok(tab);
|
|
});
|
|
|
|
await check('Archify | Open Model Graph switches the main.archify editor to the Graph page and back', async () => {
|
|
const main = workspaceFile('model/main.archify');
|
|
const diagram = workspaceFile('model/0100-context/online-shop.archify');
|
|
const commands = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.contributes.commands as { command: string; title: string }[];
|
|
assert.equal(commands.find((item) => item.command === 'archify.openModelGraph')?.title, 'Archify | Open Model Graph');
|
|
await vscode.commands.executeCommand('archify.openModelGraph', diagram);
|
|
const tab = await waitFor('model editor active', () =>
|
|
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.uri.toString() === main.toString()),
|
|
);
|
|
assert.ok(tab);
|
|
// The choice is remembered per file in the workspace state until the page is switched back.
|
|
const ext = vscode.extensions.getExtension(EXTENSION_ID);
|
|
assert.ok(ext?.isActive);
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
});
|
|
|
|
await check('a .archify-registry file opens as the registry view and Open in Text Editor opens the YAML', async () => {
|
|
const registry = workspaceFile('model/registry/people.archify-registry');
|
|
await vscode.commands.executeCommand('vscode.open', registry);
|
|
const tab = await waitFor('registry view tab', () =>
|
|
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.registry' && t.input.uri.toString() === registry.toString()),
|
|
);
|
|
assert.ok(tab);
|
|
await vscode.commands.executeCommand('archify.openInTextEditor');
|
|
const editor = await waitFor('registry text editor', () => vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === registry.toString()));
|
|
assert.equal(editor.document.languageId, 'yaml');
|
|
});
|
|
|
|
await check('a .archify-decision file opens as the decision view; an unknown decision id is a problem in a diagram', async () => {
|
|
const decision = workspaceFile('model/decisions/0002-queue-between-api-and-worker.archify-decision');
|
|
await vscode.commands.executeCommand('vscode.open', decision);
|
|
const tab = await waitFor('decision view tab', () =>
|
|
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.decision' && t.input.uri.toString() === decision.toString()),
|
|
);
|
|
assert.ok(tab);
|
|
// A diagram that points at a decision nobody defines.
|
|
const bad = workspaceFile('model/0300-components/bad-decision.archify');
|
|
await vscode.workspace.fs.writeFile(bad, Buffer.from('schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Bad\n output: bad.html\ndecisions: [nobody-decided]\ncomponents:\n - ref: api\n pos: [40, 60]\n size: [130, 60]\n decisions: [queue-between-api-and-worker]\n', 'utf8'));
|
|
const doc = await vscode.workspace.openTextDocument(bad);
|
|
await vscode.window.showTextDocument(doc);
|
|
const problem = await waitFor('unknown decision problem', () =>
|
|
vscode.languages.getDiagnostics(doc.uri).find((d) => d.source === 'archify' && /Unknown decision "nobody-decided"/.test(d.message)),
|
|
);
|
|
assert.equal(problem.range.start.line, 5);
|
|
assert.ok(!vscode.languages.getDiagnostics(doc.uri).some((d) => /queue-between-api-and-worker/.test(d.message)), 'the known decision is not reported');
|
|
await vscode.workspace.fs.delete(bad);
|
|
});
|
|
|
|
await check('Archify | New Registry… creates a registry file and opens the registry view; creators are ordered', async () => {
|
|
const commands = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.contributes as { commands: { command: string }[]; menus: Record<string, { command: string; group: string }[]> };
|
|
const creators = commands.menus['explorer/context'].filter((m) => /^archify\.new/.test(m.command));
|
|
assert.deepEqual(creators.map((m) => m.command), ['archify.newModel', 'archify.newRegistry', 'archify.newDiagram', 'archify.newDecision']);
|
|
assert.deepEqual(creators.map((m) => m.group), ['zzz_archify@1', 'zzz_archify@2', 'zzz_archify@3', 'zzz_archify@4']);
|
|
const folder = workspaceFile('model/registry');
|
|
const window = vscode.window as unknown as Record<string, unknown>;
|
|
const originalInput = window.showInputBox;
|
|
window.showInputBox = async () => 'partners';
|
|
try {
|
|
await vscode.commands.executeCommand('archify.newRegistry', folder);
|
|
} finally {
|
|
window.showInputBox = originalInput;
|
|
}
|
|
const file = vscode.Uri.joinPath(folder, 'partners.archify-registry');
|
|
const text = new TextDecoder().decode(await vscode.workspace.fs.readFile(file));
|
|
assert.match(text, /^elements:/m);
|
|
const tab = await waitFor('registry view tab', () =>
|
|
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.registry' && t.input.uri.toString() === file.toString()),
|
|
);
|
|
assert.ok(tab);
|
|
await vscode.workspace.fs.delete(file);
|
|
});
|
|
|
|
await check('Archify | New Diagram… → Model creates main.archify from the folder name', async () => {
|
|
const folder = workspaceFile('new-shop');
|
|
await vscode.workspace.fs.createDirectory(folder);
|
|
const { window } = vscode;
|
|
const originalPick = window.showQuickPick;
|
|
(window as { showQuickPick: unknown }).showQuickPick = async (items: Thenable<{ label: string }[]> | { label: string }[]) => (await items).find((i) => /^Model/.test(i.label));
|
|
try {
|
|
await vscode.commands.executeCommand('archify.newDiagram', folder);
|
|
} finally {
|
|
window.showQuickPick = originalPick;
|
|
}
|
|
const main = vscode.Uri.joinPath(folder, 'main.archify');
|
|
let content = '';
|
|
for (let i = 0; i < 50 && !content; i++) {
|
|
try {
|
|
content = new TextDecoder().decode(await vscode.workspace.fs.readFile(main));
|
|
} catch {
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
}
|
|
}
|
|
assert.match(content, /id: new-shop/);
|
|
assert.match(content, /name: New shop/);
|
|
});
|
|
|
|
await check('Archify | New Model… creates a model folder from an example and opens its page', async () => {
|
|
const commands = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.contributes.commands as { command: string; title: string }[];
|
|
assert.equal(commands.find((item) => item.command === 'archify.newModel')?.title, 'Archify | New Model…');
|
|
const parent = workspaceFile('.');
|
|
const window = vscode.window as unknown as Record<string, unknown>;
|
|
const originalPick = window.showQuickPick;
|
|
const originalInput = window.showInputBox;
|
|
let labels: string[] = [];
|
|
let existingNameMessage: unknown;
|
|
window.showQuickPick = async (items: readonly { label: string }[]) => {
|
|
labels = (await items).map((item) => item.label);
|
|
return (await items).find((item) => item.label === 'C4 - E-Shop');
|
|
};
|
|
window.showInputBox = async (options: vscode.InputBoxOptions) => {
|
|
existingNameMessage = await options.validateInput?.('new-shop');
|
|
return 'shop-two';
|
|
};
|
|
try {
|
|
await vscode.commands.executeCommand('archify.newModel', parent);
|
|
} finally {
|
|
window.showQuickPick = originalPick;
|
|
window.showInputBox = originalInput;
|
|
}
|
|
assert.deepEqual(labels, [
|
|
'C4 - Standard Web App', 'C4 - E-Shop', 'C4 - Event-Based Integration',
|
|
'arc42 - Cinema Tickets', 'arc42 - IoT Telemetry', 'arc42 - Team Wiki',
|
|
'ArchiMate - Insurance Claims', 'ArchiMate - HR Onboarding', 'ArchiMate - Logistics Tracking',
|
|
]);
|
|
assert.match(String(existingNameMessage), /already exists/);
|
|
const folder = vscode.Uri.joinPath(parent, 'shop-two');
|
|
const main = vscode.Uri.joinPath(folder, 'main.archify');
|
|
const content = new TextDecoder().decode(await vscode.workspace.fs.readFile(main));
|
|
assert.match(content, /id: shop-two/);
|
|
assert.match(content, /name: Shop two/);
|
|
assert.match(content, /title: Shop two — overview/);
|
|
const names = (await vscode.workspace.fs.readDirectory(folder)).map(([name]) => name).sort();
|
|
assert.deepEqual(names, ['0100-context', '0200-containers', '0300-components', 'decisions', 'main.archify', 'registry']);
|
|
const registry = (await vscode.workspace.fs.readDirectory(vscode.Uri.joinPath(folder, 'registry'))).map(([name]) => name).sort();
|
|
assert.deepEqual(registry, ['containers.archify-registry', 'people.archify-registry', 'systems.archify-registry']);
|
|
const tab = await waitFor('model page tab', () =>
|
|
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.uri.toString() === main.toString()),
|
|
);
|
|
assert.ok(tab);
|
|
// The new model is indexed: its diagrams resolve against the copied registry.
|
|
const containers = await vscode.workspace.openTextDocument(vscode.Uri.joinPath(folder, '0200-containers', 'online-shop.archify'));
|
|
await vscode.window.showTextDocument(containers);
|
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
const errors = vscode.languages.getDiagnostics(containers.uri).filter((d) => d.severity === vscode.DiagnosticSeverity.Error);
|
|
assert.deepEqual(errors.map((d) => d.message), []);
|
|
});
|
|
|
|
await check('a registry is checked against its schema without a YAML extension', async () => {
|
|
const target = workspaceFile('model/registry/typo.archify-registry');
|
|
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode('schema_version: 1\nelements:\n billing:\n typ: backend\n label: Billing\n'));
|
|
const doc = await vscode.workspace.openTextDocument(target);
|
|
await vscode.window.showTextDocument(doc);
|
|
const unknown = await waitFor('unknown key diagnostic', () =>
|
|
vscode.languages.getDiagnostics(doc.uri).find((d) => d.source === 'archify' && /Unknown key "typ" in elements\.billing/.test(d.message)),
|
|
);
|
|
assert.equal(doc.getText(unknown.range), 'typ');
|
|
assert.ok(vscode.languages.getDiagnostics(doc.uri).some((d) => /"type" is required in elements\.billing/.test(d.message)));
|
|
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
|
|
await vscode.workspace.fs.delete(target);
|
|
});
|
|
|
|
await check('without a shared model, sync stays unloaded and its commands are there', async () => {
|
|
const commands = await vscode.commands.getCommands(true);
|
|
for (const id of ['archify.sync.signIn', 'archify.sync.share', 'archify.sync.join', 'archify.sync.menu', 'archify.sync.setServer']) assert.ok(commands.includes(id), `${id} is registered`);
|
|
// No server of anyone's built in: empty until the user sets one.
|
|
const config = vscode.workspace.getConfiguration('archify.sync');
|
|
assert.equal(config.get('server'), '');
|
|
// The extension host's module cache: dist/sync.js (Yjs, the WebSocket) is not loaded.
|
|
const loaded = Object.keys(require.cache).filter((file) => file.endsWith(`${path.sep}dist${path.sep}sync.js`));
|
|
assert.deepEqual(loaded, [], 'the sync bundle is not loaded');
|
|
});
|
|
|
|
await check('sync file bridge: local edits reach the document, teammates\' edits reach files and editors', async () => {
|
|
const root = workspaceFile('shared');
|
|
const file = (rel: string) => vscode.Uri.joinPath(root, ...rel.split('/'));
|
|
const write = (rel: string, text: string) => vscode.workspace.fs.writeFile(file(rel), new TextEncoder().encode(text));
|
|
const read = async (rel: string) => new TextDecoder().decode(await vscode.workspace.fs.readFile(file(rel)));
|
|
const exists = (rel: string) => Promise.resolve(vscode.workspace.fs.stat(file(rel))).then(() => true, () => false);
|
|
await write('main.archify', 'model:\n id: shared\n');
|
|
await write('0100-context/a.archify', 'schema_version: 1\r\nmeta:\r\n title: A\r\n');
|
|
await write('notes.txt', 'not a model file');
|
|
|
|
const doc = new Y.Doc();
|
|
const teammate = new Y.Doc();
|
|
// The server in between: every update reaches the other side.
|
|
doc.on('update', (u: Uint8Array, origin: unknown) => origin !== 'remote' && Y.applyUpdate(teammate, u, 'remote'));
|
|
teammate.on('update', (u: Uint8Array, origin: unknown) => origin !== 'remote' && Y.applyUpdate(doc, u, 'remote'));
|
|
const quiet = { info() {}, warn() {}, error() {}, debug() {}, trace() {} } as unknown as vscode.LogOutputChannel;
|
|
const bridge = new FileBridge(root, doc, { origin: 'local' }, quiet);
|
|
const theirs = () => teammate.getMap<Y.Text>('files');
|
|
try {
|
|
await bridge.pushAll(false);
|
|
bridge.start(true);
|
|
assert.deepEqual([...theirs().keys()].sort(), ['0100-context/a.archify', 'main.archify'], 'only model files are shared');
|
|
assert.equal(theirs().get('0100-context/a.archify')!.toString(), 'schema_version: 1\nmeta:\n title: A\n', 'line endings travel as \\n');
|
|
|
|
// A local edit in an open editor.
|
|
const main = await vscode.workspace.openTextDocument(file('main.archify'));
|
|
const edit = new vscode.WorkspaceEdit();
|
|
edit.insert(main.uri, new vscode.Position(2, 0), ' name: Shared\n');
|
|
assert.ok(await vscode.workspace.applyEdit(edit));
|
|
await waitFor('local edit at the teammate', () => theirs().get('main.archify')!.toString() === 'model:\n id: shared\n name: Shared\n');
|
|
await main.save();
|
|
|
|
// The teammate types twice in a row into the open file: both land, nothing is undone by an echo.
|
|
theirs().get('main.archify')!.insert(0, '# one\n');
|
|
theirs().get('main.archify')!.insert(0, '# two\n');
|
|
await waitFor('teammate edits in the open editor', () => main.getText() === '# two\n# one\nmodel:\n id: shared\n name: Shared\n');
|
|
await waitFor('saved, so git sees it', () => !main.isDirty);
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
assert.equal(theirs().get('main.archify')!.toString(), main.getText(), 'no echo changed it back');
|
|
|
|
// The teammate edits a closed file, keeping its CRLF on disk.
|
|
const a = theirs().get('0100-context/a.archify')!;
|
|
a.insert(a.length, ' output: a.html\n');
|
|
const deadline = Date.now() + 10_000;
|
|
while ((await read('0100-context/a.archify')) !== 'schema_version: 1\r\nmeta:\r\n title: A\r\n output: a.html\r\n' && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
|
|
assert.equal(await read('0100-context/a.archify'), 'schema_version: 1\r\nmeta:\r\n title: A\r\n output: a.html\r\n');
|
|
|
|
// A new file and a deletion from the teammate.
|
|
teammate.transact(() => {
|
|
const t = new Y.Text();
|
|
t.insert(0, 'schema_version: 1\n');
|
|
theirs().set('registry/people.archify-registry', t);
|
|
theirs().set('../escape.archify', new Y.Text('nope'));
|
|
});
|
|
const until = async (what: string, probe: () => Promise<boolean>) => {
|
|
const end = Date.now() + 10_000;
|
|
while (!(await probe())) {
|
|
if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`);
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
}
|
|
};
|
|
await until('new file on disk', () => exists('registry/people.archify-registry'));
|
|
assert.ok(!(await exists('../escape.archify')), 'a path outside the model is never written');
|
|
teammate.transact(() => theirs().delete('0100-context/a.archify'));
|
|
await until('deleted file gone', async () => !(await exists('0100-context/a.archify')));
|
|
|
|
// A change on disk by another tool (git, an agent) to a closed file.
|
|
await write('registry/people.archify-registry', 'schema_version: 1\nelements: {}\n');
|
|
await waitFor('disk change at the teammate', () => theirs().get('registry/people.archify-registry')?.toString() === 'schema_version: 1\nelements: {}\n');
|
|
} finally {
|
|
bridge.dispose();
|
|
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
|
|
await vscode.workspace.fs.delete(root, { recursive: true });
|
|
}
|
|
});
|
|
|
|
// Opt-in: a running Archify Sync server, a space on it and an access token.
|
|
const syncServer = process.env.ARCHIFY_SYNC_TEST_URL;
|
|
if (syncServer) await check('two shared copies of a model stay the same through the sync server', async () => {
|
|
const space = process.env.ARCHIFY_SYNC_TEST_SPACE!;
|
|
const token = process.env.ARCHIFY_SYNC_TEST_TOKEN!;
|
|
const storage = workspaceFile('.sync-storage');
|
|
const auth = {
|
|
session: async () => ({ account: { id: 'it', display_name: 'Integration' } }),
|
|
accessToken: async () => token,
|
|
} as unknown as SyncAuth;
|
|
const quiet = { info() {}, warn() {}, error() {}, debug() {}, trace() {} } as unknown as vscode.LogOutputChannel;
|
|
const services = (dir: string) => ({ context: { globalStorageUri: vscode.Uri.joinPath(storage, dir) } as vscode.ExtensionContext, auth, log: quiet, onChange() {} });
|
|
const link = { server: syncServer, space, name: 'IT' };
|
|
const mine = workspaceFile('live-mine');
|
|
const theirs = workspaceFile('live-theirs');
|
|
const read = async (root: vscode.Uri, rel: string) => {
|
|
try {
|
|
return new TextDecoder().decode(await vscode.workspace.fs.readFile(vscode.Uri.joinPath(root, ...rel.split('/'))));
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
const until = async (what: string, probe: () => Promise<boolean>) => {
|
|
const end = Date.now() + 15_000;
|
|
while (!(await probe())) {
|
|
if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`);
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
}
|
|
};
|
|
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(mine, 'main.archify'), new TextEncoder().encode('model:\n id: it\n'));
|
|
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(mine, 'registry', 'people.archify-registry'), new TextEncoder().encode('schema_version: 1\n'));
|
|
await vscode.workspace.fs.createDirectory(theirs);
|
|
const a = new LiveSpace(mine, link, 'share', services('a'));
|
|
let b: LiveSpace | undefined;
|
|
try {
|
|
await a.start();
|
|
await until('shared copy online', async () => a.state === 'online');
|
|
b = new LiveSpace(theirs, link, 'join', services('b'));
|
|
await b.start();
|
|
await until('joined copy gets the files', async () => (await read(theirs, 'main.archify')) === 'model:\n id: it\n' && (await read(theirs, 'registry/people.archify-registry')) === 'schema_version: 1\n');
|
|
|
|
// Typing in one copy's editor shows up in the other copy's file.
|
|
const doc = await vscode.workspace.openTextDocument(vscode.Uri.joinPath(mine, 'main.archify'));
|
|
const edit = new vscode.WorkspaceEdit();
|
|
edit.insert(doc.uri, new vscode.Position(2, 0), ' name: Live 🚀\n');
|
|
assert.ok(await vscode.workspace.applyEdit(edit));
|
|
await until('edit arrives in the other copy', async () => (await read(theirs, 'main.archify')) === 'model:\n id: it\n name: Live 🚀\n');
|
|
|
|
// A file written by another tool in the other copy comes back.
|
|
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(theirs, '0100-context', 'c.archify'), new TextEncoder().encode('schema_version: 1\n'));
|
|
await until('new file arrives back', async () => (await read(mine, '0100-context/c.archify')) === 'schema_version: 1\n');
|
|
await until('presence shows one person in both', async () => a.people().length === 1 && b!.people().length === 1);
|
|
|
|
// A cursor in one copy shows in the other, and stays on its word while text is typed above it.
|
|
const at = 'model:\n id: it\n name: '.length;
|
|
assert.ok(a.setCursor(vscode.Uri.joinPath(mine, 'main.archify'), { anchor: at, head: at + 4 }));
|
|
const theirsMain = vscode.Uri.joinPath(theirs, 'main.archify');
|
|
await until('cursor arrives in the other copy', async () => b!.cursors(theirsMain).length === 1);
|
|
const cursor = b.cursors(theirsMain)[0];
|
|
assert.deepEqual([cursor.name, cursor.anchor, cursor.head], ['Integration', at, at + 4]);
|
|
assert.equal(a.cursors(vscode.Uri.joinPath(mine, 'main.archify')).length, 0, 'your own cursor is not drawn for you');
|
|
const above = new vscode.WorkspaceEdit();
|
|
above.insert(theirsMain, new vscode.Position(0, 0), '# top\n');
|
|
assert.ok(await vscode.workspace.applyEdit(above));
|
|
await until('cursor moves with the text', async () => b!.cursors(theirsMain)[0]?.anchor === at + '# top\n'.length);
|
|
a.setCursor(vscode.Uri.joinPath(mine, 'main.archify'), undefined);
|
|
await until('cursor goes away', async () => b!.cursors(theirsMain).length === 0);
|
|
} finally {
|
|
a.dispose();
|
|
b?.dispose();
|
|
await vscode.commands.executeCommand('workbench.action.revertAndCloseActiveEditor');
|
|
for (const uri of [mine, theirs, storage]) await vscode.workspace.fs.delete(uri, { recursive: true }).then(undefined, () => undefined);
|
|
}
|
|
});
|
|
|
|
await check('registry and decision pages own their document: edits make it dirty and VS Code undo reverts them', async () => {
|
|
for (const [file, viewType] of [
|
|
['model/registry/containers.archify-registry', 'archify.registry'],
|
|
['model/decisions/0001-postgres-as-source-of-truth.archify-decision', 'archify.decision'],
|
|
] as const) {
|
|
const uri = workspaceFile(file);
|
|
await vscode.commands.executeCommand('vscode.openWith', uri, viewType);
|
|
await waitFor(`${viewType} tab`, () =>
|
|
vscode.window.tabGroups.activeTabGroup.activeTab?.input instanceof vscode.TabInputCustom &&
|
|
(vscode.window.tabGroups.activeTabGroup.activeTab.input as vscode.TabInputCustom).uri.toString() === uri.toString(),
|
|
);
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
const before = doc.getText();
|
|
// What the Source view does with a keystroke: a WorkspaceEdit on the document.
|
|
const edit = new vscode.WorkspaceEdit();
|
|
edit.insert(uri, new vscode.Position(0, 0), '# edited in Source\n');
|
|
assert.ok(await vscode.workspace.applyEdit(edit));
|
|
assert.ok(doc.isDirty, `${file} is dirty`);
|
|
const tab = vscode.window.tabGroups.activeTabGroup.activeTab;
|
|
assert.ok(tab?.isDirty, 'the page tab shows the dirty dot');
|
|
// ⌘Z in the page reaches VS Code as the undo command.
|
|
await vscode.commands.executeCommand('undo');
|
|
await waitFor('undo', () => doc.getText() === before);
|
|
await vscode.commands.executeCommand('workbench.action.revertAndCloseActiveEditor');
|
|
}
|
|
});
|
|
}
|