File size: 13,173 Bytes
794b22f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0af212d
 
794b22f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/* test-designer-host.mjs: the editor side of the designer and the extension's wiring.
 * There is no VS Code in this environment, so the real extension.js is loaded against a FAKE
 * `vscode` module. That is enough to catch the failures that actually break extensions:
 * a command declared in package.json but never registered (or the reverse), a webview that
 * points at a file that is not in the package, and an Insert that edits the wrong place.
 * It is not a substitute for opening the .vsix in VS Code once. */
import { createRequire } from 'node:module';
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join, dirname, resolve as pathResolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import Module from 'node:module';
import { VERSION } from '../public/dssl/index.js';
import { designerHtml, handleMessage, DESIGNER_TABS } from '../dssl-vscode/lib/designer-host.js';

let pass = 0, fail = 0;
const ok = (name, cond, extra) => { if (cond) { pass++; console.log(`  ok  ${name}`); } else { fail++; console.log(`FAIL  ${name}${extra !== undefined ? '; ' + extra : ''}`); } };
const section = (t) => console.log(`\n== ${t}`);
const ROOT = pathResolve(dirname(fileURLToPath(import.meta.url)), '..');
const EXT = join(ROOT, 'dssl-vscode');
const pkg = JSON.parse(readFileSync(join(EXT, 'package.json'), 'utf8'));

section('the package manifest');
const mm = (v) => v.split('.').slice(0, 2).join('.');
ok('the version line (major.minor) matches the engine it bundles; patch may differ for extension-only releases', mm(pkg.version) === mm(VERSION), `${pkg.version} vs ${VERSION}`);
ok('main points at a file that exists', existsSync(join(EXT, pkg.main)));
ok('the icon exists', existsSync(join(EXT, pkg.icon)));
const declared = pkg.contributes.commands.map((c) => c.command);
ok('every command has a title and the DSSL category', pkg.contributes.commands.every((c) => c.title && c.category === 'DSSL'));
ok('every command can activate the extension (onCommand or a language event)', declared.every((c) => pkg.activationEvents.includes(`onCommand:${c}`)));
const menuCmds = Object.values(pkg.contributes.menus).flat().map((m) => m.command);
ok('every menu entry points at a declared command', menuCmds.every((c) => declared.includes(c)), menuCmds.filter((c) => !declared.includes(c)).join());
ok('the extension ships the engine copies it needs (every module the resolver imports)', ['parser', 'resolver', 'utils', 'color', 'texture', 'aura', 'slider', 'lint', 'import'].every((f) => existsSync(join(EXT, 'lib', 'engine', f + '.js'))));
const same = ['parser', 'resolver', 'utils', 'color', 'texture', 'aura', 'slider', 'lint', 'import'].filter((f) => readFileSync(join(EXT, 'lib', 'engine', f + '.js'), 'utf8') !== readFileSync(join(ROOT, 'public', 'dssl', f + '.js'), 'utf8'));
ok('and they are byte-identical to the engine (run scripts/sync-extension-engine.mjs if not)', same.length === 0, same.join());

section('the panel HTML');
const html = designerHtml({ cspSource: 'vscode-webview://abc', uri: (p) => `vscode-webview://abc/${p}`, tab: 'texture' });
ok('it carries a strict content security policy', /default-src 'none'/.test(html) && /script-src vscode-webview:\/\/abc;/.test(html) && !/unsafe-eval/.test(html));
ok('scripts only run from the extension itself, with no inline script', !/<script(?![^>]*src=)/.test(html));
ok('it opens on the requested tab, and falls back for nonsense', /data-tab="texture"/.test(html) && /data-tab="aura"/.test(designerHtml({ cspSource: 'x', uri: (p) => p, tab: '"><script>' })));
const refs = [...html.matchAll(/(?:src|href)="vscode-webview:\/\/abc\/([^"]+)"/g)].map((m) => m[1]);
ok('every file the page names exists in the package', refs.length === 2 && refs.every((r) => existsSync(join(EXT, r))), refs.join());
{
  // follow the ES-module imports of the panel script: each must resolve inside the extension
  const seen = new Set(), missing = [];
  const walk = (rel) => {
    if (seen.has(rel)) return; seen.add(rel);
    const full = join(EXT, rel);
    if (!existsSync(full)) { missing.push(rel); return; }
    for (const m of readFileSync(full, 'utf8').matchAll(/from '(\.[^']+)'/g)) walk(pathResolve(dirname(full), m[1]).slice(EXT.length + 1));
  };
  walk('media/designer.js');
  ok(`all ${seen.size} modules the panel imports resolve inside the extension`, missing.length === 0 && seen.size >= 10, missing.join());
}
ok('the three tabs are known', DESIGNER_TABS.join() === 'aura,texture,slider');

