From 30e2faa38c6792cca87ff69847c5a7ef3afe7ba5 Mon Sep 17 00:00:00 2001 From: Oleg Lukasonok Date: Wed, 30 Sep 2026 12:56:12 +0300 Subject: [PATCH] Support .archify diagram files (0.2.0) .archify files are Archify JSON diagram sources. They open as JSON, preview with the same commands, report validation problems, and get completion from a schema that dispatches on diagram_type. Adds a hello.archify example and tests for detection, the dispatching schema and the VS Code behaviour. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 ++ README.md | 37 ++++++++++- examples/hello.archify | 17 ++++++ package-lock.json | 5 +- package.json | 29 ++++++--- schemas/archify.schema.json | 98 ++++++++++++++++++++++++++++++ scripts/sync-archify.mjs | 22 +++++++ src/detect.ts | 17 ++++++ src/extension.ts | 14 +++-- src/preview.ts | 5 +- test/detect.test.ts | 12 ++++ test/fixtures/cache-miss.archify | 77 +++++++++++++++++++++++ test/fixtures/web-platform.archify | 51 ++++++++++++++++ test/integration/suite.ts | 49 +++++++++++++-- test/schema.test.ts | 43 +++++++++++++ 15 files changed, 458 insertions(+), 24 deletions(-) create mode 100644 examples/hello.archify create mode 100644 schemas/archify.schema.json create mode 100644 test/fixtures/cache-miss.archify create mode 100644 test/fixtures/web-platform.archify create mode 100644 test/schema.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0507547..6a50d99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.0 — 2026-09-30 + +- `.archify` files: Archify JSON diagram sources with their own extension. They open as JSON, preview with the same button and keys, report problems, and get completion from the schema of their `diagram_type`. +- Render to HTML and Open in Browser name the output after the diagram (`web.archify` → `web.html`). +- `examples/hello.archify` starter diagram. + ## 0.1.0 — 2026-09-30 - Live preview for Archify JSON sources (architecture, workflow, sequence, dataflow, lifecycle), re-rendered as you type. diff --git a/README.md b/README.md index 2d26585..2907c25 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,46 @@ # Archify Diagram Viewer -View [Archify](https://github.com/tt-a1i/archify) diagrams inside VS Code. Open a diagram's JSON source and see the interactive diagram next to it, updated as you type. Open rendered Archify HTML files in a viewer tab instead of a browser. +View [Archify](https://github.com/tt-a1i/archify) diagrams inside VS Code. Write a diagram in a `.archify` file and see the interactive diagram next to it, updated as you type. Open rendered Archify HTML files in a viewer tab instead of a browser. This is an unofficial extension. It bundles the Archify renderer (MIT, © tt-a1i) and runs it on VS Code's own Node runtime, so nothing else needs to be installed. +## Quick start + +1. Create a file ending in `.archify`, for example `hello.archify`: + + ```json + { + "schema_version": 1, + "diagram_type": "architecture", + "meta": { "title": "Hello Archify", "output": "hello.html" }, + "components": [ + { "id": "browser", "type": "frontend", "label": "Browser", "pos": [40, 120], "size": [140, 60] }, + { "id": "api", "type": "backend", "label": "API", "pos": [260, 120], "size": [140, 60] }, + { "id": "db", "type": "database", "label": "PostgreSQL", "pos": [480, 120], "size": [140, 60] } + ], + "connections": [ + { "from": "browser", "to": "api", "label": "HTTPS" }, + { "from": "api", "to": "db", "label": "SQL" } + ] + } + ``` + +2. Click the preview button in the editor title, or press Cmd/Ctrl+K V. + +The same file is in [examples/hello.archify](examples/hello.archify). + +## The `.archify` format + +A `.archify` file is an Archify JSON diagram source, the same JSON the Archify CLI renders. `diagram_type` selects the kind of diagram: `architecture`, `workflow`, `sequence`, `dataflow` or `lifecycle`. VS Code treats `.archify` as JSON, so you get syntax highlighting, formatting, and completion and hover from the schema for that `diagram_type`. + +Archify's own naming, `name.architecture.json`, `name.workflow.json` and so on, works too. + ## Features -- **Live preview**: for `*.architecture.json`, `*.workflow.json`, `*.sequence.json`, `*.dataflow.json` and `*.lifecycle.json` files, or any JSON with a `diagram_type`. Use the preview button in the editor title, Cmd/Ctrl+K V (to the side) or Cmd/Ctrl+Shift+V. The preview keeps the focus and route you selected (`#focus=…`, `#route=…`) when it re-renders. +- **Live preview**: for `.archify` files, Archify's `*.architecture.json`-style files, or any JSON with a `diagram_type`. Use the preview button in the editor title, Cmd/Ctrl+K V (to the side) or Cmd/Ctrl+Shift+V. The preview keeps the focus and route you selected (`#focus=…`, `#route=…`) when it re-renders. - **HTML viewer**: right-click a rendered `.html` file and choose **Open in Archify Viewer**, or use *Reopen Editor With… → Archify Diagram Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk. - **Problems**: `archify validate` runs on the source and reports schema, layout and evidence problems at the matching JSON location. -- **Schema help**: completion and hover documentation come from Archify's own JSON schemas. +- **Schema help**: completion and hover documentation come from Archify's own JSON schemas. In `.archify` files, the schema follows `diagram_type`. - **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead. - **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog. - **Commands**: *Archify: Render to HTML File…*, *Archify: Open in Browser*, *Archify: Refresh Preview*, *Archify: Show Source*. diff --git a/examples/hello.archify b/examples/hello.archify new file mode 100644 index 0000000..5e8c705 --- /dev/null +++ b/examples/hello.archify @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "diagram_type": "architecture", + "meta": { + "title": "Hello Archify", + "output": "hello.html" + }, + "components": [ + { "id": "browser", "type": "frontend", "label": "Browser", "pos": [40, 120], "size": [140, 60] }, + { "id": "api", "type": "backend", "label": "API", "pos": [260, 120], "size": [140, 60] }, + { "id": "db", "type": "database", "label": "PostgreSQL", "pos": [480, 120], "size": [140, 60] } + ], + "connections": [ + { "from": "browser", "to": "api", "label": "HTTPS" }, + { "from": "api", "to": "db", "label": "SQL" } + ] +} diff --git a/package-lock.json b/package-lock.json index 7cc27d7..582418f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "archify-vscode-ext", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "archify-vscode-ext", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", "@types/vscode": "~1.100.0", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.0.0", + "ajv": "^8.20.0", "esbuild": "^0.25.0", "jsonc-parser": "^3.3.1", "typescript": "^5.8.0" diff --git a/package.json b/package.json index ad5153d..4924e32 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "archify-vscode-ext", "displayName": "Archify Diagram Viewer", - "description": "Preview Archify diagrams (architecture, workflow, sequence, dataflow, lifecycle) from their JSON sources and open rendered Archify HTML inside VS Code.", - "version": "0.1.0", + "description": "Visualize Archify diagrams: preview .archify files (and *.architecture.json-style sources) live, and open rendered Archify HTML inside VS Code.", + "version": "0.2.0", "publisher": "vscode-extensions", "license": "MIT", "repository": { @@ -28,7 +28,7 @@ "onLanguage:json", "onLanguage:jsonc", "onLanguage:html", - "workspaceContains:**/*.{architecture,workflow,sequence,dataflow,lifecycle}.json", + "workspaceContains:**/*.{archify,architecture.json,workflow.json,sequence.json,dataflow.json,lifecycle.json}", "onWebviewPanel:archify.preview" ], "contributes": { @@ -53,7 +53,7 @@ }, { "command": "archify.renderToHtml", - "title": "Render to HTML File\u2026", + "title": "Render to HTML File…", "category": "Archify", "icon": "$(file-code)" }, @@ -142,12 +142,12 @@ "explorer/context": [ { "command": "archify.showPreview", - "when": "resourceFilename =~ /\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json$/", + "when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/", "group": "navigation@20" }, { "command": "archify.renderToHtml", - "when": "resourceFilename =~ /\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json$/", + "when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/", "group": "navigation@21" }, { @@ -159,7 +159,7 @@ "editor/title/context": [ { "command": "archify.showPreview", - "when": "resourceFilename =~ /\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json$/", + "when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/", "group": "1_open" } ] @@ -191,6 +191,10 @@ } ], "jsonValidation": [ + { + "fileMatch": "*.archify", + "url": "./schemas/archify.schema.json" + }, { "fileMatch": "*.architecture.json", "url": "./schemas/architecture.schema.json" @@ -287,7 +291,15 @@ "description": "Node.js executable (18 or newer) used to run the renderer. Empty means the runtime bundled with VS Code." } } - } + }, + "languages": [ + { + "id": "json", + "extensions": [ + ".archify" + ] + } + ] }, "scripts": { "sync:archify": "node scripts/sync-archify.mjs", @@ -304,6 +316,7 @@ "@types/vscode": "~1.100.0", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.0.0", + "ajv": "^8.20.0", "esbuild": "^0.25.0", "jsonc-parser": "^3.3.1", "typescript": "^5.8.0" diff --git a/schemas/archify.schema.json b/schemas/archify.schema.json new file mode 100644 index 0000000..02a372d --- /dev/null +++ b/schemas/archify.schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Archify Diagram", + "description": "An Archify diagram source. diagram_type selects the schema that applies.", + "type": "object", + "required": [ + "diagram_type" + ], + "properties": { + "diagram_type": { + "enum": [ + "architecture", + "workflow", + "sequence", + "dataflow", + "lifecycle" + ], + "description": "Kind of diagram: architecture, workflow, sequence, dataflow or lifecycle." + } + }, + "allOf": [ + { + "if": { + "properties": { + "diagram_type": { + "const": "architecture" + } + }, + "required": [ + "diagram_type" + ] + }, + "then": { + "$ref": "architecture.schema.json" + } + }, + { + "if": { + "properties": { + "diagram_type": { + "const": "workflow" + } + }, + "required": [ + "diagram_type" + ] + }, + "then": { + "$ref": "workflow.schema.json" + } + }, + { + "if": { + "properties": { + "diagram_type": { + "const": "sequence" + } + }, + "required": [ + "diagram_type" + ] + }, + "then": { + "$ref": "sequence.schema.json" + } + }, + { + "if": { + "properties": { + "diagram_type": { + "const": "dataflow" + } + }, + "required": [ + "diagram_type" + ] + }, + "then": { + "$ref": "dataflow.schema.json" + } + }, + { + "if": { + "properties": { + "diagram_type": { + "const": "lifecycle" + } + }, + "required": [ + "diagram_type" + ] + }, + "then": { + "$ref": "lifecycle.schema.json" + } + } + ] +} diff --git a/scripts/sync-archify.mjs b/scripts/sync-archify.mjs index 024b24e..305ab73 100644 --- a/scripts/sync-archify.mjs +++ b/scripts/sync-archify.mjs @@ -60,6 +60,28 @@ for (const file of fs.readdirSync(path.join(skill, 'schemas'))) { fs.writeFileSync(path.join(schemaOut, file), `${JSON.stringify(schema, null, 2)}\n`); } +// *.archify files carry their type only in diagram_type, so they get one +// schema that dispatches to the per-type schema. +const TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']; +const dispatcher = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'Archify Diagram', + description: 'An Archify diagram source. diagram_type selects the schema that applies.', + type: 'object', + required: ['diagram_type'], + properties: { + diagram_type: { + enum: TYPES, + description: 'Kind of diagram: architecture, workflow, sequence, dataflow or lifecycle.', + }, + }, + allOf: TYPES.map((type) => ({ + if: { properties: { diagram_type: { const: type } }, required: ['diagram_type'] }, + then: { $ref: `${type}.schema.json` }, + })), +}; +fs.writeFileSync(path.join(schemaOut, 'archify.schema.json'), `${JSON.stringify(dispatcher, null, 2)}\n`); + let revision = 'unknown'; try { revision = execFileSync('git', ['-C', source, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); diff --git a/src/detect.ts b/src/detect.ts index 95ce4db..ca46195 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -13,6 +13,7 @@ export const EVIDENCE_COLLECTIONS: Record = { }; const FILENAME_RE = /\.(architecture|workflow|sequence|dataflow|lifecycle)\.json$/i; +const ARCHIFY_EXT_RE = /\.archify$/i; const MAX_SNIFF_BYTES = 8 * 1024 * 1024; export function isDiagramType(value: unknown): value is DiagramType { @@ -24,6 +25,22 @@ export function typeFromFilename(fileName: string): DiagramType | undefined { return match ? (match[1].toLowerCase() as DiagramType) : undefined; } +/** `name.archify`: an Archify diagram whose type comes from `diagram_type`. */ +export function isArchifyFile(fileName: string): boolean { + return ARCHIFY_EXT_RE.test(fileName); +} + +/** File names that are diagram sources regardless of their content. */ +export function isDiagramFileName(fileName: string): boolean { + return isArchifyFile(fileName) || FILENAME_RE.test(fileName); +} + +/** Base name without the diagram source extension (`web.archify` → `web`). */ +export function diagramBaseName(fileName: string): string { + const base = fileName.split(/[\\/]/).pop() ?? fileName; + return base.replace(ARCHIFY_EXT_RE, '').replace(/\.json$/i, ''); +} + export interface DiagramSource { type: DiagramType; diagram: Record; diff --git a/src/extension.ts b/src/extension.ts index 580e5f4..43fff4f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import * as vscode from 'vscode'; -import { isArchifyHtml, readDiagramSource } from './detect'; +import { diagramBaseName, isArchifyHtml, isDiagramFileName, readDiagramSource } from './detect'; import { DiagnosticsController } from './diagnostics'; import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer'; import { PreviewManager } from './preview'; @@ -35,7 +35,9 @@ export function activate(context: vscode.ExtensionContext): void { let contextTimer: NodeJS.Timeout | undefined; const updateContext = () => { const doc = vscode.window.activeTextEditor?.document; - const diagram = Boolean(doc && JSON_LANGUAGES.has(doc.languageId) && readDiagramSource(doc.getText(), doc.fileName)); + const diagram = Boolean( + doc && JSON_LANGUAGES.has(doc.languageId) && (isDiagramFileName(doc.fileName) || readDiagramSource(doc.getText(), doc.fileName)), + ); const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText())); void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram); void vscode.commands.executeCommand('setContext', 'archify.isArchifyHtml', html); @@ -96,12 +98,12 @@ export function activate(context: vscode.ExtensionContext): void { const text = await readText(uri); const source = readDiagramSource(text, uri.path); if (!source) { - vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} is not an Archify diagram source.`); + vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`); return; } const meta = (source.diagram.meta ?? {}) as { output?: unknown }; const authored = typeof meta.output === 'string' && !/[\\/]/.test(meta.output) ? meta.output : undefined; - const fileName = authored ?? `${path.basename(uri.path).replace(/\.json$/i, '')}.html`; + const fileName = authored ?? `${diagramBaseName(uri.path)}.html`; const defaultUri = uri.scheme === 'file' ? vscode.Uri.joinPath(uri, '..', fileName) : undefined; const target = await vscode.window.showSaveDialog({ defaultUri, @@ -148,7 +150,7 @@ export function activate(context: vscode.ExtensionContext): void { const text = await readText(uri); const source = readDiagramSource(text, uri.path); if (!source) { - vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} is not an Archify diagram source.`); + vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`); return; } const runtime = await runtimeFor(context, uri); @@ -162,7 +164,7 @@ export function activate(context: vscode.ExtensionContext): void { } const dir = path.join(context.globalStorageUri.fsPath, 'browser'); await fs.mkdir(dir, { recursive: true }); - const file = path.join(dir, `${path.basename(uri.path).replace(/\.json$/i, '')}.html`); + const file = path.join(dir, `${diagramBaseName(uri.path)}.html`); await fs.writeFile(file, result.html, 'utf8'); await vscode.env.openExternal(vscode.Uri.file(file)); }), diff --git a/src/preview.ts b/src/preview.ts index b4ed062..96bd634 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -92,7 +92,10 @@ class DiagramPreview implements vscode.Disposable { } const source = readDiagramSource(text, this.uri.path); if (!source) { - this.showMessage('Not an Archify diagram', 'The file has no "diagram_type" of architecture, workflow, sequence, dataflow or lifecycle.'); + this.showMessage( + 'Nothing to show yet', + 'Set "diagram_type" to architecture, workflow, sequence, dataflow or lifecycle, and make sure the file is valid JSON.', + ); return; } diff --git a/test/detect.test.ts b/test/detect.test.ts index 97f8127..e161053 100644 --- a/test/detect.test.ts +++ b/test/detect.test.ts @@ -47,3 +47,15 @@ test('json pointer ranges point at keys for containers and values for leaves', ( const missing = rangeForPointer(text, '/components/0/label'); assert.equal(text.slice(missing.start, missing.end), '{', 'falls back to the nearest existing ancestor'); }); + +test('.archify files are diagram sources typed by their content', async () => { + const { diagramBaseName, isArchifyFile, isDiagramFileName } = await import('../src/detect'); + assert.ok(isArchifyFile('/w/My Platform.archify')); + assert.ok(isDiagramFileName('/w/x.ARCHIFY')); + assert.ok(isDiagramFileName('/w/x.workflow.json')); + assert.ok(!isDiagramFileName('/w/archify.json')); + assert.equal(diagramBaseName('/w/web.archify'), 'web'); + assert.equal(diagramBaseName('/w/web.architecture.json'), 'web.architecture'); + assert.equal(readDiagramSource('{"diagram_type":"lifecycle"}', 'run.archify')?.type, 'lifecycle'); + assert.equal(readDiagramSource('{"title":"no type yet"}', 'run.archify'), undefined); +}); diff --git a/test/fixtures/cache-miss.archify b/test/fixtures/cache-miss.archify new file mode 100644 index 0000000..4ef2fab --- /dev/null +++ b/test/fixtures/cache-miss.archify @@ -0,0 +1,77 @@ +{ + "schema_version": 1, + "diagram_type": "sequence", + "meta": { + "title": "Cache Miss Request Sequence", + "output": "examples/sequence-cache-miss-request.html", + "viewBox": [1080, 580], + "column_fit": "spread", + "animation": "trace", + "quality_profile": "showcase" + }, + "participants": [ + { "id": "user", "type": "external", "label": "User", "sublabel": "browser session" }, + { "id": "web", "type": "frontend", "label": "Web App", "sublabel": "React UI" }, + { "id": "api", "type": "backend", "label": "API", "sublabel": "request handler" }, + { "id": "auth", "type": "security", "label": "Auth", "sublabel": "JWT verify" }, + { "id": "redis", "type": "database", "label": "Redis", "sublabel": "cache" }, + { "id": "db", "type": "database", "label": "Postgres", "sublabel": "source of truth" }, + { "id": "trace", "type": "messagebus", "label": "Trace", "sublabel": "async event" } + ], + "segments": [ + { "from": 150, "to": 250, "label": "Request" }, + { "from": 260, "to": 370, "label": "Fallback" }, + { "from": 380, "to": 480, "label": "Response + trace" } + ], + "messages": [ + { "id": "open-page", "from": "user", "to": "web", "y": 160, "label": "open page", "variant": "default" }, + { "id": "dashboard-request", "from": "web", "to": "api", "y": 185, "label": "GET /dashboard", "variant": "emphasis" }, + { "id": "verify-jwt", "from": "api", "to": "auth", "y": 210, "label": "verify JWT", "variant": "security" }, + { "id": "auth-claims", "from": "auth", "to": "api", "y": 238, "label": "claims ok", "variant": "return" }, + { "id": "cache-read", "from": "api", "to": "redis", "y": 270, "label": "read cache", "variant": "default" }, + { "id": "cache-miss", "from": "redis", "to": "api", "y": 298, "label": "miss", "variant": "return" }, + { "id": "profile-query", "from": "api", "to": "db", "y": 330, "label": "query profile + metrics", "variant": "emphasis" }, + { "id": "profile-rows", "from": "db", "to": "api", "y": 358, "label": "rows", "variant": "return" }, + { "id": "cache-write", "from": "api", "to": "redis", "y": 390, "label": "set cache", "variant": "dashed" }, + { "id": "trace-emit", "from": "api", "to": "trace", "y": 418, "label": "emit trace", "variant": "dashed" }, + { "id": "dashboard-response", "from": "api", "to": "web", "y": 443, "label": "200 JSON", "variant": "return" }, + { "id": "page-render", "from": "web", "to": "user", "y": 468, "label": "render", "variant": "return" } + ], + "activations": [ + { "participant": "web", "from": 180, "to": 474, "type": "frontend" }, + { "participant": "api", "from": 185, "to": 450, "type": "backend" }, + { "participant": "auth", "from": 205, "to": 244, "type": "security" }, + { "participant": "redis", "from": 265, "to": 304, "type": "database" }, + { "participant": "db", "from": 325, "to": 364, "type": "database" }, + { "participant": "trace", "from": 413, "to": 449, "type": "messagebus" } + ], + "cards": [ + { + "dot": "emerald", + "title": "Happy Path", + "items": [ + "The main request is Web App -> API -> data source -> response", + "Return messages are quieter than forward calls", + "Activation bars make ownership duration visible" + ] + }, + { + "dot": "rose", + "title": "Policy + Fallback", + "items": [ + "JWT verification is colored as a security interaction", + "Cache miss is visible without overpowering the main path", + "Database access only appears after cache fallback" + ] + }, + { + "dot": "orange", + "title": "Async Trace", + "items": [ + "Trace emission is dashed and secondary", + "It does not block the response path", + "The diagram separates user-facing latency from observability" + ] + } + ] +} diff --git a/test/fixtures/web-platform.archify b/test/fixtures/web-platform.archify new file mode 100644 index 0000000..bbcf9bd --- /dev/null +++ b/test/fixtures/web-platform.archify @@ -0,0 +1,51 @@ +{ + "schema_version": 1, + "diagram_type": "architecture", + "meta": { + "title": "Production Deployment Ownership", + "output": "examples/production-deployment.html", + "visual_preset": "blueprint", + "animation": "trace", + "quality_profile": "showcase", + "engineering_profile": "deployment-ownership" + }, + "components": [ + { "id": "clients", "type": "external", "label": "Customers", "sublabel": "web + mobile", "pos": [38, 300], "size": [122, 60] }, + { "id": "edge", "type": "cloud", "label": "Global Edge", "sublabel": "CDN + WAF", "pos": [230, 300], "size": [126, 60], "tag": "edge team" }, + { "id": "gateway", "type": "security", "label": "API Gateway", "sublabel": "public :443", "pos": [430, 300], "size": [128, 60], "tag": "platform" }, + { "id": "api_a", "type": "backend", "label": "API Pods / AZ-a", "sublabel": "private subnet", "pos": [630, 195], "size": [136, 62], "tag": "app team" }, + { "id": "api_b", "type": "backend", "label": "API Pods / AZ-b", "sublabel": "private subnet", "pos": [630, 405], "size": [136, 62], "tag": "app team" }, + { "id": "redis", "type": "database", "label": "Redis", "sublabel": "multi-AZ cache", "pos": [840, 195], "size": [126, 62], "tag": "platform" }, + { "id": "postgres", "type": "database", "label": "PostgreSQL", "sublabel": "primary / encrypted", "pos": [840, 405], "size": [126, 62], "tag": "data team" }, + { "id": "events", "type": "messagebus", "label": "Event Bus", "sublabel": "orders.v1", "pos": [1040, 300], "size": [126, 60], "tag": "platform" }, + { "id": "worker", "type": "backend", "label": "Workers", "sublabel": "private workload", "pos": [1190, 300], "size": [126, 60], "tag": "app team" }, + { "id": "replica", "type": "database", "label": "DR Replica", "sublabel": "eu-west-1", "pos": [1040, 578], "size": [126, 62], "tag": "data team" }, + { "id": "audit", "type": "cloud", "label": "Audit Archive", "sublabel": "immutable objects", "pos": [1190, 450], "size": [126, 62], "tag": "security" }, + { "id": "observability", "type": "external", "label": "Observability", "sublabel": "metrics + traces", "pos": [1190, 85], "size": [126, 62], "tag": "SRE" } + ], + "boundaries": [ + { "kind": "region", "label": "AWS us-east-1 / production", "wraps": ["edge", "gateway", "api_a", "api_b", "redis", "postgres", "events", "worker", "audit"], "pad": 20 }, + { "kind": "security-group", "label": "private application network", "wraps": ["api_a", "api_b", "redis", "postgres", "events", "worker"], "pad": 14 }, + { "kind": "region", "label": "AWS eu-west-1 / disaster recovery", "wraps": ["replica"] }, + { "kind": "security-group", "label": "DR private subnet", "wraps": ["replica"], "pad": 14 } + ], + "connections": [ + { "from": "clients", "to": "edge", "label": "HTTPS", "variant": "emphasis" }, + { "from": "edge", "to": "gateway", "label": "mTLS", "variant": "security" }, + { "from": "gateway", "to": "api_a", "label": "VPC route", "variant": "emphasis", "route": "orthogonal-h", "labelAt": [594, 275] }, + { "from": "gateway", "to": "api_b", "label": "VPC route", "variant": "emphasis", "route": "orthogonal-h", "labelAt": [594, 385] }, + { "from": "api_a", "to": "redis", "label": "cache", "route": "straight" }, + { "from": "api_b", "to": "postgres", "label": "SQL", "route": "straight" }, + { "from": "api_a", "to": "events", "label": "publish", "variant": "dashed", "fromSide": "top", "toSide": "top", "via": [[698, 170], [1103, 170]] }, + { "from": "api_b", "to": "events", "variant": "dashed", "fromSide": "top", "toSide": "bottom", "via": [[698, 380], [1103, 380]] }, + { "from": "events", "to": "worker", "variant": "emphasis" }, + { "from": "postgres", "to": "replica", "label": "cross-region WAL", "variant": "security", "route": "orthogonal-v", "labelAt": [1003, 529] }, + { "from": "worker", "to": "audit", "label": "evidence", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "labelDy": 58 }, + { "from": "worker", "to": "observability", "label": "OTLP", "variant": "dashed", "route": "orthogonal-v" } + ], + "cards": [ + { "dot": "cyan", "title": "Runtime Ownership", "items": ["Platform owns the edge, gateway, cache, and event bus", "Application teams own API pods and workers", "Data owns primary and disaster-recovery state"] }, + { "dot": "rose", "title": "Named Crossings", "items": ["Public HTTPS terminates at the managed edge", "mTLS crosses into the application network", "Cross-region WAL is explicit and encrypted"] }, + { "dot": "emerald", "title": "Operational Evidence", "items": ["Workers emit traces to SRE-owned observability", "Audit evidence lands in immutable storage", "Unknown placement should remain marked, never invented"] } + ] +} diff --git a/test/integration/suite.ts b/test/integration/suite.ts index 6dbec80..23c38ec 100644 --- a/test/integration/suite.ts +++ b/test/integration/suite.ts @@ -33,7 +33,7 @@ async function check(name: string, body: () => Promise): Promise { await body(); console.log(` ✔ ${name}`); } catch (error) { - console.log(` ✖ ${name}`); + console.log(` ✖ ${name}\n ${error instanceof Error ? error.stack ?? error.message : String(error)}`); throw error; } } @@ -49,10 +49,51 @@ export async function run(): Promise { await waitFor('activation', () => extension.isActive); }); - await check('opens a live preview beside the source', async () => { + await check('.archify files open as JSON with the preview available', async () => { + const doc = await vscode.workspace.openTextDocument(workspaceFile('web-platform.archify')); + assert.equal(doc.languageId, 'json'); + await vscode.window.showTextDocument(doc); await vscode.commands.executeCommand('archify.showPreviewToSide'); + const tabs = await waitFor('.archify preview tab', () => { + const found = webviewTabs('archify.preview').filter((tab) => /web-platform\.archify/.test(tab.label)); + return found.length > 0 && found; + }); + assert.equal(tabs.length, 1); + }); + + await check('.archify completion comes from the schema of its diagram_type', async () => { + const doc = await vscode.workspace.openTextDocument(workspaceFile('web-platform.archify')); + await vscode.window.showTextDocument(doc); + // Complete the value of the first component's "type". + const offset = doc.getText().indexOf('"type": "') + '"type": "'.length; + const position = doc.positionAt(offset); + let labels: string[] = []; + for (let attempt = 0; attempt < 50 && !labels.includes('backend'); attempt += 1) { + const list = await vscode.commands.executeCommand('vscode.executeCompletionItemProvider', doc.uri, position); + labels = list.items.map((item) => (typeof item.label === 'string' ? item.label : item.label.label).replace(/"/g, '')); + if (!labels.includes('backend')) await new Promise((resolve) => setTimeout(resolve, 300)); + } + assert.ok(labels.includes('backend') && labels.includes('database'), `offered: ${labels.join(', ')}`); + }); + + await check('.archify problems are reported against the file', async () => { + const good = await vscode.workspace.openTextDocument(workspaceFile('cache-miss.archify')); + const broken = good.getText().replace('"participants"', '"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('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'); + const found = webviewTabs('archify.preview').filter((tab) => /production-deployment/.test(tab.label)); return found.length > 0 && found; }); assert.equal(tabs.length, 1); @@ -107,7 +148,7 @@ export async function run(): Promise { 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('second preview tab', () => webviewTabs('archify.preview').length >= 2); + 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'); diff --git a/test/schema.test.ts b/test/schema.test.ts new file mode 100644 index 0000000..c2a8e50 --- /dev/null +++ b/test/schema.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import Ajv2020 from 'ajv/dist/2020'; + +const root = path.resolve(__dirname, '..', '..'); +const schemas = path.join(root, 'schemas'); +const fixtures = path.join(root, 'test', 'fixtures'); + +function validator() { + const ajv = new Ajv2020({ strict: false, allErrors: true }); + // Register every schema under its file name, exactly how the relative + // $refs in the bundled copies resolve inside VS Code. + for (const file of fs.readdirSync(schemas)) { + ajv.addSchema(JSON.parse(fs.readFileSync(path.join(schemas, file), 'utf8')), file); + } + return ajv.getSchema('archify.schema.json')!; +} + +test('bundled schemas carry no absolute $id', () => { + for (const file of fs.readdirSync(schemas)) { + assert.equal(JSON.parse(fs.readFileSync(path.join(schemas, file), 'utf8')).$id, undefined, file); + } +}); + +test('the .archify schema accepts every fixture', () => { + const validate = validator(); + for (const file of fs.readdirSync(fixtures)) { + const ok = validate(JSON.parse(fs.readFileSync(path.join(fixtures, file), 'utf8'))); + assert.ok(ok, `${file}: ${JSON.stringify(validate.errors?.slice(0, 3))}`); + } +}); + +test('the .archify schema applies the schema of the declared type', () => { + const validate = validator(); + const sequence = JSON.parse(fs.readFileSync(path.join(fixtures, 'cache-miss.archify'), 'utf8')); + // A sequence diagram is not a valid architecture diagram. + assert.equal(validate({ ...sequence, diagram_type: 'architecture' }), false); + assert.equal(validate({ ...sequence, diagram_type: 'poster' }), false); + const { diagram_type: _omitted, ...untyped } = sequence; + assert.equal(validate(untyped), false); +});