| new file mode 100644 |
| index 0000000..e3f258e |
| --- /dev/null |
| +++ b/tools/drive/modal.mjs |
| @@ -0,0 +1,135 @@ |
| +/* A modal dialog owns the keyboard: no sheet command runs behind it. |
| + * |
| + * The Keyboard shortcuts dialog (account menu → Help) is a native modal |
| + * <dialog>. Native modality makes the page behind it inert to the |
| + * pointer and to focus, but not to key events: a keydown on the dialog's |
| + * Done button still bubbles to document, where commands.js, clipboard.js |
| + * and the vendored grid all listen. Astra found Ctrl+D inside the open |
| + * dialog filling A1 down over A2 and persisting (#39, finding 1). |
| + * |
| + * This opens the dialog on a real sheet, presses the editing keys, closes |
| + * it, commits anything a hidden editor might be holding, and asserts that |
| + * nothing was POSTed and the persisted cells are unchanged after a |
| + * reload. Chromium and WebKit, each with its own session. |
| + * |
| + * go build -o /tmp/sheets ./cmd/sheets |
| + * SHEETS_ORIGIN=http://127.0.0.1:8799 SHEETS_INSTANCE_KEY=dev SHEETS_OWNERS=paul@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 PLAYWRIGHT=/path/to/playwright/index.mjs node tools/drive/modal.mjs |
| + */ |
| +import net from 'node:net'; |
| +import fs from 'node:fs'; |
| +const pw = await import(process.env.PLAYWRIGHT || 'playwright'); |
| +const SOCK = process.env.SHEETS_SOCK || '/tmp/s.sock'; |
| +const LOG = process.env.SHEETS_LOG || '/tmp/s.log'; |
| +const PORT = 8799; |
| +const base = `http://127.0.0.1:${PORT}`; |
| + |
| +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)); |
| + |
| +let failed = 0; |
| +const check = (name, ok, detail = '') => { |
| + if (!ok) failed++; |
| + console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ' — ' + detail}`); |
| +}; |
| + |
| +async function signIn(page) { |
| + const before = fs.readFileSync(LOG, 'utf8').length; |
| + await page.goto(base + '/signin'); |
| + await page.fill('input[name=address]', 'paul@example.com'); |
| + await Promise.all([page.waitForURL(/sent/), page.click('form[action="/signin"] button[type=submit]')]); |
| + let m = null; |
| + for (let i = 0; i < 50 && !m; i++) { |
| + m = [...fs.readFileSync(LOG, 'utf8').slice(before).matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop(); |
| + if (!m) await page.waitForTimeout(100); |
| + } |
| + await page.goto(m[0]); |
| +} |
| + |
| +const cell = (page, c, r) => page.evaluate(([c, r]) => { |
| + const ws = window.SheetsGrid.worksheet(); |
| + return { value: ws.getValueFromCoords(c, r), bold: ws.records[r][c].element.hasAttribute('data-b') }; |
| +}, [c, r]); |
| + |
| +for (const name of ['chromium', 'webkit']) { |
| + console.log(`\n— ${name}`); |
| + const browser = await pw[name].launch(); |
| + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); |
| + const errors = []; |
| + page.on('pageerror', (e) => errors.push(e.message)); |
| + await signIn(page); |
| + await Promise.all([page.waitForURL(/\/sheets\/\d+$/), page.click('[estilo-bar-actions] form[action="/sheets"] button')]); |
| + const sheet = page.url(); |
| + await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet(), null, { timeout: 15000 }); |
| + await page.evaluate(() => { window.SheetsGrid.write(0, 0, 'Invoice'); window.SheetsGrid.write(1, 0, '920.5'); }); |
| + await page.waitForTimeout(600); |
| + await page.reload(); |
| + await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet(), null, { timeout: 15000 }); |
| + await page.locator('td[data-x="0"][data-y="0"]').click(); |
| + await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(0, 0, 0, 1)); |
| + |
| + const posts = []; |
| + page.on('request', (req) => { if (req.method() !== 'GET') posts.push(`${req.method()} ${new URL(req.url()).pathname}`); }); |
| + |
| + await page.click('[estilo-account] > summary'); |
| + await page.click('[estilo-account-panel] button[commandfor="sheets-shortcuts"]'); |
| + await page.waitForTimeout(200); |
| + check('the dialog is open and owns focus', await page.evaluate(() => { |
| + const d = document.getElementById('sheets-shortcuts'); |
| + return d.open && d.contains(document.activeElement); |
| + })); |
| + // Backspace only in Chromium: Playwright's WebKit build treats it as |
| + // history Back on a non-editable focus, a browser default no page can |
| + // or should suppress (Safari does not). Delete covers the same command. |
| + const keys = ['Control+d', 'Meta+d', 'Control+b', 'Meta+b', 'Control+z', 'Meta+z', 'Control+x', 'Delete', 'x', 'Enter', 'Control+v']; |
| + if (name === 'chromium') keys.push('Backspace'); |
| + for (const key of keys) { |
| + await page.keyboard.press(key); |
| + if (!(await page.evaluate(() => document.getElementById('sheets-shortcuts').open))) { |
| + // Enter on Done closes the dialog, which is its job; reopen it for |
| + // the keys that follow. |
| + await page.click('[estilo-account] > summary'); |
| + await page.click('[estilo-account-panel] button[commandfor="sheets-shortcuts"]'); |
| + await page.waitForTimeout(100); |
| + } |
| + } |
| + await page.waitForTimeout(400); |
| + await page.keyboard.press('Escape'); |
| + await page.waitForTimeout(200); |
| + check('Escape closes the dialog', !(await page.evaluate(() => document.getElementById('sheets-shortcuts').open))); |
| + // Commit anything a hidden in-cell editor could be holding, by clicking |
| + // another cell (D6). |
| + await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet(), null, { timeout: 15000 }); |
| + const d6 = await page.evaluate(() => { |
| + const r = window.SheetsGrid.worksheet().records[5][3].element.getBoundingClientRect(); |
| + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; |
| + }); |
| + await page.mouse.click(d6.x, d6.y); |
| + await page.waitForTimeout(800); |
| + check('nothing was posted while the dialog owned the keyboard', posts.length === 0, posts.join(', ')); |
| + |
| + await page.goto(sheet); |
| + await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet(), null, { timeout: 15000 }); |
| + const a1 = await cell(page, 0, 0), a2 = await cell(page, 0, 1); |
| + check('A1 is unchanged after a reload', a1.value === 'Invoice' && !a1.bold, JSON.stringify(a1)); |
| + check('A2 is unchanged after a reload', a2.value === '920.5' && !a2.bold, JSON.stringify(a2)); |
| + |
| + // The keys still work when no dialog is open: the suppression is the |
| + // modal's, not a switch left off. |
| + const wasBold = (await cell(page, 0, 0)).bold; |
| + await page.locator('td[data-x="0"][data-y="0"]').click(); |
| + await page.keyboard.press('Control+b'); |
| + await page.waitForTimeout(600); |
| + check('...and Ctrl+B still toggles bold once the dialog is closed', (await cell(page, 0, 0)).bold !== wasBold); |
| + check('no page errors', errors.length === 0, errors.join(' | ')); |
| + await browser.close(); |
| +} |
| + |
| +proxy.close(); |
| +console.log(failed ? `\n${failed} check(s) failed` : '\nall checks passed'); |
| +process.exit(failed ? 1 : 0); |