|
| 1 | +// Autonomous self-test for drawing tools |
| 2 | +const { chromium } = require('./open-pdf-studio/node_modules/playwright'); |
| 3 | + |
| 4 | +function sleep(ms){return new Promise(r=>setTimeout(r,ms));} |
| 5 | + |
| 6 | +async function pickPage(ctx){ |
| 7 | + // pick the page (not workers) whose URL matches and that has a real document |
| 8 | + const pages = ctx.pages(); |
| 9 | + for(const p of pages){ |
| 10 | + const url = p.url(); |
| 11 | + if(url.includes('localhost:3041') && !url.includes('pdf.worker')){ |
| 12 | + try{ |
| 13 | + const ok = await p.evaluate(()=>typeof document !== 'undefined'); |
| 14 | + if(ok) return p; |
| 15 | + }catch(e){} |
| 16 | + } |
| 17 | + } |
| 18 | + return pages[0]; |
| 19 | +} |
| 20 | + |
| 21 | +async function exposeState(page){ |
| 22 | + // Inject helper that finds the state via dynamic import of the module URL |
| 23 | + return await page.evaluate(async ()=>{ |
| 24 | + if(window.__OPDFS) return 'already'; |
| 25 | + try{ |
| 26 | + const mod = await import('/js/core/state.ts'); |
| 27 | + window.__OPDFS = { state: mod.state }; |
| 28 | + }catch(e){ |
| 29 | + try{ |
| 30 | + const mod = await import('/js/core/state.js'); |
| 31 | + window.__OPDFS = { state: mod.state }; |
| 32 | + }catch(e2){ |
| 33 | + return 'fail: '+e.message+' / '+e2.message; |
| 34 | + } |
| 35 | + } |
| 36 | + // try expose tool manager too |
| 37 | + try{ const mm = await import('/js/tools/manager.js'); window.__OPDFS.manager = mm; }catch(e){} |
| 38 | + return 'ok'; |
| 39 | + }); |
| 40 | +} |
| 41 | + |
| 42 | +async function getSnapshot(page){ |
| 43 | + return await page.evaluate(()=>{ |
| 44 | + const s = window.__OPDFS?.state; |
| 45 | + if(!s) return null; |
| 46 | + const docIdx = s.currentDocumentIndex ?? 0; |
| 47 | + const doc = (s.documents||[])[docIdx]; |
| 48 | + const anns = doc?.annotations || []; |
| 49 | + return { docs: (s.documents||[]).length, currentTool: s.currentTool, annCount: anns.length, lastType: anns[anns.length-1]?.type }; |
| 50 | + }); |
| 51 | +} |
| 52 | + |
| 53 | +async function getLastAnn(page){ |
| 54 | + return await page.evaluate(()=>{ |
| 55 | + const s = window.__OPDFS?.state; |
| 56 | + if(!s) return null; |
| 57 | + const docIdx = s.currentDocumentIndex ?? 0; |
| 58 | + const doc = (s.documents||[])[docIdx]; |
| 59 | + const anns = doc?.annotations || []; |
| 60 | + const a = anns[anns.length-1]; |
| 61 | + if(!a) return { count: anns.length, last: null }; |
| 62 | + // shallow clone with key fields |
| 63 | + const safe = {}; |
| 64 | + for(const k of Object.keys(a)){ |
| 65 | + const v = a[k]; |
| 66 | + if(v == null || typeof v !== 'object') safe[k] = v; |
| 67 | + else if(Array.isArray(v)) safe[k] = v.length<10 ? JSON.parse(JSON.stringify(v)) : '['+v.length+' items]'; |
| 68 | + else safe[k] = '[obj]'; |
| 69 | + } |
| 70 | + return { count: anns.length, last: safe }; |
| 71 | + }); |
| 72 | +} |
| 73 | + |
| 74 | +async function setTool(page, tool){ |
| 75 | + return await page.evaluate(async (t)=>{ |
| 76 | + try{ |
| 77 | + const mm = window.__OPDFS?.manager || await import('/js/tools/manager.js'); |
| 78 | + window.__OPDFS.manager = mm; |
| 79 | + if(mm.setTool) { mm.setTool(t); return 'mm.setTool'; } |
| 80 | + if(mm.toolManager?.setTool) { mm.toolManager.setTool(t); return 'tm.setTool'; } |
| 81 | + // fallback: set state.currentTool |
| 82 | + window.__OPDFS.state.currentTool = t; |
| 83 | + return 'state.currentTool'; |
| 84 | + }catch(e){ return 'err:'+e.message; } |
| 85 | + }, tool); |
| 86 | +} |
| 87 | + |
| 88 | +async function pressEsc(page){ |
| 89 | + await page.keyboard.press('Escape'); await sleep(60); |
| 90 | + await page.keyboard.press('Escape'); await sleep(80); |
| 91 | + // Defensive: also hide any open context menu via the bridge |
| 92 | + try{ await page.evaluate(async()=>{ |
| 93 | + try{ const b = await import('/js/bridge.js'); if(b.hideMenu) b.hideMenu(); if(b.closeAllPopups) b.closeAllPopups(); }catch(_){} |
| 94 | + // Reset any stale per-tool state that might block subsequent tools |
| 95 | + const s = window.__OPDFS?.state; if(s){ |
| 96 | + s.dimPoints = []; s.measurePoints = null; s.measurePhase = 'outer'; |
| 97 | + s.measureOuterPoints = null; s.measureHoles = []; |
| 98 | + s.polylinePoints = []; s.isDrawingPolyline = false; |
| 99 | + s.splinePoints = []; s.isDrawingSpline = false; |
| 100 | + s.filledAreaPoints = null; s.filledAreaPhase = 'outer'; s.filledAreaOuterPoints = null; s.filledAreaHoles = []; |
| 101 | + s.isDrawing = false; s.isDrawingDimension = false; |
| 102 | + s._closeContourPending = false; s._suppressNextContextmenu = false; |
| 103 | + } |
| 104 | + }); }catch(_){} |
| 105 | +} |
| 106 | + |
| 107 | +async function drag(page, x1,y1,x2,y2){ |
| 108 | + await page.mouse.move(x1,y1); await page.mouse.down(); await sleep(60); |
| 109 | + for(let i=1;i<=6;i++){ await page.mouse.move(x1+(x2-x1)*i/6, y1+(y2-y1)*i/6); await sleep(20); } |
| 110 | + await sleep(80); await page.mouse.up(); await sleep(150); |
| 111 | +} |
| 112 | +async function click(page,x,y){ await page.mouse.move(x,y); await sleep(30); await page.mouse.down(); await sleep(30); await page.mouse.up(); await sleep(80); } |
| 113 | +async function dblclick(page,x,y){ await page.mouse.move(x,y); await sleep(20); await page.mouse.dblclick(x,y); await sleep(150); } |
| 114 | +async function rclick(page,x,y){ await page.mouse.move(x,y); await sleep(30); await page.mouse.down({button:'right'}); await page.mouse.up({button:'right'}); await sleep(120); } |
| 115 | + |
| 116 | +async function exerciseTool(page, tool, label, expectedType, fn){ |
| 117 | + await pressEsc(page); |
| 118 | + const before = await getLastAnn(page); |
| 119 | + let err = null; |
| 120 | + let actualTool = null; |
| 121 | + try { |
| 122 | + const setRes = await setTool(page, tool); |
| 123 | + await sleep(80); |
| 124 | + actualTool = await page.evaluate(()=>window.__OPDFS?.state?.currentTool); |
| 125 | + if(actualTool !== tool) err = `tool not active (req=${tool} got=${actualTool}; setRes=${setRes})`; |
| 126 | + await fn(); |
| 127 | + await sleep(250); |
| 128 | + } catch(e){ err = err || e.message; } |
| 129 | + const after = await getLastAnn(page); |
| 130 | + await pressEsc(page); |
| 131 | + const added = after && before && after.count > before.count; |
| 132 | + return { name: label, added, expectedType, gotType: after?.last?.type, err, before: before?.count, after: after?.count, last: after?.last, actualTool }; |
| 133 | +} |
| 134 | + |
| 135 | +(async()=>{ |
| 136 | + const browser = await chromium.connectOverCDP('http://127.0.0.1:9222'); |
| 137 | + const ctx = browser.contexts()[0]; |
| 138 | + const page = await pickPage(ctx); |
| 139 | + |
| 140 | + page.on('pageerror', e=>console.log('[pageerror]', e.message.slice(0,150))); |
| 141 | + page.on('console', m=>{ if(m.type()==='error'){ const t = m.text().slice(0,200); if(!t.includes('document is not defined')) console.log('[cerr]', t); }}); |
| 142 | + |
| 143 | + console.log('URL:', page.url()); |
| 144 | + |
| 145 | + const ex = await exposeState(page); |
| 146 | + console.log('expose state:', ex); |
| 147 | + |
| 148 | + let snap = await getSnapshot(page); |
| 149 | + console.log('snapshot:', snap); |
| 150 | + |
| 151 | + if(!snap || snap.docs === 0){ |
| 152 | + console.log('NO DOC OPEN - aborting'); process.exit(1); |
| 153 | + } |
| 154 | + |
| 155 | + // wait viewport active |
| 156 | + for(let i=0;i<20;i++){ |
| 157 | + const ok = await page.evaluate(()=>!!(window.__pdfViewport && window.__pdfViewport.active)); |
| 158 | + if(ok) break; await sleep(300); |
| 159 | + } |
| 160 | + console.log('viewport active:', await page.evaluate(()=>!!window.__pdfViewport?.active)); |
| 161 | + |
| 162 | + // Resize window so canvas has ample space for many distinct test regions |
| 163 | + try { const s = await page.context().newCDPSession(page); /* no-op */ } catch(e){} |
| 164 | + |
| 165 | + // Compute canvas-relative coordinate helper. Each test gets its own row/col block |
| 166 | + // inside the actual on-screen annotation canvas (which can be ~696x597 in dev). |
| 167 | + const cv = await page.evaluate(()=>{ |
| 168 | + const c = document.getElementById('annotation-canvas') || document.getElementById('pdf-canvas'); |
| 169 | + const r = c.getBoundingClientRect(); |
| 170 | + return { x: r.x, y: r.y, w: r.width, h: r.height }; |
| 171 | + }); |
| 172 | + console.log('canvas:', cv); |
| 173 | + // Build a 6x4 grid of cells inside the canvas, each cell big enough for a stroke |
| 174 | + const cols = 6, rows = 4; |
| 175 | + const padX = 18, padY = 18; |
| 176 | + const cellW = (cv.w - padX*2) / cols; |
| 177 | + const cellH = (cv.h - padY*2) / rows; |
| 178 | + function cell(idx){ |
| 179 | + const r = Math.floor(idx / cols), c = idx % cols; |
| 180 | + const cx = cv.x + padX + cellW*c + cellW*0.1; |
| 181 | + const cy = cv.y + padY + cellH*r + cellH*0.1; |
| 182 | + return { |
| 183 | + x1: cx, y1: cy, |
| 184 | + x2: cx + cellW*0.75, y2: cy + cellH*0.75, |
| 185 | + midX: cx + cellW*0.4, midY: cy + cellH*0.4, |
| 186 | + w: cellW, h: cellH, |
| 187 | + }; |
| 188 | + } |
| 189 | + |
| 190 | + const tests = [ |
| 191 | + { tool:'line', type:'line', run: async()=>{ const c=cell(0); await click(page,c.x1,c.y1); await click(page,c.x2,c.y2); } }, |
| 192 | + { tool:'arrow', type:'arrow', run: async()=>{ const c=cell(1); await click(page,c.x1,c.y1); await click(page,c.x2,c.y2); } }, |
| 193 | + { tool:'box', type:'box', run: async()=>{ const c=cell(2); await drag(page,c.x1,c.y1,c.x2,c.y2); } }, |
| 194 | + { tool:'circle', type:'circle', run: async()=>{ const c=cell(3); await drag(page,c.x1,c.y1,c.x2,c.y2); } }, |
| 195 | + { tool:'polyline', type:'polyline', run: async()=>{ const c=cell(4); await click(page,c.x1,c.y1); await click(page,c.midX,c.midY); await click(page,c.x2-5,c.y2-5); await sleep(220); await dblclick(page,c.x2,c.y2); } }, |
| 196 | + { tool:'polygon', type:'polygon', run: async()=>{ const c=cell(5); await drag(page,c.x1,c.y1,c.x2,c.y2); } }, |
| 197 | + { tool:'cloud', type:'cloud', run: async()=>{ const c=cell(6); await drag(page,c.x1,c.y1,c.x2,c.y2); } }, |
| 198 | + { tool:'cloudPolyline', type:'cloudPolyline', run: async()=>{ const c=cell(7); await click(page,c.x1,c.y1); await click(page,c.x2,c.y1+5); await click(page,c.x2,c.y2); await click(page,c.x1,c.y2); await click(page,c.x1,c.y1); } }, |
| 199 | + { tool:'filledArea', type:'filledArea', run: async()=>{ const c=cell(8); await click(page,c.x1,c.y1); await click(page,c.x2,c.y1+5); await click(page,c.x2,c.y2); await click(page,c.x1,c.y2); await sleep(150); await page.keyboard.press('Enter'); await sleep(200); } }, |
| 200 | + { tool:'arc', type:'arc', run: async()=>{ const c=cell(9); await click(page,c.x1,c.y1); await click(page,c.midX,c.midY); await click(page,c.x2,c.y2); } }, |
| 201 | + { tool:'spline', type:'spline', run: async()=>{ const c=cell(10); await click(page,c.x1,c.y1); await click(page,c.midX,c.midY); await click(page,c.x2-5,c.y2-5); await sleep(220); await dblclick(page,c.x2,c.y2); } }, |
| 202 | + { tool:'draw', type:'draw', run: async()=>{ const c=cell(11); |
| 203 | + await page.mouse.move(c.x1,c.y1); await page.mouse.down(); |
| 204 | + for(let i=1;i<=10;i++){ await page.mouse.move(c.x1+i*4, c.y1+Math.sin(i)*5); await sleep(20); } |
| 205 | + await page.mouse.up(); }}, |
| 206 | + { tool:'highlight', type:'highlight', run: async()=>{ const c=cell(12); await drag(page,c.x1,c.y1+10,c.x2,c.y1+20); } }, |
| 207 | + { tool:'textbox', type:'textbox', run: async()=>{ const c=cell(13); await drag(page,c.x1,c.y1,c.x2,c.y2); await sleep(150); await page.keyboard.press('Escape'); await sleep(150); } }, |
| 208 | + { tool:'callout', type:'callout', run: async()=>{ const c=cell(14); await drag(page,c.x1,c.y1,c.x2,c.y2); } }, |
| 209 | + { tool:'comment', type:'comment', run: async()=>{ const c=cell(15); await click(page,c.midX,c.midY); await sleep(200); await page.keyboard.press('Escape'); } }, |
| 210 | + { tool:'stamp', type:'stamp', run: async()=>{ |
| 211 | + // Stamp normally opens a picker; inject a minimal SVG override so |
| 212 | + // placeOverrideStamp() commits an annotation directly. |
| 213 | + await page.evaluate(()=>{ |
| 214 | + const s = window.__OPDFS?.state; |
| 215 | + if(!s) return; |
| 216 | + if(!s.toolOverrides) s.toolOverrides = {}; |
| 217 | + s.toolOverrides.stampSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect x="2" y="2" width="36" height="36" fill="red"/></svg>'; |
| 218 | + s.toolOverrides.stampWidth = 40; |
| 219 | + s.toolOverrides.stampHeight = 40; |
| 220 | + }); |
| 221 | + const c=cell(16); await click(page,c.midX,c.midY); |
| 222 | + await sleep(300); |
| 223 | + } }, |
| 224 | + { tool:'measureDistance', type:'measureDistance', run: async()=>{ const c=cell(17); await click(page,c.x1,c.y1); await click(page,c.x2,c.y1+5); await click(page,c.midX,c.y2); } }, |
| 225 | + { tool:'measureArea', type:'measureArea', run: async()=>{ const c=cell(18); await click(page,c.x1,c.y1); await click(page,c.x2,c.y1+5); await click(page,c.x2,c.y2); await click(page,c.x1,c.y2); await click(page,c.x1,c.y1); await sleep(150); await rclick(page,c.x1,c.y1); await sleep(200); } }, |
| 226 | + { tool:'measurePerimeter', type:'measurePerimeter', run: async()=>{ const c=cell(19); await click(page,c.x1,c.y1); await click(page,c.x2,c.y1+5); await click(page,c.x2,c.y2); await click(page,c.x1,c.y2); await sleep(150); await rclick(page,c.x1,c.y2); await sleep(200); } }, |
| 227 | + { tool:'measureAngle', type:'measureAngle', run: async()=>{ const c=cell(20); await click(page,c.x1,c.y1); await click(page,c.midX,c.midY); await click(page,c.x2,c.y1); } }, |
| 228 | + { tool:'scaleRegion', type:'scaleRegion', run: async()=>{ const c=cell(21); await drag(page,c.x1,c.y1,c.x2,c.y2); await sleep(400); await page.keyboard.press('Enter'); await sleep(200); } }, |
| 229 | + { tool:'parametricSymbol', type:'parametricSymbol', run: async()=>{ |
| 230 | + await page.evaluate(()=>{ |
| 231 | + const s = window.__OPDFS?.state; |
| 232 | + if(s) s.parametricPickerOpen = true; |
| 233 | + }); |
| 234 | + await sleep(400); |
| 235 | + await page.evaluate(()=>{ |
| 236 | + const items = document.querySelectorAll('[data-template-key], .parametric-picker-item, .template-card, .parametric-template'); |
| 237 | + if(items[0]){ items[0].click(); } |
| 238 | + }); |
| 239 | + await sleep(300); |
| 240 | + const c=cell(22); await drag(page,c.x1,c.y1,c.x2,c.y2); |
| 241 | + } }, |
| 242 | + ]; |
| 243 | + |
| 244 | + const results = []; |
| 245 | + for(const t of tests){ |
| 246 | + const r = await exerciseTool(page, t.tool, t.tool, t.type, t.run); |
| 247 | + console.log(`${t.tool}: added=${r.added} type=${r.gotType} err=${r.err||'-'} (${r.before}->${r.after}) tool=${r.actualTool}`); |
| 248 | + results.push(r); |
| 249 | + } |
| 250 | + |
| 251 | + console.log('\n=== RESULTS ==='); |
| 252 | + console.log('| # | Tool | OK | Notes |'); |
| 253 | + console.log('|---|------|----|-------|'); |
| 254 | + results.forEach((r,i)=>{ |
| 255 | + const ok = r.added && (r.gotType === r.expectedType); |
| 256 | + let note = ''; |
| 257 | + if(!r.added) note = `no annotation added; ${r.err||''}`; |
| 258 | + else if(r.gotType !== r.expectedType) note = `wrong type: expected ${r.expectedType} got ${r.gotType}`; |
| 259 | + else { |
| 260 | + const a = r.last; |
| 261 | + if(a) note = JSON.stringify(a).slice(0,100); |
| 262 | + } |
| 263 | + console.log(`| ${i+1} | ${r.name} | ${ok?'YES':'NO'} | ${note} |`); |
| 264 | + }); |
| 265 | + |
| 266 | + await browser.close(); |
| 267 | +})().catch(e=>{ console.error('FATAL', e); process.exit(2); }); |
0 commit comments