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(what: string, probe: () => T | undefined | false, timeoutMs = 20_000): Promise { 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): Promise { 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 { 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; 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.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; 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, / { 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.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.executeDefinitionProvider', uri, new vscode.Position(line, 10)); assert.match(defs[0].uri.path, /registry\/containers\.archify-registry$/); const completions = await vscode.commands.executeCommand('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 }; 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; 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; 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('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) => { 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) => { 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'); } }); }