const assert = require("node:assert/strict"); const test = require("node:test"); const fs = require("node:fs"); const path = require("node:path"); const vm = require("node:vm"); const { setImmediate } = require("node:timers/promises"); function event() { const listeners = new Set(); return { listen(callback) { listeners.add(callback); return { dispose: () => listeners.delete(callback) }; }, fire: (...args) => Promise.all([...listeners].map((listener) => listener(...args))), }; } function fixture(options = {}) { const uri = (file) => ({ scheme: "file", fsPath: file, toString: () => `file://${file}` }); const root = path.resolve("workspace"); const folder = (file) => ({ uri: uri(file) }); const config = { backend: "/native/tsc", executable: "avril", options: [], autoStart: true, ...options.config }; const configurations = []; const errors = []; const commands = new Map(); const executedCommands = []; const contentProviders = new Map(); const clients = []; const watchers = []; const configs = new Set(options.files || [path.join(root, "avril.conf")]); const trust = event(); const folders = event(); const settings = event(); const State = { Stopped: 1, Starting: 2, Running: 3 }; const controls = { failStart: false, startGate: undefined }; const workspace = { isTrusted: options.trusted ?? true, workspaceFolders: [folder(root)], fs: { stat: async (uri) => { if (!configs.has(uri.fsPath)) throw Object.assign(new Error("Missing"), { code: "FileNotFound" }); return { type: 1 }; } }, getConfiguration: () => ({ get: (key, fallback) => config[key] ?? fallback }), getWorkspaceFolder: (uri) => workspace.workspaceFolders.filter((folder) => { const relative = path.relative(folder.uri.fsPath, uri.fsPath); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); }).sort((a, b) => b.uri.fsPath.length - a.uri.fsPath.length)[0], onDidGrantWorkspaceTrust: trust.listen, onDidChangeWorkspaceFolders: folders.listen, onDidChangeConfiguration: settings.listen, registerTextDocumentContentProvider: (scheme, provider) => { contentProviders.set(scheme, provider); return { dispose: () => contentProviders.delete(scheme) }; }, createFileSystemWatcher: (pattern) => { const created = event(); const deleted = event(); const watcher = { pattern, created, deleted, disposed: false, onDidCreate: created.listen, onDidDelete: deleted.listen, dispose() { this.disposed = true; }, }; watchers.push(watcher); return watcher; }, }; const vscode = { workspace, Uri: { joinPath: (base, ...parts) => uri(path.join(base.fsPath, ...parts)), parse: (value) => { const parsed = new URL(value); return { scheme: parsed.protocol.slice(0, -1), fsPath: parsed.protocol === "file:" ? decodeURIComponent(parsed.pathname) : parsed.pathname, toString: () => parsed.href, }; }, }, FileType: { File: 1 }, RelativePattern: class { constructor(folder, pattern) { this.folder = folder; this.pattern = pattern; } }, extensions: { getExtension: () => options.noTypeScript ? undefined : { activate: async () => ({ getAPI: () => ({ configurePlugin: (name, configuration) => { configurations.push({ name, ...JSON.parse(JSON.stringify(configuration)) }); } }) }), } }, commands: { registerCommand: (name, handler) => { commands.set(name, handler); return { dispose: () => commands.delete(name) }; }, executeCommand: async (...args) => { executedCommands.push(args); return "executed"; }, }, window: { showErrorMessage: (message) => errors.push(message) }, }; class LanguageClient { constructor(id, name, server, clientOptions) { this.server = server; this.options = clientOptions; this.requests = []; this.notifications = []; this.sendNotification = async (method, params) => { this.notifications.push({ method, params }); }; this.protocol2CodeConverter = { asUri: (value) => vscode.Uri.parse(value), asPosition: (value) => ({ kind: "Position", ...value }), asLocation: (value) => ({ kind: "Location", uri: vscode.Uri.parse(value.uri), range: value.range, }), }; this.sendRequest = async (method, params) => { this.requests.push({ method, params }); return "contents"; }; this.state = State.Stopped; this.changed = event(); this.onDidChangeState = this.changed.listen; clients.push(this); } async transition(state) { this.state = state; await this.changed.fire({ newState: state }); } async start() { await this.transition(State.Starting); await controls.startGate; if (controls.failStart) throw new Error("Startup failed"); await this.transition(State.Running); } needsStop() { return this.state !== State.Stopped; } async stop() { await this.transition(State.Stopped); } } const module = { exports: {} }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../extension.cjs"), "utf8"), { module, require(name) { if (name === "vscode") return vscode; if (name === "vscode-languageclient/node") return { LanguageClient, State }; return require(name); }, }); const context = { subscriptions: [] }; return { ...module.exports, context, root, uri, folder, config, configs, configurations, errors, commands, executedCommands, contentProviders, clients, watchers, workspace, trust, folders, settings, controls, State, parseUri: (value) => vscode.Uri.parse(value), activate: () => module.exports.activate(context), changed: () => settings.fire({ affectsConfiguration: () => true }), start: () => commands.get("avril.startLanguageServer")(), stop: () => commands.get("avril.stopLanguageServer")(), }; } test("root configuration auto-starts and mutes only the active workspace", async (t) => { const f = fixture(); t.after(f.deactivate); f.workspace.workspaceFolders.push(f.folder(path.resolve("other"))); await f.activate(); assert.equal(f.clients.length, 1); assert.equal(f.clients[0].server.options.cwd, f.root); assert.deepEqual(f.configurations.at(-1).liveRoots, [f.root]); assert.deepEqual(f.configurations.at(-1).workspaceRoots, [f.root, path.resolve("other")]); await f.stop(); assert.deepEqual(f.configurations.at(-1).liveRoots, []); await f.changed(); assert.equal(f.clients.length, 1, "explicit stop must survive configuration events"); await f.start(); assert.equal(f.clients.length, 2); }); test("nested configs and configs in other workspace folders do not claim the first folder", async (t) => { const f = fixture({ files: [path.resolve("workspace/nested/avril.conf"), path.resolve("other/avril.conf")] }); t.after(f.deactivate); f.workspace.workspaceFolders.push(f.folder(path.resolve("other"))); await f.activate(); await f.watchers[0].created.fire(f.uri(path.resolve("workspace/nested/avril.conf"))); assert.equal(f.clients.length, 0); assert.equal(f.configurations.length, 0); }); test("root creation and removal start and stop an automatic server", async (t) => { const f = fixture({ files: [] }); t.after(f.deactivate); await f.activate(); const config = f.uri(path.join(f.root, "avril.conf")); f.configs.add(config.fsPath); await f.watchers[0].created.fire(config); assert.equal(f.clients.length, 1); f.configs.delete(config.fsPath); await f.watchers[0].deleted.fire(config); assert.equal(f.clients[0].state, f.State.Stopped); assert.deepEqual(f.configurations.at(-1).liveRoots, []); }); test("trust, backend configuration, and autoStart opt-out gate automatic startup", async (t) => { const f = fixture({ trusted: false, config: { backend: "", autoStart: false } }); t.after(f.deactivate); await f.activate(); await assert.rejects(f.start(), /trusted/); f.workspace.isTrusted = true; await f.trust.fire(); assert.equal(f.clients.length, 0); f.config.autoStart = true; await f.changed(); assert.equal(f.clients.length, 0); f.config.backend = "/native/tsc"; await f.changed(); assert.equal(f.clients.length, 1); f.config.autoStart = false; await f.changed(); assert.equal(f.clients[0].state, f.State.Stopped); await f.start(); assert.equal(f.clients.length, 2, "manual startup remains available with autoStart disabled"); }); test("failed startup and unexpected termination leave built-in TypeScript usable", async (t) => { const f = fixture(); t.after(f.deactivate); f.controls.failStart = true; await f.activate(); assert.match(f.errors[0], /Startup failed/); assert.ok(f.configurations.every((configuration) => configuration.liveRoots.length === 0)); assert.equal(f.watchers[1].disposed, true); f.controls.failStart = false; await f.start(); await f.clients.at(-1).transition(f.State.Stopped); assert.deepEqual(f.configurations.at(-1).liveRoots, []); await f.start(); assert.deepEqual(f.configurations.at(-1).liveRoots, [f.root]); }); test("concurrent startup requests do not duplicate clients and stop waits for startup", async (t) => { const f = fixture(); t.after(f.deactivate); let resume; f.controls.startGate = new Promise((resolve) => { resume = resolve; }); const activation = f.activate(); const start = f.start(); await setImmediate(); assert.equal(f.clients.length, 1); const stop = f.stop(); resume(); await Promise.all([activation, start, stop]); assert.equal(f.clients.length, 1); assert.equal(f.clients[0].state, f.State.Stopped); assert.deepEqual(f.configurations.at(-1).liveRoots, []); }); test("nested workspace folders retain their own language service", async (t) => { const f = fixture(); t.after(f.deactivate); f.workspace.workspaceFolders.push(f.folder(path.join(f.root, "node"))); await f.activate(); const middleware = f.clients[0].options.middleware; let calls = 0; const next = () => { calls++; }; const nested = { uri: f.uri(path.join(f.root, "node", "main.ts")) }; middleware.didOpen(nested, next); for (const name of [ "provideHover", "provideDefinition", "provideCompletionItem", "provideSignatureHelp", "provideReferences", "provideDocumentHighlights", "provideRenameEdits", "prepareRename", "provideDocumentSymbols", "provideFoldingRanges", "provideSelectionRanges", "provideDocumentFormattingEdits", "provideDocumentRangeFormattingEdits", "provideDocumentSemanticTokens", "provideDocumentSemanticTokensEdits", "provideDocumentRangeSemanticTokens", "provideInlayHints", "prepareTypeHierarchy", "prepareCallHierarchy", "provideLinkedEditingRange", "provideImplementation", "provideTypeDefinition", "provideCodeActions", "provideCodeLenses", ]) { assert.equal(typeof middleware[name], "function", `Missing ownership gate for ${name}`); middleware[name](nested, {}, {}, {}, next); } middleware.provideTypeHierarchySupertypes({ uri: f.uri(path.join(f.root, "node", "main.ts")) }, {}, next); middleware.provideTypeHierarchySubtypes({ uri: f.uri(path.join(f.root, "node", "main.ts")) }, {}, next); middleware.provideCallHierarchyIncomingCalls({ uri: f.uri(path.join(f.root, "node", "main.ts")) }, {}, next); middleware.provideCallHierarchyOutgoingCalls({ uri: f.uri(path.join(f.root, "node", "main.ts")) }, {}, next); assert.equal(calls, 0, "avril must not claim nested workspace documents or hierarchy items"); middleware.provideWorkspaceSymbols("Square", {}, next); middleware.resolveCompletionItem({}, {}, next); middleware.resolveCodeAction({}, {}, next); assert.equal(calls, 3, "Workspace and opaque-item requests are served by the configured root project"); const owned = { uri: f.uri(path.join(f.root, "main.ts")) }; middleware.didOpen(owned, next); middleware.provideSignatureHelp(owned, {}, {}, {}, next); middleware.provideReferences(owned, {}, { includeDeclaration: true }, {}, next); middleware.provideRenameEdits(owned, {}, "renamed", {}, next); middleware.prepareTypeHierarchy(owned, {}, {}, next); middleware.provideTypeHierarchySupertypes({ uri: owned.uri }, {}, next); middleware.prepareCallHierarchy(owned, {}, {}, next); middleware.provideCallHierarchyIncomingCalls({ uri: owned.uri }, {}, next); middleware.provideCallHierarchyOutgoingCalls({ uri: owned.uri }, {}, next); assert.equal(calls, 12, "avril should serve every owned document request and hierarchy item"); }); test("resolved reference code lenses invoke only the converted built-in command", async (t) => { const f = fixture({ config: { preferences: { referencesCodeLensEnabled: true, referencesCodeLensShowOnAllFunctions: true } } }); t.after(f.deactivate); await f.activate(); const client = f.clients[0]; assert.deepEqual(JSON.parse(JSON.stringify(client.options.initializationOptions)), { preferences: { referencesCodeLensEnabled: true, referencesCodeLensShowOnAllFunctions: true, }, }); const middleware = client.options.middleware; const sourceDocument = f.uri(path.join(f.root, "main.ts")); const source = sourceDocument.toString(); const position = { line: 2, character: 4 }; const locations = [{ uri: f.uri(path.join(f.root, "dep.ts")).toString(), range: { start: { line: 4, character: 1 }, end: { line: 4, character: 6 } }, }]; class CodeLens { get isResolved() { return Boolean(this.command); } } const lens = Object.assign(new CodeLens(), { range: {}, data: { uri: source } }); const [provided] = await middleware.provideCodeLenses( { uri: sourceDocument }, {}, async () => [lens], ); assert.ok(provided instanceof CodeLens, "conversion must preserve the language client's subclass"); assert.equal(provided.isResolved, false); assert.equal(provided.data.uri, source); assert.equal(provided.data.avrilDocumentUri, source); const resolved = await middleware.resolveCodeLens( provided, {}, async (codeLens) => Object.assign(codeLens, { command: { title: "1 reference", command: "editor.action.showReferences", arguments: [source, position, locations], }, }), ); assert.ok(resolved instanceof CodeLens); assert.equal(resolved.isResolved, true); assert.equal(resolved.command.command, "avril.showReferences"); await f.commands.get(resolved.command.command)(...resolved.command.arguments); assert.equal(f.executedCommands.length, 1); assert.equal(f.executedCommands[0][0], "editor.action.showReferences"); assert.equal(f.executedCommands[0][1].toString(), source); assert.equal(f.executedCommands[0][2].kind, "Position"); assert.deepEqual(JSON.parse(JSON.stringify(f.executedCommands[0][2])), { kind: "Position", ...position }); assert.equal(f.executedCommands[0][3][0].kind, "Location"); assert.equal(f.executedCommands[0][3][0].uri.toString(), locations[0].uri); assert.deepEqual( JSON.parse(JSON.stringify(f.executedCommands[0][3][0].range)), locations[0].range, ); const outside = f.uri(path.resolve("outside/main.ts")).toString(); let nextCalled = false; assert.equal(await middleware.resolveCodeLens( { data: { avrilDocumentUri: outside } }, {}, () => { nextCalled = true; }, ), undefined); assert.equal(nextCalled, false, "unowned code lenses must not be resolved"); assert.throws( () => f.commands.get("avril.showReferences")(outside, position, locations), /outside its workspace/, ); const unexpected = await middleware.resolveCodeLens( { data: { avrilDocumentUri: source } }, {}, async (codeLens) => ({ ...codeLens, command: { title: "Unexpected", command: "workbench.action.closeWindow" } }), ); assert.equal(unexpected, undefined, "native commands other than showReferences must not escape"); assert.equal(f.executedCommands.length, 1); }); test("native preferences preserve defaults and update the active session", async (t) => { const f = fixture(); t.after(f.deactivate); await f.activate(); assert.deepEqual(JSON.parse(JSON.stringify(f.clients[0].options.initializationOptions)), { preferences: {} }); f.config.preferences = { includeInlayParameterNameHints: "all" }; await f.changed(); assert.equal(f.clients.length, 1); assert.deepEqual(JSON.parse(JSON.stringify(f.clients[0].notifications.at(-1))), { method: "workspace/didChangeConfiguration", params: { settings: { preferences: f.config.preferences } }, }); }); test("read-only document providers preserve encoded URI query identity", async (t) => { const f = fixture(); t.after(f.deactivate); await f.activate(); for (const original of [ "avril-https://example.test/module.ts?x%3Dy", "avril-https://example.test/module.ts?x%253Dy", ]) { const uri = f.parseUri(original); const content = await f.contentProviders.get("avril-https").provideTextDocumentContent(uri, {}); assert.equal(content, "contents"); assert.deepEqual(JSON.parse(JSON.stringify(f.clients[0].requests.at(-1))), { method: "avril/readDocument", params: { uri: original }, }); } }); test("changing the first workspace folder releases the old server", async (t) => { const f = fixture(); t.after(f.deactivate); await f.activate(); f.workspace.workspaceFolders = [f.folder(path.resolve("other"))]; await f.folders.fire(); assert.equal(f.clients[0].state, f.State.Stopped); assert.deepEqual(f.configurations.at(-1).liveRoots, []); }); test("manual startup still works without a root config or built-in TypeScript extension", async (t) => { const f = fixture({ files: [], noTypeScript: true }); t.after(f.deactivate); await f.activate(); await f.start(); assert.equal(f.clients[0].state, f.State.Running); assert.deepEqual(f.errors, []); });