// Isolated browser regression: every network request is served locally or mocked. // Requires Playwright and Chromium; see docs/qa-inquiry-ui-redesign-tasks.md. import assert from 'node:assert/strict'; import {readFile, mkdir} from 'node:fs/promises'; import {createRequire} from 'node:module'; import {fileURLToPath} from 'node:url'; import {join} from 'node:path'; import {tmpdir} from 'node:os'; const require = createRequire(import.meta.url); const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); const root = fileURLToPath(new URL('../../', import.meta.url)).replace(/\/$/, ''); const OWNER = '9951229e-eea5-4376-96f4-78989bc3a506'; const id = n => '01900000-0000-7000-8000-' + String(n).padStart(12, '0'); const browser = await chromium.launch({executablePath:process.env.CHROMIUM_EXECUTABLE_PATH || undefined,headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}); const images = process.env.UI_SCREENSHOT_DIR || join(tmpdir(), 'qa-inquiry-ui'); await mkdir(images,{recursive:true}); const errors = []; try { const context = await browser.newContext({viewport:{width:1900,height:1000}}); const page = await context.newPage();page.setDefaultTimeout(8000);page.setDefaultNavigationTimeout(8000); page.on('pageerror', error => errors.push(error.message)); const records = Array.from({length:15},(_,i)=>({id:id(i+1),requester_id:OWNER,requester_name:'송대일',requester_affiliation:'총괄기획&기술개발센터',requester_department:'IS3',title:['PDF 출력 시 이미지 표시 관련 문의','EG-BIM 로그인 오류 문의','모델링 기능 개선 요청'][i%3] + (i>2?' '+(i+1):''),contents:'EG-BIM 이용 중 궁금한 사항을 문의합니다.\n\n이 영역에 문의 내용과 첨부파일, 답변이 표시됩니다.',Category:['ERROR_QNA','GENERAL_QNA','IMPROVEMENT_QNA'][i%3],created_at:'2026-11-02T01:14:00Z',feedback_status:['NEW','RECEIVED','IN_PROGRESS','PENDING','COMPLETED'][i%5],is_secret:i===0,attachments:i===0?[{url:'/fixture/manual.pdf',original_file_name:'화면 설명.pdf'}]:[]})); const comments = new Map(records.map(r=>[r.id,[{id:id(101),author_id:OWNER,author_name:'송대일',content:'확인 부탁드립니다.',created_at:'2026-11-02T02:00:00Z'},{id:id(102),author_id:'other',author_name:'고객지원',content:'접수되었습니다.',created_at:'2026-11-02T02:15:00Z'}]])); const mutations = []; let failCreate = false, holdCreate = null, slowId = '', releaseSlow; await page.route('**/*',async route=>{ const request = route.request(); const url = new URL(request.url()); if(url.origin!=='https://qa.example.test') return route.abort(); const send = (body,status=200)=>route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)}); if(url.pathname==='/auth/session') return send({authenticated:true,user:{requesterId:OWNER,userUuid:OWNER,tenantId:id(555),name:'송대일',email:'fixture@example.test',company:'총괄기획&기술개발센터',department:'IS3'}}); if(url.pathname.startsWith('/api/')) { const method = request.method(); if(method!=='GET') mutations.push({url:url.pathname,method,body:request.postData(),type:request.headers()['content-type']}); if(url.pathname==='/api/feedbacks') { if(method==='GET') return send({items:records,meta:{total:records.length}}); if(holdCreate) await holdCreate; if(failCreate) {failCreate=false;return send({message:'모의 등록 오류'},500);} let payload; if(request.headers()['content-type'].includes('application/json')) payload=JSON.parse(request.postData()); else {const fields={};const text=request.postData(); for(const name of ['feedbackId','title','contents','Category','is_secret']) fields[name]=text.match(new RegExp('name="'+name+'"\\r\\n\\r\\n([^\\r]*)'))?.[1];payload=fields;} const record={...records[0],...payload,id:payload.feedbackId,attachments:[]};records.unshift(record); comments.set(record.id,[]);return send({id:record.id}); } const parts = url.pathname.split('/'); const record=records.find(r=>r.id===parts[3]); if(parts[3]===slowId && method==='GET' && parts.length===4) await new Promise(r=>{releaseSlow=r;}); if(!record) return send({message:'not found'},404); if(parts[4]==='comments') { let list=comments.get(record.id)||[]; if(method==='GET') return send({comments:list}); if(method==='POST') { const content=request.headers()['content-type'].includes('application/json')?JSON.parse(request.postData()).content:request.postData().match(/name="content"\r\n\r\n([^\r]*)/)[1];const comment={id:id(200+list.length),author_id:OWNER,author_name:'송대일',content};list.push(comment);comments.set(record.id,list);return send({comment}); } if(method==='PATCH') {const comment=list.find(c=>c.id===parts[5]);Object.assign(comment,JSON.parse(request.postData()));return send({comment});} if(method==='DELETE') {comments.set(record.id,list.filter(c=>c.id!==parts[5]));return send({ok:true});} } if(parts[4]==='completion-confirmation') {record.feedback_status='COMPLETED'; record.completion_requested=false;return send({ok:true});} if(method==='PATCH') {Object.assign(record,JSON.parse(request.postData()));return send(record);} if(method==='DELETE') {records.splice(records.indexOf(record),1);return send({ok:true});} return send({feedback:record}); } if(url.pathname.startsWith('/fixture/')) return route.fulfill({body:'fixture'}); const path=url.pathname.endsWith('/')?url.pathname+'index.html':url.pathname; const type={css:'text/css',js:'application/javascript',html:'text/html',svg:'image/svg+xml',webp:'image/webp',woff2:'font/woff2'}[path.split('.').pop()]||'application/octet-stream'; try{return route.fulfill({contentType:type,body:await readFile(root+path)});}catch{return route.abort();} }); const open = async n=>{await page.locator('#qaRows tr[data-id="'+id(n)+'"] a').click();await page.waitForFunction(()=>document.querySelector('#commentCount')?.textContent==='2');}; const close = async()=>{await page.locator('dialog[open] [data-modal-close]').first().click();await page.waitForFunction(()=>!document.querySelector('dialog[open]'));await page.waitForURL(url=>!url.searchParams.has('view'));}; const decide = accept => page.once('dialog',d=>accept?d.accept():d.dismiss()); await page.goto('https://qa.example.test/egbim/'); await page.waitForFunction(()=>document.querySelector('#resultCount').textContent==='총 15건'); assert.equal(await page.locator('#tab-tova').isVisible(),false);assert.equal(await page.locator('#tab-gaia').isVisible(),false); assert.equal(await page.locator('#tab-egbim').evaluate(e=>Math.abs(e.getBoundingClientRect().x+e.getBoundingClientRect().width/2-innerWidth/2)<1),true);assert.equal(await page.locator('.inquiry-remote').getAttribute('href'),'https://939.co.kr/saman/'); await page.screenshot({path:images+'/desktop-list.png',fullPage:true}); await page.locator('#query').fill('로그임');await page.locator('#searchForm').evaluate(f=>f.requestSubmit());assert.equal(await page.locator('#qaRows tr').count(),5); await page.locator('#qaRows tr a').first().click();await page.waitForSelector('#detailModal[open]');await close();assert.equal(await page.locator('#query').inputValue(),'로그임'); await page.locator('#query').fill('');await page.locator('#searchForm').evaluate(f=>f.requestSubmit()); await page.locator('#pagination [data-page="2"]').first().click();assert.equal(await page.locator('#qaRows tr').count(),5); await page.locator('#pagination [data-page="1"]').first().click(); await open(1);assert.equal(await page.locator('#editButton').isVisible(),true);assert.equal(await page.locator('#editForm').isVisible(),false); assert.equal(await page.locator('[data-detail-attachments] a').getAttribute('href'),'/fixture/manual.pdf'); assert.equal(await page.locator('[data-comment-edit="'+id(102)+'"]').count(),0); await page.screenshot({path:images+'/desktop-detail.png'}); await page.locator('#editButton').click();await page.locator('#editTitle').fill('수정한 제목');decide(false);await page.locator('#editCancel').click();assert.equal(await page.locator('#editForm').isVisible(),true); await page.locator('#editForm').evaluate(f=>f.requestSubmit());await page.waitForFunction(()=>document.querySelector('[data-detail-title]').textContent==='수정한 제목'); await page.locator('[data-comment-edit="'+id(101)+'"]').click();await page.locator('[data-comment-edit-input]').fill('수정한 댓글');decide(false);await page.keyboard.press('Escape');assert.equal(await page.locator('[data-comment-edit-input]').inputValue(),'수정한 댓글'); await page.locator('[data-comment-save]').click();await page.waitForFunction(()=>document.querySelector('.comment-content')?.textContent==='수정한 댓글'); await page.locator('#commentContent').fill('이미지 댓글');await page.locator('#commentImages').setInputFiles({name:'example.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jqnsAAAAASUVORK5CYII=','base64')});assert.equal(await page.locator('#commentImagePreview img').count(),1); await page.locator('#commentForm').evaluate(f=>f.requestSubmit());await page.waitForFunction(()=>document.querySelector('#commentCount').textContent==='3');assert.match(mutations.at(-1).body,/name="attachments"; filename="example.png"/); decide(true);await page.locator('[data-comment-delete="'+id(202)+'"]').click();await page.waitForFunction(()=>document.querySelector('#commentCount').textContent==='2');await close();assert.match(await page.locator('#qaRows').textContent(),/수정한 제목/); await page.locator('#writeButton').click();await page.waitForFunction(()=>document.querySelector('#writeDepartment')?.value==='IS3');assert.equal(await page.locator('#writeCompany').inputValue(),'총괄기획&기술개발센터');assert.equal(await page.locator('#writeCompany').getAttribute('readonly'),''); await page.screenshot({path:images+'/desktop-write.png'}); await page.keyboard.press('Shift+Tab'); assert.equal(await page.evaluate(()=>Boolean(document.activeElement.closest('#composeModal'))),true); await page.keyboard.press('Tab'); assert.equal(await page.evaluate(()=>Boolean(document.activeElement.closest('#composeModal'))),true); await page.locator('#category').selectOption('일반문의');await page.locator('#title').fill('등록 테스트');await page.locator('#content').fill('문의 내용');await page.locator('#secret').check();await page.locator('#attachment').setInputFiles([{name:'a.txt',mimeType:'text/plain',buffer:Buffer.from('one')},{name:'b.txt',mimeType:'text/plain',buffer:Buffer.from('two')}]); decide(false);await page.keyboard.press('Escape');assert.equal(await page.locator('#attachment').evaluate(i=>i.files.length),2); decide(false);await page.goBack();await page.waitForURL(url=>url.searchParams.get('view')==='write');assert.equal(await page.locator('#title').inputValue(),'등록 테스트'); failCreate=true;await page.locator('#qaForm').evaluate(f=>f.requestSubmit());await page.waitForFunction(()=>document.querySelector('#formError').textContent.includes('모의 등록 오류'));assert.equal(await page.locator('#attachment').evaluate(i=>i.files.length),2); let releaseCreate;holdCreate=new Promise(r=>releaseCreate=r);await page.locator('#qaForm').evaluate(f=>f.requestSubmit());await page.waitForFunction(()=>document.querySelector('#submitButton').disabled);await page.keyboard.press('Escape');assert.equal(await page.locator('#composeModal').isVisible(),true);releaseCreate();holdCreate=null; await page.waitForFunction(()=>document.querySelector('#detailModal[open] [data-detail-title]')?.textContent==='등록 테스트');await page.waitForFunction(()=>document.querySelector('#commentCount')?.textContent==='0'); assert.match(mutations.at(-1).body,/name="images"; filename="a.txt"/);assert.match(mutations.at(-1).body,/name="images"; filename="b.txt"/);assert.equal(mutations.filter(m=>m.url==='/api/feedbacks'&&m.method==='POST').length,2); decide(true);await page.locator('#deleteButton').click();await page.waitForFunction(()=>!document.querySelector('dialog[open]'));await page.waitForURL(url=>!url.searchParams.has('view'));assert.equal(await page.locator('#resultCount').textContent(),'총 15건'); // Direct old addresses and clean browser back/forward. await page.goto('https://qa.example.test/egbim/detail.html?id='+id(2));await page.waitForFunction(()=>document.querySelector('#detailModal[open] [data-detail-title]')?.textContent==='EG-BIM 로그인 오류 문의');await page.goBack();await page.waitForFunction(()=>!document.querySelector('dialog[open]'));await page.goForward();await page.waitForSelector('#detailModal[open]');await close(); await page.goto('https://qa.example.test/egbim/write.html');await page.waitForSelector('#composeModal[open]');await page.locator('#title').fill('폐기 확인');decide(false);await page.mouse.click(2,2);assert.equal(await page.locator('#composeModal').isVisible(),true);decide(true);await page.mouse.click(2,2);await page.waitForFunction(()=>!document.querySelector('dialog[open]'));await page.waitForURL(url=>!url.searchParams.has('view')); // Late detail responses stay in detached modal roots. slowId=id(4);await page.locator('#qaRows tr[data-id="'+slowId+'"] a').click();await page.waitForFunction(()=>document.querySelector('#detailModal[open]')); for(let tries=0;!releaseSlow&&tries<400;tries++) await new Promise(r=>setTimeout(r,20));assert.equal(typeof releaseSlow,'function');await close();await open(2);releaseSlow();slowId='';await page.waitForTimeout(100);assert.equal(await page.locator('[data-detail-title]').textContent(),'EG-BIM 로그인 오류 문의');await close(); records[0].completion_requested=true;await page.locator('#qaRows tr[data-id="'+records[0].id+'"] a').click();await page.waitForSelector('#completionButton:visible');await page.locator('#completionButton').click();await page.waitForSelector('#completionDoneNotice:visible');await close();assert.equal(await page.locator('#qaRows tr').first().locator('.inquiry-status').textContent(),'완료'); // Repeat open/close: one request per submit, no previous draft retained. for(let n=0;n<3;n++){await page.locator('#writeButton').click();assert.equal(await page.locator('#title').inputValue(),'');await close();} await page.locator('#writeButton').click();await page.locator('#category').selectOption('오류문의');await page.locator('#title').fill('JSON 등록');await page.locator('#content').fill('첨부 없이 등록');await page.locator('#qaForm').evaluate(f=>f.requestSubmit());await page.waitForFunction(()=>document.querySelector('[data-detail-title]')?.textContent==='JSON 등록');assert.match(mutations.at(-1).type,/application\/json/);assert.equal(mutations.filter(m=>m.url==='/api/feedbacks'&&m.method==='POST').length,3);await close(); await page.setViewportSize({width:390,height:844});await page.evaluate(()=>scrollTo(0,0));await page.screenshot({path:images+'/mobile-list.png',fullPage:true}); assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); assert.equal(await page.locator('#tab-egbim').evaluate(e=>Math.abs(e.getBoundingClientRect().x+e.getBoundingClientRect().width/2-innerWidth/2)<1),true); await page.locator('#writeButton').click();assert.equal(await page.locator('.inquiry-form-grid').evaluate(e=>getComputedStyle(e).gridTemplateColumns.split(' ').length),1);await page.screenshot({path:images+'/mobile-write.png'}); assert.equal(await page.locator('#composeModal').evaluate(d=>d.scrollHeight>d.clientHeight),true);await close(); await page.locator('#qaRows tr a').first().evaluate(a=>a.click());await page.waitForSelector('#commentForm');await page.screenshot({path:images+'/mobile-detail.png'});assert.equal(await page.locator('#detailModal').evaluate(d=>d.getBoundingClientRect().right<=innerWidth),true);await close(); await page.goto('https://qa.example.test/egbim/?view=detail&id=missing'); await page.waitForFunction(()=>document.querySelector('[data-detail-title]')?.textContent==='문의 내용을 찾을 수 없습니다.'); assert.equal(await page.locator('#editButton').isVisible(),false); assert.equal(await page.locator('#commentForm').isVisible(),false); await close(); assert.deepEqual(errors,[]);console.log(JSON.stringify({result:'PASS',mutations:mutations.length,screenshots:images}));await context.close(); for (const scenario of ['owner', 'other', 'same-name-no-id', 'anonymous-with-stale-user', 'session-unavailable', 'delayed-session']) { const context = await browser.newContext(); const page = await context.newPage(); const viewer = scenario === 'owner' || scenario === 'delayed-session' ? OWNER : 'ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916'; const currentUser = { requesterId: viewer, userUuid: viewer, ssoSubject: viewer, tenantId: 'fixture-tenant', name: '동명이인', email: 'fixture@example.test', loginId: 'fixture@example.test' }; let releaseSession; const sessionGate = new Promise(resolve => { releaseSession = resolve; }); if (scenario.includes('stale') || scenario === 'session-unavailable') await page.addInitScript(({ OWNER }) => { sessionStorage.setItem('descopeUserId', OWNER); sessionStorage.setItem('loginId', 'fixture@example.test'); sessionStorage.setItem('userName', '동명이인'); }, { OWNER }); await page.route('**/*', async route => { const url = new URL(route.request().url()); if (url.origin !== 'https://qa.example.test') return route.abort(); if (route.request().method() !== 'GET') throw new Error('Unexpected mutation during visibility test'); const send = body => route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }); if (url.pathname === '/auth/session') { if (scenario === 'delayed-session') await sessionGate; if (scenario === 'session-unavailable') return route.fulfill({ status: 503, contentType: 'application/json', body: '{}' }); return send(scenario === 'anonymous-with-stale-user' ? { authenticated: false, user: null } : { authenticated: true, user: currentUser }); } if (url.pathname === '/api/feedbacks') return send({items: [], meta: {total: 0}}); if (url.pathname.endsWith('/comments')) return send({ comments: [ { id: id(101), author_id: viewer, author_name: '동명이인', content: '본인 댓글' }, { id: id(102), author_id: scenario === 'same-name-no-id' ? '' : 'another-user', author_name: '동명이인', content: '다른 사람 댓글' } ] }); if (url.pathname === '/api/feedbacks/' + id(1)) return send({ feedback: { id: id(1), requester_id: scenario === 'same-name-no-id' ? '' : OWNER, requester_name: '동명이인', title: '테스트 문의', contents: '본문', Category: 'GENERAL_QNA' } }); try { const path = url.pathname.endsWith('/') ? url.pathname + 'index.html' : url.pathname; const contentType = path.endsWith('.css') ? 'text/css' : path.endsWith('.js') ? 'application/javascript' : path.endsWith('.html') ? 'text/html' : 'application/octet-stream'; return route.fulfill({ contentType, body: await readFile(root + path) }); } catch { return route.abort(); } }); await page.goto('https://qa.example.test/egbim/?view=detail&id=' + id(1), { waitUntil: 'domcontentloaded' }); if (scenario === 'delayed-session') { await page.waitForFunction(() => document.querySelector('[data-detail-title]').textContent === '테스트 문의'); assert.equal(await page.locator('#editButton').isVisible(), false, 'edit must be hidden while session is pending'); assert.equal(await page.locator('#deleteButton').isVisible(), false, 'delete must be hidden while session is pending'); releaseSession(); } await page.waitForFunction(() => document.querySelector('#commentCount').textContent === '2'); await page.waitForTimeout(50); const result = { scenario, postEdit: await page.locator('#editButton').isVisible(), postDelete: await page.locator('#deleteButton').isVisible(), ownComment: await page.locator('[data-comment-edit="' + id(101) + '"]').isVisible(), otherCommentCount: await page.locator('[data-comment-edit="' + id(102) + '"], [data-comment-delete="' + id(102) + '"]').count() }; console.log(JSON.stringify(result)); const expectedPost = scenario === 'owner' || scenario === 'delayed-session'; const expectedComment = !['anonymous-with-stale-user', 'session-unavailable'].includes(scenario); assert.equal(result.postEdit, expectedPost, scenario + ' post edit'); assert.equal(result.postDelete, expectedPost, scenario + ' post delete'); assert.equal(result.ownComment, expectedComment, scenario + ' own comment'); assert.equal(result.otherCommentCount, 0, scenario + ' other comment'); if (scenario === 'owner') { await page.locator('#editButton').click(); assert.equal(await page.locator('#editForm').isVisible(), true); await page.locator('#editCancel').click(); assert.equal(await page.locator('#editForm').isVisible(), false); } await context.close(); } }finally{await browser.close();}