Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion packages/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ interface ExtensionPackageJson {
version: string;
}

export const activate = (context: vscode.ExtensionContext) => {
export const activate = async (context: vscode.ExtensionContext) => {
const log = initLogger(context).child({ scope: "activate" });
const pkg = context.extension.packageJSON as ExtensionPackageJson;
log.info("extension activating", {
Expand Down Expand Up @@ -173,6 +173,18 @@ export const activate = (context: vscode.ExtensionContext) => {

context.subscriptions.push(cmd, openAsDiagram, exportPdfCmd, onOpen, onActive, onVisible, onChange, onClose);

if (!isSyncRenderReady()) {
const startedAt = Date.now();
try {
await warmupSyncRender();
log.info("warmup complete during activate", {
elapsedMs: Date.now() - startedAt,
});
} catch (err: unknown) {
log.error("warmup failed during activate", { err: String(err) });
}
}

// [VSCODE-MD-EXTEND-RETURN] VS Code reads extendMarkdownIt off THIS return value —
// NOT off the top-level module exports. This is the critical wiring per the official
// Markdown Preview Mermaid Support extension (bierner.markdown-mermaid).
Expand Down
103 changes: 70 additions & 33 deletions packages/vscode/test/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe("[VSCODE-EXT] activate", () => {
it("registers typediagram.preview command on activate", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
expect(mock.commands.registerCommand).toHaveBeenCalledWith("typediagram.preview", expect.any(Function));
expect(mock.commands.registerCommand).toHaveBeenCalledWith("typediagram.openAsDiagram", expect.any(Function));
// 7 original disposables + 1 Output Channel added by initLogger.
Expand All @@ -59,7 +59,7 @@ describe("[VSCODE-EXT] activate", () => {
it("preview command does nothing without active typediagram editor", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
mock.window.activeTextEditor = undefined;
mock.commands._handler?.();
expect(mock.window.createWebviewPanel).not.toHaveBeenCalled();
Expand All @@ -68,7 +68,7 @@ describe("[VSCODE-EXT] activate", () => {
it("preview command ignores non-typediagram files", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
mock.window.activeTextEditor = { document: makeDoc("x", "plaintext") };
mock.commands._handler?.();
expect(mock.window.createWebviewPanel).not.toHaveBeenCalled();
Expand All @@ -77,7 +77,7 @@ describe("[VSCODE-EXT] activate", () => {
it("preview command opens webview panel for .td file", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
mock.window.activeTextEditor = { document: makeDoc("type Foo { x: Int }") };
mock.commands._handler?.();
expect(mock.window.createWebviewPanel).toHaveBeenCalled();
Expand All @@ -88,7 +88,7 @@ describe("[VSCODE-EXT] activate", () => {
it("reveals existing panel instead of creating duplicate", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
mock.window.activeTextEditor = { document: makeDoc("type A { x: Int }") };
mock.commands._handler?.();
mock.commands._handler?.();
Expand All @@ -99,7 +99,7 @@ describe("[VSCODE-EXT] activate", () => {
it("forwards document changes to webview via postMessage", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const doc = makeDoc("type X { a: Int }");
mock.window.activeTextEditor = { document: doc };
mock.commands._handler?.();
Expand All @@ -113,15 +113,15 @@ describe("[VSCODE-EXT] activate", () => {
it("ignores changes to non-typediagram documents", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
mock.workspace._changeCb?.({ document: makeDoc("x", "json") });
expect(mock.mockPanel.webview.postMessage).not.toHaveBeenCalled();
});

it("cleans up panel reference on document close", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const doc = makeDoc("type Y { b: String }");
mock.window.activeTextEditor = { document: doc };
mock.commands._handler?.();
Expand All @@ -142,22 +142,22 @@ describe("[VSCODE-EXT] activate", () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
mock.workspace.textDocuments = [makeDoc("type A { x: Int }")];
activate(ctx as never);
await activate(ctx as never);
expect(mock.window.createWebviewPanel).toHaveBeenCalledTimes(1);
});

it("auto-opens preview when a .td document is opened later", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
mock.workspace._openCb?.(makeDoc("type B { y: Int }"));
expect(mock.window.createWebviewPanel).toHaveBeenCalledTimes(1);
});

it("auto-opens preview when active editor switches to a .td document", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
mock.window._activeEditorCb?.({ document: makeDoc("type C { z: Int }") });
expect(mock.window.createWebviewPanel).toHaveBeenCalledTimes(1);
});
Expand All @@ -166,14 +166,14 @@ describe("[VSCODE-EXT] activate", () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
mock.window.visibleTextEditors = [{ document: makeDoc("type V { v: Int }") }];
activate(ctx as never);
await activate(ctx as never);
expect(mock.window.createWebviewPanel).toHaveBeenCalledTimes(1);
});

it("auto-opens preview for .td doc that becomes visible later", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
mock.window._visibleEditorsCb?.([{ document: makeDoc("type W { w: Int }") }]);
expect(mock.window.createWebviewPanel).toHaveBeenCalledTimes(1);
});
Expand All @@ -182,14 +182,14 @@ describe("[VSCODE-EXT] activate", () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
mock.window.activeTextEditor = { document: makeDoc("type Act { a: Int }") };
activate(ctx as never);
await activate(ctx as never);
expect(mock.window.createWebviewPanel).toHaveBeenCalledTimes(1);
});

it("auto-open handles undefined editor and non-file schemes and non-td langs", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
mock.window._activeEditorCb?.(undefined);
mock.workspace._openCb?.(makeDoc("x", "plaintext"));
mock.workspace._openCb?.(makeDoc("x", "typediagram", "untitled"));
Expand All @@ -203,14 +203,14 @@ describe("[VSCODE-EXT] activate", () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
mock.workspace.textDocuments = [makeDoc("type D { a: Int }")];
activate(ctx as never);
await activate(ctx as never);
expect(mock.window.createWebviewPanel).not.toHaveBeenCalled();
});

it("auto-open fires only once per document", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const doc = makeDoc("type E { b: Int }");
mock.workspace._openCb?.(doc);
mock.workspace._openCb?.(doc);
Expand All @@ -221,7 +221,7 @@ describe("[VSCODE-EXT] activate", () => {
it("[VSCODE-OPEN-AS-DIAGRAM] openAsDiagram opens preview for explorer URI", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const doc = makeDoc("type Diag { d: Int }");
mock.workspace._openTextDocResult = doc;
const handler = mock.commands._handlers.get("typediagram.openAsDiagram");
Expand All @@ -234,7 +234,7 @@ describe("[VSCODE-EXT] activate", () => {
it("[VSCODE-OPEN-AS-DIAGRAM] openAsDiagram falls back to active editor URI", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const doc = makeDoc("type Fall { f: Int }");
mock.window.activeTextEditor = { document: doc };
mock.workspace._openTextDocResult = doc;
Expand All @@ -246,7 +246,7 @@ describe("[VSCODE-EXT] activate", () => {
it("[VSCODE-OPEN-AS-DIAGRAM] openAsDiagram closes existing source tabs for the same file", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const doc = makeDoc("type Close { c: Int }");
mock.workspace._openTextDocResult = doc;
const sourceTab = { input: new mock.TabInputText(doc.uri) };
Expand All @@ -261,7 +261,7 @@ describe("[VSCODE-EXT] activate", () => {
it("[VSCODE-OPEN-AS-DIAGRAM] openAsDiagram suppresses auto-open for marked diagram-only URIs", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const doc = makeDoc("type DO { d: Int }");
mock.workspace._openTextDocResult = doc;
const handler = mock.commands._handlers.get("typediagram.openAsDiagram");
Expand All @@ -275,7 +275,7 @@ describe("[VSCODE-EXT] activate", () => {
it("[VSCODE-OPEN-AS-DIAGRAM] closing the doc clears diagram-only marking", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const doc = makeDoc("type Cl { c: Int }");
mock.workspace._openTextDocResult = doc;
const handler = mock.commands._handlers.get("typediagram.openAsDiagram");
Expand All @@ -290,7 +290,7 @@ describe("[VSCODE-EXT] activate", () => {
it("[VSCODE-OPEN-AS-DIAGRAM] openAsDiagram does nothing without URI or active editor", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const handler = mock.commands._handlers.get("typediagram.openAsDiagram");
await handler?.(undefined);
expect(mock.workspace.openTextDocument).not.toHaveBeenCalled();
Expand All @@ -305,7 +305,7 @@ describe("[VSCODE-EXT] activate", () => {
const runTapScenario = async (taps: number) => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);

const doc = makeDoc("type Sample { s: Int }");
const editor = { document: doc };
Expand Down Expand Up @@ -367,13 +367,50 @@ describe("[VSCODE-EXT] activate", () => {
});

it("[VSCODE-MD-EXTEND] extendMarkdownIt schedules a preview refresh after warmup", async () => {
vi.resetModules();
let ready = false;
vi.doMock("typediagram-core", () => ({
warmupSyncRender: async () => {
await Promise.resolve();
ready = true;
},
isSyncRenderReady: () => ready,
renderToStringSync: () => ({ ok: true, value: "<svg></svg>" }),
}));
const { extendMarkdownIt } = await import("../src/extension.js");
const md = { renderer: { rules: {} as Record<string, unknown> } };
extendMarkdownIt(md as never);
// Wait for the warmup microtask chain. Warmup calls into elk which takes ~30-200ms.
// We give it a reasonable window.
await new Promise((r) => setTimeout(r, 400));
expect(mock.commands.executeCommand).toHaveBeenCalledWith("markdown.preview.refresh");
vi.doUnmock("typediagram-core");
});

it("[VSCODE-MD-COLD-FIRST-RENDER] waits for warmup before exposing markdown preview so the first render is SVG", async () => {
vi.resetModules();
let ready = false;
vi.doMock("typediagram-core", () => ({
warmupSyncRender: async () => {
await Promise.resolve();
ready = true;
},
isSyncRenderReady: () => ready,
renderToStringSync: () => ({ ok: true, value: '<svg class="typediagram-test"></svg>' }),
}));

const MarkdownIt = (await import("markdown-it")).default;
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
const api = await activate(ctx as never);
const md = new MarkdownIt();
api.extendMarkdownIt(md as never);

const html = md.render("```typeDiagram\ntype X { a: Int }\n```");
expect(html).toContain('<svg class="typediagram-test"></svg>');
expect(html).not.toContain("typediagram-pending");

vi.doUnmock("typediagram-core");
});

it("[VSCODE-MD-EXTEND-LOG] extendMarkdownIt writes lifecycle logs to the Output Channel", async () => {
Expand Down Expand Up @@ -439,7 +476,7 @@ describe("[VSCODE-EXT] activate", () => {
it("[VSCODE-MD-EXTEND-RETURN] activate returns an object containing extendMarkdownIt (the canonical Mermaid pattern)", async () => {
const { activate, extendMarkdownIt } = await import("../src/extension.js");
const ctx = makeContext();
const api = activate(ctx as never);
const api = await activate(ctx as never);
expect(api).toBeDefined();
expect(api).toHaveProperty("extendMarkdownIt");
// Must be the SAME function reference as the named export (double-wired intentionally)
Expand All @@ -454,7 +491,7 @@ describe("[VSCODE-EXT] activate", () => {
it("[PDF-COMMAND] exportMarkdownPdf command handler reads, exports, writes next to source", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const exportHandler = mock.commands._handlers.get("typediagram.exportMarkdownPdf");
expect(exportHandler).toBeDefined();

Expand Down Expand Up @@ -485,7 +522,7 @@ describe("[VSCODE-EXT] activate", () => {
it("[PDF-COMMAND] exportMarkdownPdf falls back to active editor URI when none passed", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const exportHandler = mock.commands._handlers.get("typediagram.exportMarkdownPdf");
const activeUri = {
path: "/tmp/active.md",
Expand All @@ -507,7 +544,7 @@ describe("[VSCODE-EXT] activate", () => {
it("[PDF-COMMAND] Open PDF action wires openExternal on the saved URI", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const exportHandler = mock.commands._handlers.get("typediagram.exportMarkdownPdf");
mock.workspace.fs.readFile = vi.fn(() => Promise.resolve(new TextEncoder().encode("# hi")));
mock.workspace.fs.writeFile = vi.fn(() => Promise.resolve());
Expand All @@ -531,7 +568,7 @@ describe("[VSCODE-EXT] activate", () => {
it("[PDF-COMMAND] Reveal action wires revealFileInOS via executeCommand", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const exportHandler = mock.commands._handlers.get("typediagram.exportMarkdownPdf");
mock.workspace.fs.readFile = vi.fn(() => Promise.resolve(new TextEncoder().encode("# hi")));
mock.workspace.fs.writeFile = vi.fn(() => Promise.resolve());
Expand All @@ -556,7 +593,7 @@ describe("[VSCODE-EXT] activate", () => {
it("[PDF-COMMAND] exportMarkdownPdf surfaces showErrorMessage on readFile failure", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const exportHandler = mock.commands._handlers.get("typediagram.exportMarkdownPdf");
mock.workspace.fs.readFile = vi.fn(() => Promise.reject(new Error("ENOENT fake")));
mock.workspace.fs.writeFile = vi.fn();
Expand All @@ -580,7 +617,7 @@ describe("[VSCODE-EXT] activate", () => {
it("[PDF-COMMAND] exportMarkdownPdf no-ops without URI or active editor", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const exportHandler = mock.commands._handlers.get("typediagram.exportMarkdownPdf");
mock.window.activeTextEditor = undefined;
mock.workspace.fs.readFile = vi.fn();
Expand All @@ -594,7 +631,7 @@ describe("[VSCODE-EXT] activate", () => {
mock.mockOutputChannel.appendLine.mockClear();
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const lines = mock.mockOutputChannel.appendLine.mock.calls.map((c) => c[0] as string);
expect(lines.some((l) => l.includes("extension activating"))).toBe(true);
expect(lines.some((l) => l.includes('"version":"0.3.0-test"'))).toBe(true);
Expand All @@ -604,7 +641,7 @@ describe("[VSCODE-EXT] activate", () => {
it("cleans up panel reference when webview is disposed", async () => {
const { activate } = await import("../src/extension.js");
const ctx = makeContext();
activate(ctx as never);
await activate(ctx as never);
const doc = makeDoc("type Z { c: Bool }");
mock.window.activeTextEditor = { document: doc };
mock.commands._handler?.();
Expand Down
Loading