candidates and board ui agent issue
Some checks failed
CI / check (push) Failing after 4m58s

This commit is contained in:
2026-09-05 10:46:06 +05:30
parent e02a0c23d4
commit 6249e00a3a
78 changed files with 15071 additions and 1998 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

166
scripts/browser-check.mjs Normal file
View File

@@ -0,0 +1,166 @@
/**
* The live acceptance suite: the Owliver UI-editing journey, in a real browser.
*
* npm run test:browser
*
* Unit and SSR tests render these same modules and were not enough — every
* defect this guards was found in a browser and missed by a green test run. So
* this drives the real application: real routing, real React, real network,
* real `preferences.uiLayouts`.
*
* **It never logs in.** The session is an HttpOnly cookie and this script has
* no business handling anybody's credentials, so it attaches to a browser that
* is *already* signed in and says so plainly when it cannot:
*
* KROW_E2E_BROWSER_URL=http://127.0.0.1:9222 attach to a running Chrome
* (start one with: --remote-debugging-port=9222)
*
* KROW_E2E_USER_DATA_DIR=/path/to/profile launch Chrome on a profile
* that has been signed in once by hand
*
* With neither set, or with the browser not signed in, it exits **BLOCKED** —
* never a silent pass. An acceptance suite that reports green because it never
* ran is worse than no suite.
*
* The journey spans reloads, so the phases live in `browser-flows.js` and the
* reloads live here.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import puppeteer from 'puppeteer-core';
const ROOT = process.cwd();
const BASE = process.env.KROW_E2E_BASE_URL || 'http://localhost:5173';
const FLOWS = readFileSync(join(ROOT, 'scripts/browser-flows.js'), 'utf8');
const CHROME = process.env.KROW_E2E_CHROME
|| '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
/**
* The pages under test, as data.
*
* `target` is a node the page really composes and `phrase` is how a person
* names it — the pair this suite drives every page with. Adding a page is a row.
*/
const PAGES = [
{ page: 'activity', route: '/admin/activity', target: 'audit', phrase: 'the audit log' },
{ page: 'hired-history', route: '/admin/hired', target: 'chronology', phrase: 'the recent hiring timeline' },
{ page: 'analytics', route: '/admin/analytics', target: 'funnel', phrase: 'the hiring funnel' },
{
page: 'positions', route: '/admin/positions', target: 'skill-board-board', phrase: 'the Board card',
/* The Board skill's own card: a *skill* node, so the same journey proves
built-in and definition-contributed UI move through one engine. Needs the
Board definition switched on, which `prepare` below does not do — the
page is skipped rather than failed when its node is not there. */
optional: true,
data: ['What is on the board?', 'Show me the board activity'],
ordinaryQuestion: 'How do I apply for this position?',
},
];
const results = [];
const record = (name, pass, detail) => results.push({ name, pass, detail: detail || '' });
async function connect() {
if (process.env.KROW_E2E_BROWSER_URL) {
return puppeteer.connect({ browserURL: process.env.KROW_E2E_BROWSER_URL, defaultViewport: null });
}
if (process.env.KROW_E2E_USER_DATA_DIR) {
return puppeteer.launch({
executablePath: CHROME,
userDataDir: process.env.KROW_E2E_USER_DATA_DIR,
headless: false,
defaultViewport: null,
});
}
return null;
}
/** Load a route, install the flows, and wait for the page to have composed. */
async function open(page, route) {
await page.goto(`${BASE}${route}`, { waitUntil: 'networkidle2', timeout: 45_000 });
await page.evaluate(FLOWS);
await page.waitForFunction(
() => document.querySelector('textarea') && document.querySelectorAll('[data-ui-node]').length >= 0,
{ timeout: 30_000 }
);
await new Promise((r) => setTimeout(r, 2500));
}
const run = (page, fn, args) => page.evaluate(
async (name, a) => window.__uiFlows[name](a), fn, args
);
async function main() {
const browser = await connect();
if (!browser) {
console.error(
'BLOCKED — no browser to attach to.\n'
+ ' This suite does not log in: the session is an HttpOnly cookie and this\n'
+ ' script does not handle credentials. Point it at a signed-in browser:\n\n'
+ ' KROW_E2E_BROWSER_URL=http://127.0.0.1:9222 npm run test:browser\n'
+ ' (start Chrome with --remote-debugging-port=9222)\n\n'
+ ' KROW_E2E_USER_DATA_DIR=/path/to/profile npm run test:browser\n'
);
process.exit(2);
}
const page = await browser.newPage();
try {
await open(page, PAGES[0].route);
/* Signed in, or nothing below means anything. */
const authed = await page.evaluate(async () => {
const r = await fetch('/api/v1/me/preferences', { credentials: 'include' });
return { status: r.status, composer: Boolean(document.querySelector('textarea')) };
});
if (authed.status !== 200 || !authed.composer) {
console.error(
`BLOCKED — that browser is not signed in (GET /me/preferences → ${authed.status}).\n`
+ ' Sign in once in that profile, then run this again.'
);
process.exit(2);
}
for (const spec of PAGES) {
await open(page, spec.route);
const present = await page.evaluate((t) => window.__uiFlows.nodes().includes(t), spec.target);
if (!present) {
if (spec.optional) {
record(`${spec.page}: SKIPPED — ${spec.target} is not on the page`, true,
'the definition contributing it is switched off');
continue;
}
record(`${spec.page}: ${spec.target} is on the page`, false, 'not composed');
continue;
}
results.push(...await run(page, 'nothingPreviewed', {
page: spec.page, ordinaryQuestion: spec.ordinaryQuestion || null,
}));
if (spec.data) results.push(...await run(page, 'dataStaysData', { page: spec.page, questions: spec.data }));
results.push(...await run(page, 'phase1', spec));
await open(page, spec.route);
results.push(...await run(page, 'phase2', spec));
await open(page, spec.route);
results.push(...await run(page, 'phase3', spec));
}
} finally {
if (process.env.KROW_E2E_BROWSER_URL) browser.disconnect();
else await browser.close();
}
const failed = results.filter((r) => !r.pass);
for (const r of results) {
console.log(`[ ${r.pass ? ' ok ' : 'FAIL'} ] ${r.name}${r.detail ? ` — ${r.detail}` : ''}`);
}
console.log(`\n${results.length - failed.length}/${results.length} browser checks passed`);
if (failed.length) {
console.log('\nFailed:');
for (const r of failed) console.log(` - ${r.name} (${r.detail})`);
process.exit(1);
}
}
main().catch((error) => { console.error('BROWSER SUITE ERROR:', error); process.exit(1); });

