oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

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 */
20import net from 'node:net';
21import fs from 'node:fs';
22const pw = await import(process.env.PLAYWRIGHT || 'playwright');
23const SOCK = process.env.SHEETS_SOCK || '/tmp/s.sock';
24const LOG = process.env.SHEETS_LOG || '/tmp/s.log';
25const PORT = 8799;
26const base = `http://127.0.0.1:${PORT}`;
27
28const 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});
33await new Promise((r) => proxy.listen(PORT, '127.0.0.1', r));
34
35let failed = 0;
36const check = (name, ok, detail = '') => {
37 if (!ok) failed++;
38 console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ' — ' + detail}`);
39};
40
41async 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
54const 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
59for (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
133proxy.close();
134console.log(failed ? `\n${failed} check(s) failed` : '\nall checks passed');
135process.exit(failed ? 1 : 0);
136