oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

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 */
38import net from 'node:net';
39import fs from 'node:fs';
40const 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. */
46const pw = await import(PLAYWRIGHT);
47const chromium = pw.chromium || (pw.default && pw.default.chromium);
48if (!chromium) { console.error('playwright: no chromium export'); process.exit(1); }
49
50const SOCK = process.env.SHEETS_SOCK || '/tmp/s.sock';
51const LOG = process.env.SHEETS_LOG || '/tmp/s.log';
52const 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.
57const 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});
63await new Promise((r) => proxy.listen(PORT, '127.0.0.1', r));
64
65const base = `http://127.0.0.1:${PORT}`;
66const browser = await chromium.launch();
67const ctx = await browser.newContext();
68const page = await ctx.newPage();
69
70const errors = [];
71ctx.on('weberror', (e) => errors.push('weberror: ' + e.error().message));
72page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
73page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
74
75// 1. Sign in, the way a person does.
76const before = fs.readFileSync(LOG, 'utf8').length;
77await page.goto(base + '/signin');
78await page.fill('input[name=address]', 'paul@example.com');
79await page.click('form[action="/signin"] button[type=submit]');
80await page.waitForURL(/signin\?sent=1/, { timeout: 15000 });
81
82const fresh = fs.readFileSync(LOG, 'utf8').slice(before);
83const m = fresh.match(/auth\/verify\?token=([A-Za-z0-9_-]+)/);
84if (!m) { console.log('FAIL: no magic link in the log'); process.exit(1); }
85await page.goto(`${base}/auth/verify?token=${m[1]}`);
86console.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.
93await 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.
96await page.click('[estilo-bar-actions] form[action="/sheets"] button[type=submit]');
97await page.waitForURL(/\/sheets\/\d+$/, { timeout: 15000 });
98const url = page.url();
99console.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.
104await page.fill('[estilo-bar-title-input]', 'Browser drive');
105await page.press('[estilo-bar-title-input]', 'Enter');
106await page.waitForURL(/\/sheets\/\d+$/, { timeout: 15000 });
107console.log('renamed to:', JSON.stringify(await page.inputValue('[estilo-bar-title-input]')));
108
109// 3. The grid must actually render.
110await page.waitForSelector('.jexcel, .jss_worksheet, table', { timeout: 15000 });
111console.log('grid rendered:', await page.locator('[data-sheet-grid] table').count() > 0);
112await page.waitForFunction(
113 () => document.querySelector('[data-sheet-live]')?.dataset.state === 'on',
114 null, { timeout: 15000 }).catch(() => {});
115console.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.
119const page2 = await ctx.newPage();
120page2.on('pageerror', (e) => errors.push('tab2 pageerror: ' + e.message));
121await page2.goto(url);
122await page2.waitForSelector('[data-sheet-grid] table', { timeout: 15000 });
123await page2.waitForFunction(
124 () => document.querySelector('[data-sheet-live]')?.dataset.state === 'on',
125 null, { timeout: 15000 }).catch(() => {});
126console.log('tab2 live:', await page2.getAttribute('[data-sheet-live]', 'data-state'));
127
128// 5. Type into tab 1.
129const cellA1 = page.locator('[data-sheet-grid] td[data-x="0"][data-y="0"]');
130await cellA1.dblclick();
131await page.keyboard.type('6');
132await page.keyboard.press('Enter');
133
134const cellA2 = page.locator('[data-sheet-grid] td[data-x="0"][data-y="1"]');
135await cellA2.dblclick();
136await page.keyboard.type('=A1*7');
137await page.keyboard.press('Enter');
138await page.waitForTimeout(1500);
139
140console.log('tab1 A1:', JSON.stringify(await cellA1.textContent()));
141console.log('tab1 A2:', JSON.stringify(await cellA2.textContent()));
142
143// 6. THE TEST: does tab 2 show it without a reload?
144const t2a1 = page2.locator('[data-sheet-grid] td[data-x="0"][data-y="0"]');
145const t2a2 = page2.locator('[data-sheet-grid] td[data-x="0"][data-y="1"]');
146let ok = false;
147for (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}
153console.log('tab2 A1:', JSON.stringify((await t2a1.textContent())?.trim()));
154console.log('tab2 A2:', JSON.stringify((await t2a2.textContent())?.trim()));
155console.log(ok ? 'LIVE-ACROSS-TABS: YES' : 'LIVE-ACROSS-TABS: NO');
156
157// 7. The formula bar shows the input, not the result.
158await t2a2.click();
159await page2.waitForTimeout(300);
160console.log('tab2 formula bar for A2:', JSON.stringify(await page2.inputValue('[data-formula-input]')));
161
162const shots = process.env.SHEETS_SHOTS;
163if (shots) {
164 await page.screenshot({ path: shots + '/tab1.png' });
165 await page2.screenshot({ path: shots + '/tab2.png' });
166}
167
168console.log('js errors:', errors.length ? JSON.stringify(errors.slice(0, 8), null, 1) : 'none');
169await browser.close();
170proxy.close();
171