// the extension's diagnostics cache, end to end: the real client // (editors/vscode/client/extension.js) with a stub for the vscode module, and // the built-in wasm server. the checks: a first session asks the server and // saves the answer; a second session shows the saved answer before the server // starts; a change to an included file is checked again; putting it back is // answered from the cache. // // node tests/drivers/ext_cache_test.js const fs = require('fs'); const os = require('os'); const path = require('path'); const Module = require('module'); const client = path.join(__dirname, '..', '..', 'editors', 'vscode', 'client'); // the vscode module, as far as the client uses it const world = { lines: [], sets: [], listeners: {}, watchers: [], textDocuments: [], saved: undefined, root: '' }; const stub = { EventEmitter: class { constructor() { this.event = () => ({ dispose() {} }); } fire() {} }, Diagnostic: class { constructor(range, message, severity) { Object.assign(this, { range, message, severity }); } }, Range: class { constructor(a, b, c, d) { Object.assign(this, { start: { line: a, character: b }, end: { line: c, character: d } }); } }, DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 }, SemanticTokensLegend: class { constructor(t, m) { this.tokenTypes = t; this.tokenModifiers = m; } }, SemanticTokensBuilder: class { constructor() {} push() {} build() { return null; } }, MarkdownString: class { constructor(v = '') { this.value = v; this.escaped = false; } appendText(v) { this.value += v; this.escaped = true; return this; } appendMarkdown(v) { this.value += v; return this; } appendCodeblock(v, language) { this.value += '\n```' + language + '\n' + v + '\n```\n'; return this; } }, Hover: class { constructor(c) { this.contents = c; } }, Uri: { parse: s => ({ toString: () => s, fsPath: decodeURIComponent(s.replace(/^file:\/\//, '')) }) }, workspace: { workspaceFolders: undefined, textDocuments: world.textDocuments, getConfiguration: () => ({ get: () => undefined }), createFileSystemWatcher: () => { const w = { handlers: {}, dispose() {} }; world.watchers.push(w); for (const k of ['onDidChange', 'onDidCreate', 'onDidDelete']) w[k] = f => { w.handlers[k] = f; return { dispose() {} }; }; return w; }, ...['onDidChangeConfiguration', 'onDidOpenTextDocument', 'onDidChangeTextDocument', 'onDidSaveTextDocument', 'onDidCloseTextDocument'] .reduce((o, k) => { o[k] = f => { world.listeners[k] = f; return { dispose() {} }; }; return o; }, {}), }, window: { createOutputChannel: () => ({ appendLine: s => world.lines.push(String(s)), show() {}, dispose() {} }), showWarningMessage() {}, showErrorMessage() {}, }, languages: { createDiagnosticCollection: () => ({ set: (uri, list) => world.sets.push({ uri: uri.toString(), list, at: world.lines.length }), delete() {}, dispose() {}, }), registerDocumentSemanticTokensProvider: () => ({ dispose() {} }), registerHoverProvider: (sel, p) => { world.hover = p; return { dispose() {} }; }, }, commands: { registerCommand: () => ({ dispose() {} }) }, }; const realResolve = Module._resolveFilename; Module._resolveFilename = function (req, ...rest) { return req === 'vscode' ? 'vscode' : realResolve.call(this, req, ...rest); }; require.cache['vscode'] = { id: 'vscode', filename: 'vscode', loaded: true, exports: stub }; let passed = 0, failed = 0; function check(name, ok, detail) { if (ok) { passed++; console.log('ok ' + name); } else { failed++; console.log('FAIL ' + name + (detail ? ': ' + detail : '')); } } const sleep = ms => new Promise(r => setTimeout(r, ms)); async function until(pred, ms = 20000) { const end = Date.now() + ms; while (Date.now() < end) { if (pred()) return true; await sleep(50); } return pred(); } function freshClient() { for (const f of Object.keys(require.cache)) if (f.startsWith(client)) delete require.cache[f]; return require(path.join(client, 'extension.js')); } const root = fs.mkdtempSync(path.join(os.tmpdir(), 'zc-extcache-')); stub.workspace.workspaceFolders = [{ uri: { fsPath: root } }]; const helper = path.join(root, 'helper.zc'); const main = path.join(root, 'main.zc'); const helperOk = 'fn helper() { return 1; }\n'; const helperBad = 'fn helper2() { return 1; }\n'; fs.writeFileSync(helper, helperOk); const mainText = 'include "helper.zc";\nfn main() { return helper(); }\n'; fs.writeFileSync(main, mainText); function doc(p, text) { const d = { uri: { toString: () => 'file://' + p, fsPath: p }, languageId: 'zc', version: 1, text, getText() { return this.text; } }; return d; } const ctx = () => ({ subscriptions: [], globalState: { get: () => world.saved, update: (k, v) => { world.saved = JSON.parse(JSON.stringify(v)); return Promise.resolve(); } }, }); const mainDoc = doc(main, mainText); const mainUri = 'file://' + main; (async () => { // session one: nothing cached, so the server is asked world.textDocuments.push(mainDoc); let ext = freshClient(); let c1 = ctx(); ext.activate(c1); check('session one: the server starts and checks the document', await until(() => world.lines.some(l => /server ready/.test(l))) && await until(() => world.sets.some(s => s.uri === mainUri))); const first = world.sets.filter(s => s.uri === mainUri).pop(); check('session one: the server answered with a list', first && Array.isArray(first.list), JSON.stringify(first && first.list)); await sleep(800); const savedKeys = Object.keys(world.saved || {}); // the hover is the server's markdown, parsed: a zc code block, not escaped text const hv = world.hover && await world.hover.provideHover(mainDoc, { line: 1, character: 4 }); const hc = hv && hv.contents[0]; check('the hover is markdown with a zc code block, parsed and not escaped', hc && !hc.escaped && /^```zc\n/.test(hc.value) && /\n```/.test(hc.value), JSON.stringify(hc)); check('session one: the answer is saved under a key', savedKeys.length === 1 && /^[0-9a-f]{64}$/.test(savedKeys[0]), JSON.stringify(savedKeys)); ext.deactivate(); await sleep(700); // session two: the saved answer is shown before the server has started world.lines.length = 0; world.sets.length = 0; ext = freshClient(); ext.activate(ctx()); check('session two: the saved diagnostics are shown at once', world.sets.some(s => s.uri === mainUri) && world.lines.some(l => /from the cache/.test(l))); check('session two: the server has not started yet', !world.lines.some(l => /server ready/.test(l))); await until(() => world.lines.some(l => /server ready/.test(l))); await sleep(300); check('session two: the server was not asked about the document', !world.lines.some(l => /-> textDocument\/didOpen|-> textDocument\/didChange/.test(l)), JSON.stringify(world.lines.slice(0, 6))); check('session two: the cache holds the same key as before', JSON.stringify(Object.keys(world.saved || {})) === JSON.stringify(savedKeys)); // a change to the included file, saved: the document is checked again and the error shows world.lines.length = 0; world.sets.length = 0; fs.writeFileSync(helper, helperBad); for (const h of world.watchers) if (h.handlers.onDidChange) h.handlers.onDidChange({ fsPath: helper }); check('an included file changed: the document is checked again', await until(() => world.lines.some(l => /-> textDocument\/didOpen|-> textDocument\/didChange/.test(l)) && world.sets.some(s => s.uri === mainUri)), JSON.stringify(world.lines.slice(-4))); await sleep(900); check('an included file changed: the new state is saved under its own key', Object.keys(world.saved || {}).length === 2, JSON.stringify(Object.keys(world.saved || {}))); // the file goes back: its tree is the one the cache has, so the old answer is shown from the cache world.lines.length = 0; world.sets.length = 0; fs.writeFileSync(helper, helperOk); for (const h of world.watchers) if (h.handlers.onDidChange) h.handlers.onDidChange({ fsPath: helper }); await sleep(200); check('the old tree is answered from the cache, without asking the server', world.lines.some(l => /from the cache/.test(l)) && !world.lines.some(l => /-> textDocument\/didChange/.test(l)), JSON.stringify(world.lines.slice(-4))); ext.deactivate(); await sleep(700); fs.rmSync(root, { recursive: true, force: true }); console.log(passed + ' passed, ' + failed + ' failed'); process.exit(failed ? 1 : 0); })().catch(e => { console.log('FAIL harness: ' + (e.stack || e)); process.exit(1); });