| 1 | /* A modal dialog owns the keyboard: no sheet command runs behind it. |
| 2 | * |
| 3 | * The Keyboard shortcuts dialog (account menu → Help) is a native modal |
| 4 | * <dialog>. Native modality makes the page behind it inert to the |
| 5 | * pointer and to focus, but not to key events: a keydown on the dialog's |
| 6 | * Done button still bubbles to document, where commands.js, clipboard.js |
| 7 | * and the vendored grid all listen. Astra found Ctrl+D inside the open |
| 8 | * dialog filling A1 down over A2 and persisting (#39, finding 1). |
| 9 | * |
| 10 | * This opens the dialog on a real sheet, presses the editing keys, closes |
| 11 | * it, commits anything a hidden editor might be holding, and asserts that |
| 12 | * nothing was POSTed and the persisted cells are unchanged after a |
| 13 | * reload. Chromium and WebKit, each with its own session. |
| 14 | * |
| 15 | * go build -o /tmp/sheets ./cmd/sheets |
| 16 | * SHEETS_ORIGIN=http://127.0.0.1:8799 SHEETS_INSTANCE_KEY=dev SHEETS_OWNERS=paul@example.com \ |
| 17 | * SHEETS_DEK="$(head -c 32 /dev/urandom | base64)" /tmp/sheets --db /tmp/s.db --socket /tmp/s.sock > /tmp/s.log 2>&1 & |
| 18 | * SHEETS_SOCK=/tmp/s.sock SHEETS_LOG=/tmp/s.log PLAYWRIGHT=/path/to/playwright/index.mjs node tools/drive/modal.mjs |
| 19 | */ |
| 20 | import net from 'node:net'; |
| 21 | import fs from 'node:fs'; |
| 22 | const pw = await import(process.env.PLAYWRIGHT || 'playwright'); |
| 23 | const SOCK = process.env.SHEETS_SOCK || '/tmp/s.sock'; |
| 24 | const LOG = process.env.SHEETS_LOG || '/tmp/s.log'; |
| 25 | const PORT = 8799; |
| 26 | const base = `http://127.0.0.1:${PORT}`; |
| 27 | |
| 28 | const proxy = net.createServer((c) => { |
| 29 | const u = net.connect(SOCK); |
| 30 | c.pipe(u); u.pipe(c); |
| 31 | c.on('error', () => u.destroy()); u.on('error', () => c.destroy()); |
| 32 | }); |
| 33 | await new Promise((r) => proxy.listen(PORT, '127.0.0.1', r)); |
| 34 | |
| 35 | let failed = 0; |
| 36 | const check = (name, ok, detail = '') => { |
| 37 | if (!ok) failed++; |
| 38 | console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ' — ' + detail}`); |
| 39 | }; |
| 40 | |
| 41 | async function signIn(page) { |
| 42 | const before = fs.readFileSync(LOG, 'utf8').length; |
| 43 | await page.goto(base + '/signin'); |
| 44 | await page.fill('input[name=address]', 'paul@example.com'); |
| 45 | await Promise.all([page.waitForURL(/sent/), page.click('form[action="/signin"] button[type=submit]')]); |
| 46 | let m = null; |
| 47 | for (let i = 0; i < 50 && !m; i++) { |
| 48 | m = [...fs.readFileSync(LOG, 'utf8').slice(before).matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop(); |
| 49 | if (!m) await page.waitForTimeout(100); |
| 50 | } |
| 51 | await page.goto(m[0]); |
| 52 | } |
| 53 | |
| 54 | const cell = (page, c, r) => page.evaluate(([c, r]) => { |
| 55 | const ws = window.SheetsGrid.worksheet(); |
| 56 | return { value: ws.getValueFromCoords(c, r), bold: ws.records[r][c].element.hasAttribute('data-b') }; |
| 57 | }, [c, r]); |
| 58 | |
| 59 | for (const name of ['chromium', 'webkit']) { |
| 60 | console.log(`\n— ${name}`); |
| 61 | const browser = await pw[name].launch(); |
| 62 | const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); |
| 63 | const errors = []; |
| 64 | page.on('pageerror', (e) => errors.push(e.message)); |
| 65 | await signIn(page); |
| 66 | await Promise.all([page.waitForURL(/\/sheets\/\d+$/), page.click('[estilo-bar-actions] form[action="/sheets"] button')]); |
| 67 | const sheet = page.url(); |
| 68 | await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet(), null, { timeout: 15000 }); |
| 69 | await page.evaluate(() => { window.SheetsGrid.write(0, 0, 'Invoice'); window.SheetsGrid.write(1, 0, '920.5'); }); |
| 70 | await page.waitForTimeout(600); |
| 71 | await page.reload(); |
| 72 | await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet(), null, { timeout: 15000 }); |
| 73 | await page.locator('td[data-x="0"][data-y="0"]').click(); |
| 74 | await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(0, 0, 0, 1)); |
| 75 | |
| 76 | const posts = []; |
| 77 | page.on('request', (req) => { if (req.method() !== 'GET') posts.push(`${req.method()} ${new URL(req.url()).pathname}`); }); |
| 78 | |
| 79 | await page.click('[estilo-account] > summary'); |
| 80 | await page.click('[estilo-account-panel] button[commandfor="sheets-shortcuts"]'); |
| 81 | await page.waitForTimeout(200); |
| 82 | check('the dialog is open and owns focus', await page.evaluate(() => { |
| 83 | const d = document.getElementById('sheets-shortcuts'); |
| 84 | return d.open && d.contains(document.activeElement); |
| 85 | })); |
| 86 | // Backspace only in Chromium: Playwright's WebKit build treats it as |
| 87 | // history Back on a non-editable focus, a browser default no page can |
| 88 | // or should suppress (Safari does not). Delete covers the same command. |
| 89 | const keys = ['Control+d', 'Meta+d', 'Control+b', 'Meta+b', 'Control+z', 'Meta+z', 'Control+x', 'Delete', 'x', 'Enter', 'Control+v']; |
| 90 | if (name === 'chromium') keys.push('Backspace'); |
| 91 | for (const key of keys) { |
| 92 | await page.keyboard.press(key); |
| 93 | if (!(await page.evaluate(() => document.getElementById('sheets-shortcuts').open))) { |
| 94 | // Enter on Done closes the dialog, which is its job; reopen it for |
| 95 | // the keys that follow. |
| 96 | await page.click('[estilo-account] > summary'); |
| 97 | await page.click('[estilo-account-panel] button[commandfor="sheets-shortcuts"]'); |
| 98 | await page.waitForTimeout(100); |
| 99 | } |
| 100 | } |
| 101 | await page.waitForTimeout(400); |
| 102 | await page.keyboard.press('Escape'); |
| 103 | await page.waitForTimeout(200); |
| 104 | check('Escape closes the dialog', !(await page.evaluate(() => document.getElementById('sheets-shortcuts').open))); |
| 105 | // Commit anything a hidden in-cell editor could be holding, by clicking |
| 106 | // another cell (D6). |
| 107 | await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet(), null, { timeout: 15000 }); |
| 108 | const d6 = await page.evaluate(() => { |
| 109 | const r = window.SheetsGrid.worksheet().records[5][3].element.getBoundingClientRect(); |
| 110 | return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; |
| 111 | }); |
| 112 | await page.mouse.click(d6.x, d6.y); |
| 113 | await page.waitForTimeout(800); |
| 114 | check('nothing was posted while the dialog owned the keyboard', posts.length === 0, posts.join(', ')); |
| 115 | |
| 116 | await page.goto(sheet); |
| 117 | await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet(), null, { timeout: 15000 }); |
| 118 | const a1 = await cell(page, 0, 0), a2 = await cell(page, 0, 1); |
| 119 | check('A1 is unchanged after a reload', a1.value === 'Invoice' && !a1.bold, JSON.stringify(a1)); |
| 120 | check('A2 is unchanged after a reload', a2.value === '920.5' && !a2.bold, JSON.stringify(a2)); |
| 121 | |
| 122 | // The keys still work when no dialog is open: the suppression is the |
| 123 | // modal's, not a switch left off. |
| 124 | const wasBold = (await cell(page, 0, 0)).bold; |
| 125 | await page.locator('td[data-x="0"][data-y="0"]').click(); |
| 126 | await page.keyboard.press('Control+b'); |
| 127 | await page.waitForTimeout(600); |
| 128 | check('...and Ctrl+B still toggles bold once the dialog is closed', (await cell(page, 0, 0)).bold !== wasBold); |
| 129 | check('no page errors', errors.length === 0, errors.join(' | ')); |
| 130 | await browser.close(); |
| 131 | } |
| 132 | |
| 133 | proxy.close(); |
| 134 | console.log(failed ? `\n${failed} check(s) failed` : '\nall checks passed'); |
| 135 | process.exit(failed ? 1 : 0); |
| 136 | |