aboutsummaryrefslogtreecommitdiff
path: root/tests/drivers/ext_cache_test.js
diff options
context:
space:
mode:
Diffstat (limited to 'tests/drivers/ext_cache_test.js')
-rw-r--r--tests/drivers/ext_cache_test.js154
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); });