/* A modal dialog owns the keyboard: no sheet command runs behind it. * * The Keyboard shortcuts dialog (account menu → Help) is a native modal * . 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);