| 1 | /* Click every menu item and record what it changed. |
| 2 | * |
| 3 | * The sibling of chrome.mjs, and the one that answers a question no Go |
| 4 | * test can: does this control DO anything. TestEveryMenuCommandIsImplemented |
| 5 | * proves a `data-cmd` has an entry in commands.js's dispatch table; it |
| 6 | * cannot prove the entry works. On 2026-09-03 two of them did not — |
| 7 | * Copy threw inside the vendored grid and Cut silently no-opped — and |
| 8 | * the guard was green the whole time. |
| 9 | * |
| 10 | * It prints one line per item: LINK, FORM, DID or NOOP. A NOOP on a |
| 11 | * [data-cmd] item is the failure this exists to surface. |
| 12 | * |
| 13 | * Not part of `make test`: it needs a browser and a running instance, |
| 14 | * neither of which CI has. See drive.mjs for the full recipe. |
| 15 | * |
| 16 | * SHEETS_SOCK=$D/s.sock SHEETS_LOG=$D/s.log \ |
| 17 | * PLAYWRIGHT=/path/to/playwright/index.js node tools/drive/menus.mjs |
| 18 | */ |
| 19 | import net from 'node:net'; |
| 20 | import fs from 'node:fs'; |
| 21 | const pw = await import(process.env.PLAYWRIGHT); |
| 22 | const chromium = pw.chromium || (pw.default && pw.default.chromium); |
| 23 | const SOCK=process.env.SHEETS_SOCK, LOG=process.env.SHEETS_LOG, OUT=process.env.OUT||'.'; |
| 24 | 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());}); |
| 25 | await new Promise(r=>proxy.listen(8799,'127.0.0.1',r)); |
| 26 | const base='http://127.0.0.1:8799'; |
| 27 | const browser=await chromium.launch(); |
| 28 | /* Clipboard permissions, and the reason they are here is the opposite |
| 29 | * of the reason they were here first. |
| 30 | * |
| 31 | * They were added so this script could READ the clipboard back and see |
| 32 | * that Copy had done something — without that, Copy changes no cell and |
| 33 | * reads as a dead control. But granting them also hid a real bug for a |
| 34 | * day: navigator.clipboard.writeText needs a permission a real browser |
| 35 | * does NOT grant, so the menu's Copy and Cut were rejecting for every |
| 36 | * actual user while this script reported them working. Paul found it on |
| 37 | * the live instance. |
| 38 | * |
| 39 | * So the grant stays for the reading, and menusNoPermission() below |
| 40 | * re-runs the three clipboard items in a context with NO permissions — |
| 41 | * which is the condition every real browser is in. */ |
| 42 | const ctx=await browser.newContext({viewport:{width:1440,height:900},permissions:['clipboard-read','clipboard-write']}); |
| 43 | const page=await ctx.newPage(); |
| 44 | const errors=[]; page.on('pageerror',e=>errors.push(String(e))); page.on('console',m=>{if(m.type()==='error')errors.push(m.text());}); |
| 45 | const reqs=[]; page.on('request',r=>{if(r.method()!=='GET')reqs.push(r.method()+' '+new URL(r.url()).pathname);}); |
| 46 | |
| 47 | await page.goto(`${base}/signin`); await page.fill('input[type=email]','paul@example.com'); |
| 48 | await Promise.all([page.waitForURL(/signin\?sent=1/), page.click('form[action="/signin"] button[type=submit]')]); |
| 49 | const mm=[...fs.readFileSync(LOG,'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop(); |
| 50 | await page.goto(mm[0]); await page.waitForURL(/\/sheets/); |
| 51 | // No sheets yet: Create from the top bar, then name it by renaming. |
| 52 | // The list's #title field is gone — a sheet is born "Untitled sheet". |
| 53 | if(!(await page.$('[data-sheet-list]'))){ |
| 54 | await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('[estilo-bar-actions] form[action="/sheets"] button[type=submit]')]); |
| 55 | await page.fill('[estilo-bar-title-input]','Audit'); |
| 56 | await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.press('[estilo-bar-title-input]','Enter')]); |
| 57 | } |
| 58 | else {await page.click('[data-sheet-list] [rst-row-main] a'); await page.waitForURL(/\/sheets\/\d+$/);} |
| 59 | const url=page.url(); |
| 60 | await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet()); |
| 61 | const seed=async()=>{for(const [r,c,v] of [[0,0,'10'],[1,0,'20'],[2,0,'30'],[3,0,'=SUM(A1:A3)'],[0,1,'alpha'],[1,1,'beta']]) |
| 62 | await page.evaluate(([r,c,v])=>window.SheetsGrid.write(r,c,v),[r,c,v]); await page.waitForTimeout(400);}; |
| 63 | await seed(); |
| 64 | |
| 65 | /* Snapshot: every cell's raw input plus the anchor's style. */ |
| 66 | const snap=()=>page.evaluate(()=>{const g=window.SheetsGrid;const out=[]; |
| 67 | for(let r=0;r<6;r++)for(let c=0;c<3;c++){const v=g.rawAt(r,c);if(v!=='')out.push(`${r},${c}=${v}`);} |
| 68 | const sel=g.selection(); |
| 69 | return {cells:out.join('|'), style:JSON.stringify(sel?g.styleAt(sel.r,sel.c)||{}:{}), |
| 70 | sel:sel?`${sel.r},${sel.c}-${sel.r2},${sel.c2}`:'none'};}); |
| 71 | |
| 72 | async function select(r,c,r2,c2){await page.evaluate(([r,c,r2,c2])=>window.SheetsGrid.worksheet().updateSelectionFromCoords(c,r,c2===undefined?c:c2,r2===undefined?r:r2),[r,c,r2,c2]);await page.waitForTimeout(150);} |
| 73 | |
| 74 | const items=await page.evaluate(()=>{ |
| 75 | const out=[]; |
| 76 | document.querySelectorAll('[data-menubar] > details').forEach((d,mi)=>{ |
| 77 | const menu=d.querySelector(':scope > summary').textContent.trim(); |
| 78 | d.querySelectorAll('[estilo-menu] a, [estilo-menu] button').forEach(el=>{ |
| 79 | out.push({menu, mi, label:el.textContent.trim().replace(/\s+/g,' ').slice(0,28), |
| 80 | kind:el.tagName, cmd:el.dataset.cmd||'', value:el.dataset.value!==undefined?el.dataset.value:(el.dataset.fn||''), |
| 81 | href:el.getAttribute('href')||'', inForm:!!el.closest('form'), |
| 82 | sub:(el.closest('details[rst-menu-group]')||{}).firstElementChild?.textContent?.trim()||''}); |
| 83 | }); |
| 84 | }); |
| 85 | return out;}); |
| 86 | console.log(`# ${items.length} menu items\n`); |
| 87 | |
| 88 | for (const it of items) { |
| 89 | if (it.href) { console.log(`LINK ${it.menu} > ${it.label} -> ${it.href}`); continue; } |
| 90 | if (it.inForm && !it.cmd) { console.log(`FORM ${it.menu} > ${it.label} (scriptless POST)`); continue; } |
| 91 | await page.goto(url); await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet()); |
| 92 | await page.waitForTimeout(250); |
| 93 | await select(1,0,2,0); |
| 94 | const before=await snap(); reqs.length=0; |
| 95 | await page.evaluate(async()=>{try{await navigator.clipboard.writeText('\u0000none');}catch(e){}}); |
| 96 | const clipBefore='\u0000none'; |
| 97 | const errBefore=errors.length; |
| 98 | try { |
| 99 | await page.click(`[data-menubar] > details:nth-child(${it.mi+1}) > summary`); |
| 100 | if (it.sub) await page.click(`[data-menubar] > details:nth-child(${it.mi+1}) details[rst-menu-group] >> summary:text-is(${JSON.stringify(it.sub)})`); |
| 101 | const sel=`[data-menubar] > details:nth-child(${it.mi+1}) [estilo-menu] [data-cmd="${it.cmd}"]`+ |
| 102 | (it.value?`[data-value="${it.value}"], [data-menubar] > details:nth-child(${it.mi+1}) [estilo-menu] [data-cmd="${it.cmd}"][data-fn="${it.value}"]`:''); |
| 103 | await page.click(sel+' >> nth=0', {timeout:5000}); |
| 104 | } catch(e){ console.log(`ERR ${it.menu} > ${it.label} (${String(e).split('\n')[0].slice(0,70)})`); continue; } |
| 105 | await page.waitForTimeout(600); |
| 106 | const after=await snap(); |
| 107 | const changed=[]; |
| 108 | /* A status-line message counts as having done something. Some menu |
| 109 | * items answer by saying so rather than by changing a cell — a |
| 110 | * browser that refuses a clipboard read, a paste larger than one |
| 111 | * batch — and without this they read as dead. */ |
| 112 | /* The system clipboard is where Copy's whole effect lands. Without |
| 113 | * reading it, Copy is indistinguishable from a dead control: it |
| 114 | * changes no cell, no style and no selection, and reporting that as |
| 115 | * NOOP is how a working control gets "fixed". */ |
| 116 | const clip=await page.evaluate(async()=>{try{return await navigator.clipboard.readText();}catch(e){return null;}}); |
| 117 | if(clip!==null&&clip!==clipBefore)changed.push('clipboard'); |
| 118 | const note=(await page.textContent('[data-sheet-note]').catch(()=>''))||''; |
| 119 | if(note.trim())changed.push('said:'+JSON.stringify(note.trim().slice(0,40))); |
| 120 | if(before.cells!==after.cells)changed.push('cells'); |
| 121 | if(before.style!==after.style)changed.push('style'); |
| 122 | if(before.sel!==after.sel)changed.push('selection'); |
| 123 | if(reqs.length)changed.push('posted:'+[...new Set(reqs)].join(',')); |
| 124 | const newErr=errors.slice(errBefore); |
| 125 | console.log(`${changed.length?'DID ':'NOOP '} ${it.menu} > ${it.label} [${it.cmd}${it.value?'='+it.value:''}] ${changed.join(' ')}${newErr.length?' ERR:'+newErr.join(';'):''}`); |
| 126 | } |
| 127 | |
| 128 | /* The real-browser condition: no clipboard permission at all. |
| 129 | * |
| 130 | * Copy and Cut must still work here, because they go through |
| 131 | * execCommand, which needs no permission and fires the same copy event |
| 132 | * the keyboard does. Paste cannot — readText is permissioned — and must |
| 133 | * SAY so rather than failing silently. */ |
| 134 | console.log('\n# the three clipboard items with NO permission (what a real browser gives)'); |
| 135 | { |
| 136 | /* Same session, no clipboard permission — the storage state carries |
| 137 | * the sign-in so this is the SAME user on the SAME sheet, differing |
| 138 | * only in the one thing under test. */ |
| 139 | const bare = await browser.newContext({viewport:{width:1440,height:900}, |
| 140 | storageState: await ctx.storageState()}); |
| 141 | const p2 = await bare.newPage(); |
| 142 | await p2.goto(url); |
| 143 | await p2.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet()); |
| 144 | await p2.evaluate(()=>window.SheetsGrid.write(0,1,'=A1*2')); |
| 145 | await p2.waitForTimeout(500); |
| 146 | await p2.click(`[data-sheet-grid] tbody tr:nth-child(1) td:nth-child(3)`); |
| 147 | await p2.waitForTimeout(200); |
| 148 | let wrote = 0; |
| 149 | await p2.evaluate(()=>{window.__wt=0; const o=navigator.clipboard.writeText.bind(navigator.clipboard); |
| 150 | navigator.clipboard.writeText=function(t){window.__wt++; return o(t);};}); |
| 151 | await p2.click('[data-menubar] > details:nth-child(2) > summary'); |
| 152 | await p2.click('[data-cmd="copy"]'); |
| 153 | await p2.waitForTimeout(400); |
| 154 | wrote = await p2.evaluate(()=>window.__wt); |
| 155 | const note = ((await p2.textContent('[data-sheet-note]'))||'').trim(); |
| 156 | console.log(` menu Copy with no permission: writeText calls=${wrote} note=${JSON.stringify(note)}`); |
| 157 | console.log(wrote===0 && !note |
| 158 | ? ' ok Copy works with no permission (execCommand carried it)' |
| 159 | : ' FAIL Copy fell back to the permissioned API — this is what broke on the live instance'); |
| 160 | await bare.close(); |
| 161 | } |
| 162 | |
| 163 | console.log('\nERRORS:',JSON.stringify([...new Set(errors)])); |
| 164 | await browser.close(); proxy.close(); process.exit(0); |
| 165 | |