1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
|
// 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 [server-dir]
//
// the server directory holds what the packaged extension carries in server/:
// lsp.wasm, or tinyvm.js, layout.js, lsp.json and lsp.bin (the default is
// build/editors/vscode/server, which src/tools/mkvsix.sh fills). the extension is
// copied with that server into a scratch directory, since the tree has none.
const fs = require('fs');
const os = require('os');
const path = require('path');
const Module = require('module');
const serverDir = path.resolve(process.argv[2] || path.join(__dirname, '..', '..', 'build', 'editors', 'vscode', 'server'));
if (!fs.existsSync(serverDir)) { console.log('FAIL no server directory at ' + serverDir); process.exit(1); }
const staged = fs.mkdtempSync(path.join(os.tmpdir(), 'zc-extension-'));
fs.cpSync(path.join(__dirname, '..', '..', 'editors', 'vscode'), staged, { recursive: true });
fs.cpSync(serverDir, path.join(staged, 'server'), { recursive: true });
process.on('exit', () => fs.rmSync(staged, { recursive: true, force: true }));
const client = path.join(staged, '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; }
},
CompletionItem: class { constructor(label, kind) { Object.assign(this, { label, kind }); } },
CompletionList: class { constructor(items, isIncomplete) { Object.assign(this, { items, isIncomplete }); } },
CompletionItemKind: { Variable: 5 },
CompletionItemTag: { Deprecated: 1 },
CodeActionKind: { QuickFix: 'quickfix' },
CodeAction: class { constructor(title, kind) { Object.assign(this, { title, kind }); } },
WorkspaceEdit: class { constructor() { this.edits = []; } replace(uri, range, newText) { this.edits.push({ uri, range, newText }); } },
Hover: class { constructor(c) { this.contents = c; } },
Uri: { file: p => ({ toString: () => 'file://' + p, fsPath: p }), 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;
},
registerTextDocumentContentProvider: () => ({ dispose() {} }),
...['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() {} }),
registerCompletionItemProvider: (sel, p) => { world.completion = p; return { dispose() {} }; },
registerCodeActionsProvider: (sel, p) => { world.actions = p; return { dispose() {} }; },
registerHoverProvider: (sel, p) => { world.hover = p; return { dispose() {} }; },
registerDocumentFormattingEditProvider: () => ({ dispose() {} }),
registerDefinitionProvider: (sel, p) => { world.definition = p; return { dispose() {} }; },
registerReferenceProvider: (sel, p) => { world.references = 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));
const suggestions = await world.completion.provideCompletionItems(mainDoc, { line: 1, character: 4 }, { isCancellationRequested: false });
check('completion provider returns signatures and replacement ranges', suggestions instanceof stub.CompletionList && suggestions.items.some(c => c.detail && c.range && c.insertText), JSON.stringify(suggestions && suggestions.items.slice(0, 2)));
check('cancelled completion requests are ignored', await world.completion.provideCompletionItems(mainDoc, { line: 1, character: 4 }, { isCancellationRequested: true }) === null);
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)));
const expiredDoc = doc(path.join(root, 'expired.zc'), 'include rt; fn main() {\n let used = rt.new(int, 3);\n rt.free(used);\n \n}');
const expiredList = await world.completion.provideCompletionItems(expiredDoc, { line: 3, character: 1 }, { isCancellationRequested: false });
const expiredItem = expiredList && expiredList.items.find(c => c.label === 'used');
check('completion maps consumed lifetimes to the editor strikethrough tag', expiredItem && expiredItem.tags?.[0] === stub.CompletionItemTag.Deprecated && /lifetime ended/.test(expiredItem.documentation?.value));
const actionDoc = doc(path.join(root, 'imports.zc'), 'fn main() { rt.put_n(7); }');
const actions = await world.actions.provideCodeActions(actionDoc, new stub.Range(0, 15, 0, 15), {}, { isCancellationRequested: false });
check('quick fixes reach the editor as workspace edits', actions.some(a => a.kind === 'quickfix' && a.edit.edits.some(e => e.newText === 'include rt;\n')));
const cancelled = await world.actions.provideCodeActions(actionDoc, new stub.Range(0, 15, 0, 15), {}, { isCancellationRequested: true });
check('cancelled quick-fix requests produce no editor edits', cancelled.length === 0);
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); });
|