258 lines
11 KiB
JavaScript
258 lines
11 KiB
JavaScript
// 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: [], selection: [] };
|
|
const decorationTypes = []; // in creation order: match, mismatch
|
|
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); },
|
|
},
|
|
ThemeColor: class ThemeColor {
|
|
constructor(id) { this.id = id; }
|
|
},
|
|
window: {
|
|
createOutputChannel: () => ({ append() {}, dispose() {} }),
|
|
showWarningMessage: (m) => { statusMessages.push(m); },
|
|
setStatusBarMessage: (m) => { statusMessages.push(m); },
|
|
createTextEditorDecorationType: (opts) => {
|
|
const t = { opts, dispose() {} };
|
|
decorationTypes.push(t);
|
|
return t;
|
|
},
|
|
onDidChangeTextEditorSelection: (fn) => { listeners.selection.push(fn); return { dispose() {} }; },
|
|
onDidChangeActiveTextEditor: () => ({ dispose() {} }),
|
|
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));
|
|
|
|
// live decorations: a bare close highlights its pair from any position.
|
|
const [matchType, mismatchType] = decorationTypes;
|
|
const editor = vscodeStub.window.activeTextEditor;
|
|
editor.decorations = new Map();
|
|
editor.setDecorations = (type, ranges) => editor.decorations.set(type, ranges);
|
|
editor.selection = new Selection(new Position(0, 7), new Position(0, 7));
|
|
listeners.selection.forEach((fn) => fn({ textEditor: editor }));
|
|
await waitFor(() => (editor.decorations.get(matchType) || []).length === 2,
|
|
'match decorations');
|
|
check('decorations: bare close boxes the pair', true);
|
|
|
|
// mismatch: red decoration type, match type cleared
|
|
setDocText('@ol x ul@\n');
|
|
editor.selection = new Selection(new Position(0, 0), new Position(0, 0));
|
|
listeners.selection.forEach((fn) => fn({ textEditor: editor }));
|
|
await waitFor(() => (editor.decorations.get(mismatchType) || []).length === 2,
|
|
'mismatch decorations');
|
|
check('decorations: mismatch in the red type',
|
|
(editor.decorations.get(matchType) || []).length === 0,
|
|
JSON.stringify([...editor.decorations.values()]));
|
|
|
|
// 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);
|
|
});
|