[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
343
electron/plugins/pluginViewHost.test.cjs
Normal file
343
electron/plugins/pluginViewHost.test.cjs
Normal file
@@ -0,0 +1,343 @@
|
||||
"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",
|
||||
}]);
|
||||
});
|
||||
Reference in New Issue
Block a user