144 lines
5.8 KiB
JavaScript
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)
|