- workshop-app.js 114KB → 83KB(핵심), workshop-assembly.js(22KB)·workshop-equip.js(12KB)는 해당 탭을 처음 열 때만 로드 - 코드 내용 변경 없음(구간을 잘라 옮김). 모듈 밖에서 부르던 이름은 refreshAssemblyPane(shim)과 startEquipTimers/stopEquipTimers 뿐이라 가드된 로더(loadWorkshopModule/withLazyModule)로 감쌈 - 로드 중엔 패널을 흐리게(.rb-loading)+클릭 차단, 로드 중 다른 탭으로 옮기면 실행 안 함, 실패 시 안내 표시 + 다시 열면 재시도(같은 모듈은 한 번만 받음) - 브라우저 하네스에 지연 로딩 5개 항목 추가(시작 시 미로드/탭별 로드/재요청 없음/실패 안내/재시도), 전체 43개 항목 통과 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
171 lines
12 KiB
TypeScript
171 lines
12 KiB
TypeScript
// 작업실 UI 실브라우저 검증(수동 실행): npx tsx tests/manual/workshop-ui-check.ts
|
|
// headless Chrome(playwright-core, /usr/bin/google-chrome)로 실제 workshop-app.html + 실제 라우트를 임시
|
|
// 데이터 디렉토리에서 띄워 XSS/표 보기/필터/정렬/진행률/이력/충돌/보관/시세/CSV/사진첨부를 확인한다.
|
|
// npm test(tests/*.test.ts)에는 포함되지 않는다 — 브라우저 설치가 필요해서.
|
|
import express from 'express';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import http from 'http';
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wsui-'));
|
|
process.env.SMALLCLAW_DATA_DIR = tmp;
|
|
const OUT = '/tmp/claude-1000/-srv-homeclaw/20a28794-71fc-428a-b8ac-286538537d9d/scratchpad/shots';
|
|
(async () => {
|
|
const { registerWorkshopRoutes } = await import('../../src/gateway/routes/routes-workshop');
|
|
const { chromium } = require('playwright-core');
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.get('/api/auth/status', (_q, r) => r.json({ authenticated: true }));
|
|
app.get('/api/chat/sessions/:id', (_q, r) => r.json({ history: [] }));
|
|
app.delete('/api/chat/sessions/:id', (_q, r) => r.json({ success: true }));
|
|
app.get('/api/settings/model', (_q, r) => r.json({ primary: 'test-model' }));
|
|
app.get('/api/k2/status', (_q, r) => r.json({ online: false }));
|
|
const isInside = (b: string, t: string) => path.resolve(t).startsWith(path.resolve(b) + path.sep);
|
|
registerWorkshopRoutes(app, () => ({ username: 'tester' }), isInside);
|
|
app.use(express.static('/srv/homeclaw/web-ui'));
|
|
const server = http.createServer(app);
|
|
await new Promise<void>(r => server.listen(0, r));
|
|
const base = `http://127.0.0.1:${(server.address() as any).port}`;
|
|
const put = (id: string, body: any) => fetch(`${base}/api/workshop/project/${id}?force=1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, ...body }) });
|
|
await put('pa', { name: '로봇팔 <b>XSS</b>', budget: 400000,
|
|
parts: [
|
|
{ id: 'p1', name: '<img src=x onerror="window.__xss=1">모터', qty: 4, unitPrice: 45000, status: '주문완료', memo: '', links: [], priceCheck: { min: 39000, median: 42000, count: 8, at: Date.now() } },
|
|
{ id: 'p2', name: 'Raspberry Pi 5', qty: 1, unitPrice: 200000, status: '검토중', memo: '8GB', links: [] },
|
|
{ id: 'p3', name: '18650 셀', qty: 6, unitPrice: 0, status: '보유', memo: '재사용', links: [] },
|
|
],
|
|
phases: [{ id: 'ph1', name: '1단계', tasks: [{ id: 't1', text: '주문', done: true }, { id: 't2', text: '조립', done: false, due: '2020-01-01' }, { id: 't3', text: '테스트', done: false, due: '2999-01-01' }] }, { id: 'ph2', name: '2단계', tasks: [] }],
|
|
notes: '메모', description: '개요' });
|
|
await put('pb', { name: '보관될 프로젝트', parts: [], phases: [], notes: '' });
|
|
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] });
|
|
const page = await browser.newPage({ viewport: { width: 1400, height: 850 } });
|
|
const errors: string[] = [];
|
|
const loadedJs: string[] = []; // 지연 로딩 검증용: 실제로 받은 스크립트
|
|
page.on('request', (r: any) => { const m = /\/js\/app\/(workshop-[\w-]+\.js)/.exec(r.url()); if (m) loadedJs.push(m[1]); });
|
|
page.on('pageerror', (e: any) => errors.push('pageerror: ' + e.message));
|
|
page.on('dialog', (d: any) => d.accept());
|
|
await page.addInitScript(() => { try { sessionStorage.setItem('smallclaw_token', 't'); } catch {} });
|
|
await page.goto(`${base}/html/workshop-app.html`);
|
|
await page.waitForSelector('.rb-part-card');
|
|
const shot = async (n: string) => page.screenshot({ path: `${OUT}/${n}.png` });
|
|
const results: string[] = [];
|
|
const check = (name: string, ok: boolean, extra = '') => results.push(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
|
|
|
|
await page.selectOption('#project-select', 'pa');
|
|
await page.waitForTimeout(400);
|
|
check('XSS: 부품 이름이 코드로 실행되지 않음', (await page.evaluate(() => (window as any).__xss)) === undefined);
|
|
check('XSS: 프로젝트 이름이 태그로 렌더되지 않음', (await page.locator('#project-select option:checked').innerText()).includes('<b>XSS</b>'));
|
|
check('예산 게이지 표시', await page.locator('#budget-gauge').isVisible(), await page.locator('#budget-gauge-text').innerText());
|
|
await shot('1-parts-card');
|
|
|
|
await page.click('#parts-view-seg button[data-view=table]');
|
|
check('표 보기 렌더', (await page.locator('.rb-ptable tbody tr').count()) === 3);
|
|
await page.click('#parts-chips .rb-chip:has-text("보유")');
|
|
check('상태 필터(보유)', (await page.locator('.rb-ptable tbody tr').count()) === 1);
|
|
await page.click('#parts-chips .rb-chip:has-text("전체")');
|
|
await page.selectOption('#parts-sort', 'amount');
|
|
check('금액순 정렬(첫 행=Raspberry/모터 중 최대 소계)', (await page.locator('.rb-ptable tbody tr').first().locator('.rb-pt-sub').innerText()).includes('200,000'));
|
|
await shot('2-parts-table');
|
|
|
|
await page.click('.rb-tab-btn[data-tab=tasks]');
|
|
check('작업 탭 라벨에 진행률', (await page.locator('.rb-tab-btn[data-tab=tasks]').innerText()).includes('1/3'));
|
|
check('기한 지남 표시', (await page.locator('input.rb-due.overdue').count()) === 1);
|
|
await page.locator('.rb-task-row input[type=checkbox]').nth(1).check();
|
|
await page.waitForTimeout(100);
|
|
check('체크 시 진행률 즉시 갱신', (await page.locator('.rb-tab-btn[data-tab=tasks]').innerText()).includes('2/3'));
|
|
await shot('3-tasks');
|
|
|
|
await page.waitForTimeout(1200); // 저장 debounce
|
|
await page.click('button:has-text("🕘 이력")');
|
|
await page.waitForSelector('.rb-modal-bg.open');
|
|
await page.waitForTimeout(400);
|
|
check('이력 모달 열림', (await page.locator('.rb-hist-row').count()) >= 1);
|
|
await shot('4-history');
|
|
await page.keyboard.press('Escape');
|
|
|
|
// 충돌: 다른 곳에서 서버를 먼저 수정 → 내 편집 저장 시 배너
|
|
await page.click('.rb-tab-btn[data-tab=notes]');
|
|
await fetch(`${base}/api/workshop/project/pa?force=1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'pa', name: '로봇팔 <b>XSS</b>', parts: [], phases: [], notes: '다른 곳에서 수정' }) });
|
|
await page.fill('#notes-textarea', '내가 쓴 메모');
|
|
await page.waitForTimeout(1200);
|
|
check('충돌 배너 표시', await page.locator('#conflict-banner').isVisible());
|
|
await shot('5-conflict');
|
|
await page.click('#conflict-banner button:has-text("서버 버전")');
|
|
await page.waitForTimeout(300);
|
|
check('서버 버전 채택 후 배너 사라짐+내용 반영', !(await page.locator('#conflict-banner').isVisible()) && (await page.inputValue('#notes-textarea')) === '다른 곳에서 수정');
|
|
|
|
// 보관
|
|
await page.selectOption('#project-select', 'pb');
|
|
await page.waitForTimeout(300);
|
|
await page.click('#archive-btn');
|
|
await page.waitForTimeout(600);
|
|
const opts = await page.locator('#project-select option').allInnerTexts();
|
|
check('보관 시 목록에서 숨김+다른 프로젝트로 전환', !opts.some(o => o.includes('보관될')) && opts.length >= 1, opts.join(' | '));
|
|
await page.check('#show-archived');
|
|
check('보관 포함 체크 시 표시(📦)', (await page.locator('#project-select option').allInnerTexts()).some(o => o.startsWith('📦')));
|
|
|
|
|
|
// 시세 조회 모달(다나와는 네트워크라 응답을 가로채 대체) + 적용
|
|
await put('pa', { name: '로봇팔', budget: 400000, parts: [{ id: 'p2', name: 'Raspberry Pi 5', qty: 1, unitPrice: 200000, status: '검토중', memo: '', links: [] }], phases: [], notes: '' });
|
|
await page.reload();
|
|
await page.waitForSelector('.rb-part-card, .rb-ptable');
|
|
await page.selectOption('#project-select', 'pa');
|
|
await page.waitForTimeout(300);
|
|
await page.click('.rb-tab-btn[data-tab=parts]');
|
|
await page.route('**/api/workshop/part-price', (route: any) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ query: 'Raspberry Pi 5', items: [{ name: 'RPi5 8GB', price: 150000 }, { name: 'RPi5 4GB', price: 99000 }], min: 99000, median: 150000, count: 2 }) }));
|
|
await page.click('.rb-ptable tbody tr:first-child button:has-text("💰")').catch(async () => { await page.click('#parts-view-seg button[data-view=table]'); await page.click('.rb-ptable tbody tr:first-child button:has-text("💰")'); });
|
|
await page.waitForSelector('.rb-price-item');
|
|
check('시세 모달 결과 표시', (await page.locator('.rb-price-item').count()) === 2);
|
|
await page.click('button:has-text("단가로 적용")');
|
|
await page.waitForTimeout(200);
|
|
const firstPrice = await page.locator('.rb-ptable tbody tr').first().locator('input[type=number]').nth(1).inputValue();
|
|
check('최저가가 단가로 적용됨', firstPrice === '99000', firstPrice);
|
|
|
|
// CSV 내보내기(수식 주입 방어 포함)
|
|
const [dl] = await Promise.all([page.waitForEvent('download'), page.click('button:has-text("CSV")')]);
|
|
const csv = fs.readFileSync(await dl.path(), 'utf-8');
|
|
check('CSV 다운로드(BOM+합계)', csv.startsWith('\uFEFF') && csv.includes('합계'));
|
|
|
|
// 사진 첨부 팝업 + 태그 삽입
|
|
await page.click('.rb-attach-btn');
|
|
await page.waitForSelector('#attach-pop.open');
|
|
check('사진 첨부 팝업(업로드 버튼)', (await page.locator('#attach-pop').innerText()).includes('새 사진'));
|
|
await page.evaluate(() => (window as any).attachPhoto('사진/motor.jpg'));
|
|
check('[첨부 사진: …] 태그가 입력창에 들어감', (await page.inputValue('#chat-input')).includes('[첨부 사진: 사진/motor.jpg]'));
|
|
|
|
// ── 지연 로딩: 조립/장비 모듈은 시작할 때 받지 않고, 탭을 처음 열 때만 받는다 ──
|
|
check('시작 시 조립/장비 모듈은 받지 않음', !loadedJs.includes('workshop-assembly.js') && !loadedJs.includes('workshop-equip.js'), loadedJs.join(','));
|
|
await page.click('.rb-tab-btn[data-tab=equip]');
|
|
await page.waitForFunction(() => typeof (window as any).startEquipTimers === 'function', null, { timeout: 15000 });
|
|
check('장비 탭을 열면 장비 모듈만 로드', loadedJs.includes('workshop-equip.js') && !loadedJs.includes('workshop-assembly.js'), loadedJs.join(','));
|
|
check('장비 탭 로딩 표시가 사라짐(클릭 가능)', !(await page.locator('#pane-equip.rb-loading').count()));
|
|
await page.click('.rb-tab-btn[data-tab=assembly]');
|
|
await page.waitForSelector('#asm-scad', { timeout: 15000 });
|
|
check('조립 탭을 열면 조립 모듈 로드 + 패널 렌더', loadedJs.includes('workshop-assembly.js'));
|
|
await page.click('.rb-tab-btn[data-tab=parts]');
|
|
await page.click('.rb-tab-btn[data-tab=assembly]');
|
|
await page.waitForTimeout(300);
|
|
check('같은 모듈을 다시 받지 않음', loadedJs.filter(x => x === 'workshop-assembly.js').length === 1 && loadedJs.filter(x => x === 'workshop-equip.js').length === 1);
|
|
|
|
// ── 지연 로딩 실패: 모듈 요청이 막히면 안내가 뜨고, 다시 누르면 재시도해서 로드된다 ──
|
|
const page3 = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
|
await page3.addInitScript(() => { try { sessionStorage.setItem('smallclaw_token', 't'); } catch {} });
|
|
let block = true;
|
|
await page3.route('**/js/app/workshop-assembly.js', (route: any) => block ? route.abort() : route.continue());
|
|
await page3.goto(`${base}/html/workshop-app.html`);
|
|
await page3.waitForSelector('#project-select');
|
|
await page3.click('.rb-tab-btn[data-tab=assembly]');
|
|
await page3.waitForSelector('#pane-assembly .rb-empty', { timeout: 10000 });
|
|
check('모듈 로드 실패 시 안내 표시(패널이 흐림 상태로 안 남음)', (await page3.locator('#pane-assembly').innerText()).includes('불러오지 못했습니다') && !(await page3.locator('#pane-assembly.rb-loading').count()));
|
|
block = false;
|
|
await page3.click('.rb-tab-btn[data-tab=parts]');
|
|
await page3.click('.rb-tab-btn[data-tab=assembly]');
|
|
await page3.waitForSelector('#asm-scad', { timeout: 15000 });
|
|
check('네트워크 복구 후 다시 열면 재시도 성공', true);
|
|
|
|
console.log(results.join('\n'));
|
|
console.log('페이지 JS 오류:', errors.length ? errors.join('; ') : '없음');
|
|
await browser.close();
|
|
server.close();
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
})().catch(e => { console.error('HARNESS ERROR', e); process.exit(1); });
|