/* Browser layout checks with an isolated fixture API. Never calls a model. */ const assert = require('node:assert/strict'); const fs = require('node:fs/promises'); const http = require('node:http'); const path = require('node:path'); const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright-core'); async function main() { const output = process.argv[2]; if (!output || !process.env.CHROMIUM_PATH) throw new Error('Provide a new output directory and CHROMIUM_PATH.'); await fs.mkdir(output, { recursive: false }); const root = path.resolve(__dirname, '../web'); const catalog = [ { id: 'fixture-main', label: 'Qwen3 4B · UI fixture', description: 'Browser fixture only; no model inference.', calibrated: false, max_tokens: 1024 }, { id: 'fixture-small', label: 'Qwen3.5 2B · UI fixture', description: 'A second browser fixture.', calibrated: true, max_tokens: 1024 }, ]; let failure = null; let modelFailure = false; let scoreCount = 0; let lastRequest; const server = http.createServer(async (request, response) => { response.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"); response.setHeader('Cache-Control', 'no-store'); const json = (status, body) => { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(body)); }; if (request.url === '/api/models') return modelFailure ? json(503, { error: 'Fixture model connection unavailable. Please retry.' }) : json(200, { models: catalog, default_model: catalog[0].id }); if (request.url === '/api/status') return json(200, { status: 'ready', loaded_model_id: catalog[0].id }); if (request.url === '/api/score') { let raw = ''; for await (const chunk of request) raw += chunk; lastRequest = JSON.parse(raw); scoreCount += 1; await new Promise(resolve => setTimeout(resolve, 220)); if (failure) return json(failure.status, { error: failure.message }); return json(200, { model: catalog.find(model => model.id === lastRequest.model), probabilities: lastRequest.options.map((option, index) => ({ option, probability: index ? .3 / (lastRequest.options.length - 1) : .7 })), elapsed_seconds: .22, input_tokens: 391, }); } if (request.url === '/favicon.ico') { response.writeHead(204); return response.end(); } const name = request.url === '/' ? 'playground.html' : request.url.slice(1); if (!['playground.html', 'playground.css', 'playground.js'].includes(name)) { response.writeHead(404); return response.end(); } response.writeHead(200, { 'Content-Type': name.endsWith('.js') ? 'text/javascript' : name.endsWith('.css') ? 'text/css' : 'text/html' }); response.end(await fs.readFile(path.join(root, name))); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const url = `http://127.0.0.1:${server.address().port}`; const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH, headless: true, args: ['--disable-gpu', '--no-sandbox'] }); const report = { fixture_only: true, scope: 'Responsive layout and interaction checks; no model inference or training changes.', started_utc: new Date().toISOString(), viewports: [], checks: [], errors: [] }; try { const context = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'], reducedMotion: 'reduce' }); const page = await context.newPage(); page.on('pageerror', error => report.errors.push(error.message)); page.on('console', message => { if (/Content Security Policy|Refused to|violates.*directive/i.test(message.text())) report.errors.push(message.text()); }); const ready = () => page.waitForFunction(() => !document.getElementById('submit').disabled); const tabs = () => page.locator('#workspace-tabs').isVisible(); const view = async name => { if (await tabs()) await page.locator(`#tab-${name}`).click(); }; const geometry = async label => { const measured = await page.evaluate(() => { const root = document.documentElement; return { width: innerWidth, height: innerHeight, scrollWidth: root.scrollWidth, scrollHeight: root.scrollHeight, bodyWidth: document.body.scrollWidth, bodyHeight: document.body.scrollHeight }; }); assert(measured.scrollWidth <= measured.width + 1 && measured.bodyWidth <= measured.width + 1, `${label}: horizontal overflow ${JSON.stringify(measured)}`); assert(measured.scrollHeight <= measured.height + 1 && measured.bodyHeight <= measured.height + 1, `${label}: vertical page overflow ${JSON.stringify(measured)}`); const scroll = await page.evaluate(() => { window.scrollTo(200, 200); return { x: scrollX, y: scrollY }; }); assert.deepEqual(scroll, { x: 0, y: 0 }, `${label}: page can scroll`); return measured; }; const inView = async (selector, label) => { const result = await page.locator(selector).evaluate(element => { const box = element.getBoundingClientRect(); let clip = { left: 0, top: 0, right: innerWidth, bottom: innerHeight }; for (let parent = element.parentElement; parent; parent = parent.parentElement) { const style = getComputedStyle(parent), parentBox = parent.getBoundingClientRect(); if (/(auto|scroll|hidden|clip)/.test(style.overflowX)) { clip.left = Math.max(clip.left, parentBox.left); clip.right = Math.min(clip.right, parentBox.right); } if (/(auto|scroll|hidden|clip)/.test(style.overflowY)) { clip.top = Math.max(clip.top, parentBox.top); clip.bottom = Math.min(clip.bottom, parentBox.bottom); } } const style = getComputedStyle(element); const minimumHeight = element.tagName === 'TEXTAREA' ? ['lineHeight', 'paddingTop', 'paddingBottom', 'borderTopWidth', 'borderBottomWidth'].reduce((sum, property) => sum + parseFloat(style[property]), 0) : 0; return { x: box.x, y: box.y, width: box.width, height: box.height, minimumHeight, visible: box.width > 0 && box.height > 0 && box.left >= clip.left - 1 && box.right <= clip.right + 1 && box.top >= clip.top - 1 && box.bottom <= clip.bottom + 1 }; }); assert(result.visible, `${label}: ${selector} clipped or hidden ${JSON.stringify(result)}`); assert(result.height >= result.minimumHeight - 1, `${label}: ${selector} has less than one readable line ${JSON.stringify(result)}`); return result; }; const score = async () => { await ready(); await inView('#submit', 'Submit available'); await page.locator('#submit').click(); await page.locator('#loading-state').waitFor({ state: 'visible' }); await geometry('Loading'); await page.locator('#results-content').waitFor({ state: 'visible' }); await ready(); }; for (const [width, height] of [[1440, 900], [1366, 768], [1280, 720], [1024, 600], [768, 1024], [390, 844], [375, 667], [320, 568], [844, 390], [667, 375], [1024, 420], [390, 360], [320, 420]]) { const label = `${width}x${height}`; await page.setViewportSize({ width, height }); await page.goto(url); await ready(); const compact = await tabs(); const measurements = await geometry(`${label} initial`); await inView('#model', label); await inView('#calibration-badge', label); await inView('#context', label); await inView('#submit', label); await page.locator('#context').fill('An invented context. '.repeat(90)); await view('choices'); await inView('#question', label); await inView('#options', label); await inView('#submit', label); await page.locator('#question').fill('Which option fits?'); await page.locator('#options').fill('First option\nSecond option\nThird option'); await geometry(`${label} choices`); if (['1366x768', '320x568', '844x390'].includes(label)) { await page.screenshot({ path: path.join(output, `${label}-choices.png`) }); await view('context'); await page.screenshot({ path: path.join(output, `${label}-context.png`) }); await view('choices'); } await score(); await geometry(`${label} results`); await inView('#copy-results', label); assert.equal(await page.locator('.probability-item').count(), 3); if (height >= 568) { for (let index = 1; index <= 3; index += 1) await inView(`.probability-item:nth-child(${index})`, `${label} short answer list`); } assert.equal(lastRequest.options.length, 3); if (compact) assert.equal(await page.locator('#tab-results').getAttribute('aria-selected'), 'true'); if (['1366x768', '390x844', '320x568', '844x390'].includes(label)) await page.screenshot({ path: path.join(output, `${label}-results.png`) }); await view('choices'); assert.equal(await page.locator('#question').inputValue(), 'Which option fits?'); await page.locator('#options').fill('Duplicate\nDuplicate'); const before = scoreCount; await page.locator('#submit').click(); await inView('#error-message', label); await inView('#submit', label); await inView('#options', `${label} invalid options remain editable`); await geometry(`${label} validation`); assert.equal(scoreCount, before, 'Invalid input must not send a score request'); report.viewports.push({ ...measurements, compact, verified: ['context', 'choices', 'loading', 'results', 'validation', 'input preservation', 'visible controls'] }); } await page.setViewportSize({ width: 390, height: 844 }); await page.reload(); await ready(); if (await tabs()) { await page.locator('#tab-context').focus(); await page.keyboard.press('ArrowRight'); assert.equal(await page.locator('#tab-choices').getAttribute('aria-selected'), 'true'); await page.keyboard.press('End'); assert.equal(await page.locator('#tab-results').getAttribute('aria-selected'), 'true'); await page.keyboard.press('Home'); assert.equal(await page.locator('#tab-context').getAttribute('aria-selected'), 'true'); report.checks.push('Tab arrow/Home/End keyboard navigation'); } await view('choices'); const answers = Array.from({ length: 24 }, (_, i) => `${i + 1}. A longer invented answer ${'with enough detail to wrap on a small screen. '.repeat(3)}`); await page.locator('#options').fill(answers.join('\n')); await score(); await geometry('Long results'); await inView('#copy-results', 'Long results'); await page.locator('#copy-results').click(); await page.waitForFunction(() => document.getElementById('copy-label').textContent === 'Copied'); const copied = JSON.parse(await page.evaluate(() => navigator.clipboard.readText())); assert.equal(copied.probabilities.length, 24); await page.locator('.probability-item').last().scrollIntoViewIfNeeded(); await geometry('Last result is reachable without page scrolling'); await inView('#copy-results', 'After internal result scrolling'); report.checks.push('Long answers remain reachable inside results; copy stays visible and exports all 24 options'); await view('context'); await page.locator('#context').fill('Changed context'); await view('results'); await inView('#stale-notice', 'Stale notice'); await geometry('Stale results'); await page.setViewportSize({ width: 1366, height: 768 }); await inView('#context', 'Resize to desktop'); await inView('#options', 'Resize to desktop'); await inView('#copy-results', 'Resize to desktop'); await geometry('Desktop resize'); await page.setViewportSize({ width: 320, height: 568 }); await view('choices'); failure = { status: 422, message: 'The complete prompt exceeds the supported token limit. Shorten the context or options before retrying.' }; await page.locator('#submit').click(); await page.waitForFunction(() => !document.getElementById('error-message').hidden); await geometry('Server error'); await inView('#submit', 'Server error'); await inView('#error-message', 'Server error'); failure = null; modelFailure = true; await page.reload(); await page.locator('#retry-models').waitFor({ state: 'visible' }); await geometry('Connection error'); await inView('#retry-models', 'Connection retry'); modelFailure = false; await page.locator('#retry-models').click(); await ready(); report.checks.push('Stale results, desktop/mobile resize, server error and model connection retry'); assert.deepEqual(report.errors, []); report.status = 'passed'; } catch (error) { report.status = 'failed'; report.failure = error.stack; process.exitCode = 1; } finally { report.finished_utc = new Date().toISOString(); await fs.writeFile(path.join(output, 'layout-verification.json'), JSON.stringify(report, null, 2) + '\n'); await browser.close(); await new Promise(resolve => server.close(resolve)); } console.log(JSON.stringify({ status: report.status, viewports: report.viewports.length, failure: report.failure, output })); } main().catch(error => { console.error(error); process.exitCode = 1; });