section('messages from the panel');
{
  const calls = [];
  const editor = { insert: async (t) => calls.push(['insert', t]), selectionText: () => 'aura-glow: #fff 20' };
  const api = (ed) => ({ activeDsslEditor: () => ed, copy: async (t) => calls.push(['copy', t]), info: (m) => calls.push(['info', m]), post: (m) => calls.push(['post', m]) });
  await handleMessage({ type: 'insert', text: 'texture: grain' }, api(editor));
  ok('insert goes to the .dssl editor', calls.some((c) => c[0] === 'insert' && c[1] === 'texture: grain'));
  calls.length = 0;
  await handleMessage({ type: 'insert', text: 'texture: grain' }, api(null));
  ok('with no .dssl editor open it says so (and keeps the text in the panel)', calls.length === 1 && calls[0][0] === 'info' && /Open a \.dssl file/.test(calls[0][1]));
  calls.length = 0;
  await handleMessage({ type: 'insert', text: '' }, api(editor)); await handleMessage({ type: 'insert' }, api(editor)); await handleMessage({ type: 'insert', text: 5 }, api(editor));
  ok('an empty or non-text insert does nothing', calls.length === 0);
  await handleMessage({ type: 'copy', text: 'x' }, api(editor));
  ok('copy writes the clipboard', calls.some((c) => c[0] === 'copy' && c[1] === 'x'));
  calls.length = 0;
  await handleMessage({ type: 'requestSelection' }, api(editor));
  ok('requestSelection answers with the selected text', calls.length === 1 && calls[0][1].type === 'selection' && calls[0][1].text === 'aura-glow: #fff 20');
  calls.length = 0;
  for (const bad of [null, undefined, 5, 'str', [], {}, { type: 'rm -rf' }, { type: 'insert', text: {} }]) await handleMessage(bad, api(editor));
  ok('junk messages are ignored without throwing', calls.length === 0);
}

