257 lines
10 KiB
JavaScript
257 lines
10 KiB
JavaScript
/**
|
|
* 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 };
|
|
}());
|