256
scripts/browser-flows.js Normal file
View 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 };
}());

115
scripts/render-page.mjs Normal file
View File

@@ -0,0 +1,115 @@
/**
* Render an Admin page to static markup, outside a browser.
*
* The migration to the UI node tree has to be provable rather than asserted:
* a page is rendered before it is touched, rendered again afterwards, and the
* two are compared. This is the thing that renders it, used both to capture a
* baseline and, from the check script, to compare against one.
*
* node scripts/render-page.mjs src/pages/admin/HiredHistory.jsx out.html
*
* The page is loaded through a real Vite server, so `@/` aliases, Markdown
* imports and `import.meta.glob` behave exactly as they do in the app. Queries
* are disabled rather than mocked: every page then renders its empty state,
* deterministically, which is all a structural comparison needs.
*/
import { createServer } from 'vite';
import { join } from 'node:path';
import { writeFileSync } from 'node:fs';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
/**
* The design system reads `window` when its modules evaluate, which is a
* pre-existing SSR limitation and not what any of this is testing. The shim is
* the same one the citation tests use.
*/
export function shimWindow() {
const had = 'window' in globalThis;
const hadSvg = 'SVGElement' in globalThis;
if (!had) {
globalThis.window = {
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
addEventListener() {}, removeEventListener() {},
};
}
/* Recharts tests `instanceof SVGElement` while measuring, which is a browser
global with no Node equivalent. A bare class is enough: nothing is ever an
instance of it, which is the correct answer outside a browser. */
if (!hadSvg) globalThis.SVGElement = class SVGElement {};
return () => {
if (!had) delete globalThis.window;
if (!hadSvg) delete globalThis.SVGElement;
};
}
/**
* Render one page.
*
* `providers` are supplied by the caller rather than assumed here, because the
* editing session belongs to the layout and a baseline captured before a
* migration must be rendered without it.
*/
export async function renderPage(server, modulePath, { route = '/', wrap = null } = {}) {
/* Imported through Node rather than the Vite graph so the instance matches
the one the page itself resolves — loading them as SSR modules creates a
second copy, and a second QueryClientProvider provides nothing. */
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
const { MemoryRouter } = await import('react-router-dom');
const Page = (await server.ssrLoadModule(modulePath)).default;
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
const inner = wrap ? wrap(React.createElement(Page)) : React.createElement(Page);
return renderToStaticMarkup(
React.createElement(MemoryRouter, { initialEntries: [route] },
React.createElement(QueryClientProvider, { client }, inner))
);
}
/**
* Two renderings are the same page when they paint the same styled boxes, in
* the same order, around the same words.
*
* Compared this way rather than byte-for-byte because a migration legitimately
* adds `data-ui-*` identity attributes, and for components that cannot forward
* unknown props a wrapper element carrying nothing else. Both are invisible.
* A changed utility class, a reordered section or altered text moves one of
* these and fails.
*/
export const classSignature = (html) => (html.match(/class="[^"]*"/g) || []).join('\n');
export const wordSignature = (html) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
/** Tag counts, so an added element is visible and can be characterised. */
export const tagCounts = (html) => (html.match(/<\/?[a-z][a-z0-9-]*/gi) || [])
.map((t) => t.toLowerCase())
.reduce((acc, t) => ({ ...acc, [t]: (acc[t] || 0) + 1 }), {});
/* Run directly: capture a baseline. */
if (process.argv[1] && process.argv[1].endsWith('render-page.mjs')) {
const [modulePath, out, route] = process.argv.slice(2);
const restore = shimWindow();
const server = await createServer({
root: process.cwd(),
server: { middlewareMode: true },
appType: 'custom',
logLevel: 'error',
resolve: { alias: { 'react-hot-toast': join(process.cwd(), 'scripts/stubs/react-hot-toast.js') } },
});
try {
const html = await renderPage(server, `/${modulePath.replace(/^\//, '')}`, { route: route || '/' });
writeFileSync(out, html);
console.log(`rendered ${html.length} chars -> ${out}`);
} catch (error) {
console.error('RENDER FAILED:', error.message);
process.exitCode = 1;
} finally {
await server.close();
restore();
/* `server.close()` leaves a handle open often enough that the process hangs
without this, and a capture script that never exits is a capture script
nobody runs. */
process.exit(process.exitCode || 0);
}
}

File diff suppressed because it is too large Load Diff

19
scripts/stubs/react-hot-toast.js vendored Normal file
View File

@@ -0,0 +1,19 @@
/**
* A no-op stand-in for `react-hot-toast`, used only by scripts/skill-check.mjs.
*
* The real package evaluates `goober`, which calls `document.createElement` the
* moment it is imported. That makes the whole design-system barrel — and so the
* Owliver block renderers — impossible to load in Node, which would leave the
* citation tests asserting on block objects rather than on rendered markup.
*
* Nothing under test ever raises a toast, so a stub costs nothing and buys the
* stronger assertion: the real renderers, producing real HTML.
*/
const noop = () => '';
export const toast = Object.assign(noop, {
success: noop, error: noop, loading: noop, custom: noop, dismiss: noop, remove: noop, promise: noop,
});
export const Toaster = () => null;
export const useToaster = () => ({ toasts: [], handlers: {} });
export const useToasterStore = () => ({ toasts: [] });
export default toast;