diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f171f4..7a687f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,10 @@ jobs: node-version-file: .nvmrc cache: pnpm - run: pnpm install --frozen-lockfile --ignore-scripts + - run: pnpm exec playwright install --with-deps chromium - run: pnpm lint - run: pnpm build + - run: pnpm qa:demo - run: pnpm typecheck - run: pnpm test - run: pnpm smoke:consumer diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfc75ff..6e9b486 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,8 +31,10 @@ jobs: cache: pnpm registry-url: 'https://registry.npmjs.org' - run: pnpm install --frozen-lockfile --ignore-scripts + - run: pnpm exec playwright install --with-deps chromium - run: pnpm lint - run: pnpm build + - run: pnpm qa:demo - run: pnpm typecheck - run: pnpm test - run: pnpm smoke:consumer diff --git a/package.json b/package.json index c205ca4..4bddd24 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "lint": "biome check .", "lint:fix": "biome check --write .", "typecheck": "pnpm -r --filter \"./packages/*\" --filter @usdh-kit-apps/demo typecheck", - "verify": "pnpm lint && pnpm build && pnpm typecheck && pnpm test && pnpm smoke:consumer", + "qa:demo": "node scripts/demo-browser-smoke.mjs", + "verify": "pnpm lint && pnpm build && pnpm qa:demo && pnpm typecheck && pnpm test && pnpm smoke:consumer", "smoke:consumer": "node scripts/consumer-smoke.mjs", "changeset": "changeset", "release": "changeset publish", @@ -29,6 +30,7 @@ "@commitlint/config-conventional": "19.6.0", "husky": "9.1.7", "lint-staged": "15.3.0", + "playwright": "1.56.1", "typescript": "5.7.2" }, "lint-staged": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7557a10..c5a43a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: lint-staged: specifier: 15.3.0 version: 15.3.0 + playwright: + specifier: 1.56.1 + version: 1.56.1 typescript: specifier: 5.7.2 version: 5.7.2 @@ -238,7 +241,7 @@ importers: version: 3.4.17 tsup: specifier: 8.3.5 - version: 8.3.5(jiti@2.6.1)(postcss@8.5.12)(tsx@4.19.2)(typescript@5.7.2)(yaml@2.6.1) + version: 8.3.5(jiti@2.6.1)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.7.2)(yaml@2.6.1) typescript: specifier: 5.7.2 version: 5.7.2 @@ -2945,6 +2948,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3701,6 +3709,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.56.1: + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.56.1: + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} + engines: {node: '>=18'} + hasBin: true + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -7752,6 +7770,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -8445,6 +8466,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.56.1: {} + + playwright@1.56.1: + dependencies: + playwright-core: 1.56.1 + optionalDependencies: + fsevents: 2.3.2 + pngjs@5.0.0: {} pony-cause@2.1.11: {} @@ -8477,6 +8506,15 @@ snapshots: optionalDependencies: postcss: 8.4.49 + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.4.49)(tsx@4.19.2)(yaml@2.6.1): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 2.6.1 + postcss: 8.4.49 + tsx: 4.19.2 + yaml: 2.6.1 + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.12)(tsx@4.19.2)(yaml@2.6.1): dependencies: lilconfig: 3.1.3 @@ -9112,6 +9150,33 @@ snapshots: tslib@2.8.1: {} + tsup@8.3.5(jiti@2.6.1)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.7.2)(yaml@2.6.1): + dependencies: + bundle-require: 5.1.0(esbuild@0.24.2) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3(supports-color@5.5.0) + esbuild: 0.24.2 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.4.49)(tsx@4.19.2)(yaml@2.6.1) + resolve-from: 5.0.0 + rollup: 4.60.2 + source-map: 0.8.0-beta.0 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.4.49 + typescript: 5.7.2 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + tsup@8.3.5(jiti@2.6.1)(postcss@8.5.12)(tsx@4.19.2)(typescript@5.7.2)(yaml@2.6.1): dependencies: bundle-require: 5.1.0(esbuild@0.24.2) diff --git a/scripts/demo-browser-smoke.mjs b/scripts/demo-browser-smoke.mjs new file mode 100644 index 0000000..810a3e4 --- /dev/null +++ b/scripts/demo-browser-smoke.mjs @@ -0,0 +1,263 @@ +import { spawn, spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { chromium } from 'playwright' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const demoDir = join(repoRoot, 'apps', 'demo') +const routes = [ + '/components', + '/components/usdh-widget', + '/components/market-board', + '/components/outcome-reads', + '/components/outcome-market-row', + '/components/outcome-odds-selector', + '/components/outcome-order-book', + '/components/outcome-position-row', + '/components/order-ticket-mock', +] +const viewports = [ + { name: 'desktop', width: 1280, height: 720 }, + { name: 'mobile', width: 390, height: 844 }, +] +const criticalConsoleTypes = new Set(['error']) +const forbiddenVisiblePhrases = ['sample fallback', 'live read-only', 'mocked preview data'] + +const externalBaseUrl = process.env.USDH_DEMO_BASE_URL +const port = process.env.USDH_DEMO_QA_PORT ?? '43157' +const baseUrl = externalBaseUrl ?? `http://127.0.0.1:${port}` +let server + +try { + if (!externalBaseUrl) { + assertBuiltDemo() + server = startDemoServer(port) + await waitForServer(baseUrl) + } + + await runBrowserSmoke(baseUrl) + process.stdout.write(`demo browser smoke passed at ${baseUrl}\n`) +} finally { + if (server) await stopServer(server) +} + +function assertBuiltDemo() { + const buildId = join(demoDir, '.next', 'BUILD_ID') + if (!existsSync(buildId)) { + throw new Error(`Missing ${relative(repoRoot, buildId)}. Run the demo build before qa:demo.`) + } +} + +function startDemoServer(serverPort) { + const child = spawn( + pnpmCommand(), + ['--filter', '@usdh-kit-apps/demo', 'exec', 'next', 'start', '-p', serverPort], + { + cwd: repoRoot, + env: { ...process.env, NODE_ENV: 'production' }, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + windowsHide: true, + }, + ) + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.output = '' + child.stdout.on('data', (chunk) => { + child.output += chunk + }) + child.stderr.on('data', (chunk) => { + child.output += chunk + }) + return child +} + +async function waitForServer(url) { + const deadline = Date.now() + 30_000 + let lastError + while (Date.now() < deadline) { + if (server?.exitCode !== null) { + throw new Error(`Demo server exited early.\n${server.output}`) + } + try { + const response = await fetch(`${url}/components`, { method: 'HEAD' }) + if (response.ok) return + lastError = new Error(`HTTP ${response.status}`) + } catch (error) { + lastError = error + } + await sleep(250) + } + throw new Error(`Demo server did not become ready: ${lastError?.message ?? 'unknown error'}`) +} + +async function runBrowserSmoke(url) { + const browser = await chromium.launch({ headless: true }) + try { + for (const viewport of viewports) { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + colorScheme: 'dark', + }) + const page = await context.newPage() + const browserErrors = collectBrowserErrors(page, url) + try { + for (const route of routes) { + await checkRoute(page, browserErrors, url, route, viewport) + } + await checkCopyButton(page, browserErrors, url, viewport) + } finally { + await context.close() + } + } + } finally { + await browser.close() + } +} + +function collectBrowserErrors(page, url) { + const errors = [] + page.on('console', (message) => { + if (criticalConsoleTypes.has(message.type())) { + errors.push(`console ${message.type()}: ${message.text()}`) + } + }) + page.on('pageerror', (error) => { + errors.push(`page error: ${error.message}`) + }) + page.on('response', (response) => { + const responseUrl = response.url() + if (!responseUrl.startsWith(url) && !responseUrl.includes('/_next/')) return + if (response.status() >= 400) { + errors.push(`HTTP ${response.status()}: ${responseUrl}`) + } + }) + return errors +} + +async function checkRoute(page, browserErrors, url, route, viewport) { + const response = await page.goto(`${url}${route}`, { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }) + if (!response?.ok()) { + throw new Error(`${viewport.name} ${route} returned HTTP ${response?.status() ?? 'unknown'}`) + } + + await page.locator('main').waitFor({ timeout: 10_000 }) + await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => undefined) + + await assertCssLoaded(page, route, viewport) + await assertNoHorizontalOverflow(page, route, viewport) + await assertVisibleContent(page, route, viewport) + assertNoBrowserErrors(browserErrors, route, viewport) +} + +async function assertCssLoaded(page, route, viewport) { + const css = await page.evaluate(() => { + const body = window.getComputedStyle(document.body) + const overviewCard = [ + ...document.querySelectorAll('main a[href="/components/usdh-widget"]'), + ].find((candidate) => candidate.querySelector('h3')) + const overviewStyle = overviewCard ? window.getComputedStyle(overviewCard) : null + return { + background: body.backgroundColor, + styleSheets: document.styleSheets.length, + overviewDisplay: overviewStyle?.display ?? null, + overviewBorderWidth: overviewStyle?.borderTopWidth ?? null, + overviewRadius: overviewStyle?.borderTopLeftRadius ?? null, + } + }) + + if (css.styleSheets === 0) { + throw new Error(`${viewport.name} ${route} loaded without stylesheets`) + } + if (css.background === 'rgba(0, 0, 0, 0)') { + throw new Error(`${viewport.name} ${route} body background suggests CSS did not load`) + } + if (route === '/components' && css.overviewDisplay !== 'block') { + throw new Error(`${viewport.name} ${route} overview card is unstyled: ${JSON.stringify(css)}`) + } +} + +async function assertNoHorizontalOverflow(page, route, viewport) { + const overflow = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + })) + const maxScrollWidth = Math.max(overflow.scrollWidth, overflow.bodyScrollWidth) + if (maxScrollWidth > overflow.clientWidth + 2) { + throw new Error( + `${viewport.name} ${route} has horizontal overflow: ${maxScrollWidth}px > ${overflow.clientWidth}px`, + ) + } +} + +async function assertVisibleContent(page, route, viewport) { + const h1Count = await page.locator('h1').count() + if (h1Count < 1) { + throw new Error(`${viewport.name} ${route} rendered without an h1`) + } + + const visibleText = (await page.locator('main').innerText()).toLowerCase() + for (const phrase of forbiddenVisiblePhrases) { + if (visibleText.includes(phrase)) { + throw new Error(`${viewport.name} ${route} exposes debug phrase: ${phrase}`) + } + } +} + +async function checkCopyButton(page, browserErrors, url, viewport) { + const route = '/components/outcome-reads' + await page.goto(`${url}${route}`, { waitUntil: 'domcontentloaded', timeout: 30_000 }) + await page.locator('main').waitFor({ timeout: 10_000 }) + await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => undefined) + + const copyButtons = page.getByRole('button', { name: 'Copy' }) + const count = await copyButtons.count() + if (count < 1) { + throw new Error(`${viewport.name} ${route} rendered without copy buttons`) + } + await copyButtons.first().click() + await page.getByRole('button', { name: 'Copied' }).first().waitFor({ timeout: 2_500 }) + assertNoBrowserErrors(browserErrors, route, viewport) +} + +function assertNoBrowserErrors(browserErrors, route, viewport) { + if (browserErrors.length === 0) return + const details = browserErrors.splice(0).join('\n') + throw new Error(`${viewport.name} ${route} emitted browser errors:\n${details}`) +} + +async function stopServer(child) { + if (child.exitCode !== null) return + if (process.platform === 'win32' && child.pid) { + spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }) + } else { + child.kill('SIGTERM') + } + await Promise.race([onceExit(child), sleep(2_000)]) + child.stdout.destroy() + child.stderr.destroy() +} + +function pnpmCommand() { + return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +} + +function sleep(ms) { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)) +} + +function onceExit(child) { + return new Promise((resolveExit) => { + if (child.exitCode !== null) { + resolveExit() + return + } + child.once('exit', resolveExit) + }) +}