| 1 | /* Drive cut, copy and paste in a real browser. |
| 2 | * |
| 3 | * The third of the drive scripts, and the one that had to exist: every |
| 4 | * bug it covers was invisible to the Go suite. The vendored grid's own |
| 5 | * paste dropped cells — a six-cell block landed five, an external |
| 6 | * two-row paste landed one — and the cells that vanished did so because |
| 7 | * the library wrote them by a path that never fires onchange, so they |
| 8 | * never reached the server at all. |
| 9 | * |
| 10 | * It also drives spec §4.2's translation pass end to end: fill `=A1*2` |
| 11 | * down and read `=A2*2` back out of the formula bar's own source. |
| 12 | * |
| 13 | * Exits non-zero on any failed check, so it can gate a deploy by hand. |
| 14 | * Not part of `make test`: it needs a browser and a running instance, |
| 15 | * and it needs clipboard permissions granted to the context. |
| 16 | * |
| 17 | * SHEETS_SOCK=$D/s.sock SHEETS_LOG=$D/s.log \ |
| 18 | * PLAYWRIGHT=/path/to/playwright/index.js node tools/drive/clipboard.mjs |
| 19 | */ |
| 20 | import { checkCellQueue } from './batch-checks.mjs'; |
| 21 | import net from 'node:net'; |
| 22 | import fs from 'node:fs'; |
| 23 | const pw=await import(process.env.PLAYWRIGHT); const chromium=pw.chromium||(pw.default&&pw.default.chromium); |
| 24 | const proxy=net.createServer(c=>{const u=net.connect(process.env.SHEETS_SOCK);c.pipe(u);u.pipe(c);c.on('error',()=>u.destroy());u.on('error',()=>c.destroy());}); |
| 25 | await new Promise(r=>proxy.listen(8799,'127.0.0.1',r)); |
| 26 | const base='http://127.0.0.1:8799'; |
| 27 | const browser=await chromium.launch({executablePath:process.env.CHROMIUM_EXECUTABLE_PATH}); |
| 28 | const ctx=await browser.newContext({viewport:{width:1440,height:900},permissions:['clipboard-read','clipboard-write']}); |
| 29 | const page=await ctx.newPage(); |
| 30 | const errors=[]; page.on('pageerror',e=>errors.push(String(e))); page.on('console',m=>{if(m.type()==='error')errors.push(m.text());}); |
| 31 | const fails=[]; const ok=n=>console.log(' ok '+n); |
| 32 | const check=(n,c,d)=>c?ok(n):(fails.push(n),console.log(' FAIL '+n+' — '+d)); |
| 33 | await page.goto(`${base}/signin`); await page.fill('input[type=email]','paul@example.com'); |
| 34 | await Promise.all([page.waitForURL(/signin\?sent=1/), page.click('form[action="/signin"] button[type=submit]')]); |
| 35 | const mm=[...fs.readFileSync(process.env.SHEETS_LOG,'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop(); |
| 36 | await page.goto(mm[0]); await page.waitForURL(/\/sheets/); |
| 37 | // Create from the top bar, then name it by renaming: the list's #title |
| 38 | // field is gone, so a sheet is born "Untitled sheet" and named after. |
| 39 | await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('[estilo-bar-actions] form[action="/sheets"] button[type=submit]')]); |
| 40 | await page.fill('[estilo-bar-title-input]','Clip2'); |
| 41 | await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.press('[estilo-bar-title-input]','Enter')]); |
| 42 | await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet()); |
| 43 | const w=(r,c,v)=>page.evaluate(([r,c,v])=>window.SheetsGrid.write(r,c,v),[r,c,v]); |
| 44 | const sel=(x1,y1,x2,y2)=>page.evaluate(([a,b,c,d])=>window.SheetsGrid.worksheet().updateSelectionFromCoords(a,b,c,d),[x1,y1,x2,y2]); |
| 45 | const raw=(r,c)=>page.evaluate(([r,c])=>window.SheetsGrid.rawAt(r,c),[r,c]); |
| 46 | const shown=(r,c)=>page.evaluate(([r,c])=>window.SheetsGrid.worksheet().getValueFromCoords(c,r),[r,c]); |
| 47 | |
| 48 | for(const [r,c,v] of [[0,0,'10'],[1,0,'20'],[2,0,'30'],[0,1,'=A1*2']]) await w(r,c,v); |
| 49 | await page.waitForTimeout(500); |
| 50 | |
| 51 | /* A REAL MOUSE CLICK first, and this is the most important line in the |
| 52 | * file. The vendored grid acts on Ctrl+C/X/V from its own document |
| 53 | * keydown whenever jspreadsheet.current is set — which a mousedown |
| 54 | * inside a worksheet does and updateSelectionFromCoords does not. An |
| 55 | * earlier version of this script never clicked a cell, so it measured |
| 56 | * the one state a person with a mouse is never in, and reported the |
| 57 | * clipboard as working while Ctrl+C copied the library's display |
| 58 | * string. Found by review; do not replace these clicks with a |
| 59 | * programmatic selection. */ |
| 60 | const cellAt=(r,c)=>`[data-sheet-grid] tbody tr:nth-child(${r+1}) td:nth-child(${c+2})`; |
| 61 | await page.click(cellAt(0,1)); |
| 62 | await page.waitForTimeout(200); |
| 63 | check('a mouse click arms the vendored grid (the state this must survive)', |
| 64 | await page.evaluate(()=>{try{return !!(window.jspreadsheet&&jspreadsheet.current);}catch(e){return false;}}), |
| 65 | 'jspreadsheet.current is not set, so this run is measuring the wrong state'); |
| 66 | await page.evaluate(async()=>{try{await navigator.clipboard.writeText('SENTINEL');}catch(e){}}); |
| 67 | await page.keyboard.press('Control+c'); |
| 68 | await page.waitForTimeout(400); |
| 69 | const afterClick=await page.evaluate(async()=>{try{return await navigator.clipboard.readText();}catch(e){return 'ERR';}}); |
| 70 | check('after a mouse click, Ctrl+C copies the RAW input, not the display string', |
| 71 | afterClick==='=A1*2', `got ${JSON.stringify(afterClick)} — "20" means the library took the keydown`); |
| 72 | |
| 73 | console.log('\n— fill down translates (spec 4.2)'); |
| 74 | await sel(1,0,1,2); await page.waitForTimeout(200); |
| 75 | await page.click('[data-menubar] > details:nth-child(2) > summary'); |
| 76 | await page.click('[data-cmd="fill-down"]'); await page.waitForTimeout(900); |
| 77 | check('B2 is =A2*2', await raw(1,1)==='=A2*2', 'got '+await raw(1,1)); |
| 78 | check('B3 shows 60', await shown(2,1)==='60', 'got '+await shown(2,1)); |
| 79 | |
| 80 | console.log('\n— copy carries every cell and paste loses none'); |
| 81 | await page.evaluate(()=>window.SheetsGrid.focus()); |
| 82 | await sel(0,0,1,2); await page.waitForTimeout(200); |
| 83 | await page.keyboard.press('Control+c'); await page.waitForTimeout(300); |
| 84 | const clip=await page.evaluate(async()=>navigator.clipboard.readText()); |
| 85 | console.log(' clipboard:',JSON.stringify(clip)); |
| 86 | await sel(5,0,5,0); await page.waitForTimeout(200); |
| 87 | await page.keyboard.press('Control+v'); await page.waitForTimeout(1400); |
| 88 | const block=await page.evaluate(()=>{const g=window.SheetsGrid;const o={}; |
| 89 | for(let r=0;r<3;r++)for(let c=5;c<7;c++){const v=g.rawAt(r,c); if(v!=='')o[r+','+c]=v;} return o;}); |
| 90 | console.log(' pasted:',JSON.stringify(block)); |
| 91 | check('all six cells arrived', Object.keys(block).length===6, Object.keys(block).length+' cells'); |
| 92 | check('the formula translated to =F3*2', block['2,6']==='=F3*2', 'got '+block['2,6']); |
| 93 | |
| 94 | console.log('\n— paste from outside loses no row'); |
| 95 | await page.evaluate(async()=>navigator.clipboard.writeText('p\tq\nr\ts')); |
| 96 | await page.evaluate(()=>window.SheetsGrid.focus()); |
| 97 | await sel(9,0,9,0); await page.waitForTimeout(200); |
| 98 | await page.keyboard.press('Control+v'); await page.waitForTimeout(1200); |
| 99 | const ext=await page.evaluate(()=>{const g=window.SheetsGrid;const o={}; |
| 100 | for(let r=0;r<3;r++)for(let c=9;c<12;c++){const v=g.rawAt(r,c); if(v!=='')o[r+','+c]=v;} return o;}); |
| 101 | console.log(' external:',JSON.stringify(ext)); |
| 102 | check('external paste keeps its first row', ext['0,9']==='p'&&ext['0,10']==='q', JSON.stringify(ext)); |
| 103 | |
| 104 | console.log('\n— quoting survives a round trip'); |
| 105 | await w(20,0,'has\ttab and\nnewline'); await page.waitForTimeout(400); |
| 106 | await page.evaluate(()=>window.SheetsGrid.focus()); |
| 107 | await sel(0,20,0,20); await page.waitForTimeout(200); |
| 108 | await page.keyboard.press('Control+c'); await page.waitForTimeout(300); |
| 109 | await sel(3,20,3,20); await page.waitForTimeout(200); |
| 110 | await page.keyboard.press('Control+v'); await page.waitForTimeout(900); |
| 111 | check('a cell holding a tab and a newline round-trips', await raw(20,3)==='has\ttab and\nnewline', JSON.stringify(await raw(20,3))); |
| 112 | |
| 113 | console.log('\n— cut holds the source until paste'); |
| 114 | await sel(0,0,0,0); await page.waitForTimeout(200); |
| 115 | await page.evaluate(()=>window.SheetsGrid.focus()); |
| 116 | await page.keyboard.press('Control+x'); await page.waitForTimeout(900); |
| 117 | check('Ctrl+X kept A1', await raw(0,0)==='10', 'got '+JSON.stringify(await raw(0,0))); |
| 118 | await sel(10,0,10,0); await page.keyboard.press('Control+v'); |
| 119 | await page.waitForFunction(()=>SheetsGrid.rawAt(0,0)==='' && SheetsGrid.rawAt(0,10)==='10'); |
| 120 | |
| 121 | console.log('\n— the menu items'); |
| 122 | // B1 holds =A1*2 (sel takes col,row). |
| 123 | await sel(1,0,1,0); await page.waitForTimeout(200); |
| 124 | await page.click('[data-menubar] > details:nth-child(2) > summary'); |
| 125 | await page.click('[data-cmd="copy"]'); await page.waitForTimeout(500); |
| 126 | check('menu Copy reaches the system clipboard', (await page.evaluate(async()=>navigator.clipboard.readText()))==='=A1*2', 'got '+await page.evaluate(async()=>navigator.clipboard.readText())); |
| 127 | await sel(7,7,7,7); await page.waitForTimeout(200); |
| 128 | await page.click('[data-menubar] > details:nth-child(2) > summary'); |
| 129 | await page.click('[data-cmd="paste"]'); await page.waitForTimeout(900); |
| 130 | // H8 is 7 rows and 6 columns from B1, so A1 becomes G8. |
| 131 | check('menu Paste writes a translated cell', await raw(7,7)==='=G8*2', 'got '+JSON.stringify(await raw(7,7))); |
| 132 | console.log('\n note text:',JSON.stringify(await page.textContent('[data-sheet-note]'))); |
| 133 | try { await checkCellQueue(page); ok('atomic paste and failed cut queue'); } |
| 134 | catch (err) { check('atomic paste and failed cut queue', false, err.message); } |
| 135 | console.log('ERRORS:',JSON.stringify([...new Set(errors)])); |
| 136 | console.log(fails.length?('\nFAILURES: '+fails.join('; ')):'\nall checks passed'); |
| 137 | await browser.close(); proxy.close(); process.exit(fails.length?1:0); |
| 138 | |