This commit is contained in:
1
scripts/__baseline__/activity-page.pre-migration.html
Normal file
1
scripts/__baseline__/activity-page.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/analytics.pre-migration.html
Normal file
1
scripts/__baseline__/analytics.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/candidates.pre-migration.html
Normal file
1
scripts/__baseline__/candidates.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
5
scripts/__baseline__/control-center.pre-migration.html
Normal file
5
scripts/__baseline__/control-center.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/hired-history.pre-migration.html
Normal file
1
scripts/__baseline__/hired-history.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/positions.pre-migration.html
Normal file
1
scripts/__baseline__/positions.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/talent-pool.pre-migration.html
Normal file
1
scripts/__baseline__/talent-pool.pre-migration.html
Normal file
File diff suppressed because one or more lines are too long
166
scripts/browser-check.mjs
Normal file
166
scripts/browser-check.mjs
Normal 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
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 };
|
||||
}());
|
||||
115
scripts/render-page.mjs
Normal file
115
scripts/render-page.mjs
Normal 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
19
scripts/stubs/react-hot-toast.js
vendored
Normal 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;
|
||||
Reference in New Issue
Block a user