Skip to content

Commit 4740804

Browse files
committed
test(vscode): headless activation smoke — run real activate() against a mock vscode
Inject a fake `vscode`, run the compiled activate(), and exercise the cockpit render, chat participant, LM tool and MCP provider end-to-end. Catches runtime wiring bugs the type-checker can't, without a live editor. +9 tests (36 total).
1 parent 49c4ec3 commit 4740804

2 files changed

Lines changed: 335 additions & 0 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Headless activation smoke test: inject a fake `vscode`, run the real compiled
2+
// activate(), and exercise the AI surfaces + cockpit end-to-end. This catches
3+
// runtime wiring bugs (bad command ids, missing registrations, render throws)
4+
// that the type-checker can't, without needing a live editor.
5+
const { test } = require('node:test');
6+
const assert = require('node:assert/strict');
7+
const Module = require('node:module');
8+
9+
const vscodeMock = require('./vscode-mock.js');
10+
const rec = vscodeMock.__;
11+
12+
// Intercept `require('vscode')` for the extension and its modules.
13+
const origLoad = Module._load;
14+
Module._load = function (request, parent, isMain) {
15+
if (request === 'vscode') return vscodeMock;
16+
return origLoad.call(this, request, parent, isMain);
17+
};
18+
19+
const extension = require('../out/extension.js');
20+
21+
const ctxState = new Map();
22+
const context = {
23+
subscriptions: [],
24+
extensionPath: '/fake/ext',
25+
extensionUri: vscodeMock.Uri.file('/fake/ext'),
26+
workspaceState: {
27+
get: (k, d) => (ctxState.has(k) ? ctxState.get(k) : d),
28+
update: (k, v) => {
29+
ctxState.set(k, v);
30+
return Promise.resolve();
31+
},
32+
},
33+
};
34+
35+
// Activate once (no workspace folders → check() early-returns, nothing spawns).
36+
let activateError = null;
37+
try {
38+
extension.activate(context);
39+
} catch (e) {
40+
activateError = e;
41+
}
42+
43+
test('activate() runs without throwing and registers disposables', () => {
44+
assert.equal(activateError, null);
45+
assert.ok(context.subscriptions.length > 5);
46+
});
47+
48+
test('all commands are registered', () => {
49+
for (const id of [
50+
'gate.check',
51+
'gate.refresh',
52+
'gate.showOutput',
53+
'gate.focusCockpit',
54+
'gate.muteFinding',
55+
'gate.clearMutes',
56+
'gate.installEngine',
57+
]) {
58+
assert.equal(typeof rec.commands[id], 'function', `missing command: ${id}`);
59+
}
60+
});
61+
62+
test('views, diagnostics and the three providers are wired', () => {
63+
assert.ok(rec.trees.some((t) => t.id === 'gate.verdict'));
64+
assert.ok(rec.webviews.some((w) => w.id === 'gate.cockpit'));
65+
assert.equal(rec.codeActionProviders.length, 1);
66+
assert.equal(rec.hoverProviders.length, 1);
67+
assert.equal(rec.codeLensProviders.length, 1);
68+
assert.ok(rec.saveHandlers.length >= 1);
69+
});
70+
71+
test('AI surfaces are registered: tool, participant, mcp provider', () => {
72+
assert.ok(rec.tools['gate_check'], 'gate_check tool not registered');
73+
assert.ok(rec.participants['nugehs.gate'], 'chat participant not registered');
74+
assert.ok(rec.mcpProviders['nugehs-gate'], 'mcp provider not registered');
75+
});
76+
77+
test('cockpit renders HTML when resolved', () => {
78+
const cockpit = rec.webviews.find((w) => w.id === 'gate.cockpit').provider;
79+
const view = { webview: { options: {}, html: '', onDidReceiveMessage: () => ({ dispose() {} }) } };
80+
cockpit.resolveWebviewView(view);
81+
assert.match(view.webview.html, /<!DOCTYPE html>/);
82+
assert.match(view.webview.html, /Re-check/);
83+
assert.match(view.webview.html, /No workspace folder open/); // no folders in this harness
84+
});
85+
86+
test('chat participant streams a verdict', async () => {
87+
const handler = rec.participants['nugehs.gate'];
88+
const streamed = [];
89+
const stream = {
90+
markdown: (s) => streamed.push(typeof s === 'string' ? s : s.value),
91+
progress: () => {},
92+
button: () => {},
93+
};
94+
const token = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) };
95+
await handler({ command: undefined, prompt: '' }, {}, stream, token);
96+
const text = streamed.join('');
97+
assert.match(text, /gate:/i);
98+
assert.match(text, /NONE/); // no folders → nothing to check
99+
});
100+
101+
test('LM tool returns an actionable text verdict', async () => {
102+
const tool = rec.tools['gate_check'];
103+
const token = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) };
104+
const result = await tool.invoke({ input: {} }, token);
105+
assert.ok(result && Array.isArray(result.parts));
106+
assert.match(result.parts[0].value, /gate verdict:/);
107+
});
108+
109+
test('MCP provider yields a stdio definition that runs `gate mcp`', () => {
110+
const provider = rec.mcpProviders['nugehs-gate'];
111+
const defs = provider.provideMcpServerDefinitions();
112+
assert.equal(defs.length, 1);
113+
assert.ok(defs[0].args.includes('mcp'));
114+
});
115+
116+
test('deactivate() is clean', () => {
117+
assert.doesNotThrow(() => extension.deactivate());
118+
Module._load = origLoad; // restore
119+
});

