Oregami
Repositories/oxedyne/daimond

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.
42import fs from 'node:fs';
43import path from 'node:path';
44import os from 'node:os';
45import { open, shot, errors } from './harness.mjs';
46
47const RED = !!process.env.LANE4_RED;
48const OLD = process.env.LANE4_OLD
49 || path.join(os.homedir(), '.cache/daimond/lane4-head');
50const HERE = path.dirname(new URL(import.meta.url).pathname);
51
52const ok = [], bad = [];
53const check = (name, pass, detail) => {
54 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
55 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
56};
57const 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.
61const 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
70const s = await open({ name: 'lane4', connect: false, defaults: false, route: serveOld });
71const p = s.page;
72await 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.
80const 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.
95let bareId = '', other = '';
96
97/// The Diamond engine, for a fixture that has to write the store directly.
98const 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.
116await section('§1 Pending', async () => {
117
118await 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});
123await p.evaluate(() => window.DaimondPanels.show('pending'));
124await p.waitForTimeout(500);
125
126/// The Pending tile carrying one headline, as the page has it now.
127const 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
140const cards = () => p.evaluate(() =>
141 [...document.querySelectorAll('#pending-list .pend-card')].length);
142const 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 ──
146const ASK = 'An agent wants to click Accept on example.test';
147const 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);
151await p.waitForTimeout(400);
152let tile = await pendTile(ASK);
153check('a consent tile is raised, drawn, and named by the words it was given',
154 !!rawId && !!tile && (await cards()) === 1, JSON.stringify(tile));
155check('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');
158await 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.
165const 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})));
169await p.waitForTimeout(400);
170const drewOne = await p.evaluate(() =>
171 [...document.querySelectorAll('#pending-list .pend-line')]
172 .map((n) => n.textContent.trim()).filter((x) => /^Raised as /.test(x)));
173check('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.
182await p.click('#pending-list .pend-card .pend-go');
183await p.waitForTimeout(500);
184const toldThem = await p.evaluate(() =>
185 [...document.querySelectorAll('.daimond-toast')].map((n) => n.textContent.trim()));
186check('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));
190check('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.
198const LATER = 'An agent wants to reach example.test';
199await p.evaluate((h) => window.DaimondPendingView.add({
200 headline: h, detail: 'Asked before the reload.', priority: 'normal',
201}), LATER);
202await p.waitForTimeout(400);
203tile = await pendTile(LATER);
204check('a second tile is raised, and its tick is live while the turn is',
205 !!tile && tile.goLive === true, JSON.stringify(tile));
206
207await p.reload({ waitUntil: 'domcontentloaded' });
208await p.waitForTimeout(1200);
209const { signInAs } = await import('./harness.mjs');
210await signInAs(s, 'lane4');
211await p.waitForTimeout(2500);
212await p.evaluate(() => window.DaimondPanels.show('pending'));
213await p.waitForTimeout(1200);
214tile = await pendTile(LATER);
215check('the tile survives the reload, because a question that went unanswered is worth keeping',
216 !!tile, JSON.stringify(tile));
217check('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');
222await shot(s, 'lane4-1-expired-after-reload');
223
224// ── And the ✕, which is the answer that always works ──
225await 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);
230await p.waitForTimeout(400);
231const emptied = await p.evaluate(() =>
232 (document.getElementById('pending-list') || {}).textContent || '');
233check('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 ═══════════════════════════════════════
239await section('§2 Agents', async () => {
240
241// Two conversations, made the ordinary way, so the chips carry real chat ids.
242for (let i = 0; i < 2; i++) {
243 await p.evaluate(() => document.getElementById('new-session-btn').click());
244 await p.waitForTimeout(700);
245}
246const chatIds = await p.$$eval('.chat-box', (els) => els.map((e) => e.dataset.id));
247check('two conversations exist to tell apart', chatIds.length >= 2, chatIds.join(', '));
248const [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.
252const dId = await app('create_diamond', ['Pricing research']);
253await p.evaluate(() => window.DaimondCore.loadDiamonds());
254await p.waitForTimeout(600);
255
256await 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 });
271await p.waitForTimeout(500);
272
273/// Which runs the panel is showing, and which chip each carries.
274const 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
280let cards = await shown();
281check('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(', '));
285check('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.
290await p.click('#panel-agents .acard:has(.an:text-is("from-chat-a")) .diamond-chip');
291await p.waitForTimeout(500);
292cards = await shown();
293check('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');
296check('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]));
298const standing = await p.evaluate(() => {
299 const c = document.querySelector('#agent-filter .tag-chip');
300 return c ? { text: c.textContent, title: c.title } : null;
301});
302check('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));
305await shot(s, 'lane4-2-chat-filter-on');
306
307// Clearing it, from the standing chip, brings everything back.
308await p.click('#agent-filter .tag-chip');
309await p.waitForTimeout(400);
310cards = await shown();
311check('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.
317await p.click('#panel-agents .acard:has(.an:text-is("from-chat-a")) .diamond-chip');
318await p.waitForTimeout(400);
319await p.click('#panel-agents .acard:has(.an:text-is("from-chat-a")) .diamond-chip');
320await p.waitForTimeout(400);
321await p.click('#panel-agents .acard:has(.an:text-is("from-diamond")) .diamond-chip');
322await p.waitForTimeout(400);
323cards = await shown();
324check('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');
327await p.click('#agent-filter .tag-chip');
328await p.waitForTimeout(400);
329
330});
331
332// ══ §3 The crystal's `+` is reachable when there is nothing attached ═══════
333await 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.
337await 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});
341bareId = await app('create_diamond', ['Bare pursuit']);
342await p.evaluate(() => window.DaimondCore.loadDiamonds());
343await p.waitForTimeout(700);
344await 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);
348await p.waitForTimeout(1200);
349
350const noLinks = await app('links_touching', ['diamond:' + bareId]);
351check('the Diamond under test holds nothing', JSON.parse(noLinks || '[]').length === 0, noLinks);
352
353const 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});
358check('the workspace strip is on screen even with nothing kept yet',
359 !!stripState && stripState.shown && /\S/.test(stripState.text),
360 JSON.stringify(stripState));
361
362await p.click('#arte-strip');
363await p.waitForTimeout(600);
364const 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.
381check('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));
383check('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)'));
386await 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.
390await p.click('#arte-list [data-act="attach-add"]');
391await p.waitForSelector('.attach-pick-tick[data-path="attach-spec.md"]', { timeout: 10000 });
392await p.click('.attach-pick-tick[data-path="attach-spec.md"]');
393await p.click('.dlg-ok');
394await p.waitForTimeout(1500);
395
396const nowLinks = JSON.parse(await app('links_touching', ['diamond:' + bareId]) || '[]');
397const held = nowLinks.find((l) => /attach-spec\.md$/.test(String(l.other)));
398check('the picker reached from that control actually attaches the file',
399 !!held && held.rel === 'holds', JSON.stringify(nowLinks));
400const tileNow = await p.evaluate(() => [...document.querySelectorAll('#arte-list .arte-row')]
401 .map((r) => (r.querySelector('.arte-open') || {}).textContent || ''));
402check('and the tile for it names that path',
403 tileNow.some((x) => /attach-spec\.md/.test(x)), JSON.stringify(tileNow));
404await shot(s, 'lane4-3-attached');
405
406});
407
408// ══ §4 A link's relations are chips on both surfaces ══════════════════════
409await 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.
413other = await app('create_diamond', ['Brand voice']);
414await 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.
417await p.evaluate(() => window.DaimondCore.loadDiamonds());
418await p.waitForTimeout(900);
419await 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);
423await p.waitForTimeout(1200);
424await p.evaluate(() => window.DaimondLinks.toggle(true));
425await p.waitForTimeout(800);
426check('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
430const 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})));
437let shapes = await relShapes();
438const two = shapes.find((r) => /diamond:/.test(r.other) && r.rels.length);
439check('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));
444await shot(s, 'lane4-4-two-chips');
445
446// The WRITE side: the form has to be able to say two things.
447await p.click('#link-add');
448await p.waitForSelector('#link-rel', { timeout: 8000 });
449await p.fill('#link-pick', 'Brand');
450await p.waitForTimeout(500);
451await p.click('.link-pick-hit');
452await p.waitForTimeout(400);
453await p.click('.link-sug[data-rel="part-of"]');
454await p.waitForTimeout(300);
455const 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}));
460check('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
463await p.fill('#link-rel', 'blocks');
464await p.press('#link-rel', 'Enter');
465await p.waitForTimeout(300);
466const bothChips = await p.evaluate(() => [...document.querySelectorAll('#link-rel-chips .tag-chip')]
467 .map((c) => c.textContent.replace(/×$/, '').trim()));
468check('a word typed and entered joins it, so the form can say two things',
469 bothChips.join('|') === 'part-of|blocks', JSON.stringify(bothChips));
470await shot(s, 'lane4-4-form-chips');
471
472await p.click('#link-save');
473await p.waitForTimeout(1500);
474const saved = JSON.parse(await app('links_touching', ['diamond:' + bareId]) || '[]')
475 .filter((l) => l.other === 'diamond:' + other);
476const made = saved.find((l) => /part-of/.test(l.rel || ''));
477check('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
480shapes = await relShapes();
481const drawn = shapes.find((r) => r.rels.map((x) => x.text).join('|') === 'part-of|blocks');
482check('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.
486await p.click('#link-add');
487await p.waitForSelector('#link-rel', { timeout: 8000 });
488await p.click('.link-sug[data-rel="part-of"]');
489await p.waitForTimeout(300);
490await p.click('.link-sug[data-rel="relates-to"]');
491await p.waitForTimeout(300);
492await 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});
497await p.waitForTimeout(300);
498const left = await p.evaluate(() => [...document.querySelectorAll('#link-rel-chips .tag-chip')]
499 .map((c) => c.textContent.replace(/×$/, '').trim()));
500check('closing a chip takes that relation off and leaves the rest',
501 left.join('|') === 'relates-to', JSON.stringify(left));
502await p.click('#link-cancel');
503});
504
505const errs = errors ? errors(s) : (s.errs || []);
506console.log('\n' + (RED ? 'RED RUN (HEAD code) ' : 'GREEN RUN ')
507 + ok.length + ' ok, ' + bad.length + ' failed');
508if (bad.length) console.log('failed:\n ' + bad.join('\n '));
509if (errs && errs.length) console.log('console errors:\n ' + errs.slice(0, 8).join('\n '));
510await s.close();
511process.exit(bad.length ? 1 : 0);