| 1 | /* Drive the editor chrome in a real browser. |
| 2 | * |
| 3 | * The sibling of drive.mjs, which drives live sync across two tabs. |
| 4 | * This one drives the menu bar, the toolbar, the formula bar and the |
| 5 | * cell paint. Like its sibling it is NOT part of `make test`: it needs |
| 6 | * a browser and a running instance, neither of which CI has. |
| 7 | * |
| 8 | * It earned its place immediately. The menu bar tested perfectly from |
| 9 | * the keyboard and did not work with a mouse at all: reaching for a |
| 10 | * menu is ENTER THEN CLICK, the hover-switch opened it, and <summary>'s |
| 11 | * own activation behaviour closed it again a millisecond later. No Go |
| 12 | * test can see that, and neither could reading the code. |
| 13 | * |
| 14 | * Usage, from the repository root — see drive.mjs for the full recipe: |
| 15 | * |
| 16 | * SHEETS_SOCK=$D/s.sock SHEETS_LOG=$D/s.log OUT=$D node tools/drive/chrome.mjs |
| 17 | * |
| 18 | * OUT is where the four screenshots land (light, dark, 320px, 768px). |
| 19 | */ |
| 20 | import net from 'node:net'; |
| 21 | import fs from 'node:fs'; |
| 22 | const PLAYWRIGHT = process.env.PLAYWRIGHT || 'playwright'; |
| 23 | const pw = await import(PLAYWRIGHT); |
| 24 | /* A CJS build imported from ESM puts its exports on .default. */ |
| 25 | const chromium = pw.chromium || (pw.default && pw.default.chromium); |
| 26 | if (!chromium) { console.error('playwright: no chromium export'); process.exit(1); } |
| 27 | |
| 28 | const SOCK = process.env.SHEETS_SOCK; |
| 29 | const LOG = process.env.SHEETS_LOG; |
| 30 | const PORT = parseInt(process.env.SHEETS_PORT || '8799', 10); |
| 31 | const OUT = process.env.OUT || '.'; |
| 32 | |
| 33 | const proxy = net.createServer((c) => { |
| 34 | const u = net.connect(SOCK); |
| 35 | c.pipe(u); u.pipe(c); |
| 36 | c.on('error', () => u.destroy()); |
| 37 | u.on('error', () => c.destroy()); |
| 38 | }); |
| 39 | await new Promise((r) => proxy.listen(PORT, '127.0.0.1', r)); |
| 40 | const base = `http://127.0.0.1:${PORT}`; |
| 41 | |
| 42 | const fails = []; |
| 43 | const ok = (name) => console.log(` ok ${name}`); |
| 44 | const bad = (name, detail) => { fails.push(`${name}: ${detail}`); console.log(` FAIL ${name} — ${detail}`); }; |
| 45 | const check = (name, cond, detail) => cond ? ok(name) : bad(name, detail || 'false'); |
| 46 | |
| 47 | const browser = await chromium.launch(); |
| 48 | const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); |
| 49 | const page = await ctx.newPage(); |
| 50 | const errors = []; |
| 51 | page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); |
| 52 | page.on('pageerror', (e) => errors.push(String(e))); |
| 53 | |
| 54 | /* Sign in through the link the log prints. Strip at the first backslash: |
| 55 | * the log escapes the mail body, so a naive grep captures the token plus |
| 56 | * "\n\nIt..." and the link comes back expired. */ |
| 57 | await page.goto(`${base}/signin`); |
| 58 | await page.fill('input[type=email]', 'paul@example.com'); |
| 59 | await Promise.all([page.waitForURL(/signin\?sent=1/), page.click('form[action="/signin"] button[type=submit]')]); |
| 60 | const log = fs.readFileSync(LOG, 'utf8'); |
| 61 | const m = [...log.matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop(); |
| 62 | if (!m) { console.error('no magic link in the log'); process.exit(1); } |
| 63 | await page.goto(m[0]); |
| 64 | await page.waitForURL(/\/sheets/); |
| 65 | |
| 66 | /* A sheet with something in it. |
| 67 | * |
| 68 | * Created from the top bar's Create button, then named by renaming it. |
| 69 | * The list page's #title field is gone: it carries no forms now, Create |
| 70 | * posts an empty one and the sheet lands as "Untitled sheet", so the |
| 71 | * only place a sheet is ever named is the title in the top bar. The |
| 72 | * rename is a real form submit, so driving it here covers the |
| 73 | * scriptless path as well as the button. */ |
| 74 | await Promise.all([ |
| 75 | page.waitForURL(/\/sheets\/\d+$/), |
| 76 | page.click('[estilo-bar-actions] form[action="/sheets"] button[type=submit]'), |
| 77 | ]); |
| 78 | await page.fill('[estilo-bar-title-input]', 'Commission Q2'); |
| 79 | await Promise.all([ |
| 80 | page.waitForURL(/\/sheets\/\d+$/), |
| 81 | page.press('[estilo-bar-title-input]', 'Enter'), |
| 82 | ]); |
| 83 | const url = page.url(); |
| 84 | await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet()); |
| 85 | |
| 86 | async function type(r, c, text) { |
| 87 | await page.evaluate(([r, c, text]) => window.SheetsGrid.write(r, c, text), [r, c, text]); |
| 88 | } |
| 89 | const rows = [ |
| 90 | ['Invoice Quarter', 'Event', 'Invoice (GBP)', 'Commission'], |
| 91 | ['Q2/26', 'Juxt', '3228.33', '=C2*0.1'], |
| 92 | ['Q2/26', 'Techarena', '1840.00', '=C3*0.1'], |
| 93 | ['Q2/26', 'Seapoint', '920.50', '=C4*0.1'], |
| 94 | ['', 'Total commission owing', '', '=SUM(D2:D4)'], |
| 95 | ]; |
| 96 | for (let r = 0; r < rows.length; r++) { |
| 97 | for (let c = 0; c < rows[r].length; c++) if (rows[r][c]) await type(r, c, rows[r][c]); |
| 98 | } |
| 99 | await page.waitForTimeout(400); |
| 100 | |
| 101 | console.log('\n— the formula engine still owns every painted string'); |
| 102 | const d5 = await page.evaluate(() => window.SheetsGrid.worksheet().getValueFromCoords(3, 4)); |
| 103 | check('SUM of the commission column', d5 === '598.883', `got ${JSON.stringify(d5)}`); |
| 104 | |
| 105 | console.log('\n— the menu bar'); |
| 106 | await page.click('[data-menubar] details:nth-child(1) > summary'); |
| 107 | check('File opens', await page.isVisible('a[href$="/export.xlsx"]')); |
| 108 | await page.click('[data-menubar] details:nth-child(2) > summary'); |
| 109 | check('Edit opens and File closed', await page.isVisible('[data-cmd="fill-down"]') |
| 110 | && !(await page.evaluate(() => document.querySelector('[data-menubar] details:nth-child(1)').open)), |
| 111 | 'the exclusive group is not working'); |
| 112 | await page.keyboard.press('Escape'); |
| 113 | |
| 114 | /* Keyboard: focus the first summary, walk right, open, walk down. */ |
| 115 | await page.evaluate(() => document.querySelector('[data-menubar] > details > summary').focus()); |
| 116 | await page.keyboard.press('ArrowRight'); |
| 117 | await page.keyboard.press('ArrowDown'); |
| 118 | const focusTag = await page.evaluate(() => document.activeElement.textContent.trim().split('\n')[0]); |
| 119 | check('arrows open a menu and land on its first item', focusTag.length > 0, `focus is on ${JSON.stringify(focusTag)}`); |
| 120 | const cursorMoved = await page.evaluate(() => { |
| 121 | const s = window.SheetsGrid.selection(); |
| 122 | return s ? `${s.r},${s.c}` : 'none'; |
| 123 | }); |
| 124 | await page.keyboard.press('Escape'); |
| 125 | |
| 126 | console.log('\n— formatting reaches the cell and the paint reaches the td'); |
| 127 | await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(0, 0, 3, 0)); |
| 128 | await page.waitForTimeout(150); |
| 129 | await page.click('[data-toolbar] [data-cmd="bold"]'); |
| 130 | await page.waitForTimeout(400); |
| 131 | const hdr = await page.evaluate(() => { |
| 132 | const td = window.SheetsGrid.worksheet().records[0][0].element; |
| 133 | return { attr: td.hasAttribute('data-b'), weight: getComputedStyle(td).fontWeight }; |
| 134 | }); |
| 135 | check('header row is bold on the td', hdr.attr, 'data-b was never set'); |
| 136 | check('...and the CSS actually applies it', parseInt(hdr.weight, 10) >= 600, `font-weight ${hdr.weight}`); |
| 137 | |
| 138 | const pressed = await page.getAttribute('[data-toolbar] [data-cmd="bold"]', 'aria-pressed'); |
| 139 | check('the toolbar reflects the selection', pressed === 'true', `aria-pressed=${pressed}`); |
| 140 | |
| 141 | /* Alignment. This check exists because its absence let a blocker |
| 142 | * through: the library writes text-align as an INLINE style on every |
| 143 | * cell, so the attribute was set, aria-pressed reflected it, and the |
| 144 | * text did not move. Bold and fill both passed, because neither paints |
| 145 | * a property the library touches. Assert the COMPUTED value, never the |
| 146 | * attribute — the attribute was always right. */ |
| 147 | await page.click('[data-toolbar] [data-cmd="align"][data-value="right"]'); |
| 148 | await page.waitForTimeout(400); |
| 149 | const aligned = await page.evaluate(() => |
| 150 | getComputedStyle(window.SheetsGrid.worksheet().records[0][0].element).textAlign); |
| 151 | check('alignment actually moves the text', aligned === 'right' || aligned === 'end', `computed ${aligned}`); |
| 152 | await page.click('[data-toolbar] [data-cmd="align"][data-value="right"]'); |
| 153 | await page.waitForTimeout(400); |
| 154 | |
| 155 | /* Numbers right-align without anybody asking. A spreadsheet that |
| 156 | * centres its numbers does not look like one. */ |
| 157 | const numAlign = await page.evaluate(() => |
| 158 | getComputedStyle(window.SheetsGrid.worksheet().records[1][2].element).textAlign); |
| 159 | check('numbers right-align on their own', numAlign === 'right' || numAlign === 'end', `computed ${numAlign}`); |
| 160 | const textAlign = await page.evaluate(() => |
| 161 | getComputedStyle(window.SheetsGrid.worksheet().records[1][1].element).textAlign); |
| 162 | check('...and text does not', textAlign === 'left' || textAlign === 'start', `computed ${textAlign}`); |
| 163 | |
| 164 | /* Fill: a swatch by name, resolved against the theme's lightness. */ |
| 165 | await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(1, 4, 3, 4)); |
| 166 | await page.waitForTimeout(150); |
| 167 | await page.click('[data-toolbar] [data-tool][aria-label="Fill colour"]'); |
| 168 | await page.click('[data-toolbar] [data-cmd="fill"][data-value="green"]'); |
| 169 | await page.waitForTimeout(400); |
| 170 | const fill = await page.evaluate(() => { |
| 171 | const td = window.SheetsGrid.worksheet().records[4][1].element; |
| 172 | return { attr: td.hasAttribute('data-fill'), bg: getComputedStyle(td).backgroundColor }; |
| 173 | }); |
| 174 | check('the total row has a fill', fill.attr, 'data-fill was never set'); |
| 175 | check('...and it paints a real colour', fill.bg && fill.bg !== 'rgba(0, 0, 0, 0)', `background ${fill.bg}`); |
| 176 | |
| 177 | /* A number format changes the SERVER-rendered string, not a client one. */ |
| 178 | await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(3, 1, 3, 4)); |
| 179 | await page.waitForTimeout(150); |
| 180 | await page.click('[data-toolbar] [data-tool][aria-label="Number format"]'); |
| 181 | await page.click('[data-toolbar] [data-cmd="numfmt"][data-value="0.00"]'); |
| 182 | await page.waitForTimeout(500); |
| 183 | const formatted = await page.evaluate(() => window.SheetsGrid.worksheet().getValueFromCoords(3, 4)); |
| 184 | check('number format re-renders server-side', formatted === '598.88', `got ${JSON.stringify(formatted)}`); |
| 185 | |
| 186 | console.log('\n— the formula bar'); |
| 187 | await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(3, 4, 3, 4)); |
| 188 | await page.waitForTimeout(200); |
| 189 | check('name box shows the reference', (await page.inputValue('[data-name-box]')) === 'D5', |
| 190 | `got ${await page.inputValue('[data-name-box]')}`); |
| 191 | check('formula bar shows the input, not the value', |
| 192 | (await page.inputValue('[data-formula-input]')) === '=SUM(D2:D4)', |
| 193 | `got ${await page.inputValue('[data-formula-input]')}`); |
| 194 | |
| 195 | await page.fill('[data-name-box]', 'B2'); |
| 196 | await page.press('[data-name-box]', 'Enter'); |
| 197 | await page.waitForTimeout(250); |
| 198 | const jumped = await page.evaluate(() => { const s = window.SheetsGrid.selection(); return `${s.r},${s.c}`; }); |
| 199 | check('typing a reference moves the selection', jumped === '1,1', `selection is ${jumped}`); |
| 200 | |
| 201 | /* The anchor cursor has to survive a fill. It used to be marked by a |
| 202 | * background alone, and the fill rules land later at the same |
| 203 | * specificity — so on a filled range the cursor vanished at exactly the |
| 204 | * moment you were aiming with it. */ |
| 205 | await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(1, 4, 3, 4)); |
| 206 | await page.waitForTimeout(300); |
| 207 | const anchorRing = await page.evaluate(() => |
| 208 | getComputedStyle(window.SheetsGrid.worksheet().records[4][1].element).boxShadow); |
| 209 | check('the anchor is visible on a filled cell', anchorRing && anchorRing !== 'none', `box-shadow ${anchorRing}`); |
| 210 | |
| 211 | console.log('\n— column widths'); |
| 212 | /* Resize through the library the way a drag does, then prove it survived |
| 213 | * a reload: a width the server did not keep looks identical until the |
| 214 | * page comes back. */ |
| 215 | await page.evaluate(() => window.SheetsGrid.worksheet().setWidth(0, 220)); |
| 216 | await page.waitForTimeout(500); |
| 217 | await page.reload(); |
| 218 | await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet()); |
| 219 | await page.waitForTimeout(700); |
| 220 | const w = await page.evaluate(() => { |
| 221 | const td = document.querySelector('[data-sheet-grid] thead td[data-x="0"]') |
| 222 | || window.SheetsGrid.worksheet().records[0][0].element; |
| 223 | return Math.round(td.getBoundingClientRect().width); |
| 224 | }); |
| 225 | check('a column width survives a reload', Math.abs(w - 220) <= 3, `column A is ${w}px, want ~220`); |
| 226 | |
| 227 | console.log('\n— export'); |
| 228 | const dl = await page.request.get(`${url}/export.xlsx`); |
| 229 | const buf = await dl.body(); |
| 230 | check('export downloads a real xlsx', dl.status() === 200 && buf[0] === 0x50 && buf[1] === 0x4b, |
| 231 | `status ${dl.status()}, ${buf.length} bytes`); |
| 232 | check('...named after the sheet', |
| 233 | (dl.headers()['content-disposition'] || '').includes('Commission Q2.xlsx'), |
| 234 | dl.headers()['content-disposition']); |
| 235 | |
| 236 | console.log('\n— screenshots and reflow'); |
| 237 | await page.evaluate(() => document.activeElement.blur()); |
| 238 | await page.screenshot({ path: `${OUT}/sheet-light.png` }); |
| 239 | |
| 240 | /* Dark, chosen explicitly through the form the account menu posts |
| 241 | * (estilo's /appearance, which replaced /mode). */ |
| 242 | await page.evaluate(() => { |
| 243 | const f = document.createElement('form'); |
| 244 | f.method = 'post'; f.action = '/appearance'; |
| 245 | f.innerHTML = '<input name="return_to" value="' + location.pathname + '"><input name="mode" value="dark">'; |
| 246 | document.body.appendChild(f); f.submit(); |
| 247 | }); |
| 248 | await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet()); |
| 249 | await page.waitForTimeout(700); |
| 250 | await page.screenshot({ path: `${OUT}/sheet-dark.png` }); |
| 251 | const darkFill = await page.evaluate(() => getComputedStyle(window.SheetsGrid.worksheet().records[4][1].element).backgroundColor); |
| 252 | check('the fill follows the theme into dark', darkFill !== fill.bg, `light ${fill.bg} vs dark ${darkFill}`); |
| 253 | |
| 254 | for (const w of [320, 768]) { |
| 255 | await page.setViewportSize({ width: w, height: 800 }); |
| 256 | await page.waitForTimeout(300); |
| 257 | const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); |
| 258 | check(`no horizontal scroll at ${w}px`, over <= 0, `overflows by ${over}px`); |
| 259 | await page.screenshot({ path: `${OUT}/sheet-${w}.png` }); |
| 260 | } |
| 261 | |
| 262 | check('no console errors', errors.length === 0, errors.slice(0, 3).join(' | ')); |
| 263 | |
| 264 | /* A viewer's page, in a second context with its own session. |
| 265 | * |
| 266 | * The chrome is gated on MayWrite, and the failure this guards against |
| 267 | * is not a crash: it is a viewer being handed a toolbar whose every |
| 268 | * control 404s, which looks like a broken app rather than like a |
| 269 | * permission. A control somebody may not use should be absent. */ |
| 270 | console.log('\n— the read-only viewer'); |
| 271 | await page.setViewportSize({ width: 1440, height: 900 }); |
| 272 | await page.goto(`${url}/share`); |
| 273 | await page.fill('input[name=address]', 'ada@elsewhere.example'); |
| 274 | await page.selectOption('select[name=role]', 'viewer').catch(() => {}); |
| 275 | await Promise.all([ |
| 276 | page.waitForLoadState('networkidle'), |
| 277 | page.click('form[action$="/share"] button[type=submit]'), |
| 278 | ]); |
| 279 | |
| 280 | const viewerCtx = await browser.newContext(); |
| 281 | const vp = await viewerCtx.newPage(); |
| 282 | const vErrors = []; |
| 283 | vp.on('pageerror', (e) => vErrors.push(String(e))); |
| 284 | await vp.goto(`${base}/signin`); |
| 285 | await vp.fill('input[type=email]', 'ada@elsewhere.example'); |
| 286 | await Promise.all([vp.waitForURL(/signin\?sent=1/), vp.click('form[action="/signin"] button[type=submit]')]); |
| 287 | const vm = [...fs.readFileSync(LOG, 'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop(); |
| 288 | await vp.goto(vm[0]); |
| 289 | await vp.goto(url); |
| 290 | await vp.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet(), null, { timeout: 15000 }).catch(() => {}); |
| 291 | await vp.waitForTimeout(600); |
| 292 | |
| 293 | const vMenus = await vp.evaluate(() => |
| 294 | Array.from(document.querySelectorAll('[data-menubar] > details > summary')).map((s) => s.textContent.trim())); |
| 295 | check('a viewer still gets a working grid', await vp.evaluate(() => !!(window.SheetsGrid && window.SheetsGrid.worksheet()))); |
| 296 | check('...with no toolbar', !(await vp.isVisible('[data-toolbar]')), 'a viewer was offered controls that 404'); |
| 297 | check('...no Edit or Format menu', !vMenus.includes('Edit') && !vMenus.includes('Format'), `menus: ${vMenus.join(', ')}`); |
| 298 | check('...but File is still there', vMenus.includes('File')); |
| 299 | check('...a read-only formula bar', await vp.evaluate(() => document.querySelector('[data-formula-input]').readOnly)); |
| 300 | /* Read-only is about writing, not about seeing. A viewer must still get |
| 301 | * the document as authored. */ |
| 302 | check('...and cells that still paint their formats', |
| 303 | 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)); |
| 304 | check('...no console errors on the viewer page', vErrors.length === 0, vErrors.slice(0, 2).join(' | ')); |
| 305 | await vp.screenshot({ path: `${OUT}/sheet-viewer.png` }); |
| 306 | |
| 307 | await browser.close(); |
| 308 | proxy.close(); |
| 309 | console.log(fails.length ? `\n${fails.length} FAILED\n` + fails.map((f) => ' ' + f).join('\n') : '\nall checks passed'); |
| 310 | process.exit(fails.length ? 1 : 0); |
| 311 | |