[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:
683
electron/plugins/contributionService.test.cjs
Normal file
683
electron/plugins/contributionService.test.cjs
Normal file
@@ -0,0 +1,683 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { assertJsonValue, MAX_SETTING_BYTES, PluginContributionService } = require("./contributionService.cjs");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
|
||||
function manifest(id = "com.example.contributions", activationEvents = []) {
|
||||
return {
|
||||
manifestVersion: 1,
|
||||
id,
|
||||
name: "contributions",
|
||||
displayName: { en: "Contributions", "zh-CN": "贡献" },
|
||||
version: "1.0.0",
|
||||
publisher: "example",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" },
|
||||
main: { browser: "index.js" },
|
||||
activationEvents,
|
||||
contributes: {
|
||||
settings: [{
|
||||
id: `${id}.greeting`,
|
||||
label: { en: "Greeting", "zh-CN": "问候" },
|
||||
control: "text",
|
||||
scope: "application",
|
||||
default: "hello",
|
||||
}, {
|
||||
id: `${id}.token`,
|
||||
label: "Token",
|
||||
control: "password",
|
||||
scope: "application",
|
||||
secret: true,
|
||||
}],
|
||||
commands: [{ id: `${id}.hello`, title: { en: "Say hello", "zh-CN": "打招呼" }, enablement: `${id}.ready` }],
|
||||
menus: [{ command: `${id}.hello`, location: "commandPalette", when: `${id}.visible` }],
|
||||
keybindings: [{ command: `${id}.hello`, key: "ctrl+shift+h", when: `${id}.visible` }],
|
||||
views: [{ id: `${id}.view`, title: "Example view", location: "aside", entry: "view.html" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installPlugin(database, pluginManifest) {
|
||||
database.installVersion({
|
||||
pluginId: pluginManifest.id,
|
||||
version: pluginManifest.version,
|
||||
manifest: pluginManifest,
|
||||
archiveSha256: "a".repeat(64),
|
||||
packageRelativePath: `${pluginManifest.id}/${pluginManifest.version}/package`,
|
||||
}, { enable: true });
|
||||
}
|
||||
|
||||
function setup(context, pluginManifest, options = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-contributions-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const database = new PluginDatabase(path.join(root, "plugins.sqlite"));
|
||||
context.after(() => database.close());
|
||||
installPlugin(database, pluginManifest);
|
||||
const secrets = new Map();
|
||||
const secretStore = {
|
||||
getReference(pluginId, key) { return secrets.has(`${pluginId}:${key}`) ? secrets.get(`${pluginId}:${key}`).ref : null; },
|
||||
set(pluginId, key, value) {
|
||||
const ref = { kind: "secret", id: `secret-${"x".repeat(24)}`, key };
|
||||
secrets.set(`${pluginId}:${key}`, { ref, value });
|
||||
return ref;
|
||||
},
|
||||
delete(pluginId, key) { secrets.delete(`${pluginId}:${key}`); },
|
||||
};
|
||||
const calls = [];
|
||||
const runtimeSupervisor = options.runtimeSupervisor ?? {
|
||||
async start(pluginId) {
|
||||
calls.push(`start:${pluginId}`);
|
||||
return { runtimeId: `runtime:${pluginId}`, pluginVersion: pluginManifest.version };
|
||||
},
|
||||
async request(pluginId, method, params) { calls.push(`request:${pluginId}:${method}`); return params; },
|
||||
async notify(pluginId, method) { calls.push(`notify:${pluginId}:${method}`); },
|
||||
getRuntimeIdentity(pluginId) {
|
||||
return { runtimeId: `runtime:${pluginId}`, pluginVersion: pluginManifest.version };
|
||||
},
|
||||
};
|
||||
const service = new PluginContributionService({
|
||||
database,
|
||||
runtimeSupervisor,
|
||||
secretStore,
|
||||
getLocale: () => "zh-CN",
|
||||
});
|
||||
return { calls, database, secretStore, service, secrets };
|
||||
}
|
||||
|
||||
test("plugin contribution values enforce the exact JSON value boundary", () => {
|
||||
assert.deepEqual(assertJsonValue({ nested: [null, true, 1, "ok"] }), {
|
||||
nested: [null, true, 1, "ok"],
|
||||
});
|
||||
for (const value of [new Date(), new Map(), Number.NaN, Number.POSITIVE_INFINITY, undefined]) {
|
||||
assert.throws(() => assertJsonValue(value), /bounded JSON value/u);
|
||||
}
|
||||
});
|
||||
|
||||
test("only onStartupFinished plugins activate during contribution initialization", async (context) => {
|
||||
const pluginManifest = manifest("com.example.startup", ["onStartupFinished"]);
|
||||
const { calls, service } = setup(context, pluginManifest);
|
||||
await service.initialize();
|
||||
assert.deepEqual(calls, ["start:com.example.startup"]);
|
||||
|
||||
const lazyManifest = manifest("com.example.lazy", ["onCommand:com.example.lazy.hello"]);
|
||||
const lazy = setup(context, lazyManifest);
|
||||
await lazy.service.initialize();
|
||||
assert.deepEqual(lazy.calls, []);
|
||||
});
|
||||
|
||||
test("commands activate lazily and Context Keys are evaluated by the host", async (context) => {
|
||||
const pluginManifest = manifest(undefined, ["onCommand:com.example.contributions.hello"]);
|
||||
const { calls, service } = setup(context, pluginManifest);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
const transport = { signal: new AbortController().signal };
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.ready`, value: true }, transport);
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.visible`, value: true }, transport);
|
||||
|
||||
const snapshot = service.snapshot();
|
||||
assert.equal(snapshot.plugins[0].displayName, "贡献");
|
||||
assert.equal(snapshot.plugins[0].commands[0].enabled, true);
|
||||
assert.equal(snapshot.plugins[0].menus[0].visible, true);
|
||||
assert.equal(snapshot.plugins[0].menus[0].checked, undefined);
|
||||
assert.equal(snapshot.plugins[0].menus[0].shortcut, "ctrl+shift+h");
|
||||
assert.equal(snapshot.plugins[0].keybindings[0].enabled, true);
|
||||
|
||||
const result = await service.executeCommand(`${pluginManifest.id}.hello`, { name: "Catty" });
|
||||
assert.equal(result.command, `${pluginManifest.id}.hello`);
|
||||
assert.deepEqual(calls.slice(-2), [
|
||||
`start:${pluginManifest.id}`,
|
||||
`request:${pluginManifest.id}:plugin.command.execute`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("Context Keys remain available without an unrelated menus permission", async (context) => {
|
||||
const pluginManifest = manifest("com.example.command-only", ["onCommand:com.example.command-only.hello"]);
|
||||
pluginManifest.permissions = { required: ["commands"] };
|
||||
pluginManifest.contributes.settings = [];
|
||||
pluginManifest.contributes.menus = [];
|
||||
pluginManifest.contributes.views = [];
|
||||
const { database, service } = setup(context, pluginManifest);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registry.use(new PluginPermissionEngine({ database }).createMiddleware());
|
||||
service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-command-only",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
|
||||
await routes.requestHandlers["contextKeys.set"]({
|
||||
key: `${pluginManifest.id}.ready`,
|
||||
value: true,
|
||||
}, { signal: new AbortController().signal });
|
||||
|
||||
assert.equal(service.snapshot().plugins[0].commands[0].enabled, true);
|
||||
await assert.rejects(
|
||||
routes.requestHandlers["contextKeys.set"]({
|
||||
key: "com.example.other.ready",
|
||||
value: true,
|
||||
}, { signal: new AbortController().signal }),
|
||||
/must be namespaced to/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("Context Key ownership stays exact for plugin IDs that share a prefix", async (context) => {
|
||||
const shorter = manifest("com.example");
|
||||
const longer = manifest("com.example.foo");
|
||||
const { database, service } = setup(context, shorter);
|
||||
installPlugin(database, longer);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service.registerRpcCapabilities(registry);
|
||||
const transport = { signal: new AbortController().signal };
|
||||
const longerRoutes = registry.createRoutes({
|
||||
pluginId: longer.id,
|
||||
pluginVersion: longer.version,
|
||||
runtimeId: "runtime-longer",
|
||||
runtimeKind: "browser",
|
||||
manifest: longer,
|
||||
});
|
||||
const shorterRoutes = registry.createRoutes({
|
||||
pluginId: shorter.id,
|
||||
pluginVersion: shorter.version,
|
||||
runtimeId: "runtime-shorter",
|
||||
runtimeKind: "browser",
|
||||
manifest: shorter,
|
||||
});
|
||||
|
||||
await longerRoutes.requestHandlers["contextKeys.set"]({
|
||||
key: `${longer.id}.ready`,
|
||||
value: true,
|
||||
}, transport);
|
||||
await assert.rejects(
|
||||
shorterRoutes.requestHandlers["contextKeys.set"]({
|
||||
key: `${longer.id}.ready`,
|
||||
value: false,
|
||||
}, transport),
|
||||
/must be namespaced to/u,
|
||||
);
|
||||
|
||||
service.onRuntimeStateChanged({ pluginId: shorter.id, status: "stopped" });
|
||||
const longerSnapshot = service.snapshot().plugins.find((plugin) => plugin.id === longer.id);
|
||||
assert.equal(longerSnapshot.commands[0].enabled, true);
|
||||
});
|
||||
|
||||
test("secret settings enforce patterns and size bounds before safeStorage", async (context) => {
|
||||
const pluginManifest = manifest("com.example.secret-constraints");
|
||||
const secretSetting = pluginManifest.contributes.settings.find((setting) => setting.secret);
|
||||
secretSetting.pattern = "^[A-Z]{4}$";
|
||||
const { secrets, service } = setup(context, pluginManifest);
|
||||
|
||||
await service.updateSetting(pluginManifest.id, secretSetting.id, "ABCD");
|
||||
assert.equal([...secrets.values()][0].value, "ABCD");
|
||||
await assert.rejects(
|
||||
service.updateSetting(pluginManifest.id, secretSetting.id, "invalid"),
|
||||
/does not match its pattern/u,
|
||||
);
|
||||
assert.equal([...secrets.values()][0].value, "ABCD");
|
||||
|
||||
delete secretSetting.pattern;
|
||||
await assert.rejects(
|
||||
service.updateSetting(pluginManifest.id, secretSetting.id, "x".repeat(MAX_SETTING_BYTES)),
|
||||
/bounded JSON value/u,
|
||||
);
|
||||
assert.equal([...secrets.values()][0].value, "ABCD");
|
||||
});
|
||||
|
||||
test("host invocation context cannot override plugin-owned Context Keys", async (context) => {
|
||||
const pluginManifest = manifest(undefined, ["onCommand:com.example.contributions.hello"]);
|
||||
const { service } = setup(context, pluginManifest);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
await routes.requestHandlers["contextKeys.set"]({
|
||||
key: `${pluginManifest.id}.ready`,
|
||||
value: false,
|
||||
}, { signal: new AbortController().signal });
|
||||
|
||||
await assert.rejects(service.executeCommand(`${pluginManifest.id}.hello`, undefined, {
|
||||
context: { [`${pluginManifest.id}.ready`]: true },
|
||||
}), /command is disabled/u);
|
||||
});
|
||||
|
||||
test("quarantined plugins are excluded from snapshots and contribution lookup", async (context) => {
|
||||
const pluginManifest = manifest(undefined, ["onCommand:com.example.contributions.hello"]);
|
||||
const { database, service } = setup(context, pluginManifest);
|
||||
database.recordCrash(pluginManifest.id, pluginManifest.version, 300_000, 3);
|
||||
database.recordCrash(pluginManifest.id, pluginManifest.version, 300_000, 3);
|
||||
database.recordCrash(pluginManifest.id, pluginManifest.version, 300_000, 3);
|
||||
|
||||
assert.deepEqual(service.snapshot().plugins, []);
|
||||
await assert.rejects(
|
||||
service.executeCommand(`${pluginManifest.id}.hello`),
|
||||
/contribution was not found/u,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.getSetting(pluginManifest.id, `${pluginManifest.id}.greeting`),
|
||||
/Plugin is not enabled/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime quarantine clears owned Context Keys and publishes contribution removal", async (context) => {
|
||||
const pluginManifest = manifest();
|
||||
const { database, service } = setup(context, pluginManifest);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
const transport = { signal: new AbortController().signal };
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.ready`, value: true }, transport);
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.visible`, value: true }, transport);
|
||||
const changes = [];
|
||||
service.onDidChange((event) => changes.push(event));
|
||||
|
||||
database.recordCrash(pluginManifest.id, pluginManifest.version, 300_000, 3);
|
||||
database.recordCrash(pluginManifest.id, pluginManifest.version, 300_000, 3);
|
||||
database.recordCrash(pluginManifest.id, pluginManifest.version, 300_000, 3);
|
||||
service.onRuntimeStateChanged({
|
||||
pluginId: pluginManifest.id,
|
||||
status: "quarantined",
|
||||
});
|
||||
|
||||
assert.equal(changes.at(-1).reason, "runtime-quarantined");
|
||||
assert.equal(changes.at(-1).pluginId, pluginManifest.id);
|
||||
assert.deepEqual(service.snapshot().plugins, []);
|
||||
database.clearQuarantine(pluginManifest.id, pluginManifest.version);
|
||||
const snapshot = service.snapshot();
|
||||
assert.equal(snapshot.plugins[0].commands[0].enabled, false);
|
||||
assert.equal(snapshot.plugins[0].menus[0].visible, false);
|
||||
});
|
||||
|
||||
test("every terminal runtime state clears runtime-owned Context Keys", async (context) => {
|
||||
const pluginManifest = manifest();
|
||||
const { service } = setup(context, pluginManifest);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-terminal",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
const changes = [];
|
||||
service.onDidChange((event) => changes.push(event));
|
||||
for (const status of ["stopped", "error"]) {
|
||||
await routes.requestHandlers["contextKeys.set"]({
|
||||
key: `${pluginManifest.id}.ready`,
|
||||
value: true,
|
||||
}, { signal: new AbortController().signal });
|
||||
assert.equal(service.snapshot().plugins[0].commands[0].enabled, true);
|
||||
|
||||
service.onRuntimeStateChanged({ pluginId: pluginManifest.id, status });
|
||||
|
||||
assert.equal(changes.at(-1).reason, `runtime-${status}`);
|
||||
assert.equal(changes.at(-1).pluginId, pluginManifest.id);
|
||||
assert.equal(service.snapshot().plugins[0].commands[0].enabled, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("menu and keybinding enablement include the target command state", async (context) => {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.contributes.menus[0].enablement = `${pluginManifest.id}.paletteEnabled`;
|
||||
const { service } = setup(context, pluginManifest);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
const transport = { signal: new AbortController().signal };
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.visible`, value: true }, transport);
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.ready`, value: true }, transport);
|
||||
|
||||
let snapshot = service.snapshot();
|
||||
assert.equal(snapshot.plugins[0].commands[0].enabled, true);
|
||||
assert.equal(snapshot.plugins[0].menus[0].enabled, false);
|
||||
assert.equal(snapshot.plugins[0].keybindings[0].enabled, true);
|
||||
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.ready`, value: false }, transport);
|
||||
snapshot = service.snapshot();
|
||||
assert.equal(snapshot.plugins[0].keybindings[0].enabled, false);
|
||||
});
|
||||
|
||||
test("menu shortcut display uses the current platform and honors explicit suppression", async (context) => {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.contributes.keybindings[0].mac = "cmd+shift+h";
|
||||
const { service } = setup(context, pluginManifest);
|
||||
|
||||
assert.equal(service.snapshot({ platform: "darwin" }).plugins[0].menus[0].shortcut, undefined);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "browser",
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
const transport = { signal: new AbortController().signal };
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.visible`, value: true }, transport);
|
||||
await routes.requestHandlers["contextKeys.set"]({ key: `${pluginManifest.id}.ready`, value: true }, transport);
|
||||
|
||||
const visible = service.snapshot({ platform: "darwin" });
|
||||
assert.equal(visible.plugins[0].menus[0].shortcut, "cmd+shift+h");
|
||||
|
||||
const suppressedManifest = manifest("com.example.shortcut-suppressed");
|
||||
delete suppressedManifest.contributes.commands[0].enablement;
|
||||
delete suppressedManifest.contributes.keybindings[0].when;
|
||||
suppressedManifest.contributes.menus[0].showKeybinding = false;
|
||||
const suppressed = setup(context, suppressedManifest);
|
||||
assert.equal(suppressed.service.snapshot({ platform: "darwin" }).plugins[0].menus[0].shortcut, undefined);
|
||||
});
|
||||
|
||||
test("surface-specific view conditions are evaluated from the requesting host context", (context) => {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.contributes.views[0].when = "netcatty.surface == 'settings'";
|
||||
const { service } = setup(context, pluginManifest);
|
||||
|
||||
assert.equal(service.snapshot().plugins[0].views[0].visible, false);
|
||||
assert.equal(service.snapshot({
|
||||
context: { "netcatty.surface": "commandPalette" },
|
||||
}).plugins[0].views[0].visible, false);
|
||||
assert.equal(service.snapshot({
|
||||
context: { "netcatty.surface": "settings" },
|
||||
}).plugins[0].views[0].visible, true);
|
||||
});
|
||||
|
||||
test("menu placements use their own source-specific host contexts", (context) => {
|
||||
const pluginManifest = manifest("com.example.menu-contexts");
|
||||
pluginManifest.contributes.commands = [{
|
||||
id: `${pluginManifest.id}.toolbar`,
|
||||
title: "Toolbar command",
|
||||
enablement: "netcatty.surface == 'terminal/toolbar' && terminal.sessionId == 'session-1'",
|
||||
}, {
|
||||
id: `${pluginManifest.id}.status`,
|
||||
title: "Status command",
|
||||
enablement: "netcatty.surface == 'statusBar' && terminal.sessionId == 'session-1'",
|
||||
}];
|
||||
pluginManifest.contributes.menus = [{
|
||||
command: `${pluginManifest.id}.toolbar`,
|
||||
location: "terminal/toolbar",
|
||||
when: "netcatty.surface == 'terminal/toolbar'",
|
||||
}, {
|
||||
command: `${pluginManifest.id}.status`,
|
||||
location: "statusBar",
|
||||
when: "netcatty.surface == 'statusBar'",
|
||||
}];
|
||||
const { service } = setup(context, pluginManifest);
|
||||
const toolbarContext = { "netcatty.surface": "terminal/toolbar", "terminal.sessionId": "session-1" };
|
||||
const statusBarContext = { "netcatty.surface": "statusBar", "terminal.sessionId": "session-1" };
|
||||
|
||||
const snapshot = service.snapshot({
|
||||
context: toolbarContext,
|
||||
menuContexts: {
|
||||
"terminal/toolbar": toolbarContext,
|
||||
statusBar: statusBarContext,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(snapshot.plugins[0].menus.map((menu) => ({
|
||||
command: menu.command,
|
||||
visible: menu.visible,
|
||||
enabled: menu.enabled,
|
||||
})), [{
|
||||
command: `${pluginManifest.id}.toolbar`,
|
||||
visible: true,
|
||||
enabled: true,
|
||||
}, {
|
||||
command: `${pluginManifest.id}.status`,
|
||||
visible: true,
|
||||
enabled: true,
|
||||
}]);
|
||||
});
|
||||
|
||||
test("lazy activation receives the latest host environment before its first contribution call", async (context) => {
|
||||
const pluginManifest = manifest("com.example.environment", ["onView:com.example.environment.view"]);
|
||||
const calls = [];
|
||||
const runtimeSupervisor = {
|
||||
async start(pluginId) {
|
||||
calls.push(["start", pluginId]);
|
||||
return { runtimeId: "runtime-environment-1" };
|
||||
},
|
||||
async notify(pluginId, method, params) {
|
||||
calls.push(["notify", pluginId, method, params]);
|
||||
},
|
||||
async request() { return null; },
|
||||
getRuntimeIdentity() { return { runtimeId: "runtime-environment-1", pluginVersion: pluginManifest.version }; },
|
||||
};
|
||||
const { service } = setup(context, pluginManifest, { runtimeSupervisor });
|
||||
const environment = { locale: "zh-CN", theme: "dark", reducedMotion: true, highContrast: false };
|
||||
await service.setEnvironment(environment);
|
||||
assert.deepEqual(service.getEnvironment(), environment);
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
await service.activateView(`${pluginManifest.id}.view`);
|
||||
assert.deepEqual(calls, [
|
||||
["start", pluginManifest.id],
|
||||
["notify", pluginManifest.id, "plugin.environment.changed", environment],
|
||||
]);
|
||||
});
|
||||
|
||||
test("view activation identity fails closed after version or runtime replacement", async (context) => {
|
||||
const pluginManifest = manifest("com.example.view-identity", ["onView:com.example.view-identity.view"]);
|
||||
let runtimeId = "runtime-view-1";
|
||||
const runtimeSupervisor = {
|
||||
async start() { return { runtimeId, pluginVersion: pluginManifest.version }; },
|
||||
async request() { return null; },
|
||||
async notify() {},
|
||||
getRuntimeIdentity() { return { runtimeId, pluginVersion: pluginManifest.version }; },
|
||||
};
|
||||
const { database, service } = setup(context, pluginManifest, { runtimeSupervisor });
|
||||
const activated = await service.activateView(`${pluginManifest.id}.view`);
|
||||
const expected = {
|
||||
pluginId: activated.plugin.id,
|
||||
pluginVersion: activated.plugin.activeVersion,
|
||||
viewId: activated.view.id,
|
||||
runtimeId: activated.identity.runtimeId,
|
||||
};
|
||||
assert.equal(service.assertViewActivationCurrent(expected).view.id, activated.view.id);
|
||||
|
||||
runtimeId = "runtime-view-2";
|
||||
assert.throws(() => service.assertViewActivationCurrent(expected), /runtime ownership changed/u);
|
||||
|
||||
runtimeId = "runtime-view-1";
|
||||
database.setEnabled(pluginManifest.id, false);
|
||||
assert.throws(() => service.assertViewActivationCurrent(expected), /contribution was not found/u);
|
||||
});
|
||||
|
||||
test("settings validate declared controls and secrets never enter settings storage", async (context) => {
|
||||
const pluginManifest = manifest();
|
||||
const { database, secrets, service } = setup(context, pluginManifest);
|
||||
assert.equal(await service.getSetting(pluginManifest.id, `${pluginManifest.id}.greeting`), "hello");
|
||||
await service.updateSetting(pluginManifest.id, `${pluginManifest.id}.greeting`, "bonjour");
|
||||
assert.equal(await service.getSetting(pluginManifest.id, `${pluginManifest.id}.greeting`), "bonjour");
|
||||
await assert.rejects(
|
||||
service.updateSetting(pluginManifest.id, `${pluginManifest.id}.greeting`, 42),
|
||||
/must be a string/u,
|
||||
);
|
||||
|
||||
await service.updateSetting(pluginManifest.id, `${pluginManifest.id}.token`, "plaintext");
|
||||
const reference = await service.getSetting(pluginManifest.id, `${pluginManifest.id}.token`);
|
||||
assert.equal(reference.kind, "secret");
|
||||
assert.equal(database.listSettings(pluginManifest.id).some((entry) => entry.settingId.endsWith(".token")), false);
|
||||
assert.equal([...secrets.values()][0].value, "plaintext");
|
||||
await service.resetSetting(pluginManifest.id, `${pluginManifest.id}.greeting`);
|
||||
assert.equal(await service.getSetting(pluginManifest.id, `${pluginManifest.id}.greeting`), "hello");
|
||||
});
|
||||
|
||||
test("numeric setting writes enforce the declared range and step", async (context) => {
|
||||
const pluginManifest = manifest("com.example.numeric-setting");
|
||||
pluginManifest.contributes.settings.push({
|
||||
id: `${pluginManifest.id}.opacity`,
|
||||
label: "Opacity",
|
||||
control: "slider",
|
||||
scope: "application",
|
||||
minimum: 0.1,
|
||||
maximum: 1,
|
||||
step: 0.1,
|
||||
default: 0.5,
|
||||
});
|
||||
const { service } = setup(context, pluginManifest);
|
||||
|
||||
await service.updateSetting(pluginManifest.id, `${pluginManifest.id}.opacity`, 0.3);
|
||||
await assert.rejects(
|
||||
service.updateSetting(pluginManifest.id, `${pluginManifest.id}.opacity`, 0.35),
|
||||
/align to its step/u,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.updateSetting(pluginManifest.id, `${pluginManifest.id}.opacity`, 1.1),
|
||||
/above its maximum/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("contextual settings expose values only for an explicit host-owned scope", async (context) => {
|
||||
const pluginManifest = manifest("com.example.scoped-settings");
|
||||
pluginManifest.contributes.settings.push({
|
||||
id: `${pluginManifest.id}.workspace-label`,
|
||||
label: "Workspace label",
|
||||
control: "text",
|
||||
scope: "workspace",
|
||||
default: "default-label",
|
||||
});
|
||||
const { service } = setup(context, pluginManifest);
|
||||
await service.updateSetting(
|
||||
pluginManifest.id,
|
||||
`${pluginManifest.id}.workspace-label`,
|
||||
"production",
|
||||
"workspace-1",
|
||||
);
|
||||
|
||||
const withoutScope = service.snapshot().plugins[0].settings
|
||||
.find((setting) => setting.id.endsWith(".workspace-label"));
|
||||
assert.equal(withoutScope.scopeId, null);
|
||||
assert.equal(withoutScope.value, "default-label");
|
||||
|
||||
const withScope = service.snapshot({ scopeIds: { workspace: "workspace-1" } }).plugins[0].settings
|
||||
.find((setting) => setting.id.endsWith(".workspace-label"));
|
||||
assert.equal(withScope.scopeId, "workspace-1");
|
||||
assert.equal(withScope.value, "production");
|
||||
});
|
||||
|
||||
test("user settings and view state survive package removal", async (context) => {
|
||||
const pluginManifest = manifest();
|
||||
const { database, service } = setup(context, pluginManifest);
|
||||
await service.updateSetting(pluginManifest.id, `${pluginManifest.id}.greeting`, "preserved");
|
||||
database.setViewState(pluginManifest.id, `${pluginManifest.id}.view`, "window-1", { selected: 2 });
|
||||
database.removePlugin(pluginManifest.id);
|
||||
assert.equal(database.getSetting(pluginManifest.id, `${pluginManifest.id}.greeting`, "application", "application"), "preserved");
|
||||
assert.deepEqual(database.getViewState(pluginManifest.id, `${pluginManifest.id}.view`, "window-1"), { selected: 2 });
|
||||
});
|
||||
|
||||
test("structured settings use the host restricted schema and unsafe text patterns fail closed", async (context) => {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.contributes.settings.push({
|
||||
id: `${pluginManifest.id}.servers`,
|
||||
label: "Servers",
|
||||
control: "table",
|
||||
scope: "application",
|
||||
valueSchema: {
|
||||
type: "array",
|
||||
maxItems: 4,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", minLength: 1, maxLength: 32 },
|
||||
port: { type: "integer", minimum: 1, maximum: 65_535 },
|
||||
},
|
||||
required: ["name", "port"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
id: `${pluginManifest.id}.patterned`,
|
||||
label: "Patterned",
|
||||
control: "text",
|
||||
scope: "application",
|
||||
pattern: "^[a-z]+$",
|
||||
}, {
|
||||
id: `${pluginManifest.id}.unsafe-pattern`,
|
||||
label: "Unsafe pattern",
|
||||
control: "text",
|
||||
scope: "application",
|
||||
pattern: "^(a+)+$",
|
||||
});
|
||||
const { service } = setup(context, pluginManifest);
|
||||
await service.updateSetting(pluginManifest.id, `${pluginManifest.id}.servers`, [{ name: "ssh", port: 22 }]);
|
||||
await assert.rejects(
|
||||
service.updateSetting(pluginManifest.id, `${pluginManifest.id}.servers`, [{ name: "ssh", port: 70_000 }]),
|
||||
/does not match/u,
|
||||
);
|
||||
await service.updateSetting(pluginManifest.id, `${pluginManifest.id}.patterned`, "netcatty");
|
||||
await assert.rejects(
|
||||
service.updateSetting(pluginManifest.id, `${pluginManifest.id}.unsafe-pattern`, "aaaa"),
|
||||
/unsafe pattern/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("Provider contributions expose a PR 5 lazy activation seam", async (context) => {
|
||||
const pluginManifest = manifest("com.example.provider", ["onProvider:com.example.provider.completion"]);
|
||||
pluginManifest.contributes.providers = [{
|
||||
id: "com.example.provider.completion",
|
||||
label: { en: "Completion", "zh-CN": "补全" },
|
||||
description: { en: "Complete input", "zh-CN": "补全输入" },
|
||||
kind: "terminal.completion",
|
||||
}, {
|
||||
id: "com.example.provider.connection",
|
||||
label: "Connection",
|
||||
kind: "connection",
|
||||
}];
|
||||
const calls = [];
|
||||
const identity = { pluginId: pluginManifest.id, runtimeId: "runtime-provider-1" };
|
||||
const { service } = setup(context, pluginManifest, {
|
||||
runtimeSupervisor: {
|
||||
async start(pluginId) { calls.push(`start:${pluginId}`); return identity; },
|
||||
async notify() {},
|
||||
async request() { return null; },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(service.listProviders({ kind: "terminal.completion" }), [{
|
||||
pluginId: pluginManifest.id,
|
||||
pluginVersion: pluginManifest.version,
|
||||
pluginDisplayName: "贡献",
|
||||
provider: {
|
||||
id: "com.example.provider.completion",
|
||||
label: "补全",
|
||||
description: "补全输入",
|
||||
kind: "terminal.completion",
|
||||
},
|
||||
}]);
|
||||
const result = await service.activateProvider("com.example.provider.completion");
|
||||
assert.equal(result.provider.id, "com.example.provider.completion");
|
||||
assert.deepEqual(result.identity, identity);
|
||||
assert.deepEqual(calls, ["start:com.example.provider"]);
|
||||
});
|
||||
Reference in New Issue
Block a user