|
| 1 | +/*--------------------------------------------------------------------------------------------- |
| 2 | + * Every icon anyone names is one the chat can draw — run: node test/chatIcons.test.js |
| 3 | + * |
| 4 | + * WHY THIS EXISTS. media/chat.html draws its icons from an inline table (`IC`) through |
| 5 | + * codicon(name), and a name the table lacks is not an error — it is PRINTED. The incident: the chat |
| 6 | + * moved to an editor tab and its banner read "layout MOVED TO THE EDITOR <title>", the word where |
| 7 | + * the glyph belongs. `globe` (the preview chip) and `history` (the recall and project-memory chips) |
| 8 | + * had been printing into the timeline rail the same way. |
| 9 | + * |
| 10 | + * Nothing failed, because the contract has two halves in two files and neither can see the other: |
| 11 | + * the HOST names an icon as the `icon:` on a message (extension.js, agent.js) and the WEBVIEW owns |
| 12 | + * the table. A name can be added on one side, or dropped from the other, and both still compile. |
| 13 | + * |
| 14 | + * So this runs the REAL table through the REAL codicon() — lifted out of the shipped chat.html the |
| 15 | + * way narrativeUi and sessionsUi lift theirs, not a copy that can drift — over every name written |
| 16 | + * down on either side, and fails on any that comes back as text instead of an <svg>. |
| 17 | + * |
| 18 | + * WHAT IT CANNOT SEE: a name computed at run time. A literal is the only thing a source reader can |
| 19 | + * check, so a host `icon:` built from a variable is invisible here. §3 closes that from the |
| 20 | + * webview's side, where it can be closed: every codicon() call must take its name from somewhere |
| 21 | + * this file reads, so a new source of names fails until it is taught to this file. |
| 22 | + *--------------------------------------------------------------------------------------------*/ |
| 23 | +// @ts-check |
| 24 | +'use strict'; |
| 25 | + |
| 26 | +const assert = require('assert'); |
| 27 | +const fs = require('fs'); |
| 28 | +const path = require('path'); |
| 29 | + |
| 30 | +const ROOT = path.join(__dirname, '..'); |
| 31 | +const html = fs.readFileSync(path.join(ROOT, 'media', 'chat.html'), 'utf8'); |
| 32 | + |
| 33 | +let n = 0; |
| 34 | +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } |
| 35 | + |
| 36 | +// The webview's one inline <script> — the same slice narrativeUi compiles. |
| 37 | +const scriptOpen = html.lastIndexOf('<script'); |
| 38 | +const scriptStart = html.indexOf('>', scriptOpen) + 1; |
| 39 | +const scriptEnd = html.indexOf('</script>', scriptStart); |
| 40 | +assert.ok(scriptOpen >= 0 && scriptEnd > scriptStart, 'no <script> block found in chat.html'); |
| 41 | +const script = html.slice(scriptStart, scriptEnd); |
| 42 | + |
| 43 | +/** 1-based line of `index` in `text` — every failure below points at a line. */ |
| 44 | +const lineOf = (text, index) => text.slice(0, index).split('\n').length; |
| 45 | +const scriptLine = (index) => lineOf(html, scriptStart + index); |
| 46 | + |
| 47 | +/** Every match of a global `re` in `text`, each with exec()'s `index`. */ |
| 48 | +function all(re, text) { |
| 49 | + const out = []; |
| 50 | + let m; |
| 51 | + while ((m = re.exec(text))) { out.push(m); } |
| 52 | + return out; |
| 53 | +} |
| 54 | + |
| 55 | +// ---- The real table and the real lookup, lifted out of the shipped file --------------------------- |
| 56 | + |
| 57 | +/** A one-line helper, whole: from `marker` to the end of its line. */ |
| 58 | +function oneLiner(marker) { |
| 59 | + const at = script.indexOf(marker); |
| 60 | + assert.ok(at >= 0, 'chat.html no longer has `' + marker + '`'); |
| 61 | + return script.slice(at, script.indexOf('\n', at)); |
| 62 | +} |
| 63 | +const tableAt = script.indexOf('const IC = {'); |
| 64 | +const tableEnd = script.indexOf('\n };', tableAt); |
| 65 | +assert.ok(tableAt >= 0 && tableEnd > tableAt, 'chat.html no longer defines the inline icon table `IC`'); |
| 66 | +let W; |
| 67 | +try { |
| 68 | + // eslint-disable-next-line no-new-func |
| 69 | + W = new Function( |
| 70 | + oneLiner('function esc(') + '\n' + script.slice(tableAt, tableEnd + 5) + '\n' + oneLiner('function codicon(') |
| 71 | + + '\nreturn { IC: IC, codicon: codicon };' |
| 72 | + )(); |
| 73 | +} catch (e) { |
| 74 | + assert.fail('could not lift `IC` and codicon() out of chat.html (' + String((e && e.message) || e) |
| 75 | + + ') — this file expects esc() and codicon() to stay one-line helpers'); |
| 76 | +} |
| 77 | +const SET = Object.keys(W.IC); |
| 78 | + |
| 79 | +/** What the webview would put on screen for `name`: true for a glyph, false for the bug — the name, as text. */ |
| 80 | +const draws = (name) => Object.prototype.hasOwnProperty.call(W.IC, name) && /^<svg /.test(W.codicon(name)); |
| 81 | + |
| 82 | +// ---- Everywhere a name is written down ------------------------------------------------------------ |
| 83 | + |
| 84 | +/** |
| 85 | + * Every codicon(…) CALL in the script, as { arg, line } — `arg` is the text of its first argument. |
| 86 | + * Scanned outward from each call's own "(" rather than by tokenising the whole script: the script |
| 87 | + * holds regex literals (`/"/g`) that a string-tracking pass over the file would trip on. |
| 88 | + */ |
| 89 | +function codiconCalls() { |
| 90 | + const out = []; |
| 91 | + for (const m of all(/\bcodicon\(/g, script)) { |
| 92 | + const before = script.slice(script.lastIndexOf('\n', m.index) + 1, m.index); |
| 93 | + if (/^\s*\/\//.test(before) || /function\s+$/.test(before)) { continue; } // a comment about it, or its definition |
| 94 | + const from = m.index + m[0].length; |
| 95 | + let depth = 1, i = from, str = '', end = -1; |
| 96 | + for (; i < script.length && depth > 0; i++) { |
| 97 | + const c = script[i]; |
| 98 | + if (str) { if (c === str && script[i - 1] !== '\\') { str = ''; } continue; } |
| 99 | + if (c === "'" || c === '"' || c === '`') { str = c; } |
| 100 | + else if (c === '(' || c === '[' || c === '{') { depth++; } |
| 101 | + else if (c === ')' || c === ']' || c === '}') { depth--; } |
| 102 | + else if (c === ',' && depth === 1 && end < 0) { end = i; } // codicon(name, cls): the name is the first argument |
| 103 | + } |
| 104 | + assert.strictEqual(depth, 0, 'unbalanced codicon( at chat.html:' + scriptLine(m.index)); |
| 105 | + out.push({ arg: script.slice(from, end < 0 ? i - 1 : end).trim(), line: scriptLine(m.index) }); |
| 106 | + } |
| 107 | + return out; |
| 108 | +} |
| 109 | + |
| 110 | +/** The string literals in an expression, minus any that sit inside a nested call's parentheses. */ |
| 111 | +function literals(expr) { |
| 112 | + let flat = expr, prev; |
| 113 | + do { prev = flat; flat = flat.replace(/\([^()]*\)/g, ''); } while (flat !== prev); |
| 114 | + return all(/'([^'\\]*)'|"([^"\\]*)"/g, flat).map((m) => m[1] !== undefined ? m[1] : m[2]); |
| 115 | +} |
| 116 | + |
| 117 | +/** Every name chat.html writes down itself, with where. */ |
| 118 | +function webviewNames() { |
| 119 | + const out = []; |
| 120 | + for (const c of codiconCalls()) { |
| 121 | + for (const name of literals(c.arg)) { out.push({ name, where: 'chat.html:' + c.line + ' codicon(' + c.arg + ')' }); } |
| 122 | + } |
| 123 | + // A name chosen first and drawn a line later: `icon = ok ? 'check-circle' : 'circle-slash'; … codicon(icon)`. |
| 124 | + for (const m of all(/\bicon = ([^;\n]+);/g, script)) { |
| 125 | + for (const name of literals(m[1])) { out.push({ name, where: 'chat.html:' + scriptLine(m.index) + ' icon = ' + m[1] }); } |
| 126 | + } |
| 127 | + // A name in the markup, drawn at startup by the [data-ico] sweep. |
| 128 | + for (const m of all(/data-ico="([^"]*)"/g, html)) { |
| 129 | + out.push({ name: m[1], where: 'chat.html:' + lineOf(html, m.index) + ' ' + m[0] }); |
| 130 | + } |
| 131 | + return out; |
| 132 | +} |
| 133 | + |
| 134 | +// Host files whose `icon:` fields are NOT messages to this webview, and why. Everything else is |
| 135 | +// checked: an `icon:` this file cannot place fails, rather than passing because nobody looked. |
| 136 | +const NOT_CHAT = new Map([ |
| 137 | + ['sketch/agentCatalog.js', 'palette groups for the Sketch canvas (media/sketch.html), which shows their label and draws no glyph'], |
| 138 | +]); |
| 139 | + |
| 140 | +/** Every .js file the extension ships, relative to its root. The webview (media/) and the tests are not host code. */ |
| 141 | +function hostFiles(dir, rel) { |
| 142 | + let out = []; |
| 143 | + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { |
| 144 | + const r = rel ? rel + '/' + e.name : e.name; |
| 145 | + if (e.isDirectory()) { |
| 146 | + if (!['test', 'media', 'node_modules'].includes(e.name)) { out = out.concat(hostFiles(path.join(dir, e.name), r)); } |
| 147 | + } else if (e.name.endsWith('.js')) { out.push(r); } |
| 148 | + } |
| 149 | + return out.sort(); |
| 150 | +} |
| 151 | + |
| 152 | +/** Every `icon: '<name>'` literal in host code, with where. */ |
| 153 | +function hostNames() { |
| 154 | + const out = []; |
| 155 | + for (const rel of hostFiles(ROOT, '')) { |
| 156 | + if (NOT_CHAT.has(rel)) { continue; } |
| 157 | + const src = fs.readFileSync(path.join(ROOT, rel), 'utf8'); |
| 158 | + for (const m of all(/\bicon:\s*(['"])([^'"\n]*)\1/g, src)) { |
| 159 | + out.push({ name: m[2], where: rel + ':' + lineOf(src, m.index) + ' ' + m[0] }); |
| 160 | + } |
| 161 | + } |
| 162 | + return out; |
| 163 | +} |
| 164 | + |
| 165 | +/** Fail with EVERY undrawable name at once — one run should state the whole problem, not its first line. */ |
| 166 | +function assertDrawn(found, who) { |
| 167 | + const bad = found.filter((f) => !draws(f.name)); |
| 168 | + assert.ok(!bad.length, |
| 169 | + who + ' an icon the inline set does not define — the chat would PRINT the name where the glyph belongs:\n' |
| 170 | + + bad.map((f) => ' "' + f.name + '" <- ' + f.where).join('\n') |
| 171 | + + '\n Add it to `IC` in media/chat.html (viewBox + path from @vscode/codicons), or use one the set has: ' |
| 172 | + + SET.join(', ')); |
| 173 | +} |
| 174 | + |
| 175 | +// ---- 1. The set --------------------------------------------------------------------------------- |
| 176 | + |
| 177 | +test('SET: every entry is something codicon() can draw', () => { |
| 178 | + // narrativeUi proves the table PARSES. This is the next thing a bad paste breaks: an entry that is |
| 179 | + // valid JavaScript and an empty picture. |
| 180 | + assert.ok(SET.length > 0, 'the inline icon table is empty'); |
| 181 | + for (const name of SET) { |
| 182 | + const i = W.IC[name]; |
| 183 | + assert.match(String(i.vb), /^[\d.]+( [\d.]+){3}$/, name + ' has no usable viewBox'); |
| 184 | + assert.match(String(i.p), /^<\w[\s\S]*>$/, name + ' has no markup to draw'); |
| 185 | + assert.ok(draws(name), name + ' is in the table but codicon() does not draw it'); |
| 186 | + } |
| 187 | +}); |
| 188 | + |
| 189 | +// ---- 2. Both sides of the contract --------------------------------------------------------------- |
| 190 | + |
| 191 | +test('WEBVIEW: every name chat.html writes down is in the set', () => { |
| 192 | + assertDrawn(webviewNames(), 'chat.html names'); |
| 193 | +}); |
| 194 | + |
| 195 | +test('HOST: every icon the host puts on a message is in the set', () => { |
| 196 | + // The half the webview cannot see. `layout`, `globe` and `history` all came through here. |
| 197 | + assertDrawn(hostNames(), 'the host posts'); |
| 198 | +}); |
| 199 | + |
| 200 | +// ---- 3. The limits of a source reader ------------------------------------------------------------ |
| 201 | + |
| 202 | +// A call that does not spell its name out takes it from somewhere else. Each such shape is listed |
| 203 | +// with where that somewhere is read above. (`m.icon || 'sync'` needs no entry: it spells its default, |
| 204 | +// and `m.icon` is the host's half.) |
| 205 | +const INDIRECT = new Map([ |
| 206 | + ['icon', 'a local set from literals a line above (webviewNames), or the host\'s `icon:` handed to addAgentLine (hostNames)'], |
| 207 | + ["s.getAttribute('data-ico')", 'a data-ico="…" attribute in the markup (webviewNames)'], |
| 208 | +]); |
| 209 | + |
| 210 | +test('SHAPE: no codicon() call takes its name from somewhere this file does not read', () => { |
| 211 | + const unread = codiconCalls().filter((c) => !literals(c.arg).length && !INDIRECT.has(c.arg)); |
| 212 | + assert.ok(!unread.length, |
| 213 | + 'codicon() is called with a name this test cannot trace to a literal:\n' |
| 214 | + + unread.map((c) => ' chat.html:' + c.line + ' codicon(' + c.arg + ')').join('\n') |
| 215 | + + '\n Spell the name in the call, or add the shape to INDIRECT and read its names in webviewNames()/hostNames().'); |
| 216 | +}); |
| 217 | + |
| 218 | +test('EXEMPT: a host file excused from the check really is not talking to the chat', () => { |
| 219 | + // An exemption is where a guard goes quiet, so each one has to keep earning it. |
| 220 | + for (const [rel, why] of NOT_CHAT) { |
| 221 | + const file = path.join(ROOT, rel); |
| 222 | + assert.ok(fs.existsSync(file), rel + ' is exempt but no longer exists — drop the exemption'); |
| 223 | + const src = fs.readFileSync(file, 'utf8'); |
| 224 | + assert.ok(/\bicon:/.test(src), rel + ' is exempt but names no icon any more — drop the exemption'); |
| 225 | + assert.ok(!/\bpost(Message)?\(/.test(src), rel + ' is exempt as "' + why + '" but now posts messages — check its icons instead'); |
| 226 | + } |
| 227 | +}); |
| 228 | + |
| 229 | +test('ALIVE: the scans found what they exist to check', () => { |
| 230 | + // Every assertion above passes on an empty list, so a scan that silently stopped matching — a |
| 231 | + // renamed helper, a restyled literal — would turn this whole file into a no-op. |
| 232 | + assert.ok(codiconCalls().length > 0, 'found no codicon() call in chat.html'); |
| 233 | + assert.ok(hostNames().length > 0, 'found no `icon:` literal in any host file'); |
| 234 | + // …and the host scan is reading the field the webview draws: both renderers take `m.icon`. |
| 235 | + assert.match(script, /addAgentLine\(m\.icon,/, 'agentTool no longer hands m.icon to the timeline — hostNames() reads the wrong field'); |
| 236 | + assert.match(script, /\bcodicon\(m\.icon\b/, 'the banner no longer draws m.icon — hostNames() reads the wrong field'); |
| 237 | +}); |
| 238 | + |
| 239 | +console.log('chatIcons: ' + n + ' tests passed'); |
0 commit comments