oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

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 */
19import net from 'node:net';
20import fs from 'node:fs';
21const pw = await import(process.env.PLAYWRIGHT);
22const chromium = pw.chromium || (pw.default && pw.default.chromium);
23const SOCK=process.env.SHEETS_SOCK, LOG=process.env.SHEETS_LOG, OUT=process.env.OUT||'.';
24const 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());});
25await new Promise(r=>proxy.listen(8799,'127.0.0.1',r));
26const base='http://127.0.0.1:8799';
27const 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. */
42const ctx=await browser.newContext({viewport:{width:1440,height:900},permissions:['clipboard-read','clipboard-write']});
43const page=await ctx.newPage();
44const errors=[]; page.on('pageerror',e=>errors.push(String(e))); page.on('console',m=>{if(m.type()==='error')errors.push(m.text());});
45const reqs=[]; page.on('request',r=>{if(r.method()!=='GET')reqs.push(r.method()+' '+new URL(r.url()).pathname);});
46
47await page.goto(`${base}/signin`); await page.fill('input[type=email]','paul@example.com');
48await Promise.all([page.waitForURL(/signin\?sent=1/), page.click('form[action="/signin"] button[type=submit]')]);
49const mm=[...fs.readFileSync(LOG,'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop();
50await 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".
53if(!(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}
58else {await page.click('[data-sheet-list] [rst-row-main] a'); await page.waitForURL(/\/sheets\/\d+$/);}
59const url=page.url();
60await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet());
61const 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);};
63await seed();
64
65/* Snapshot: every cell's raw input plus the anchor's style. */
66const 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
72async 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
74const 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;});
86console.log(`# ${items.length} menu items\n`);
87
88for (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. */
134console.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
163console.log('\nERRORS:',JSON.stringify([...new Set(errors)]));
164await browser.close(); proxy.close(); process.exit(0);
165