oxedyne/daimond/dev/verify_workspace.mjs
28.6 KiB, 1 run
created by r2519314175:825, 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_workspace.mjs — the controls that arrange Daimond. |
| 2 | // |
| 3 | // What is checked here is not the pixels but the guarantees the design rests on, |
| 4 | // because those are the things a later change can quietly break: |
| 5 | // |
| 6 | // * a chip never moves when a neighbour opens (the reason chips are toggles), |
| 7 | // * the dock's capacity comes from the chosen grid, not a constant, |
| 8 | // * a grid too small to hold what is open CLOSES the surplus rather than |
| 9 | // losing it, so it comes back as a chip, |
| 10 | // * pinning is the only thing that decides the row, and nothing reorders, |
| 11 | // * the row may be incomplete, but the gallery and the palette may not, |
| 12 | // * text scaling moves the TYPE and leaves the frame where it is, |
| 13 | // * an arrangement is restored only where one was deliberately saved. |
| 14 | // |
| 15 | // Run with dev/serve.mjs up. No gateway needed. |
| 16 | import { open, signInAs, errors } from './harness.mjs'; |
| 17 | |
| 18 | const s = await open({ name: 'workspace', connect: false }); |
| 19 | const p = s.page; |
| 20 | await p.waitForTimeout(2500); |
| 21 | |
| 22 | const ok = [], bad = []; |
| 23 | const check = (name, pass, detail) => { |
| 24 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 25 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 26 | }; |
| 27 | |
| 28 | // A Diamond to hang an arrangement on later. |
| 29 | const diamondId = await p.evaluate(async () => { |
| 30 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 31 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 32 | return await app.create_diamond('Ship the launch'); |
| 33 | }); |
| 34 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 35 | await signInAs(s, 'workspace'); |
| 36 | await p.waitForTimeout(3000); |
| 37 | |
| 38 | // The Agents panel hides until the first agent runs; reveal it so the fleet is |
| 39 | // whole and the counts below mean what they say. |
| 40 | await p.evaluate(() => { |
| 41 | localStorage.setItem('daimond-agents-revealed', '1'); |
| 42 | document.body.classList.remove('agents-hidden'); |
| 43 | window.DaimondPanels.reflow(); |
| 44 | }); |
| 45 | await p.waitForTimeout(300); |
| 46 | |
| 47 | const chips = () => p.$$eval('#panel-tags .ptag[data-panel]', els => els.map(e => ({ |
| 48 | id: e.dataset.panel, |
| 49 | on: e.classList.contains('on'), |
| 50 | disabled: e.disabled, |
| 51 | zone: (e.className.match(/ptag-(rail|stage|dock)/) || [])[1] || null, |
| 52 | x: Math.round(e.getBoundingClientRect().left), |
| 53 | }))); |
| 54 | |
| 55 | /// Which panels the DOCUMENT says belong to the dock. |
| 56 | /// |
| 57 | /// The number of them is not a constant this file may hold: it was four, the |
| 58 | /// Terminal panel made it five, and a check carrying the old number fails for a |
| 59 | /// panel that is working. `data-zone` in the markup is where that fact lives. |
| 60 | const dockZone = () => p.$$eval('.panel[data-zone="dock"]', els => els.map(e => e.dataset.panel)); |
| 61 | |
| 62 | /// How many panels the dock actually DRAWS. |
| 63 | /// |
| 64 | /// Not how many nodes sit in its columns: closing a panel hides its element and |
| 65 | /// leaves it where it was seated (apply() says so in as many words), so counting |
| 66 | /// nodes counts a panel that has just been shed and reads a working shrink as an |
| 67 | /// overfull dock. The computed style is what says a seat was given up. |
| 68 | const seatedNow = () => p.$$eval('#dock .pcol > .panel', |
| 69 | els => els.filter(e => getComputedStyle(e).display !== 'none').length); |
| 70 | |
| 71 | // ── The row shows every panel, not only the closed ones ───────────────── |
| 72 | { |
| 73 | const c = await chips(); |
| 74 | const openCount = c.filter(x => x.on).length; |
| 75 | check('the row carries chips for open panels too', openCount > 0, `${openCount} of ${c.length} shown as open`); |
| 76 | check('an open panel is marked, not hidden', |
| 77 | c.some(x => x.id === 'ai' && x.on), 'the chat is open and chipped'); |
| 78 | } |
| 79 | |
| 80 | // ── A chip does not move when its neighbour opens ─────────────────────── |
| 81 | // This is the whole reason the row was inverted. When only closed panels had |
| 82 | // chips, opening one shifted every chip to its right and the target a user was |
| 83 | // aiming at slid out from under the cursor. |
| 84 | { |
| 85 | const before = await chips(); |
| 86 | await p.evaluate(() => window.DaimondPanels.show('tools')); |
| 87 | await p.waitForTimeout(350); |
| 88 | const after = await chips(); |
| 89 | const moved = before.filter(b => { |
| 90 | const a = after.find(x => x.id === b.id); |
| 91 | return a && a.x !== b.x && b.id !== 'tools'; |
| 92 | }); |
| 93 | check('opening a panel moves no other chip', moved.length === 0, |
| 94 | moved.length ? moved.map(m => m.id).join(', ') + ' shifted' : 'every chip held its place'); |
| 95 | check('the opened panel is now marked open', |
| 96 | after.find(x => x.id === 'tools')?.on === true); |
| 97 | await p.evaluate(() => window.DaimondPanels.hide('tools')); |
| 98 | await p.waitForTimeout(300); |
| 99 | } |
| 100 | |
| 101 | // ── Zones are legible by position, in screen order ────────────────────── |
| 102 | { |
| 103 | const c = await chips(); |
| 104 | const firstOf = z => c.findIndex(x => x.zone === z); |
| 105 | const rail = firstOf('rail'), stage = firstOf('stage'), dock = firstOf('dock'); |
| 106 | check('chips are grouped rail, then stage, then dock — the order the zones sit in', |
| 107 | rail < stage && stage < dock, `rail@${rail} stage@${stage} dock@${dock}`); |
| 108 | const divs = await p.$$eval('#panel-tags .ptag-div', e => e.length); |
| 109 | check('the groups are separated', divs >= 2, `${divs} dividers`); |
| 110 | } |
| 111 | |
| 112 | // ── The dock's capacity is the grid, not a constant ───────────────────── |
| 113 | { |
| 114 | const cap = async g => p.evaluate(grid => { |
| 115 | window.DaimondPanels.setGrid(grid); |
| 116 | return window.DaimondPanels.model().dockMax; |
| 117 | }, g); |
| 118 | check('a 2 by 2 dock holds four', await cap('2x2') === 4); |
| 119 | check('a 2 by 3 dock holds six', await cap('2x3') === 6); |
| 120 | check('a 3 by 2 dock holds six', await cap('3x2') === 6); |
| 121 | check('one column holds four', await cap('1') === 4); |
| 122 | } |
| 123 | |
| 124 | // ── Columns are real, and balanced ────────────────────────────────────── |
| 125 | { |
| 126 | await p.evaluate(() => { |
| 127 | window.DaimondPanels.setGrid('2x3'); |
| 128 | ['agents', 'mail', 'work', 'spend'].forEach(id => window.DaimondPanels.show(id)); |
| 129 | }); |
| 130 | await p.waitForTimeout(500); |
| 131 | const cols = await p.$$eval('#dock .pcol', els => |
| 132 | els.map(e => e.children.length).filter(n => n > 0)); |
| 133 | check('four dock panels tile across two columns', cols.length === 2, `columns: ${cols.join(' + ')}`); |
| 134 | check('the columns are balanced rather than filled in turn', |
| 135 | Math.abs(cols[0] - cols[1]) <= 1, `${cols.join(' vs ')}`); |
| 136 | } |
| 137 | |
| 138 | // ── A smaller grid CLOSES the surplus; it does not lose it ────────────── |
| 139 | // A panel that simply vanished would be a panel the user cannot get back |
| 140 | // without knowing it was ever there. Closed, it returns to the row as a chip. |
| 141 | { |
| 142 | await p.evaluate(() => window.DaimondPanels.setGrid('2x2')); |
| 143 | await p.waitForTimeout(400); |
| 144 | const seated = await seatedNow(); |
| 145 | check('a 2 by 2 dock seats no more than four', seated <= 4, `${seated} seated`); |
| 146 | |
| 147 | await p.evaluate(() => window.DaimondPanels.setGrid('1')); |
| 148 | await p.waitForTimeout(400); |
| 149 | // Which panels belong to the dock is DECLARED in the markup, so the |
| 150 | // expectation is read from there rather than written down here. It used to |
| 151 | // say four; the Terminal panel made it five and the check went red for a |
| 152 | // panel that was working perfectly. Reading the document also cross-checks |
| 153 | // two sources against each other -- `data-zone` in the markup against the |
| 154 | // `ptag-dock` class the chip row draws from the panel registry -- so a panel |
| 155 | // declared in one zone and registered in another is caught here too. |
| 156 | const dockPanels = await dockZone(); |
| 157 | const dockChips = (await chips()).filter(x => x.zone === 'dock').map(x => x.id); |
| 158 | const chipless = dockPanels.filter(id => !dockChips.includes(id)); |
| 159 | check('every dock panel still has a chip after the grid shrank', |
| 160 | chipless.length === 0 && dockChips.length === dockPanels.length, |
| 161 | `${dockChips.length} chips for ${dockPanels.length} dock panels` |
| 162 | + (chipless.length ? `; no chip for ${chipless.join(', ')}` : '')); |
| 163 | const drawn = await seatedNow(); |
| 164 | const capNow = await p.evaluate(() => window.DaimondPanels.model().dockMax); |
| 165 | check('the dock never seats more than the grid allows', drawn <= capNow, |
| 166 | `${drawn} seated, ${capNow} allowed`); |
| 167 | } |
| 168 | |
| 169 | // ── A fifth dock panel, so the shedding arm can be driven ────────────── |
| 170 | // The smallest grid seats four, and the product ships four dock panels, so |
| 171 | // nothing is ever surplus and `setGrid`'s shedding arm — and the "this dock is |
| 172 | // full" chip below it — run in no test at all. |
| 173 | // |
| 174 | // That gap was invisible for a day because the Terminal happened to be a fifth |
| 175 | // dock panel, and it closed again the moment the Terminal moved to the stage. |
| 176 | // Depending on a particular product panel being docked is what made the check |
| 177 | // fragile, so it does not depend on one: a panel is DECLARED, the way any panel |
| 178 | // is declared, and the engine is left to discover it through the same `scan()` |
| 179 | // that finds the real ones. It is added before the document's own scripts run |
| 180 | // (a MutationObserver at document-start, because the registry is read during |
| 181 | // boot and a DOMContentLoaded listener is a race against it), and the page is |
| 182 | // reloaded so boot sees it. |
| 183 | // |
| 184 | // HOW MANY dock panels the product ships is the product's business, so it is |
| 185 | // measured here rather than written down. It said `=== 5` and phase H's Pending |
| 186 | // panel made it six, which failed a check for a panel that was working |
| 187 | // perfectly — the same shape as the sentence above about the Terminal, one |
| 188 | // release later. What this section needs is not a number but ONE MORE THAN |
| 189 | // THERE WERE. |
| 190 | { |
| 191 | const before = await dockZone(); |
| 192 | await p.addInitScript(() => { |
| 193 | const add = () => { |
| 194 | if (document.getElementById('panel-scratch')) return true; |
| 195 | const dock = document.getElementById('dock'); |
| 196 | if (!dock) return false; // not parsed yet; the observer waits |
| 197 | const a = document.createElement('aside'); |
| 198 | a.className = 'panel scratch'; |
| 199 | a.id = 'panel-scratch'; |
| 200 | a.dataset.panel = 'scratch'; |
| 201 | a.dataset.zone = 'dock'; |
| 202 | a.dataset.label = 'Scratch'; |
| 203 | a.innerHTML = '<div class="railhead"><span role="heading" aria-level="2">Scratch</span></div>'; |
| 204 | dock.parentNode.insertBefore(a, dock.nextSibling); |
| 205 | return true; |
| 206 | }; |
| 207 | if (!add()) { |
| 208 | // `document`, not `document.documentElement`: at document-start the root |
| 209 | // element does not exist yet, and observing null throws before anything |
| 210 | // else in this script has been registered. |
| 211 | const mo = new MutationObserver(() => { if (add()) mo.disconnect(); }); |
| 212 | mo.observe(document, { childList: true, subtree: true }); |
| 213 | document.addEventListener('DOMContentLoaded', add); |
| 214 | } |
| 215 | }); |
| 216 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 217 | await signInAs(s, 'workspace'); |
| 218 | await p.waitForTimeout(2500); |
| 219 | const dockPanels = await dockZone(); |
| 220 | check('one more dock panel is registered from the markup, as any panel is', |
| 221 | dockPanels.includes('scratch') && dockPanels.length === before.length + 1, |
| 222 | `${before.length} -> ${dockPanels.length}: ${dockPanels.join(', ')}`); |
| 223 | const smallest = await p.evaluate(() => { |
| 224 | const g = window.DaimondPanels.grids(); |
| 225 | return Math.min(...Object.keys(g).filter(k => g[k]).map(k => g[k].cols * g[k].rows)); |
| 226 | }); |
| 227 | check('and the smallest grid seats fewer than there now are, so a surplus exists', |
| 228 | smallest < dockPanels.length, `${smallest} seats for ${dockPanels.length} panels`); |
| 229 | } |
| 230 | |
| 231 | // ── The surplus is CLOSED, not lost ───────────────────────────────────── |
| 232 | // A grid too small to hold what is open leaves a panel with nowhere to sit, and |
| 233 | // what has to be true of it is that it was CLOSED, and is therefore still on the |
| 234 | // row to be clicked back. A panel that simply vanished would be one the user |
| 235 | // cannot get back without knowing it was ever there. |
| 236 | { |
| 237 | const dockPanels = await dockZone(); |
| 238 | // The BIGGEST grid, and its real capacity, both read from the app rather |
| 239 | // than written here. |
| 240 | // |
| 241 | // This asserted that a `2x3` seats every dock panel, which was true when |
| 242 | // there were five. There are six now, plus the one this file registers, and |
| 243 | // no grid in the app seats seven — `2x3` and `3x2` are both six — so the |
| 244 | // check could only fail, and it was failing for a premise that had quietly |
| 245 | // stopped being true rather than for anything the dock does wrong. |
| 246 | // |
| 247 | // What is actually worth asserting is what this section's own heading says: |
| 248 | // the grid fills to capacity, and the surplus is CLOSED rather than lost. So |
| 249 | // it fills to capacity here, and the check below it does the surplus. |
| 250 | const big = await p.evaluate(() => { |
| 251 | const g = window.DaimondPanels.grids(); |
| 252 | let best = null; |
| 253 | for (const k of Object.keys(g)) { |
| 254 | if (!g[k]) continue; // `auto` has no fixed size |
| 255 | const seats = g[k].cols * g[k].rows; |
| 256 | if (!best || seats > best.seats) best = { key: k, seats: seats }; |
| 257 | } |
| 258 | return best; |
| 259 | }); |
| 260 | check('the app offers a grid with a known capacity to fill', !!big, JSON.stringify(big)); |
| 261 | await p.evaluate((arg) => { |
| 262 | window.DaimondPanels.setGrid(arg.key); |
| 263 | arg.ids.forEach(id => window.DaimondPanels.show(id)); |
| 264 | }, { key: big.key, ids: dockPanels }); |
| 265 | await p.waitForTimeout(600); |
| 266 | const openFull = await p.evaluate((ids) => |
| 267 | ids.filter(id => window.DaimondPanels.isOpen(id)), dockPanels); |
| 268 | const want = Math.min(big.seats, dockPanels.length); |
| 269 | check(`the largest grid (${big.key}) seats every panel it has room for`, |
| 270 | openFull.length === want, |
| 271 | `${openFull.length} of ${dockPanels.length} open, ${big.seats} seats`); |
| 272 | |
| 273 | await p.evaluate(() => window.DaimondPanels.setGrid('1')); |
| 274 | await p.waitForTimeout(600); |
| 275 | const shrunk = await p.evaluate((ids) => ({ |
| 276 | open: ids.filter(id => window.DaimondPanels.isOpen(id)), |
| 277 | cap: window.DaimondPanels.model().dockMax, |
| 278 | }), dockPanels); |
| 279 | shrunk.seated = await seatedNow(); |
| 280 | const shed = openFull.filter(id => !shrunk.open.includes(id)); |
| 281 | check('a grid too small to hold them closes the surplus', |
| 282 | shrunk.seated <= shrunk.cap && shed.length === openFull.length - shrunk.cap, |
| 283 | `${shrunk.seated} seated of ${shrunk.cap}; closed: ${shed.join(', ') || 'none'}`); |
| 284 | const onRow = (await chips()).map(x => x.id); |
| 285 | check('and what was closed is back on the row, where it can be clicked again', |
| 286 | shed.length > 0 && shed.every(id => onRow.includes(id)), |
| 287 | `closed ${shed.join(', ') || 'nothing'}; row has ${onRow.join(', ')}`); |
| 288 | |
| 289 | await p.evaluate((ids) => { |
| 290 | ids.forEach(id => window.DaimondPanels.hide(id)); |
| 291 | window.DaimondPanels.setGrid('auto'); |
| 292 | }, dockPanels); |
| 293 | await p.waitForTimeout(400); |
| 294 | } |
| 295 | |
| 296 | // ── A full dock says so on the chip, before it is clicked ─────────────── |
| 297 | { |
| 298 | await p.evaluate(() => { |
| 299 | window.DaimondPanels.setGrid('2x2'); |
| 300 | ['agents', 'mail', 'work', 'spend'].forEach(id => window.DaimondPanels.show(id)); |
| 301 | }); |
| 302 | await p.waitForTimeout(400); |
| 303 | const full = await p.evaluate(() => { |
| 304 | // Close one and fill with the other three, then ask about the odd one out. |
| 305 | window.DaimondPanels.setGrid('1'); |
| 306 | return window.DaimondPanels.model().panels.filter(x => x.full).map(x => x.id); |
| 307 | }); |
| 308 | await p.waitForTimeout(300); |
| 309 | const c = await chips(); |
| 310 | const disabled = c.filter(x => x.disabled).map(x => x.id); |
| 311 | // Two checks, not one. This used to read `full.length === 0 || disabled…`, |
| 312 | // which passes without asserting anything at all on a dock that is never |
| 313 | // full — and with four dock panels and a four-seat floor, that was every run. |
| 314 | check('a dock with no seat left names the chip it cannot honour', |
| 315 | full.length > 0, `full: [${full}]`); |
| 316 | check('and that chip is disabled rather than silently inert', |
| 317 | full.length > 0 && full.every(id => disabled.includes(id)), |
| 318 | `full: [${full}] disabled: [${disabled}]`); |
| 319 | await p.evaluate(() => window.DaimondPanels.setGrid('auto')); |
| 320 | await p.waitForTimeout(300); |
| 321 | } |
| 322 | |
| 323 | // ── Pinning decides the row, and only pinning ─────────────────────────── |
| 324 | { |
| 325 | const before = (await chips()).length; |
| 326 | await p.evaluate(() => window.DaimondPanels.setPinned('tools', false)); |
| 327 | await p.waitForTimeout(350); |
| 328 | const after = await chips(); |
| 329 | check('unpinning takes a chip off the row', after.length === before - 1, |
| 330 | `${before} then ${after.length}`); |
| 331 | check('the unpinned panel is gone from the row', |
| 332 | !after.some(x => x.id === 'tools')); |
| 333 | const more = await p.$$eval('#panel-more', e => e.map(x => x.textContent)); |
| 334 | check('an overflow chip appears, carrying the count', more.length === 1, more[0]); |
| 335 | |
| 336 | // Unpinning ONE must not read as unpinning all: the implicit "all of them" |
| 337 | // has to become a real list without collapsing. |
| 338 | check('every other panel keeps its pin', |
| 339 | after.length === before - 1, `${after.length} chips remain`); |
| 340 | } |
| 341 | |
| 342 | // ── The row may be incomplete; the gallery may not ────────────────────── |
| 343 | { |
| 344 | await p.click('#panel-more'); |
| 345 | await p.waitForTimeout(400); |
| 346 | const rows = await p.$$eval('#panel-gallery .gal-row .nm', els => els.map(e => e.textContent)); |
| 347 | const model = await p.evaluate(() => window.DaimondPanels.model().panels.length); |
| 348 | check('the gallery lists every panel, pinned or not', rows.length === model, |
| 349 | `${rows.length} rows for ${model} panels`); |
| 350 | check('including the one that is not on the row', rows.includes('Tools')); |
| 351 | |
| 352 | await p.fill('#panel-gallery .gal-search', 'spend'); |
| 353 | await p.waitForTimeout(300); |
| 354 | const hits = await p.$$eval('#panel-gallery .gal-row .nm', els => els.map(e => e.textContent)); |
| 355 | check('the gallery searches', hits.length === 1 && hits[0] === 'Spending', hits.join(', ')); |
| 356 | |
| 357 | await p.keyboard.press('Escape'); |
| 358 | await p.waitForTimeout(250); |
| 359 | check('escape closes the gallery', await p.$eval('#panel-gallery', e => e.hidden)); |
| 360 | } |
| 361 | |
| 362 | // ── A panel with nothing to hold waits before joining the row ────────── |
| 363 | // Message and Compose mean nothing without an account for mail to arrive at or |
| 364 | // be sent from, and a Doc panel means nothing before something is compiled into |
| 365 | // it. Standing in the row from the first run they read as broken features |
| 366 | // rather than as features not yet reached. |
| 367 | { |
| 368 | const row = await chips(); |
| 369 | const onRow = row.map(x => x.id); |
| 370 | check('Message stays off the row while no mail account exists', |
| 371 | !onRow.includes('msg'), onRow.join(', ')); |
| 372 | check('and so does Compose, which needs somewhere to send from', |
| 373 | !onRow.includes('compose')); |
| 374 | check('and Doc, until something has been compiled into it', |
| 375 | !onRow.includes('doc')); |
| 376 | |
| 377 | // But all three are still enumerable, which is the rule the row bends to. |
| 378 | await p.evaluate(() => window.DaimondRelease && 0); |
| 379 | const model = await p.evaluate(() => window.DaimondPanels.model().panels.map(x => x.id)); |
| 380 | check('all three are still in the model, for the gallery and the palette', |
| 381 | ['msg', 'compose', 'doc'].every(id => model.includes(id)), model.join(', ')); |
| 382 | |
| 383 | // Reaching for one is the event it was waiting for. |
| 384 | await p.evaluate(() => window.DaimondPanels.activate('doc')); |
| 385 | await p.waitForTimeout(400); |
| 386 | const after2 = (await chips()).map(x => x.id); |
| 387 | check('reaching for Doc puts it on the row for good', after2.includes('doc'), after2.join(', ')); |
| 388 | } |
| 389 | |
| 390 | // ── A panel that has not revealed itself is still enumerable ─────────── |
| 391 | // This was proved with the Agents panel, which stayed off the chip row until a |
| 392 | // Diamond dispatched its first agent. It no longer does: a panel that comes and |
| 393 | // goes reads as a panel that was taken away, which is how the user read it, so |
| 394 | // Agents is now reachable at all times and says that nothing is running. The |
| 395 | // property is unchanged and is proved directly above with the Doc panel, which |
| 396 | // is what still waits to be earned ("reaching for Doc puts it on the row for |
| 397 | // good"). What went with the block was its fixture, not its subject. |
| 398 | { |
| 399 | // `show`, not `activate`: activate TOGGLES a panel that has already revealed |
| 400 | // itself, so calling it on one that happens to be open closes it -- which is |
| 401 | // what the first draft of this check did to itself. |
| 402 | await p.evaluate(() => window.DaimondPanels.hide('agents')); |
| 403 | await p.waitForTimeout(300); |
| 404 | await p.evaluate(() => window.DaimondPanels.show('agents')); |
| 405 | await p.waitForTimeout(400); |
| 406 | check('the Agents panel opens whether or not an agent has ever run', |
| 407 | await p.evaluate(() => window.DaimondPanels.isOpen('agents'))); |
| 408 | const row = (await chips()).map(x => x.id); |
| 409 | check('and it is on the chip row from the start', row.includes('agents'), row.join(', ')); |
| 410 | } |
| 411 | |
| 412 | // ── Pins survive a reload, and so does the grid ───────────────────────── |
| 413 | { |
| 414 | await p.evaluate(() => window.DaimondPanels.setGrid('2x3')); |
| 415 | await p.waitForTimeout(300); |
| 416 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 417 | await signInAs(s, 'workspace'); |
| 418 | await p.waitForTimeout(2500); |
| 419 | const grid = await p.evaluate(() => window.DaimondPanels.grid()); |
| 420 | check('the chosen tiling outlives a reload', grid === '2x3', grid); |
| 421 | const c = await chips(); |
| 422 | check('so does the pin list', !c.some(x => x.id === 'tools'), |
| 423 | `${c.length} chips, tools absent`); |
| 424 | } |
| 425 | |
| 426 | // ── The palette reaches what the row does not ─────────────────────────── |
| 427 | { |
| 428 | await p.keyboard.press('Control+k'); |
| 429 | await p.waitForTimeout(400); |
| 430 | check('ctrl-k opens the palette', !(await p.$eval('#palette', e => e.hidden))); |
| 431 | await p.fill('#pal-input', 'tools'); |
| 432 | await p.waitForTimeout(300); |
| 433 | const items = await p.$$eval('#pal-list .pal-item .nm', els => els.map(e => e.textContent)); |
| 434 | check('an unpinned panel is still reachable by typing', items.includes('Tools'), items.join(', ')); |
| 435 | await p.keyboard.press('Enter'); |
| 436 | await p.waitForTimeout(500); |
| 437 | check('and opening it from the palette works', |
| 438 | await p.evaluate(() => window.DaimondPanels.isOpen('tools'))); |
| 439 | check('the palette closes itself once it has acted', |
| 440 | await p.$eval('#palette', e => e.hidden)); |
| 441 | |
| 442 | // Settings are reachable the same way, which is what makes the palette |
| 443 | // complete rather than merely a panel switcher. |
| 444 | await p.keyboard.press('Control+k'); |
| 445 | await p.waitForTimeout(300); |
| 446 | await p.fill('#pal-input', 'lolly'); |
| 447 | await p.waitForTimeout(250); |
| 448 | await p.keyboard.press('Enter'); |
| 449 | await p.waitForTimeout(400); |
| 450 | check('a theme can be set from the palette', |
| 451 | await p.evaluate(() => document.documentElement.getAttribute('data-theme')) === 'lollypop'); |
| 452 | await p.evaluate(() => window.DaimondTheme.set('dark')); |
| 453 | } |
| 454 | |
| 455 | // ── Text scaling moves the type and leaves the frame ──────────────────── |
| 456 | { |
| 457 | const measure = () => p.evaluate(() => { |
| 458 | const cs = getComputedStyle(document.body); |
| 459 | const head = document.querySelector('.topbar, .header, header'); |
| 460 | return { |
| 461 | font: parseFloat(cs.fontSize), |
| 462 | headerH: head ? Math.round(head.getBoundingClientRect().height) : null, |
| 463 | railW: Math.round(document.getElementById('panel-rail').getBoundingClientRect().width), |
| 464 | }; |
| 465 | }); |
| 466 | const base = await measure(); |
| 467 | await p.evaluate(() => window.DaimondWorkspace.setScale(1.3)); |
| 468 | await p.waitForTimeout(500); |
| 469 | const big = await measure(); |
| 470 | |
| 471 | check('the type grows', big.font > base.font, `${base.font}px then ${big.font}px`); |
| 472 | check('by the amount asked for', |
| 473 | Math.abs(big.font - base.font * 1.3) < 0.6, `${big.font} vs ${(base.font * 1.3).toFixed(1)}`); |
| 474 | check('the rail does not', big.railW === base.railW, `${base.railW}px then ${big.railW}px`); |
| 475 | |
| 476 | await p.evaluate(() => window.DaimondWorkspace.setScale(1)); |
| 477 | await p.waitForTimeout(400); |
| 478 | const back = await measure(); |
| 479 | check('and it goes back exactly', back.font === base.font, `${back.font}px`); |
| 480 | } |
| 481 | |
| 482 | // ── The size reaches the framed guide, which is its own document ──────── |
| 483 | // The guide is a separate document with its own stylesheet, so a size set on |
| 484 | // the app does not reach it by inheritance. It has to be mirrored, exactly as |
| 485 | // the theme already was -- and this is the check that would have caught the |
| 486 | // guide falling back to a default when the tokens were first introduced. |
| 487 | { |
| 488 | await p.click('#guide-btn').catch(() => {}); |
| 489 | await p.waitForTimeout(2500); |
| 490 | // Read through Playwright's frame API, not contentDocument: the Web panel |
| 491 | // withholds `allow-same-origin`, so the document is opaque to the page. |
| 492 | const guideFrame = () => p.frames().find(f => /\/guide\//.test(f.url())); |
| 493 | const guideFont = async () => { |
| 494 | const f = guideFrame(); |
| 495 | if (!f) return null; |
| 496 | try { return await f.evaluate(() => parseFloat(getComputedStyle(document.body).fontSize)); } |
| 497 | catch (e) { return null; } |
| 498 | }; |
| 499 | const a = await guideFont(); |
| 500 | if (a === null) { |
| 501 | check('the framed guide could be measured', false, 'no same-origin frame to read'); |
| 502 | } else { |
| 503 | await p.evaluate(() => window.DaimondWorkspace.setScale(1.3)); |
| 504 | await p.waitForTimeout(700); |
| 505 | const b = await guideFont(); |
| 506 | check('the guide carries the reader\'s size too', b > a, `${a}px then ${b}px`); |
| 507 | await p.evaluate(() => window.DaimondWorkspace.setScale(1)); |
| 508 | |
| 509 | // The guide could never see the app's theme through the sandbox, and had |
| 510 | // been quietly defaulting to dark whatever the app was set to. |
| 511 | await p.evaluate(() => window.DaimondTheme.set('light')); |
| 512 | await p.waitForTimeout(600); |
| 513 | const th = await guideFrame().evaluate(() => document.documentElement.getAttribute('data-theme')); |
| 514 | check('and the theme, which the sandbox had always blocked', th === 'light', String(th)); |
| 515 | await p.evaluate(() => window.DaimondTheme.set('dark')); |
| 516 | } |
| 517 | } |
| 518 | |
| 519 | // ── An arrangement is restored only where one was saved ───────────────── |
| 520 | { |
| 521 | await p.waitForTimeout(300); |
| 522 | // Nothing saved yet: selecting the Diamond must not disturb the panels. |
| 523 | const before = await p.evaluate(() => window.DaimondPanels.model().panels |
| 524 | .filter(x => x.open).map(x => x.id).sort().join(',')); |
| 525 | await p.evaluate(async (id) => { |
| 526 | const row = Array.from(document.querySelectorAll('#diamond-list .diamond-box')) |
| 527 | .find(e => e.dataset.id === id); |
| 528 | if (row) row.click(); |
| 529 | await new Promise(r => setTimeout(r, 800)); |
| 530 | }, diamondId); |
| 531 | await p.waitForTimeout(600); |
| 532 | const after = await p.evaluate(() => window.DaimondPanels.model().panels |
| 533 | .filter(x => x.open).map(x => x.id).sort().join(',')); |
| 534 | check('a Diamond with no saved arrangement changes nothing', before === after, |
| 535 | `${before} then ${after}`); |
| 536 | |
| 537 | // Save one, disturb the layout, come back. |
| 538 | await p.evaluate((id) => { |
| 539 | window.DaimondPanels.show('spend'); |
| 540 | window.DaimondPanels.saveArrangement(id); |
| 541 | }, diamondId); |
| 542 | await p.waitForTimeout(400); |
| 543 | check('the arrangement is recorded', |
| 544 | await p.evaluate((id) => window.DaimondPanels.hasArrangement(id), diamondId)); |
| 545 | |
| 546 | await p.evaluate(() => window.DaimondPanels.hide('spend')); |
| 547 | await p.waitForTimeout(300); |
| 548 | check('and the layout was genuinely disturbed', |
| 549 | !(await p.evaluate(() => window.DaimondPanels.isOpen('spend')))); |
| 550 | |
| 551 | await p.evaluate((id) => window.DaimondPanels.restoreArrangement(id), diamondId); |
| 552 | await p.waitForTimeout(500); |
| 553 | check('returning to the Diamond puts the panels back', |
| 554 | await p.evaluate(() => window.DaimondPanels.isOpen('spend'))); |
| 555 | |
| 556 | await p.evaluate((id) => window.DaimondPanels.forgetArrangement(id), diamondId); |
| 557 | check('and it can be forgotten', |
| 558 | !(await p.evaluate((id) => window.DaimondPanels.hasArrangement(id), diamondId))); |
| 559 | } |
| 560 | |
| 561 | // ── The row fits the window, and takes nothing off screen with it ────── |
| 562 | // This one is worth keeping. The header used to run off the right of a narrow |
| 563 | // window, carrying the README, guide and appearance buttons out of reach, and |
| 564 | // NOTHING caught it: the body does not scroll when it happens, so the usual |
| 565 | // horizontal-overflow check reads clean. The row now sheds its tail into the |
| 566 | // menu instead, and what is asserted is that the controls stay reachable. |
| 567 | { |
| 568 | for (const w of [1440, 1100, 950, 850]) { |
| 569 | await p.setViewportSize({ width: w, height: 850 }); |
| 570 | await p.evaluate(() => window.DaimondPanels.reflow()); |
| 571 | await p.waitForTimeout(400); |
| 572 | const r = await p.evaluate(() => { |
| 573 | const acts = document.querySelector('.top-actions').getBoundingClientRect(); |
| 574 | const tags = document.getElementById('panel-tags'); |
| 575 | const btn = document.getElementById('settings-menu-btn').getBoundingClientRect(); |
| 576 | return { |
| 577 | actsRight: Math.round(acts.right), |
| 578 | btnRight: Math.round(btn.right), |
| 579 | rowOverflows: tags.scrollWidth > tags.clientWidth + 1, |
| 580 | onRow: document.querySelectorAll('#panel-tags .ptag[data-panel]').length, |
| 581 | }; |
| 582 | }); |
| 583 | check(`at ${w}px the header stays inside the window`, |
| 584 | r.actsRight <= w + 1, `actions end at ${r.actsRight}`); |
| 585 | check(`at ${w}px the appearance button is reachable`, |
| 586 | r.btnRight <= w + 1 && r.btnRight > 0, `button ends at ${r.btnRight}`); |
| 587 | check(`at ${w}px the row does not overflow its own box`, |
| 588 | !r.rowOverflows, `${r.onRow} chips on the row`); |
| 589 | } |
| 590 | await p.setViewportSize({ width: 1500, height: 950 }); |
| 591 | await p.evaluate(() => window.DaimondPanels.reflow()); |
| 592 | await p.waitForTimeout(400); |
| 593 | } |
| 594 | |
| 595 | // ── Nothing threw ─────────────────────────────────────────────────────── |
| 596 | { |
| 597 | const errs = errors(s).filter(e => !/502|Bad Gateway|Failed to load resource/.test(e)); |
| 598 | check('no unexpected console errors', errs.length === 0, errs.join(' | ') || 'clean'); |
| 599 | } |
| 600 | |
| 601 | await s.close(); |
| 602 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 603 | if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); } |