| index 38eaee9..25b19bd 100644 |
| --- a/tools/drive/contextmenu.mjs |
| +++ b/tools/drive/contextmenu.mjs |
| @@ -1,8 +1,13 @@ |
| -// Right-clicking a cell must not open the grid library's stock context menu |
| -// (B7). Its items ("Delete selected rows", "Add comments", "Copy...", |
| -// "Paste...", "Save as...", "About") act on the library's own model and |
| -// bypass the app's write, comment and export paths. The app configures |
| -// contextMenu to return false, so the browser's own menu shows instead. |
| +// The grid library's own actions must not change the grid behind the app's |
| +// back (B7 and its review). Each of these acts on the library's model only, |
| +// never the server's, and after a local row delete or row move every later |
| +// write lands at shifted coordinates: |
| +// - right-click: the stock menu ("Delete selected rows", "Add comments", |
| +// "Save as...", "About"). contextMenu returns false: browser menu instead. |
| +// - row header + Delete, column header + Delete: confirm() then deleteRow / |
| +// deleteColumn locally (allowDeleteRow / allowDeleteColumn: false). |
| +// - Ctrl/Cmd+S: the library's CSV download(), bypassing export (allowExport). |
| +// - dragging a row header's edge: moveRow locally (rowDrag: false). |
| // Real server-rendered page and assets, mocked transport; no live data. |
| // SHEETS_MENU_FIXTURE=/tmp/menus.json go test ./internal/sheets -run TestBrowserMenuFixture -count=1 |
| // PLAYWRIGHT=/path/to/playwright/index.mjs SHEETS_MENU_FIXTURE=/tmp/menus.json node tools/drive/contextmenu.mjs |
| @@ -11,13 +16,18 @@ 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'; |
| +const failures = []; |
| 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}}); |
| - const errors = []; let writes = []; |
| + const context = await browser.newContext({viewport:{width:1440,height:1000}, acceptDownloads:true}); |
| + const page = await context.newPage(); |
| + let errors = [], writes = [], dialogs = [], downloads = []; |
| page.on('pageerror', e=>errors.push(e.message)); |
| + // Accept every confirm(): dismissing would hide the delete it guards. |
| + page.on('dialog', d=>{ dialogs.push(d.message()); d.accept(); }); |
| + page.on('download', d=>downloads.push(d.suggestedFilename())); |
| 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: |
| @@ -25,33 +35,78 @@ for (const name of ['chromium', 'webkit']) { |
| if (path.endsWith('/stream')) return route.fulfill({body:'',contentType:'text/event-stream'}); |
| if (req.method() !== 'GET') writes.push(path); |
| 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:0,c:0,raw:'10',display:'10'},{r:1,c:0,raw:'20',display:'20'},{r:2,c:0,raw:'30',display:'30'}, |
| + {r:0,c:1,raw:'b1',display:'b1'}]}}); |
| return route.fulfill({body:fixture.html,contentType:'text/html'}); |
| }); |
| - await page.goto(origin+'/sheets/1'); |
| - await page.waitForFunction(()=>window.SheetsGrid?.worksheet()); |
| + const shape = () => page.evaluate(() => { |
| + const t = document.querySelector('[data-sheet-grid] .jss_worksheet'); |
| + const at = (x, y) => t.querySelector(`tbody td[data-x="${x}"][data-y="${y}"]`)?.textContent ?? null; |
| + return {rows: t.querySelectorAll(':scope > tbody > tr').length, |
| + cols: t.querySelectorAll(':scope > thead > tr:last-child > td[data-x]').length, |
| + a: [at(0,0), at(0,1), at(0,2)], b1: at(1,0)}; |
| + }); |
| + async function check(label, act) { |
| + await page.goto(origin+'/sheets/1'); |
| + await page.waitForFunction(()=>window.SheetsGrid?.worksheet()); |
| + await page.locator('td[data-x="0"][data-y="0"]').click(); |
| + const before = await shape(); |
| + errors = []; writes = []; dialogs = []; downloads = []; |
| + try { |
| + await act(); |
| + await page.waitForTimeout(300); |
| + const after = await shape(); |
| + assert.deepEqual(after, before, `${name} ${label}: the grid changed locally`); |
| + assert.deepEqual(dialogs, [], `${name} ${label}: the library asked to confirm`); |
| + assert.deepEqual(downloads, [], `${name} ${label}: the library downloaded a file`); |
| + assert.deepEqual(writes, [], `${name} ${label}: sent writes`); |
| + assert.deepEqual(errors, [], `${name} ${label}: page errors`); |
| + console.log(`${name}: ok ${label}`); |
| + } catch (e) { failures.push(e.message); console.log(`${name}: FAIL ${label}: ${e.message.split('\n')[0]}`); } |
| + } |
| const cell = page.locator('td[data-x="0"][data-y="0"]'); |
| - await cell.click(); |
| - writes = []; |
| - await cell.click({button:'right'}); |
| - const shown = await page.evaluate(()=>[...document.querySelectorAll('.jcontextmenu')] |
| - .filter(m=>getComputedStyle(m).display!=='none' && m.offsetHeight>0 && m.children.length>0).map(m=>m.innerText)); |
| - assert.deepEqual(shown, [], `${name}: the library context menu opened: ${JSON.stringify(shown)}`); |
| - for (const label of ['Delete selected rows','Add comments','Save as','About']) { |
| - assert.equal(await page.getByText(label).filter({visible:true}).count(), 0, `${name}: "${label}" is on screen`); |
| + await check('right-click opens no library menu', async () => { |
| + await cell.click({button:'right'}); |
| + const shown = await page.evaluate(()=>[...document.querySelectorAll('.jcontextmenu')] |
| + .filter(m=>getComputedStyle(m).display!=='none' && m.offsetHeight>0 && m.children.length>0).map(m=>m.innerText)); |
| + assert.deepEqual(shown, [], `${name}: the library context menu opened: ${JSON.stringify(shown)}`); |
| + for (const label of ['Delete selected rows','Add comments','Save as','About']) |
| + assert.equal(await page.getByText(label).filter({visible:true}).count(), 0, `${name}: "${label}" is on screen`); |
| + await page.keyboard.press('Escape'); |
| + // Nothing may claim the event: the browser's own menu should show. |
| + const prevented = await cell.evaluate(td => { |
| + const r = td.getBoundingClientRect(); |
| + const e = new MouseEvent('contextmenu', {bubbles:true, cancelable:true, button:2, clientX:r.x+5, clientY:r.y+5}); |
| + td.dispatchEvent(e); return e.defaultPrevented; |
| + }); |
| + assert.equal(prevented, false, `${name}: something swallowed the right-click`); |
| + }); |
| + // Playwright's WebKit treats an unclaimed Backspace as history-back and |
| + // leaves the page (on main too), so Backspace is driven in Chromium only; |
| + // the library's handler takes Delete and Backspace down one path. |
| + for (const key of name === 'webkit' ? ['Delete'] : ['Delete', 'Backspace']) { |
| + await check(`row header + ${key} deletes nothing`, async () => { |
| + await page.locator('td.jss_row[data-y="1"]').click(); |
| + await page.keyboard.press(key); |
| + }); |
| + await check(`column header + ${key} deletes nothing`, async () => { |
| + await page.locator('thead td[data-x="0"]').click(); |
| + await page.keyboard.press(key); |
| + }); |
| + } |
| + for (const combo of ['Control+s', 'Meta+s']) { |
| + await check(`${combo} downloads nothing`, async () => { await page.keyboard.press(combo); }); |
| } |
| - // Dispatched as a real contextmenu event; defaultPrevented tells us whether |
| - // anything claimed it (the library's menu does, the browser's does not). |
| - const prevented = await cell.evaluate(td => { |
| - const e = new MouseEvent('contextmenu', {bubbles:true, cancelable:true, button:2, clientX:td.getBoundingClientRect().x+5, clientY:td.getBoundingClientRect().y+5}); |
| - td.dispatchEvent(e); |
| - return e.defaultPrevented; |
| + await check('dragging a row header moves no row', async () => { |
| + const from = await page.locator('td.jss_row[data-y="0"]').boundingBox(); |
| + const to = await page.locator('td.jss_row[data-y="2"]').boundingBox(); |
| + await page.mouse.move(from.x + from.width - 3, from.y + from.height / 2); |
| + await page.mouse.down(); |
| + await page.mouse.move(to.x + to.width - 3, to.y + to.height / 2, {steps: 8}); |
| + await page.mouse.up(); |
| }); |
| - assert.equal(prevented, false, `${name}: something swallowed the right-click instead of leaving the browser menu`); |
| - assert.deepEqual(writes, [], `${name}: right-click sent writes`); |
| - assert.deepEqual(errors, [], `${name}: page errors`); |
| - console.log(`${name}: no library context menu`); |
| } finally { |
| await browser.close(); |
| } |
| } |
| +if (failures.length) { console.error(`${failures.length} failure(s)`); process.exit(1); } |