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 sheet list's drop overlay in a real browser.
2 *
3 * The third of the drives, after drive.mjs (live sync across two tabs)
4 * and chrome.mjs (the editor chrome). Like both it is NOT part of
5 * `make test`: it needs a browser and a running instance, neither of
6 * which CI has.
7 *
8 * It is here because the Go suite can assert the markup and read the
9 * stylesheet, and neither tells you whether the thing works. The list
10 * has no visible drop box any more — a list of sheets should be a list
11 * of sheets — so the whole affordance is an overlay that must be
12 * invisible at rest, appear when a file is over the window, and go away
13 * again. "Invisible" and "appears" are not things a Go test can see.
14 *
15 * It earned its place on the first run. The overlay carries data-drop,
16 * because that is what drop.js looks for, and so it also matched
17 * [data-drop][data-drop-over] — 0-2-0 against [rst-modal-overlay]'s
18 * 0-1-0. The scrim was painted accent-soft and the sheet list vanished
19 * behind a flat blue screen. Every Go test passed.
20 *
21 * Usage, from the repository root — see drive.mjs for the full recipe:
22 *
23 * SHEETS_SOCK=$D/s.sock SHEETS_LOG=$D/s.log OUT=$D \
24 * SHEETS_FIXTURE=testdata/corpus/xlsx/numbers.xlsx \
25 * node tools/drive/drop.mjs
26 *
27 * The drag events are synthesised rather than performed: Playwright
28 * cannot drag a file in from the desktop, and a DragEvent carrying a
29 * real DataTransfer of real File objects is what the browser delivers
30 * when it does. The final drop is a genuine multipart upload — the
31 * import at the end really imports.
32 */
33import net from 'node:net';
34import fs from 'node:fs';
35const PLAYWRIGHT = process.env.PLAYWRIGHT || 'playwright';
36const pw = await import(PLAYWRIGHT);
37/* A CJS build imported from ESM puts its exports on .default. */
38const chromium = pw.chromium || (pw.default && pw.default.chromium);
39if (!chromium) { console.error('playwright: no chromium export'); process.exit(1); }
40
41const SOCK = process.env.SHEETS_SOCK;
42const LOG = process.env.SHEETS_LOG;
43const PORT = parseInt(process.env.SHEETS_PORT || '8799', 10);
44const OUT = process.env.OUT || '.';
45const FIXTURE = process.env.SHEETS_FIXTURE || 'testdata/corpus/xlsx/numbers.xlsx';
46
47const proxy = net.createServer((c) => {
48 const u = net.connect(SOCK);
49 c.pipe(u); u.pipe(c);
50 c.on('error', () => u.destroy());
51 u.on('error', () => c.destroy());
52});
53await new Promise((r) => proxy.listen(PORT, '127.0.0.1', r));
54
55const base = `http://127.0.0.1:${PORT}`;
56const browser = await chromium.launch();
57const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
58const page = await ctx.newPage();
59
60const errors = [];
61page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
62page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
63
64const failed = [];
65function check(name, ok, detail) {
66 console.log((ok ? ' ok ' : ' FAIL ') + name + (detail === undefined ? '' : ' ' + JSON.stringify(detail)));
67 if (!ok) failed.push(name);
68}
69
70/* Sign in. The magic link is read out of the log, which only works on a
71 * loopback origin with no SMTP — see internal/sheets/auth.go. */
72const before = fs.readFileSync(LOG, 'utf8').length;
73await page.goto(base + '/signin');
74await page.fill('input[name=address]', 'paul@example.com');
75await page.click('form[action="/signin"] button[type=submit]');
76await page.waitForURL(/signin\?sent=1/, { timeout: 15000 });
77const link = fs.readFileSync(LOG, 'utf8').slice(before).match(/auth\/verify\?token=([A-Za-z0-9_-]+)/);
78if (!link) { console.error('no magic link in the log'); process.exit(1); }
79await page.goto(`${base}/auth/verify?token=${link[1]}`);
80await page.goto(base + '/sheets');
81
82console.log('\n— at rest');
83
84const rest = await page.evaluate(() => {
85 const z = document.querySelector('[data-drop-page]');
86 if (!z) return null;
87 const r = z.getBoundingClientRect();
88 const input = z.querySelector('input[type=file]');
89 return {
90 hidden: z.hidden,
91 display: getComputedStyle(z).display,
92 area: r.width * r.height,
93 reachable: input ? input.offsetParent !== null : null,
94 };
95});
96check('the overlay is in the page', rest !== null);
97check('...hidden', rest && rest.hidden === true && rest.display === 'none', rest);
98check('...costing no layout', rest && rest.area === 0, rest && rest.area);
99check('...and no tab stop', rest && rest.reachable === false, rest && rest.reachable);
100await page.screenshot({ path: OUT + '/list-at-rest.png' });
101
102/* Synthesised drags. __mkdt builds a DataTransfer of n copies of the
103 * fixture; __fire dispatches at the centre of the viewport, on whatever
104 * element is actually there, so the event bubbles the way a real one
105 * does rather than being handed straight to the listener. */
106const helper = `
107 window.__mkdt = (b64, n) => {
108 const bin = atob(b64);
109 const bytes = new Uint8Array(bin.length);
110 for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
111 const dt = new DataTransfer();
112 for (let i = 0; i < n; i++) {
113 dt.items.add(new File([bytes], (i ? i + '-' : '') + 'drop.xlsx',
114 { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }));
115 }
116 return dt;
117 };
118 window.__fire = (type, dt) => {
119 const e = new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt });
120 (document.elementFromPoint(640, 400) || document.body).dispatchEvent(e);
121 };
122`;
123const b64 = fs.readFileSync(FIXTURE).toString('base64');
124const drag = (n) => page.evaluate(([b64, n]) => {
125 window.__dt = window.__mkdt(b64, n);
126 window.__fire('dragenter', window.__dt);
127 window.__fire('dragover', window.__dt);
128}, [b64, n]);
129
130console.log('\n— a file over the window');
131
132await page.evaluate(helper);
133await drag(1);
134const dragging = await page.evaluate(() => {
135 const z = document.querySelector('[data-drop-page]');
136 const r = z.getBoundingClientRect();
137 return {
138 hidden: z.hidden,
139 over: z.hasAttribute('data-drop-over'),
140 w: Math.round(r.width), h: Math.round(r.height),
141 scrim: getComputedStyle(z).backgroundColor,
142 recover: getComputedStyle(z.querySelector('[data-drop-page-recover]')).display,
143 };
144});
145check('the overlay appears', dragging.hidden === false && dragging.over === true, dragging);
146check('...covering the window', dragging.w >= 1200 && dragging.h >= 700, dragging);
147/* The list must still be visible behind it. This is the check that
148 * caught the specificity bug in the header comment: an opaque backdrop
149 * reads as the page having gone away. */
150check('...over a translucent scrim, with the list still there',
151 /^rgba\(/.test(dragging.scrim) && parseFloat(dragging.scrim.split(',')[3]) < 0.8, dragging.scrim);
152check('...and the chooser out of the way', dragging.recover === 'none', dragging.recover);
153await page.screenshot({ path: OUT + '/list-dragging-light.png' });
154
155await page.evaluate(() => window.__fire('dragleave', window.__dt));
156check('the overlay goes when the drag leaves',
157 (await page.evaluate(() => document.querySelector('[data-drop-page]').hidden)) === true);
158
159console.log('\n— a drop it cannot take');
160
161await page.evaluate(([b64]) => {
162 const dt = window.__mkdt(b64, 2);
163 window.__fire('dragenter', dt);
164 window.__fire('drop', dt);
165}, [b64]);
166const refused = await page.evaluate(() => {
167 const z = document.querySelector('[data-drop-page]');
168 return {
169 hidden: z.hidden,
170 hint: z.querySelector('[data-drop-hint]').textContent.trim(),
171 recover: getComputedStyle(z.querySelector('[data-drop-page-recover]')).display,
172 path: location.pathname,
173 };
174});
175check('two files are refused in words', refused.hint === 'Drop one file at a time.', refused);
176check('...with the overlay left up to say so', refused.hidden === false, refused);
177check('...and the chooser back, to recover with', refused.recover !== 'none', refused.recover);
178check('...having submitted nothing', refused.path === '/sheets', refused.path);
179
180/* The recovery form must be reachable by KEYBOARD, not merely visible.
181 *
182 * An independent review found both controls carrying tabindex="-1",
183 * which put them out of the tab order at the one moment they exist to
184 * be used: the overlay stays up after a refusal so they can be used to
185 * recover, and Tab went straight past them into the list behind. It
186 * looked completely correct in a screenshot, which is why this check
187 * presses Tab instead of reading a style — the same lesson the menu bar
188 * taught when it worked from the keyboard and not from a mouse.
189 *
190 * Tabbing from the panel must land ON the panel's own controls. */
191const reachable = await page.evaluate(() => {
192 const rec = document.querySelector('[data-drop-page-recover]');
193 const controls = [...rec.querySelectorAll('input,button')];
194 return {
195 negative: controls.filter((c) => c.tabIndex < 0).length,
196 count: controls.length,
197 };
198});
199check('the recovery controls are in the tab order', reachable.negative === 0
200 && reachable.count === 2, JSON.stringify(reachable));
201
202/* And prove it by actually tabbing: focus the panel, press Tab, and the
203 * focus must be inside the panel rather than on the list underneath. */
204await page.evaluate(() => document.querySelector('[data-drop-page-recover] input').focus());
205const focusedInPanel = await page.evaluate(() =>
206 !!document.activeElement.closest('[data-drop-page-recover]'));
207check('...and focus really lands in the panel', focusedInPanel, 'focus escaped the overlay');
208await page.keyboard.press('Tab');
209const nextInPanel = await page.evaluate(() =>
210 !!document.activeElement.closest('[data-drop-page-recover]'));
211check('...and Tab moves to Import, not past the overlay', nextInPanel,
212 'Tab left the recovery form');
213
214await page.screenshot({ path: OUT + '/list-refused.png' });
215
216await page.keyboard.press('Escape');
217const dismissed = await page.evaluate(() => {
218 const z = document.querySelector('[data-drop-page]');
219 return { hidden: z.hidden, hint: z.querySelector('[data-drop-hint]').textContent.trim() };
220});
221check('Escape dismisses it', dismissed.hidden === true, dismissed);
222check('...and the refusal does not outlive the dismissal',
223 dismissed.hint.startsWith('Excel'), dismissed.hint);
224
225console.log('\n— dark');
226
227await ctx.addCookies([{ name: 'estilo_appearance', value: 'dark', url: base }]);
228await page.goto(base + '/sheets');
229await page.evaluate(helper);
230await drag(1);
231/* The cookie is estilo_appearance (estilo's chrome.AppearanceCookie; it
232 * was sheets_mode until round 2). Naming it wrong renders the light page
233 * and the screenshot proves nothing — which it once did, and the only
234 * tell was that the two images were pixel-identical. */
235check('dark really is dark', (await page.getAttribute('html', 'data-theme')) === 'dark');
236await page.screenshot({ path: OUT + '/list-dragging-dark.png' });
237
238console.log('\n— the drop imports');
239
240await ctx.addCookies([{ name: 'estilo_appearance', value: 'light', url: base }]);
241await page.goto(base + '/sheets');
242await page.evaluate(helper);
243await page.evaluate(([b64]) => {
244 const dt = window.__mkdt(b64, 1);
245 window.__fire('dragenter', dt);
246 window.__fire('drop', dt);
247}, [b64]);
248let landed = 'never left /sheets';
249try {
250 await page.waitForURL((u) => !u.pathname.endsWith('/sheets'), { timeout: 20000 });
251 landed = new URL(page.url()).pathname;
252} catch (e) { /* landed stays as it was */ }
253check('a drop starts a real import', /^\/(jobs|sheets)\//.test(landed), landed);
254
255let arrived = false;
256for (let i = 0; i < 60; i++) {
257 await page.goto(base + '/sheets');
258 if (await page.locator('[data-sheet-list] li').count() > 0) { arrived = true; break; }
259 await page.waitForTimeout(500);
260}
261check('...and the sheet lands in the list', arrived);
262await page.screenshot({ path: OUT + '/list-after-import.png' });
263
264check('no console errors', errors.length === 0, errors.slice(0, 8));
265
266console.log(failed.length ? '\nFAILED: ' + failed.join(', ') : '\nall checks passed');
267await browser.close();
268proxy.close();
269process.exit(failed.length ? 1 : 0);
270