433 lines
17 KiB
TypeScript
433 lines
17 KiB
TypeScript
|
|
import assert from 'node:assert/strict';
|
||
|
|
import test from 'node:test';
|
||
|
|
|
||
|
|
import {
|
||
|
|
collectPluginTerminalProviderKinds,
|
||
|
|
isPluginTerminalProviderKindAvailable,
|
||
|
|
PluginTerminalProviderAvailability,
|
||
|
|
PluginTerminalProviderRegistry,
|
||
|
|
} from './pluginTerminalProviderRegistry.ts';
|
||
|
|
|
||
|
|
const session: NetcattyTerminalSessionSnapshot = {
|
||
|
|
sessionId: 'session-1',
|
||
|
|
protocol: 'ssh',
|
||
|
|
status: 'connected',
|
||
|
|
};
|
||
|
|
|
||
|
|
test('terminal Provider availability fails closed before or after failed enumeration', async () => {
|
||
|
|
assert.equal(isPluginTerminalProviderKindAvailable(null, 'terminal.matcher'), false);
|
||
|
|
const registry = new PluginTerminalProviderRegistry({
|
||
|
|
async listPluginTerminalProviders() { throw new Error('bridge unavailable'); },
|
||
|
|
async providePluginTerminal() { return []; },
|
||
|
|
async cancelPluginTerminalRequest() { return false; },
|
||
|
|
async publishPluginTerminalSessionEvent() { return []; },
|
||
|
|
});
|
||
|
|
const kinds = await collectPluginTerminalProviderKinds(registry, [
|
||
|
|
'terminal.matcher',
|
||
|
|
'terminal.background',
|
||
|
|
]);
|
||
|
|
assert.equal(isPluginTerminalProviderKindAvailable(kinds, 'terminal.matcher'), false);
|
||
|
|
assert.equal(isPluginTerminalProviderKindAvailable(kinds, 'terminal.background'), false);
|
||
|
|
registry.dispose();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('terminal Provider availability ignores an older enumeration that finishes last', async () => {
|
||
|
|
let resolveOlder: ((providers: NetcattyTerminalProviderContribution[]) => void) | undefined;
|
||
|
|
const olderRegistry = new PluginTerminalProviderRegistry({
|
||
|
|
listPluginTerminalProviders() {
|
||
|
|
return new Promise((resolve) => { resolveOlder = resolve; });
|
||
|
|
},
|
||
|
|
async providePluginTerminal() { return []; },
|
||
|
|
async cancelPluginTerminalRequest() { return false; },
|
||
|
|
async publishPluginTerminalSessionEvent() { return []; },
|
||
|
|
});
|
||
|
|
const newerRegistry = new PluginTerminalProviderRegistry({
|
||
|
|
async listPluginTerminalProviders() {
|
||
|
|
return [{
|
||
|
|
pluginId: 'com.example.matcher',
|
||
|
|
pluginVersion: '1.0.0',
|
||
|
|
pluginDisplayName: 'Matcher',
|
||
|
|
provider: {
|
||
|
|
id: 'com.example.matcher.output',
|
||
|
|
label: 'Output matcher',
|
||
|
|
kind: 'terminal.matcher',
|
||
|
|
},
|
||
|
|
}];
|
||
|
|
},
|
||
|
|
async providePluginTerminal() { return []; },
|
||
|
|
async cancelPluginTerminalRequest() { return false; },
|
||
|
|
async publishPluginTerminalSessionEvent() { return []; },
|
||
|
|
});
|
||
|
|
const availability = new PluginTerminalProviderAvailability();
|
||
|
|
const older = availability.refresh(olderRegistry, ['terminal.matcher']);
|
||
|
|
const newer = availability.refresh(newerRegistry, ['terminal.matcher']);
|
||
|
|
assert.equal(await newer, true);
|
||
|
|
assert.equal(availability.has('terminal.matcher'), true);
|
||
|
|
resolveOlder?.([]);
|
||
|
|
assert.equal(await older, false);
|
||
|
|
assert.equal(availability.has('terminal.matcher'), true);
|
||
|
|
olderRegistry.dispose();
|
||
|
|
newerRegistry.dispose();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('terminal Provider registry coalesces immutable enumeration until contributions change', async () => {
|
||
|
|
let changed: (() => void) | undefined;
|
||
|
|
let listCalls = 0;
|
||
|
|
const resolvers: Array<(providers: NetcattyTerminalProviderContribution[]) => void> = [];
|
||
|
|
const registry = new PluginTerminalProviderRegistry({
|
||
|
|
listPluginTerminalProviders() {
|
||
|
|
listCalls += 1;
|
||
|
|
return new Promise((resolve) => { resolvers.push(resolve); });
|
||
|
|
},
|
||
|
|
async providePluginTerminal() { return []; },
|
||
|
|
async cancelPluginTerminalRequest() { return false; },
|
||
|
|
async publishPluginTerminalSessionEvent() { return []; },
|
||
|
|
onPluginContributionsChanged(listener) {
|
||
|
|
changed = listener;
|
||
|
|
return () => {};
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const first = registry.listProviders({ kind: 'terminal.matcher' });
|
||
|
|
const duplicate = registry.listProviders({ kind: 'terminal.matcher' });
|
||
|
|
assert.equal(listCalls, 1);
|
||
|
|
resolvers[0]?.([]);
|
||
|
|
assert.strictEqual(await first, await duplicate);
|
||
|
|
assert.strictEqual(await registry.listProviders({ kind: 'terminal.matcher' }), await first);
|
||
|
|
assert.equal(listCalls, 1);
|
||
|
|
|
||
|
|
changed?.();
|
||
|
|
const refreshed = registry.listProviders({ kind: 'terminal.matcher' });
|
||
|
|
assert.equal(listCalls, 2);
|
||
|
|
resolvers[1]?.([]);
|
||
|
|
await refreshed;
|
||
|
|
registry.dispose();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('terminal Provider registry suppresses repeated lifecycle RPC after the bridge is unavailable', async () => {
|
||
|
|
let changed: (() => void) | undefined;
|
||
|
|
let listCalls = 0;
|
||
|
|
let lifecycleCalls = 0;
|
||
|
|
const registry = new PluginTerminalProviderRegistry({
|
||
|
|
async listPluginTerminalProviders() {
|
||
|
|
listCalls += 1;
|
||
|
|
throw new Error('PLUGIN_DEVELOPMENT_DISABLED');
|
||
|
|
},
|
||
|
|
async providePluginTerminal() { return []; },
|
||
|
|
async cancelPluginTerminalRequest() { return false; },
|
||
|
|
async publishPluginTerminalSessionEvent() {
|
||
|
|
lifecycleCalls += 1;
|
||
|
|
throw new Error('PLUGIN_DEVELOPMENT_DISABLED');
|
||
|
|
},
|
||
|
|
onPluginContributionsChanged(listener) {
|
||
|
|
changed = listener;
|
||
|
|
return () => {};
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(registry.listProviders({ kind: 'terminal.link' }), /PLUGIN_DEVELOPMENT_DISABLED/);
|
||
|
|
assert.deepEqual(await registry.listProviders({ kind: 'terminal.link' }), []);
|
||
|
|
assert.equal(listCalls, 1);
|
||
|
|
await registry.publishSessionEvent({ type: 'created', session });
|
||
|
|
await registry.publishSessionEvent({ type: 'titleChanged', session: { ...session, title: 'ready' } });
|
||
|
|
assert.equal(lifecycleCalls, 0);
|
||
|
|
|
||
|
|
changed?.();
|
||
|
|
await assert.rejects(
|
||
|
|
registry.publishSessionEvent({ type: 'cwdChanged', session: { ...session, cwd: '/work' } }),
|
||
|
|
/PLUGIN_DEVELOPMENT_DISABLED/,
|
||
|
|
);
|
||
|
|
await registry.publishSessionEvent({ type: 'resized', session: { ...session, cols: 80, rows: 24 } });
|
||
|
|
assert.equal(lifecycleCalls, 1);
|
||
|
|
registry.dispose();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('terminal Provider registry cancels superseded requests and suppresses stale results', async () => {
|
||
|
|
const cancellations: string[] = [];
|
||
|
|
const resolvers: Array<(value: ReadonlyArray<NetcattyTerminalProviderResult>) => void> = [];
|
||
|
|
const registry = new PluginTerminalProviderRegistry({
|
||
|
|
async listPluginTerminalProviders() { return []; },
|
||
|
|
providePluginTerminal() {
|
||
|
|
return new Promise((resolve) => resolvers.push(resolve));
|
||
|
|
},
|
||
|
|
async cancelPluginTerminalRequest(requestId) { cancellations.push(requestId); return true; },
|
||
|
|
async publishPluginTerminalSessionEvent() { return []; },
|
||
|
|
});
|
||
|
|
|
||
|
|
const first = registry.request({
|
||
|
|
kind: 'terminal.completion',
|
||
|
|
operation: 'provideCompletions',
|
||
|
|
session,
|
||
|
|
payload: { input: 'g' },
|
||
|
|
});
|
||
|
|
const second = registry.request({
|
||
|
|
kind: 'terminal.completion',
|
||
|
|
operation: 'provideCompletions',
|
||
|
|
session,
|
||
|
|
payload: { input: 'gi' },
|
||
|
|
});
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
assert.equal(cancellations.length, 1);
|
||
|
|
resolvers[1]([]);
|
||
|
|
const secondResult = await second;
|
||
|
|
resolvers[0]([]);
|
||
|
|
const firstResult = await first;
|
||
|
|
assert.equal(secondResult.stale, false);
|
||
|
|
assert.equal(firstResult.stale, true);
|
||
|
|
assert.deepEqual(firstResult.results, []);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('terminal Provider registry aborts an in-flight bridge request and settles stale', async () => {
|
||
|
|
const cancellations: string[] = [];
|
||
|
|
const requests: NetcattyTerminalProviderRequest[] = [];
|
||
|
|
const controller = new AbortController();
|
||
|
|
const registry = new PluginTerminalProviderRegistry({
|
||
|
|
async listPluginTerminalProviders() { return []; },
|
||
|
|
providePluginTerminal(request) {
|
||
|
|
requests.push(request);
|
||
|
|
return new Promise(() => {});
|
||
|
|
},
|
||
|
|
async cancelPluginTerminalRequest(requestId) { cancellations.push(requestId); return true; },
|
||
|
|
async publishPluginTerminalSessionEvent() { return []; },
|
||
|
|
});
|
||
|
|
|
||
|
|
const pending = registry.request({
|
||
|
|
kind: 'terminal.completion',
|
||
|
|
operation: 'provideCompletions',
|
||
|
|
session,
|
||
|
|
}, { signal: controller.signal });
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
assert.equal(requests.length, 1);
|
||
|
|
|
||
|
|
controller.abort();
|
||
|
|
const result = await pending;
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
assert.equal(result.stale, true);
|
||
|
|
assert.deepEqual(result.results, []);
|
||
|
|
assert.deepEqual(cancellations, [requests[0].requestId]);
|
||
|
|
registry.dispose();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('terminal Provider registry scopes link supersession per buffer line', async () => {
|
||
|
|
const cancellations: string[] = [];
|
||
|
|
const resolvers: Array<(value: ReadonlyArray<NetcattyTerminalProviderResult>) => void> = [];
|
||
|
|
const registry = new PluginTerminalProviderRegistry({
|
||
|
|
async listPluginTerminalProviders() { return []; },
|
||
|
|
providePluginTerminal() { return new Promise((resolve) => resolvers.push(resolve)); },
|
||
|
|
async cancelPluginTerminalRequest(requestId) { cancellations.push(requestId); return true; },
|
||
|
|
async publishPluginTerminalSessionEvent() { return []; },
|
||
|
|
});
|
||
|
|
const firstLine = registry.request({
|
||
|
|
kind: 'terminal.link',
|
||
|
|
operation: 'provideLinks',
|
||
|
|
session,
|
||
|
|
supersessionKey: 'line:1',
|
||
|
|
});
|
||
|
|
const secondLine = registry.request({
|
||
|
|
kind: 'terminal.link',
|
||
|
|
operation: 'provideLinks',
|
||
|
|
session,
|
||
|
|
supersessionKey: 'line:2',
|
||
|
|
});
|
||
|
|
assert.equal(cancellations.length, 0);
|
||
|
|
const replacementFirstLine = registry.request({
|
||
|
|
kind: 'terminal.link',
|
||
|
|
operation: 'provideLinks',
|
||
|
|
session,
|
||
|
|
supersessionKey: 'line:1',
|
||
|
|
});
|
||
|
|
assert.equal(cancellations.length, 1);
|
||
|
|
resolvers[1]([]);
|
||
|
|
resolvers[2]([]);
|
||
|
|
assert.equal((await secondLine).stale, false);
|
||
|
|
assert.equal((await replacementFirstLine).stale, false);
|
||
|
|
resolvers[0]([]);
|
||
|
|
assert.equal((await firstLine).stale, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('terminal Provider registry returns a stale response when a superseded bridge request rejects', async () => {
|
||
|
|
const resolvers: Array<{
|
||
|
|
resolve: (value: ReadonlyArray<NetcattyTerminalProviderResult>) => void;
|
||
|
|
reject: (error: Error) => void;
|
||
|
|
}> = [];
|
||
|
|
const registry = new PluginTerminalProviderRegistry({
|
||
|
|
async listPluginTerminalProviders() { return []; },
|
||
|
|
providePluginTerminal() {
|
||
|
|
return new Promise((resolve, reject) => resolvers.push({ resolve, reject }));
|
||
|
|
},
|
||
|
|
async cancelPluginTerminalRequest() { return true; },
|
||
|
|
async publishPluginTerminalSessionEvent() { return []; },
|
||
|
|
});
|
||
|
|
const first = registry.request({
|
||
|
|
kind: 'terminal.decoration',
|
||
|
|
operation: 'provideDecorations',
|
||
|
|
session,
|
||
|
|
});
|
||
|
|
const second = registry.request({
|
||
|
|
kind: 'terminal.decoration',
|
||
|
|
operation: 'provideDecorations',
|
||
|
|
session,
|
||
|
|
});
|
||
|
|
resolvers[1].resolve([]);
|
||
|
|
assert.equal((await second).stale, false);
|
||
|
|
resolvers[0].reject(new Error('cancelled'));
|
||
|
|
const firstResult = await first;
|
||
|
|
assert.equal(firstResult.stale, true);
|
||
|
|
assert.deepEqual(firstResult.results, []);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('terminal Provider registry freezes enumeration and cancels all session requests', async () => {
|
||
|
|
const cancellations: string[] = [];
|
||
|
|
const registry = new PluginTerminalProviderRegistry({
|
||
|
|
async listPluginTerminalProviders() {
|
||
|
|
return [{
|
||
|
|
pluginId: 'com.example',
|
||
|
|
pluginVersion: '1.0.0',
|
||
|
|
pluginDisplayName: 'Example',
|
||
|
|
provider: { id: 'com.example.completion', label: 'Completion', kind: 'terminal.completion' },
|
||
|
|
}];
|
||
|
|
},
|
||
|
|
providePluginTerminal() { return new Promise(() => {}); },
|
||
|
|
async cancelPluginTerminalRequest(requestId) { cancellations.push(requestId); return true; },
|
||
|
|
async publishPluginTerminalSessionEvent() { return []; },
|
||
|
|
});
|
||
|
|
const providers = await registry.listProviders({ kind: 'terminal.completion' });
|
||
|
|
assert.equal(Object.isFrozen(providers), true);
|
||
|
|
assert.equal(Object.isFrozen(providers[0].provider), true);
|
||
|
|
void registry.request({ kind: 'terminal.completion', operation: 'provide', session });
|
||
|
|
void registry.request({ kind: 'terminal.decoration', operation: 'provide', session });
|
||
|
|
registry.cancelSession(session.sessionId);
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
assert.equal(cancellations.length, 2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('terminal Provider registry publishes metadata-only lifecycle snapshots', async () => {
|
||
|
|
const events: NetcattyTerminalSessionEvent[] = [];
|
||
|
|
const registry = new PluginTerminalProviderRegistry({
|
||
|
|
async listPluginTerminalProviders() { return []; },
|
||
|
|
async providePluginTerminal() { return []; },
|
||
|
|
async cancelPluginTerminalRequest() { return false; },
|
||
|
|
async publishPluginTerminalSessionEvent(event) { events.push(event); return []; },
|
||
|
|
});
|
||
|
|
await registry.publishSessionEvent({ type: 'connected', session });
|
||
|
|
await registry.publishSessionEvent({
|
||
|
|
type: 'cwdChanged',
|
||
|
|
session: { sessionId: session.sessionId, protocol: 'ssh', status: 'connected', cwd: '/srv/app' },
|
||
|
|
});
|
||
|
|
assert.deepEqual(events, [
|
||
|
|
{ type: 'connected', session },
|
||
|
|
{ type: 'cwdChanged', session: { ...session, cwd: '/srv/app' } },
|
||
|
|
]);
|
||
|
|
assert.equal(Object.isFrozen(events[0]), true);
|
||
|
|
assert.equal(Object.isFrozen(events[0].session), true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('terminal Provider lifecycle clears optional fields instead of retaining stale metadata', async () => {
|
||
|
|
const events: NetcattyTerminalSessionEvent[] = [];
|
||
|
|
const requests: NetcattyTerminalProviderRequest[] = [];
|
||
|
|
const registry = new PluginTerminalProviderRegistry({
|
||
|
|
async listPluginTerminalProviders() { return []; },
|
||
|
|
async providePluginTerminal(request) { requests.push(request); return []; },
|
||
|
|
async cancelPluginTerminalRequest() { return false; },
|
||
|
|
async publishPluginTerminalSessionEvent(event) { events.push(event); return []; },
|
||
|
|
});
|
||
|
|
await registry.publishSessionEvent({
|
||
|
|
type: 'created',
|
||
|
|
session: { ...session, cwd: '/srv/app', title: 'Application' },
|
||
|
|
});
|
||
|
|
await registry.publishSessionEvent({ type: 'cwdChanged', session });
|
||
|
|
await registry.publishSessionEvent({ type: 'titleChanged', session });
|
||
|
|
await registry.request({
|
||
|
|
kind: 'terminal.completion',
|
||
|
|
operation: 'provide',
|
||
|
|
session,
|
||
|
|
});
|
||
|
|
assert.equal(Object.hasOwn(events[1].session, 'cwd'), false);
|
||
|
|
assert.equal(Object.hasOwn(events[1].session, 'title'), true);
|
||
|
|
assert.equal(Object.hasOwn(events[2].session, 'cwd'), false);
|
||
|
|
assert.equal(Object.hasOwn(events[2].session, 'title'), false);
|
||
|
|
assert.equal(Object.hasOwn(requests[0].session, 'cwd'), false);
|
||
|
|
assert.equal(Object.hasOwn(requests[0].session, 'title'), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('terminal Provider reconnect lifecycle clears omitted connection-scoped metadata', async () => {
|
||
|
|
const events: NetcattyTerminalSessionEvent[] = [];
|
||
|
|
const requests: NetcattyTerminalProviderRequest[] = [];
|
||
|
|
const registry = new PluginTerminalProviderRegistry({
|
||
|
|
async listPluginTerminalProviders() { return []; },
|
||
|
|
async providePluginTerminal(request) { requests.push(request); return []; },
|
||
|
|
async cancelPluginTerminalRequest() { return false; },
|
||
|
|
async publishPluginTerminalSessionEvent(event) { events.push(event); return []; },
|
||
|
|
});
|
||
|
|
await registry.publishSessionEvent({
|
||
|
|
type: 'connected',
|
||
|
|
session: { ...session, cwd: '/srv/app', title: 'Application', alternateScreen: true },
|
||
|
|
});
|
||
|
|
await registry.publishSessionEvent({ type: 'disconnected', session: { ...session, status: 'disconnected' } });
|
||
|
|
await registry.publishSessionEvent({ type: 'reconnected', session });
|
||
|
|
await registry.request({ kind: 'terminal.completion', operation: 'provide', session });
|
||
|
|
|
||
|
|
for (const event of events.slice(1)) {
|
||
|
|
assert.equal(Object.hasOwn(event.session, 'cwd'), false);
|
||
|
|
assert.equal(Object.hasOwn(event.session, 'title'), false);
|
||
|
|
assert.equal(Object.hasOwn(event.session, 'alternateScreen'), false);
|
||
|
|
}
|
||
|
|
assert.equal(Object.hasOwn(requests[0].session, 'cwd'), false);
|
||
|
|
assert.equal(Object.hasOwn(requests[0].session, 'title'), false);
|
||
|
|
assert.equal(Object.hasOwn(requests[0].session, 'alternateScreen'), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('terminal Provider requests merge the latest lifecycle snapshot before invocation', async () => {
|
||
|
|
const requests: NetcattyTerminalProviderRequest[] = [];
|
||
|
|
const registry = new PluginTerminalProviderRegistry({
|
||
|
|
async listPluginTerminalProviders() { return []; },
|
||
|
|
async providePluginTerminal(request) { requests.push(request); return []; },
|
||
|
|
async cancelPluginTerminalRequest() { return false; },
|
||
|
|
async publishPluginTerminalSessionEvent() { return []; },
|
||
|
|
});
|
||
|
|
await registry.publishSessionEvent({
|
||
|
|
type: 'created',
|
||
|
|
session: { ...session, workspaceId: 'workspace-1', title: 'Initial title', cols: 100, rows: 30 },
|
||
|
|
});
|
||
|
|
await registry.request({
|
||
|
|
kind: 'terminal.completion',
|
||
|
|
operation: 'provide',
|
||
|
|
session: { ...session, cwd: '/srv/app' },
|
||
|
|
});
|
||
|
|
assert.deepEqual(requests[0].session, {
|
||
|
|
...session,
|
||
|
|
workspaceId: 'workspace-1',
|
||
|
|
title: 'Initial title',
|
||
|
|
cols: 100,
|
||
|
|
rows: 30,
|
||
|
|
cwd: '/srv/app',
|
||
|
|
});
|
||
|
|
assert.equal(Object.isFrozen(requests[0].session), true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('terminal Provider registry forwards contribution lifecycle invalidation', () => {
|
||
|
|
let bridgeListener: (() => void) | undefined;
|
||
|
|
let disposed = false;
|
||
|
|
const cancellations: string[] = [];
|
||
|
|
const registry = new PluginTerminalProviderRegistry({
|
||
|
|
async listPluginTerminalProviders() { return []; },
|
||
|
|
async providePluginTerminal() { return []; },
|
||
|
|
async cancelPluginTerminalRequest(requestId) { cancellations.push(requestId); return true; },
|
||
|
|
async publishPluginTerminalSessionEvent() { return []; },
|
||
|
|
onPluginContributionsChanged(listener) {
|
||
|
|
bridgeListener = listener;
|
||
|
|
return () => { disposed = true; };
|
||
|
|
},
|
||
|
|
});
|
||
|
|
let changes = 0;
|
||
|
|
const unsubscribe = registry.onDidChangeProviders(() => { changes += 1; });
|
||
|
|
void registry.request({ kind: 'terminal.completion', operation: 'provide', session });
|
||
|
|
bridgeListener?.();
|
||
|
|
assert.equal(changes, 1);
|
||
|
|
assert.equal(cancellations.length, 1);
|
||
|
|
unsubscribe();
|
||
|
|
bridgeListener?.();
|
||
|
|
assert.equal(changes, 1);
|
||
|
|
registry.dispose();
|
||
|
|
assert.equal(disposed, true);
|
||
|
|
});
|