// Real server-rendered menus and assets, mocked transport; no live user data. // SHEETS_MENU_FIXTURE=/tmp/menus.json go test ./internal/sheets -run TestBrowserMenuFixture // PLAYWRIGHT=/path/to/playwright/index.mjs SHEETS_MENU_FIXTURE=/tmp/menus.json node tools/drive/menu-actions.mjs import assert from 'node:assert/strict'; import fs from 'node:fs'; const pw = await import(process.env.PLAYWRIGHT || 'playwright'); const fixture = JSON.parse(fs.readFileSync(process.env.SHEETS_MENU_FIXTURE)); const origin = 'https://sheets.test'; for (const name of ['chromium', 'webkit']) { const options = name === 'webkit' && process.env.WEBKIT_EXECUTABLE_PATH ? {executablePath:process.env.WEBKIT_EXECUTABLE_PATH} : {}; const browser = await pw[name].launch(options); try { const page = await browser.newPage({viewport:{width:1440,height:1000}}); let requests = [], errors = []; page.on('pageerror', e=>errors.push(e.message)); // Force the Clipboard API fallback so command dispatch is deterministic in // both engines. Real clipboard behavior is covered by clipboard.mjs. await page.addInitScript(()=>{ window.menuClipboard = ''; document.execCommand = ()=>false; Object.defineProperty(navigator,'clipboard',{value:{ writeText:async text=>{window.menuClipboard=text;}, readText:async ()=>'pasted' }}); }); await page.route(origin+'/**',async route=>{ const req=route.request(),path=new URL(req.url()).pathname; if(path in fixture.assets) return route.fulfill({body:fixture.assets[path],contentType: path.endsWith('.js')?'application/javascript':path.endsWith('.css')?'text/css':'image/svg+xml'}); if(path.endsWith('/stream'))return route.fulfill({body:'',contentType:'text/event-stream'}); requests.push({method:req.method(),path,body:req.postData()}); if(path.startsWith('/api/'))return route.fulfill({json:{version:1,cells:[ {r:0,c:0,raw:'10',display:'10'},{r:1,c:0,raw:'20',display:'20'}, {r:2,c:0,raw:'30',display:'30'}]}}); return route.fulfill({body:fixture.html,contentType:'text/html'}); }); async function load() { await page.goto(origin+'/sheets/1'); await page.waitForFunction(()=>window.SheetsGrid?.worksheet()); await page.locator('td[data-x="0"][data-y="0"]').click(); await page.evaluate(()=>SheetsGrid.worksheet().updateSelectionFromCoords(0,0,1,1)); requests=[]; } await load(); const inventory=await page.locator('[data-menubar] a, [data-menubar] button').evaluateAll(els=>els.map((el,index)=>{ const top=el.closest('[data-menubar] > details'), sub=el.closest('[rst-menu-group]'); return {index,label:el.textContent.trim(),cmd:el.dataset.cmd,href:el.getAttribute('href'), action:el.form?.getAttribute('action'),axis:el.form?.dataset.axis, menu:[...top.parentElement.children].indexOf(top), sub:sub?[...top.querySelectorAll('[rst-menu-group]')].indexOf(sub):null}; })); for(const item of inventory) { await load(); if(item.cmd==='fn')await page.evaluate(()=>SheetsGrid.worksheet().updateSelectionFromCoords(0,3,0,3)); const menu=page.locator('[data-menubar] > details').nth(item.menu); await menu.locator(':scope > summary').click(); if(item.sub!==null)await menu.locator('[rst-menu-group]').nth(item.sub).locator(':scope > summary').click(); if(item.axis)await menu.locator(`[data-axis="${item.axis}"] input`).fill('48'); const button=page.locator('[data-menubar] a, [data-menubar] button').nth(item.index); if(item.href || item.action) { const method=item.action?'POST':'GET',path=item.action||item.href; const sent=page.waitForRequest(req=>req.method()===method&&new URL(req.url()).pathname===path,{timeout:3000}); await button.click();await sent; } else { const suffix=item.axis || item.cmd==='autofit-columns'?'/size':item.cmd==='paste'?'/cells/batch': ['fn','clear','fill-down','fill-right'].includes(item.cmd)?'/cells':'/format'; const posted=['copy','cut','select-all'].includes(item.cmd)?null: page.waitForRequest(req=>req.method()==='POST'&&new URL(req.url()).pathname.endsWith(suffix),{timeout:3000}); await button.click(); if(['copy','cut'].includes(item.cmd)) { await page.waitForFunction(()=>window.menuClipboard!== '',{},{timeout:3000}); assert.equal(requests.filter(r=>r.method==='POST').length,0,item.label+' must not write cells'); } else if(item.cmd==='select-all') { assert.equal(await page.evaluate(()=>SheetsGrid.selection().r2===SheetsGrid.rows-1&&SheetsGrid.selection().c2===SheetsGrid.cols-1),true); } else { await posted; assert.ok(requests.some(r=>r.method==='POST'&&r.path.endsWith(suffix)),`${name}: ${item.label} must POST ${suffix}`); } } console.log(`${name}: OK ${item.label.replace(/\s+/g,' ')}`); } // Keyboard field editing must not be swallowed by menu navigation. await load(); const format=page.locator('[data-menubar] > details').filter({has:page.locator(':scope > summary', {hasText:'Format'})}); await format.locator(':scope > summary').click(); const width=page.locator('#colw');await width.fill('48');await width.press('ArrowUp'); assert.equal(await width.inputValue(),'49'); await width.press('Enter'); assert.ok(requests.some(r=>r.path.endsWith('/size')&&JSON.parse(r.body).size===49)); await load(); const file=page.locator('[data-menubar] > details').first(); await file.locator('summary').press('Enter'); const submitted=page.waitForRequest(req=>req.method()==='POST'&&new URL(req.url()).pathname==='/sheets'); await page.keyboard.press('Enter');await submitted; await load();await file.locator('summary').click();await page.keyboard.press('Escape'); assert.equal(await file.getAttribute('open'),null); await file.locator('summary').click();await page.locator('[data-formula-input]').click(); assert.equal(await file.getAttribute('open'),null); assert.deepEqual(errors,[]); console.log(`${name}: ${inventory.length} actions, keyboard submission, resize, Escape, and outside click passed`); } finally {await browser.close();} }