Files
edgeever/apps/web/src/lib/plugins/plugin-host.test.mjs
T
Dariusz Koryto bec688b91a ci: add oxlint correctness check and resolve its findings (#592)
* refactor: resolve oxlint correctness findings without behavior changes

- Snapshot iterations that mutate their collection with Array.from()
  instead of a spread (live childNodes, self-removing disposers, a Set
  extended inside its own loop).
- Drop empty spread fallbacks, redundant regex escapes, Boolean() casts
  inside negations, a rethrow-only try/catch and statement ternaries.
- Use startsWith/endsWith for anchored literal checks in the extension.
- List U+FE0F explicitly in the mobile attachment emoji class; the
  matched set is unchanged.
- Fix the WeChat copy fixture, where \oint in a template literal lost its
  backslash and the block formula never exercised \oint.
- Document the intentional control-character regexes (filename
  sanitizing and input validation), the throwing provider stream test,
  Astro's generated type reference and the mobile auto-sync finally
  block with narrow inline exceptions.

* ci: run oxlint correctness rules on pull requests

Pin oxlint 1.88.0 and enable only its correctness category across apps/
and packages/, excluding build output, vendored code and the iOS app.
Findings are errors and the script passes --deny-warnings, so CI fails
on any finding. no-unused-vars stays off until unused code is removed
in a dedicated pass.
2026-10-16 21:57:36 +08:00

950 lines
48 KiB
JavaScript

import { afterAll, beforeEach, describe, expect, test } from "bun:test";
import { restoreTestGlobal } from "../restore-test-global.mjs";
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalMutationObserver = globalThis.MutationObserver;
const values = new Map();
const styles = new Map();
const eventListeners = new Map();
const stubWindow = {
location: { href: "https://edgeever.example/settings" },
localStorage: {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
removeItem: (key) => values.delete(key),
get length() { return values.size; },
key: (index) => [...values.keys()][index] ?? null,
},
addEventListener: (name, listener) => eventListeners.set(name, listener),
removeEventListener: (name) => eventListeners.delete(name),
};
globalThis.window = stubWindow;
const stubDocument = {
documentElement: {
classList: { contains: () => false },
dataset: {},
style: {
setProperty: (key, value) => styles.set(key, value),
removeProperty: (key) => styles.delete(key),
},
removeAttribute: (name) => {
if (name === "data-edgeever-extension-theme") delete globalThis.document.documentElement.dataset.edgeeverExtensionTheme;
},
},
};
globalThis.document = stubDocument;
const StubMutationObserver = class {
observe() {}
disconnect() {}
};
globalThis.MutationObserver = StubMutationObserver;
const { EdgeEverPluginHost, applyPluginMarkdownEdits } = await import("./plugin-host.ts");
const { sha256Hex } = await import("./github-plugin-distribution.ts");
const { withRepositoryMutationEvents } = await import("../repository-events.ts");
const { results: capabilityResults } = await import('./plugin-capabilities.fixture.mjs');
afterAll(() => {
restoreTestGlobal("window", originalWindow, stubWindow);
restoreTestGlobal("document", originalDocument, stubDocument);
restoreTestGlobal("MutationObserver", originalMutationObserver, StubMutationObserver);
});
const repository = {
getMemo: async () => ({ memo: { id: "note-1", contentMarkdown: "hello" } }),
listMemos: async () => ({ memos: [], totalCount: 0, nextCursor: null }),
};
beforeEach(() => {
capabilityResults.clear();
values.clear();
styles.clear();
eventListeners.clear();
delete globalThis.window.fetch;
globalThis.document.documentElement.dataset = {};
});
describe("EdgeEverPluginHost", () => {
test("rejects custom settings panels for API v2 plugins", async () => {
const host = new EdgeEverPluginHost({ repository, scope: "settings-policy" });
const id = "org.edgeever.settings-policy";
host.installManifest({
type: "plugin",
id,
name: "Settings policy",
version: "1.0.0",
apiVersion: "2",
settingsUi: "host",
entry: new URL("./plugin-settings-policy.fixture.mjs", import.meta.url).href,
permissions: ["ui:panels"],
}, "https://example.org/manifest.json");
await expect(host.setEnabled(id, true)).rejects.toThrow("custom settings panels are not allowed");
expect(host.getSnapshot().extensions.find((extension) => extension.manifest.id === id)).toMatchObject({
enabled: false,
error: expect.stringContaining("custom settings panels are not allowed"),
});
await host.dispose();
});
test('enabled plugins can use host capabilities without manifest permission declarations', async () => {
const calls = [];
const directCalls = [];
globalThis.window.fetch = async (...input) => { directCalls.push(input); return new Response('direct'); };
const host = new EdgeEverPluginHost({ repository, scope: 'test',
aiAdapter: { status: async () => ({ configured: true }), generate: async input => { calls.push(input); return { text: 'HELLO' }; } },
publicNetworkAdapter: { fetchPublic: async input => { calls.push(input); return { url: input.url, status: 429, statusText: 'Too Many Requests', headers: {}, body: new TextEncoder().encode('limited').buffer }; } },
});
const install = async (id, permissions) => {
host.installManifest({ type: 'plugin', id, name: id, version: '1.0.0', apiVersion: '2', settingsUi: 'host', entry: new URL('./plugin-capabilities.fixture.mjs', import.meta.url).href, ...(permissions ? { permissions } : {}) }, 'https://example.org/manifest.json');
await host.setEnabled(id, true);
};
await install('org.test.trusted');
await host.runCommand('org.test.trusted', 'ai'); expect(capabilityResults.get('org.test.trusted')).toEqual({ text: 'HELLO' });
await host.runCommand('org.test.trusted', 'public'); expect(capabilityResults.get('org.test.trusted')).toEqual({ status: 429, text: 'limited', url: 'https://example.org/feed' });
await host.runCommand('org.test.trusted', 'direct-unlisted');
expect(String(directCalls[0][0])).toBe('http://192.168.1.8/feed');
expect(directCalls[0][1].credentials).toBe('include');
expect(new Headers(directCalls[0][1].headers).get('Authorization')).toBe('Bearer test');
await expect(host.runCommand('org.test.trusted', 'post')).rejects.toThrow();
await host.runCommand('org.test.trusted', 'unlisted'); expect(calls.at(-1).url).toBe('https://unlisted.org/feed');
expect(calls[0].signal.aborted).toBe(false); await host.setEnabled('org.test.trusted', false); expect(calls[0].signal.aborted).toBe(true);
await host.dispose();
});
test("lets a permitted plugin idempotently own schedules for its registered commands", async () => {
const calls = [];
const scheduleAdapter = {
upsert: async (pluginId, input) => {
calls.push({ pluginId, input });
return {
...input,
timezone: input.timezone ?? "UTC",
missedRunPolicy: input.missedRunPolicy ?? "run-once",
isEnabled: input.isEnabled ?? true,
runsOnThisDevice: true,
lastRun: null,
};
},
list: async () => [],
remove: async () => undefined,
};
const host = new EdgeEverPluginHost({ repository, scope: "test", scheduleAdapter });
host.installManifest({
type: "plugin",
id: "org.edgeever.schedule-test",
name: "Schedule Test",
version: "1.0.0",
apiVersion: "2",
settingsUi: "host",
entry: new URL("./plugin-host-schedules.fixture.mjs", import.meta.url).href,
permissions: ["ui:commands", "schedules"],
}, "https://example.com/schedule-plugin/manifest.json");
await host.setEnabled("org.edgeever.schedule-test", true);
expect(calls).toEqual([{
pluginId: "org.edgeever.schedule-test",
input: {
key: "hourly-refresh",
name: "Hourly refresh",
commandId: "refresh",
cronExpression: "0 * * * *",
},
}]);
await host.dispose();
});
test("applies a validated code-free theme", async () => {
const host = new EdgeEverPluginHost({ repository, scope: "test" });
host.installManifest({
type: "theme",
id: "org.edgeever.test-theme",
name: "Test theme",
version: "1.0.0",
themeApiVersion: "1",
modes: ["light"],
light: {
"color.background": "#010203",
"color.accent": "#16a06e",
},
}, "https://example.com/theme/manifest.json");
await host.setEnabled("org.edgeever.test-theme", true);
expect(styles.get("--edgeever-theme-background")).toBe("#010203");
expect(styles.get("--edgeever-theme-accent")).toBe("#16a06e");
expect(globalThis.document.documentElement.dataset.edgeeverExtensionTheme).toBe("org.edgeever.test-theme");
await host.dispose();
});
test("loads a plugin and runs its registered command", async () => {
const notices = [];
const secrets = new Map();
const secretStorage = {
get: async (pluginId, key) => secrets.get(`${pluginId}:${key}`) ?? null,
set: async (pluginId, key, value) => secrets.set(`${pluginId}:${key}`, value),
remove: async (pluginId, key) => secrets.delete(`${pluginId}:${key}`),
clearNamespace: async (pluginId) => {
for (const key of Array.from(secrets.keys())) if (key.startsWith(`${pluginId}:`)) secrets.delete(key);
},
};
const packageStorage = {
get: async () => null,
put: async () => undefined,
remove: async () => undefined,
};
const host = new EdgeEverPluginHost({ repository, scope: "test", onNotice: (message) => notices.push(message), secretStorage, packageStorage });
let replacement = null;
host.setEditorAdapter({
getSelection: () => ({ noteId: "note-1", from: 1, to: 6, empty: false, text: "hello", contentMarkdown: "hello" }),
getDocument: () => ({ noteId: "note-1", contentMarkdown: "hello", hasUnsavedChanges: false }),
replaceDocument: () => undefined,
insertEmbed: () => undefined,
replaceSelection: (value) => { replacement = value; },
insertAtCursor: () => undefined,
});
const entry = new URL("./plugin-host.fixture.mjs", import.meta.url).href;
host.installManifest({
type: "plugin",
id: "org.edgeever.test-plugin",
name: "Test plugin",
version: "1.0.0",
apiVersion: "2",
settingsUi: "host",
entry,
permissions: ["notes:write", "ui:commands", "ui:notices", "ui:panels", "editor:read", "editor:write", "secrets", "storage"],
settings: {
fields: [
{ key: "endpoint", type: "text", label: "Endpoint", default: "https://default.example" },
{ key: "token", type: "secret", label: "Token", required: true },
{ key: "limit", type: "number", label: "Limit", min: 1, max: 10, default: 5 },
],
},
}, "https://example.com/plugin/manifest.json");
await host.setEnabled("org.edgeever.test-plugin", true);
await host.runCommand("org.edgeever.test-plugin", "hello");
expect(host.getSnapshot().commands).toHaveLength(7);
expect(host.getSnapshot().panels).toEqual([{ pluginId: "org.edgeever.test-plugin", id: "fixture", title: "Fixture panel", purpose: "workflow", presentation: "dialog" }]);
expect(notices).toEqual(["hello from plugin"]);
await host.runCommand("org.edgeever.test-plugin", "read-without-permission");
await host.runCommand("org.edgeever.test-plugin", "subscribe-without-read-permission");
await host.runCommand("org.edgeever.test-plugin", "replace-selection");
expect(replacement).toBe("HELLO");
await host.runCommand("org.edgeever.test-plugin", "write-secret");
expect(secrets.get("test:org.edgeever.test-plugin:token")).toBe("secret-value");
await host.runCommand("org.edgeever.test-plugin", "write-storage");
expect(values.get("edgeever.plugin-data.v1:test:org.edgeever.test-plugin:preference")).toBe('"stored-value"');
await expect(host.getSettingValue("org.edgeever.test-plugin", "endpoint")).resolves.toBe("https://default.example");
await host.setSettingValue("org.edgeever.test-plugin", "endpoint", "https://custom.example");
await host.setSettingValue("org.edgeever.test-plugin", "token", "configured-token");
await expect(host.getSettingValue("org.edgeever.test-plugin", "token")).resolves.toBeNull();
await expect(host.getSettingValue("org.edgeever.test-plugin", "token", true)).resolves.toBe("configured-token");
await expect(host.setSettingValue("org.edgeever.test-plugin", "limit", 11)).rejects.toThrow("at most 10");
const container = {};
const disposePanel = await host.mountPanel("org.edgeever.test-plugin", "fixture", container);
expect(container.mountedByFixture).toBe(true);
disposePanel();
expect(container.mountedByFixture).toBe(false);
const mountedDuringDisable = {};
await host.mountPanel("org.edgeever.test-plugin", "fixture", mountedDuringDisable);
await host.setEnabled("org.edgeever.test-plugin", false);
expect(mountedDuringDisable.mountedByFixture).toBe(false);
expect(host.getSnapshot().panels).toHaveLength(0);
await host.uninstall("org.edgeever.test-plugin");
expect(secrets.has("test:org.edgeever.test-plugin:token")).toBe(false);
expect(secrets.has("test:org.edgeever.test-plugin:setting:token")).toBe(false);
expect(values.has("edgeever.plugin-data.v1:test:org.edgeever.test-plugin:preference")).toBe(false);
expect(values.has("edgeever.plugin-settings.v1:test:org.edgeever.test-plugin:endpoint")).toBe(false);
await host.dispose();
});
test("installs a checksum-pinned marketplace package and removes its cache on uninstall", async () => {
const manifest = {
type: "plugin",
id: "org.edgeever.marketplace-test",
name: "Marketplace Test",
version: "1.0.0",
apiVersion: "2",
settingsUi: "host",
entry: "./main.js",
permissions: [],
};
const manifestText = JSON.stringify(manifest);
const mainJs = "export default { activate() {} };";
globalThis.window.fetch = async (input) => {
const url = String(input);
if (url.endsWith("/manifest.json")) return new Response(manifestText, { headers: { "content-type": "application/json" } });
if (url.endsWith("/main.js")) return new Response(mainJs, { headers: { "content-type": "text/javascript" } });
return new Response(null, { status: 404 });
};
const packages = new Map();
const packageStorage = {
get: async (pluginId, version) => packages.get(`${pluginId}:${version}`) ?? null,
put: async (value) => packages.set(`${value.pluginId}:${value.version}`, value),
remove: async (pluginId, version) => {
if (version) packages.delete(`${pluginId}:${version}`);
else for (const key of Array.from(packages.keys())) if (key.startsWith(`${pluginId}:`)) packages.delete(key);
},
};
const secretStorage = {
get: async () => null,
set: async () => undefined,
remove: async () => undefined,
clearNamespace: async () => undefined,
};
const host = new EdgeEverPluginHost({ repository, scope: "test", packageStorage, secretStorage });
const entry = {
id: manifest.id,
name: manifest.name,
description: "Verified test plugin",
author: "EdgeEver",
category: "Testing",
repositoryUrl: "https://github.com/edgeever/marketplace-test",
distribution: { type: "manifest", manifestUrl: "https://plugins.example/manifest.json" },
verification: {
version: manifest.version,
checksums: { manifestJson: await sha256Hex(manifestText), mainJs: await sha256Hex(mainJs) },
},
};
await host.installMarketplaceEntry(entry);
expect(host.getSnapshot().extensions[0].source).toMatchObject({ kind: "marketplace", verified: true });
expect(packages.get(`${manifest.id}:${manifest.version}`)?.checksums.mainJs).toBe(entry.verification.checksums.mainJs);
await host.uninstall(manifest.id);
expect([...packages.keys()].some((key) => key.startsWith(`${manifest.id}:`))).toBe(false);
await host.dispose();
});
test("rolls back an enabled plugin when an update cannot activate", async () => {
const pluginId = "org.edgeever.rollback-test";
const entry = new URL("./plugin-host.fixture.mjs", import.meta.url).href;
const packages = new Map();
const packageStorage = {
get: async (id, version) => packages.get(`${id}:${version}`) ?? null,
put: async (value) => packages.set(`${value.pluginId}:${value.version}`, value),
remove: async (id, version) => {
if (version) packages.delete(`${id}:${version}`);
else for (const key of Array.from(packages.keys())) if (key.startsWith(`${id}:`)) packages.delete(key);
},
};
const host = new EdgeEverPluginHost({ repository, scope: "test", packageStorage });
host.installManifest({
type: "plugin",
id: pluginId,
name: "Rollback Test",
version: "1.0.0",
apiVersion: "2",
settingsUi: "host",
entry,
permissions: ["notes:write", "ui:commands", "ui:notices", "ui:panels", "editor:read", "editor:write", "secrets", "storage"],
}, "https://plugins.example/v1/manifest.json");
await host.setEnabled(pluginId, true);
const nextManifest = {
type: "plugin",
id: pluginId,
name: "Rollback Test",
version: "2.0.0",
apiVersion: "2",
settingsUi: "host",
entry: "./main.js",
permissions: [],
};
globalThis.window.fetch = async (input) => String(input).endsWith("main.js")
? new Response("export default {};", { headers: { "content-type": "text/javascript" } })
: Response.json(nextManifest);
await expect(host.installFromManifestUrl("https://plugins.example/v2/manifest.json", undefined, nextManifest))
.rejects.toThrow("activate(context)");
const restored = host.getSnapshot().extensions.find((extension) => extension.manifest.id === pluginId);
expect(restored?.manifest.version).toBe("1.0.0");
expect(restored?.enabled).toBe(true);
expect(host.getSnapshot().commands.some((command) => command.pluginId === pluginId && command.id === "hello")).toBe(true);
expect(packages.has(`${pluginId}:2.0.0`)).toBe(false);
await host.dispose();
});
test("rejects a manifest that changed after update confirmation", async () => {
const confirmedManifest = {
type: "theme",
id: "org.edgeever.changed-theme",
name: "Changed theme",
version: "2.0.0",
themeApiVersion: "1",
modes: ["light"],
light: { "color.background": "#ffffff" },
};
globalThis.window.fetch = async () => Response.json({
...confirmedManifest,
light: { "color.background": "#000000" },
});
const host = new EdgeEverPluginHost({ repository, scope: "test" });
await expect(host.installFromManifestUrl(
"https://plugins.example/manifest.json",
undefined,
confirmedManifest,
)).rejects.toThrow("changed after update confirmation");
expect(host.getSnapshot().extensions).toHaveLength(0);
await host.dispose();
});
test("routes P1 notebook, note revision, resource, and setting capabilities through the repository", async () => {
const resource = {
id: "resource-1",
memoId: "note-1",
originalMemoId: null,
kind: "attachment",
mimeType: "text/plain",
filename: "hello.txt",
byteSize: 5,
sha256: "resource-hash",
width: null,
height: null,
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt: "2026-09-01T00:00:00.000Z",
url: "https://edgeever.example/resource-1",
};
const note = {
id: "note-1",
notebookId: "notebook-1",
title: "First",
excerpt: "First note",
tags: ["test"],
isPinned: false,
isArchived: false,
isDeleted: false,
revision: 3,
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt: "2026-09-01T00:00:00.000Z",
deletedAt: null,
contentJson: { type: "doc", content: [] },
contentMarkdown: "First note",
contentText: "First note",
contentHash: "first-hash",
sourceMemoIds: [],
mergeSourceCount: 0,
mergedIntoMemoId: null,
};
let updateInput = null;
const template = {
id: "template-1",
name: "Existing template",
description: null,
title: null,
contentMarkdown: "Template",
tags: [],
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt: "2026-09-01T00:00:00.000Z",
};
const repositoryWithCapabilities = {
...repository,
listMemos: async () => ({ memos: [note], totalCount: 1, nextCursor: null }),
getMemo: async () => ({ memo: note }),
updateMemo: async (_memo, input) => {
updateInput = input;
return { memo: { ...note, contentMarkdown: input.contentMarkdown, contentText: "Updated note" }, queued: true };
},
createNotebook: async (input) => ({ notebook: { id: "notebook-created", parentId: null, name: input.name, memoCount: 0 } }),
moveMemos: async () => ({ ok: true, moved: 1 }),
pinMemos: async () => ({ ok: true, updated: 1 }),
listMemoRevisions: async () => ({ revisions: [{
id: "revision-1",
memoId: "note-1",
revision: 1,
title: "First",
tags: ["test"],
contentMarkdown: "First",
contentText: "First",
createdAt: "2026-09-01T00:00:00.000Z",
}] }),
listResources: async () => ({ resources: [{ ...resource, memoTitle: "Note", memoExcerpt: "", memoDeleted: false }], summary: {} }),
readResource: async () => new Blob(["resource bytes"], { type: "text/plain" }),
uploadMemoResource: async () => ({ resource }),
updateResource: async (_resourceId, file) => ({ resource: { ...resource, filename: file.name, mimeType: file.type, sha256: "updated-resource-hash" } }),
listTemplates: async () => ({ templates: [template] }),
createTemplate: async (input) => ({ template: { ...template, name: input.name } }),
updateTemplate: async (_templateId, input) => ({ template: { ...template, name: input.name ?? template.name } }),
deleteTemplate: async () => ({ ok: true }),
useTemplate: async () => ({ memo: { ...note, id: "templated-note", contentMarkdown: template.contentMarkdown } }),
};
const packageStorage = { get: async () => null, put: async () => undefined, remove: async () => undefined };
const host = new EdgeEverPluginHost({ repository: repositoryWithCapabilities, scope: "capabilities", packageStorage });
const opened = [];
const openedPanels = [];
const insertedEmbeds = [];
let liveDocument = "First note";
let liveDocumentDirty = false;
host.setNavigationAdapter({ openNote: (noteId, notebookId, options) => opened.push({ noteId, notebookId, options }) });
host.setPanelAdapter({ openPanel: (pluginId, panelId, options) => openedPanels.push({ pluginId, panelId, options }) });
host.setEditorAdapter({
getSelection: () => ({ noteId: "note-1", from: 0, to: 0, empty: true, text: "", contentMarkdown: "" }),
getDocument: () => ({ noteId: "note-1", contentMarkdown: liveDocument, hasUnsavedChanges: liveDocumentDirty }),
replaceDocument: (contentMarkdown) => { liveDocument = contentMarkdown; liveDocumentDirty = true; },
insertEmbed: (embed) => insertedEmbeds.push(embed),
replaceSelection: () => undefined,
insertAtCursor: () => undefined,
});
const entry = new URL("./plugin-host-capabilities.fixture.mjs", import.meta.url).href;
host.installManifest({
type: "plugin",
id: "org.edgeever.capabilities",
name: "Capabilities",
version: "1.0.0",
apiVersion: "2",
settingsUi: "host",
entry,
permissions: [],
settings: { fields: [{ key: "endpoint", type: "text", label: "Endpoint", default: "https://api.example" }] },
}, "https://plugins.example/capabilities/manifest.json");
await host.setEnabled("org.edgeever.capabilities", true);
await host.runCommand("org.edgeever.capabilities", "exercise-capabilities");
expect(globalThis.edgeeverPluginCapabilityResult).toMatchObject({
notebook: { id: "notebook-created", name: "Created by plugin" },
moved: 1,
pinned: 1,
revisions: [{ id: "revision-1", noteId: "note-1", contentMarkdown: "First" }],
resources: [{ id: "resource-1", noteId: "note-1", filename: "hello.txt" }],
resourceText: "resource bytes",
uploaded: { id: "resource-1", noteId: "note-1" },
updatedResource: { id: "resource-1", filename: "drawing.excalidraw", contentHash: "updated-resource-hash" },
endpoint: "https://api.example",
note: { id: "note-1", revision: 3, contentHash: "first-hash" },
edited: { contentMarkdown: "Updated note" },
contentQuery: { notes: [{ id: "note-1", contentMarkdown: "First note" }], totalCount: 1 },
templates: [{ id: "template-1", name: "Existing template" }],
createdTemplate: { id: "template-1", name: "Plugin template" },
updatedTemplate: { id: "template-1", name: "Updated template" },
templatedNote: { id: "templated-note", contentMarkdown: "Template" },
editorDocument: { noteId: "note-1", contentMarkdown: "First note", hasUnsavedChanges: false },
editedEditorDocument: { noteId: "note-1", contentMarkdown: "Live note", hasUnsavedChanges: true },
insertedEmbed: { pluginId: "org.edgeever.capabilities", type: "drawing", resourceId: "resource-1", data: { mode: "edit" } },
});
expect(updateInput).toMatchObject({
expectedRevision: 3,
expectedContentHash: "first-hash",
contentMarkdown: "Updated note",
});
expect(opened).toEqual([{ noteId: "note-1", notebookId: "notebook-1", options: { search: "Updated note" } }]);
expect(openedPanels).toEqual([{
pluginId: "org.edgeever.capabilities",
panelId: "capability-panel",
options: { state: { resourceId: "resource-1" } },
}]);
expect(insertedEmbeds).toHaveLength(1);
expect(host.getSnapshot().embeds).toEqual([{ pluginId: "org.edgeever.capabilities", type: "drawing" }]);
const embedContainer = { dataset: {} };
const disposeEmbed = await host.mountEmbed("org.edgeever.capabilities", "drawing", embedContainer, insertedEmbeds[0]);
expect(embedContainer.dataset.embedId).toBe(insertedEmbeds[0].id);
disposeEmbed();
expect(embedContainer.dataset.embedId).toBeUndefined();
expect(host.getSnapshot().panels[0]).toMatchObject({ presentation: "fullscreen" });
const panelContainer = {};
let requestedPanelClose = false;
const disposePanel = await host.mountPanel(
"org.edgeever.capabilities",
"capability-panel",
panelContainer,
{ state: { resourceId: "resource-1" } },
() => { requestedPanelClose = true; },
);
expect(panelContainer.panelState).toEqual({ resourceId: "resource-1" });
expect(panelContainer.hasPanelShell).toBe(true);
await panelContainer.requestPanelClose();
expect(requestedPanelClose).toBe(true);
disposePanel();
expect(panelContainer.panelState).toBeUndefined();
await expect(host.getPanelCloseDecision("org.edgeever.capabilities", "capability-panel")).resolves.toEqual({
title: "Unsaved drawing",
message: "Close without saving?",
confirmLabel: "Close drawing",
});
await expect(host.runCommand("org.edgeever.capabilities", "edit-stale-note"))
.rejects.toMatchObject({ code: "NOTE_CONFLICT" });
host.setEditorAdapter({
getSelection: () => ({ noteId: "note-1", from: 0, to: 0, empty: true, text: "", contentMarkdown: "" }),
getDocument: () => ({ noteId: "note-1", contentMarkdown: "Draft", hasUnsavedChanges: true }),
replaceDocument: () => undefined,
insertEmbed: () => undefined,
replaceSelection: () => undefined,
insertAtCursor: () => undefined,
});
await expect(host.runCommand("org.edgeever.capabilities", "exercise-capabilities"))
.rejects.toMatchObject({ code: "NOTE_CONFLICT" });
delete globalThis.edgeeverPluginCapabilityResult;
await host.dispose();
});
test("applies non-overlapping Markdown edits and rejects invalid ranges", () => {
expect(applyPluginMarkdownEdits("one two three", [
{ from: 8, to: 13, insert: "THREE" },
{ from: 0, to: 3, insert: "ONE" },
])).toBe("ONE two THREE");
expect(() => applyPluginMarkdownEdits("abcdef", [
{ from: 1, to: 4, insert: "x" },
{ from: 3, to: 5, insert: "y" },
])).toThrow("cannot overlap");
expect(() => applyPluginMarkdownEdits("A😀B", [{ from: 2, to: 2, insert: "x" }]))
.toThrow("surrogate pair");
});
test("delivers successful user and plugin repository mutations through one workspace event stream", async () => {
const updatedMemo = {
id: "note-events",
notebookId: "notebook-1",
title: "Updated",
excerpt: "Updated",
tags: ["events"],
isPinned: false,
isArchived: false,
isDeleted: false,
revision: 2,
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt: "2026-09-01T00:01:00.000Z",
deletedAt: null,
contentJson: { type: "doc", content: [] },
contentMarkdown: "Updated",
contentText: "Updated",
contentHash: "updated-hash",
sourceMemoIds: [],
mergeSourceCount: 0,
mergedIntoMemoId: null,
};
const createdTemplate = {
id: "template-events",
name: "Event template",
description: null,
title: null,
contentJson: { type: "doc", content: [] },
contentMarkdown: "Template",
tags: ["events"],
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt: "2026-09-01T00:00:00.000Z",
};
const updatedResource = {
id: "resource-events",
memoId: "note-events",
originalMemoId: null,
kind: "attachment",
mimeType: "application/vnd.excalidraw+json",
filename: "drawing.excalidraw",
byteSize: 5,
sha256: "resource-event-hash",
width: null,
height: null,
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt: "2026-09-01T00:01:00.000Z",
url: "/api/v1/resources/resource-events/blob",
};
const repositoryWithEvents = withRepositoryMutationEvents({
...repository,
updateMemo: async () => ({ memo: updatedMemo, queued: true }),
createTemplate: async () => ({ template: createdTemplate, queued: true }),
updateResource: async () => ({ resource: updatedResource }),
}, "event-workspace");
const packageStorage = { get: async () => null, put: async () => undefined, remove: async () => undefined };
const host = new EdgeEverPluginHost({ repository: repositoryWithEvents, scope: "event-workspace", packageStorage });
host.installManifest({
type: "plugin",
id: "org.edgeever.events",
name: "Events",
version: "1.0.0",
apiVersion: "2",
settingsUi: "host",
entry: new URL("./plugin-host-events.fixture.mjs", import.meta.url).href,
permissions: ["notes:read", "templates:read", "resources:read"],
settings: { fields: [{ key: "mode", type: "select", label: "Mode", default: "daily", options: [
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
] }] },
}, "https://plugins.example/events/manifest.json");
await host.setEnabled("org.edgeever.events", true);
await host.activateEnabled();
host.installManifest({
type: "plugin",
id: "org.edgeever.events-other",
name: "Other Events",
version: "1.0.0",
apiVersion: "2",
settingsUi: "host",
entry: new URL("./plugin-host-events.fixture.mjs", import.meta.url).href,
permissions: [],
settings: { fields: [{ key: "mode", type: "text", label: "Mode" }] },
}, "https://plugins.example/events-other/manifest.json");
await host.setEnabled("org.edgeever.events-other", true);
await host.setSettingValue("org.edgeever.events", "mode", "weekly");
await host.removeSettingValue("org.edgeever.events", "mode");
await repositoryWithEvents.updateMemo(updatedMemo, {});
await repositoryWithEvents.createTemplate({ name: "Event template" });
await repositoryWithEvents.updateResource("resource-events", new File(["scene"], "drawing.excalidraw"), "old-hash");
expect(globalThis.edgeeverPluginObservedNote).toMatchObject({
id: "note-events",
contentMarkdown: "Updated",
});
expect(globalThis.edgeeverPluginObservedTemplate).toMatchObject({
id: "template-events",
contentMarkdown: "Template",
});
expect(globalThis.edgeeverPluginObservedResource).toMatchObject({
id: "resource-events",
contentHash: "resource-event-hash",
});
expect(globalThis.edgeeverPluginObservedSettings).toEqual([
{ pluginId: "org.edgeever.events", key: "mode" },
{ pluginId: "org.edgeever.events", key: "mode" },
]);
delete globalThis.edgeeverPluginObservedNote;
delete globalThis.edgeeverPluginObservedTemplate;
delete globalThis.edgeeverPluginObservedResource;
delete globalThis.edgeeverPluginObservedSettings;
await host.dispose();
});
});
const structured = await import("@edgeever/shared");
describe("structured plugin note APIs", () => {
const setup = (contentMarkdown, contentJson = structured.markdownToDoc(contentMarkdown)) => {
let memo = { id: "note-1", notebookId: "book", title: "Original", excerpt: "", tags: ["keep"], isPinned: false,
createdAt: "2026-01-01", updatedAt: "2026-01-01", revision: 4, contentHash: "cloud-hash", contentMarkdown, contentJson, contentText: "" };
const saves = [];
const resources = [];
let onResourceRead = () => {};
const host = new EdgeEverPluginHost({ scope: "structured", repository: {
getMemo: async () => ({ memo: structuredClone(memo) }),
listResources: async () => { onResourceRead(); return { resources }; },
updateMemo: async (current, input) => {
saves.push(input);
// Simulate an offline Web write: cloud revision/hash have not advanced yet.
memo = { ...memo, ...input };
return { memo: structuredClone(memo) };
},
}});
const context = host.createContext({ id: "org.test.structured", permissions: [] }, []);
return { host, context, saves, resources, setResourceRead: (callback) => { onResourceRead = callback; }, getMemo: () => memo, change: (patch) => { memo = { ...memo, ...patch }; } };
};
const input = result => ({ expectedRevision: result.note.revision, expectedContentHash: result.note.contentHash, expectedEditToken: result.editToken });
const diagramMarkdown = () => structured.serializeDiagramDocument({ schemaVersion: 1, kind: "mind-map", theme: "slate", structure: "tree",
nodes: [{ id: "root", label: "Root", shape: "topic", x: 71, y: 22, width: 120, height: 40 }], edges: [] });
const tableMarkdown = () => structured.serializeTableDocument({ schemaVersion: 1,
fields: [{ id: "name", name: "Name", type: "text" }, { id: "file", name: "File", type: "attachment" }],
records: [{ id: "row", cells: { name: "Original", file: [] } }], view: { filters: [], sort: null } });
test("exposes full detached content and preserves special notes on metadata updates", async () => {
const originalDoc = { type: "doc", content: [{ type: "paragraph", attrs: { custom: "preserve" } }] };
const f = setup(diagramMarkdown(), originalDoc);
const read = await f.context.notes.get("note-1");
expect(read.kind).toBe("mind-map");
read.contentJson.content[0].attrs.custom = "changed";
expect(f.getMemo().contentJson).toEqual(originalDoc);
await f.context.notes.update("note-1", { title: "Renamed", tags: ["new"] });
expect(f.saves[0].contentJson).toEqual(originalDoc);
expect(f.saves[0].contentMarkdown).toBe(diagramMarkdown());
await expect(f.context.notes.update("note-1", { contentMarkdown: "oops" })).rejects.toMatchObject({ code: "STRUCTURED_NOTE_REQUIRED" });
await expect(f.context.notes.editMarkdown("note-1", { expectedRevision: 4, expectedContentHash: "cloud-hash", edits: [{ from: 0, to: 0, insert: "oops" }] })).rejects.toMatchObject({ code: "STRUCTURED_NOTE_REQUIRED" });
expect(f.saves).toHaveLength(1);
});
test("previews and edits diagrams while preserving styles and rejecting stale unsynced snapshots", async () => {
const f = setup(diagramMarkdown());
const snapshot = await f.context.diagrams.get("note-1", { includeLayout: true });
const edit = { ...input(snapshot), operations: [{ op: "update_node", nodeId: "root", changes: { label: "Edited" } }] };
const preview = await f.context.diagrams.edit("note-1", { ...edit, dryRun: true });
expect(preview.diagram.nodes[0].label).toBe("Edited");
expect(preview.note.contentMarkdown).toBe(snapshot.note.contentMarkdown);
expect(f.saves).toHaveLength(0);
const result = await f.context.diagrams.edit("note-1", edit);
expect(result.diagram).toMatchObject({ theme: "slate", structure: "tree" });
expect(result.diagram.nodes[0].layout).toMatchObject({ x: 71, y: 22 });
await expect(f.context.diagrams.edit("note-1", edit)).rejects.toMatchObject({ code: "NOTE_CONFLICT" });
expect(f.saves).toHaveLength(1);
});
test("rejects invalid diagram batches without a partial write", async () => {
const f = setup(diagramMarkdown());
const read = await f.context.diagrams.get("note-1");
await expect(f.context.diagrams.edit("note-1", { ...input(read), operations: [
{ op: "update_node", nodeId: "root", changes: { label: "Must not save" } },
{ op: "add_edge", edge: { source: "root", target: "missing" } },
] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" });
expect(f.saves).toHaveLength(0);
expect((await f.context.diagrams.get("note-1")).diagram.nodes[0].label).toBe("Root");
});
test("applies table records and cells as one batch and rejects bad values atomically", async () => {
const f = setup(tableMarkdown());
let read = await f.context.tables.get("note-1");
await expect(f.context.tables.edit("note-1", { ...input(read), operations: [
{ op: "update_record", recordId: "row", cells: { name: "Must not save" } },
{ op: "update_record", recordId: "row", cells: { missing: "oops" } },
] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" });
expect(f.saves).toHaveLength(0);
const result = await f.context.tables.edit("note-1", { ...input(read), operations: [
{ op: "update_record", recordId: "row", cells: { name: "Edited" } },
{ op: "add_record", recordId: "row2", cells: { name: "New" } },
] });
expect(result.table.records.map(row => row.cells.name)).toEqual(["Edited", "New"]);
expect(f.saves).toHaveLength(1);
await expect(f.context.tables.edit("note-1", { ...input(read), operations: [{ op: "remove_record", recordId: "row" }] })).rejects.toMatchObject({ code: "NOTE_CONFLICT" });
read = await f.context.tables.get("note-1");
await f.context.tables.edit("note-1", { ...input(read), operations: [{ op: "remove_record", recordId: "row2" }] });
expect(f.getMemo().tags).toEqual(["keep"]);
});
test("previews destructive schema changes and requires explicit data conversion", async () => {
const f = setup(tableMarkdown());
const read = await f.context.tables.get("note-1");
const edit = { ...input(read), operations: [{ op: "update_field", fieldId: "name", changes: { type: "number" } }] };
const preview = await f.context.tables.edit("note-1", { ...edit, dryRun: true });
expect(preview.changedCellCount).toBe(1);
expect(f.saves).toHaveLength(0);
await expect(f.context.tables.edit("note-1", edit)).rejects.toMatchObject({ code: "TABLE_DATA_CHANGES_REQUIRED" });
await f.context.tables.edit("note-1", { ...edit, allowDataChanges: true });
expect(f.saves).toHaveLength(1);
});
test("validates attachment ownership and resolves metadata through the repository", async () => {
const f = setup(tableMarkdown());
const read = await f.context.tables.get("note-1");
const edit = { ...input(read), operations: [{ op: "update_record", recordId: "row", cells: { file: ["attachment"] } }] };
f.resources.push({ id: "attachment", memoId: "other-note", kind: "attachment", filename: "a.pdf", mimeType: "application/pdf", byteSize: 12 });
await expect(f.context.tables.edit("note-1", edit)).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" });
f.resources[0].memoId = "note-1";
const result = await f.context.tables.edit("note-1", edit);
expect(result.table.records[0].cells.file).toEqual([{ resourceId: "attachment", filename: "a.pdf", mimeType: "application/pdf", byteSize: 12 }]);
});
test("rejects dirty editors, wrong note types, and concurrent writes using the same snapshot", async () => {
const f = setup(tableMarkdown());
const read = await f.context.tables.get("note-1");
const edit = { ...input(read), operations: [{ op: "update_record", recordId: "row", cells: { name: "Edited" } }] };
f.host.setEditorAdapter({ getDocument: () => ({ noteId: "note-1", contentMarkdown: "draft", hasUnsavedChanges: true }) });
await expect(f.context.tables.edit("note-1", edit)).rejects.toMatchObject({ code: "NOTE_CONFLICT" });
await expect(f.context.editor.insertAtCursor("oops")).rejects.toMatchObject({ code: "STRUCTURED_NOTE_REQUIRED" });
await expect(f.context.editor.replaceSelection("oops")).rejects.toMatchObject({ code: "STRUCTURED_NOTE_REQUIRED" });
await expect(f.context.editor.editMarkdown([{ from: 0, to: 0, insert: "oops" }])).rejects.toMatchObject({ code: "STRUCTURED_NOTE_REQUIRED" });
f.host.setEditorAdapter(null);
await expect(f.context.diagrams.get("note-1")).rejects.toMatchObject({ code: "NOTE_TYPE_MISMATCH" });
const results = await Promise.allSettled([f.context.tables.edit("note-1", edit), f.context.tables.edit("note-1", edit)]);
expect(results.map(result => result.status)).toEqual(["fulfilled", "rejected"]);
expect(f.saves).toHaveLength(1);
});
test("rechecks the saved baseline after asynchronous attachment validation", async () => {
const f = setup(tableMarkdown());
const read = await f.context.tables.get("note-1");
f.resources.push({ id: "attachment", memoId: "note-1", kind: "attachment", filename: "file", mimeType: "text/plain", byteSize: 1 });
f.setResourceRead(() => f.change({ title: "Changed elsewhere" }));
await expect(f.context.tables.edit("note-1", { ...input(read), operations: [
{ op: "update_record", recordId: "row", cells: { file: ["attachment"] } },
] })).rejects.toMatchObject({ code: "NOTE_CONFLICT" });
expect(f.saves).toHaveLength(0);
expect(f.getMemo().title).toBe("Changed elsewhere");
});
test("rejects missing snapshot tokens and invalid typed cells", async () => {
const f = setup(tableMarkdown());
const read = await f.context.tables.get("note-1");
await expect(f.context.tables.edit("note-1", { expectedRevision: 4, expectedContentHash: "cloud-hash", operations: [] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" });
await expect(f.context.tables.edit("note-1", { ...input(read), operations: [
{ op: "add_field", field: { name: "Count", type: "number" } },
{ op: "update_record", recordId: "row", cells: { name: 42 } },
] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" });
expect(f.saves).toHaveLength(0);
});
test("previews poster element changes, preserves locked elements and rejects stale snapshots", async () => {
const poster = structured.createPosterDocument("Original", "Keep subtitle");
poster.elements.find(element => element.id === "subtitle").locked = true;
poster.previewResourceId = "old-preview";
const f = setup(structured.serializePosterDocument(poster));
const read = await f.context.posters.get("note-1");
expect((await f.context.posters.capabilities()).editableFields).toContain("text");
const edit = { ...input(read), edits: [{ id: "heading", patch: { text: "Edited", fill: "#16A06E" } }] };
const preview = await f.context.posters.edit("note-1", { ...edit, dryRun: true });
expect(preview.poster.elements.find(element => element.id === "heading").text).toBe("Edited");
expect(preview.poster.previewResourceId).toBeUndefined();
expect(f.saves).toHaveLength(0);
const result = await f.context.posters.edit("note-1", edit);
expect(result.poster.elements.find(element => element.id === "subtitle")).toEqual(poster.elements.find(element => element.id === "subtitle"));
expect(f.getMemo().tags).toEqual(["keep"]);
await expect(f.context.posters.edit("note-1", edit)).rejects.toMatchObject({ code: "NOTE_CONFLICT" });
const fresh = await f.context.posters.get("note-1");
await expect(f.context.posters.edit("note-1", { ...input(fresh), operations: [{ op: "remove", ids: ["subtitle"] }] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" });
await expect(f.context.posters.edit("note-1", { ...input(fresh), operations: [{ op: "apply_template", template: "bold" }] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" });
expect(f.saves).toHaveLength(1);
});
test("rejects poster partial batches and foreign image resources", async () => {
const f = setup(structured.serializePosterDocument(structured.createPosterDocument("Original")));
const read = await f.context.posters.get("note-1");
const image = { id: "image", name: "Image", type: "image", resourceId: "resource", x: 0, y: 0, width: 100, height: 100, rotation: 0, opacity: 1 };
const edit = { ...input(read), operations: [{ op: "add", element: image }] };
f.resources.push({ id: "resource", memoId: "other-note", kind: "image" });
await expect(f.context.posters.edit("note-1", edit)).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" });
expect(f.saves).toHaveLength(0);
await expect(f.context.posters.edit("note-1", { ...input(read), edits: [{ id: "heading", patch: { text: "Must not save" } }], operations: [{ op: "remove", ids: ["missing"] }] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" });
expect(f.saves).toHaveLength(0);
f.resources[0].memoId = "note-1";
const result = await f.context.posters.edit("note-1", edit);
expect(result.poster.elements.find(element => element.id === "image")).toEqual(image);
});
test("reads and edits infographic data without losing styles or conversation history", async () => {
const infographic = { schemaVersion: 1, syntax: "infographic list-grid-simple\ndata\n title Original\n lists\n - label First\n desc Keep\ntheme dark\n colorBg #123456", history: [
{ id: "history", prompt: "Create", createdAt: "2026-01-01", kind: "generated", resultTitle: "Original" },
] };
const f = setup(structured.serializeInfographicDocument(infographic));
const read = await f.context.infographics.get("note-1");
expect((await f.context.infographics.capabilities()).templates).toContain("list-grid-simple");
expect((await f.context.infographics.capabilities()).themes).toContain("dark");
expect(read).toMatchObject({ template: "list-grid-simple", theme: "dark", data: { title: "Original", lists: [{ label: "First", desc: "Keep" }] } });
const edit = { ...input(read), operations: [{ op: "set_data", path: ["lists", 0, "label"], value: "Edited" }] };
const preview = await f.context.infographics.edit("note-1", { ...edit, dryRun: true });
expect(preview.infographic.syntax).toContain("label Edited");
expect(f.saves).toHaveLength(0);
const result = await f.context.infographics.edit("note-1", edit);
expect(result.infographic.history).toEqual(infographic.history);
expect(result.infographic.syntax).toContain("theme dark\n colorBg #123456");
expect(f.getMemo().title).toBe("Original");
await expect(f.context.infographics.edit("note-1", edit)).rejects.toMatchObject({ code: "NOTE_CONFLICT" });
const fresh = await f.context.infographics.get("note-1");
expect(fresh.data.lists[0].desc).toBe("Keep");
expect(f.saves).toHaveLength(1);
});
test("rejects wrong poster/infographic types, dirty editors and invalid infographic batches", async () => {
const f = setup(structured.serializeInfographicDocument({ schemaVersion: 1, syntax: "infographic list-grid-simple\ndata\n lists\n - label Original" }));
const read = await f.context.infographics.get("note-1");
await expect(f.context.posters.get("note-1")).rejects.toMatchObject({ code: "NOTE_TYPE_MISMATCH" });
await expect(f.context.infographics.edit("note-1", { ...input(read), operations: [
{ op: "set_data", path: ["lists", 0, "label"], value: "Must not save" },
{ op: "remove_data", path: ["lists", 8] },
] })).rejects.toMatchObject({ code: "INVALID_STRUCTURED_EDIT" });
f.host.setEditorAdapter({ getDocument: () => ({ noteId: "note-1", contentMarkdown: "draft", hasUnsavedChanges: true }) });
await expect(f.context.infographics.edit("note-1", { ...input(read), operations: [{ op: "set_theme", theme: "dark" }] })).rejects.toMatchObject({ code: "NOTE_CONFLICT" });
expect(f.saves).toHaveLength(0);
});
test("protects poster and infographic markers, including malformed payloads", async () => {
for (const kind of ["poster", "infographic", "diagram", "table"]) {
const f = setup(`Body\n<!-- edgeever-${kind}-v1:invalid -->`);
await expect(f.context.notes.update("note-1", { contentMarkdown: "oops" })).rejects.toMatchObject({ code: "STRUCTURED_NOTE_REQUIRED" });
expect(f.saves).toHaveLength(0);
}
});
});