section('the real extension.js against a fake vscode');
{
  const registered = new Map();
  const createdPanels = [];
  const infos = [];
  const editors = [];
  const noop = () => {};
  const disposable = { dispose: noop };
  class Range { constructor(...a) { this.a = a; } }
  const makeEditor = (lines, line = 1, col = 0, selected = '') => {
    const edits = [];
    const ed = {
      document: { languageId: 'dssl', lineAt: (n) => ({ text: lines[n] }), getText: () => selected, uri: { fsPath: '/x/a.dssl' } },
      selection: { start: { line, character: col }, active: { line, character: col }, isEmpty: !selected },
      edit: async (fn) => { fn({ insert: (pos, t) => edits.push(['insert', t]), replace: (sel, t) => edits.push(['replace', t]) }); return true; },
      edits,
    };
    return ed;
  };
  const vscode = {
    CompletionItemKind: new Proxy({}, { get: () => 0 }), DiagnosticSeverity: { Error: 0, Warning: 1 }, ViewColumn: { Beside: 2, Active: -1 },
    Range, Diagnostic: class { constructor(r, m, s) { Object.assign(this, { r, m, s }); } }, MarkdownString: class { constructor(v) { this.value = v; } }, Hover: class {},
    Uri: { file: (p) => ({ fsPath: p }), joinPath: (base, ...p) => ({ fsPath: join(base.fsPath, ...p) }) },
    languages: { createDiagnosticCollection: () => ({ clear: noop, set: noop, dispose: noop }), registerCompletionItemProvider: () => disposable, registerHoverProvider: () => disposable },
    workspace: {
      onDidChangeTextDocument: () => disposable, onDidOpenTextDocument: () => disposable, onDidChangeWorkspaceFolders: () => disposable,
      findFiles: async () => [], getConfiguration: () => ({ get: (k, d) => d }), openTextDocument: async () => ({ getText: () => '' }),
      fs: { readFile: async () => Buffer.from(''), writeFile: async () => {}, stat: async () => { throw new Error('none'); } },
    },
    window: {
      activeTextEditor: null, visibleTextEditors: editors, onDidChangeActiveTextEditor: () => disposable,
      showInformationMessage: async (m) => { infos.push(m); }, showErrorMessage: async (m) => { infos.push('ERR ' + m); }, setStatusBarMessage: (m) => infos.push(m),
      createOutputChannel: () => ({ clear: noop, appendLine: noop, show: noop }), showTextDocument: async () => ({}),
      createWebviewPanel: (id, title, col, opts) => {
        const messages = [];
        let onMsg = null, onDispose = null;
        const panel = {
          id, title, opts, revealed: 0, reveal() { this.revealed++; },
          webview: { cspSource: 'vscode-webview://test', html: '', asWebviewUri: (u) => ({ toString: () => 'vscode-webview://test' + u.fsPath.slice(EXT.length) }), onDidReceiveMessage: (fn) => { onMsg = fn; return disposable; }, postMessage: async (m) => { messages.push(m); return true; } },
          onDidDispose: (fn) => { onDispose = fn; return disposable; },
          messages, send: (m) => onMsg(m), dispose: () => onDispose && onDispose(),
        };
        createdPanels.push(panel);
        return panel;
      },
    },
    commands: { registerCommand: (id, fn) => { registered.set(id, fn); return disposable; } },
    env: { clipboard: { writeText: async () => {} } },
  };
  const origLoad = Module._load;
  Module._load = function (request, ...rest) { return request === 'vscode' ? vscode : origLoad.call(this, request, ...rest); };
  const req = createRequire(import.meta.url);
  const ext = req(join(EXT, 'extension.js'));
  const context = { subscriptions: [], extensionUri: { fsPath: EXT } };
  await ext.activate(context);
  Module._load = origLoad;

  ok('activate() succeeds against the fake editor API', true);
  ok('every command declared in package.json is registered', declared.every((c) => registered.has(c)), declared.filter((c) => !registered.has(c)).join());
  ok('and nothing is registered that package.json does not declare', [...registered.keys()].every((c) => declared.includes(c)), [...registered.keys()].filter((c) => !declared.includes(c)).join());

  await registered.get('dssl.openTextureDesigner')();
  const panel = createdPanels[0];
  ok('opening the texture designer creates one panel with scripts on and the extension as its only resource root', panel && panel.opts.enableScripts === true && panel.opts.localResourceRoots.length === 1);
  ok('its HTML opens on the texture tab', /data-tab="texture"/.test(panel.webview.html));
  const files = [...panel.webview.html.matchAll(/vscode-webview:\/\/test\/([^"]+)"/g)].map((m) => m[1]);
  ok('and names files that exist', files.length === 2 && files.every((f) => existsSync(join(EXT, f))), files.join());
  await registered.get('dssl.openSliderDesigner')();
  ok('opening again reuses the panel and just switches tab', createdPanels.length === 1 && panel.revealed === 1 && panel.messages.some((m) => m.type === 'tab' && m.tab === 'slider'));

  // the editor the panel inserts into
  const ed = makeEditor(['.card {', '  color: red', '}'], 1, 14);
  editors.push(ed);
  panel.send({ type: 'insert', text: 'aura-glow: #f00 20\naura-particle: #fff 10' });
  await new Promise((r) => setTimeout(r, 30));
  ok('Insert edits the .dssl editor and indents every new line like the line it sits on', ed.edits.length === 1 && ed.edits[0][0] === 'insert' && ed.edits[0][1] === 'aura-glow: #f00 20\n  aura-particle: #fff 10', JSON.stringify(ed.edits));
  const sel = makeEditor(['  x'], 0, 2, 'texture: grain');
  editors.length = 0; editors.push(sel);
  panel.send({ type: 'insert', text: 'texture: wood' }); await new Promise((r) => setTimeout(r, 30));
  ok('with a selection, Insert replaces it', sel.edits[0] && sel.edits[0][0] === 'replace');
  panel.send({ type: 'requestSelection' }); await new Promise((r) => setTimeout(r, 30));
  ok('Load from selection sends the selected text back to the panel', panel.messages.some((m) => m.type === 'selection' && m.text === 'texture: grain'));
  editors.length = 0;
  panel.send({ type: 'insert', text: 'x: 1' }); await new Promise((r) => setTimeout(r, 30));
  ok('with no .dssl editor visible the panel is told to open one', infos.some((m) => /Open a \.dssl file/.test(m)));
  panel.dispose();
  await registered.get('dssl.openDesigner')();
  ok('closing the panel and opening the designer again makes a fresh one', createdPanels.length === 2);

  await registered.get('dssl.convertCss')({ fsPath: '/tmp/not-a-css.txt' });
  ok('converting something that is not .css explains instead of failing', infos.some((m) => /pick a \.css file/.test(m)));
}

section('the media folder');
{
  const media = readdirSync(join(EXT, 'media'));
  ok('the panel assets are all in the package folder', ['designer.js', 'designer.css', 'designer-core.js'].every((f) => media.includes(f)));
  const ignore = readFileSync(join(EXT, '.vscodeignore'), 'utf8');
  ok('.vscodeignore does not exclude the panel, the engine copies or the media', !/^(media|lib)/m.test(ignore));
}

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);