clients/vscode/test/vscode-mock.js

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// A minimal fake of the `vscode` module — just enough surface to run the real
2+
// activate() and exercise the cockpit, chat participant, LM tool and MCP provider
3+
// in plain node:test, with no editor. Every registration is recorded in `__` so
4+
// the activation test can assert on it and invoke captured handlers.
5+
6+
const rec = {
7+
commands: {},
8+
executed: [],
9+
trees: [],
10+
webviews: [],
11+
terminals: [],
12+
codeActionProviders: [],
13+
hoverProviders: [],
14+
codeLensProviders: [],
15+
saveHandlers: [],
16+
folderHandlers: [],
17+
tools: {},
18+
mcpProviders: {},
19+
participants: {},
20+
};
21+
22+
class EventEmitter {
23+
constructor() {
24+
this.listeners = [];
25+
}
26+
get event() {
27+
return (fn) => {
28+
this.listeners.push(fn);
29+
return { dispose() {} };
30+
};
31+
}
32+
fire(e) {
33+
for (const l of this.listeners) l(e);
34+
}
35+
dispose() {}
36+
}
37+
38+
class Range {
39+
constructor(...a) {
40+
this.args = a;
41+
}
42+
}
43+
class Diagnostic {
44+
constructor(range, message, severity) {
45+
Object.assign(this, { range, message, severity });
46+
}
47+
}
48+
class ThemeIcon {
49+
constructor(id, color) {
50+
this.id = id;
51+
this.color = color;
52+
}
53+
}
54+
class ThemeColor {
55+
constructor(id) {
56+
this.id = id;
57+
}
58+
}
59+
class TreeItem {
60+
constructor(label, collapsibleState) {
61+
this.label = label;
62+
this.collapsibleState = collapsibleState;
63+
}
64+
}
65+
class CodeAction {
66+
constructor(title, kind) {
67+
this.title = title;
68+
this.kind = kind;
69+
}
70+
}
71+
class MarkdownString {
72+
constructor(value, supportThemeIcons) {
73+
this.value = value || '';
74+
this.supportThemeIcons = supportThemeIcons;
75+
this.isTrusted = false;
76+
}
77+
appendMarkdown(s) {
78+
this.value += s;
79+
return this;
80+
}
81+
}
82+
class Hover {
83+
constructor(contents) {
84+
this.contents = contents;
85+
}
86+
}
87+
class CodeLens {
88+
constructor(range, command) {
89+
this.range = range;
90+
this.command = command;
91+
}
92+
}
93+
class LanguageModelToolResult {
94+
constructor(parts) {
95+
this.parts = parts;
96+
}
97+
}
98+
class LanguageModelTextPart {
99+
constructor(value) {
100+
this.value = value;
101+
}
102+
}
103+
class McpStdioServerDefinition {
104+
constructor(label, command, args, env, version) {
105+
Object.assign(this, { label, command, args, env, version });
106+
}
107+
}
108+
109+
const Uri = {
110+
file: (p) => ({ scheme: 'file', fsPath: p, path: p, toString: () => 'file://' + p }),
111+
parse: (s) => ({ scheme: 'parsed', fsPath: s, toString: () => s }),
112+
joinPath: (base, ...segs) => ({ fsPath: [base.fsPath, ...segs].join('/'), toString: () => [base.fsPath, ...segs].join('/') }),
113+
};
114+
115+
const window = {
116+
createOutputChannel: (name) => ({ name, appendLine() {}, show() {}, dispose() {} }),
117+
createStatusBarItem: () => ({ text: '', tooltip: '', command: '', show() {}, hide() {}, dispose() {} }),
118+
registerTreeDataProvider: (id, provider) => {
119+
rec.trees.push({ id, provider });
120+
return { dispose() {} };
121+
},
122+
registerWebviewViewProvider: (id, provider) => {
123+
rec.webviews.push({ id, provider });
124+
return { dispose() {} };
125+
},
126+
createTerminal: (name) => {
127+
rec.terminals.push(name);
128+
return { show() {}, sendText() {} };
129+
},
130+
};
131+
132+
const languages = {
133+
createDiagnosticCollection: (name) => ({ name, clear() {}, set() {}, delete() {}, dispose() {} }),
134+
registerCodeActionsProvider: (_sel, provider) => {
135+
rec.codeActionProviders.push(provider);
136+
return { dispose() {} };
137+
},
138+
registerHoverProvider: (_sel, provider) => {
139+
rec.hoverProviders.push(provider);
140+
return { dispose() {} };
141+
},
142+
registerCodeLensProvider: (_sel, provider) => {
143+
rec.codeLensProviders.push(provider);
144+
return { dispose() {} };
145+
},
146+
};
147+
148+
const commands = {
149+
registerCommand: (id, fn) => {
150+
rec.commands[id] = fn;
151+
return { dispose() {} };
152+
},
153+
executeCommand: (id, ...args) => {
154+
rec.executed.push({ id, args });
155+
return Promise.resolve();
156+
},
157+
};
158+
159+
const workspace = {
160+
workspaceFolders: undefined,
161+
getConfiguration: () => ({ get: (_key, def) => def }),
162+
onDidSaveTextDocument: (fn) => {
163+
rec.saveHandlers.push(fn);
164+
return { dispose() {} };
165+
},
166+
onDidChangeWorkspaceFolders: (fn) => {
167+
rec.folderHandlers.push(fn);
168+
return { dispose() {} };
169+
},
170+
};
171+
172+
const lm = {
173+
registerTool: (name, tool) => {
174+
rec.tools[name] = tool;
175+
return { dispose() {} };
176+
},
177+
registerMcpServerDefinitionProvider: (id, provider) => {
178+
rec.mcpProviders[id] = provider;
179+
return { dispose() {} };
180+
},
181+
};
182+
183+
const chat = {
184+
createChatParticipant: (id, handler) => {
185+
rec.participants[id] = handler;
186+
return { id, iconPath: undefined, dispose() {} };
187+
},
188+
};
189+
190+
module.exports = {
191+
__: rec,
192+
EventEmitter,
193+
Range,
194+
Diagnostic,
195+
ThemeIcon,
196+
ThemeColor,
197+
TreeItem,
198+
CodeAction,
199+
MarkdownString,
200+
Hover,
201+
CodeLens,
202+
LanguageModelToolResult,
203+
LanguageModelTextPart,
204+
McpStdioServerDefinition,
205+
Uri,
206+
window,
207+
languages,
208+
commands,
209+
workspace,
210+
lm,
211+
chat,
212+
StatusBarAlignment: { Left: 1, Right: 2 },
213+
DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 },
214+
TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 },
215+
CodeActionKind: { QuickFix: { value: 'quickfix' } },
216+
};

0 commit comments

Comments
 (0)