This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user