import assert from 'node:assert/strict'; import test from 'node:test'; import { createCorePluginApi, type PluginApiSite } from '../src/pluginApi.ts'; import { CORE_GATES, PluginPermissionError, createPluginContext, parsePermissions, scopePluginApi } from '../src/permissions.ts'; const SITE: PluginApiSite = { name: 'Demo', product: 'wayBetter for Test', storagePrefix: 'demo-plugin:', styleDataKey: 'demoPlugin', toastId: 'demo-toast', toastBackground: '#111', itemSelector: 'article.message', chordKeys: ['g'], }; function fakeApi() { const noop = () => {}; const doc = { activeElement: null, documentElement: {}, addEventListener: noop, removeEventListener: noop, head: { appendChild(node: { isConnected: boolean }) { node.isConnected = true; } }, querySelectorAll: () => [], createElement: () => ({ dataset: {}, textContent: '', isConnected: false, remove() {} }), }; const store = new Map(); const win = { document: doc, location: { href: 'https://site.test/' }, addEventListener: noop, removeEventListener: noop, localStorage: { get length() { return store.size; }, key: (i: number) => [...store.keys()][i] ?? null, getItem: (k: string) => store.get(k) ?? null, setItem: (k: string, v: string) => store.set(k, v), removeItem: (k: string) => store.delete(k) }, navigator: { clipboard: { writeText: async () => {} } }, requestAnimationFrame: (fn: () => void) => { fn(); return 1; }, cancelAnimationFrame: noop, MutationObserver: class { observe() {} disconnect() {} }, }; const core = createCorePluginApi(win as unknown as Window & typeof globalThis, SITE); // A site adds its own gated members, like X's `account()` and `users`. const users = Object.freeze({ get: () => null, onUpdate: () => () => {} }); return Object.freeze(Object.assign(core, { account: () => ({ handle: 'me' }), users })); } const GATES = { ...CORE_GATES, account: 'account', users: 'users' } as const; test('parsePermissions mirrors the native header rules', () => { assert.deepEqual(parsePermissions('storage, ui clipboard,UI'), { permissions: ['storage', 'ui', 'clipboard'] }); assert.deepEqual(parsePermissions('network:none'), { permissions: ['network:none'] }); assert.deepEqual(parsePermissions('none'), { permissions: [] }); assert.match((parsePermissions('storage, cookies') as { error: string }).error, /unknown permission "cookies"/); assert.match((parsePermissions('network, network:none') as { error: string }).error, /contradict/); }); test('undeclared members throw a clear PluginPermissionError; declared ones work', async () => { const api = scopePluginApi(fakeApi(), ['storage'], 'notes.plugin.js', GATES); assert.equal(api.storage('n').set('a', 1), true, 'storage granted'); assert.equal(typeof api.injectCSS, 'function', 'DOM helpers need no permission'); assert.equal(typeof api.theme.current, 'function', 'theme needs no permission'); assert.throws(() => api.copyText('x'), (error: unknown) => error instanceof PluginPermissionError && error.permission === 'clipboard' && /notes\.plugin\.js: api\.copyText needs the "clipboard" permission/.test(error.message)); assert.throws(() => api.toast('hi'), /api\.toast needs the "ui" permission/); assert.throws(() => api.ui.confirm({ title: 'x' } as never), /api\.ui\.confirm needs the "ui" permission/); assert.throws(() => (api as unknown as { account(): unknown }).account(), /"account" permission/); assert.throws(() => (api as unknown as { users: { get(h: string): unknown } }).users.get('x'), /api\.users\.get needs the "users" permission/); assert.ok(Object.isFrozen(api), 'a plugin cannot patch its view'); }); test('scope().ui is gated like api.ui; the rest of the scope works and disposes', () => { const api = scopePluginApi(fakeApi(), [], 'plain.plugin.js', GATES); const scope = api.scope(); let disposed = 0; scope.defer(() => { disposed += 1; }); scope.css('x', 'a{}'); assert.throws(() => scope.ui.toast('hi'), /scope\(\)\.ui\.toast needs the "ui" permission/); scope.dispose(); assert.equal(disposed, 1); const withUi = scopePluginApi(fakeApi(), ['ui'], 'ui.plugin.js', GATES).scope(); assert.equal(typeof withUi.ui.toast, 'function'); }); test('the context hands each plugin its own view while its code runs; legacy plugins get the full API', () => { const full = fakeApi(); const context = createPluginContext(full, GATES); assert.equal(context.current(), full, 'outside any plugin: the full API'); const seen = context.run('a.plugin.js', ['clipboard'], () => context.current()); assert.notEqual(seen, full); assert.throws(() => seen.storage('x'), /a\.plugin\.js: api\.storage needs the "storage" permission/); // stop() runs later without grants: the registered view is reused. assert.equal(context.run('a.plugin.js', undefined, () => context.current()), seen); assert.deepEqual(context.grants('a.plugin.js'), ['clipboard']); // Nested: a plugin started from inside another's code gets its own view, then the outer one returns. context.run('outer.plugin.js', ['ui'], () => { const outer = context.current(); assert.equal(context.run('legacy.plugin.js', null, () => context.current()), full, 'legacy = full API'); assert.equal(context.current(), outer); }); assert.equal(context.grants('legacy.plugin.js'), null); assert.equal(context.current(), full); // An exception inside a plugin never leaves its view installed. assert.throws(() => context.run('boom.plugin.js', [], () => { throw new Error('boom'); }), /boom/); assert.equal(context.current(), full); // Unknown grant names are dropped rather than trusted. context.run('odd.plugin.js', ['storage', 'root'], () => {}); assert.deepEqual(context.grants('odd.plugin.js'), ['storage']); });