tracker/e2e/usecases-check.mjs
// Run against TEMPORARY tracker data only. Requires an existing Playwright installation.
// node e2e/usecases-check.mjs <chrome> <base-url> <output-dir> [playwright-module-path]
import {pathToFileURL} from 'node:url';
import {readFileSync,writeFileSync} from 'node:fs';
const [chrome,base,out,modulePath='playwright',dataDir]=process.argv.slice(2);
if(!chrome||!base||!out)throw new Error('chrome base-url output-dir required (temporary server data only)');
const {chromium}=await import(modulePath.startsWith('/')?pathToFileURL(modulePath).href:modulePath);
const browser=await chromium.launch({executablePath:chrome,headless:true,args:['--no-sandbox']});
const page=await browser.newPage({viewport:{width:1600,height:1000}});
await page.route('https://fonts.googleapis.com/**',r=>r.abort());await page.route('https://fonts.gstatic.com/**',r=>r.abort());
const errors=[];page.on('pageerror',e=>errors.push(e.message));
let passed=0;
function check(ok,msg){if(!ok)throw new Error(msg);passed++;console.log('PASS '+msg);}
const go=async path=>{console.log('OPEN '+path);await page.goto(base+path,{waitUntil:'domcontentloaded'});};
const wait=expression=>page.waitForFunction(expression);
const shot=name=>page.screenshot({path:out+'/'+name+'.png'});
try{
await go('/use-cases');check(await page.locator('.uc-row').count()===162,'table shows all 162 use cases');await shot('usecases-table');
check(await page.locator('.uc-tabs a').count()===4,'catalog has all, Frontend, Backoffice and Integration tabs');
check(await page.evaluate(()=>{const t=document.querySelector('.uc-table-wrap');return t.clientHeight>=t.scrollHeight-1&&getComputedStyle(t).maxHeight==='none'}),'table extends down the page without a height limit');
const group=page.locator('[data-uc-module]').first(),groupButton=group.locator('[data-uc-toggle]');
const totalVisible=await page.locator('.uc-row:visible').count(),inGroup=await group.locator('.uc-row').count();
check(await group.locator('.uc-module-name').textContent().then(t=>t.length>5),'module groups show the full module name');
await groupButton.click();check(await page.locator('.uc-row:visible').count()===totalVisible-inGroup,'module group can collapse its use-case rows');
await go('/use-cases');check(await page.locator('[data-uc-module]').first().locator('[data-uc-toggle]').getAttribute('aria-expanded')==='false','collapsed module state survives reload');
await page.click('[data-uc-collapse-all]');check(await page.locator('.uc-row:visible').count()===0,'Collapse all hides every module row');
await page.click('[data-uc-expand-all]');check(await page.locator('.uc-row:visible').count()===162,'Expand all restores every use case');
await go('/use-cases?channel=frontend&module=REG&q=W-49');check(await page.locator('.uc-row').count()===1&&await page.locator('.uc-tabs [aria-current=page]').textContent().then(t=>t.includes('Frontend')),'channel tabs combine with module and search filters');
await go('/use-cases?channel=integration');check(await page.locator('.uc-row .uc-code').allTextContents().then(ids=>ids.includes('W-36')),'Integration tab includes explicitly named external-system workflows');
await go('/use-cases?module=REG&q=W-49');check(await page.locator('.uc-row').count()===1&&await page.locator('.uc-name').textContent()==='ลงทะเบียนเรียน','module and search filters preserve source');
await go('/use-cases?module=NOTI');check(await page.locator('.uc-row').count()===0,'background module shows empty results');
await go('/use-cases/W-49');await page.locator('.uc-node').first().waitFor();check(await page.locator('.uc-node').count()===8&&await page.locator('.uc-edge').count()===7,'draft includes all six source steps');await page.waitForFunction(()=>document.querySelector('#uc-history').getAttribute('aria-busy')==='false');check(await page.locator('#uc-history-empty').isVisible(),'revision history has a clear empty state');await shot('usecases-light');
check(await page.evaluate(()=>[...document.querySelectorAll('.uc-source ol > li')].every((li,i)=>document.querySelector(`[data-node="step-${i+1}"] .uc-node-desc`).textContent===li.querySelector('p').textContent)),'every source description is displayed in full');
check(await page.evaluate(()=>[...document.querySelectorAll('.uc-node-desc tspan')].every(line=>line.getComputedTextLength()<=219)),'description lines wrap within the shape width');
check(await page.evaluate(()=>{const nodes=[...document.querySelectorAll('.uc-node')].map(n=>n.getBoundingClientRect());return nodes.every((n,i)=>!i||n.left>nodes[i-1].right)}),'initial draft flows left to right with space between shapes');
check(await page.evaluate(()=>{const area=document.querySelector('#uc-canvas').getBoundingClientRect(),nodes=[...document.querySelectorAll('.uc-node')].map(n=>n.getBoundingClientRect());return nodes.every(r=>r.left>=area.left&&r.right<=area.right&&r.top>=area.top&&r.bottom<=area.bottom)}),'default zoom fits every shape in the canvas');
check(await page.evaluate(()=>{const svg=document.querySelector('#uc-svg'),box=svg.querySelector('.uc-graph').getBBox(),p=svg.createSVGPoint();p.x=box.x+box.width/2;p.y=box.y+box.height/2;const c=p.matrixTransform(svg.getScreenCTM()),area=svg.getBoundingClientRect();return Math.abs(c.x-area.x-area.width/2)<2&&Math.abs(c.y-area.y-area.height/2)<2}),'diagram content is centered horizontally and vertically');
await page.locator('#uc-canvas').scrollIntoViewIfNeeded();
const canvasBox=await page.locator('#uc-canvas').boundingBox();
const wheelX=Math.round(canvasBox.x+canvasBox.width*0.7),wheelY=Math.round(canvasBox.y+canvasBox.height*0.3);
const wheelState=()=>page.evaluate(({x,y})=>{const svg=document.querySelector('#uc-svg'),p=svg.createSVGPoint();p.x=x;p.y=y;const w=p.matrixTransform(svg.getScreenCTM().inverse());return {scale:svg.getScreenCTM().a,x:w.x,y:w.y,pixelRatio:devicePixelRatio};},{x:wheelX,y:wheelY});
await page.mouse.move(wheelX,wheelY);const beforeWheel=await wheelState();
await page.keyboard.down('Control');await page.mouse.wheel(0,-120);
await page.waitForFunction(old=>document.querySelector('#uc-svg').getScreenCTM().a>old,beforeWheel.scale);
const zoomed=await wheelState();check(zoomed.scale>beforeWheel.scale,'Ctrl + scroll up zooms in');
check(Math.abs(zoomed.x-beforeWheel.x)<0.001&&Math.abs(zoomed.y-beforeWheel.y)<0.001,'wheel zoom keeps the world point under the mouse fixed');
check(Math.abs(Number(await page.inputValue('#uc-zoom'))-zoomed.scale)<0.001&&zoomed.pixelRatio===beforeWheel.pixelRatio,'zoom selector follows wheel zoom without zooming the browser');
await page.mouse.wheel(0,120);await page.keyboard.up('Control');
await page.waitForFunction(old=>document.querySelector('#uc-svg').getScreenCTM().a<old,zoomed.scale);
check((await wheelState()).scale<zoomed.scale,'Ctrl + scroll down zooms out');
const beforePan=await wheelState();await page.mouse.wheel(0,80);await page.waitForTimeout(100);
const afterPan=await wheelState();check(Math.abs(afterPan.scale-beforePan.scale)<0.001&&Math.abs(afterPan.y-beforePan.y)>1,'scroll without Ctrl still pans the diagram');
check(await page.isDisabled('#uc-save'),'zoom and pan do not mark the diagram as edited');
await page.click('#uc-fit');
await page.locator('#uc-canvas').scrollIntoViewIfNeeded();
const matrixScale=await page.evaluate(()=>document.querySelector('#uc-svg').getScreenCTM().a);
const startWorld=await page.evaluate(()=>{const m=document.querySelector('[data-node=start]').transform.baseVal.getItem(0).matrix;return {x:m.e,y:m.f}});
const start=await page.locator('[data-node=start]').boundingBox();
await page.mouse.move(start.x+start.width/2,start.y+start.height/2);await page.mouse.down();await page.mouse.move(start.x+start.width/2+45*matrixScale,start.y+start.height/2+35*matrixScale,{steps:5});await page.mouse.up();
check(await page.locator('[data-node=start]').getAttribute('transform')===`translate(${Math.round((startWorld.x+45)/20)*20} ${Math.round((startWorld.y+35)/20)*20})`,'shape dragging snaps position to the 20-unit grid');
check(await page.evaluate(()=>[...document.querySelectorAll('.uc-edge-line')].every(el=>{const d=el.getAttribute('d');if(/[CQAST]/i.test(d))return false;const nums=d.match(/-?\d+(?:\.\d+)?/g).map(Number);for(let i=2;i<nums.length;i+=2)if(nums[i]!==nums[i-2]&&nums[i+1]!==nums[i-1])return false;return true})),'every connector segment is horizontal or vertical');
// Drag a connection port from start to the first process.
const port=await page.locator('[data-node=start] .uc-port').boundingBox(),target=await page.locator('[data-node=step-1]').boundingBox();
await page.mouse.move(port.x+port.width/2,port.y+port.height/2);await page.mouse.down();await page.mouse.move(target.x+target.width/2,target.y+target.height/2,{steps:5});await page.mouse.up();
check(await page.locator('.uc-edge').count()===8,'connection can be drawn by dragging a port');
await page.selectOption('#uc-zoom','1');check(await page.evaluate(()=>Math.abs(document.querySelector('#uc-svg').getScreenCTM().a-1)<0.01),'user can zoom in to 100 percent');
await page.click('#uc-fit');check(await page.evaluate(()=>document.querySelector('#uc-zoom').value==='fit'),'fit button returns to the whole diagram');
for(const type of ['start','end','process','decision']){
await page.selectOption('#uc-add-type',type);await page.click('#uc-add');await page.fill('#uc-title','ทดสอบ '+type);await page.fill('#uc-description','รายละเอียด <script> ต้องเป็นข้อความ\n'+('ข้อความภาษาไทยและ English text อ่านครบทุกตัวอักษร '.repeat(8)));
check(await page.evaluate(()=>{const n=document.querySelector('.uc-node.is-selected'),text=n.querySelector('.uc-node-desc'),box=n.querySelector('.uc-shape').getBoundingClientRect(),r=text.getBoundingClientRect();return text.textContent===document.querySelector('#uc-description').value&&text.querySelectorAll('tspan').length>2&&r.left>=box.left-1&&r.right<=box.right+1&&r.top>=box.top-1&&r.bottom<=box.bottom+1;}),'full multi-line description fits inside '+type+' shape');
}
check(await page.locator('.uc-node').count()===12,'all four shape types can be added');
await page.click('#uc-connect');
await page.locator('[data-node=start]').focus();await page.keyboard.press('Enter');await page.locator('[data-node=step-1]').focus();await page.keyboard.press('Enter');
await page.fill('#uc-label','อนุมัติ / ส่งกลับ');await page.selectOption('#uc-head','diamond');await page.selectOption('#uc-tail','open');
check(await page.locator('.uc-edge').count()===9&&await page.locator('.uc-edge.is-selected text').textContent()==='อนุมัติ / ส่งกลับ','connection text and both arrowheads are editable');
await page.click('#uc-undo');await page.click('#uc-redo');check(await page.locator('.uc-edge').count()===9,'undo and redo preserve graph');
await page.click('#uc-save');await wait(()=>document.querySelector('#uc-status').textContent.includes('บันทึกแล้ว'));
await go('/use-cases/W-49');await wait(()=>document.querySelectorAll('.uc-node').length===12);
const stored=await page.evaluate(async()=>await(await fetch('/api/use-cases/W-49/diagram')).json());
check(stored.edges.length===9&&stored.edges.at(-1).tail==='open'&&stored.edges.at(-1).head==='diamond'&&stored.nodes[0].x===140&&stored.nodes.every(n=>n.x%20===0&&n.y%20===0),'shared positions, labels and arrowheads survive reload');
const positionsBefore=await page.evaluate(()=>[...document.querySelectorAll('.uc-node')].map(n=>n.getAttribute('transform')));
await page.click('#uc-layout-horizontal');
check(await page.evaluate(()=>{const nodes=[...document.querySelectorAll('.uc-node')].map(n=>n.getBoundingClientRect());return nodes.every((n,i)=>!i||n.left>nodes[i-1].right)}),'saved diagram can be rearranged left to right without resetting its content');
check(await page.locator('.uc-node').count()===12&&await page.locator('.uc-edge').count()===9,'horizontal layout preserves custom shapes and connections');
await page.click('#uc-undo');check(await page.evaluate(()=>[...document.querySelectorAll('.uc-node')].map(n=>n.getAttribute('transform'))).then(p=>JSON.stringify(p)===JSON.stringify(positionsBefore)),'undo restores positions before horizontal layout');
await page.locator('[data-node=start]').focus();await page.keyboard.press('Enter');await page.fill('#uc-title','งานที่ยังไม่บันทึก');
await page.evaluate(()=>document.dispatchEvent(new Event('tracker:updated')));
check(await page.inputValue('#uc-title')==='งานที่ยังไม่บันทึก','editor reinitialization preserves unsaved edits');
if(dataDir){
if(!dataDir.startsWith('/tmp/'))throw new Error('live-update check must use /tmp data');
const file=dataDir+'/milestones.json',before=readFileSync(file,'utf8');
try{const d=JSON.parse(before);d[0].title+=' · live-check';writeFileSync(file,JSON.stringify(d));await page.waitForFunction(()=>document.querySelector('#live .live-text').textContent.includes('มีข้อมูลใหม่'));check(await page.inputValue('#uc-title')==='งานที่ยังไม่บันทึก','real SSE refresh preserves unsaved diagram');}
finally{writeFileSync(file,before);}
}
const other=await browser.newPage();await other.goto(base+'/use-cases/W-49');await other.locator('.uc-node').first().waitFor();
await other.locator('[data-node=start]').focus();await other.keyboard.press('Enter');await other.fill('#uc-title','บันทึกจากอีกคน');await other.click('#uc-save');await other.waitForFunction(()=>document.querySelector('#uc-status').textContent.includes('บันทึกแล้ว'));
await page.bringToFront();await page.click('#uc-save');await wait(()=>document.querySelector('#uc-status').dataset.error==='true');
check(await page.inputValue('#uc-title')==='งานที่ยังไม่บันทึก'&&!await page.isDisabled('#uc-save'),'two-client stale save is rejected without erasing edits');
// Deleting a shape also deletes every incident edge, and undo restores both.
await other.bringToFront();await other.locator('[data-node=start]').focus();await other.keyboard.press('Enter');await other.click('#uc-delete');
check(await other.locator('.uc-node').count()===11,'selected shape can be deleted');
const remaining=await other.evaluate(()=>[...document.querySelectorAll('.uc-edge')].length);check(remaining===6,'deleting shape removes incident connections');
await other.click('#uc-undo');check(await other.locator('.uc-node').count()===12&&await other.locator('.uc-edge').count()===9,'undo restores deleted shape and connections');
await page.bringToFront();
await page.click('[data-theme-choice=dark]');await shot('usecases-dark');check(await page.locator('html').getAttribute('data-theme')==='dark','dark theme applies');
await page.setViewportSize({width:390,height:844});check(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+2),'editor fits mobile width');await shot('usecases-mobile');
// Revision history and development approval: writes go to temporary server data only.
const audit=await browser.newPage({viewport:{width:1600,height:1000}});await audit.route('https://fonts.googleapis.com/**',r=>r.abort());await audit.route('https://fonts.gstatic.com/**',r=>r.abort());
await audit.goto(base+'/use-cases/W-49',{waitUntil:'domcontentloaded'});await audit.locator('.uc-node').first().waitFor();await audit.waitForFunction(()=>!document.querySelector('#uc-approve').disabled);
await audit.waitForFunction(()=>document.querySelector('#uc-history').getAttribute('aria-busy')==='false');
await audit.locator('.uc-review-panels').screenshot({path:out+'/review-pending-light.png'});
await audit.click('[data-theme-choice=dark]');await audit.locator('.uc-review-panels').screenshot({path:out+'/review-pending-dark.png'});
await audit.setViewportSize({width:390,height:844});check(await audit.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+2),'review panels fit mobile without page overflow');await audit.locator('.uc-review-panels').screenshot({path:out+'/review-mobile.png'});
await audit.setViewportSize({width:1600,height:1000});await audit.click('[data-theme-choice=light]');
check(await audit.getByRole('button',{name:'Save diagram',exact:true}).count()===1&&await audit.getByRole('button',{name:'Arrange horizontally',exact:true}).count()===1,'workflow toolbar uses clear English action labels');
await audit.fill('#uc-reviewer','QA reviewer');await audit.fill('#uc-approval-note','Reviewed source and diagram');await audit.check('#uc-approval-confirm');await audit.click('#uc-approve');
await audit.waitForFunction(()=>document.querySelector('#uc-approval-badge').dataset.approvalState==='approved');
check(await audit.locator('#uc-approval-form').isHidden()&&await audit.locator('#uc-approved-by').textContent()==='QA reviewer','approved state shows reviewer metadata instead of an empty approval form');
check(await audit.locator('#uc-approved-note').textContent()==='Reviewed source and diagram','approval note has its own readable section');
await audit.locator('.uc-review-panels').screenshot({path:out+'/review-approved.png'});
let approved=await audit.evaluate(async()=>await(await fetch('/api/use-cases/W-49/approval')).json());check(approved.approved&&approved.latest.reviewer==='QA reviewer'&&approved.latest.at,'approval persists reviewer, timestamp and exact revision');
const oldSnapshot=await audit.evaluate(async()=>await(await fetch('/api/use-cases/W-49/revisions/1')).json());
await audit.locator('[data-node=start]').focus();await audit.keyboard.press('Enter');await audit.fill('#uc-title','New revision for rollback');
check(await audit.isDisabled('#uc-approve'),'unsaved changes cannot be approved');await audit.click('#uc-save');await audit.waitForFunction(()=>document.querySelector('#uc-status').textContent.includes('บันทึกแล้ว'));
await audit.waitForFunction(()=>document.querySelector('#uc-approval-badge').dataset.approvalState==='stale');check(true,'saving a change invalidates prior development approval');
await audit.locator('#uc-history').evaluate(el=>el.open=true);await audit.waitForFunction(()=>document.querySelectorAll('#uc-history-rows tr').length===3);
check(await audit.locator('#uc-history-rows tr').count()===3,'each saved revision appears in history');
audit.once('dialog',dialog=>dialog.accept());await audit.click('[data-uc-restore="1"]');await audit.waitForFunction(()=>document.querySelector('#uc-status').textContent.includes('new revision 4'));
const restored=await audit.evaluate(async()=>await(await fetch('/api/use-cases/W-49/diagram')).json());
check(restored.revision===4&&JSON.stringify(restored.nodes)===JSON.stringify(oldSnapshot.nodes)&&JSON.stringify(restored.edges)===JSON.stringify(oldSnapshot.edges),'rollback restores the exact saved content as a new revision');
await audit.waitForFunction(()=>document.querySelectorAll('#uc-history-rows tr').length===4);
check(await audit.locator('#uc-history-rows [data-uc-restore="2"]').count()===1&&await audit.locator('#uc-history-rows [data-uc-restore="3"]').count()===1,'rollback preserves all intermediate revisions');
await audit.waitForFunction(()=>document.querySelector('#uc-approval-badge').dataset.approvalState==='stale');check(await audit.locator('#uc-approval-history').textContent().then(t=>t.includes('QA reviewer')),'rollback requires reapproval and preserves approval history');
check(await audit.locator('#uc-history-rows tr.is-current').count()===1,'revision history clearly marks the current revision');
await audit.route('**/api/use-cases/W-49/revisions',route=>route.abort());await audit.click('#uc-refresh-history');await audit.waitForFunction(()=>document.querySelector('#uc-history-status').dataset.error==='true');check(await audit.locator('#uc-history-rows tr').count()===4,'history refresh failure retains already loaded revisions');
await audit.unroute('**/api/use-cases/W-49/revisions');await audit.click('#uc-refresh-history');await audit.waitForFunction(()=>document.querySelector('#uc-history').getAttribute('aria-busy')==='false'&&document.querySelector('#uc-history-status').dataset.error==='false');
const catalog=await browser.newPage();await catalog.goto(base+'/use-cases?module=REG&q=W-49');check(await catalog.locator('.uc-row [data-approval-state]').textContent()==='Needs reapproval','table exposes current development approval status');
const initial=await browser.newPage({viewport:{width:1600,height:1000}});await initial.goto(base+'/use-cases/W-51',{waitUntil:'domcontentloaded'});await initial.locator('.uc-node').first().waitFor();await initial.waitForFunction(()=>!document.querySelector('#uc-approve').disabled);
await initial.fill('#uc-reviewer','Initial QA');await initial.check('#uc-approval-confirm');await initial.click('#uc-approve');await initial.waitForFunction(()=>document.querySelector('#uc-approval-badge').dataset.approvalState==='approved');
const initialApproval=await initial.evaluate(async()=>({approval:await(await fetch('/api/use-cases/W-51/approval')).json(),history:await(await fetch('/api/use-cases/W-51/revisions')).json()}));
check(initialApproval.approval.approved&&initialApproval.approval.revision===1&&initialApproval.history.length===1,'approving an initial draft first saves its exact diagram as revision one');
// PNG export covers the full scene at any viewport zoom.
await initial.selectOption('#uc-zoom','1');
const scene=await initial.evaluate(()=>{const b=document.querySelector('#uc-svg .uc-graph').getBBox(),w=Math.ceil(b.width+64),h=Math.ceil(b.height+64),s=Math.min(2,16384/w,16384/h,Math.sqrt(16000000/(w*h)));return {width:Math.floor(w*s),height:Math.floor(h*s)}});
await initial.locator('.uc-more summary').click();await initial.click('#uc-export-png');check(await initial.locator('#uc-export-dialog').isVisible(),'Export PNG opens a background selection dialog');
for(const background of ['white','transparent']){
await initial.check(`input[name="background"][value="${background}"]`);const downloading=initial.waitForEvent('download');await initial.click('#uc-export-download');
await initial.waitForFunction(()=>!document.querySelector('#uc-export-download').disabled);if(await initial.locator('#uc-export-status').getAttribute('data-error')==='true')throw Error(await initial.locator('#uc-export-status').textContent());
const download=await downloading;await download.saveAs(out+'/export-'+background+'.png');const bytes=readFileSync(await download.path());
const pixels=await initial.evaluate(async b64=>{const img=new Image();img.src='data:image/png;base64,'+b64;await img.decode();const c=document.createElement('canvas');c.width=img.naturalWidth;c.height=img.naturalHeight;const ctx=c.getContext('2d');ctx.drawImage(img,0,0);return {width:c.width,height:c.height,pixel:[...ctx.getImageData(0,0,1,1).data]};},bytes.toString('base64'));
check(pixels.width===scene.width&&pixels.height===scene.height,'PNG '+background+' includes the full diagram rather than the zoomed viewport');
check(background==='white'?pixels.pixel.every(v=>v===255):pixels.pixel[3]===0,'PNG background is '+background);
}
await initial.click('#uc-export-close');
check(errors.length===0,'no browser JavaScript exceptions');console.log(`${passed} checks passed`);
}catch(e){console.error(e);process.exitCode=1;}finally{await browser.close();}