/* Drive the grid in two real browser tabs. * * This is NOT part of `make test`, and deliberately so: it needs a * browser and a running instance, neither of which CI has. It is a * manual gate, run before a canary is called good. * * It earns its place. Two bugs got through the entire Go suite and * showed up only here: * * 1. Jspreadsheet v5 takes its event handlers on the ROOT config, * not on a worksheet. Nested one level too deep they are accepted * in silence and never called: the grid rendered, the console was * clean, and not one edit was ever sent to the server. * 2. `rst-sr-only` written as an attribute instead of a class. It is * one of the seven class-only utilities, so it matched nothing and * a screen-reader label rendered in the middle of the toolbar. * * The second is now caught by a guard test. The first cannot be — no * static check knows which options a third-party library reads. * * Usage, from the repository root: * * go build -o /tmp/sheets ./cmd/sheets * SHEETS_ORIGIN=http://127.0.0.1:8799 \ * SHEETS_INSTANCE_KEY=dev SHEETS_OWNERS=you@example.com \ * SHEETS_DEK="$(head -c 32 /dev/urandom | base64)" \ * /tmp/sheets --db /tmp/s.db --socket /tmp/s.sock > /tmp/s.log 2>&1 & * SHEETS_SOCK=/tmp/s.sock SHEETS_LOG=/tmp/s.log node tools/drive/drive.mjs * * It reads the magic link out of the log, which only works because a * loopback origin with no SMTP logs links instead of mailing them — * see internal/sheets/auth.go. A deployed instance never does this. * * PLAYWRIGHT is wherever playwright is installed; there is no * package.json here, because this app has no build step and is not * about to grow one for a manual script. */ import net from 'node:net'; import fs from 'node:fs'; const PLAYWRIGHT = process.env.PLAYWRIGHT || 'playwright'; /* A CJS build imported from ESM puts its exports on .default, so a * plain `const { chromium } = await import(...)` yields undefined and * dies one line later at chromium.launch — which is how this read until * the branch that added the checks below tried to run it. chrome.mjs * already spelled it this way; the two now agree. */ const pw = await import(PLAYWRIGHT); 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 || '/tmp/s.sock'; const LOG = process.env.SHEETS_LOG || '/tmp/s.log'; const PORT = parseInt(process.env.SHEETS_PORT || '8799', 10); // TCP -> unix socket, so the browser has a real origin to use. The app // speaks the platform's activation contract (a socket path, no $PORT), // so this stands in for the edge that terminates HTTP in production. 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 browser = await chromium.launch(); const ctx = await browser.newContext(); const page = await ctx.newPage(); const errors = []; ctx.on('weberror', (e) => errors.push('weberror: ' + e.error().message)); page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); // 1. Sign in, the way a person does. const before = fs.readFileSync(LOG, 'utf8').length; await page.goto(base + '/signin'); await page.fill('input[name=address]', 'paul@example.com'); await page.click('form[action="/signin"] button[type=submit]'); await page.waitForURL(/signin\?sent=1/, { timeout: 15000 }); const fresh = fs.readFileSync(LOG, 'utf8').slice(before); const m = fresh.match(/auth\/verify\?token=([A-Za-z0-9_-]+)/); if (!m) { console.log('FAIL: no magic link in the log'); process.exit(1); } await page.goto(`${base}/auth/verify?token=${m[1]}`); console.log('signed in as:', (await page.textContent('body')).includes('Signed in as') ? 'yes' : 'NO'); // 2. Make a sheet, from the top bar's Create button. // // This used to fill #title on the list page. That field is gone: the // list carries no forms now, Create posts an empty one, and the sheet // lands as "Untitled sheet" — so naming it is a rename, below. await page.goto(base + '/sheets'); // Not networkidle: the SSE stream is a long-lived connection, so the // network on this page is never idle by design. await page.click('[estilo-bar-actions] form[action="/sheets"] button[type=submit]'); await page.waitForURL(/\/sheets\/\d+$/, { timeout: 15000 }); const url = page.url(); console.log('sheet url:', url); // 2b. Rename it from the top bar, which is now the only place a sheet // is ever named — and a real form submit, so this covers the // scriptless path too. await page.fill('[estilo-bar-title-input]', 'Browser drive'); await page.press('[estilo-bar-title-input]', 'Enter'); await page.waitForURL(/\/sheets\/\d+$/, { timeout: 15000 }); console.log('renamed to:', JSON.stringify(await page.inputValue('[estilo-bar-title-input]'))); // 3. The grid must actually render. await page.waitForSelector('.jexcel, .jss_worksheet, table', { timeout: 15000 }); console.log('grid rendered:', await page.locator('[data-sheet-grid] table').count() > 0); await page.waitForFunction( () => document.querySelector('[data-sheet-live]')?.dataset.state === 'on', null, { timeout: 15000 }).catch(() => {}); console.log('live indicator:', await page.getAttribute('[data-sheet-live]', 'data-state'), JSON.stringify(await page.textContent('[data-sheet-live]'))); // 4. A SECOND tab on the same sheet. const page2 = await ctx.newPage(); page2.on('pageerror', (e) => errors.push('tab2 pageerror: ' + e.message)); await page2.goto(url); await page2.waitForSelector('[data-sheet-grid] table', { timeout: 15000 }); await page2.waitForFunction( () => document.querySelector('[data-sheet-live]')?.dataset.state === 'on', null, { timeout: 15000 }).catch(() => {}); console.log('tab2 live:', await page2.getAttribute('[data-sheet-live]', 'data-state')); // 5. Type into tab 1. const cellA1 = page.locator('[data-sheet-grid] td[data-x="0"][data-y="0"]'); await cellA1.dblclick(); await page.keyboard.type('6'); await page.keyboard.press('Enter'); const cellA2 = page.locator('[data-sheet-grid] td[data-x="0"][data-y="1"]'); await cellA2.dblclick(); await page.keyboard.type('=A1*7'); await page.keyboard.press('Enter'); await page.waitForTimeout(1500); console.log('tab1 A1:', JSON.stringify(await cellA1.textContent())); console.log('tab1 A2:', JSON.stringify(await cellA2.textContent())); // 6. THE TEST: does tab 2 show it without a reload? const t2a1 = page2.locator('[data-sheet-grid] td[data-x="0"][data-y="0"]'); const t2a2 = page2.locator('[data-sheet-grid] td[data-x="0"][data-y="1"]'); let ok = false; for (let i = 0; i < 40; i++) { const a1 = (await t2a1.textContent())?.trim(); const a2 = (await t2a2.textContent())?.trim(); if (a1 === '6' && a2 === '42') { ok = true; break; } await page2.waitForTimeout(250); } console.log('tab2 A1:', JSON.stringify((await t2a1.textContent())?.trim())); console.log('tab2 A2:', JSON.stringify((await t2a2.textContent())?.trim())); console.log(ok ? 'LIVE-ACROSS-TABS: YES' : 'LIVE-ACROSS-TABS: NO'); // 7. The formula bar shows the input, not the result. await t2a2.click(); await page2.waitForTimeout(300); console.log('tab2 formula bar for A2:', JSON.stringify(await page2.inputValue('[data-formula-input]'))); const shots = process.env.SHEETS_SHOTS; if (shots) { await page.screenshot({ path: shots + '/tab1.png' }); await page2.screenshot({ path: shots + '/tab2.png' }); } console.log('js errors:', errors.length ? JSON.stringify(errors.slice(0, 8), null, 1) : 'none'); await browser.close(); proxy.close();