diff --git a/frontend/package.json b/frontend/package.json index 08f0ac8c..0920fc7f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "build": "webpack --mode=production", "build:watch": "webpack --mode=development --watch", "dev": "webpack serve --mode=development", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "test": "node scripts/run-tests.mjs" }, "dependencies": { "@codemirror/lang-html": "^6.4.11", diff --git a/frontend/scripts/run-tests.mjs b/frontend/scripts/run-tests.mjs new file mode 100644 index 00000000..559d7d62 --- /dev/null +++ b/frontend/scripts/run-tests.mjs @@ -0,0 +1,58 @@ +// Frontend test runner, zero new dependencies: esbuild (already a dependency of +// the build chain) bundles each *.test.ts(x) with its imports and path aliases +// resolved, then Node's built-in test runner executes the result. +// +// Why not jest/vitest: this worktree's node_modules is shared, and the whole job +// here is running our own pure logic. If component/DOM tests are ever needed, +// that is the moment to add a real DOM environment, not before. +import { build } from 'esbuild'; +import { globSync } from 'node:fs'; +import { mkdirSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const root = path.dirname(fileURLToPath(import.meta.url)) + '/..'; +const tests = globSync('src/**/*.test.{ts,tsx}', { cwd: root }).sort(); +if (tests.length === 0) { + console.error('no test files found (src/**/*.test.ts(x))'); + process.exit(1); +} + +// Build INSIDE the package, not tmpdir: anything left external (react-dom/server) has to +// resolve through frontend/node_modules, and a tmpdir has no node_modules to walk up to. +// One fixed dir, wiped up front, so a Ctrl-C'd run litters the repo once instead of forever. +let status = 1; +const outDir = path.join(root, '.test-build'); +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); +try { + await build({ + entryPoints: tests.map((t) => path.join(root, t)), + outdir: outDir, + bundle: true, + format: 'esm', + platform: 'node', + target: 'node22', + sourcemap: 'inline', + logLevel: 'warning', + // node builtins stay external, and so does react-dom/server: esbuild's CJS interop + // cannot follow its conditional require chain, so bundling it dies at import time. + // react rides along as external for a subtler reason: react-dom/server resolves its OWN react + // from node_modules, so bundling a second copy leaves the hook dispatcher null and every + // component that calls useContext dies with "Cannot read properties of null". + external: ['node:*', 'react-dom/server', 'react'], + alias: { '@': path.join(root, 'src'), '@toolui': path.join(root, 'src/toolui') }, + loader: { '.css': 'empty', '.svg': 'empty', '.png': 'empty', '.woff2': 'empty', '.mp4': 'empty' }, + }); + const built = globSync('**/*.mjs', { cwd: outDir }).concat(globSync('**/*.js', { cwd: outDir })); + const setup = path.join(root, 'scripts/test-globals.mjs'); + const res = spawnSync(process.execPath, ['--import', setup, '--test', ...built.map((f) => path.join(outDir, f))], + { stdio: 'inherit', cwd: root }); + status = res.status ?? 1; +} finally { + // Exit AFTER this block, never inside the try: process.exit() skips finally outright, + // which is how every single run used to leave its build dir behind. + rmSync(outDir, { recursive: true, force: true }); +} +process.exit(status); diff --git a/frontend/scripts/test-globals.mjs b/frontend/scripts/test-globals.mjs new file mode 100644 index 00000000..ef8511ae --- /dev/null +++ b/frontend/scripts/test-globals.mjs @@ -0,0 +1,54 @@ +// Just enough browser surface for a module that touches `window` at import time +// to LOAD inside node:test. This is deliberately not a DOM: if a test ever needs +// real rendering, that is the moment to bring in a proper DOM environment. +const noop = () => {}; +const store = new Map(); +const storage = { + getItem: (k) => (store.has(String(k)) ? store.get(String(k)) : null), + setItem: (k, v) => void store.set(String(k), String(v)), + removeItem: (k) => void store.delete(String(k)), + clear: () => void store.clear(), + key: (i) => [...store.keys()][i] ?? null, + get length() { return store.size; }, +}; +const el = () => ({ + style: {}, classList: { add: noop, remove: noop, contains: () => false, toggle: noop }, + setAttribute: noop, getAttribute: () => null, removeAttribute: noop, + appendChild: noop, removeChild: noop, addEventListener: noop, removeEventListener: noop, + getBoundingClientRect: () => ({ x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }), + querySelector: () => null, querySelectorAll: () => [], contains: () => false, focus: noop, click: noop, + dataset: {}, children: [], parentElement: null, textContent: '', +}); +const doc = { + ...el(), + documentElement: el(), body: el(), head: el(), + createElement: el, createTextNode: () => ({}), getElementById: () => null, + readyState: 'complete', visibilityState: 'visible', cookie: '', +}; +const observer = class { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } }; +const notWired = () => { throw new Error('network is not wired in tests; stub the module under test'); }; +const win = { + document: doc, localStorage: storage, sessionStorage: storage, + location: { href: 'http://localhost/', pathname: '/', search: '', hash: '', origin: 'http://localhost' }, + navigator: { userAgent: 'node', onLine: true, language: 'en-US', clipboard: { writeText: async () => {} } }, + addEventListener: noop, removeEventListener: noop, dispatchEvent: () => true, + matchMedia: () => ({ matches: false, addEventListener: noop, removeEventListener: noop, addListener: noop, removeListener: noop }), + getComputedStyle: () => ({ getPropertyValue: () => '' }), + requestAnimationFrame: (cb) => setTimeout(() => cb(Date.now()), 0), + cancelAnimationFrame: (id) => clearTimeout(id), + innerWidth: 1440, innerHeight: 900, devicePixelRatio: 2, + scrollTo: noop, open: () => null, alert: noop, confirm: () => false, + fetch: globalThis.fetch ?? notWired, WebSocket: class { close() {} send() {} addEventListener() {} removeEventListener() {} }, + ResizeObserver: observer, IntersectionObserver: observer, MutationObserver: observer, + performance: globalThis.performance, crypto: globalThis.crypto, + setTimeout, clearTimeout, setInterval, clearInterval, +}; +win.window = win; win.self = win; win.top = win; win.parent = win; +for (const [k, v] of Object.entries({ window: win, document: doc, navigator: win.navigator, + localStorage: storage, sessionStorage: storage, location: win.location, + matchMedia: win.matchMedia, getComputedStyle: win.getComputedStyle, + requestAnimationFrame: win.requestAnimationFrame, cancelAnimationFrame: win.cancelAnimationFrame, + ResizeObserver: observer, IntersectionObserver: observer, MutationObserver: observer, + WebSocket: win.WebSocket })) { + if (!(k in globalThis)) Object.defineProperty(globalThis, k, { value: v, writable: true, configurable: true }); +}