oxedyne/daimond/dev/verify_links.mjs
20.7 KiB, 1 run
created by r2519314175:507, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_links.mjs — the link substrate: one stored record, found from both ends. |
| 2 | // |
| 3 | // Links are the layer under whatever gets built on them, so what matters here is not a |
| 4 | // feature but the guarantees the rest will rest on: that a link is stored ONCE and still |
| 5 | // found from either end, that its ends can name things that are not Diamonds, that direction |
| 6 | // survives even though both ends find it, that provenance says who drew it, and that an |
| 7 | // agent can read and write the sidecar with the file tools it already has — because if it |
| 8 | // cannot, "agents read and write the graph" needs a whole tool surface that does not exist. |
| 9 | // |
| 10 | // The second half drives the association UI itself -- the real form, the real picker, the |
| 11 | // real suggestion chips -- because a substrate that is right underneath a control nobody |
| 12 | // can complete is still a feature nobody has. |
| 13 | // |
| 14 | // Run with dev/serve.mjs up. No gateway needed. |
| 15 | import { open, signInAs, shot } from './harness.mjs'; |
| 16 | |
| 17 | const ok = [], bad = []; |
| 18 | const check = (name, pass, detail) => { |
| 19 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 20 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 21 | }; |
| 22 | |
| 23 | const s = await open({ name: 'links', connect: false }); |
| 24 | const p = s.page; |
| 25 | await p.waitForTimeout(2500); // open() has already signed in; wait for the app to settle |
| 26 | |
| 27 | // ── Two Diamonds to hang links between ──────────────────────────────────── |
| 28 | const ids = await p.evaluate(async () => { |
| 29 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 30 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 31 | const a = await app.create_diamond('Ship the launch'); |
| 32 | const b = await app.create_diamond('Brand voice'); |
| 33 | return { a, b }; |
| 34 | }); |
| 35 | check('two Diamonds were created', !!ids.a && !!ids.b, `${ids.a} / ${ids.b}`); |
| 36 | |
| 37 | const call = (fn, args = []) => p.evaluate(async ({ fn, args }) => { |
| 38 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 39 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 40 | return await app[fn](...args); |
| 41 | }, { fn, args }); |
| 42 | |
| 43 | // ── A link, asserted once ─────────────────────────────────────────────── |
| 44 | const linkId = await call('add_link', [ |
| 45 | ids.a, `diamond:${ids.a}`, `diamond:${ids.b}`, 'Informs', |
| 46 | 'The launch copy has to sound like this.', 'user', |
| 47 | ]); |
| 48 | check('a link was asserted', typeof linkId === 'string' && linkId.length > 0, linkId); |
| 49 | |
| 50 | // ── Found from the end it was asserted from ───────────────────────────── |
| 51 | const fromA = JSON.parse(await call('links_touching', [`diamond:${ids.a}`])); |
| 52 | check('found from the end it was asserted from', fromA.length === 1, `${fromA.length} link(s)`); |
| 53 | check('the relation was normalised, as a Diamond tag would be', |
| 54 | fromA[0]?.rel === 'informs', fromA[0]?.rel); |
| 55 | check('the note was kept as written', |
| 56 | fromA[0]?.note === 'The launch copy has to sound like this.', fromA[0]?.note); |
| 57 | check('provenance says who drew it', fromA[0]?.by === 'user', fromA[0]?.by); |
| 58 | check('the owner is carried, so it can be deleted without a search', |
| 59 | fromA[0]?.owner === ids.a, fromA[0]?.owner); |
| 60 | |
| 61 | // ── And from the OTHER end, with no second copy stored ────────────────── |
| 62 | const fromB = JSON.parse(await call('links_touching', [`diamond:${ids.b}`])); |
| 63 | check('the same link is found from the far end', fromB.length === 1, `${fromB.length} link(s)`); |
| 64 | check('it is the SAME record, not a mirrored copy', fromB[0]?.id === linkId, fromB[0]?.id); |
| 65 | check('the far end is told which end is the other one', |
| 66 | fromB[0]?.other === `diamond:${ids.a}`, fromB[0]?.other); |
| 67 | check('direction survived being found from the wrong end', |
| 68 | fromB[0]?.from === `diamond:${ids.a}` && fromB[0]?.to === `diamond:${ids.b}`, |
| 69 | `${fromB[0]?.from} -> ${fromB[0]?.to}`); |
| 70 | |
| 71 | // Only one record exists on disk, in one sidecar. |
| 72 | const onDisk = await p.evaluate(async ({ a, b }) => { |
| 73 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 74 | const read = async (path) => { |
| 75 | try { return await mod.read_file(path); } catch (e) { return null; } |
| 76 | }; |
| 77 | return { |
| 78 | a: await read(`diamonds/${a}/.daimond/links.jsonl`), |
| 79 | b: await read(`diamonds/${b}/.daimond/links.jsonl`), |
| 80 | }; |
| 81 | }, ids); |
| 82 | check('the record is stored once, in the asserting Diamond’s sidecar', |
| 83 | (onDisk.a || '').trim().split('\n').filter(Boolean).length === 1 && onDisk.b === null, |
| 84 | `a=${(onDisk.a || '').length}b b=${onDisk.b === null ? 'absent' : 'present'}`); |
| 85 | |
| 86 | // ── The node space is not Diamonds only ─────────────────────────────────── |
| 87 | await call('add_link', [ids.a, `diamond:${ids.a}`, 'file:notes/pricing.md', 'produced', '', 'agent:worker-1']); |
| 88 | await call('add_link', [ids.a, `diamond:${ids.a}`, 'url:https://stripe.com/docs?a=1', 'consulted', '', 'user']); |
| 89 | await call('add_link', [ids.a, `diamond:${ids.a}`, 'email:msg-99@example.com', 'from', '', 'user']); |
| 90 | |
| 91 | const all = JSON.parse(await call('links_touching', [`diamond:${ids.a}`])); |
| 92 | check('a Diamond can link to a file', all.some(l => l.to === 'file:notes/pricing.md')); |
| 93 | check('a Diamond can link to a page, colons in the URL and all', |
| 94 | all.some(l => l.to === 'url:https://stripe.com/docs?a=1'), |
| 95 | all.find(l => l.to.startsWith('url:'))?.to); |
| 96 | check('a kind this build does not model is stored rather than refused', |
| 97 | all.some(l => l.to === 'email:msg-99@example.com')); |
| 98 | check('an agent-asserted link says so', |
| 99 | all.find(l => l.to === 'file:notes/pricing.md')?.by === 'agent:worker-1'); |
| 100 | |
| 101 | // A file is a node too, so the link is findable from the file's side. |
| 102 | const fromFile = JSON.parse(await call('links_touching', ['file:notes/pricing.md'])); |
| 103 | check('a link is findable from the file end as well', |
| 104 | fromFile.length === 1 && fromFile[0].other === `diamond:${ids.a}`, `${fromFile.length} link(s)`); |
| 105 | |
| 106 | // ── What is refused ───────────────────────────────────────────────────── |
| 107 | const selfLink = await call('add_link', [ids.a, `diamond:${ids.a}`, `diamond:${ids.a}`, '', '', 'user']) |
| 108 | .then(() => 'accepted').catch(() => 'refused'); |
| 109 | check('a link from a thing to itself is refused', selfLink === 'refused', selfLink); |
| 110 | |
| 111 | const badRef = await call('add_link', [ids.a, `diamond:${ids.a}`, 'not a reference', '', '', 'user']) |
| 112 | .then(() => 'accepted').catch(() => 'refused'); |
| 113 | check('an end that is not a kind:rest reference is refused', badRef === 'refused', badRef); |
| 114 | |
| 115 | // ── An agent can reach the sidecar with the tools it already has ──────── |
| 116 | // |
| 117 | // This is the load-bearing one. The sidecar sits inside `.daimond/`, and if the tool |
| 118 | // fence covered it, agents could not touch the graph at all without a new tool surface. |
| 119 | const agent = await p.evaluate(async ({ a }) => { |
| 120 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 121 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 122 | const path = `diamonds/${a}/.daimond/links.jsonl`; |
| 123 | const read = await app.run_tool('file_read', JSON.stringify({ path })); |
| 124 | // Append a link the way an agent would: by writing the file. |
| 125 | const line = JSON.stringify({ |
| 126 | id: 'agent-made-1', ts: 1700000000000, |
| 127 | from: `diamond:${a}`, to: 'file:notes/agent-wrote-this.md', |
| 128 | rel: 'produced', note: 'written straight into the sidecar', by: 'agent:tester', |
| 129 | }); |
| 130 | const wrote = await app.run_tool('file_write', |
| 131 | JSON.stringify({ path, content: read.replace(/\n*$/, '\n') + line + '\n' })); |
| 132 | return { read, wrote }; |
| 133 | }, ids); |
| 134 | check('an agent can READ the sidecar with file_read', |
| 135 | /diamond:/.test(agent.read || ''), (agent.read || '').slice(0, 40)); |
| 136 | check('an agent can WRITE the sidecar with file_write', |
| 137 | !/error|refus|denied/i.test(agent.wrote || ''), (agent.wrote || '').slice(0, 60)); |
| 138 | |
| 139 | const afterAgent = JSON.parse(await call('links_touching', [`diamond:${ids.a}`])); |
| 140 | check('the link the agent wrote is read back by the store', |
| 141 | afterAgent.some(l => l.id === 'agent-made-1' && l.by === 'agent:tester'), |
| 142 | `${afterAgent.length} link(s)`); |
| 143 | |
| 144 | // ── A hand-edited sidecar is forgiving ────────────────────────────────── |
| 145 | const handEdit = await p.evaluate(async ({ a }) => { |
| 146 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 147 | const path = `diamonds/${a}/.daimond/links.jsonl`; |
| 148 | const text = await mod.read_file(path); |
| 149 | // A person edits this file. One line comes out mangled. |
| 150 | await mod.write_file(path, text + 'oops, half a line{\n' |
| 151 | + JSON.stringify({ from: `diamond:${a}`, to: 'file:by-hand.md', rel: 'noted' }) + '\n'); |
| 152 | return true; |
| 153 | }, ids); |
| 154 | check('the sidecar was hand-edited', handEdit === true); |
| 155 | |
| 156 | const afterHand = JSON.parse(await call('links_touching', [`diamond:${ids.a}`])); |
| 157 | check('a mangled line does not take the other links with it', |
| 158 | afterHand.length === afterAgent.length + 1, `${afterHand.length} link(s)`); |
| 159 | check('a link written by hand with only its two ends still counts', |
| 160 | afterHand.some(l => l.to === 'file:by-hand.md')); |
| 161 | |
| 162 | // ── Removal ───────────────────────────────────────────────────────────── |
| 163 | const removed = await call('remove_link', [ids.a, linkId]); |
| 164 | check('a link can be removed', removed === true, String(removed)); |
| 165 | const afterRemove = JSON.parse(await call('links_touching', [`diamond:${ids.b}`])); |
| 166 | check('and is then gone from the far end too', afterRemove.length === 0, |
| 167 | `${afterRemove.length} link(s)`); |
| 168 | const removeAgain = await call('remove_link', [ids.a, linkId]); |
| 169 | check('removing what is not there is false, not an error', removeAgain === false); |
| 170 | |
| 171 | // ── Deleting a Diamond takes its links with it ──────────────────────────── |
| 172 | await call('delete_diamond', [ids.a]); |
| 173 | const afterDelete = JSON.parse(await call('links_touching', ['file:notes/pricing.md'])); |
| 174 | check('deleting a Diamond takes the links in its sidecar with it', |
| 175 | afterDelete.length === 0, `${afterDelete.length} link(s)`); |
| 176 | |
| 177 | // ── The UI: the same record, made and read from both ends ──────────────── |
| 178 | // |
| 179 | // Everything above went straight at the store. This drives what a person drives: the |
| 180 | // Links section on the crystal, the picker that finds the other Diamond by name, the three |
| 181 | // suggested relations, the note, and the × that takes the link away again. The direction |
| 182 | // is the load-bearing part -- the row on the Diamond the link was asserted FROM must read |
| 183 | // `out`, and the very same record must show as `in` on the other one. |
| 184 | |
| 185 | // Three Diamonds to link between, made through the store rather than through the rail's |
| 186 | // "+": that dialog will not complete without a model, and this session deliberately has |
| 187 | // none. Then a clean page, so the rail holds what the store holds. |
| 188 | for (const n of ['Ship the console', 'Pricing research', 'Support playbook']) { |
| 189 | await call('create_diamond', [n]); |
| 190 | } |
| 191 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 192 | await signInAs(s, 'links'); |
| 193 | await p.waitForTimeout(1800); |
| 194 | |
| 195 | /// Put a Diamond in the Centre by clicking its box in the rail. |
| 196 | async function selectDiamond(name) { |
| 197 | await p.$$eval('.diamond-box', (els, n) => { |
| 198 | for (const e of els) { |
| 199 | const t = (e.querySelector('.session-box-name') || {}).textContent; |
| 200 | if (t === n) { e.click(); return; } |
| 201 | } |
| 202 | }, name); |
| 203 | await p.waitForTimeout(900); |
| 204 | } |
| 205 | /// Open the Links section, which renders no rows while it is shut. |
| 206 | async function expandLinks() { |
| 207 | await p.waitForSelector('#link-strip', { timeout: 10000 }); |
| 208 | const shut = await p.$eval('#link-strip', e => e.getAttribute('aria-expanded') !== 'true'); |
| 209 | if (shut) { await p.click('#link-strip', { force: true }); await p.waitForTimeout(700); } |
| 210 | } |
| 211 | const rows = () => p.$$eval('.link-row', els => els.map(e => ({ |
| 212 | lid: e.dataset.linkId, |
| 213 | dir: e.dataset.dir, |
| 214 | other: e.dataset.other, |
| 215 | cls: e.getAttribute('class'), |
| 216 | rel: (e.querySelector('.link-rel') || {}).textContent ?? null, |
| 217 | name: (e.querySelector('.link-other') || {}).textContent ?? null, |
| 218 | note: (e.querySelector('.link-note') || {}).textContent ?? null, |
| 219 | hasNoteBtn: !!e.querySelector('.link-note-btn'), |
| 220 | }))); |
| 221 | |
| 222 | const railIds = await p.$$eval('.diamond-box', els => els.map(e => ({ |
| 223 | id: e.dataset.id, name: (e.querySelector('.session-box-name') || {}).textContent, |
| 224 | }))); |
| 225 | const idOf = (n) => (railIds.find(r => r.name === n) || {}).id; |
| 226 | check('three Diamonds are in the rail to link between', |
| 227 | !!idOf('Ship the console') && !!idOf('Pricing research') && !!idOf('Support playbook'), |
| 228 | railIds.map(r => r.name).join(', ')); |
| 229 | |
| 230 | await selectDiamond('Ship the console'); |
| 231 | check('the Links section is on the crystal, whether or not there are links', |
| 232 | await p.isVisible('#link-sec')); |
| 233 | check('a shut section renders no rows', (await rows()).length === 0 |
| 234 | && await p.$eval('#link-body', e => e.style.display === 'none'), 'body hidden'); |
| 235 | await expandLinks(); |
| 236 | check('the strip opens the section', await p.$eval('#link-strip', e => e.getAttribute('aria-expanded') === 'true'), |
| 237 | await p.textContent('#link-strip')); |
| 238 | check('an empty section still offers the way in to making one', await p.isVisible('#link-add')); |
| 239 | |
| 240 | // ── Making one through the form ───────────────────────────────────────── |
| 241 | await p.click('#link-add', { force: true }); |
| 242 | await p.waitForSelector('#link-form', { timeout: 5000 }); |
| 243 | check('the add control becomes the form', await p.isVisible('#link-form') |
| 244 | && await p.isVisible('#link-pick') && await p.isVisible('#link-rel') |
| 245 | && await p.isVisible('#link-note') && await p.isVisible('#link-save')); |
| 246 | check('the form says which way the link will run', /\S/.test(await p.textContent('#link-says')), |
| 247 | (await p.textContent('#link-says')).slice(0, 60)); |
| 248 | |
| 249 | const sugs = await p.$$eval('#link-rel-sug .link-sug', els => els.map(e => e.dataset.rel)); |
| 250 | check('exactly three relations are suggested, and none enforced', |
| 251 | JSON.stringify(sugs) === JSON.stringify(['part-of', 'relates-to', 'derives-from']), JSON.stringify(sugs)); |
| 252 | |
| 253 | await p.fill('#link-pick', 'pricing'); |
| 254 | await p.waitForTimeout(500); |
| 255 | const hits = await p.$$eval('.link-pick-hit', els => els.map(e => ({ id: e.dataset.id, name: e.textContent }))); |
| 256 | check('typing part of a name finds the Diamond, and offers only the others', |
| 257 | hits.length === 1 && hits[0].id === idOf('Pricing research'), JSON.stringify(hits)); |
| 258 | await p.click('.link-pick-hit', { force: true }); |
| 259 | await p.waitForTimeout(400); |
| 260 | check('picking one replaces the search with the choice, and a way to change it', |
| 261 | await p.isVisible('#link-chosen') && await p.isVisible('#link-change') |
| 262 | && (await p.textContent('#link-chosen')).includes('Pricing research')); |
| 263 | |
| 264 | await p.click('.link-sug[data-rel="part-of"]', { force: true }); |
| 265 | await p.waitForTimeout(300); |
| 266 | // A link carries a SET of relations now, edited as the Graph edits the same |
| 267 | // field: a suggestion becomes a chip ON the link and leaves the box clear for |
| 268 | // the next word, rather than filling the box in and being the only one. |
| 269 | const chosenRels = await p.$$eval('#link-rel-chips .tag-chip', |
| 270 | els => els.map(e => e.textContent.replace(/×$/, '').trim())); |
| 271 | check('a suggestion chip puts that relation on the link, and clears the box for another', |
| 272 | chosenRels.join('|') === 'part-of' && await p.inputValue('#link-rel') === '', |
| 273 | JSON.stringify(chosenRels)); |
| 274 | await p.fill('#link-note', 'the pricing work feeds the console rollout'); |
| 275 | await p.click('#link-save', { force: true }); |
| 276 | await p.waitForTimeout(1200); |
| 277 | |
| 278 | const outRows = await rows(); |
| 279 | check('the link appears on the Diamond it was asserted from, pointing out', |
| 280 | outRows.length === 1 && outRows[0].dir === 'out' && /link-row-out/.test(outRows[0].cls), |
| 281 | JSON.stringify(outRows[0])); |
| 282 | check('the row names the far end and the relation', |
| 283 | outRows[0]?.rel === 'part-of' && outRows[0]?.name === 'Pricing research' |
| 284 | && outRows[0]?.other === `diamond:${idOf('Pricing research')}`, |
| 285 | `${outRows[0]?.rel} → ${outRows[0]?.name}`); |
| 286 | check('a link with a note offers the control that shows it', outRows[0]?.hasNoteBtn === true); |
| 287 | check('the strip carries the count', /1/.test(await p.textContent('#link-strip')), |
| 288 | await p.textContent('#link-strip')); |
| 289 | const madeId = outRows[0]?.lid; |
| 290 | await shot(s, 'links-out-row'); |
| 291 | |
| 292 | // ── The same record, read from the far end ────────────────────────────── |
| 293 | await selectDiamond('Pricing research'); |
| 294 | await expandLinks(); |
| 295 | const inRows = await rows(); |
| 296 | check('the far end shows the SAME record, pointing in', |
| 297 | inRows.length === 1 && inRows[0].lid === madeId && inRows[0].dir === 'in' |
| 298 | && /link-row-in/.test(inRows[0].cls), JSON.stringify(inRows[0])); |
| 299 | check('and names the Diamond at the other end of it', |
| 300 | inRows[0]?.other === `diamond:${idOf('Ship the console')}` && inRows[0]?.name === 'Ship the console', |
| 301 | inRows[0]?.name); |
| 302 | |
| 303 | // ── A relation of the user's own words ────────────────────────────────── |
| 304 | await p.click('#link-add', { force: true }); |
| 305 | await p.waitForSelector('#link-form', { timeout: 5000 }); |
| 306 | await p.fill('#link-pick', 'playbook'); |
| 307 | await p.waitForTimeout(500); |
| 308 | await p.click('.link-pick-hit', { force: true }); |
| 309 | await p.waitForTimeout(300); |
| 310 | await p.fill('#link-rel', ' Feeds INTO '); |
| 311 | await p.click('#link-save', { force: true }); |
| 312 | await p.waitForTimeout(1200); |
| 313 | const freeRows = await rows(); |
| 314 | const free = freeRows.find(r => r.lid !== madeId); |
| 315 | check('a relation nobody suggested is accepted', !!free && freeRows.length === 2, |
| 316 | `${freeRows.length} row(s)`); |
| 317 | check('and is normalised to lowercase, the way a tag is', |
| 318 | free?.rel === 'feeds into', JSON.stringify(free?.rel)); |
| 319 | |
| 320 | // ── Shut again, with links to shut over ───────────────────────────────── |
| 321 | // The rows are built only while the section is open, so closing it has to take them out of |
| 322 | // the document rather than merely hide them -- with two links stored, this can tell. |
| 323 | await p.click('#link-strip', { force: true }); |
| 324 | await p.waitForTimeout(700); |
| 325 | check('closing the section takes the rows out of the document, not just out of sight', |
| 326 | (await rows()).length === 0 && await p.$eval('#link-body', e => e.style.display === 'none'), |
| 327 | `${(await rows()).length} row(s) in the DOM with 2 links stored`); |
| 328 | await expandLinks(); |
| 329 | check('and opening it again brings them back', (await rows()).length === 2, |
| 330 | `${(await rows()).length} row(s)`); |
| 331 | |
| 332 | // ── Escape leaves the form without asking anything ────────────────────── |
| 333 | await p.click('#link-add', { force: true }); |
| 334 | await p.waitForSelector('#link-form', { timeout: 5000 }); |
| 335 | await p.fill('#link-pick', 'ship'); |
| 336 | await p.waitForTimeout(300); |
| 337 | await p.focus('#link-pick'); |
| 338 | await p.keyboard.press('Escape'); |
| 339 | await p.waitForTimeout(600); |
| 340 | check('Escape closes the form', await p.$('#link-form') === null); |
| 341 | check('and asks nothing on the way out', await p.$('.dlg-card') === null); |
| 342 | check('the rows it was sitting under are still there', (await rows()).length === 2); |
| 343 | |
| 344 | // ── Taking one away, from one end, removes it from both ───────────────── |
| 345 | await p.$$eval('.link-row', (els, lid) => { |
| 346 | for (const e of els) if (e.dataset.linkId === lid) { e.querySelector('.link-drop').click(); return; } |
| 347 | }, madeId); |
| 348 | await p.waitForSelector('.dlg-card', { timeout: 5000 }); |
| 349 | check('dropping a link asks first, in the app’s own dialog', await p.isVisible('.dlg-card')); |
| 350 | await p.click('.dlg-ok', { force: true }); |
| 351 | await p.waitForTimeout(1200); |
| 352 | const leftHere = await rows(); |
| 353 | check('the dropped link is gone from the end it was dropped at', |
| 354 | leftHere.length === 1 && !leftHere.some(r => r.lid === madeId), `${leftHere.length} row(s)`); |
| 355 | await selectDiamond('Ship the console'); |
| 356 | await expandLinks(); |
| 357 | const leftThere = await rows(); |
| 358 | check('and from the other end as well, with no second copy to clean up', |
| 359 | leftThere.length === 0, `${leftThere.length} row(s)`); |
| 360 | |
| 361 | // ── The way through to the picture ────────────────────────────────────── |
| 362 | check('the graph button is on the rail head', await p.isVisible('#link-graph-btn')); |
| 363 | await p.click('#link-graph-btn', { force: true }); |
| 364 | await p.waitForTimeout(1200); |
| 365 | check('it opens the Graph pane', |
| 366 | await p.$eval('#panel-graph', e => !e.classList.contains('closed') && e.offsetParent !== null), |
| 367 | await p.$eval('#panel-graph', e => e.className)); |
| 368 | check('and the pane has drawn the link that is left', |
| 369 | await p.$$eval('#graph-body g.graph-edge', els => els.length) === 1, |
| 370 | `${await p.$$eval('#graph-body g.graph-edge', els => els.length)} edge(s)`); |
| 371 | await shot(s, 'links-graph-open'); |
| 372 | |
| 373 | await s.close(); |
| 374 | |
| 375 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 376 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 377 | process.exit(bad.length ? 1 : 0); |