| new file mode 100644 |
| index 0000000..25b19bd |
| --- /dev/null |
| +++ b/tools/drive/contextmenu.mjs |
| @@ -0,0 +1,112 @@ |
| +// 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 |
| +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'; |
| +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 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: |
| + 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'}); |
| + 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:2,c:0,raw:'30',display:'30'}, |
| + {r:0,c:1,raw:'b1',display:'b1'}]}}); |
| + return route.fulfill({body:fixture.html,contentType:'text/html'}); |
| + }); |
| + 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 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); }); |
| + } |
| + 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(); |
| + }); |
| + } finally { |
| + await browser.close(); |
| + } |
| +} |
| +if (failures.length) { console.error(`${failures.length} failure(s)`); process.exit(1); } |