HR-ATS-Portal/frontend/theme.test.mjs

144 lines
5.8 KiB
JavaScript

/**
* Theme-propagation test.
*
* npm run test:theme
*
* Flipping [data-theme] repaints the entire app for free, because every colour
* in styles.css is a custom property. This test guards the one component that
* cannot ride that: EmailBody renders into an iframe, and custom properties do
* not inherit across a document boundary, so its palette is snapshotted into the
* frame's <style> and has to be rebuilt by hand.
*
* That made it the one place where a theme switch left stale colours on screen —
* on /inbox (both tabs) and /matching. The assertions below are what stops it
* regressing: a real MutationObserver, a real attribute flip, and the frame's
* own srcDoc read back afterwards.
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import esbuild from 'esbuild'
import { JSDOM } from 'jsdom'
// ---------------------------------------------------------------- environment
const dom = new JSDOM('<!doctype html><html data-theme="light"><body><div id="root"></div></body></html>', {
url: 'http://localhost:5173/',
pretendToBeVisual: true,
})
globalThis.window = dom.window
globalThis.document = dom.window.document
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
globalThis.HTMLElement = dom.window.HTMLElement
globalThis.Element = dom.window.Element
globalThis.Node = dom.window.Node
globalThis.DOMParser = dom.window.DOMParser
globalThis.getComputedStyle = dom.window.getComputedStyle
globalThis.localStorage = dom.window.localStorage
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0)
globalThis.cancelAnimationFrame = clearTimeout
globalThis.IS_REACT_ACT_ENVIRONMENT = true
// jsdom ships a real MutationObserver, and this test depends on it — the render
// smoke test stubs it out, which would silently make every assertion here pass.
globalThis.MutationObserver = dom.window.MutationObserver
// ---------------------------------------------------------------- bundle
const outDir = mkdtempSync(join(tmpdir(), 'tf-theme-'))
const outFile = join(outDir, 'entry.mjs')
await esbuild.build({
stdin: {
contents: `
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import EmailBody from './ui/EmailBody'
export async function mount(container, html) {
const root = createRoot(container)
await act(async () => { root.render(<EmailBody html={html} />) })
return root
}
export async function flush() { await act(async () => {}) }
`,
resolveDir: 'src',
sourcefile: 'theme-entry.jsx',
loader: 'jsx',
},
outfile: outFile,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
jsx: 'automatic',
loader: { '.js': 'jsx', '.jsx': 'jsx' },
logLevel: 'error',
define: {
'process.env.NODE_ENV': '"development"',
'import.meta.env': JSON.stringify({ DEV: true, PROD: false, MODE: 'development', VITE_API_BASE: '' }),
},
})
// ---------------------------------------------------------------- run
let failed = 0
const check = (label, ok, detail = '') => {
console.log(`${ok ? 'ok ' : 'FAIL '} ${label}${ok || !detail ? '' : `\n ${detail}`}`)
if (!ok) failed++
}
/** The palette a real stylesheet would supply, set inline so jsdom resolves it. */
function applyPalette(theme) {
const root = dom.window.document.documentElement
const vars = theme === 'dark'
? { '--bg-sunken': '#0e1d1f', '--text': '#e8f2ef', '--border': '#24403f', '--primary': '#ceff71' }
: { '--bg-sunken': '#f1f7f4', '--text': '#10231f', '--border': '#dbe8e2', '--primary': '#004d43' }
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v)
root.setAttribute('data-theme', theme)
}
const srcDocOf = (container) => container.querySelector('iframe')?.getAttribute('srcdoc') || ''
try {
const mod = await import(pathToFileURL(outFile).href)
applyPalette('light')
const container = dom.window.document.createElement('div')
dom.window.document.body.appendChild(container)
await mod.mount(container, '<p>Hello from a candidate.</p>')
const light = srcDocOf(container)
check('renders an iframe', Boolean(light), 'no iframe in the output')
check('light frame declares color-scheme: light', light.includes('color-scheme: light'))
check('light frame picks up the light background', light.includes('#f1f7f4'), light.slice(0, 200))
check('light frame picks up the light text colour', light.includes('#10231f'))
check('the mail itself still renders', light.includes('Hello from a candidate.'))
// The flip under test. MutationObserver delivers on a microtask, so the
// act() flush below is what lets the re-render land before we read back.
applyPalette('dark')
await mod.flush()
await mod.flush()
const dark = srcDocOf(container)
check('THE REGRESSION: frame rebuilds on theme flip', dark !== light,
'srcDoc is byte-identical after the flip — the email kept the light palette')
check('dark frame declares color-scheme: dark', dark.includes('color-scheme: dark'),
dark.slice(0, 200))
check('dark frame picks up the dark background', dark.includes('#0e1d1f'))
check('dark frame picks up the dark text colour', dark.includes('#e8f2ef'))
check('light colours are gone', !dark.includes('#f1f7f4') && !dark.includes('#10231f'))
check('the mail survives the rebuild', dark.includes('Hello from a candidate.'))
// And back, so this is a toggle rather than a one-way upgrade.
applyPalette('light')
await mod.flush()
await mod.flush()
check('flips back to light', srcDocOf(container).includes('color-scheme: light'))
} finally {
rmSync(outDir, { recursive: true, force: true })
}
console.log(failed ? `\n${failed} theme check(s) failed` : '\nAll theme checks passed')
process.exit(failed ? 1 : 0)