/* Click every menu item and record what it changed. * * The sibling of chrome.mjs, and the one that answers a question no Go * test can: does this control DO anything. TestEveryMenuCommandIsImplemented * proves a `data-cmd` has an entry in commands.js's dispatch table; it * cannot prove the entry works. On 2026-09-03 two of them did not — * Copy threw inside the vendored grid and Cut silently no-opped — and * the guard was green the whole time. * * It prints one line per item: LINK, FORM, DID or NOOP. A NOOP on a * [data-cmd] item is the failure this exists to surface. * * Not part of `make test`: it needs a browser and a running instance, * neither of which CI has. See drive.mjs for the full recipe. * * SHEETS_SOCK=$D/s.sock SHEETS_LOG=$D/s.log \ * PLAYWRIGHT=/path/to/playwright/index.js node tools/drive/menus.mjs */ import net from 'node:net'; import fs from 'node:fs'; const pw = await import(process.env.PLAYWRIGHT); const chromium = pw.chromium || (pw.default && pw.default.chromium); const SOCK=process.env.SHEETS_SOCK, LOG=process.env.SHEETS_LOG, OUT=process.env.OUT||'.'; 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(8799,'127.0.0.1',r)); const base='http://127.0.0.1:8799'; const browser=await chromium.launch(); /* Clipboard permissions, and the reason they are here is the opposite * of the reason they were here first. * * They were added so this script could READ the clipboard back and see * that Copy had done something — without that, Copy changes no cell and * reads as a dead control. But granting them also hid a real bug for a * day: navigator.clipboard.writeText needs a permission a real browser * does NOT grant, so the menu's Copy and Cut were rejecting for every * actual user while this script reported them working. Paul found it on * the live instance. * * So the grant stays for the reading, and menusNoPermission() below * re-runs the three clipboard items in a context with NO permissions — * which is the condition every real browser is in. */ const ctx=await browser.newContext({viewport:{width:1440,height:900},permissions:['clipboard-read','clipboard-write']}); const page=await ctx.newPage(); const errors=[]; page.on('pageerror',e=>errors.push(String(e))); page.on('console',m=>{if(m.type()==='error')errors.push(m.text());}); const reqs=[]; page.on('request',r=>{if(r.method()!=='GET')reqs.push(r.method()+' '+new URL(r.url()).pathname);}); await page.goto(`${base}/signin`); await page.fill('input[type=email]','paul@example.com'); await Promise.all([page.waitForURL(/signin\?sent=1/), page.click('form[action="/signin"] button[type=submit]')]); const mm=[...fs.readFileSync(LOG,'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop(); await page.goto(mm[0]); await page.waitForURL(/\/sheets/); // No sheets yet: Create from the top bar, then name it by renaming. // The list's #title field is gone — a sheet is born "Untitled sheet". if(!(await page.$('[data-sheet-list]'))){ await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('[estilo-bar-actions] form[action="/sheets"] button[type=submit]')]); await page.fill('[estilo-bar-title-input]','Audit'); await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.press('[estilo-bar-title-input]','Enter')]); } else {await page.click('[data-sheet-list] [rst-row-main] a'); await page.waitForURL(/\/sheets\/\d+$/);} const url=page.url(); await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet()); 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']]) await page.evaluate(([r,c,v])=>window.SheetsGrid.write(r,c,v),[r,c,v]); await page.waitForTimeout(400);}; await seed(); /* Snapshot: every cell's raw input plus the anchor's style. */ const snap=()=>page.evaluate(()=>{const g=window.SheetsGrid;const out=[]; 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}`);} const sel=g.selection(); return {cells:out.join('|'), style:JSON.stringify(sel?g.styleAt(sel.r,sel.c)||{}:{}), sel:sel?`${sel.r},${sel.c}-${sel.r2},${sel.c2}`:'none'};}); 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);} const items=await page.evaluate(()=>{ const out=[]; document.querySelectorAll('[data-menubar] > details').forEach((d,mi)=>{ const menu=d.querySelector(':scope > summary').textContent.trim(); d.querySelectorAll('[estilo-menu] a, [estilo-menu] button').forEach(el=>{ out.push({menu, mi, label:el.textContent.trim().replace(/\s+/g,' ').slice(0,28), kind:el.tagName, cmd:el.dataset.cmd||'', value:el.dataset.value!==undefined?el.dataset.value:(el.dataset.fn||''), href:el.getAttribute('href')||'', inForm:!!el.closest('form'), sub:(el.closest('details[rst-menu-group]')||{}).firstElementChild?.textContent?.trim()||''}); }); }); return out;}); console.log(`# ${items.length} menu items\n`); for (const it of items) { if (it.href) { console.log(`LINK ${it.menu} > ${it.label} -> ${it.href}`); continue; } if (it.inForm && !it.cmd) { console.log(`FORM ${it.menu} > ${it.label} (scriptless POST)`); continue; } await page.goto(url); await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet()); await page.waitForTimeout(250); await select(1,0,2,0); const before=await snap(); reqs.length=0; await page.evaluate(async()=>{try{await navigator.clipboard.writeText('\u0000none');}catch(e){}}); const clipBefore='\u0000none'; const errBefore=errors.length; try { await page.click(`[data-menubar] > details:nth-child(${it.mi+1}) > summary`); 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)})`); const sel=`[data-menubar] > details:nth-child(${it.mi+1}) [estilo-menu] [data-cmd="${it.cmd}"]`+ (it.value?`[data-value="${it.value}"], [data-menubar] > details:nth-child(${it.mi+1}) [estilo-menu] [data-cmd="${it.cmd}"][data-fn="${it.value}"]`:''); await page.click(sel+' >> nth=0', {timeout:5000}); } catch(e){ console.log(`ERR ${it.menu} > ${it.label} (${String(e).split('\n')[0].slice(0,70)})`); continue; } await page.waitForTimeout(600); const after=await snap(); const changed=[]; /* A status-line message counts as having done something. Some menu * items answer by saying so rather than by changing a cell — a * browser that refuses a clipboard read, a paste larger than one * batch — and without this they read as dead. */ /* The system clipboard is where Copy's whole effect lands. Without * reading it, Copy is indistinguishable from a dead control: it * changes no cell, no style and no selection, and reporting that as * NOOP is how a working control gets "fixed". */ const clip=await page.evaluate(async()=>{try{return await navigator.clipboard.readText();}catch(e){return null;}}); if(clip!==null&&clip!==clipBefore)changed.push('clipboard'); const note=(await page.textContent('[data-sheet-note]').catch(()=>''))||''; if(note.trim())changed.push('said:'+JSON.stringify(note.trim().slice(0,40))); if(before.cells!==after.cells)changed.push('cells'); if(before.style!==after.style)changed.push('style'); if(before.sel!==after.sel)changed.push('selection'); if(reqs.length)changed.push('posted:'+[...new Set(reqs)].join(',')); const newErr=errors.slice(errBefore); console.log(`${changed.length?'DID ':'NOOP '} ${it.menu} > ${it.label} [${it.cmd}${it.value?'='+it.value:''}] ${changed.join(' ')}${newErr.length?' ERR:'+newErr.join(';'):''}`); } /* The real-browser condition: no clipboard permission at all. * * Copy and Cut must still work here, because they go through * execCommand, which needs no permission and fires the same copy event * the keyboard does. Paste cannot — readText is permissioned — and must * SAY so rather than failing silently. */ console.log('\n# the three clipboard items with NO permission (what a real browser gives)'); { /* Same session, no clipboard permission — the storage state carries * the sign-in so this is the SAME user on the SAME sheet, differing * only in the one thing under test. */ const bare = await browser.newContext({viewport:{width:1440,height:900}, storageState: await ctx.storageState()}); const p2 = await bare.newPage(); await p2.goto(url); await p2.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet()); await p2.evaluate(()=>window.SheetsGrid.write(0,1,'=A1*2')); await p2.waitForTimeout(500); await p2.click(`[data-sheet-grid] tbody tr:nth-child(1) td:nth-child(3)`); await p2.waitForTimeout(200); let wrote = 0; await p2.evaluate(()=>{window.__wt=0; const o=navigator.clipboard.writeText.bind(navigator.clipboard); navigator.clipboard.writeText=function(t){window.__wt++; return o(t);};}); await p2.click('[data-menubar] > details:nth-child(2) > summary'); await p2.click('[data-cmd="copy"]'); await p2.waitForTimeout(400); wrote = await p2.evaluate(()=>window.__wt); const note = ((await p2.textContent('[data-sheet-note]'))||'').trim(); console.log(` menu Copy with no permission: writeText calls=${wrote} note=${JSON.stringify(note)}`); console.log(wrote===0 && !note ? ' ok Copy works with no permission (execCommand carried it)' : ' FAIL Copy fell back to the permissioned API — this is what broke on the live instance'); await bare.close(); } console.log('\nERRORS:',JSON.stringify([...new Set(errors)])); await browser.close(); proxy.close(); process.exit(0);