| new file mode 100644 |
| index 0000000..953747c |
| --- /dev/null |
| +++ b/tools/drive/menu-actions.mjs |
| @@ -0,0 +1,102 @@ |
| +// 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();} |
| +} |