| 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 | */ |
| 33 | import net from 'node:net'; |
| 34 | import fs from 'node:fs'; |
| 35 | const PLAYWRIGHT = process.env.PLAYWRIGHT || 'playwright'; |
| 36 | const pw = await import(PLAYWRIGHT); |
| 37 | /* A CJS build imported from ESM puts its exports on .default. */ |
| 38 | const chromium = pw.chromium || (pw.default && pw.default.chromium); |
| 39 | if (!chromium) { console.error('playwright: no chromium export'); process.exit(1); } |
| 40 | |
| 41 | const SOCK = process.env.SHEETS_SOCK; |
| 42 | const LOG = process.env.SHEETS_LOG; |
| 43 | const PORT = parseInt(process.env.SHEETS_PORT || '8799', 10); |
| 44 | const OUT = process.env.OUT || '.'; |
| 45 | const FIXTURE = process.env.SHEETS_FIXTURE || 'testdata/corpus/xlsx/numbers.xlsx'; |
| 46 | |
| 47 | const 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 | }); |
| 53 | await new Promise((r) => proxy.listen(PORT, '127.0.0.1', r)); |
| 54 | |
| 55 | const base = `http://127.0.0.1:${PORT}`; |
| 56 | const browser = await chromium.launch(); |
| 57 | const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); |
| 58 | const page = await ctx.newPage(); |
| 59 | |
| 60 | const errors = []; |
| 61 | page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); |
| 62 | page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); |
| 63 | |
| 64 | const failed = []; |
| 65 | function 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. */ |
| 72 | const before = fs.readFileSync(LOG, 'utf8').length; |
| 73 | await page.goto(base + '/signin'); |
| 74 | await page.fill('input[name=address]', 'paul@example.com'); |
| 75 | await page.click('form[action="/signin"] button[type=submit]'); |
| 76 | await page.waitForURL(/signin\?sent=1/, { timeout: 15000 }); |
| 77 | const link = fs.readFileSync(LOG, 'utf8').slice(before).match(/auth\/verify\?token=([A-Za-z0-9_-]+)/); |
| 78 | if (!link) { console.error('no magic link in the log'); process.exit(1); } |
| 79 | await page.goto(`${base}/auth/verify?token=${link[1]}`); |
| 80 | await page.goto(base + '/sheets'); |
| 81 | |
| 82 | console.log('\n— at rest'); |
| 83 | |
| 84 | const 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 | }); |
| 96 | check('the overlay is in the page', rest !== null); |
| 97 | check('...hidden', rest && rest.hidden === true && rest.display === 'none', rest); |
| 98 | check('...costing no layout', rest && rest.area === 0, rest && rest.area); |
| 99 | check('...and no tab stop', rest && rest.reachable === false, rest && rest.reachable); |
| 100 | await 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. */ |
| 106 | const 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 | `; |
| 123 | const b64 = fs.readFileSync(FIXTURE).toString('base64'); |
| 124 | const 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 | |
| 130 | console.log('\n— a file over the window'); |
| 131 | |
| 132 | await page.evaluate(helper); |
| 133 | await drag(1); |
| 134 | const 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 | }); |
| 145 | check('the overlay appears', dragging.hidden === false && dragging.over === true, dragging); |
| 146 | check('...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. */ |
| 150 | check('...over a translucent scrim, with the list still there', |
| 151 | /^rgba\(/.test(dragging.scrim) && parseFloat(dragging.scrim.split(',')[3]) < 0.8, dragging.scrim); |
| 152 | check('...and the chooser out of the way', dragging.recover === 'none', dragging.recover); |
| 153 | await page.screenshot({ path: OUT + '/list-dragging-light.png' }); |
| 154 | |
| 155 | await page.evaluate(() => window.__fire('dragleave', window.__dt)); |
| 156 | check('the overlay goes when the drag leaves', |
| 157 | (await page.evaluate(() => document.querySelector('[data-drop-page]').hidden)) === true); |
| 158 | |
| 159 | console.log('\n— a drop it cannot take'); |
| 160 | |
| 161 | await page.evaluate(([b64]) => { |
| 162 | const dt = window.__mkdt(b64, 2); |
| 163 | window.__fire('dragenter', dt); |
| 164 | window.__fire('drop', dt); |
| 165 | }, [b64]); |
| 166 | const 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 | }); |
| 175 | check('two files are refused in words', refused.hint === 'Drop one file at a time.', refused); |
| 176 | check('...with the overlay left up to say so', refused.hidden === false, refused); |
| 177 | check('...and the chooser back, to recover with', refused.recover !== 'none', refused.recover); |
| 178 | check('...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. */ |
| 191 | const 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 | }); |
| 199 | check('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. */ |
| 204 | await page.evaluate(() => document.querySelector('[data-drop-page-recover] input').focus()); |
| 205 | const focusedInPanel = await page.evaluate(() => |
| 206 | !!document.activeElement.closest('[data-drop-page-recover]')); |
| 207 | check('...and focus really lands in the panel', focusedInPanel, 'focus escaped the overlay'); |
| 208 | await page.keyboard.press('Tab'); |
| 209 | const nextInPanel = await page.evaluate(() => |
| 210 | !!document.activeElement.closest('[data-drop-page-recover]')); |
| 211 | check('...and Tab moves to Import, not past the overlay', nextInPanel, |
| 212 | 'Tab left the recovery form'); |
| 213 | |
| 214 | await page.screenshot({ path: OUT + '/list-refused.png' }); |
| 215 | |
| 216 | await page.keyboard.press('Escape'); |
| 217 | const 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 | }); |
| 221 | check('Escape dismisses it', dismissed.hidden === true, dismissed); |
| 222 | check('...and the refusal does not outlive the dismissal', |
| 223 | dismissed.hint.startsWith('Excel'), dismissed.hint); |
| 224 | |
| 225 | console.log('\n— dark'); |
| 226 | |
| 227 | await ctx.addCookies([{ name: 'estilo_appearance', value: 'dark', url: base }]); |
| 228 | await page.goto(base + '/sheets'); |
| 229 | await page.evaluate(helper); |
| 230 | await 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. */ |
| 235 | check('dark really is dark', (await page.getAttribute('html', 'data-theme')) === 'dark'); |
| 236 | await page.screenshot({ path: OUT + '/list-dragging-dark.png' }); |
| 237 | |
| 238 | console.log('\n— the drop imports'); |
| 239 | |
| 240 | await ctx.addCookies([{ name: 'estilo_appearance', value: 'light', url: base }]); |
| 241 | await page.goto(base + '/sheets'); |
| 242 | await page.evaluate(helper); |
| 243 | await page.evaluate(([b64]) => { |
| 244 | const dt = window.__mkdt(b64, 1); |
| 245 | window.__fire('dragenter', dt); |
| 246 | window.__fire('drop', dt); |
| 247 | }, [b64]); |
| 248 | let landed = 'never left /sheets'; |
| 249 | try { |
| 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 */ } |
| 253 | check('a drop starts a real import', /^\/(jobs|sheets)\//.test(landed), landed); |
| 254 | |
| 255 | let arrived = false; |
| 256 | for (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 | } |
| 261 | check('...and the sheet lands in the list', arrived); |
| 262 | await page.screenshot({ path: OUT + '/list-after-import.png' }); |
| 263 | |
| 264 | check('no console errors', errors.length === 0, errors.slice(0, 8)); |
| 265 | |
| 266 | console.log(failed.length ? '\nFAILED: ' + failed.join(', ') : '\nall checks passed'); |
| 267 | await browser.close(); |
| 268 | proxy.close(); |
| 269 | process.exit(failed.length ? 1 : 0); |
| 270 | |