| 1 | // The grid library's own actions must not change the grid behind the app's |
| 2 | // back (B7 and its review). Each of these acts on the library's model only, |
| 3 | // never the server's, and after a local row delete or row move every later |
| 4 | // write lands at shifted coordinates: |
| 5 | // - right-click: the stock menu ("Delete selected rows", "Add comments", |
| 6 | // "Save as...", "About"). contextMenu returns false: browser menu instead. |
| 7 | // - row header + Delete, column header + Delete: confirm() then deleteRow / |
| 8 | // deleteColumn locally (allowDeleteRow / allowDeleteColumn: false). |
| 9 | // - Ctrl/Cmd+S: the library's CSV download(), bypassing export (allowExport). |
| 10 | // - dragging a row header's edge: moveRow locally (rowDrag: false). |
| 11 | // Real server-rendered page and assets, mocked transport; no live data. |
| 12 | // SHEETS_MENU_FIXTURE=/tmp/menus.json go test ./internal/sheets -run TestBrowserMenuFixture -count=1 |
| 13 | // PLAYWRIGHT=/path/to/playwright/index.mjs SHEETS_MENU_FIXTURE=/tmp/menus.json node tools/drive/contextmenu.mjs |
| 14 | import assert from 'node:assert/strict'; |
| 15 | import fs from 'node:fs'; |
| 16 | const pw = await import(process.env.PLAYWRIGHT || 'playwright'); |
| 17 | const fixture = JSON.parse(fs.readFileSync(process.env.SHEETS_MENU_FIXTURE)); |
| 18 | const origin = 'https://sheets.test'; |
| 19 | const failures = []; |
| 20 | for (const name of ['chromium', 'webkit']) { |
| 21 | const options = name === 'webkit' && process.env.WEBKIT_EXECUTABLE_PATH ? {executablePath:process.env.WEBKIT_EXECUTABLE_PATH} : {}; |
| 22 | const browser = await pw[name].launch(options); |
| 23 | try { |
| 24 | const context = await browser.newContext({viewport:{width:1440,height:1000}, acceptDownloads:true}); |
| 25 | const page = await context.newPage(); |
| 26 | let errors = [], writes = [], dialogs = [], downloads = []; |
| 27 | page.on('pageerror', e=>errors.push(e.message)); |
| 28 | // Accept every confirm(): dismissing would hide the delete it guards. |
| 29 | page.on('dialog', d=>{ dialogs.push(d.message()); d.accept(); }); |
| 30 | page.on('download', d=>downloads.push(d.suggestedFilename())); |
| 31 | await page.route(origin+'/**', async route => { |
| 32 | const req = route.request(), path = new URL(req.url()).pathname; |
| 33 | if (path in fixture.assets) return route.fulfill({body:fixture.assets[path],contentType: |
| 34 | path.endsWith('.js')?'application/javascript':path.endsWith('.css')?'text/css':'image/svg+xml'}); |
| 35 | if (path.endsWith('/stream')) return route.fulfill({body:'',contentType:'text/event-stream'}); |
| 36 | if (req.method() !== 'GET') writes.push(path); |
| 37 | if (path.startsWith('/api/')) return route.fulfill({json:{version:1,cells:[ |
| 38 | {r:0,c:0,raw:'10',display:'10'},{r:1,c:0,raw:'20',display:'20'},{r:2,c:0,raw:'30',display:'30'}, |
| 39 | {r:0,c:1,raw:'b1',display:'b1'}]}}); |
| 40 | return route.fulfill({body:fixture.html,contentType:'text/html'}); |
| 41 | }); |
| 42 | const shape = () => page.evaluate(() => { |
| 43 | const t = document.querySelector('[data-sheet-grid] .jss_worksheet'); |
| 44 | const at = (x, y) => t.querySelector(`tbody td[data-x="${x}"][data-y="${y}"]`)?.textContent ?? null; |
| 45 | return {rows: t.querySelectorAll(':scope > tbody > tr').length, |
| 46 | cols: t.querySelectorAll(':scope > thead > tr:last-child > td[data-x]').length, |
| 47 | a: [at(0,0), at(0,1), at(0,2)], b1: at(1,0)}; |
| 48 | }); |
| 49 | async function check(label, act) { |
| 50 | await page.goto(origin+'/sheets/1'); |
| 51 | await page.waitForFunction(()=>window.SheetsGrid?.worksheet()); |
| 52 | await page.locator('td[data-x="0"][data-y="0"]').click(); |
| 53 | const before = await shape(); |
| 54 | errors = []; writes = []; dialogs = []; downloads = []; |
| 55 | try { |
| 56 | await act(); |
| 57 | await page.waitForTimeout(300); |
| 58 | const after = await shape(); |
| 59 | assert.deepEqual(after, before, `${name} ${label}: the grid changed locally`); |
| 60 | assert.deepEqual(dialogs, [], `${name} ${label}: the library asked to confirm`); |
| 61 | assert.deepEqual(downloads, [], `${name} ${label}: the library downloaded a file`); |
| 62 | assert.deepEqual(writes, [], `${name} ${label}: sent writes`); |
| 63 | assert.deepEqual(errors, [], `${name} ${label}: page errors`); |
| 64 | console.log(`${name}: ok ${label}`); |
| 65 | } catch (e) { failures.push(e.message); console.log(`${name}: FAIL ${label}: ${e.message.split('\n')[0]}`); } |
| 66 | } |
| 67 | const cell = page.locator('td[data-x="0"][data-y="0"]'); |
| 68 | await check('right-click opens no library menu', async () => { |
| 69 | await cell.click({button:'right'}); |
| 70 | const shown = await page.evaluate(()=>[...document.querySelectorAll('.jcontextmenu')] |
| 71 | .filter(m=>getComputedStyle(m).display!=='none' && m.offsetHeight>0 && m.children.length>0).map(m=>m.innerText)); |
| 72 | assert.deepEqual(shown, [], `${name}: the library context menu opened: ${JSON.stringify(shown)}`); |
| 73 | for (const label of ['Delete selected rows','Add comments','Save as','About']) |
| 74 | assert.equal(await page.getByText(label).filter({visible:true}).count(), 0, `${name}: "${label}" is on screen`); |
| 75 | await page.keyboard.press('Escape'); |
| 76 | // Nothing may claim the event: the browser's own menu should show. |
| 77 | const prevented = await cell.evaluate(td => { |
| 78 | const r = td.getBoundingClientRect(); |
| 79 | const e = new MouseEvent('contextmenu', {bubbles:true, cancelable:true, button:2, clientX:r.x+5, clientY:r.y+5}); |
| 80 | td.dispatchEvent(e); return e.defaultPrevented; |
| 81 | }); |
| 82 | assert.equal(prevented, false, `${name}: something swallowed the right-click`); |
| 83 | }); |
| 84 | // Playwright's WebKit treats an unclaimed Backspace as history-back and |
| 85 | // leaves the page (on main too), so Backspace is driven in Chromium only; |
| 86 | // the library's handler takes Delete and Backspace down one path. |
| 87 | for (const key of name === 'webkit' ? ['Delete'] : ['Delete', 'Backspace']) { |
| 88 | await check(`row header + ${key} deletes nothing`, async () => { |
| 89 | await page.locator('td.jss_row[data-y="1"]').click(); |
| 90 | await page.keyboard.press(key); |
| 91 | }); |
| 92 | await check(`column header + ${key} deletes nothing`, async () => { |
| 93 | await page.locator('thead td[data-x="0"]').click(); |
| 94 | await page.keyboard.press(key); |
| 95 | }); |
| 96 | } |
| 97 | for (const combo of ['Control+s', 'Meta+s']) { |
| 98 | await check(`${combo} downloads nothing`, async () => { await page.keyboard.press(combo); }); |
| 99 | } |
| 100 | await check('dragging a row header moves no row', async () => { |
| 101 | const from = await page.locator('td.jss_row[data-y="0"]').boundingBox(); |
| 102 | const to = await page.locator('td.jss_row[data-y="2"]').boundingBox(); |
| 103 | await page.mouse.move(from.x + from.width - 3, from.y + from.height / 2); |
| 104 | await page.mouse.down(); |
| 105 | await page.mouse.move(to.x + to.width - 3, to.y + to.height / 2, {steps: 8}); |
| 106 | await page.mouse.up(); |
| 107 | }); |
| 108 | } finally { |
| 109 | await browser.close(); |
| 110 | } |
| 111 | } |
| 112 | if (failures.length) { console.error(`${failures.length} failure(s)`); process.exit(1); } |
| 113 | |