| 1 | // Real server-rendered menus and assets, mocked transport; no live user data. |
| 2 | // SHEETS_MENU_FIXTURE=/tmp/menus.json go test ./internal/sheets -run TestBrowserMenuFixture |
| 3 | // PLAYWRIGHT=/path/to/playwright/index.mjs SHEETS_MENU_FIXTURE=/tmp/menus.json node tools/drive/menu-actions.mjs |
| 4 | import assert from 'node:assert/strict'; |
| 5 | import fs from 'node:fs'; |
| 6 | const pw = await import(process.env.PLAYWRIGHT || 'playwright'); |
| 7 | const fixture = JSON.parse(fs.readFileSync(process.env.SHEETS_MENU_FIXTURE)); |
| 8 | const origin = 'https://sheets.test'; |
| 9 | for (const name of ['chromium', 'webkit']) { |
| 10 | const options = name === 'webkit' && process.env.WEBKIT_EXECUTABLE_PATH ? {executablePath:process.env.WEBKIT_EXECUTABLE_PATH} : {}; |
| 11 | const browser = await pw[name].launch(options); |
| 12 | try { |
| 13 | const page = await browser.newPage({viewport:{width:1440,height:1000}}); |
| 14 | let requests = [], errors = []; |
| 15 | page.on('pageerror', e=>errors.push(e.message)); |
| 16 | // Force the Clipboard API fallback so command dispatch is deterministic in |
| 17 | // both engines. Real clipboard behavior is covered by clipboard.mjs. |
| 18 | await page.addInitScript(()=>{ |
| 19 | window.menuClipboard = ''; |
| 20 | document.execCommand = ()=>false; |
| 21 | Object.defineProperty(navigator,'clipboard',{value:{ |
| 22 | writeText:async text=>{window.menuClipboard=text;}, |
| 23 | readText:async ()=>'pasted' |
| 24 | }}); |
| 25 | }); |
| 26 | await page.route(origin+'/**',async route=>{ |
| 27 | const req=route.request(),path=new URL(req.url()).pathname; |
| 28 | if(path in fixture.assets) return route.fulfill({body:fixture.assets[path],contentType: |
| 29 | path.endsWith('.js')?'application/javascript':path.endsWith('.css')?'text/css':'image/svg+xml'}); |
| 30 | if(path.endsWith('/stream'))return route.fulfill({body:'',contentType:'text/event-stream'}); |
| 31 | requests.push({method:req.method(),path,body:req.postData()}); |
| 32 | if(path.startsWith('/api/'))return route.fulfill({json:{version:1,cells:[ |
| 33 | {r:0,c:0,raw:'10',display:'10'},{r:1,c:0,raw:'20',display:'20'}, |
| 34 | {r:2,c:0,raw:'30',display:'30'}]}}); |
| 35 | return route.fulfill({body:fixture.html,contentType:'text/html'}); |
| 36 | }); |
| 37 | async function load() { |
| 38 | await page.goto(origin+'/sheets/1'); |
| 39 | await page.waitForFunction(()=>window.SheetsGrid?.worksheet()); |
| 40 | await page.locator('td[data-x="0"][data-y="0"]').click(); |
| 41 | await page.evaluate(()=>SheetsGrid.worksheet().updateSelectionFromCoords(0,0,1,1)); |
| 42 | requests=[]; |
| 43 | } |
| 44 | await load(); |
| 45 | const inventory=await page.locator('[data-menubar] a, [data-menubar] button').evaluateAll(els=>els.map((el,index)=>{ |
| 46 | const top=el.closest('[data-menubar] > details'), sub=el.closest('[rst-menu-group]'); |
| 47 | return {index,label:el.textContent.trim(),cmd:el.dataset.cmd,href:el.getAttribute('href'), |
| 48 | action:el.form?.getAttribute('action'),axis:el.form?.dataset.axis, |
| 49 | menu:[...top.parentElement.children].indexOf(top), |
| 50 | sub:sub?[...top.querySelectorAll('[rst-menu-group]')].indexOf(sub):null}; |
| 51 | })); |
| 52 | for(const item of inventory) { |
| 53 | await load(); |
| 54 | if(item.cmd==='fn')await page.evaluate(()=>SheetsGrid.worksheet().updateSelectionFromCoords(0,3,0,3)); |
| 55 | const menu=page.locator('[data-menubar] > details').nth(item.menu); |
| 56 | await menu.locator(':scope > summary').click(); |
| 57 | if(item.sub!==null)await menu.locator('[rst-menu-group]').nth(item.sub).locator(':scope > summary').click(); |
| 58 | if(item.axis)await menu.locator(`[data-axis="${item.axis}"] input`).fill('48'); |
| 59 | const button=page.locator('[data-menubar] a, [data-menubar] button').nth(item.index); |
| 60 | if(item.href || item.action) { |
| 61 | const method=item.action?'POST':'GET',path=item.action||item.href; |
| 62 | const sent=page.waitForRequest(req=>req.method()===method&&new URL(req.url()).pathname===path,{timeout:3000}); |
| 63 | await button.click();await sent; |
| 64 | } else { |
| 65 | const suffix=item.axis || item.cmd==='autofit-columns'?'/size':item.cmd==='paste'?'/cells/batch': |
| 66 | ['fn','clear','fill-down','fill-right'].includes(item.cmd)?'/cells':'/format'; |
| 67 | const posted=['copy','cut','select-all'].includes(item.cmd)?null: |
| 68 | page.waitForRequest(req=>req.method()==='POST'&&new URL(req.url()).pathname.endsWith(suffix),{timeout:3000}); |
| 69 | await button.click(); |
| 70 | if(['copy','cut'].includes(item.cmd)) { |
| 71 | await page.waitForFunction(()=>window.menuClipboard!== '',{},{timeout:3000}); |
| 72 | assert.equal(requests.filter(r=>r.method==='POST').length,0,item.label+' must not write cells'); |
| 73 | } else if(item.cmd==='select-all') { |
| 74 | assert.equal(await page.evaluate(()=>SheetsGrid.selection().r2===SheetsGrid.rows-1&&SheetsGrid.selection().c2===SheetsGrid.cols-1),true); |
| 75 | } else { |
| 76 | await posted; |
| 77 | assert.ok(requests.some(r=>r.method==='POST'&&r.path.endsWith(suffix)),`${name}: ${item.label} must POST ${suffix}`); |
| 78 | } |
| 79 | } |
| 80 | console.log(`${name}: OK ${item.label.replace(/\s+/g,' ')}`); |
| 81 | } |
| 82 | // Keyboard field editing must not be swallowed by menu navigation. |
| 83 | await load(); |
| 84 | const format=page.locator('[data-menubar] > details').filter({has:page.locator(':scope > summary', {hasText:'Format'})}); |
| 85 | await format.locator(':scope > summary').click(); |
| 86 | const width=page.locator('#colw');await width.fill('48');await width.press('ArrowUp'); |
| 87 | assert.equal(await width.inputValue(),'49'); |
| 88 | await width.press('Enter'); |
| 89 | assert.ok(requests.some(r=>r.path.endsWith('/size')&&JSON.parse(r.body).size===49)); |
| 90 | await load(); |
| 91 | const file=page.locator('[data-menubar] > details').first(); |
| 92 | await file.locator('summary').press('Enter'); |
| 93 | const submitted=page.waitForRequest(req=>req.method()==='POST'&&new URL(req.url()).pathname==='/sheets'); |
| 94 | await page.keyboard.press('Enter');await submitted; |
| 95 | await load();await file.locator('summary').click();await page.keyboard.press('Escape'); |
| 96 | assert.equal(await file.getAttribute('open'),null); |
| 97 | await file.locator('summary').click();await page.locator('[data-formula-input]').click(); |
| 98 | assert.equal(await file.getAttribute('open'),null); |
| 99 | assert.deepEqual(errors,[]); |
| 100 | console.log(`${name}: ${inventory.length} actions, keyboard submission, resize, Escape, and outside click passed`); |
| 101 | } finally {await browser.close();} |
| 102 | } |
| 103 | |