"use strict"; const assert = require("node:assert/strict"); const { EventEmitter } = require("node:events"); const test = require("node:test"); const { PluginViewHost, normalizeBounds } = require("./pluginViewHost.cjs"); function fixture(options = {}) { const ipcHandlers = new Map(); const senderOwners = new Map(); const sessions = []; const views = []; let nextContentsId = 10; class FakeContents extends EventEmitter { constructor() { super(); this.id = nextContentsId++; this.sent = []; this.destroyed = false; } setWindowOpenHandler(handler) { this.windowOpenHandler = handler; } async loadURL(url) { this.loadedUrl = url; if (options.loadURL) await options.loadURL(this); } send(channel, payload) { this.sent.push([channel, payload]); } isDestroyed() { return this.destroyed; } close() { this.destroyed = true; this.emit("destroyed"); } } class FakeWebContentsView { constructor(optionsValue) { this.options = optionsValue; this.webContents = new FakeContents(); views.push(this); } setBounds(bounds) { this.bounds = bounds; } setVisible(visible) { this.visible = visible; } } const electron = { ipcMain: { handle(channel, handler) { ipcHandlers.set(channel, handler); } }, BrowserWindow: { fromWebContents(sender) { return senderOwners.get(sender) ?? null; } }, WebContentsView: FakeWebContentsView, session: { fromPartition(partition, sessionOptions) { const pluginSession = new EventEmitter(); pluginSession.partition = partition; pluginSession.options = sessionOptions; pluginSession.permissions = {}; pluginSession.webRequest = { onBeforeRequest(handler) { pluginSession.beforeRequest = handler; }, }; pluginSession.setProxy = options.setProxy ?? (async (proxy) => { pluginSession.proxy = proxy; }); pluginSession.setPermissionCheckHandler = (handler) => { pluginSession.permissions.check = handler; }; pluginSession.setPermissionRequestHandler = (handler) => { pluginSession.permissions.request = handler; }; pluginSession.setDevicePermissionHandler = (handler) => { pluginSession.permissions.device = handler; }; sessions.push(pluginSession); return pluginSession; }, }, }; const disposals = []; const protocol = { registerView() { return { token: "view-token", url: "netcatty-plugin://view-token/package/view.html", dispose() { disposals.push("view"); if (options.disposeViewThrows) throw new Error("view cleanup failed"); }, }; }, registerSession() { return { dispose() { disposals.push("session"); if (options.disposeSessionThrows) throw new Error("session cleanup failed"); } }; }, }; let viewListener; let changeListener; let activationCurrent = true; let activationChecks = 0; const contributionService = { runtimeSupervisor: { async notify(...args) { return args; } }, onDidPostViewMessage(listener) { viewListener = listener; return { dispose() {} }; }, onDidChange(listener) { changeListener = listener; return { dispose() {} }; }, async activateView() { return { plugin: { id: "com.example.view", activeVersion: "1.0.0" }, view: { id: "com.example.view.panel", entry: "view.html", location: "aside" }, identity: { runtimeId: "runtime-view-1", pluginVersion: "1.0.0" }, }; }, assertViewActivationCurrent(expected) { activationChecks += 1; if (!activationCurrent) throw new Error("Plugin view ownership changed while opening"); assert.deepEqual(expected, { pluginId: "com.example.view", pluginVersion: "1.0.0", viewId: "com.example.view.panel", runtimeId: "runtime-view-1", }); return expected; }, async executeCommand(command, args, invocation) { return { command, args, invocation }; }, }; const state = new Map(); const host = new PluginViewHost({ electron, protocol, packageStore: { async preparePackageRoot() { if (options.preparePackageRoot) await options.preparePackageRoot(); return "/package"; }, }, database: { getViewState(pluginId, viewId, scopeId) { return state.get(`${pluginId}:${viewId}:${scopeId}`); }, setViewState(pluginId, viewId, scopeId, value) { state.set(`${pluginId}:${viewId}:${scopeId}`, value); }, }, contributionService, preloadPath: "/runtime/viewPreload.cjs", }); function createOwner() { const owner = new EventEmitter(); owner.children = []; owner.isDestroyed = () => false; owner.contentView = { addChildView(view) { owner.children.push(view); }, removeChildView(view) { owner.children = owner.children.filter((entry) => entry !== view); }, }; const sender = {}; senderOwners.set(sender, owner); return { owner, sender }; } return { changeListener: () => changeListener, contributionService, createOwner, disposals, host, ipcHandlers, sessions, activationChecks: () => activationChecks, setActivationCurrent(value) { activationCurrent = value; }, viewListener: () => viewListener, views, }; } test("plugin view bounds reject unsafe or oversized geometry", () => { assert.deepEqual(normalizeBounds({ x: 1, y: 2, width: 640, height: 480 }), { x: 1, y: 2, width: 640, height: 480 }); assert.throws(() => normalizeBounds({ x: 0.5, y: 0, width: 1, height: 1 }), /safe integers/u); assert.throws(() => normalizeBounds({ x: 0, y: 0, width: 20_000, height: 1 }), /invalid/u); }); test("custom views use an ephemeral sandbox and deny direct browser capabilities", async () => { const value = fixture(); const { owner, sender } = value.createOwner(); const opened = await value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1", instanceId: "view-1", bounds: { x: 5, y: 6, width: 700, height: 500 }, }, sender); assert.deepEqual(opened, { instanceId: "view-1" }); const contentsView = value.views[0]; assert.deepEqual(contentsView.options.webPreferences, { sandbox: true, nodeIntegration: false, nodeIntegrationInSubFrames: false, nodeIntegrationInWorker: false, contextIsolation: true, webSecurity: true, devTools: false, disableDialogs: true, navigateOnDragDrop: false, session: value.sessions[0], preload: "/runtime/viewPreload.cjs", }); assert.match(value.sessions[0].partition, /^netcatty-plugin-view-/u); assert.deepEqual(value.sessions[0].options, { cache: false }); assert.equal(value.sessions[0].permissions.check(), false); assert.equal(value.sessions[0].permissions.device(), false); let permissionDecision; value.sessions[0].permissions.request(null, "camera", (decision) => { permissionDecision = decision; }); assert.equal(permissionDecision, false); let allowed; value.sessions[0].beforeRequest({ url: "netcatty-plugin://view-token/package/view.js" }, (decision) => { allowed = decision; }); assert.deepEqual(allowed, { cancel: false }); value.sessions[0].beforeRequest({ url: "https://example.com/track" }, (decision) => { allowed = decision; }); assert.deepEqual(allowed, { cancel: true }); assert.deepEqual(contentsView.webContents.windowOpenHandler(), { action: "deny" }); const navigation = { prevented: false, preventDefault() { this.prevented = true; } }; contentsView.webContents.emit("will-navigate", navigation, "https://example.com"); assert.equal(navigation.prevented, true); assert.deepEqual(contentsView.bounds, { x: 5, y: 6, width: 700, height: 500 }); assert.equal(owner.children.length, 1); assert.equal(value.activationChecks(), 2); const environment = { locale: "zh-CN", theme: "dark", reducedMotion: true, highContrast: false, themeTokens: { "--background": "220 10% 10%" }, }; value.host.setEnvironment(environment); assert.deepEqual(await value.ipcHandlers.get("netcatty-plugin-view:get-environment")( { sender: contentsView.webContents }, ), environment); value.host.setVisible("view-1", false, sender); assert.equal(contentsView.visible, false); assert.deepEqual(await value.ipcHandlers.get("netcatty-plugin-view:execute-command")( { sender: contentsView.webContents }, { command: "com.example.view.run", args: { selected: 1 } }, ), { command: "com.example.view.run", args: { selected: 1 }, invocation: { source: "view", callerPluginId: "com.example.view", context: { "netcatty.view": "com.example.view.panel" }, }, }); const other = value.createOwner(); assert.throws(() => value.host.setVisible("view-1", true, other.sender), /another window/u); assert.throws(() => value.host.setBounds("view-1", { x: 0, y: 0, width: 1, height: 1 }, other.sender), /another window/u); await assert.rejects(value.host.close("view-1", other.sender), /another window/u); owner.emit("closed"); await new Promise((resolve) => setImmediate(resolve)); assert.equal(owner.children.length, 0); assert.deepEqual(value.disposals.sort(), ["session", "view"]); }); test("view setup failures dispose protocol registrations before exposing an instance", async () => { const value = fixture({ setProxy: async () => { throw new Error("proxy unavailable"); } }); const { sender } = value.createOwner(); await assert.rejects(value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1", bounds: { x: 0, y: 0, width: 10, height: 10 }, }, sender), /proxy unavailable/u); assert.deepEqual(value.disposals.sort(), ["session", "view"]); assert.equal(value.views.length, 0); }); test("terminal runtime states close every open view owned by the plugin", async () => { for (const reason of ["runtime-stopped", "runtime-error", "runtime-quarantined"]) { const value = fixture(); const { owner, sender } = value.createOwner(); await value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1", instanceId: `view-${reason}`, bounds: { x: 0, y: 0, width: 320, height: 240 }, }, sender); assert.equal(owner.children.length, 1); value.changeListener()({ reason, pluginId: "com.example.view" }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(owner.children.length, 0); assert.deepEqual(value.disposals.sort(), ["session", "view"]); } }); test("plugin replacement during package preparation cannot publish a stale view", async () => { let releasePackage; const packageReady = new Promise((resolve) => { releasePackage = resolve; }); const value = fixture({ preparePackageRoot: () => packageReady }); const { owner, sender } = value.createOwner(); const opening = value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1", instanceId: "view-replaced", bounds: { x: 0, y: 0, width: 320, height: 240 }, }, sender); value.setActivationCurrent(false); releasePackage(); await assert.rejects(opening, /ownership changed/u); assert.equal(owner.children.length, 0); assert.equal(value.views.length, 0); }); test("runtime shutdown cancels an in-flight view before it attaches and publishes a close event", async () => { let releaseLoad; const loadReady = new Promise((resolve) => { releaseLoad = resolve; }); const value = fixture({ loadURL: () => loadReady }); const { owner, sender } = value.createOwner(); const closed = []; value.host.onDidClose((event) => closed.push(event)); const opening = value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1", instanceId: "view-opening", bounds: { x: 0, y: 0, width: 320, height: 240 }, }, sender); while (value.views.length === 0) await new Promise((resolve) => setImmediate(resolve)); assert.equal(owner.children.length, 0); value.changeListener()({ reason: "runtime-stopped", pluginId: "com.example.view" }); await new Promise((resolve) => setImmediate(resolve)); releaseLoad(); await assert.rejects(opening, /ownership changed/u); assert.equal(owner.children.length, 0); assert.equal(value.views[0].webContents.destroyed, true); assert.deepEqual(closed, [{ instanceId: "view-opening", pluginId: "com.example.view", viewId: "com.example.view.panel", reason: "runtime-stopped", }]); }); test("view cleanup failures cannot suppress owner removal or the renderer close signal", async () => { const value = fixture({ disposeViewThrows: true, disposeSessionThrows: true }); const { owner, sender } = value.createOwner(); const closed = []; value.host.onDidClose((event) => closed.push(event)); await value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1", instanceId: "view-cleanup-failure", bounds: { x: 0, y: 0, width: 320, height: 240 }, }, sender); await value.host.close("view-cleanup-failure", sender); assert.equal(owner.children.length, 0); assert.equal(value.views[0].webContents.destroyed, true); assert.deepEqual(value.disposals, ["view", "session"]); assert.deepEqual(closed, [{ instanceId: "view-cleanup-failure", pluginId: "com.example.view", viewId: "com.example.view.panel", reason: "renderer-requested", }]); });