| 1 | // Browser-level component checks with the real vendored grid and mocked transport. |
| 2 | // PLAYWRIGHT=/path/to/playwright/index.mjs node tools/drive/editing.mjs |
| 3 | import assert from 'node:assert/strict'; |
| 4 | import fs from 'node:fs'; |
| 5 | const pw = await import(process.env.PLAYWRIGHT || 'playwright'); |
| 6 | const browser = await pw.chromium.launch({headless: true}); |
| 7 | const page = await browser.newPage(); |
| 8 | const root = new URL('../../internal/sheets/static/', import.meta.url); |
| 9 | let cells, batches, fail; |
| 10 | const errors = []; |
| 11 | page.on('pageerror', e => errors.push(e.message)); |
| 12 | await page.route('https://sheets.test/**', async route => { |
| 13 | const req = route.request(), path = new URL(req.url()).pathname; |
| 14 | if (path === '/') return route.fulfill({contentType:'text/html', body:` |
| 15 | <link rel="stylesheet" href="/static/vendor/jspreadsheet.css"> |
| 16 | <link rel="stylesheet" href="/static/sheets.css"> |
| 17 | <style>td {font: 24px Arial} :root {--rst-line-strong: blue}</style> |
| 18 | <input data-formula-input><div data-sheet-surface data-sheet-id="1" data-tab-id="1" |
| 19 | data-rows="30" data-cols="30" data-may-write="1"><div data-sheet-grid tabindex="0"></div></div> |
| 20 | ${['vendor/jsuites.js','vendor/jspreadsheet.js','tsv.js','cellqueue.js','grid.js','formulabar.js','formulapick.js','clipboard.js'].map(x=>`<script src="/static/${x}"></script>`).join('')}`}); |
| 21 | if (path.startsWith('/static/')) return route.fulfill({body:fs.readFileSync(new URL(path.slice(8),root)), |
| 22 | contentType:path.endsWith('.css')?'text/css':'application/javascript'}); |
| 23 | if (req.method() === 'POST') { |
| 24 | const data=req.postDataJSON(); |
| 25 | const jobs=data.cells || [data]; |
| 26 | batches.push(jobs); |
| 27 | if(fail) return route.fulfill({status:400,body:'rejected'}); |
| 28 | jobs.forEach(c=>cells.set(`${c.r},${c.c}`, {...c,display:c.raw})); |
| 29 | } |
| 30 | return route.fulfill({json:{cells:[...cells.values()],version:batches.length+1}}); |
| 31 | }); |
| 32 | async function reset(seed=[]) { |
| 33 | cells=new Map(seed.map(([r,c,raw])=>[`${r},${c}`,{r,c,raw,display:raw}]));batches=[];fail=false; |
| 34 | await page.goto('https://sheets.test/'); |
| 35 | await page.waitForFunction(()=>window.SheetsGrid?.worksheet()); |
| 36 | } |
| 37 | const td=(r,c)=>page.locator(`td[data-x="${c}"][data-y="${r}"]`); |
| 38 | async function select(r,c,r2=r,c2=c) { |
| 39 | await page.evaluate(([r,c,r2,c2])=>{SheetsGrid.worksheet().updateSelectionFromCoords(c,r,c2,r2);SheetsGrid.focus();},[r,c,r2,c2]); |
| 40 | } |
| 41 | async function clip(type,text) { |
| 42 | return page.evaluate(([type,text])=>{ |
| 43 | const data=new DataTransfer();if(text!==undefined)data.setData('text/plain',text); |
| 44 | document.activeElement.dispatchEvent(new ClipboardEvent(type,{bubbles:true,cancelable:true,clipboardData:data})); |
| 45 | return data.getData('text/plain'); |
| 46 | },[type,text]); |
| 47 | } |
| 48 | async function settled() {await page.evaluate(()=>SheetsCellQueue([],SheetsGrid.post));} |
| 49 | try { |
| 50 | await reset([[0,0,'one'],[1,0,'two']]);await select(0,0,1,0); |
| 51 | const text=await clip('cut');await settled();assert.equal(batches.length,0); |
| 52 | assert.equal(cells.get('0,0').raw,'one'); |
| 53 | await select(1,0);await clip('paste',text);await settled(); |
| 54 | assert.equal(batches.length,1);assert.equal(cells.get('0,0').raw,''); |
| 55 | assert.equal(cells.get('1,0').raw,'one');assert.equal(cells.get('2,0').raw,'two'); |
| 56 | assert.equal(new Set(batches[0].map(c=>`${c.r},${c.c}`)).size,batches[0].length); |
| 57 | console.log('ok deferred cut and overlapping atomic move'); |
| 58 | await reset([[0,0,'=B1']]);await select(0,0);const formula=await clip('cut'); |
| 59 | await select(1,0);fail=true;await clip('paste',formula);await settled(); |
| 60 | assert.equal(cells.get('0,0').raw,'=B1');fail=false; |
| 61 | await clip('paste',formula);await settled();assert.equal(cells.get('0,0').raw,''); |
| 62 | assert.equal(cells.get('1,0').raw,'=B1');assert.ok(!('fromR' in batches.at(-1)[0])); |
| 63 | console.log('ok rejected paste preserves cut for retry; moved formulas stay verbatim'); |
| 64 | await reset([[0,0,'hello']]);await select(0,0);await clip('cut'); |
| 65 | await page.keyboard.press('Escape');await select(1,0);await clip('paste','hello');await settled(); |
| 66 | assert.equal(cells.get('0,0').raw,'hello'); |
| 67 | await select(0,0);await clip('cut');await page.locator('[data-formula-input]').focus(); |
| 68 | await clip('copy');await select(2,0);await clip('paste','hello');await settled(); |
| 69 | assert.equal(cells.get('0,0').raw,'hello');console.log('ok Escape and text-field copy cancel pending cuts'); |
| 70 | await reset([[0,0,'one']]);await select(0,0);await clip('cut'); |
| 71 | cells.set('0,0',{r:0,c:0,raw:'changed',display:'changed'}); |
| 72 | await page.evaluate(()=>SheetsGrid.refetch());await select(1,0);await clip('paste','one');await settled(); |
| 73 | assert.equal(batches.length,0);console.log('ok changed source rejects stale cut'); |
| 74 | await reset([[0,0,'one'],[1,0,'two']]);await select(0,0,1,0);await clip('cut'); |
| 75 | await select(29,0);await clip('paste','one\ntwo');await settled();assert.equal(batches.length,0); |
| 76 | console.log('ok out-of-bounds move keeps source'); |
| 77 | await reset([[0,0,'Room three']]);await td(0,0).dblclick();await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); |
| 78 | const input=td(0,0).locator('input,textarea');await input.waitFor(); |
| 79 | const fonts=await input.evaluate(el=>[getComputedStyle(el).fontSize,getComputedStyle(el.parentElement).fontSize]); |
| 80 | assert.equal(fonts[0],fonts[1]);console.log('ok editor font matches cell'); |
| 81 | await input.fill('=SUM('); |
| 82 | const a=await td(0,1).boundingBox(),b=await td(2,2).boundingBox(); |
| 83 | await page.mouse.move(a.x+10,a.y+10);await page.mouse.down(); |
| 84 | await page.mouse.move(b.x+10,b.y+10);await page.mouse.up(); |
| 85 | assert.equal(await input.inputValue(),'=SUM(B1:C3)'.slice(0,-1)); |
| 86 | assert.equal(batches.length,0);await input.press('End');await input.type(')');await input.press('Enter');await page.waitForFunction(()=>SheetsGrid.rawAt(0,0)==='=SUM(B1:C3)'); |
| 87 | assert.equal(cells.get('0,0').raw,'=SUM(B1:C3)');console.log('ok in-cell formula range drag and commit'); |
| 88 | await select(3,0);const bar=page.locator('[data-formula-input]');await bar.fill('=SUM('); |
| 89 | await td(0,1).click();assert.equal(await bar.inputValue(),'=SUM(B1'); |
| 90 | await bar.type(')');assert.equal(await bar.inputValue(),'=SUM(B1)');await bar.press('Enter');await page.waitForFunction(()=>SheetsGrid.rawAt(3,0)==='=SUM(B1)');assert.equal(cells.get('3,0').raw,'=SUM(B1)'); |
| 91 | console.log('ok formula bar inserts cell reference and commits to original cell'); |
| 92 | await reset([[0,0,'=SUM(B1:B3)']]);await td(0,0).dblclick();await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); |
| 93 | await td(0,0).locator('input,textarea').waitFor(); |
| 94 | await page.waitForFunction(()=>document.activeElement.selectionStart===0); |
| 95 | await td(0,1).click();assert.equal(cells.get('0,0').raw,'=SUM(B1:B3)'); |
| 96 | console.log('ok clicking away from a selected existing formula preserves it'); |
| 97 | await reset([[0,0,'original']]);await td(0,0).dblclick();await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); |
| 98 | const cancel=td(0,0).locator('input,textarea'); |
| 99 | await page.waitForFunction(()=>document.activeElement.value==='original' && document.activeElement.selectionEnd===8); |
| 100 | await cancel.fill('=SUM('); |
| 101 | await td(0,1).click();await cancel.press('Escape'); |
| 102 | assert.equal(cells.get('0,0').raw,'original');assert.equal(batches.length,0); |
| 103 | console.log('ok Escape discards formula reference edit'); |
| 104 | assert.deepEqual(errors,[]); |
| 105 | } finally {await browser.close();} |
| 106 | |