diff options
Diffstat (limited to 'tests/drivers/ext_cache_test.js')
| -rw-r--r-- | tests/drivers/ext_cache_test.js | 154 |
1 files changed, 154 insertions, 0 deletions
diff --git a/tests/drivers/ext_cache_test.js b/tests/drivers/ext_cache_test.js new file mode 100644 index 0000000..11bf50b --- /dev/null +++ b/tests/drivers/ext_cache_test.js @@ -0,0 +1,154 @@ +// 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); }); |
