/* Drive the sheet list's drop overlay in a real browser. * * The third of the drives, after drive.mjs (live sync across two tabs) * and chrome.mjs (the editor chrome). Like both it is NOT part of * `make test`: it needs a browser and a running instance, neither of * which CI has. * * It is here because the Go suite can assert the markup and read the * stylesheet, and neither tells you whether the thing works. The list * has no visible drop box any more — a list of sheets should be a list * of sheets — so the whole affordance is an overlay that must be * invisible at rest, appear when a file is over the window, and go away * again. "Invisible" and "appears" are not things a Go test can see. * * It earned its place on the first run. The overlay carries data-drop, * because that is what drop.js looks for, and so it also matched * [data-drop][data-drop-over] — 0-2-0 against [rst-modal-overlay]'s * 0-1-0. The scrim was painted accent-soft and the sheet list vanished * behind a flat blue screen. Every Go test passed. * * Usage, from the repository root — see drive.mjs for the full recipe: * * SHEETS_SOCK=$D/s.sock SHEETS_LOG=$D/s.log OUT=$D \ * SHEETS_FIXTURE=testdata/corpus/xlsx/numbers.xlsx \ * node tools/drive/drop.mjs * * The drag events are synthesised rather than performed: Playwright * cannot drag a file in from the desktop, and a DragEvent carrying a * real DataTransfer of real File objects is what the browser delivers * when it does. The final drop is a genuine multipart upload — the * import at the end really imports. */ import net from 'node:net'; import fs from 'node:fs'; const PLAYWRIGHT = process.env.PLAYWRIGHT || 'playwright'; const pw = await import(PLAYWRIGHT); /* A CJS build imported from ESM puts its exports on .default. */ const chromium = pw.chromium || (pw.default && pw.default.chromium); if (!chromium) { console.error('playwright: no chromium export'); process.exit(1); } const SOCK = process.env.SHEETS_SOCK; const LOG = process.env.SHEETS_LOG; const PORT = parseInt(process.env.SHEETS_PORT || '8799', 10); const OUT = process.env.OUT || '.'; const FIXTURE = process.env.SHEETS_FIXTURE || 'testdata/corpus/xlsx/numbers.xlsx'; 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)); const base = `http://127.0.0.1:${PORT}`; const browser = await chromium.launch(); const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); const page = await ctx.newPage(); const errors = []; page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); const failed = []; function check(name, ok, detail) { console.log((ok ? ' ok ' : ' FAIL ') + name + (detail === undefined ? '' : ' ' + JSON.stringify(detail))); if (!ok) failed.push(name); } /* Sign in. The magic link is read out of the log, which only works on a * loopback origin with no SMTP — see internal/sheets/auth.go. */ const before = fs.readFileSync(LOG, 'utf8').length; await page.goto(base + '/signin'); await page.fill('input[name=address]', 'paul@example.com'); await page.click('form[action="/signin"] button[type=submit]'); await page.waitForURL(/signin\?sent=1/, { timeout: 15000 }); const link = fs.readFileSync(LOG, 'utf8').slice(before).match(/auth\/verify\?token=([A-Za-z0-9_-]+)/); if (!link) { console.error('no magic link in the log'); process.exit(1); } await page.goto(`${base}/auth/verify?token=${link[1]}`); await page.goto(base + '/sheets'); console.log('\n— at rest'); const rest = await page.evaluate(() => { const z = document.querySelector('[data-drop-page]'); if (!z) return null; const r = z.getBoundingClientRect(); const input = z.querySelector('input[type=file]'); return { hidden: z.hidden, display: getComputedStyle(z).display, area: r.width * r.height, reachable: input ? input.offsetParent !== null : null, }; }); check('the overlay is in the page', rest !== null); check('...hidden', rest && rest.hidden === true && rest.display === 'none', rest); check('...costing no layout', rest && rest.area === 0, rest && rest.area); check('...and no tab stop', rest && rest.reachable === false, rest && rest.reachable); await page.screenshot({ path: OUT + '/list-at-rest.png' }); /* Synthesised drags. __mkdt builds a DataTransfer of n copies of the * fixture; __fire dispatches at the centre of the viewport, on whatever * element is actually there, so the event bubbles the way a real one * does rather than being handed straight to the listener. */ const helper = ` window.__mkdt = (b64, n) => { const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); const dt = new DataTransfer(); for (let i = 0; i < n; i++) { dt.items.add(new File([bytes], (i ? i + '-' : '') + 'drop.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })); } return dt; }; window.__fire = (type, dt) => { const e = new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt }); (document.elementFromPoint(640, 400) || document.body).dispatchEvent(e); }; `; const b64 = fs.readFileSync(FIXTURE).toString('base64'); const drag = (n) => page.evaluate(([b64, n]) => { window.__dt = window.__mkdt(b64, n); window.__fire('dragenter', window.__dt); window.__fire('dragover', window.__dt); }, [b64, n]); console.log('\n— a file over the window'); await page.evaluate(helper); await drag(1); const dragging = await page.evaluate(() => { const z = document.querySelector('[data-drop-page]'); const r = z.getBoundingClientRect(); return { hidden: z.hidden, over: z.hasAttribute('data-drop-over'), w: Math.round(r.width), h: Math.round(r.height), scrim: getComputedStyle(z).backgroundColor, recover: getComputedStyle(z.querySelector('[data-drop-page-recover]')).display, }; }); check('the overlay appears', dragging.hidden === false && dragging.over === true, dragging); check('...covering the window', dragging.w >= 1200 && dragging.h >= 700, dragging); /* The list must still be visible behind it. This is the check that * caught the specificity bug in the header comment: an opaque backdrop * reads as the page having gone away. */ check('...over a translucent scrim, with the list still there', /^rgba\(/.test(dragging.scrim) && parseFloat(dragging.scrim.split(',')[3]) < 0.8, dragging.scrim); check('...and the chooser out of the way', dragging.recover === 'none', dragging.recover); await page.screenshot({ path: OUT + '/list-dragging-light.png' }); await page.evaluate(() => window.__fire('dragleave', window.__dt)); check('the overlay goes when the drag leaves', (await page.evaluate(() => document.querySelector('[data-drop-page]').hidden)) === true); console.log('\n— a drop it cannot take'); await page.evaluate(([b64]) => { const dt = window.__mkdt(b64, 2); window.__fire('dragenter', dt); window.__fire('drop', dt); }, [b64]); const refused = await page.evaluate(() => { const z = document.querySelector('[data-drop-page]'); return { hidden: z.hidden, hint: z.querySelector('[data-drop-hint]').textContent.trim(), recover: getComputedStyle(z.querySelector('[data-drop-page-recover]')).display, path: location.pathname, }; }); check('two files are refused in words', refused.hint === 'Drop one file at a time.', refused); check('...with the overlay left up to say so', refused.hidden === false, refused); check('...and the chooser back, to recover with', refused.recover !== 'none', refused.recover); check('...having submitted nothing', refused.path === '/sheets', refused.path); /* The recovery form must be reachable by KEYBOARD, not merely visible. * * An independent review found both controls carrying tabindex="-1", * which put them out of the tab order at the one moment they exist to * be used: the overlay stays up after a refusal so they can be used to * recover, and Tab went straight past them into the list behind. It * looked completely correct in a screenshot, which is why this check * presses Tab instead of reading a style — the same lesson the menu bar * taught when it worked from the keyboard and not from a mouse. * * Tabbing from the panel must land ON the panel's own controls. */ const reachable = await page.evaluate(() => { const rec = document.querySelector('[data-drop-page-recover]'); const controls = [...rec.querySelectorAll('input,button')]; return { negative: controls.filter((c) => c.tabIndex < 0).length, count: controls.length, }; }); check('the recovery controls are in the tab order', reachable.negative === 0 && reachable.count === 2, JSON.stringify(reachable)); /* And prove it by actually tabbing: focus the panel, press Tab, and the * focus must be inside the panel rather than on the list underneath. */ await page.evaluate(() => document.querySelector('[data-drop-page-recover] input').focus()); const focusedInPanel = await page.evaluate(() => !!document.activeElement.closest('[data-drop-page-recover]')); check('...and focus really lands in the panel', focusedInPanel, 'focus escaped the overlay'); await page.keyboard.press('Tab'); const nextInPanel = await page.evaluate(() => !!document.activeElement.closest('[data-drop-page-recover]')); check('...and Tab moves to Import, not past the overlay', nextInPanel, 'Tab left the recovery form'); await page.screenshot({ path: OUT + '/list-refused.png' }); await page.keyboard.press('Escape'); const dismissed = await page.evaluate(() => { const z = document.querySelector('[data-drop-page]'); return { hidden: z.hidden, hint: z.querySelector('[data-drop-hint]').textContent.trim() }; }); check('Escape dismisses it', dismissed.hidden === true, dismissed); check('...and the refusal does not outlive the dismissal', dismissed.hint.startsWith('Excel'), dismissed.hint); console.log('\n— dark'); await ctx.addCookies([{ name: 'estilo_appearance', value: 'dark', url: base }]); await page.goto(base + '/sheets'); await page.evaluate(helper); await drag(1); /* The cookie is estilo_appearance (estilo's chrome.AppearanceCookie; it * was sheets_mode until round 2). Naming it wrong renders the light page * and the screenshot proves nothing — which it once did, and the only * tell was that the two images were pixel-identical. */ check('dark really is dark', (await page.getAttribute('html', 'data-theme')) === 'dark'); await page.screenshot({ path: OUT + '/list-dragging-dark.png' }); console.log('\n— the drop imports'); await ctx.addCookies([{ name: 'estilo_appearance', value: 'light', url: base }]); await page.goto(base + '/sheets'); await page.evaluate(helper); await page.evaluate(([b64]) => { const dt = window.__mkdt(b64, 1); window.__fire('dragenter', dt); window.__fire('drop', dt); }, [b64]); let landed = 'never left /sheets'; try { await page.waitForURL((u) => !u.pathname.endsWith('/sheets'), { timeout: 20000 }); landed = new URL(page.url()).pathname; } catch (e) { /* landed stays as it was */ } check('a drop starts a real import', /^\/(jobs|sheets)\//.test(landed), landed); let arrived = false; for (let i = 0; i < 60; i++) { await page.goto(base + '/sheets'); if (await page.locator('[data-sheet-list] li').count() > 0) { arrived = true; break; } await page.waitForTimeout(500); } check('...and the sheet lands in the list', arrived); await page.screenshot({ path: OUT + '/list-after-import.png' }); check('no console errors', errors.length === 0, errors.slice(0, 8)); console.log(failed.length ? '\nFAILED: ' + failed.join(', ') : '\nall checks passed'); await browser.close(); proxy.close(); process.exit(failed.length ? 1 : 0);