File size: 13,341 Bytes
e60eab9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 | /* 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; });
|