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; });