// vscode_ext_test.js — behavioral test for the Klammertext VS Code // extension (doc/edit/vscode/extension.js) OUTSIDE VS Code. // // The `vscode` module is stubbed with just enough API for activation, and // the extension then talks to the REAL language server it spawned — so this // exercises the whole chain: extension glue -> hand-rolled LSP client -> // klammertext_ls.py -> shared core. Checks: activation + handshake, // publishDiagnostics reaching the diagnostic collection, Format Document // equalling the indent fixtures, documentHighlight pairs, and the // alignTable command round-tripping through workspace/applyEdit. // // Runs under any Node >= 16 — including VS Code's own Electron binary // (ELECTRON_RUN_AS_NODE=1 code vscode_ext_test.js EXT_DIR FIXTURE_DIR). // Prints PASS/FAIL lines; exits nonzero on any failure. 'use strict'; const fs = require('fs'); const path = require('path'); const Module = require('module'); const extDir = path.resolve(process.argv[2]); const fixDir = path.resolve(process.argv[3]); let passed = 0, failed = 0; function check(name, ok, detail) { if (ok) { passed++; console.log('PASS ' + name); } else { failed++; console.log('FAIL ' + name + ' ' + (detail || '')); } } // --- the vscode stub ------------------------------------------------------- class Position { constructor(line, character) { this.line = line; this.character = character; } } class Range { constructor(a, b, c, d) { if (typeof a === 'number') { this.start = new Position(a, b); this.end = new Position(c, d); } else { this.start = a; this.end = b; } } } class Selection extends Range { constructor(a, b) { super(a, b); this.active = b; } } class TextEdit { constructor(range, newText) { this.range = range; this.newText = newText; } } class Diagnostic { constructor(range, message, severity) { this.range = range; this.message = message; this.severity = severity; } } class Location { constructor(uri, range) { this.uri = uri; this.range = range; } } class DocumentHighlight { constructor(range) { this.range = range; } } class WorkspaceEdit { constructor() { this.edits = []; } replace(uri, range, newText) { this.edits.push({ uri, range, newText }); } } const listeners = { open: [], change: [], close: [] }; const providers = {}; const commands = {}; const collections = {}; let appliedEdits = []; let statusMessages = []; const fakeDoc = { uri: { toString: () => 'file:///ext_test.kt' }, languageId: 'klammertext', version: 1, text: '@i abc\n', getText() { return this.text; }, }; const vscodeStub = { Position, Range, Selection, TextEdit, Diagnostic, Location, DocumentHighlight, WorkspaceEdit, Uri: { parse: (s) => ({ toString: () => s }) }, DiagnosticSeverity: { Error: 0, Warning: 1 }, workspace: { getConfiguration: () => ({ get: (k) => (k === 'pythonPath' ? 'python3' : '') }), textDocuments: [fakeDoc], onDidOpenTextDocument: (fn) => { listeners.open.push(fn); return { dispose() {} }; }, onDidChangeTextDocument: (fn) => { listeners.change.push(fn); return { dispose() {} }; }, onDidCloseTextDocument: (fn) => { listeners.close.push(fn); return { dispose() {} }; }, applyEdit: (we) => { appliedEdits.push(we); return Promise.resolve(true); }, }, window: { createOutputChannel: () => ({ append() {}, dispose() {} }), showWarningMessage: (m) => { statusMessages.push(m); }, setStatusBarMessage: (m) => { statusMessages.push(m); }, activeTextEditor: null, }, languages: { createDiagnosticCollection: (name) => { const c = { store: new Map(), set(uri, diags) { this.store.set(uri.toString(), diags); }, dispose() {}, }; collections[name] = c; return c; }, registerDocumentFormattingEditProvider: (lang, p) => { providers.format = p; return { dispose() {} }; }, registerDocumentRangeFormattingEditProvider: (lang, p) => { providers.rangeFormat = p; return { dispose() {} }; }, registerDocumentHighlightProvider: (lang, p) => { providers.highlight = p; return { dispose() {} }; }, registerDefinitionProvider: (lang, p) => { providers.definition = p; return { dispose() {} }; }, }, commands: { registerCommand: (name, fn) => { commands[name] = fn; return { dispose() {} }; }, }, }; const realResolve = Module._resolveFilename; Module._resolveFilename = function (request, ...rest) { if (request === 'vscode') return 'vscode'; return realResolve.call(this, request, ...rest); }; require.cache.vscode = { id: 'vscode', filename: 'vscode', loaded: true, exports: vscodeStub }; // --- helpers --------------------------------------------------------------- function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } async function waitFor(pred, what, ms = 8000) { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (pred()) return true; await sleep(25); } throw new Error('timeout waiting for ' + what); } function applyEditsToText(text, edits) { // Convert {line, character} ranges to offsets (fixtures are ASCII). const lineStart = [0]; for (let i = 0; i < text.length; i++) if (text[i] === '\n') lineStart.push(i + 1); const off = (p) => lineStart[p.line] + p.character; const resolved = edits.map((e) => ({ a: off(e.range.start), b: off(e.range.end), t: e.newText })); resolved.sort((x, y) => y.a - x.a); for (const e of resolved) text = text.slice(0, e.a) + e.t + text.slice(e.b); return text; } function setDocText(text) { fakeDoc.text = text; fakeDoc.version++; listeners.change.forEach((fn) => fn({ document: fakeDoc })); } // --- the test -------------------------------------------------------------- async function main() { const ext = require(path.join(extDir, 'extension.js')); const context = { extensionPath: extDir, subscriptions: [] }; ext.activate(context); // Activation opens the (unbalanced) preloaded document; diagnostics // arrive from the real server. await waitFor(() => (collections.klammertext && (collections.klammertext.store.get('file:///ext_test.kt') || []).length > 0), 'diagnostics'); const diags = collections.klammertext.store.get('file:///ext_test.kt'); check('activation + publishDiagnostics', diags.length === 1 && /never closed/.test(diags[0].message), JSON.stringify(diags.map((d) => d.message))); // Format Document == every indent fixture's expected file. for (const f of ['indent_list', 'indent_document', 'indent_table', 'indent_untouched', 'indent_defs', 'indent_escapes', 'indent_named_close']) { const src = fs.readFileSync(path.join(fixDir, f + '.kt'), 'utf8'); const exp = fs.readFileSync(path.join(fixDir, f + '_expected.kt'), 'utf8'); setDocText(src); const edits = await providers.format.provideDocumentFormattingEdits(fakeDoc); check('format ' + f, applyEditsToText(src, edits) === exp); } // Balanced text clears the diagnostics. setDocText('@i abc @\n'); await waitFor(() => (collections.klammertext.store.get('file:///ext_test.kt') || []).length === 0, 'diagnostics cleared'); check('diagnostics cleared on balanced text', true); // documentHighlight: the delimiter pair. const hl = await providers.highlight.provideDocumentHighlights(fakeDoc, new Position(0, 0)); check('documentHighlight pair', hl.length === 2, JSON.stringify(hl)); // jumpToMatch moves the cursor to the close. vscodeStub.window.activeTextEditor = { document: fakeDoc, selection: new Selection(new Position(0, 0), new Position(0, 0)), revealRange() {}, }; await commands['klammertext.jumpToMatch'](); const sel = vscodeStub.window.activeTextEditor.selection; check('jumpToMatch cursor at close', sel.active.character === 7, JSON.stringify(sel)); // alignTable round-trips through workspace/applyEdit. const src = fs.readFileSync(path.join(fixDir, 'align_mixed.kt'), 'utf8'); const exp = fs.readFileSync(path.join(fixDir, 'align_mixed_expected.kt'), 'utf8'); setDocText(src); const barOffset = src.indexOf('|'); const barLine = src.slice(0, barOffset).split('\n').length - 1; const barCol = barOffset - (src.lastIndexOf('\n', barOffset - 1) + 1); vscodeStub.window.activeTextEditor.selection = new Selection(new Position(barLine, barCol), new Position(barLine, barCol)); appliedEdits = []; await commands['klammertext.alignTable'](); await waitFor(() => appliedEdits.length > 0, 'applyEdit'); const edits = appliedEdits[0].edits.map((e) => ({ range: e.range, newText: e.newText })); check('alignTable via applyEdit', applyEditsToText(src, edits) === exp); ext.deactivate(); console.log('vscode_ext_test: %d passed, %d failed', passed, failed); process.exit(failed ? 1 : 0); } main().catch((err) => { console.error('vscode_ext_test: ' + err.stack); process.exit(1); });