Files
baron_qa_write/tests/browser/inquiry-ui.mjs
T
root 2b37bed2b8
Deploy EG-BIM QA Gateway / deploy (push) Successful in 59s
fix(ui): center EG-BIM tab and hide upcoming products
2026-11-02 13:32:26 +09:00

188 lines
21 KiB
JavaScript

// 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();}