/** * The Owliver UI-editing journey, as a script that runs *inside a real page*. * * Unit and SSR tests render the same modules this file drives, and they were * not enough: every defect this suite now guards was found in a browser, not in * a test run. So the acceptance test is the browser, and this is what it runs. * * It is deliberately dependency-free and framework-free — one function, no * imports, no build step — because it has to be evaluatable in any authenticated * session: through the puppeteer driver beside it, or pasted into a console. * * A full journey spans reloads, and nothing in a page survives one. So the * journey is split into **phases** and the *driver* owns the reloads: * * phase 1 inspect → hide → preview → discard → hide → Apply * ── reload ── * phase 2 verify hidden → hidden node still listed → unhide → Apply * ── reload ── * phase 3 verify restored * * Every assertion is about what the browser actually shows — the composed * nodes in the document and the bytes in `preferences.uiLayouts` — never about * a module's return value. */ /* global window, document, fetch, HTMLTextAreaElement, Event */ (function attach() { if (typeof window === 'undefined') return; /** The nodes the page is actually drawing, in document order. */ const nodes = () => [...document.querySelectorAll('[data-ui-node]')] .map((el) => el.getAttribute('data-ui-node')); /** What the panel is showing. The transcript, not a component's state. */ const panel = () => document.querySelector('[class*="assistant"], aside')?.innerText || ''; /** The saved layouts, read back over the wire like any other client would. */ const saved = async () => { const response = await fetch('/api/v1/me/preferences', { credentials: 'include' }); const body = await response.json(); const prefs = (body.data || body).preferences || (body.data || body); return { uiLayouts: prefs.uiLayouts || {}, disabledSkills: prefs.disabledSkills || [] }; }; const sleep = (ms) => new Promise((resolve) => { window.setTimeout(resolve, ms); }); /** * Wait until the panel is genuinely idle. * * Not cosmetic: a message submitted while the panel is streaming is dropped, * and a suite that does not wait reports a phantom failure for a request that * was never sent. That is exactly what happened during the manual audit. */ const idle = async () => { for (let attempt = 0; attempt < 60; attempt += 1) { const box = document.querySelector('textarea'); if (box && !box.disabled && !/Thinking/.test(document.body.innerText)) return true; await sleep(500); } return false; }; /** * Ask Owliver, the way a person does. * * Through the composer and the form's own submit — not by calling a handler — * so the routing, the gate and the panel are all really exercised. */ const ask = async (question) => { await idle(); const box = document.querySelector('textarea'); const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; setValue.call(box, question); box.dispatchEvent(new Event('input', { bubbles: true })); await sleep(150); box.closest('form').requestSubmit(); await sleep(1500); await idle(); await sleep(600); return panel(); }; /** * Every request that left the page, so "did this reach the model?" is * answered by the network rather than by reading the reply and guessing. */ const spy = () => { if (window.__uiFlowSpy) { window.__uiFlowCalls = []; return; } window.__uiFlowCalls = []; const original = window.fetch; window.fetch = function spied(...args) { const url = typeof args[0] === 'string' ? args[0] : args[0]?.url; const method = (args[1]?.method || 'GET').toUpperCase(); if (/\/api\/v1\/(agents|runs)/.test(url) || method !== 'GET') { window.__uiFlowCalls.push(`${method} ${url}`); } return original.apply(this, args); }; window.__uiFlowSpy = true; }; const modelCalls = () => (window.__uiFlowCalls || []).filter((c) => /\/agents\//.test(c)); const writeCalls = () => (window.__uiFlowCalls || []).filter((c) => /^PATCH/.test(c)); /** One assertion. `detail` is what a reader needs to debug a failure. */ const check = (results, name, pass, detail) => { results.push({ name, pass: Boolean(pass), detail: String(detail ?? '') }); return Boolean(pass); }; /** * Phase 1 — the whole preview contract, before anything is kept. * * `target` is a node id; `phrase` is how a person would name it. Both are * given by the caller so this file names no page and no section. */ async function phase1({ page, target, phrase }) { const results = []; spy(); const inventory = await ask('What is on this page?'); check(results, `${page}: inspect answers locally`, modelCalls().length === 0, modelCalls().join(', ') || 'no agent run'); check(results, `${page}: inspect lists ${target}`, inventory.includes(`(${target})`), inventory.slice(-300)); const before = nodes(); check(results, `${page}: ${target} is on the page`, before.includes(target), before.join(', ')); /* What was stored before anything was previewed. Compared against rather than assumed empty: a page that has been customised before still has a patch, and the claim under test is that a *preview* does not change it. */ const storedBefore = JSON.stringify((await saved()).uiLayouts[page] ?? null); window.__uiFlowCalls = []; await ask(`Hide ${phrase}`); const hidden = nodes(); check(results, `${page}: hide removes it from the page`, !hidden.includes(target), hidden.join(', ')); check(results, `${page}: hide does not reach the model`, modelCalls().length === 0, modelCalls().join(', ') || 'no agent run'); const storedDuring = JSON.stringify((await saved()).uiLayouts[page] ?? null); check(results, `${page}: preview writes nothing`, storedDuring === storedBefore && writeCalls().length === 0, `before ${storedBefore} · during ${storedDuring}`); await ask('Discard the layout change'); check(results, `${page}: discard restores it`, nodes().includes(target), nodes().join(', ')); await ask(`Hide ${phrase}`); check(results, `${page}: hide again`, !nodes().includes(target), nodes().join(', ')); window.__uiFlowCalls = []; await ask('Apply the layout change'); await sleep(1200); const persisted = await saved(); const patch = persisted.uiLayouts[page]; check(results, `${page}: apply writes preferences`, writeCalls().length > 0, writeCalls().join(', ')); check(results, `${page}: apply stores an operation, not a tree`, Boolean(patch) && patch.ops.some((op) => op.op === 'hide' && op.target === target && op.hidden === true), JSON.stringify(patch ?? null)); check(results, `${page}: apply does not reach the model`, modelCalls().length === 0, modelCalls().join(', ') || 'no agent run'); return results; } /** Phase 2 — after a cold reload: still hidden, still addressable, put back. */ async function phase2({ page, target, phrase }) { const results = []; spy(); check(results, `${page}: reload reconstructs the hidden state`, !nodes().includes(target), nodes().join(', ')); const inventory = await ask('What is on this page?'); check(results, `${page}: a hidden node stays in the inventory`, inventory.includes(`(${target})`), inventory.slice(-300)); window.__uiFlowCalls = []; await ask(`Show ${phrase}`); check(results, `${page}: a hidden node is addressable after a reload`, nodes().includes(target), nodes().join(', ')); check(results, `${page}: unhide does not reach the model`, modelCalls().length === 0, modelCalls().join(', ') || 'no agent run'); await ask('Apply the layout change'); await sleep(1200); const patch = (await saved()).uiLayouts[page]; check(results, `${page}: the unhide is persisted`, Boolean(patch) && patch.ops.some((op) => op.op === 'hide' && op.target === target && op.hidden === false), JSON.stringify(patch ?? null)); return results; } /** Phase 3 — after a second cold reload, the page is itself again. */ async function phase3({ page, target }) { const results = []; check(results, `${page}: reload reconstructs the restored state`, nodes().includes(target), nodes().join(', ')); return results; } /** * The regression this suite exists for. * * Applying or discarding with nothing previewed used to fall through to the * model, which answered — reasonably, for an agent scoped to open roles — * that layout changes were not in its scope. A request about the interface * must never be answered by something that does not know the interface * exists. The second half is as important: ordinary "apply" must still be an * ordinary word. */ async function nothingPreviewed({ page, ordinaryQuestion }) { const results = []; spy(); for (const verb of ['Discard', 'Apply']) { window.__uiFlowCalls = []; const reply = await ask(`${verb} the layout change`); check(results, `${page}: "${verb} the layout change" with nothing previewed stays local`, modelCalls().length === 0, modelCalls().join(', ') || 'no agent run'); check(results, `${page}: it says there is nothing to ${verb.toLowerCase()}`, new RegExp(`nothing to ${verb.toLowerCase()}`, 'i').test(reply), reply.slice(-240)); } if (ordinaryQuestion) { window.__uiFlowCalls = []; const reply = await ask(ordinaryQuestion); check(results, `${page}: an ordinary "apply" is not swallowed`, modelCalls().length > 0 && !/nothing to apply/i.test(reply.slice(-260)), modelCalls().join(', ') || 'no agent run'); } return results; } /** A data question must stay a data question, however it is worded. */ async function dataStaysData({ page, questions }) { const results = []; spy(); for (const question of questions) { window.__uiFlowCalls = []; const before = nodes(); await ask(question); check(results, `${page}: "${question}" is answered as data`, modelCalls().length > 0 && !/Previewing/.test(document.body.innerText) && JSON.stringify(before) === JSON.stringify(nodes()), `${modelCalls().join(', ') || 'no agent run'} · nodes ${nodes().join(', ')}`); } return results; } window.__uiFlows = { phase1, phase2, phase3, nothingPreviewed, dataStaysData, nodes, saved, ask, panel }; }());