| 1 | /* Drive the grid in two real browser tabs. |
| 2 | * |
| 3 | * This is NOT part of `make test`, and deliberately so: it needs a |
| 4 | * browser and a running instance, neither of which CI has. It is a |
| 5 | * manual gate, run before a canary is called good. |
| 6 | * |
| 7 | * It earns its place. Two bugs got through the entire Go suite and |
| 8 | * showed up only here: |
| 9 | * |
| 10 | * 1. Jspreadsheet v5 takes its event handlers on the ROOT config, |
| 11 | * not on a worksheet. Nested one level too deep they are accepted |
| 12 | * in silence and never called: the grid rendered, the console was |
| 13 | * clean, and not one edit was ever sent to the server. |
| 14 | * 2. `rst-sr-only` written as an attribute instead of a class. It is |
| 15 | * one of the seven class-only utilities, so it matched nothing and |
| 16 | * a screen-reader label rendered in the middle of the toolbar. |
| 17 | * |
| 18 | * The second is now caught by a guard test. The first cannot be — no |
| 19 | * static check knows which options a third-party library reads. |
| 20 | * |
| 21 | * Usage, from the repository root: |
| 22 | * |
| 23 | * go build -o /tmp/sheets ./cmd/sheets |
| 24 | * SHEETS_ORIGIN=http://127.0.0.1:8799 \ |
| 25 | * SHEETS_INSTANCE_KEY=dev SHEETS_OWNERS=you@example.com \ |
| 26 | * SHEETS_DEK="$(head -c 32 /dev/urandom | base64)" \ |
| 27 | * /tmp/sheets --db /tmp/s.db --socket /tmp/s.sock > /tmp/s.log 2>&1 & |
| 28 | * SHEETS_SOCK=/tmp/s.sock SHEETS_LOG=/tmp/s.log node tools/drive/drive.mjs |
| 29 | * |
| 30 | * It reads the magic link out of the log, which only works because a |
| 31 | * loopback origin with no SMTP logs links instead of mailing them — |
| 32 | * see internal/sheets/auth.go. A deployed instance never does this. |
| 33 | * |
| 34 | * PLAYWRIGHT is wherever playwright is installed; there is no |
| 35 | * package.json here, because this app has no build step and is not |
| 36 | * about to grow one for a manual script. |
| 37 | */ |
| 38 | import net from 'node:net'; |
| 39 | import fs from 'node:fs'; |
| 40 | const PLAYWRIGHT = process.env.PLAYWRIGHT || 'playwright'; |
| 41 | /* A CJS build imported from ESM puts its exports on .default, so a |
| 42 | * plain `const { chromium } = await import(...)` yields undefined and |
| 43 | * dies one line later at chromium.launch — which is how this read until |
| 44 | * the branch that added the checks below tried to run it. chrome.mjs |
| 45 | * already spelled it this way; the two now agree. */ |
| 46 | const pw = await import(PLAYWRIGHT); |
| 47 | const chromium = pw.chromium || (pw.default && pw.default.chromium); |
| 48 | if (!chromium) { console.error('playwright: no chromium export'); process.exit(1); } |
| 49 | |
| 50 | const SOCK = process.env.SHEETS_SOCK || '/tmp/s.sock'; |
| 51 | const LOG = process.env.SHEETS_LOG || '/tmp/s.log'; |
| 52 | const PORT = parseInt(process.env.SHEETS_PORT || '8799', 10); |
| 53 | |
| 54 | // TCP -> unix socket, so the browser has a real origin to use. The app |
| 55 | // speaks the platform's activation contract (a socket path, no $PORT), |
| 56 | // so this stands in for the edge that terminates HTTP in production. |
| 57 | const proxy = net.createServer((c) => { |
| 58 | const u = net.connect(SOCK); |
| 59 | c.pipe(u); u.pipe(c); |
| 60 | c.on('error', () => u.destroy()); |
| 61 | u.on('error', () => c.destroy()); |
| 62 | }); |
| 63 | await new Promise((r) => proxy.listen(PORT, '127.0.0.1', r)); |
| 64 | |
| 65 | const base = `http://127.0.0.1:${PORT}`; |
| 66 | const browser = await chromium.launch(); |
| 67 | const ctx = await browser.newContext(); |
| 68 | const page = await ctx.newPage(); |
| 69 | |
| 70 | const errors = []; |
| 71 | ctx.on('weberror', (e) => errors.push('weberror: ' + e.error().message)); |
| 72 | page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); |
| 73 | page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); |
| 74 | |
| 75 | // 1. Sign in, the way a person does. |
| 76 | const before = fs.readFileSync(LOG, 'utf8').length; |
| 77 | await page.goto(base + '/signin'); |
| 78 | await page.fill('input[name=address]', 'paul@example.com'); |
| 79 | await page.click('form[action="/signin"] button[type=submit]'); |
| 80 | await page.waitForURL(/signin\?sent=1/, { timeout: 15000 }); |
| 81 | |
| 82 | const fresh = fs.readFileSync(LOG, 'utf8').slice(before); |
| 83 | const m = fresh.match(/auth\/verify\?token=([A-Za-z0-9_-]+)/); |
| 84 | if (!m) { console.log('FAIL: no magic link in the log'); process.exit(1); } |
| 85 | await page.goto(`${base}/auth/verify?token=${m[1]}`); |
| 86 | console.log('signed in as:', (await page.textContent('body')).includes('Signed in as') ? 'yes' : 'NO'); |
| 87 | |
| 88 | // 2. Make a sheet, from the top bar's Create button. |
| 89 | // |
| 90 | // This used to fill #title on the list page. That field is gone: the |
| 91 | // list carries no forms now, Create posts an empty one, and the sheet |
| 92 | // lands as "Untitled sheet" — so naming it is a rename, below. |
| 93 | await page.goto(base + '/sheets'); |
| 94 | // Not networkidle: the SSE stream is a long-lived connection, so the |
| 95 | // network on this page is never idle by design. |
| 96 | await page.click('[estilo-bar-actions] form[action="/sheets"] button[type=submit]'); |
| 97 | await page.waitForURL(/\/sheets\/\d+$/, { timeout: 15000 }); |
| 98 | const url = page.url(); |
| 99 | console.log('sheet url:', url); |
| 100 | |
| 101 | // 2b. Rename it from the top bar, which is now the only place a sheet |
| 102 | // is ever named — and a real form submit, so this covers the |
| 103 | // scriptless path too. |
| 104 | await page.fill('[estilo-bar-title-input]', 'Browser drive'); |
| 105 | await page.press('[estilo-bar-title-input]', 'Enter'); |
| 106 | await page.waitForURL(/\/sheets\/\d+$/, { timeout: 15000 }); |
| 107 | console.log('renamed to:', JSON.stringify(await page.inputValue('[estilo-bar-title-input]'))); |
| 108 | |
| 109 | // 3. The grid must actually render. |
| 110 | await page.waitForSelector('.jexcel, .jss_worksheet, table', { timeout: 15000 }); |
| 111 | console.log('grid rendered:', await page.locator('[data-sheet-grid] table').count() > 0); |
| 112 | await page.waitForFunction( |
| 113 | () => document.querySelector('[data-sheet-live]')?.dataset.state === 'on', |
| 114 | null, { timeout: 15000 }).catch(() => {}); |
| 115 | console.log('live indicator:', await page.getAttribute('[data-sheet-live]', 'data-state'), |
| 116 | JSON.stringify(await page.textContent('[data-sheet-live]'))); |
| 117 | |
| 118 | // 4. A SECOND tab on the same sheet. |
| 119 | const page2 = await ctx.newPage(); |
| 120 | page2.on('pageerror', (e) => errors.push('tab2 pageerror: ' + e.message)); |
| 121 | await page2.goto(url); |
| 122 | await page2.waitForSelector('[data-sheet-grid] table', { timeout: 15000 }); |
| 123 | await page2.waitForFunction( |
| 124 | () => document.querySelector('[data-sheet-live]')?.dataset.state === 'on', |
| 125 | null, { timeout: 15000 }).catch(() => {}); |
| 126 | console.log('tab2 live:', await page2.getAttribute('[data-sheet-live]', 'data-state')); |
| 127 | |
| 128 | // 5. Type into tab 1. |
| 129 | const cellA1 = page.locator('[data-sheet-grid] td[data-x="0"][data-y="0"]'); |
| 130 | await cellA1.dblclick(); |
| 131 | await page.keyboard.type('6'); |
| 132 | await page.keyboard.press('Enter'); |
| 133 | |
| 134 | const cellA2 = page.locator('[data-sheet-grid] td[data-x="0"][data-y="1"]'); |
| 135 | await cellA2.dblclick(); |
| 136 | await page.keyboard.type('=A1*7'); |
| 137 | await page.keyboard.press('Enter'); |
| 138 | await page.waitForTimeout(1500); |
| 139 | |
| 140 | console.log('tab1 A1:', JSON.stringify(await cellA1.textContent())); |
| 141 | console.log('tab1 A2:', JSON.stringify(await cellA2.textContent())); |
| 142 | |
| 143 | // 6. THE TEST: does tab 2 show it without a reload? |
| 144 | const t2a1 = page2.locator('[data-sheet-grid] td[data-x="0"][data-y="0"]'); |
| 145 | const t2a2 = page2.locator('[data-sheet-grid] td[data-x="0"][data-y="1"]'); |
| 146 | let ok = false; |
| 147 | for (let i = 0; i < 40; i++) { |
| 148 | const a1 = (await t2a1.textContent())?.trim(); |
| 149 | const a2 = (await t2a2.textContent())?.trim(); |
| 150 | if (a1 === '6' && a2 === '42') { ok = true; break; } |
| 151 | await page2.waitForTimeout(250); |
| 152 | } |
| 153 | console.log('tab2 A1:', JSON.stringify((await t2a1.textContent())?.trim())); |
| 154 | console.log('tab2 A2:', JSON.stringify((await t2a2.textContent())?.trim())); |
| 155 | console.log(ok ? 'LIVE-ACROSS-TABS: YES' : 'LIVE-ACROSS-TABS: NO'); |
| 156 | |
| 157 | // 7. The formula bar shows the input, not the result. |
| 158 | await t2a2.click(); |
| 159 | await page2.waitForTimeout(300); |
| 160 | console.log('tab2 formula bar for A2:', JSON.stringify(await page2.inputValue('[data-formula-input]'))); |
| 161 | |
| 162 | const shots = process.env.SHEETS_SHOTS; |
| 163 | if (shots) { |
| 164 | await page.screenshot({ path: shots + '/tab1.png' }); |
| 165 | await page2.screenshot({ path: shots + '/tab2.png' }); |
| 166 | } |
| 167 | |
| 168 | console.log('js errors:', errors.length ? JSON.stringify(errors.slice(0, 8), null, 1) : 'none'); |
| 169 | await browser.close(); |
| 170 | proxy.close(); |
| 171 | |