import { afterEach, describe, expect, it, vi } from "vitest"; import type { VirtualDocument } from "@/platform/ipc"; const JDT = "jdt://contents/lib-1.0.jar/org.example/Lib.class?=demo/%5C/m2%5C/lib-1.0.jar%3Corg.example%28Lib.class"; const TEXT = "// Source code is unavailable, and was generated by the Fernflower decompiler.\npackage org.example;\n\npublic class Lib {\n void run() {}\n}\n"; vi.mock("@/platform/virtualDocs", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, virtualDocument: vi.fn(async (uri: string): Promise => { if (uri !== JDT && uri !== "file:///p/target/classes/Lib.class") throw new Error(`no contents for ${uri}`); return { uri, text: TEXT, languageId: "java", title: "Lib.class", description: "/m2/lib-1.0.jar", decompiled: true, }; }), }; }); const { EditorController, isTextInput, useTabs, virtualInput } = await import("./controller"); const { createMemoryBackend } = await import("./backend"); const { initialTabsState } = await import("./model/tabs"); const { serializeSession } = await import("./model/session"); const { useDocMeta } = await import("./model/document"); const { registerMenuGroup } = await import("@/workbench/contributions"); const { registerActions } = await import("@/platform/actions"); const { commands } = await import("@/platform/ipc"); const { resolveMenuItems } = await import("@/ui"); let ctrl: InstanceType | null = null; afterEach(() => { ctrl?.stop(); ctrl = null; useTabs.setState(initialTabsState(), true); }); const activeTab = () => { const s = useTabs.getState(); const pane = s.panes[s.activePane]!; return pane.tabs.find((t) => t.id === pane.active)!; }; describe("virtual editor input", () => { it("describes a library class as a text input keyed by its URI", () => { const input = virtualInput({ uri: JDT, title: "Lib.class", description: "/m2/lib-1.0.jar", decompiled: true }); expect(input).toMatchObject({ kind: "virtual", key: `virtual:${JDT}`, path: JDT }); expect(input.data).toEqual({ title: "Lib.class", description: "/m2/lib-1.0.jar", decompiled: true }); expect(isTextInput(input)).toBe(true); expect(isTextInput({ kind: "diff", key: "d" })).toBe(false); }); it.each([ ["/m2/lib.jar!/org/example/Lib.class", JDT, "virtual"], ["/p/target/classes/Lib.class", "/p/Lib.java", "file"], ["/p/build/classes/Lib.class", "file:///p/target/classes/Lib.class", "virtual"], ])("routes %s before choosing a binary viewer", async (path, target, kind) => { const resolve = vi.spyOn(commands, "lspResolveClassFile").mockResolvedValue({ status: "ok", data: target }); ctrl = new EditorController(createMemoryBackend({ [target]: TEXT })); ctrl.start(); try { await ctrl.openFile(path!, { focus: false }); expect(resolve).toHaveBeenCalledWith(path); expect(activeTab().input).toMatchObject({ path: target, kind }); expect(ctrl.slotFor(activeTab().id)!.model.ctx().readOnly).toBe(kind === "virtual"); } finally { resolve.mockRestore(); } }); it("opens a URI read-only at the target and keeps it out of the saved session", async () => { ctrl = new EditorController(createMemoryBackend({ "/p/App.java": "class App {}\n", [JDT]: TEXT })); ctrl.start(); await ctrl.openFile("/p/App.java", { focus: false }); await ctrl.openFile(JDT, { selection: { line: 3, col: 13, endLine: 3, endCol: 16 }, focus: false }); const tab = activeTab(); expect(tab.input.kind).toBe("virtual"); expect(tab.input.key).toBe(`virtual:${JDT}`); const slot = ctrl.slotFor(tab.id)!; expect(slot.model.virtual).toBe(true); expect(slot.model.ctx()).toMatchObject({ path: JDT, language: "java", readOnly: true, virtual: true }); expect(useDocMeta.getState()[JDT]).toMatchObject({ readOnly: true, banner: null }); const sel = slot.currentState().selection.main; expect(slot.currentState().sliceDoc(sel.from, sel.to)).toBe("Lib"); // The same URI reuses its tab. await ctrl.openFile(JDT, { focus: false }); expect(Object.values(useTabs.getState().panes).flatMap((p) => p.tabs)).toHaveLength(2); // Navigation history goes back to the file and forward into the library class. await ctrl.back(); expect(activeTab().input.path).toBe("/p/App.java"); await ctrl.forward(); expect(activeTab().input.path).toBe(JDT); const session = serializeSession(useTabs.getState(), {}); expect(JSON.stringify(session)).not.toContain("jdt:"); expect( ctrl .tabMenu(useTabs.getState().activePane, activeTab()) .some((i) => "label" in i && i.label === "Reveal in Project"), ).toBe(false); // Pathless tabs still get contributed groups, with the tab's input in the context. const disposeAction = registerActions([{ id: "test.tab", title: "Test Item", run: () => undefined }]); const disposeGroup = registerMenuGroup({ id: "test.tab", menus: ["editorTab"], order: 50, items: (_menu, { paths, input }) => paths.length || input?.kind !== "virtual" ? [] : [{ kind: "action", action: "test.tab", label: "Test Item", arg: input.key }], }); try { const items = resolveMenuItems(ctrl.tabMenu(useTabs.getState().activePane, activeTab())); expect(items.some((i) => "label" in i && i.label === "Test Item")).toBe(true); } finally { disposeGroup(); disposeAction(); } }); it("does not open a tab when the server has no contents", async () => { ctrl = new EditorController(createMemoryBackend()); ctrl.start(); await ctrl.openFile("jdt://contents/missing.jar/p/X.class", { focus: false }); expect(Object.values(useTabs.getState().panes).flatMap((p) => p.tabs)).toHaveLength(0); }); });