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 editor chrome in a real browser.
2 *
3 * The sibling of drive.mjs, which drives live sync across two tabs.
4 * This one drives the menu bar, the toolbar, the formula bar and the
5 * cell paint. Like its sibling it is NOT part of `make test`: it needs
6 * a browser and a running instance, neither of which CI has.
7 *
8 * It earned its place immediately. The menu bar tested perfectly from
9 * the keyboard and did not work with a mouse at all: reaching for a
10 * menu is ENTER THEN CLICK, the hover-switch opened it, and <summary>'s
11 * own activation behaviour closed it again a millisecond later. No Go
12 * test can see that, and neither could reading the code.
13 *
14 * Usage, from the repository root — see drive.mjs for the full recipe:
15 *
16 * SHEETS_SOCK=$D/s.sock SHEETS_LOG=$D/s.log OUT=$D node tools/drive/chrome.mjs
17 *
18 * OUT is where the four screenshots land (light, dark, 320px, 768px).
19 */
20import net from 'node:net';
21import fs from 'node:fs';
22const PLAYWRIGHT = process.env.PLAYWRIGHT || 'playwright';
23const pw = await import(PLAYWRIGHT);
24/* A CJS build imported from ESM puts its exports on .default. */
25const chromium = pw.chromium || (pw.default && pw.default.chromium);
26if (!chromium) { console.error('playwright: no chromium export'); process.exit(1); }
27
28const SOCK = process.env.SHEETS_SOCK;
29const LOG = process.env.SHEETS_LOG;
30const PORT = parseInt(process.env.SHEETS_PORT || '8799', 10);
31const OUT = process.env.OUT || '.';
32
33const proxy = net.createServer((c) => {
34 const u = net.connect(SOCK);
35 c.pipe(u); u.pipe(c);
36 c.on('error', () => u.destroy());
37 u.on('error', () => c.destroy());
38});
39await new Promise((r) => proxy.listen(PORT, '127.0.0.1', r));
40const base = `http://127.0.0.1:${PORT}`;
41
42const fails = [];
43const ok = (name) => console.log(` ok ${name}`);
44const bad = (name, detail) => { fails.push(`${name}: ${detail}`); console.log(` FAIL ${name} — ${detail}`); };
45const check = (name, cond, detail) => cond ? ok(name) : bad(name, detail || 'false');
46
47const browser = await chromium.launch();
48const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
49const page = await ctx.newPage();
50const errors = [];
51page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
52page.on('pageerror', (e) => errors.push(String(e)));
53
54/* Sign in through the link the log prints. Strip at the first backslash:
55 * the log escapes the mail body, so a naive grep captures the token plus
56 * "\n\nIt..." and the link comes back expired. */
57await page.goto(`${base}/signin`);
58await page.fill('input[type=email]', 'paul@example.com');
59await Promise.all([page.waitForURL(/signin\?sent=1/), page.click('form[action="/signin"] button[type=submit]')]);
60const log = fs.readFileSync(LOG, 'utf8');
61const m = [...log.matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop();
62if (!m) { console.error('no magic link in the log'); process.exit(1); }
63await page.goto(m[0]);
64await page.waitForURL(/\/sheets/);
65
66/* A sheet with something in it.
67 *
68 * Created from the top bar's Create button, then named by renaming it.
69 * The list page's #title field is gone: it carries no forms now, Create
70 * posts an empty one and the sheet lands as "Untitled sheet", so the
71 * only place a sheet is ever named is the title in the top bar. The
72 * rename is a real form submit, so driving it here covers the
73 * scriptless path as well as the button. */
74await Promise.all([
75 page.waitForURL(/\/sheets\/\d+$/),
76 page.click('[estilo-bar-actions] form[action="/sheets"] button[type=submit]'),
77]);
78await page.fill('[estilo-bar-title-input]', 'Commission Q2');
79await Promise.all([
80 page.waitForURL(/\/sheets\/\d+$/),
81 page.press('[estilo-bar-title-input]', 'Enter'),
82]);
83const url = page.url();
84await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet());
85
86async function type(r, c, text) {
87 await page.evaluate(([r, c, text]) => window.SheetsGrid.write(r, c, text), [r, c, text]);
88}
89const rows = [
90 ['Invoice Quarter', 'Event', 'Invoice (GBP)', 'Commission'],
91 ['Q2/26', 'Juxt', '3228.33', '=C2*0.1'],
92 ['Q2/26', 'Techarena', '1840.00', '=C3*0.1'],
93 ['Q2/26', 'Seapoint', '920.50', '=C4*0.1'],
94 ['', 'Total commission owing', '', '=SUM(D2:D4)'],
95];
96for (let r = 0; r < rows.length; r++) {
97 for (let c = 0; c < rows[r].length; c++) if (rows[r][c]) await type(r, c, rows[r][c]);
98}
99await page.waitForTimeout(400);
100
101console.log('\n— the formula engine still owns every painted string');
102const d5 = await page.evaluate(() => window.SheetsGrid.worksheet().getValueFromCoords(3, 4));
103check('SUM of the commission column', d5 === '598.883', `got ${JSON.stringify(d5)}`);
104
105console.log('\n— the menu bar');
106await page.click('[data-menubar] details:nth-child(1) > summary');
107check('File opens', await page.isVisible('a[href$="/export.xlsx"]'));
108await page.click('[data-menubar] details:nth-child(2) > summary');
109check('Edit opens and File closed', await page.isVisible('[data-cmd="fill-down"]')
110 && !(await page.evaluate(() => document.querySelector('[data-menubar] details:nth-child(1)').open)),
111 'the exclusive group is not working');
112await page.keyboard.press('Escape');
113
114/* Keyboard: focus the first summary, walk right, open, walk down. */
115await page.evaluate(() => document.querySelector('[data-menubar] > details > summary').focus());
116await page.keyboard.press('ArrowRight');
117await page.keyboard.press('ArrowDown');
118const focusTag = await page.evaluate(() => document.activeElement.textContent.trim().split('\n')[0]);
119check('arrows open a menu and land on its first item', focusTag.length > 0, `focus is on ${JSON.stringify(focusTag)}`);
120const cursorMoved = await page.evaluate(() => {
121 const s = window.SheetsGrid.selection();
122 return s ? `${s.r},${s.c}` : 'none';
123});
124await page.keyboard.press('Escape');
125
126console.log('\n— formatting reaches the cell and the paint reaches the td');
127await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(0, 0, 3, 0));
128await page.waitForTimeout(150);
129await page.click('[data-toolbar] [data-cmd="bold"]');
130await page.waitForTimeout(400);
131const hdr = await page.evaluate(() => {
132 const td = window.SheetsGrid.worksheet().records[0][0].element;
133 return { attr: td.hasAttribute('data-b'), weight: getComputedStyle(td).fontWeight };
134});
135check('header row is bold on the td', hdr.attr, 'data-b was never set');
136check('...and the CSS actually applies it', parseInt(hdr.weight, 10) >= 600, `font-weight ${hdr.weight}`);
137
138const pressed = await page.getAttribute('[data-toolbar] [data-cmd="bold"]', 'aria-pressed');
139check('the toolbar reflects the selection', pressed === 'true', `aria-pressed=${pressed}`);
140
141/* Alignment. This check exists because its absence let a blocker
142 * through: the library writes text-align as an INLINE style on every
143 * cell, so the attribute was set, aria-pressed reflected it, and the
144 * text did not move. Bold and fill both passed, because neither paints
145 * a property the library touches. Assert the COMPUTED value, never the
146 * attribute — the attribute was always right. */
147await page.click('[data-toolbar] [data-cmd="align"][data-value="right"]');
148await page.waitForTimeout(400);
149const aligned = await page.evaluate(() =>
150 getComputedStyle(window.SheetsGrid.worksheet().records[0][0].element).textAlign);
151check('alignment actually moves the text', aligned === 'right' || aligned === 'end', `computed ${aligned}`);
152await page.click('[data-toolbar] [data-cmd="align"][data-value="right"]');
153await page.waitForTimeout(400);
154
155/* Numbers right-align without anybody asking. A spreadsheet that
156 * centres its numbers does not look like one. */
157const numAlign = await page.evaluate(() =>
158 getComputedStyle(window.SheetsGrid.worksheet().records[1][2].element).textAlign);
159check('numbers right-align on their own', numAlign === 'right' || numAlign === 'end', `computed ${numAlign}`);
160const textAlign = await page.evaluate(() =>
161 getComputedStyle(window.SheetsGrid.worksheet().records[1][1].element).textAlign);
162check('...and text does not', textAlign === 'left' || textAlign === 'start', `computed ${textAlign}`);
163
164/* Fill: a swatch by name, resolved against the theme's lightness. */
165await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(1, 4, 3, 4));
166await page.waitForTimeout(150);
167await page.click('[data-toolbar] [data-tool][aria-label="Fill colour"]');
168await page.click('[data-toolbar] [data-cmd="fill"][data-value="green"]');
169await page.waitForTimeout(400);
170const fill = await page.evaluate(() => {
171 const td = window.SheetsGrid.worksheet().records[4][1].element;
172 return { attr: td.hasAttribute('data-fill'), bg: getComputedStyle(td).backgroundColor };
173});
174check('the total row has a fill', fill.attr, 'data-fill was never set');
175check('...and it paints a real colour', fill.bg && fill.bg !== 'rgba(0, 0, 0, 0)', `background ${fill.bg}`);
176
177/* A number format changes the SERVER-rendered string, not a client one. */
178await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(3, 1, 3, 4));
179await page.waitForTimeout(150);
180await page.click('[data-toolbar] [data-tool][aria-label="Number format"]');
181await page.click('[data-toolbar] [data-cmd="numfmt"][data-value="0.00"]');
182await page.waitForTimeout(500);
183const formatted = await page.evaluate(() => window.SheetsGrid.worksheet().getValueFromCoords(3, 4));
184check('number format re-renders server-side', formatted === '598.88', `got ${JSON.stringify(formatted)}`);
185
186console.log('\n— the formula bar');
187await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(3, 4, 3, 4));
188await page.waitForTimeout(200);
189check('name box shows the reference', (await page.inputValue('[data-name-box]')) === 'D5',
190 `got ${await page.inputValue('[data-name-box]')}`);
191check('formula bar shows the input, not the value',
192 (await page.inputValue('[data-formula-input]')) === '=SUM(D2:D4)',
193 `got ${await page.inputValue('[data-formula-input]')}`);
194
195await page.fill('[data-name-box]', 'B2');
196await page.press('[data-name-box]', 'Enter');
197await page.waitForTimeout(250);
198const jumped = await page.evaluate(() => { const s = window.SheetsGrid.selection(); return `${s.r},${s.c}`; });
199check('typing a reference moves the selection', jumped === '1,1', `selection is ${jumped}`);
200
201/* The anchor cursor has to survive a fill. It used to be marked by a
202 * background alone, and the fill rules land later at the same
203 * specificity — so on a filled range the cursor vanished at exactly the
204 * moment you were aiming with it. */
205await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(1, 4, 3, 4));
206await page.waitForTimeout(300);
207const anchorRing = await page.evaluate(() =>
208 getComputedStyle(window.SheetsGrid.worksheet().records[4][1].element).boxShadow);
209check('the anchor is visible on a filled cell', anchorRing && anchorRing !== 'none', `box-shadow ${anchorRing}`);
210
211console.log('\n— column widths');
212/* Resize through the library the way a drag does, then prove it survived
213 * a reload: a width the server did not keep looks identical until the
214 * page comes back. */
215await page.evaluate(() => window.SheetsGrid.worksheet().setWidth(0, 220));
216await page.waitForTimeout(500);
217await page.reload();
218await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet());
219await page.waitForTimeout(700);
220const w = await page.evaluate(() => {
221 const td = document.querySelector('[data-sheet-grid] thead td[data-x="0"]')
222 || window.SheetsGrid.worksheet().records[0][0].element;
223 return Math.round(td.getBoundingClientRect().width);
224});
225check('a column width survives a reload', Math.abs(w - 220) <= 3, `column A is ${w}px, want ~220`);
226
227console.log('\n— export');
228const dl = await page.request.get(`${url}/export.xlsx`);
229const buf = await dl.body();
230check('export downloads a real xlsx', dl.status() === 200 && buf[0] === 0x50 && buf[1] === 0x4b,
231 `status ${dl.status()}, ${buf.length} bytes`);
232check('...named after the sheet',
233 (dl.headers()['content-disposition'] || '').includes('Commission Q2.xlsx'),
234 dl.headers()['content-disposition']);
235
236console.log('\n— screenshots and reflow');
237await page.evaluate(() => document.activeElement.blur());
238await page.screenshot({ path: `${OUT}/sheet-light.png` });
239
240/* Dark, chosen explicitly through the form the account menu posts
241 * (estilo's /appearance, which replaced /mode). */
242await page.evaluate(() => {
243 const f = document.createElement('form');
244 f.method = 'post'; f.action = '/appearance';
245 f.innerHTML = '<input name="return_to" value="' + location.pathname + '"><input name="mode" value="dark">';
246 document.body.appendChild(f); f.submit();
247});
248await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet());
249await page.waitForTimeout(700);
250await page.screenshot({ path: `${OUT}/sheet-dark.png` });
251const darkFill = await page.evaluate(() => getComputedStyle(window.SheetsGrid.worksheet().records[4][1].element).backgroundColor);
252check('the fill follows the theme into dark', darkFill !== fill.bg, `light ${fill.bg} vs dark ${darkFill}`);
253
254for (const w of [320, 768]) {
255 await page.setViewportSize({ width: w, height: 800 });
256 await page.waitForTimeout(300);
257 const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
258 check(`no horizontal scroll at ${w}px`, over <= 0, `overflows by ${over}px`);
259 await page.screenshot({ path: `${OUT}/sheet-${w}.png` });
260}
261
262check('no console errors', errors.length === 0, errors.slice(0, 3).join(' | '));
263
264/* A viewer's page, in a second context with its own session.
265 *
266 * The chrome is gated on MayWrite, and the failure this guards against
267 * is not a crash: it is a viewer being handed a toolbar whose every
268 * control 404s, which looks like a broken app rather than like a
269 * permission. A control somebody may not use should be absent. */
270console.log('\n— the read-only viewer');
271await page.setViewportSize({ width: 1440, height: 900 });
272await page.goto(`${url}/share`);
273await page.fill('input[name=address]', 'ada@elsewhere.example');
274await page.selectOption('select[name=role]', 'viewer').catch(() => {});
275await Promise.all([
276 page.waitForLoadState('networkidle'),
277 page.click('form[action$="/share"] button[type=submit]'),
278]);
279
280const viewerCtx = await browser.newContext();
281const vp = await viewerCtx.newPage();
282const vErrors = [];
283vp.on('pageerror', (e) => vErrors.push(String(e)));
284await vp.goto(`${base}/signin`);
285await vp.fill('input[type=email]', 'ada@elsewhere.example');
286await Promise.all([vp.waitForURL(/signin\?sent=1/), vp.click('form[action="/signin"] button[type=submit]')]);
287const vm = [...fs.readFileSync(LOG, 'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop();
288await vp.goto(vm[0]);
289await vp.goto(url);
290await vp.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet(), null, { timeout: 15000 }).catch(() => {});
291await vp.waitForTimeout(600);
292
293const vMenus = await vp.evaluate(() =>
294 Array.from(document.querySelectorAll('[data-menubar] > details > summary')).map((s) => s.textContent.trim()));
295check('a viewer still gets a working grid', await vp.evaluate(() => !!(window.SheetsGrid && window.SheetsGrid.worksheet())));
296check('...with no toolbar', !(await vp.isVisible('[data-toolbar]')), 'a viewer was offered controls that 404');
297check('...no Edit or Format menu', !vMenus.includes('Edit') && !vMenus.includes('Format'), `menus: ${vMenus.join(', ')}`);
298check('...but File is still there', vMenus.includes('File'));
299check('...a read-only formula bar', await vp.evaluate(() => document.querySelector('[data-formula-input]').readOnly));
300/* Read-only is about writing, not about seeing. A viewer must still get
301 * the document as authored. */
302check('...and cells that still paint their formats',
303 await vp.evaluate(() => document.querySelectorAll('[data-sheet-grid] td[data-b],[data-sheet-grid] td[data-fill],[data-sheet-grid] td[data-fx]').length > 0));
304check('...no console errors on the viewer page', vErrors.length === 0, vErrors.slice(0, 2).join(' | '));
305await vp.screenshot({ path: `${OUT}/sheet-viewer.png` });
306
307await browser.close();
308proxy.close();
309console.log(fails.length ? `\n${fails.length} FAILED\n` + fails.map((f) => ' ' + f).join('\n') : '\nall checks passed');
310process.exit(fails.length ? 1 : 0);
311