tracker/e2e/mockups-check.mjs
// Writes test graphs: use an isolated tracker -data folder, never the shared 8099 server.
import {pathToFileURL} from 'node:url';
import {readFileSync,writeFileSync} from 'node:fs';
const [chrome,base,out,modulePath,dataDir]=process.argv.slice(2);
if(!dataDir||new URL(base).port==='8099')throw Error('temporary server and data directory required');
const {chromium}=await import(pathToFileURL(modulePath).href),browser=await chromium.launch({executablePath:chrome,headless:true,args:['--no-sandbox']});
const page=await browser.newPage({viewport:{width:1600,height:1100}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.route('https://fonts.googleapis.com/**',r=>r.abort());await page.route('https://fonts.gstatic.com/**',r=>r.abort());
let passed=0;const check=(ok,msg)=>{if(!ok)throw Error(msg);passed++;console.log('PASS '+msg)};
const go=path=>page.goto(base+path,{waitUntil:'domcontentloaded'});
const ready=()=>page.waitForFunction(()=>document.querySelectorAll('.mock-node').length>0&&!document.querySelector('#mock-reload').disabled);
const graph=()=>page.evaluate(async()=>await(await fetch('/api/mockups/graphs/'+document.querySelector('#mock-editor').dataset.key)).json());
const choose=async id=>{await page.locator(`[data-node="${id}"]`).focus();await page.keyboard.press('Enter');};
try {
await go('/mockup/screens');check(await page.locator('.mock-screen-row').count()===170,'Screen Listing covers 164 screen IDs and six proposal references');
check(await page.locator('.mock-nav a').allTextContents().then(x=>x.join('|').includes('Sitemap')&&x.join('|').includes('Page flow')&&x.join('|').includes('Screen Listing')),'Mockup sidebar has all three submenu pages');
check(await page.evaluate(()=>getComputedStyle(document.querySelector('.mock-table-wrap')).maxHeight==='none'),'screen table has no fixed height');
await page.fill('input[name=q]','A01');await page.locator('.mock-filters button').click();check(await page.locator('.mock-screen-row').count()===1,'screen search filters stable IDs');
await page.locator('.mock-screen-link').click();check(await page.locator('h2').textContent()==='สร้างบัญชีผู้สมัคร','screen opens its individual detail');await page.locator('#mock-detail-image').evaluate(img=>img.decode());check(await page.locator('#mock-detail-image').evaluate(img=>img.naturalWidth>0),'detail shows actual mockup artifact');
await page.click('[data-variant=error]');await page.locator('#mock-detail-image').evaluate(img=>img.decode());check(await page.locator('#mock-detail-image').getAttribute('src').then(x=>x.includes('/error/')),'state selector changes preview artifact');check(await page.locator('.mock-detail-info').textContent().then(x=>x.includes('screen.json')&&x.includes('app-shell')),'detail exposes manifest, shared components and provenance');await page.screenshot({path:out+'/mockup-screen-detail.png'});
await go('/mockup/page-flow');check(await page.locator('[data-flow-search]').count()===162,'Page flow index includes every use case');await page.fill('#mock-flow-search','W-41');check(await page.locator('[data-flow-search]:visible').count()===1,'use-case search works');
await go('/mockup/sitemap');await ready();check(await page.locator('.mock-node[data-type=screen]').count()===170,'Sitemap renders reusable screen thumbnails');
check((await graph()).graph.direction==='TB','Sitemap uses top-to-bottom orientation');
check(await page.locator('.mock-node-image').count()===170,'every sitemap screen has an image shape');
check(await page.evaluate(()=>[...document.querySelectorAll('.mock-edge-line')].every(p=>!/[CQAST]/i.test(p.getAttribute('d')))),'Sitemap edges use orthogonal connectors');
check(await page.evaluate(()=>{const nodes=[...document.querySelectorAll('.mock-node')].map(n=>({x:n.transform.baseVal.getItem(0).matrix.e,y:n.transform.baseVal.getItem(0).matrix.f}));return Math.max(...nodes.map(n=>n.x))-Math.min(...nodes.map(n=>n.x))<5000&&Math.max(...nodes.map(n=>n.y))>Math.max(...nodes.map(n=>n.x));}),'large Sitemap wraps into bounded top-down rows instead of one wide strip');
await page.selectOption('#mock-scope-module','ADM');const visible=await page.locator('.mock-node').count();check(visible>0&&visible<170,'Sitemap module scope filters the visible screen set');check(await page.isDisabled('#mock-save'),'scope filtering changes the view without dirtying graph data');await page.selectOption('#mock-scope-module','');check(await page.locator('.mock-node').count()===170,'clearing the scope restores all screens');
await page.locator('#mock-canvas').scrollIntoViewIfNeeded();await page.screenshot({path:out+'/mockup-sitemap.png'});
await page.fill('#mock-find','A01');await page.click('#mock-focus');check(await page.inputValue('#mock-screen')==='A01','find focuses the requested screen and properties');
const before=await page.evaluate(()=>({transform:document.querySelector('[data-node=A01]').getAttribute('transform'),scale:document.querySelector('#mock-svg').getScreenCTM().a}));const box=await page.locator('[data-node=A01]').boundingBox();await page.mouse.move(box.x+box.width/2,box.y+30*before.scale);await page.mouse.down();await page.mouse.move(box.x+box.width/2+45*before.scale,box.y+65*before.scale,{steps:5});await page.mouse.up();
check(await page.locator('[data-node=A01]').getAttribute('transform').then(t=>t!==before.transform&&t.match(/-?\d+(?:\.\d+)?/g).map(Number).every(n=>n%20===0)),'screen thumbnail dragging snaps to grid');
await page.click('#mock-undo');check(await page.locator('[data-node=A01]').getAttribute('transform')===before.transform,'Undo restores a dragged screen');
await page.click('#mock-save');await page.waitForFunction(()=>document.querySelector('#mock-status').textContent.includes('บันทึกแล้ว'));check((await graph()).graph.revision===1,'Sitemap positions save to shared revision one');check((await graph()).graph.nodes.length===170,'saved graph retains screens hidden by previous scope filters');
await go('/mockup/page-flow/W-41');await ready();const initial=await graph();check(initial.graph.direction==='LR','Page flow uses left-to-right orientation');check(await page.locator('[data-type=decision]').count()===2,'canonical use-case decisions remain visible');
check(await page.locator('.mock-edge text').allTextContents().then(x=>x.includes('ไม่จำเป็น — สมัครด้วยตนเอง')),'guardian bypass branch retains source label');check(await page.locator('.mock-node-image').count()>5,'Page flow shows screen images for human steps and alternate outcomes');
check(await page.locator('[data-node=step-1]').getAttribute('data-screen')==='A01'&&await page.locator('[data-node=step-4]').getAttribute('data-screen')==='A05','application account and attachment steps map to distinct proposed screens');
await page.locator('#mock-canvas').scrollIntoViewIfNeeded();await page.screenshot({path:out+'/mockup-page-flow.png'});
const boxCanvas=await page.locator('#mock-canvas').boundingBox();await page.mouse.move(boxCanvas.x+boxCanvas.width*.6,boxCanvas.y+boxCanvas.height*.5);const scale=await page.evaluate(()=>document.querySelector('#mock-svg').getScreenCTM().a);await page.keyboard.down('Control');await page.mouse.wheel(0,-120);await page.keyboard.up('Control');await page.waitForFunction(s=>document.querySelector('#mock-svg').getScreenCTM().a>s,scale);check(true,'Ctrl+wheel zooms the screen graph');check(await page.isDisabled('#mock-save'),'zoom does not dirty the graph');
await choose('step-1');await page.selectOption('#mock-variant','error');check(await page.locator('[data-node=step-1] image').getAttribute('href').then(x=>x.includes('/error/')),'screen occurrence supports its own state variant');
await page.selectOption('#mock-add-screen','A01');await page.click('#mock-add');check(await page.locator('[data-screen=A01]').count()===2,'one screen artifact can appear multiple times in a flow');
const added=await page.locator('.mock-node.is-selected').getAttribute('data-node');await page.fill('#mock-title','หน้าจอซ้ำสำหรับตรวจข้อมูล');await page.click('#mock-connect');await choose('step-1');await choose(added);check(await page.locator('.mock-edge').count()===initial.graph.edges.length+1,'Connect adds a link between screen occurrences');
const edge=page.locator('.mock-edge').last();await edge.focus();await page.keyboard.press('Enter');await page.fill('#mock-label','แก้ไขข้อมูล');await page.selectOption('#mock-head','open');await page.selectOption('#mock-tail','diamond');check(await page.locator('.mock-edge.is-selected text').textContent()==='แก้ไขข้อมูล','connection labels and arrowheads can be edited');
await page.click('#mock-save');await page.waitForFunction(()=>document.querySelector('#mock-status').textContent.includes('บันทึกแล้ว'));let saved=await graph();check(saved.graph.revision===1&&saved.graph.nodes.some(n=>n.id===added),'flow save preserves mapping, new screen and connection');
await go('/mockup/page-flow/W-41');await ready();check(await page.locator('[data-node=step-1] image').getAttribute('href').then(x=>x.includes('/error/')),'saved variant survives a page reload');
await choose('step-1');await page.fill('#mock-title','ฉบับแก้ไขที่สอง');await page.click('#mock-save');await page.waitForFunction(()=>document.querySelector('#mock-status').textContent.includes('Revision 2'));
page.once('dialog',d=>d.accept());await page.click('[data-restore="1"]');await page.waitForFunction(()=>document.querySelector('#mock-status').textContent.includes('Revision 3'));saved=await graph();check(saved.graph.revision===3&&saved.graph.nodes.find(n=>n.id==='step-1').title!=='ฉบับแก้ไขที่สอง','Restore creates a new revision with exact prior content');check(await page.locator('.mock-history-item').count()===3,'revision history retains intermediate versions');
await choose('step-1');await page.fill('#mock-title','งานที่ยังไม่บันทึก');const file=dataDir+'/milestones.json',original=readFileSync(file,'utf8');try{const d=JSON.parse(original);d[0].title+=' · mockup SSE';writeFileSync(file,JSON.stringify(d));await page.waitForFunction(()=>document.querySelector('#live .live-text').textContent.includes('มีข้อมูลใหม่'));check(await page.inputValue('#mock-title')==='งานที่ยังไม่บันทึก','live updates preserve unsaved mockup edits');}finally{writeFileSync(file,original)}
const other=await browser.newPage();await other.goto(base+'/mockup/page-flow/W-41');await other.waitForFunction(()=>document.querySelectorAll('.mock-node').length>0&&!document.querySelector('#mock-reload').disabled);await other.locator('[data-node=step-1]').focus();await other.keyboard.press('Enter');await other.fill('#mock-title','ฉบับจากอีกหน้าต่าง');await other.click('#mock-save');await other.waitForFunction(()=>document.querySelector('#mock-status').textContent.includes('Revision 4'));await other.close();await page.bringToFront();await page.click('#mock-save');await page.waitForFunction(()=>document.querySelector('#mock-status').dataset.error==='true');check(await page.inputValue('#mock-title')==='งานที่ยังไม่บันทึก'&&!await page.isDisabled('#mock-save'),'stale save is rejected and keeps local edits');
// Read-only mobile screenshots, leave dirty editor through a handled dialog.
page.on('dialog',d=>d.accept());await page.setViewportSize({width:390,height:844});await go('/mockup/screens/A01');check(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+2),'screen detail fits mobile');await go('/mockup/page-flow/W-77');await ready();check(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+2),'flow editor fits mobile width');await page.screenshot({path:out+'/mockup-mobile.png'});
check(errors.length===0,'no browser JavaScript exceptions');console.log(passed+' Mockup checks passed');
}catch(e){console.error(e);await page.screenshot({path:out+'/mockup-failure.png'});process.exitCode=1}finally{await browser.close()}