Files
Trilium/apps/script-deployer/src/deploy.spec.ts
T

387 lines
15 KiB
TypeScript

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 <div>{x}</div>;`;
expect(transpile(source, "test.jsx")).toBe(source);
});
it("strips type annotations from .tsx but preserves JSX", () => {
const source = `const x: number = 1;\nconst el = <div>{x}</div>;`;
const result = transpile(source, "test.tsx");
expect(result).not.toContain(": number");
expect(result).toContain("<div>");
});
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<typeof buildNote>[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 <div>Hello</div>; }`;
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 <div>{x}</div>; }`;
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("<div>");
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();
});
});
});