oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

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
14import assert from 'node:assert/strict';
15import fs from 'node:fs';
16const pw = await import(process.env.PLAYWRIGHT || 'playwright');
17const fixture = JSON.parse(fs.readFileSync(process.env.SHEETS_MENU_FIXTURE));
18const origin = 'https://sheets.test';
19const failures = [];
20for (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}
112if (failures.length) { console.error(`${failures.length} failure(s)`); process.exit(1); }
113