diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 8eb321c..d9dd174 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -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", { @@ -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). diff --git a/packages/vscode/test/extension.test.ts b/packages/vscode/test/extension.test.ts index baa594a..2ac2447 100644 --- a/packages/vscode/test/extension.test.ts +++ b/packages/vscode/test/extension.test.ts @@ -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. @@ -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(); @@ -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(); @@ -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(); @@ -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?.(); @@ -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?.(); @@ -113,7 +113,7 @@ 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(); }); @@ -121,7 +121,7 @@ describe("[VSCODE-EXT] activate", () => { 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?.(); @@ -142,14 +142,14 @@ 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); }); @@ -157,7 +157,7 @@ describe("[VSCODE-EXT] activate", () => { 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); }); @@ -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); }); @@ -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")); @@ -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); @@ -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"); @@ -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; @@ -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) }; @@ -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"); @@ -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"); @@ -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(); @@ -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 }; @@ -367,6 +367,16 @@ 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: "" }), + })); const { extendMarkdownIt } = await import("../src/extension.js"); const md = { renderer: { rules: {} as Record } }; extendMarkdownIt(md as never); @@ -374,6 +384,33 @@ describe("[VSCODE-EXT] activate", () => { // 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: '' }), + })); + + 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(''); + expect(html).not.toContain("typediagram-pending"); + + vi.doUnmock("typediagram-core"); }); it("[VSCODE-MD-EXTEND-LOG] extendMarkdownIt writes lifecycle logs to the Output Channel", async () => { @@ -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) @@ -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(); @@ -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", @@ -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()); @@ -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()); @@ -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(); @@ -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(); @@ -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); @@ -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?.();