/* Drive the editor chrome in a real browser. * * The sibling of drive.mjs, which drives live sync across two tabs. * This one drives the menu bar, the toolbar, the formula bar and the * cell paint. Like its sibling it is NOT part of `make test`: it needs * a browser and a running instance, neither of which CI has. * * It earned its place immediately. The menu bar tested perfectly from * the keyboard and did not work with a mouse at all: reaching for a * menu is ENTER THEN CLICK, the hover-switch opened it, and 's * own activation behaviour closed it again a millisecond later. No Go * test can see that, and neither could reading the code. * * Usage, from the repository root — see drive.mjs for the full recipe: * * SHEETS_SOCK=$D/s.sock SHEETS_LOG=$D/s.log OUT=$D node tools/drive/chrome.mjs * * OUT is where the four screenshots land (light, dark, 320px, 768px). */ import net from 'node:net'; import fs from 'node:fs'; const PLAYWRIGHT = process.env.PLAYWRIGHT || 'playwright'; const pw = await import(PLAYWRIGHT); /* A CJS build imported from ESM puts its exports on .default. */ const chromium = pw.chromium || (pw.default && pw.default.chromium); if (!chromium) { console.error('playwright: no chromium export'); process.exit(1); } const SOCK = process.env.SHEETS_SOCK; const LOG = process.env.SHEETS_LOG; const PORT = parseInt(process.env.SHEETS_PORT || '8799', 10); const OUT = process.env.OUT || '.'; const proxy = net.createServer((c) => { const u = net.connect(SOCK); c.pipe(u); u.pipe(c); c.on('error', () => u.destroy()); u.on('error', () => c.destroy()); }); await new Promise((r) => proxy.listen(PORT, '127.0.0.1', r)); const base = `http://127.0.0.1:${PORT}`; const fails = []; const ok = (name) => console.log(` ok ${name}`); const bad = (name, detail) => { fails.push(`${name}: ${detail}`); console.log(` FAIL ${name} — ${detail}`); }; const check = (name, cond, detail) => cond ? ok(name) : bad(name, detail || 'false'); const browser = await chromium.launch(); const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); const errors = []; page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); page.on('pageerror', (e) => errors.push(String(e))); /* Sign in through the link the log prints. Strip at the first backslash: * the log escapes the mail body, so a naive grep captures the token plus * "\n\nIt..." and the link comes back expired. */ await page.goto(`${base}/signin`); await page.fill('input[type=email]', 'paul@example.com'); await Promise.all([page.waitForURL(/signin\?sent=1/), page.click('form[action="/signin"] button[type=submit]')]); const log = fs.readFileSync(LOG, 'utf8'); const m = [...log.matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop(); if (!m) { console.error('no magic link in the log'); process.exit(1); } await page.goto(m[0]); await page.waitForURL(/\/sheets/); /* A sheet with something in it. * * Created from the top bar's Create button, then named by renaming it. * The list page's #title field is gone: it carries no forms now, Create * posts an empty one and the sheet lands as "Untitled sheet", so the * only place a sheet is ever named is the title in the top bar. The * rename is a real form submit, so driving it here covers the * scriptless path as well as the button. */ await Promise.all([ page.waitForURL(/\/sheets\/\d+$/), page.click('[estilo-bar-actions] form[action="/sheets"] button[type=submit]'), ]); await page.fill('[estilo-bar-title-input]', 'Commission Q2'); await Promise.all([ page.waitForURL(/\/sheets\/\d+$/), page.press('[estilo-bar-title-input]', 'Enter'), ]); const url = page.url(); await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet()); async function type(r, c, text) { await page.evaluate(([r, c, text]) => window.SheetsGrid.write(r, c, text), [r, c, text]); } const rows = [ ['Invoice Quarter', 'Event', 'Invoice (GBP)', 'Commission'], ['Q2/26', 'Juxt', '3228.33', '=C2*0.1'], ['Q2/26', 'Techarena', '1840.00', '=C3*0.1'], ['Q2/26', 'Seapoint', '920.50', '=C4*0.1'], ['', 'Total commission owing', '', '=SUM(D2:D4)'], ]; for (let r = 0; r < rows.length; r++) { for (let c = 0; c < rows[r].length; c++) if (rows[r][c]) await type(r, c, rows[r][c]); } await page.waitForTimeout(400); console.log('\n— the formula engine still owns every painted string'); const d5 = await page.evaluate(() => window.SheetsGrid.worksheet().getValueFromCoords(3, 4)); check('SUM of the commission column', d5 === '598.883', `got ${JSON.stringify(d5)}`); console.log('\n— the menu bar'); await page.click('[data-menubar] details:nth-child(1) > summary'); check('File opens', await page.isVisible('a[href$="/export.xlsx"]')); await page.click('[data-menubar] details:nth-child(2) > summary'); check('Edit opens and File closed', await page.isVisible('[data-cmd="fill-down"]') && !(await page.evaluate(() => document.querySelector('[data-menubar] details:nth-child(1)').open)), 'the exclusive group is not working'); await page.keyboard.press('Escape'); /* Keyboard: focus the first summary, walk right, open, walk down. */ await page.evaluate(() => document.querySelector('[data-menubar] > details > summary').focus()); await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowDown'); const focusTag = await page.evaluate(() => document.activeElement.textContent.trim().split('\n')[0]); check('arrows open a menu and land on its first item', focusTag.length > 0, `focus is on ${JSON.stringify(focusTag)}`); const cursorMoved = await page.evaluate(() => { const s = window.SheetsGrid.selection(); return s ? `${s.r},${s.c}` : 'none'; }); await page.keyboard.press('Escape'); console.log('\n— formatting reaches the cell and the paint reaches the td'); await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(0, 0, 3, 0)); await page.waitForTimeout(150); await page.click('[data-toolbar] [data-cmd="bold"]'); await page.waitForTimeout(400); const hdr = await page.evaluate(() => { const td = window.SheetsGrid.worksheet().records[0][0].element; return { attr: td.hasAttribute('data-b'), weight: getComputedStyle(td).fontWeight }; }); check('header row is bold on the td', hdr.attr, 'data-b was never set'); check('...and the CSS actually applies it', parseInt(hdr.weight, 10) >= 600, `font-weight ${hdr.weight}`); const pressed = await page.getAttribute('[data-toolbar] [data-cmd="bold"]', 'aria-pressed'); check('the toolbar reflects the selection', pressed === 'true', `aria-pressed=${pressed}`); /* Alignment. This check exists because its absence let a blocker * through: the library writes text-align as an INLINE style on every * cell, so the attribute was set, aria-pressed reflected it, and the * text did not move. Bold and fill both passed, because neither paints * a property the library touches. Assert the COMPUTED value, never the * attribute — the attribute was always right. */ await page.click('[data-toolbar] [data-cmd="align"][data-value="right"]'); await page.waitForTimeout(400); const aligned = await page.evaluate(() => getComputedStyle(window.SheetsGrid.worksheet().records[0][0].element).textAlign); check('alignment actually moves the text', aligned === 'right' || aligned === 'end', `computed ${aligned}`); await page.click('[data-toolbar] [data-cmd="align"][data-value="right"]'); await page.waitForTimeout(400); /* Numbers right-align without anybody asking. A spreadsheet that * centres its numbers does not look like one. */ const numAlign = await page.evaluate(() => getComputedStyle(window.SheetsGrid.worksheet().records[1][2].element).textAlign); check('numbers right-align on their own', numAlign === 'right' || numAlign === 'end', `computed ${numAlign}`); const textAlign = await page.evaluate(() => getComputedStyle(window.SheetsGrid.worksheet().records[1][1].element).textAlign); check('...and text does not', textAlign === 'left' || textAlign === 'start', `computed ${textAlign}`); /* Fill: a swatch by name, resolved against the theme's lightness. */ await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(1, 4, 3, 4)); await page.waitForTimeout(150); await page.click('[data-toolbar] [data-tool][aria-label="Fill colour"]'); await page.click('[data-toolbar] [data-cmd="fill"][data-value="green"]'); await page.waitForTimeout(400); const fill = await page.evaluate(() => { const td = window.SheetsGrid.worksheet().records[4][1].element; return { attr: td.hasAttribute('data-fill'), bg: getComputedStyle(td).backgroundColor }; }); check('the total row has a fill', fill.attr, 'data-fill was never set'); check('...and it paints a real colour', fill.bg && fill.bg !== 'rgba(0, 0, 0, 0)', `background ${fill.bg}`); /* A number format changes the SERVER-rendered string, not a client one. */ await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(3, 1, 3, 4)); await page.waitForTimeout(150); await page.click('[data-toolbar] [data-tool][aria-label="Number format"]'); await page.click('[data-toolbar] [data-cmd="numfmt"][data-value="0.00"]'); await page.waitForTimeout(500); const formatted = await page.evaluate(() => window.SheetsGrid.worksheet().getValueFromCoords(3, 4)); check('number format re-renders server-side', formatted === '598.88', `got ${JSON.stringify(formatted)}`); console.log('\n— the formula bar'); await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(3, 4, 3, 4)); await page.waitForTimeout(200); check('name box shows the reference', (await page.inputValue('[data-name-box]')) === 'D5', `got ${await page.inputValue('[data-name-box]')}`); check('formula bar shows the input, not the value', (await page.inputValue('[data-formula-input]')) === '=SUM(D2:D4)', `got ${await page.inputValue('[data-formula-input]')}`); await page.fill('[data-name-box]', 'B2'); await page.press('[data-name-box]', 'Enter'); await page.waitForTimeout(250); const jumped = await page.evaluate(() => { const s = window.SheetsGrid.selection(); return `${s.r},${s.c}`; }); check('typing a reference moves the selection', jumped === '1,1', `selection is ${jumped}`); /* The anchor cursor has to survive a fill. It used to be marked by a * background alone, and the fill rules land later at the same * specificity — so on a filled range the cursor vanished at exactly the * moment you were aiming with it. */ await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(1, 4, 3, 4)); await page.waitForTimeout(300); const anchorRing = await page.evaluate(() => getComputedStyle(window.SheetsGrid.worksheet().records[4][1].element).boxShadow); check('the anchor is visible on a filled cell', anchorRing && anchorRing !== 'none', `box-shadow ${anchorRing}`); console.log('\n— column widths'); /* Resize through the library the way a drag does, then prove it survived * a reload: a width the server did not keep looks identical until the * page comes back. */ await page.evaluate(() => window.SheetsGrid.worksheet().setWidth(0, 220)); await page.waitForTimeout(500); await page.reload(); await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet()); await page.waitForTimeout(700); const w = await page.evaluate(() => { const td = document.querySelector('[data-sheet-grid] thead td[data-x="0"]') || window.SheetsGrid.worksheet().records[0][0].element; return Math.round(td.getBoundingClientRect().width); }); check('a column width survives a reload', Math.abs(w - 220) <= 3, `column A is ${w}px, want ~220`); console.log('\n— export'); const dl = await page.request.get(`${url}/export.xlsx`); const buf = await dl.body(); check('export downloads a real xlsx', dl.status() === 200 && buf[0] === 0x50 && buf[1] === 0x4b, `status ${dl.status()}, ${buf.length} bytes`); check('...named after the sheet', (dl.headers()['content-disposition'] || '').includes('Commission Q2.xlsx'), dl.headers()['content-disposition']); console.log('\n— screenshots and reflow'); await page.evaluate(() => document.activeElement.blur()); await page.screenshot({ path: `${OUT}/sheet-light.png` }); /* Dark, chosen explicitly through the form the account menu posts * (estilo's /appearance, which replaced /mode). */ await page.evaluate(() => { const f = document.createElement('form'); f.method = 'post'; f.action = '/appearance'; f.innerHTML = ''; document.body.appendChild(f); f.submit(); }); await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet()); await page.waitForTimeout(700); await page.screenshot({ path: `${OUT}/sheet-dark.png` }); const darkFill = await page.evaluate(() => getComputedStyle(window.SheetsGrid.worksheet().records[4][1].element).backgroundColor); check('the fill follows the theme into dark', darkFill !== fill.bg, `light ${fill.bg} vs dark ${darkFill}`); for (const w of [320, 768]) { await page.setViewportSize({ width: w, height: 800 }); await page.waitForTimeout(300); const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); check(`no horizontal scroll at ${w}px`, over <= 0, `overflows by ${over}px`); await page.screenshot({ path: `${OUT}/sheet-${w}.png` }); } check('no console errors', errors.length === 0, errors.slice(0, 3).join(' | ')); /* A viewer's page, in a second context with its own session. * * The chrome is gated on MayWrite, and the failure this guards against * is not a crash: it is a viewer being handed a toolbar whose every * control 404s, which looks like a broken app rather than like a * permission. A control somebody may not use should be absent. */ console.log('\n— the read-only viewer'); await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(`${url}/share`); await page.fill('input[name=address]', 'ada@elsewhere.example'); await page.selectOption('select[name=role]', 'viewer').catch(() => {}); await Promise.all([ page.waitForLoadState('networkidle'), page.click('form[action$="/share"] button[type=submit]'), ]); const viewerCtx = await browser.newContext(); const vp = await viewerCtx.newPage(); const vErrors = []; vp.on('pageerror', (e) => vErrors.push(String(e))); await vp.goto(`${base}/signin`); await vp.fill('input[type=email]', 'ada@elsewhere.example'); await Promise.all([vp.waitForURL(/signin\?sent=1/), vp.click('form[action="/signin"] button[type=submit]')]); const vm = [...fs.readFileSync(LOG, 'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop(); await vp.goto(vm[0]); await vp.goto(url); await vp.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet(), null, { timeout: 15000 }).catch(() => {}); await vp.waitForTimeout(600); const vMenus = await vp.evaluate(() => Array.from(document.querySelectorAll('[data-menubar] > details > summary')).map((s) => s.textContent.trim())); check('a viewer still gets a working grid', await vp.evaluate(() => !!(window.SheetsGrid && window.SheetsGrid.worksheet()))); check('...with no toolbar', !(await vp.isVisible('[data-toolbar]')), 'a viewer was offered controls that 404'); check('...no Edit or Format menu', !vMenus.includes('Edit') && !vMenus.includes('Format'), `menus: ${vMenus.join(', ')}`); check('...but File is still there', vMenus.includes('File')); check('...a read-only formula bar', await vp.evaluate(() => document.querySelector('[data-formula-input]').readOnly)); /* Read-only is about writing, not about seeing. A viewer must still get * the document as authored. */ check('...and cells that still paint their formats', await vp.evaluate(() => document.querySelectorAll('[data-sheet-grid] td[data-b],[data-sheet-grid] td[data-fill],[data-sheet-grid] td[data-fx]').length > 0)); check('...no console errors on the viewer page', vErrors.length === 0, vErrors.slice(0, 2).join(' | ')); await vp.screenshot({ path: `${OUT}/sheet-viewer.png` }); await browser.close(); proxy.close(); console.log(fails.length ? `\n${fails.length} FAILED\n` + fails.map((f) => ' ' + f).join('\n') : '\nall checks passed'); process.exit(fails.length ? 1 : 0);