oxedyne/daimond/dev/verify_lane4.mjs
25.0 KiB, 1 run
created by r2519314175:499, 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_lane4.mjs — four controls that were drawn and did not work. |
| 2 | // |
| 3 | // Each of the four is a surface that LOOKS finished. Nothing here can be proved |
| 4 | // by reading a variable, because in every case the broken code holds the right |
| 5 | // variable and never reaches it — so every one of these presses the control the |
| 6 | // way a hand does and then asks what changed on the page. |
| 7 | // |
| 8 | // §1 Pending REWRITTEN 2026-08-16. It used to drive the draft tile: the |
| 9 | // tick opened the composer and dropped the tile in the same |
| 10 | // breath, so a composer closed without sending lost the action. |
| 11 | // That whole kind was DELETED on 2026-08-15 for having no |
| 12 | // producer -- see `dev/REACHABILITY.md` Finding 1 -- and nine |
| 13 | // checks here drove it. Keeping a branch alive because a |
| 14 | // verifier drives it is the same error as calling a panel done |
| 15 | // because a verifier called it, only inverted. So §1 now drives |
| 16 | // what is left: the one kind that has a producer, and the door |
| 17 | // that refuses the rest. |
| 18 | // §2 Agents a run dispatched from a chat carries a chip whose comment says |
| 19 | // it filters. The listener was `if (run.diamondId)`, which a chat |
| 20 | // run does not have, so every chat chip was inert. |
| 21 | // §3 Crystal the `+` that attaches the first file lives inside a list that |
| 22 | // hid itself when there was nothing attached — unreachable in |
| 23 | // exactly the state it exists for. |
| 24 | // §4 Links a link carries a SET of relations. The Graph drew chips; the |
| 25 | // Diamond panel printed the raw field, so `blocks,informs` read |
| 26 | // literally as that, and its form could only take one word. |
| 27 | // |
| 28 | // Run it with LANE4_RED=1 to serve the two scripts as they stand at HEAD — the |
| 29 | // same page, the broken code — which is how each check above was proved to fail |
| 30 | // before it was believed passing. Nothing is written to the working tree either |
| 31 | // way; the old files are served from a scratch copy through `page.route`. |
| 32 | // |
| 33 | // bash dev/world.sh 3 --up && eval "$(bash dev/world.sh 3 --env)" |
| 34 | // LANE4_RED=1 node dev/verify_lane4.mjs # every fixed check must FAIL |
| 35 | // node dev/verify_lane4.mjs # and all of them pass here |
| 36 | // |
| 37 | // `LANE4_NAIVE` went with §1's rewrite. It served a `Pending.sweep` cut down to |
| 38 | // decide on `read_file` alone, and there is no `sweep` any more: with the draft |
| 39 | // kind gone its first test kept every item, so it walked the list and could not |
| 40 | // change it. A mode that reproduces a defect in deleted code cannot go red for |
| 41 | // the right reason, and a red nobody can trigger is a red nobody will see. |
| 42 | import fs from 'node:fs'; |
| 43 | import path from 'node:path'; |
| 44 | import os from 'node:os'; |
| 45 | import { open, shot, errors } from './harness.mjs'; |
| 46 | |
| 47 | const RED = !!process.env.LANE4_RED; |
| 48 | const OLD = process.env.LANE4_OLD |
| 49 | || path.join(os.homedir(), '.cache/daimond/lane4-head'); |
| 50 | const HERE = path.dirname(new URL(import.meta.url).pathname); |
| 51 | |
| 52 | const ok = [], bad = []; |
| 53 | const check = (name, pass, detail) => { |
| 54 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 55 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 56 | }; |
| 57 | const head = (n) => console.log('\n── ' + n + ' ' + '─'.repeat(Math.max(0, 60 - n.length))); |
| 58 | |
| 59 | // A broken build, served in place of the fixed one. Only for a red run: a green |
| 60 | // run touches nothing, and neither writes to the tree. |
| 61 | const serveOld = async (page) => { |
| 62 | if (!RED) return; |
| 63 | for (const f of ['daimond.js', 'graph.js']) { |
| 64 | const body = fs.readFileSync(path.join(OLD, f), 'utf8'); |
| 65 | await page.route('**/js/' + f, (route) => |
| 66 | route.fulfill({ status: 200, contentType: 'application/javascript', body })); |
| 67 | } |
| 68 | }; |
| 69 | |
| 70 | const s = await open({ name: 'lane4', connect: false, defaults: false, route: serveOld }); |
| 71 | const p = s.page; |
| 72 | await p.waitForTimeout(2000); |
| 73 | |
| 74 | /// Run one section, and let the next one run whatever this one does. |
| 75 | /// |
| 76 | /// A red run has to reach all four. When the sections shared one `try`, the |
| 77 | /// first defect threw on a control that is not there and the other three were |
| 78 | /// never exercised at all -- which is how a check comes to be believed without |
| 79 | /// ever having been seen to fail. |
| 80 | const section = async (name, fn) => { |
| 81 | head(name); |
| 82 | try { await fn(); } |
| 83 | catch (e) { |
| 84 | // The locator is in the call log, not the message, and a bare "click timed |
| 85 | // out" does not say WHICH control was not there -- which is the whole |
| 86 | // finding when a section fails on the broken build. |
| 87 | check(name + ' ran to the end', false, |
| 88 | String(e.message || e).replace(/\s+/g, ' ').slice(0, 200)); |
| 89 | try { await shot(s, 'lane4-threw-' + name.replace(/\W+/g, '')); } |
| 90 | catch (e2) { /* nothing left to photograph */ } |
| 91 | } |
| 92 | }; |
| 93 | |
| 94 | // Carried between sections: §4 links the Diamond §3 built. |
| 95 | let bareId = '', other = ''; |
| 96 | |
| 97 | /// The Diamond engine, for a fixture that has to write the store directly. |
| 98 | const app = (fn, args = []) => p.evaluate(async ({ fn, args }) => { |
| 99 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 100 | const a = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 101 | return await a[fn](...args); |
| 102 | }, { fn, args }); |
| 103 | |
| 104 | // ══ §1 Pending: one kind has a producer, and the door refuses the rest ═════ |
| 105 | // |
| 106 | // The panel's `draft` and `note` kinds went on 2026-08-15 for never having had a |
| 107 | // producer, and with them the nine checks that used to stand here. What is left |
| 108 | // is `consent`, raised by `parkConsent` when a dispatched worker asks a question |
| 109 | // nobody is in front of the tab to answer, and a door that refuses any other |
| 110 | // kind rather than drawing a tile whose tick would grant a permission to nobody. |
| 111 | // |
| 112 | // Every refusal below is paired with a tile that DID draw, in the same list, in |
| 113 | // the same breath. Alone, "no tile appeared" passes on a panel that renders |
| 114 | // nothing at all, on a renamed `#pending-list`, and on a build where `add` |
| 115 | // refuses everything -- three ways of being right for the wrong reason. |
| 116 | await section('§1 Pending', async () => { |
| 117 | |
| 118 | await p.evaluate(() => { |
| 119 | try { localStorage.removeItem('daimond-pending'); } catch (e) { /* nothing stored */ } |
| 120 | if (window.DaimondPendingView) window.DaimondPendingView.items() |
| 121 | .forEach((it) => window.DaimondPendingView.drop(it.id)); |
| 122 | }); |
| 123 | await p.evaluate(() => window.DaimondPanels.show('pending')); |
| 124 | await p.waitForTimeout(500); |
| 125 | |
| 126 | /// The Pending tile carrying one headline, as the page has it now. |
| 127 | const pendTile = (headline) => p.evaluate((h) => { |
| 128 | const card = [...document.querySelectorAll('#pending-list .pend-card')] |
| 129 | .find((c) => ((c.querySelector('.pend-line') || {}).textContent || '').trim() === h); |
| 130 | if (!card) return null; |
| 131 | const go = card.querySelector('.pend-go'); |
| 132 | return { |
| 133 | id: card.dataset.id, |
| 134 | note: (card.querySelector('.pend-consent') || {}).textContent || '', |
| 135 | goLive: !!go && !go.disabled, |
| 136 | goTitle: go ? (go.title || '') : '', |
| 137 | }; |
| 138 | }, headline); |
| 139 | |
| 140 | const cards = () => p.evaluate(() => |
| 141 | [...document.querySelectorAll('#pending-list .pend-card')].length); |
| 142 | const said = (key) => p.evaluate((k) => |
| 143 | window.DaimondI18n ? window.DaimondI18n.t(k) : '', key); |
| 144 | |
| 145 | // ── A tile that IS raised, which everything below is measured against ── |
| 146 | const ASK = 'An agent wants to click Accept on example.test'; |
| 147 | const rawId = await p.evaluate((h) => window.DaimondPendingView.add({ |
| 148 | headline: h, detail: 'It asked while you were looking at something else.', |
| 149 | priority: 'high', diamondName: 'Pricing', |
| 150 | }), ASK); |
| 151 | await p.waitForTimeout(400); |
| 152 | let tile = await pendTile(ASK); |
| 153 | check('a consent tile is raised, drawn, and named by the words it was given', |
| 154 | !!rawId && !!tile && (await cards()) === 1, JSON.stringify(tile)); |
| 155 | check('and it says a turn is still waiting on the answer, in the catalogue\'s words', |
| 156 | !!tile && tile.note.trim() === (await said('pending.consent.waiting')).trim(), |
| 157 | tile ? tile.note : 'no tile'); |
| 158 | await shot(s, 'lane4-1-consent-tile'); |
| 159 | |
| 160 | // ── The door: a kind this panel cannot act on is refused, not drawn ── |
| 161 | // |
| 162 | // `draft` and `note` are the two that were deleted; `whatever` stands for the |
| 163 | // next one somebody invents. A tile for any of them would carry the consent |
| 164 | // tile's "still waiting" line over a turn that was never parked. |
| 165 | const refused = await p.evaluate(() => ['draft', 'note', 'whatever'].map((k) => ({ |
| 166 | kind: k, |
| 167 | id: window.DaimondPendingView.add({ headline: 'Raised as ' + k, kind: k, detail: '' }), |
| 168 | }))); |
| 169 | await p.waitForTimeout(400); |
| 170 | const drewOne = await p.evaluate(() => |
| 171 | [...document.querySelectorAll('#pending-list .pend-line')] |
| 172 | .map((n) => n.textContent.trim()).filter((x) => /^Raised as /.test(x))); |
| 173 | check('a kind the panel cannot act on is refused at the door, and nothing is drawn for it', |
| 174 | refused.every((r) => r.id === null) && drewOne.length === 0 && (await cards()) === 1, |
| 175 | JSON.stringify(refused) + ' drew ' + JSON.stringify(drewOne)); |
| 176 | |
| 177 | // ── The tick answers honestly when nothing is parked on the tile ── |
| 178 | // |
| 179 | // Raised from outside, so no worker is holding a turn open on it. The tick must |
| 180 | // say that rather than report a permission it granted to nobody -- and it must |
| 181 | // still clear the tile, because there is nothing left to answer. |
| 182 | await p.click('#pending-list .pend-card .pend-go'); |
| 183 | await p.waitForTimeout(500); |
| 184 | const toldThem = await p.evaluate(() => |
| 185 | [...document.querySelectorAll('.daimond-toast')].map((n) => n.textContent.trim())); |
| 186 | check('the tick on a tile nothing is parked on says so, rather than reporting an allow', |
| 187 | toldThem.includes((await said('pending.consent.gone')).trim()) |
| 188 | && !toldThem.includes((await said('pending.consent.allowed')).trim()), |
| 189 | JSON.stringify(toldThem)); |
| 190 | check('and the tile goes, because there is nothing left to answer', |
| 191 | !(await pendTile(ASK)) && (await cards()) === 0, String(await cards())); |
| 192 | |
| 193 | // ── A promise does not survive a reload, and the tile says which state it is in ── |
| 194 | // |
| 195 | // The tile is kept: "an agent asked and got no answer" is worth keeping. What |
| 196 | // must not survive is the offer to say yes, because the turn it would resume |
| 197 | // went with the page. |
| 198 | const LATER = 'An agent wants to reach example.test'; |
| 199 | await p.evaluate((h) => window.DaimondPendingView.add({ |
| 200 | headline: h, detail: 'Asked before the reload.', priority: 'normal', |
| 201 | }), LATER); |
| 202 | await p.waitForTimeout(400); |
| 203 | tile = await pendTile(LATER); |
| 204 | check('a second tile is raised, and its tick is live while the turn is', |
| 205 | !!tile && tile.goLive === true, JSON.stringify(tile)); |
| 206 | |
| 207 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 208 | await p.waitForTimeout(1200); |
| 209 | const { signInAs } = await import('./harness.mjs'); |
| 210 | await signInAs(s, 'lane4'); |
| 211 | await p.waitForTimeout(2500); |
| 212 | await p.evaluate(() => window.DaimondPanels.show('pending')); |
| 213 | await p.waitForTimeout(1200); |
| 214 | tile = await pendTile(LATER); |
| 215 | check('the tile survives the reload, because a question that went unanswered is worth keeping', |
| 216 | !!tile, JSON.stringify(tile)); |
| 217 | check('but it says the agent has gone, and no longer offers a yes that could not be honoured', |
| 218 | !!tile && tile.goLive === false |
| 219 | && tile.note.trim() === (await said('pending.consent.expired')).trim() |
| 220 | && tile.goTitle.trim() === (await said('pending.consent.expired')).trim(), |
| 221 | tile ? `live: ${tile.goLive}, said: ${tile.note.slice(0, 60)}` : 'no tile'); |
| 222 | await shot(s, 'lane4-1-expired-after-reload'); |
| 223 | |
| 224 | // ── And the ✕, which is the answer that always works ── |
| 225 | await p.evaluate((h) => { |
| 226 | const card = [...document.querySelectorAll('#pending-list .pend-card')] |
| 227 | .find((c) => ((c.querySelector('.pend-line') || {}).textContent || '').trim() === h); |
| 228 | if (card) card.querySelector('.pend-no').click(); |
| 229 | }, LATER); |
| 230 | await p.waitForTimeout(400); |
| 231 | const emptied = await p.evaluate(() => |
| 232 | (document.getElementById('pending-list') || {}).textContent || ''); |
| 233 | check('dropping the last tile empties the list, and the panel says so in words', |
| 234 | (await cards()) === 0 && emptied.trim() === (await said('pending.empty')).trim(), |
| 235 | emptied.trim().slice(0, 80)); |
| 236 | }); |
| 237 | |
| 238 | // ══ §2 Agents: the chat chip filters ═══════════════════════════════════════ |
| 239 | await section('§2 Agents', async () => { |
| 240 | |
| 241 | // Two conversations, made the ordinary way, so the chips carry real chat ids. |
| 242 | for (let i = 0; i < 2; i++) { |
| 243 | await p.evaluate(() => document.getElementById('new-session-btn').click()); |
| 244 | await p.waitForTimeout(700); |
| 245 | } |
| 246 | const chatIds = await p.$$eval('.chat-box', (els) => els.map((e) => e.dataset.id)); |
| 247 | check('two conversations exist to tell apart', chatIds.length >= 2, chatIds.join(', ')); |
| 248 | const [chatA, chatB] = chatIds; |
| 249 | |
| 250 | // One Diamond, so the Diamond filter and the chat filter are both in the picture |
| 251 | // and neither can be mistaken for the other. |
| 252 | const dId = await app('create_diamond', ['Pricing research']); |
| 253 | await p.evaluate(() => window.DaimondCore.loadDiamonds()); |
| 254 | await p.waitForTimeout(600); |
| 255 | |
| 256 | await p.evaluate(({ a, b, d }) => { |
| 257 | const mk = (o) => Object.assign({ |
| 258 | id: 'w-' + o.name, batch: 'b1', task: 'sort a list', status: 'done', |
| 259 | diamondId: '', diamondName: '', chatId: '', chatName: '', |
| 260 | model: 'mock/fast', provider: '', tools: [], text: '', sees: false, |
| 261 | promptTokens: 0, completionTokens: 0, cachedTokens: 0, costUsd: 0, app: null, |
| 262 | }, o); |
| 263 | window.DaimondWorkers.runs = [ |
| 264 | mk({ name: 'from-chat-a', chatId: a }), |
| 265 | mk({ name: 'from-chat-b', chatId: b }), |
| 266 | mk({ name: 'from-diamond', diamondId: d, diamondName: 'Pricing research' }), |
| 267 | ]; |
| 268 | window.DaimondPanels.show('agents'); |
| 269 | window.DaimondWorkers.render(); |
| 270 | }, { a: chatA, b: chatB, d: dId }); |
| 271 | await p.waitForTimeout(500); |
| 272 | |
| 273 | /// Which runs the panel is showing, and which chip each carries. |
| 274 | const shown = () => p.$$eval('#panel-agents .acard', (els) => els.map((e) => ({ |
| 275 | name: (e.querySelector('.an') || {}).textContent || '', |
| 276 | chip: (e.querySelector('.diamond-chip') || {}).textContent || '', |
| 277 | active: !!(e.querySelector('.diamond-chip') || {}).classList?.contains('tag-active'), |
| 278 | }))); |
| 279 | |
| 280 | let cards = await shown(); |
| 281 | check('all three runs are listed before anything is filtered', |
| 282 | cards.length === 3 && ['from-chat-a', 'from-chat-b', 'from-diamond'] |
| 283 | .every((n) => cards.some((c) => c.name === n)), |
| 284 | cards.map((c) => c.name).join(', ')); |
| 285 | check('each chat run carries a chip that names where it came from', |
| 286 | cards.filter((c) => /^from-chat/.test(c.name)).every((c) => /↳\s*\S/.test(c.chip)), |
| 287 | JSON.stringify(cards.map((c) => c.chip))); |
| 288 | |
| 289 | // The click. On the tile of the run from chat A, on the chip itself. |
| 290 | await p.click('#panel-agents .acard:has(.an:text-is("from-chat-a")) .diamond-chip'); |
| 291 | await p.waitForTimeout(500); |
| 292 | cards = await shown(); |
| 293 | check('clicking the chat chip filters the panel to that conversation', |
| 294 | cards.length === 1 && cards[0].name === 'from-chat-a', |
| 295 | cards.length ? cards.map((c) => c.name).join(', ') : 'nothing shown'); |
| 296 | check('the chip it was clicked on now reads as the one in force', |
| 297 | cards.length === 1 && cards[0].active === true, JSON.stringify(cards[0])); |
| 298 | const standing = await p.evaluate(() => { |
| 299 | const c = document.querySelector('#agent-filter .tag-chip'); |
| 300 | return c ? { text: c.textContent, title: c.title } : null; |
| 301 | }); |
| 302 | check('a standing filter chip says why the other runs are not on screen', |
| 303 | !!standing && /\S/.test(standing.text) && /\S/.test(standing.title), |
| 304 | JSON.stringify(standing)); |
| 305 | await shot(s, 'lane4-2-chat-filter-on'); |
| 306 | |
| 307 | // Clearing it, from the standing chip, brings everything back. |
| 308 | await p.click('#agent-filter .tag-chip'); |
| 309 | await p.waitForTimeout(400); |
| 310 | cards = await shown(); |
| 311 | check('clearing the filter brings the other conversations back', |
| 312 | cards.length === 3, cards.map((c) => c.name).join(', ')); |
| 313 | |
| 314 | // The two filters are one at a time, as the rail's are: a Diamond chip pressed |
| 315 | // while a chat filter is on must replace it rather than intersect with it and |
| 316 | // show nothing. |
| 317 | await p.click('#panel-agents .acard:has(.an:text-is("from-chat-a")) .diamond-chip'); |
| 318 | await p.waitForTimeout(400); |
| 319 | await p.click('#panel-agents .acard:has(.an:text-is("from-chat-a")) .diamond-chip'); |
| 320 | await p.waitForTimeout(400); |
| 321 | await p.click('#panel-agents .acard:has(.an:text-is("from-diamond")) .diamond-chip'); |
| 322 | await p.waitForTimeout(400); |
| 323 | cards = await shown(); |
| 324 | check('a Diamond chip pressed after a chat chip replaces the filter, not adds to it', |
| 325 | cards.length === 1 && cards[0].name === 'from-diamond', |
| 326 | cards.map((c) => c.name).join(', ') || 'nothing shown'); |
| 327 | await p.click('#agent-filter .tag-chip'); |
| 328 | await p.waitForTimeout(400); |
| 329 | |
| 330 | }); |
| 331 | |
| 332 | // ══ §3 The crystal's `+` is reachable when there is nothing attached ═══════ |
| 333 | await section('§3 Attach', async () => { |
| 334 | |
| 335 | // A file to attach, and a Diamond holding nothing at all — which is the state |
| 336 | // the control exists for and the state it used to be hidden in. |
| 337 | await p.evaluate(async () => { |
| 338 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 339 | await m.write_file('attach-spec.md', '# The spec\n\nWhat we agreed.\n'); |
| 340 | }); |
| 341 | bareId = await app('create_diamond', ['Bare pursuit']); |
| 342 | await p.evaluate(() => window.DaimondCore.loadDiamonds()); |
| 343 | await p.waitForTimeout(700); |
| 344 | await p.evaluate((id) => { |
| 345 | const box = [...document.querySelectorAll('.diamond-box')].find((b) => b.dataset.id === id); |
| 346 | box.querySelector('.session-box-name, .session-box-open, button').click(); |
| 347 | }, bareId); |
| 348 | await p.waitForTimeout(1200); |
| 349 | |
| 350 | const noLinks = await app('links_touching', ['diamond:' + bareId]); |
| 351 | check('the Diamond under test holds nothing', JSON.parse(noLinks || '[]').length === 0, noLinks); |
| 352 | |
| 353 | const stripState = await p.evaluate(() => { |
| 354 | const st = document.getElementById('arte-strip'); |
| 355 | if (!st) return null; |
| 356 | return { shown: getComputedStyle(st).display !== 'none', text: st.textContent }; |
| 357 | }); |
| 358 | check('the workspace strip is on screen even with nothing kept yet', |
| 359 | !!stripState && stripState.shown && /\S/.test(stripState.text), |
| 360 | JSON.stringify(stripState)); |
| 361 | |
| 362 | await p.click('#arte-strip'); |
| 363 | await p.waitForTimeout(600); |
| 364 | const addBtn = await p.evaluate(() => { |
| 365 | const b = document.querySelector('#arte-list [data-act="attach-add"]'); |
| 366 | if (!b) return null; |
| 367 | const r = b.getBoundingClientRect(); |
| 368 | return { text: b.textContent, title: b.title, w: Math.round(r.width), h: Math.round(r.height) }; |
| 369 | }); |
| 370 | // WHAT THIS ASKS, AND WHAT IT DELIBERATELY DOES NOT. The defect was a control |
| 371 | // that did not exist in the one state it exists for, so what is asked is that |
| 372 | // it is THERE, that it leads with the `+`, and that it has real size on the |
| 373 | // page. It used to demand `textContent === '+'` exactly. `bd6425d` then gave |
| 374 | // that button its words -- on a Diamond this `+` widens the write fence, and |
| 375 | // the two `+` buttons had been telling apart by position and a tooltip alone -- |
| 376 | // so from seq 149 this check asserted the button back into the state that made |
| 377 | // a fence widen silently, and `verify_reachlegible` 6b-6e asserted the opposite |
| 378 | // about the same button in the same suite. Two checks disagreeing about one |
| 379 | // element is a gate whose colour depends on running order. The words are 6c's |
| 380 | // to guard; the reachability is this one's. |
| 381 | check('opening it reveals the control that attaches the first thing, with real size', |
| 382 | !!addBtn && /^\+/.test(addBtn.text.trim()) && addBtn.w > 0 && addBtn.h > 0, JSON.stringify(addBtn)); |
| 383 | check('and a line saying what keeping something here is for', |
| 384 | await p.evaluate(() => /\S/.test((document.querySelector('#arte-list .arte-empty') || {}).textContent || '')), |
| 385 | await p.evaluate(() => (document.querySelector('#arte-list .arte-empty') || {}).textContent || '(nothing)')); |
| 386 | await shot(s, 'lane4-3-empty-strip-open'); |
| 387 | |
| 388 | // Press it, and go all the way through: a picker that opens but attaches |
| 389 | // nothing would be the same defect one step further along. |
| 390 | await p.click('#arte-list [data-act="attach-add"]'); |
| 391 | await p.waitForSelector('.attach-pick-tick[data-path="attach-spec.md"]', { timeout: 10000 }); |
| 392 | await p.click('.attach-pick-tick[data-path="attach-spec.md"]'); |
| 393 | await p.click('.dlg-ok'); |
| 394 | await p.waitForTimeout(1500); |
| 395 | |
| 396 | const nowLinks = JSON.parse(await app('links_touching', ['diamond:' + bareId]) || '[]'); |
| 397 | const held = nowLinks.find((l) => /attach-spec\.md$/.test(String(l.other))); |
| 398 | check('the picker reached from that control actually attaches the file', |
| 399 | !!held && held.rel === 'holds', JSON.stringify(nowLinks)); |
| 400 | const tileNow = await p.evaluate(() => [...document.querySelectorAll('#arte-list .arte-row')] |
| 401 | .map((r) => (r.querySelector('.arte-open') || {}).textContent || '')); |
| 402 | check('and the tile for it names that path', |
| 403 | tileNow.some((x) => /attach-spec\.md/.test(x)), JSON.stringify(tileNow)); |
| 404 | await shot(s, 'lane4-3-attached'); |
| 405 | |
| 406 | }); |
| 407 | |
| 408 | // ══ §4 A link's relations are chips on both surfaces ══════════════════════ |
| 409 | await section('§4 Link relations', async () => { |
| 410 | |
| 411 | // The READ side first, on a record the store already holds: two relations in |
| 412 | // one field, which is what the Graph has written since the set arrived. |
| 413 | other = await app('create_diamond', ['Brand voice']); |
| 414 | await app('add_link', [bareId, 'diamond:' + bareId, 'diamond:' + other, 'blocks,informs', '', 'user']); |
| 415 | // The far end has to be ON THE RAIL, or the form's picker has nothing to offer: |
| 416 | // it searches the Diamonds the app is holding, not the store. |
| 417 | await p.evaluate(() => window.DaimondCore.loadDiamonds()); |
| 418 | await p.waitForTimeout(900); |
| 419 | await p.evaluate((id) => { |
| 420 | const box = [...document.querySelectorAll('.diamond-box')].find((b) => b.dataset.id === id); |
| 421 | box.querySelector('.session-box-name, .session-box-open, button').click(); |
| 422 | }, bareId); |
| 423 | await p.waitForTimeout(1200); |
| 424 | await p.evaluate(() => window.DaimondLinks.toggle(true)); |
| 425 | await p.waitForTimeout(800); |
| 426 | check('the Links section opened on the Diamond under test', |
| 427 | await p.isVisible('#link-add') || await p.isVisible('#link-form'), |
| 428 | await p.textContent('#link-strip')); |
| 429 | |
| 430 | const relShapes = () => p.$$eval('.link-row', (els) => els.map((e) => ({ |
| 431 | other: e.dataset.other, |
| 432 | rels: [...e.querySelectorAll('.link-phrase .link-rel')].map((c) => ({ |
| 433 | text: c.textContent.replace(/×$/, '').trim(), |
| 434 | chip: c.classList.contains('tag-chip'), |
| 435 | })), |
| 436 | }))); |
| 437 | let shapes = await relShapes(); |
| 438 | const two = shapes.find((r) => /diamond:/.test(r.other) && r.rels.length); |
| 439 | check('a link carrying two relations draws two chips, not one string with a comma in it', |
| 440 | !!two && two.rels.length === 2 |
| 441 | && two.rels.map((r) => r.text).join('|') === 'blocks|informs' |
| 442 | && two.rels.every((r) => r.chip), |
| 443 | JSON.stringify(two)); |
| 444 | await shot(s, 'lane4-4-two-chips'); |
| 445 | |
| 446 | // The WRITE side: the form has to be able to say two things. |
| 447 | await p.click('#link-add'); |
| 448 | await p.waitForSelector('#link-rel', { timeout: 8000 }); |
| 449 | await p.fill('#link-pick', 'Brand'); |
| 450 | await p.waitForTimeout(500); |
| 451 | await p.click('.link-pick-hit'); |
| 452 | await p.waitForTimeout(400); |
| 453 | await p.click('.link-sug[data-rel="part-of"]'); |
| 454 | await p.waitForTimeout(300); |
| 455 | const afterSug = await p.evaluate(() => ({ |
| 456 | box: (document.getElementById('link-rel') || {}).value, |
| 457 | chips: [...document.querySelectorAll('#link-rel-chips .tag-chip')] |
| 458 | .map((c) => c.textContent.replace(/×$/, '').trim()), |
| 459 | })); |
| 460 | check('a suggestion becomes a chip on the link and empties the box for the next word', |
| 461 | afterSug.chips.join('|') === 'part-of' && afterSug.box === '', JSON.stringify(afterSug)); |
| 462 | |
| 463 | await p.fill('#link-rel', 'blocks'); |
| 464 | await p.press('#link-rel', 'Enter'); |
| 465 | await p.waitForTimeout(300); |
| 466 | const bothChips = await p.evaluate(() => [...document.querySelectorAll('#link-rel-chips .tag-chip')] |
| 467 | .map((c) => c.textContent.replace(/×$/, '').trim())); |
| 468 | check('a word typed and entered joins it, so the form can say two things', |
| 469 | bothChips.join('|') === 'part-of|blocks', JSON.stringify(bothChips)); |
| 470 | await shot(s, 'lane4-4-form-chips'); |
| 471 | |
| 472 | await p.click('#link-save'); |
| 473 | await p.waitForTimeout(1500); |
| 474 | const saved = JSON.parse(await app('links_touching', ['diamond:' + bareId]) || '[]') |
| 475 | .filter((l) => l.other === 'diamond:' + other); |
| 476 | const made = saved.find((l) => /part-of/.test(l.rel || '')); |
| 477 | check('the store keeps both, in the one field the Graph reads', |
| 478 | !!made && made.rel === 'part-of,blocks', JSON.stringify(saved.map((l) => l.rel))); |
| 479 | |
| 480 | shapes = await relShapes(); |
| 481 | const drawn = shapes.find((r) => r.rels.map((x) => x.text).join('|') === 'part-of|blocks'); |
| 482 | check('and the row it drew shows them as two chips as well', |
| 483 | !!drawn && drawn.rels.every((r) => r.chip), JSON.stringify(shapes.map((r) => r.rels))); |
| 484 | |
| 485 | // A chip closes, which is the other half of editing a set. |
| 486 | await p.click('#link-add'); |
| 487 | await p.waitForSelector('#link-rel', { timeout: 8000 }); |
| 488 | await p.click('.link-sug[data-rel="part-of"]'); |
| 489 | await p.waitForTimeout(300); |
| 490 | await p.click('.link-sug[data-rel="relates-to"]'); |
| 491 | await p.waitForTimeout(300); |
| 492 | await p.evaluate(() => { |
| 493 | const c = [...document.querySelectorAll('#link-rel-chips .tag-chip')] |
| 494 | .find((x) => x.textContent.replace(/×$/, '').trim() === 'part-of'); |
| 495 | c.querySelector('.tag-x').click(); |
| 496 | }); |
| 497 | await p.waitForTimeout(300); |
| 498 | const left = await p.evaluate(() => [...document.querySelectorAll('#link-rel-chips .tag-chip')] |
| 499 | .map((c) => c.textContent.replace(/×$/, '').trim())); |
| 500 | check('closing a chip takes that relation off and leaves the rest', |
| 501 | left.join('|') === 'relates-to', JSON.stringify(left)); |
| 502 | await p.click('#link-cancel'); |
| 503 | }); |
| 504 | |
| 505 | const errs = errors ? errors(s) : (s.errs || []); |
| 506 | console.log('\n' + (RED ? 'RED RUN (HEAD code) ' : 'GREEN RUN ') |
| 507 | + ok.length + ' ok, ' + bad.length + ' failed'); |
| 508 | if (bad.length) console.log('failed:\n ' + bad.join('\n ')); |
| 509 | if (errs && errs.length) console.log('console errors:\n ' + errs.slice(0, 8).join('\n ')); |
| 510 | await s.close(); |
| 511 | process.exit(bad.length ? 1 : 0); |