import { chromium } from 'playwright'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; const url = process.argv[2] ?? 'http://127.0.0.1:4173/'; const result = { url, checks: [], errors: [] }; const browser = await chromium.launch({ executablePath: '/usr/bin/chromium', headless: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); page.setDefaultTimeout(15000); page.on('pageerror', error => result.errors.push(error.message)); const ready = async () => { await page.waitForFunction(() => window.browserPi?.terminal); await page.evaluate(() => window.browserPi.ready); }; const divider = page.getByRole('separator', { name: 'Resize chat and workspace' }); const geometry = async () => { const size = await page.evaluate(() => { const rect = selector => document.querySelector(selector).getBoundingClientRect().toJSON(); return { main: rect('main'), chat: rect('.conversation'), workspace: rect('.workspace'), handle: rect('#column-resizer'), desktop: matchMedia('(min-width: 851px)').matches, gutter: parseFloat(getComputedStyle(document.querySelector('main')).getPropertyValue('--content-gutter')), cols: window.browserPi.terminal.cols, overflow: document.documentElement.scrollWidth > innerWidth }; }); if (size.desktop) { closeTo(size.workspace.left, size.chat.right); closeTo(size.workspace.right + size.gutter, size.main.right); } return size; }; const frames = () => page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); async function drag(delta, release = true) { const r = await divider.boundingBox(); await page.mouse.move(r.x + r.width / 2, r.y + r.height / 2); await page.mouse.down(); await page.mouse.move(r.x + r.width / 2 + delta, r.y + r.height / 2, { steps: 12 }); if (release) await page.mouse.up(); await frames(); } const closeTo = (actual, expected) => assert(Math.abs(actual - expected) < 1, `${actual} vs ${expected}`); try { await page.goto(url); await ready(); assert(await divider.isVisible()); const initial = await geometry(); closeTo(initial.chat.width, initial.main.width / 1.83); assert(initial.handle.x >= initial.chat.right - 14.1 && initial.handle.right <= initial.workspace.left + .1); await page.locator('.xterm-helper-textarea').focus(); await page.keyboard.type('Keep this draft'); await page.keyboard.press('Shift+Enter'); await page.keyboard.type('while resizing'); await page.locator('#files button[title="/workspace/products.json"]').click(); await page.locator('#editor').fill('{"unsaved": true}'); await page.locator('#command').fill('echo "unsent command"'); await drag(240); assert((await geometry()).chat.width > initial.chat.width + 230); await page.waitForFunction(cols => window.browserPi.terminal.cols > cols, initial.cols); assert.equal(await page.evaluate(() => window.browserPi.terminal.text), 'Keep this draft\nwhile resizing'); assert.equal(await page.locator('#editor').inputValue(), '{"unsaved": true}'); assert.equal(await page.locator('#command').inputValue(), 'echo "unsent command"'); assert(await page.locator('#save').isEnabled()); await page.waitForFunction(() => document.querySelector('#editor-highlight').clientWidth === document.querySelector('#editor').clientWidth); result.checks.push('Live resizing preserves Pi, file and shell drafts and resizes the terminal and syntax layer'); await drag(-2000); closeTo((await geometry()).chat.width, 420); await divider.press('End'); await frames(); let size = await geometry(); closeTo(size.main.width - size.chat.width, 370); assert.equal(await divider.getAttribute('aria-valuenow'), await divider.getAttribute('aria-valuemax')); await divider.press('ArrowLeft'); await frames(); closeTo((await geometry()).chat.width, size.chat.width - 16); await divider.press('Shift+ArrowLeft'); await frames(); closeTo((await geometry()).chat.width, size.chat.width - 64); await divider.press('Home'); await frames(); closeTo((await geometry()).chat.width, 420); await divider.press('Enter'); await frames(); closeTo((await geometry()).chat.width, initial.chat.width); result.checks.push('Width limits, keyboard nudges, Shift, Home/End, Enter reset and accessible values work'); await drag(150, false); await divider.press('Escape'); await page.mouse.up(); await frames(); closeTo((await geometry()).chat.width, initial.chat.width); assert(!await page.locator('html').evaluate(el => el.classList.contains('is-resizing-columns'))); await drag(100, false); await divider.dispatchEvent('pointercancel', { pointerId: 1 }); await page.mouse.up(); await frames(); closeTo((await geometry()).chat.width, initial.chat.width); await drag(170); const saved = (await geometry()).chat.width; await page.reload(); await ready(); await frames(); closeTo((await geometry()).chat.width, saved); await page.setViewportSize({ width: 851, height: 800 }); await frames(); size = await geometry(); assert(size.chat.width >= 420 && size.main.width - size.chat.width >= 369.9 && !size.overflow); await page.setViewportSize({ width: 1440, height: 1000 }); await frames(); closeTo((await geometry()).chat.width, saved); result.checks.push('Cancelled drags roll back; saved ratio survives reload and returns after narrow-window clamping'); await divider.dblclick(); await frames(); closeTo((await geometry()).chat.width, initial.chat.width); await page.setViewportSize({ width: 2400, height: 1000 }); await frames(); size = await geometry(); assert.equal(size.main.width, 1800); assert(size.main.left > 0); await drag(100); closeTo((await geometry()).chat.width, size.chat.width + 100); await page.screenshot({ path: '../validation/column-resizer-wide.png', fullPage: true }); await page.setViewportSize({ width: 1440, height: 1000 }); await frames(); await divider.dblclick(); await frames(); await page.screenshot({ path: '../validation/column-resizer-desktop.png', fullPage: true }); await drag(80, false); await page.setViewportSize({ width: 850, height: 800 }); await page.mouse.up(); await frames(); assert(!await page.locator('html').evaluate(el => el.classList.contains('is-resizing-columns'))); for (const width of [850,390,320]) { await page.setViewportSize({ width, height: 844 }); await frames(); assert(await page.locator('#column-resizer').isHidden()); size = await geometry(); assert(!size.overflow && size.workspace.top >= size.chat.bottom - 1); } await page.screenshot({ path: '../validation/column-resizer-mobile.png', fullPage: true }); result.checks.push('Double-click resets; centered wide layouts drag accurately; mobile stays stacked and cancels desktop drags'); const blocked = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); await blocked.addInitScript(() => { Storage.prototype.getItem = Storage.prototype.setItem = () => { throw new DOMException('Blocked', 'SecurityError'); }; }); await blocked.goto(url); await blocked.waitForFunction(() => window.browserPi?.terminal); await blocked.getByRole('separator', { name: 'Resize chat and workspace' }).press('Home'); closeTo(await blocked.locator('.conversation').evaluate(el => el.getBoundingClientRect().width), 420); await blocked.close(); result.checks.push('Resizing works with blocked browser storage'); assert.deepEqual(result.errors, []); result.passed = true; console.log(JSON.stringify(result)); } catch (error) { result.failure = error.stack; await page.screenshot({ path: '../validation/column-resizer-failure.png', fullPage: true }); throw error; } finally { await fs.writeFile('../validation/column-resizer.json', JSON.stringify(result, null, 2)); await browser.close(); }