This commit is contained in:
256
scripts/browser-flows.js
Normal file
256
scripts/browser-flows.js
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 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 };
|
||||
}());
|
||||
Reference in New Issue
Block a user