684 lines
28 KiB
JavaScript
684 lines
28 KiB
JavaScript
|
|
"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"]);
|
||
|
|
});
|