// 실행: 개발 서버를 켠 뒤 node solution/frontend/tests/generation.mjs [URL] // 모든 API는 가짜 응답으로 막는다. 외부 생성 API·실제 사업장에는 쓰지 않는다. import assert from 'node:assert/strict'; import {chromium} from 'playwright'; const origin = process.argv[2] ?? 'http://127.0.0.1:3015'; const placeId = '10000000-0000-4000-8000-000000000001'; const jobId = '20000000-0000-4000-8000-000000000001'; const result = {success: true, code: 0}; const browser = await chromium.launch({channel: 'chrome'}); let passed = 0; async function scenario(name, run) { const page = await browser.newPage(); page.on('pageerror', (error) => console.error(error.message)); const state = { posts: [], gets: 0, unavailable: false, job: {job_id: jobId, place_id: placeId, job_type: 3, status: 2, attempts: 1, progress: {attempt: 1, steps: [ {id: 'prepare', status: 'done'}, {id: 'generate', status: 'running'}, {id: 'save', status: 'pending'}, {id: 'faq_fill', status: 'pending'}, ]}}, }; await page.addInitScript(() => localStorage.setItem('o2o-web4ai.accessToken', 'test-token')); await page.route('**/*', async (route) => { const url = new URL(route.request().url()); if (!url.pathname.startsWith('/v1/')) { return url.origin === origin ? route.continue() : route.abort(); } let body = {result}; if (url.pathname === `/v1/job/${jobId}`) { state.gets += 1; if (state.unavailable) return route.fulfill({status: 503, json: {}}); body.job = state.job; } else if (url.pathname.endsWith('/copy')) { state.posts.push(route.request().postDataJSON()); body = {...body, job_id: jobId, created: true, status: state.job.status}; } else if (url.pathname === `/v1/place/${placeId}`) { body.place = {place_id: placeId, name: '진행 복구 테스트', category: 1, status: 3, verified_at: '2026-09-15T00:00:00Z', road_address: '서울 강남구'}; } else if (url.pathname.endsWith('/schema')) body.fields = []; else if (url.pathname.endsWith('/fact/list')) body.facts = []; else if (url.pathname.endsWith('/media')) body.media = []; else if (url.pathname.endsWith('/site')) body.site = {site_id: 'test-site', status: 1, template_id: 'stay.oasi'}; else if (url.pathname.endsWith('/auth/me')) body = {...body, user_id: 'test-owner', id: 'test', role: 1}; await route.fulfill({json: body}); }); const open = (suffix = `&jobId=${jobId}`) => page.goto(`${origin}/builder?step=generating&flow=onboarding&placeId=${placeId}${suffix}`); try { await run(page, state, open); console.log(`PASS ${name}`); passed += 1; } catch (error) { console.error(await page.locator('body').innerText()); console.error(JSON.stringify({gets: state.gets, posts: state.posts})); throw error; } finally { await page.close(); } } try { await scenario('새로고침은 같은 잡 조회만, 서버가 멈추면 표시도 그대로', async (page, state, open) => { await open(); await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor(); await page.reload(); await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor(); await page.waitForFunction(() => document.querySelectorAll('ol li').length === 4); await page.waitForTimeout(2300); assert.equal(state.posts.length, 0); assert.ok(state.gets >= 3); assert.match(await page.locator('ol li').nth(1).innerText(), /진행 중/); assert.match(await page.locator('ol li').nth(2).innerText(), /대기/); assert.equal(await page.getByText(/\d+%/).count(), 0); }); await scenario('구 URL은 resume으로 복구하고 잡 ID를 주소에 보존', async (page, state, open) => { await open(''); await page.waitForURL(`**jobId=${jobId}`); assert.ok(state.posts.length > 0); assert.ok(state.posts.every((body) => body.resume === true)); const count = state.posts.length; await page.reload(); await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor(); assert.equal(state.posts.length, count); }); await scenario('완료된 잡 재접속은 재생성 없이 편집기로', async (page, state, open) => { state.job.status = 3; await open(); await page.waitForURL('**step=editor**'); assert.equal(new URL(page.url()).searchParams.has('jobId'), false); assert.equal(state.posts.length, 0); }); await scenario('실패는 완료로 표시하거나 자동으로 편집기로 보내지 않음', async (page, state, open) => { state.job.status = 4; await open(); await page.getByRole('heading', {name: '콘텐츠 생성을 완료하지 못했습니다'}).waitFor(); assert.equal(new URL(page.url()).searchParams.get('step'), 'generating'); assert.match(await page.locator('ol li').nth(1).innerText(), /중단/); assert.equal(state.posts.length, 0); }); await scenario('통신 오류 뒤에는 같은 잡 조회만 재시도', async (page, state, open) => { state.unavailable = true; await open(); await page.getByRole('button', {name: '상태 다시 확인'}).waitFor(); state.unavailable = false; await page.getByRole('button', {name: '상태 다시 확인'}).click(); await page.getByText('소개문·FAQ 생성 및 근거 검증', {exact: true}).waitFor(); assert.equal(state.posts.length, 0); }); await scenario('다른 사업장의 잡 ID는 완료여도 이동 금지', async (page, state, open) => { state.job.place_id = 'other-place'; state.job.status = 3; await open(); await page.getByText('이 화면의 생성 작업이 아닙니다. 이전 단계에서 다시 시작해 주세요.').waitFor(); assert.equal(new URL(page.url()).searchParams.get('step'), 'generating'); }); console.log(`${passed} scenarios passed`); } finally { await browser.close(); }