116 lines
4.8 KiB
JavaScript
116 lines
4.8 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
}
|