import { beforeEach, describe, expect, it, vi } from "vitest"; import becca from "@triliumnext/server/src/becca/becca"; import BBranch from "@triliumnext/server/src/becca/entities/bbranch"; import { buildNote } from "@triliumnext/server/src/test/becca_easy_mocking"; import { parseScriptMeta, transpile, deployScript, codeNoteId, renderNoteId, SCRIPTS_NOTE_ID, type NoteService, } from "./deploy"; // ── Mocks — only SQL and entity_changes, becca is real ─────────────────────── vi.mock("@triliumnext/server/src/services/sql", () => ({ default: { transactional: (cb: Function) => cb(), upsert: () => {}, execute: () => {}, replace: () => {}, getMap: () => {}, getValue: () => null, }, })); vi.mock("@triliumnext/server/src/services/sql_init", () => ({ dbReady: () => {}, })); vi.mock("@triliumnext/server/src/services/entity_changes", () => ({ default: { putEntityChange: () => {} }, putEntityChange: () => {}, })); // ── parseScriptMeta ────────────────────────────────────────────────────────── describe("parseScriptMeta", () => { it("parses a valid @trilium-script block", () => { const source = `/** * @trilium-script * * id: my-script * type: render * title: My Script */ export default function() {}`; const meta = parseScriptMeta(source, "my-script.jsx"); expect(meta).toEqual({ id: "my-script", type: "render", title: "My Script", }); }); it("returns null when @trilium-script marker is missing", () => { const source = `/** Just a normal comment */\nexport default function() {}`; expect(parseScriptMeta(source, "test.jsx")).toBeNull(); }); it("returns null and logs error when id is missing", () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); const source = `/** * @trilium-script * * type: render * title: My Script */`; expect(parseScriptMeta(source, "test.jsx")).toBeNull(); expect(spy).toHaveBeenCalledWith(expect.stringContaining("missing required fields: id")); spy.mockRestore(); }); it("returns null when type is missing", () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); const source = `/** * @trilium-script * * id: test * title: Test */`; expect(parseScriptMeta(source, "test.jsx")).toBeNull(); expect(spy).toHaveBeenCalledWith(expect.stringContaining("missing required fields: type")); spy.mockRestore(); }); it("returns null when title is missing", () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); const source = `/** * @trilium-script * * id: test * type: code */`; expect(parseScriptMeta(source, "test.jsx")).toBeNull(); expect(spy).toHaveBeenCalledWith(expect.stringContaining("missing required fields: title")); spy.mockRestore(); }); it("preserves extra metadata fields", () => { const source = `/** * @trilium-script * * id: test * type: render * title: Test * icon: bx-rocket */`; const meta = parseScriptMeta(source, "test.jsx"); expect(meta).toMatchObject({ icon: "bx-rocket" }); }); }); // ── transpile ──────────────────────────────────────────────────────────────── describe("transpile", () => { it("passes .jsx files through unchanged", () => { const source = `const x: number = 1; return
{x}
;`; expect(transpile(source, "test.jsx")).toBe(source); }); it("strips type annotations from .tsx but preserves JSX", () => { const source = `const x: number = 1;\nconst el =
{x}
;`; const result = transpile(source, "test.tsx"); expect(result).not.toContain(": number"); expect(result).toContain("
"); }); it("strips type annotations from .ts", () => { const source = `const x: number = 1;`; const result = transpile(source, "test.ts"); expect(result).not.toContain(": number"); expect(result).toContain("const x = 1"); }); it("passes .js files through unchanged", () => { const source = `const x = 1;`; expect(transpile(source, "test.js")).toBe(source); }); }); // ── deployScript ───────────────────────────────────────────────────────────── describe("deployScript", () => { /** * A NoteService backed by buildNote — each createNewNote call * populates the real becca cache via becca_easy_mocking. */ const notesService: NoteService = { createNewNote(params) { const note = buildNote({ id: params.noteId, title: params.title, type: params.type as Parameters[0]["type"], mime: params.mime ?? "text/html", content: params.content, }); // buildNote doesn't create a branch to the parent, so add one. new BBranch({ branchId: `${params.parentNoteId}_${params.noteId}`, noteId: params.noteId, parentNoteId: params.parentNoteId, }); return { note: { noteId: note.noteId } }; }, }; beforeEach(() => { becca.reset(); // Root + Scripts folder, using buildNote for the tree structure. buildNote({ id: "root", title: "root", children: [ { id: SCRIPTS_NOTE_ID, title: "Scripts", type: "doc" }, ], }); }); describe("render note with JSX", () => { const meta = { id: "xopp-importer", type: "render", title: "XOPP Importer" }; const jsxContent = `import { h } from "trilium:preact";\nexport default function() { return
Hello
; }`; const mime = "text/jsx"; it("creates a render note as parent with code note child linked via ~renderNote", () => { deployScript(meta, jsxContent, mime, becca, notesService); const renderId = renderNoteId(meta.id); const codeId = codeNoteId(meta.id); const renderNote = becca.notes[renderId]; const codeNote = becca.notes[codeId]; // Render note exists with correct type/title expect(renderNote).toBeDefined(); expect(renderNote.type).toBe("render"); expect(renderNote.title).toBe("XOPP Importer"); // Code note exists with correct type/title expect(codeNote).toBeDefined(); expect(codeNote.type).toBe("code"); expect(codeNote.title).toBe("XOPP Importer"); // Code note is a child of the render note expect(codeNote.getParentBranches().some((b) => b.parentNoteId === renderId)).toBe(true); // Render note is under scripts folder expect(renderNote.getParentBranches().some((b) => b.parentNoteId === SCRIPTS_NOTE_ID)).toBe(true); // ~renderNote relation points to the code note const relation = renderNote.getRelations().find((a) => a.name === "renderNote"); expect(relation).toBeDefined(); expect(relation!.value).toBe(codeId); }); it("returns a 'created' result with both note IDs", () => { const result = deployScript(meta, jsxContent, mime, becca, notesService); expect(result).toEqual({ action: "created", codeNoteId: codeNoteId(meta.id), renderNoteId: renderNoteId(meta.id), title: "XOPP Importer", type: "render", }); }); }); describe("render note with TSX", () => { const meta = { id: "tsx-widget", type: "render", title: "TSX Widget" }; const tsxSource = `const x: number = 1;\nexport default function() { return
{x}
; }`; const transpiledContent = transpile(tsxSource, "tsx-widget.tsx"); const mime = "text/jsx"; it("creates the same render+code structure as JSX, with types stripped but JSX preserved", () => { // Content was correctly transpiled expect(transpiledContent).not.toContain(": number"); expect(transpiledContent).toContain("
"); deployScript(meta, transpiledContent, mime, becca, notesService); const renderId = renderNoteId(meta.id); const codeId = codeNoteId(meta.id); expect(becca.notes[renderId]).toBeDefined(); expect(becca.notes[renderId].type).toBe("render"); expect(becca.notes[codeId]).toBeDefined(); expect(becca.notes[codeId].type).toBe("code"); }); }); describe("updating an existing script", () => { const meta = { id: "existing-script", type: "render", title: "Original" }; const mime = "text/jsx"; it("updates content and title of existing code note", () => { deployScript(meta, "old content", mime, becca, notesService); const codeId = codeNoteId(meta.id); const note = becca.notes[codeId]; const saveSpy = vi.fn(); const setContentSpy = vi.fn(); note.save = saveSpy; note.setContent = setContentSpy; const result = deployScript({ ...meta, title: "New Title" }, "new content", mime, becca, notesService); expect(result.action).toBe("updated"); expect(note.title).toBe("New Title"); expect(saveSpy).toHaveBeenCalled(); expect(setContentSpy).toHaveBeenCalledWith("new content"); }); }); describe("plain code note (non-render)", () => { const meta = { id: "backend-helper", type: "code", title: "Backend Helper" }; const content = "module.exports = {};"; const mime = "application/javascript;env=backend"; it("creates a single code note under scripts folder, no render note", () => { deployScript(meta, content, mime, becca, notesService); const codeId = codeNoteId(meta.id); const codeNote = becca.notes[codeId]; expect(codeNote).toBeDefined(); expect(codeNote.type).toBe("code"); expect(codeNote.getParentBranches().some((b) => b.parentNoteId === SCRIPTS_NOTE_ID)).toBe(true); // No render note was created expect(becca.notes[renderNoteId(meta.id)]).toBeUndefined(); }); it("returns a 'created' result without renderNoteId", () => { const result = deployScript(meta, content, mime, becca, notesService); expect(result).toEqual({ action: "created", codeNoteId: codeNoteId(meta.id), title: "Backend Helper", type: "code", }); }); }); describe("backend script", () => { const meta = { id: "hello-world", type: "backend", run: "backendStartup", title: "Hello World" }; const content = `api.log("Hello from script-deployer!");`; const mime = "application/javascript;env=backend"; it("creates a code note with #run label", () => { deployScript(meta, content, mime, becca, notesService); const codeId = codeNoteId(meta.id); const codeNote = becca.notes[codeId]; expect(codeNote).toBeDefined(); expect(codeNote.type).toBe("code"); const runLabel = codeNote.getLabels().find((a) => a.name === "run"); expect(runLabel).toBeDefined(); expect(runLabel!.value).toBe("backendStartup"); }); it("places the code note under scripts folder, no render note", () => { deployScript(meta, content, mime, becca, notesService); const codeId = codeNoteId(meta.id); const codeNote = becca.notes[codeId]; expect(codeNote.getParentBranches().some((b) => b.parentNoteId === SCRIPTS_NOTE_ID)).toBe(true); expect(becca.notes[renderNoteId(meta.id)]).toBeUndefined(); }); it("does not set #run when run field is absent", () => { const metaNoRun = { id: "no-run", type: "backend", title: "No Run" }; deployScript(metaNoRun, content, mime, becca, notesService); const codeNote = becca.notes[codeNoteId(metaNoRun.id)]; expect(codeNote.getLabels().find((a) => a.name === "run")).toBeUndefined(); }); }); describe("execute labels", () => { const content = `api.log("test");`; const mime = "application/javascript;env=backend"; it("sets executeButton, executeDescription, and executeTitle on creation", () => { const meta = { id: "exec-test", type: "backend", title: "Exec Test", executeButton: "true", executeDescription: "Does something useful", executeTitle: "Run It", }; deployScript(meta, content, mime, becca, notesService); const codeNote = becca.notes[codeNoteId(meta.id)]; const labels = codeNote.getLabels(); expect(labels.find((a) => a.name === "executeButton")?.value).toBe("true"); expect(labels.find((a) => a.name === "executeDescription")?.value).toBe("Does something useful"); expect(labels.find((a) => a.name === "executeTitle")?.value).toBe("Run It"); }); it("sets execute labels on update of an existing note", () => { const meta = { id: "exec-update", type: "backend", title: "Before" }; deployScript(meta, content, mime, becca, notesService); const updated = { ...meta, title: "After", executeButton: "true", executeDescription: "Now executable" }; const result = deployScript(updated, content, mime, becca, notesService); expect(result.action).toBe("updated"); const labels = becca.notes[codeNoteId(meta.id)].getLabels(); expect(labels.find((a) => a.name === "executeButton")?.value).toBe("true"); expect(labels.find((a) => a.name === "executeDescription")?.value).toBe("Now executable"); }); it("does not set execute labels when absent from metadata", () => { const meta = { id: "no-exec", type: "backend", title: "Plain" }; deployScript(meta, content, mime, becca, notesService); const labels = becca.notes[codeNoteId(meta.id)].getLabels(); expect(labels.find((a) => a.name === "executeButton")).toBeUndefined(); expect(labels.find((a) => a.name === "executeDescription")).toBeUndefined(); }); }); });