From 8909196198d749848a925380c88d2cc7c7625a37 Mon Sep 17 00:00:00 2001 From: Ryan Min Date: Wed, 2 Sep 2026 22:24:03 +0900 Subject: [PATCH 1/5] test: strengthen customer and operator browser proof --- e2e/backtest.e2e.ts | 180 ++++++++++++++++++++- e2e/basic-strategy-real.e2e.ts | 18 +++ e2e/competition-room-create-real.e2e.ts | 118 ++++++++++++++ e2e/mockApi.ts | 2 +- e2e/operator-commands.e2e.ts | 14 +- e2e/operator-session.e2e.ts | 19 +++ e2e/realApiGlobalSetup.ts | 36 ++--- e2e/realApiRuntimePolicy.test.ts | 27 +++- e2e/realApiRuntimePolicy.ts | 18 +++ src/CompetitionApiWorkspace.test.tsx | 26 ++- src/components/CompetitionApiWorkspace.tsx | 28 +++- 11 files changed, 459 insertions(+), 27 deletions(-) diff --git a/e2e/backtest.e2e.ts b/e2e/backtest.e2e.ts index 0f8d199..0ce82f6 100644 --- a/e2e/backtest.e2e.ts +++ b/e2e/backtest.e2e.ts @@ -3,9 +3,13 @@ import type { Page, Request } from '@playwright/test'; import { SESSION_STORAGE_KEY } from '../src/lib/session'; import { BOT_ID, + FAILED_RUN, OWNER_ACCOUNT_ID, OWNER_TOKEN, + QUEUED_RUN, + RUNNING_RUN, RUN_ID, + UNAVAILABLE_RUN, } from '../src/test/backtestFixtures'; import { MOCK_API_URL } from './ports'; @@ -30,16 +34,55 @@ const BACKTESTS = '/backtests'; * reaches into the app's internals or stubs the client. A test that could not sign in * this way would mean the store is not actually reading real session state. */ -async function signIn(page: Page, token: string = OWNER_TOKEN): Promise { +async function signIn(page: Page, token: string = OWNER_TOKEN, expiresAt: string | null = null): Promise { await page.addInitScript( ([key, value]) => window.sessionStorage.setItem(key, value), [ SESSION_STORAGE_KEY, - JSON.stringify({ accessToken: token, accountId: OWNER_ACCOUNT_ID, expiresAt: null }), + JSON.stringify({ accessToken: token, accountId: OWNER_ACCOUNT_ID, expiresAt }), ] as const, ); } +type RunFixture = Record; + +const escapePattern = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const listPattern = new RegExp(`^${escapePattern(MOCK_API_URL)}/api/v1/backtests(?:\\?.*)?$`); +const runPattern = new RegExp(`^${escapePattern(MOCK_API_URL)}/api/v1/backtests/${RUN_ID}$`); +const attemptsPattern = new RegExp(`^${escapePattern(MOCK_API_URL)}/api/v1/backtests/${RUN_ID}/attempts$`); +const cancellationPattern = new RegExp(`^${escapePattern(MOCK_API_URL)}/api/v1/backtests/${RUN_ID}/cancellation$`); + +async function routeRun(page: Page, run: RunFixture, listDelayMs = 0): Promise { + await page.route(listPattern, async (route) => { + if (listDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, listDelayMs)); + await route.fulfill({ json: { items: [run], limit: 25, offset: 0 } }); + }); + await page.route(runPattern, (route) => route.fulfill({ json: run })); + await page.route(attemptsPattern, (route) => route.fulfill({ json: { items: [] } })); +} + +function observeUnexpectedBrowserErrors(page: Page, allowedStatuses: readonly number[] = []) { + const expectedStatuses = new Set([401, 404, ...allowedStatuses]); + const errors: string[] = []; + page.on('console', (message) => { + if (message.type() !== 'error') return; + const expectedControlResponse = [...expectedStatuses] + .some((status) => message.text().includes(`status of ${status}`)); + if (!expectedControlResponse) errors.push(`console:${message.text()}`); + }); + page.on('requestfailed', (request) => { + if (!request.failure()?.errorText.includes('ERR_ABORTED')) { + errors.push(`request:${new URL(request.url()).pathname}:${request.failure()?.errorText ?? 'unknown'}`); + } + }); + page.on('response', (response) => { + if (response.status() >= 400 && !expectedStatuses.has(response.status())) { + errors.push(`response:${response.status()}:${new URL(response.url()).pathname}`); + } + }); + return errors; +} + /** Every request the page makes to the backtest API, in order. */ function recordApiRequests(page: Page): Request[] { const seen: Request[] = []; @@ -292,4 +335,137 @@ test.describe('backtest screens against the /api/v1 contract', () => { ); expect(stored).toBeNull(); }); + + const stateScenarios: Array<{ name: string; run: RunFixture; copy: string }> = [ + { name: 'queued', run: QUEUED_RUN as RunFixture, copy: '공식 백테스트 실행을 기다리고 있습니다.' }, + { name: 'running', run: RUNNING_RUN as RunFixture, copy: '고정된 입력으로 공식 백테스트를 실행하고 있습니다.' }, + { + name: 'cancelling', + run: { ...(RUNNING_RUN as RunFixture), cancellationRequestedAt: '2026-08-08T12:00:00Z', cancellationReasonCode: 'USER_CANCELLED' }, + copy: '취소 요청을 전달했습니다. 워커가 다음 안전 지점에서 실행을 종료합니다.', + }, + { + name: 'cancelled', + run: { + ...(QUEUED_RUN as RunFixture), status: 'CANCELLED', completedAt: '2026-08-08T12:01:00Z', + cancelledAt: '2026-08-08T12:01:00Z', cancellationReasonCode: 'USER_CANCELLED', + }, + copy: '사용자가 백테스트 실행을 취소했습니다.', + }, + { name: 'failed', run: FAILED_RUN as RunFixture, copy: '백테스트 실행이 실패했습니다.' }, + { name: 'unavailable', run: UNAVAILABLE_RUN as RunFixture, copy: '필수 입력이 없어 백테스트를 실행할 수 없습니다.' }, + ]; + + for (const scenario of stateScenarios) { + test(`renders the ${scenario.name} lifecycle state in a fresh browser context`, async ({ page }) => { + const errors = observeUnexpectedBrowserErrors(page); + await routeRun(page, scenario.run); + await signIn(page); + + await page.goto(BACKTESTS); + + await expect(page.getByText(scenario.copy, { exact: true })).toBeVisible(); + expect(errors).toEqual([]); + }); + } + + test('renders loading and then the honest empty state', async ({ page }) => { + const errors = observeUnexpectedBrowserErrors(page); + await page.route(listPattern, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 800)); + await route.fulfill({ json: { items: [], limit: 25, offset: 0 } }); + }); + await signIn(page); + + await page.goto(BACKTESTS); + await expect(page.getByRole('status')).toContainText('백테스트 결과를 불러오는 중입니다.'); + await expect(page.getByText('백테스트할 봇이 없습니다.', { exact: true })).toBeVisible(); + await expect(page.getByText('출시된 봇이 생기면 공식 백테스트가 자동으로 시작되고 이곳에 결과가 표시됩니다.')).toBeVisible(); + expect(errors).toEqual([]); + }); + + test('renders a forbidden list without discarding the valid customer session', async ({ page }) => { + const errors = observeUnexpectedBrowserErrors(page, [403]); + await page.route(listPattern, (route) => route.fulfill({ status: 403, json: { detail: 'forbidden' } })); + await signIn(page); + + await page.goto(BACKTESTS); + + await expect(page.getByRole('heading', { name: '백테스트 결과를 볼 권한이 없습니다.' })).toBeVisible(); + expect(await page.evaluate((key) => window.sessionStorage.getItem(key), SESSION_STORAGE_KEY)).not.toBeNull(); + expect(errors).toEqual([]); + }); + + test('expires a locally stale session before any backtest request leaves', async ({ page }) => { + const requests = recordApiRequests(page); + const errors = observeUnexpectedBrowserErrors(page); + await signIn(page, OWNER_TOKEN, '2020-01-01T00:00:00Z'); + + await page.goto(BACKTESTS); + + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('heading', { name: '로그인' })).toBeVisible(); + expect(requests).toEqual([]); + expect(errors).toEqual([]); + }); + + test('keeps a cancellation conflict visible and retryable', async ({ page }) => { + const errors = observeUnexpectedBrowserErrors(page, [409]); + await routeRun(page, RUNNING_RUN as RunFixture); + await page.route(cancellationPattern, (route) => route.fulfill({ + status: 409, + json: { detail: { reasonCode: 'BACKTEST_TERMINAL_STATE', message: 'run already terminal' } }, + })); + await signIn(page); + + await page.goto(BACKTESTS); + await page.getByRole('button', { name: '실행 취소' }).click(); + + await expect(page.getByText('백테스트 취소 요청을 처리하지 못했습니다. 상태를 새로고침한 뒤 다시 시도해 주세요.')).toBeVisible(); + await expect(page.getByRole('button', { name: '실행 취소' })).toBeEnabled(); + expect(errors).toEqual([]); + }); + + for (const viewport of [ + { name: 'phone', width: 390, height: 844 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'laptop', width: 1440, height: 900 }, + { name: 'desktop', width: 1920, height: 1080 }, + ] as const) { + test(`keeps the completed analysis usable at ${viewport.name} width`, async ({ page }) => { + const errors = observeUnexpectedBrowserErrors(page); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await signIn(page); + await page.goto(BACKTESTS); + await expect(page.getByTestId('backtest-live-workspace')).toBeVisible(); + + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true); + const actions = await page.locator('.backtest-live-page button:not(:disabled), .backtest-live-page a[href], .backtest-live-page input:not(:disabled), .backtest-live-page select:not(:disabled)') + .evaluateAll((elements) => elements.filter((element) => { + const node = element as HTMLElement; + const style = getComputedStyle(node); + return style.display !== 'none' && style.visibility !== 'hidden' && node.getClientRects().length > 0; + }).map((element) => ({ + name: element.getAttribute('aria-label')?.trim() + || (element as HTMLElement).innerText?.trim() + || ('labels' in element ? (element as HTMLInputElement).labels?.[0]?.innerText.trim() : '') + || element.getAttribute('title')?.trim() + || '', + html: element.outerHTML.slice(0, 240), + }))); + expect(actions.length).toBeGreaterThan(0); + expect(actions.filter((action) => !action.name)).toEqual([]); + + const launcher = page.getByRole('button', { name: '새 백테스트' }); + await launcher.focus(); + await page.keyboard.press('Enter'); + const dialog = page.getByRole('dialog', { name: '새 백테스트' }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('button', { name: '새 백테스트 창 닫기' })).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await expect(launcher).toBeFocused(); + expect(errors).toEqual([]); + }); + } }); diff --git a/e2e/basic-strategy-real.e2e.ts b/e2e/basic-strategy-real.e2e.ts index fb2e613..8f85810 100644 --- a/e2e/basic-strategy-real.e2e.ts +++ b/e2e/basic-strategy-real.e2e.ts @@ -1,6 +1,22 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +const PROOF_VIEWPORTS = [ + { name: 'phone', width: 390, height: 844 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'laptop', width: 1440, height: 900 }, + { name: 'desktop', width: 1920, height: 1080 }, +] as const; + +async function assertResponsiveWorkspace(page: Page, testId: string) { + for (const viewport of PROOF_VIEWPORTS) { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await expect(page.getByTestId(testId), viewport.name).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), viewport.name).toBe(true); + } +} test.skip(!process.env.A23_FULL_STACK_E2E, 'requires the deploy-like local stack'); @@ -42,6 +58,7 @@ test('opens the CLI-authored full Basic catalog across four resolutions', async await expect(page.getByRole('article', { name: 'PARTITION 04' })).toContainText('NVDA'); await expect(page.getByRole('article', { name: 'PARTITION 04' })).toContainText('일봉'); await expect(page.locator('[data-strategy-card]')).toHaveCount(14); + await assertResponsiveWorkspace(page, 'basic-editor-workspace'); // Every user-visible Basic condition family is represented by the canonical // fixture. Order emission is implicit in each card and verified by the @@ -167,6 +184,7 @@ test('releases missing Basic blocks and renders the real official backtest resul await expect(selectedResult.getByText('시장 대비 누적 수익률', { exact: true })).toBeVisible(); await expect(page.getByTestId('backtest-live-metrics')).toBeVisible(); expect(completedRun?.resultHash).toMatch(/^[0-9a-f]{64}$/); + await assertResponsiveWorkspace(page, 'backtest-live-workspace'); mkdirSync(path.dirname(receiptPath), { recursive: true }); writeFileSync(receiptPath, `${JSON.stringify({ diff --git a/e2e/competition-room-create-real.e2e.ts b/e2e/competition-room-create-real.e2e.ts index 6389429..ef507b2 100644 --- a/e2e/competition-room-create-real.e2e.ts +++ b/e2e/competition-room-create-real.e2e.ts @@ -1,5 +1,12 @@ import { expect, test } from '@playwright/test'; +const VIEWPORTS = [ + { name: 'phone', width: 390, height: 844 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'laptop', width: 1440, height: 900 }, + { name: 'desktop', width: 1920, height: 1080 }, +] as const; + test('creates and cancels a real competition room through the three-milestone calendar', async ({ page }) => { const email = `competition-${Date.now()}@example.com`; const password = 'CompetitionUser!2026'; @@ -59,6 +66,35 @@ test('creates and cancels a real competition room through the three-milestone ca timezoneName: 'Asia/Seoul', }); + const noMatch = `찾을 수 없는 대회 ${Date.now()}`; + const filtered = page.waitForResponse((response) => response.url().includes('/api/v1/competition/rooms/public?') + && new URL(response.url()).searchParams.get('q') === noMatch); + await page.getByRole('searchbox', { name: '대회 검색' }).fill(noMatch); + expect((await filtered).status()).toBe(200); + await expect(page.getByText('검색 결과가 없습니다.', { exact: true })).toBeVisible(); + const cleared = page.waitForResponse((response) => response.url().includes('/api/v1/competition/rooms/public?') + && new URL(response.url()).searchParams.get('q') === ''); + await page.getByRole('button', { name: '대회 검색어 지우기' }).click(); + expect((await cleared).status()).toBe(200); + + for (const viewport of VIEWPORTS) { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await expect(page.getByRole('heading', { name: '모의투자' }), viewport.name).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), viewport.name).toBe(true); + } + + const createLauncher = page.getByRole('button', { name: '대회 만들기' }); + await createLauncher.focus(); + await page.keyboard.press('Enter'); + const keyboardDialog = page.getByRole('dialog', { name: '대회 만들기' }); + await keyboardDialog.getByText('검증된 표준 채점·수수료·구매력 정책을 자동 적용합니다.').waitFor(); + await expect(keyboardDialog.getByRole('button', { name: '대회 만들기 닫기' })).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(keyboardDialog.getByRole('button', { name: '대회 생성' })).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(keyboardDialog).toBeHidden(); + await expect(createLauncher).toBeFocused(); + const owned = page.getByRole('listitem', { name: `${name} 관리` }); await expect(owned).toBeVisible(); await owned.click(); @@ -72,3 +108,85 @@ test('creates and cancels a real competition room through the three-milestone ca expect(cancelled.status()).toBe(200); await expect(manager.getByRole('status')).toContainText('대회를 취소했습니다.'); }); + +test('creates a secret room and exercises its one-time invitation controls', async ({ page }) => { + const email = `competition-secret-${Date.now()}@example.com`; + const password = 'CompetitionUser!2026'; + + await page.goto('/signup'); + await page.getByLabel('가입 이메일').fill(email); + await page.getByLabel('가입 비밀번호', { exact: true }).fill(password); + await page.getByLabel('가입 비밀번호 확인', { exact: true }).fill(password); + const [signup] = await Promise.all([ + page.waitForResponse((response) => response.url().endsWith('/api/v1/auth/signup')), + page.getByRole('button', { name: '가입', exact: true }).click(), + ]); + expect(signup.status()).toBe(202); + + await page.getByLabel('로그인 이메일').fill(email); + await page.getByLabel('로그인 비밀번호', { exact: true }).fill(password); + await Promise.all([ + page.waitForResponse((response) => response.url().endsWith('/api/v1/auth/login') && response.status() === 200), + page.getByRole('button', { name: '로그인', exact: true }).click(), + ]); + await page.goto('/competition'); + + await page.getByRole('button', { name: '대회 만들기' }).click(); + const dialog = page.getByRole('dialog', { name: '대회 만들기' }); + await dialog.getByText('검증된 표준 채점·수수료·구매력 정책을 자동 적용합니다.').waitFor(); + await dialog.getByLabel('접근 방식').selectOption('SECRET'); + await dialog.getByRole('button', { name: '다음 달' }).click(); + const monthLabel = await dialog.locator('.competition-schedule-calendar > header strong').innerText(); + const [, year, month] = monthLabel.match(/(\d{4})년 (\d+)월/) ?? []; + expect(year && month).toBeTruthy(); + for (const [label, suffix, day] of [['모집 시작', '으로', 3], ['평가 시작', '으로', 7], ['평가 종료', '로', 13]] as const) { + await dialog.getByRole('gridcell', { name: `${year}년 ${Number(month)}월 ${day}일을 ${label}${suffix} 선택` }).click(); + } + await dialog.getByLabel('모집 시작 시간').fill('09:00'); + await dialog.getByLabel('평가 시작 시간').fill('10:30'); + await dialog.getByLabel('평가 종료 시간').fill('16:00'); + + const name = `비밀 대회 검증 ${Date.now()}`; + await dialog.getByLabel('대회 이름').fill(name); + const [created] = await Promise.all([ + page.waitForResponse((response) => response.url().endsWith('/api/v1/competition/rooms') && response.request().method() === 'POST'), + dialog.getByRole('button', { name: '대회 생성' }).click(), + ]); + expect(created.status()).toBe(201); + expect(await created.json()).toMatchObject({ accessType: 'SECRET', status: 'DRAFT' }); + + const owned = page.getByRole('listitem', { name: `${name} 관리` }); + await expect(owned).toContainText('SECRET'); + await owned.click(); + let manager = page.getByRole('region', { name: `${name} 관리` }); + await expect(manager).toContainText('방장에게만 공개되는 설정·초대·참가 관리 화면입니다.'); + await manager.getByLabel('초대 종류').selectOption('CODE'); + const [issued] = await Promise.all([ + page.waitForResponse((response) => response.url().includes('/invitations') && response.request().method() === 'POST'), + manager.getByRole('button', { name: '초대 생성' }).click(), + ]); + expect(issued.status()).toBe(201); + await expect(manager.locator('.competition-api-inline-status')).toContainText('초대를 생성했습니다.'); + + // Leave the manager before any screenshot or later failure can retain the + // one-time credential. The refreshed room keeps only its invitation metadata. + await manager.getByRole('button', { name: '대회 목록' }).click(); + await page.getByRole('listitem', { name: `${name} 관리` }).click(); + manager = page.getByRole('region', { name: `${name} 관리` }); + const invitationPanel = manager.getByRole('region', { name: '초대 관리' }); + const [revoked] = await Promise.all([ + page.waitForResponse((response) => response.url().includes('/invitations/') && response.request().method() === 'DELETE'), + invitationPanel.getByRole('button', { name: '취소' }).click(), + ]); + expect(revoked.status()).toBe(204); + await expect(manager.locator('.competition-api-inline-status')).toContainText('초대를 취소했습니다.'); + + await manager.getByLabel('대회 취소 사유').fill('LOCAL_E2E_CLEANUP'); + await manager.getByLabel('대회 취소 확인').fill('취소'); + const [cancelled] = await Promise.all([ + page.waitForResponse((response) => response.url().includes('/cancellation') && response.request().method() === 'POST'), + manager.getByRole('button', { name: '대회 취소', exact: true }).click(), + ]); + expect(cancelled.status()).toBe(200); + await expect(manager.locator('.competition-api-inline-status')).toContainText('대회를 취소했습니다.'); +}); diff --git a/e2e/mockApi.ts b/e2e/mockApi.ts index fd4396b..d673184 100644 --- a/e2e/mockApi.ts +++ b/e2e/mockApi.ts @@ -109,7 +109,7 @@ function respond(response: ServerResponse, answer: Answer, origin?: string): voi 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': origin ?? 'null', 'Access-Control-Allow-Credentials': 'true', - 'Access-Control-Allow-Headers': 'Authorization, Accept, Content-Type', + 'Access-Control-Allow-Headers': 'Authorization, Accept, Content-Type, Idempotency-Key, X-Correlation-Id, X-Operator-CSRF', 'Access-Control-Allow-Methods': 'GET, OPTIONS', Vary: 'Origin', ...answer.headers, diff --git a/e2e/operator-commands.e2e.ts b/e2e/operator-commands.e2e.ts index bd7803a..098711a 100644 --- a/e2e/operator-commands.e2e.ts +++ b/e2e/operator-commands.e2e.ts @@ -52,9 +52,21 @@ test('runs a high-risk sanction journey with an opaque session, CSRF, and correl await page.goto('/operations/cases'); await page.getByRole('button', { name: /REPORT/ }).click(); await expect(page.getByText('REPORT · UNDER_REVIEW')).toBeVisible(); + for (const viewport of [ + { name: 'phone', width: 390, height: 844 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'laptop', width: 1440, height: 900 }, + { name: 'desktop', width: 1920, height: 1080 }, + ] as const) { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await expect(page.getByText('REPORT · UNDER_REVIEW'), viewport.name).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), viewport.name).toBe(true); + } await page.getByLabel('Operation reason code').fill('POLICY_VIOLATION'); await page.getByLabel('Sanction ID', { exact: true }).fill(SANCTION_ID); - await page.getByRole('button', { name: 'Apply sanction' }).click(); + const applySanction = page.getByRole('button', { name: 'Apply sanction' }); + await applySanction.focus(); + await page.keyboard.press('Enter'); await expect(page.getByRole('button', { name: 'Execute high-risk operation' })).toBeDisabled(); await page.getByLabel('Type APPLY_SANCTION to confirm').fill('APPLY_SANCTION'); await page.getByRole('button', { name: 'Execute high-risk operation' }).click(); diff --git a/e2e/operator-session.e2e.ts b/e2e/operator-session.e2e.ts index 269cd2d..463a878 100644 --- a/e2e/operator-session.e2e.ts +++ b/e2e/operator-session.e2e.ts @@ -14,6 +14,9 @@ test('uses the opaque cookie session and keeps CSRF only in memory', async ({ pa await page.route(`${MOCK_API_URL}/api/v1/operator-auth/sessions`, async (route) => { expect(await route.request().headerValue('authorization')).toBeNull(); loginBody = route.request().postDataJSON() as Record; + if (loginBody.password !== 'correct-horse-battery-staple' || loginBody.totpCode !== '123456') { + return route.fulfill({ status: 401, json: { code: 'OPERATOR_AUTHENTICATION_REJECTED' } }); + } authenticated = true; return route.fulfill({ headers: { 'set-cookie': 'operator_session=opaque-browser-token; HttpOnly; SameSite=Strict; Path=/' }, @@ -37,7 +40,23 @@ test('uses the opaque cookie session and keeps CSRF only in memory', async ({ pa await page.goto('/operations/rbac'); await expect(page.getByRole('heading', { name: '운영자 로그인' })).toBeVisible(); + for (const viewport of [ + { name: 'phone', width: 390, height: 844 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'laptop', width: 1440, height: 900 }, + { name: 'desktop', width: 1920, height: 1080 }, + ] as const) { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await expect(page.getByRole('heading', { name: '운영자 로그인' }), viewport.name).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), viewport.name).toBe(true); + } await page.getByLabel('운영자 아이디').fill('admin'); + await page.getByLabel('비밀번호').fill('wrong-on-purpose'); + await page.getByLabel('인증 앱 6자리 코드').fill('000000'); + await page.getByRole('button', { name: '운영자 로그인', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('아이디, 비밀번호 또는 인증 앱 코드를 확인해 주세요.'); + await expect(page).toHaveURL(/\/operations\/login$/); + await page.getByLabel('비밀번호').fill('correct-horse-battery-staple'); await page.getByLabel('인증 앱 6자리 코드').fill('123456'); await page.getByRole('button', { name: '운영자 로그인', exact: true }).click(); diff --git a/e2e/realApiGlobalSetup.ts b/e2e/realApiGlobalSetup.ts index fa53103..d063662 100644 --- a/e2e/realApiGlobalSetup.ts +++ b/e2e/realApiGlobalSetup.ts @@ -4,7 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import os from 'node:os'; import path from 'node:path'; import { hasActiveProjectRun, interpretDockerInspect, isDockerContainerNameConflict, shouldReapContainer, shouldReapNetwork } from './dockerResourcePolicy'; -import { backendReadyTimeoutMs, powershellPolicyArguments } from './realApiRuntimePolicy'; +import { backendReadyTimeoutMs, developmentSeedRelativePath, powershellPolicyArguments, unexpectedRepositoryChanges } from './realApiRuntimePolicy'; const projectLabel = 'com.idea2strategy.a23-real-api=true'; const backendPort = Number(process.env.A23_BACKEND_PORT); @@ -15,6 +15,9 @@ if (!Number.isInteger(backendPort) || backendPort < 1024 || backendPort > 65_535 const run = (program: string, args: string[], cwd?: string) => execFileSync(program, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }).trim(); +const runPreservingLeadingStatus = (program: string, args: string[], cwd?: string) => execFileSync(program, args, { + cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], +}).replace(/[\r\n]+$/, ''); const docker = (...args: string[]) => run('docker', args); const dockerLogs = (container: string) => { const result = spawnSync('docker', ['logs', container], { encoding: 'utf8' }); @@ -26,6 +29,7 @@ export default async function globalSetup(): Promise<() => void> { process.env.A23_ROOT_DIR ?? path.join('..'), process.env.A23_ROOT_REVISION, 'A23_ROOT_DIR', + process.env.A23_ROOT_ALLOWED_DIRTY_GITLINKS?.split(',').map((item) => item.trim()).filter(Boolean) ?? [], ); const backendDir = exactCleanRepository( process.env.A23_BACKEND_DIR ?? path.join('..', 'backend'), @@ -152,13 +156,7 @@ function seedStrategyInstruments(postgres: string): void { } function seedScoringCatalog(postgres: string, rootDir: string): void { - const seedPath = path.join( - rootDir, - 'proposals', - 'development-scoring-template', - 'artifacts', - 'scoring-template-seed.sql', - ); + const seedPath = path.join(rootDir, ...developmentSeedRelativePath('scoring')); if (!existsSync(seedPath)) throw new Error(`Reviewed scoring catalog seed is missing: ${seedPath}`); const result = spawnSync( 'docker', @@ -171,13 +169,7 @@ function seedScoringCatalog(postgres: string, rootDir: string): void { } function seedRuntimePolicies(postgres: string, rootDir: string): void { - const seedPath = path.join( - rootDir, - 'proposals', - 'development-runtime-policy', - 'artifacts', - 'policy-seed.sql', - ); + const seedPath = path.join(rootDir, ...developmentSeedRelativePath('runtime-policy')); if (!existsSync(seedPath)) throw new Error(`Reviewed runtime policy seed is missing: ${seedPath}`); const result = spawnSync( 'docker', @@ -204,15 +196,23 @@ function gradleCacheSource(): string { return volume; } -function exactCleanRepository(value: string, revision: string | undefined, variable: string): string { +function exactCleanRepository( + value: string, + revision: string | undefined, + variable: string, + allowedDirtyGitlinks: readonly string[] = [], +): string { const repository = path.resolve(value); if (!existsSync(path.join(repository, '.git'))) { throw new Error(`${variable} must point to a Git worktree: ${repository}`); } const actual = run('git', ['rev-parse', 'HEAD'], repository); if (revision && actual !== revision) throw new Error(`${variable} must be exact ${revision}; found ${actual}`); - const status = run('git', ['status', '--porcelain=v1'], repository); - if (status !== '') throw new Error(`${variable} must be clean; found:\n${status}`); + const status = runPreservingLeadingStatus('git', ['status', '--porcelain=v1'], repository); + const unexpected = unexpectedRepositoryChanges(status, allowedDirtyGitlinks, (candidate) => ( + /^160000 [0-9a-f]{40} 0\t/.test(run('git', ['ls-files', '--stage', '--', candidate], repository)) + )); + if (unexpected.length > 0) throw new Error(`${variable} must be clean except for allowed working-tree gitlinks; found:\n${unexpected.join('\n')}`); return repository; } diff --git a/e2e/realApiRuntimePolicy.test.ts b/e2e/realApiRuntimePolicy.test.ts index e43eb50..bb64516 100644 --- a/e2e/realApiRuntimePolicy.test.ts +++ b/e2e/realApiRuntimePolicy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { backendReadyTimeoutMs, powershellPolicyArguments } from './realApiRuntimePolicy'; +import { backendReadyTimeoutMs, developmentSeedRelativePath, powershellPolicyArguments, unexpectedRepositoryChanges } from './realApiRuntimePolicy'; describe('real API runtime policy', () => { it('allows a cold Gradle build enough time to become healthy', () => { @@ -21,4 +21,29 @@ describe('real API runtime policy', () => { '-NoProfile', '-File', '/tmp/verify.ps1', ]); }); + + it('allows only explicitly named, tracked gitlinks to be dirty', () => { + const trackedGitlinks = new Set(['backend', 'backtest-engine']); + const classify = (status: string) => unexpectedRepositoryChanges( + status, + ['backend', 'backtest-engine'], + (path) => trackedGitlinks.has(path), + ); + + expect(classify(' M backend\n M backtest-engine')).toEqual([]); + expect(classify(' M backend\n?? backend/db-migration/V999__forged.sql')).toEqual([ + '?? backend/db-migration/V999__forged.sql', + ]); + expect(classify('M backend')).toEqual(['M backend']); + expect(unexpectedRepositoryChanges(' M UI', ['UI'], () => false)).toEqual([' M UI']); + }); + + it('uses the canonical development seed directories', () => { + expect(developmentSeedRelativePath('runtime-policy')).toEqual([ + 'config', 'development', 'runtime-policy', 'policy-seed.sql', + ]); + expect(developmentSeedRelativePath('scoring')).toEqual([ + 'config', 'development', 'scoring', 'scoring-template-seed.sql', + ]); + }); }); diff --git a/e2e/realApiRuntimePolicy.ts b/e2e/realApiRuntimePolicy.ts index d584102..2b0b128 100644 --- a/e2e/realApiRuntimePolicy.ts +++ b/e2e/realApiRuntimePolicy.ts @@ -15,3 +15,21 @@ export function powershellPolicyArguments(platform: NodeJS.Platform, policyPath: ? ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', policyPath] : ['-NoProfile', '-File', policyPath]; } + +export function unexpectedRepositoryChanges( + status: string, + allowedDirtyGitlinks: readonly string[], + isTrackedGitlink: (path: string) => boolean, +): string[] { + const allowed = new Set(allowedDirtyGitlinks); + return status.replaceAll('\r', '').split('\n').filter(Boolean).filter((line) => { + const match = /^ M (.+)$/.exec(line); + return !match || !allowed.has(match[1]) || !isTrackedGitlink(match[1]); + }); +} + +export function developmentSeedRelativePath(kind: 'runtime-policy' | 'scoring'): string[] { + return kind === 'runtime-policy' + ? ['config', 'development', 'runtime-policy', 'policy-seed.sql'] + : ['config', 'development', 'scoring', 'scoring-template-seed.sql']; +} diff --git a/src/CompetitionApiWorkspace.test.tsx b/src/CompetitionApiWorkspace.test.tsx index 19410d2..2b8005c 100644 --- a/src/CompetitionApiWorkspace.test.tsx +++ b/src/CompetitionApiWorkspace.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, test, vi } from 'vitest'; import { CompetitionApiWorkspace, joinFailureMessage } from './components/CompetitionApiWorkspace'; @@ -71,6 +71,25 @@ describe('real competition room workspace', () => { expect(search).toHaveValue(''); }); + test('traps keyboard focus in competition dialogs and restores the launcher', async () => { + const user = userEvent.setup(); + render(); + await screen.findByRole('listitem', { name: '실전 API 대회 열기' }); + + const launcher = screen.getByRole('button', { name: '대회 만들기' }); + await user.click(launcher); + const dialog = screen.getByRole('dialog', { name: '대회 만들기' }); + await within(dialog).findByText('검증된 표준 채점·수수료·구매력 정책을 자동 적용합니다.'); + + expect(within(dialog).getByRole('button', { name: '대회 만들기 닫기' })).toHaveFocus(); + fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); + expect(within(dialog).getByRole('button', { name: '대회 생성' })).toHaveFocus(); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog', { name: '대회 만들기' })).not.toBeInTheDocument(); + expect(launcher).toHaveFocus(); + }); + test('shows loading, schedule, anonymous ranking, owned comparison and post-end choice', async () => { const api = client(); render(); @@ -85,6 +104,11 @@ describe('real competition room workspace', () => { expect(within(detail).getAllByText('내 봇').length).toBeGreaterThan(0); expect(within(detail).getAllByText('모의 성과 · 실제 투자 결과를 보장하지 않습니다.')).toHaveLength(2); + const publicLeaderboard = within(detail).getAllByRole('region', { name: '익명 봇 리더보드' }).at(-1)!; + const ownedLeaderboard = within(detail).getAllByRole('region', { name: '내 봇 비교' }).at(-1)!; + expect(within(publicLeaderboard).queryByText('내 봇')).not.toBeInTheDocument(); + expect(within(ownedLeaderboard).getByText('내 봇')).toBeInTheDocument(); + await userEvent.click(within(detail).getByRole('radio', { name: '비공개 봇으로 계속 운용' })); await userEvent.click(within(detail).getByRole('button', { name: '종료 후 선택 저장' })); await waitFor(() => expect(api.setPostEvaluationChoice).toHaveBeenCalledWith(room.id, 'p1', 'CONTINUE_PRIVATE')); diff --git a/src/components/CompetitionApiWorkspace.tsx b/src/components/CompetitionApiWorkspace.tsx index 459f7a3..2f61822 100644 --- a/src/components/CompetitionApiWorkspace.tsx +++ b/src/components/CompetitionApiWorkspace.tsx @@ -238,7 +238,7 @@ function LeaderboardSection({ title, load, history, setHistory, cursor, setCurso return
{denied && owned ? '로그인하면 내 봇 비교를 볼 수 있습니다.' : `${title}를 불러오지 못했습니다.`}{!denied && }
; } const page = load.value!; - return
{(history.length > 0 || page.hasMore) &&
{history.length + 1}페이지
}
; + return
{(history.length > 0 || page.hasMore) &&
{history.length + 1}페이지
}
; } function Leaderboard({ title, items, owned = false }: { title: string; items: LeaderboardItem[]; owned?: boolean }) { @@ -269,12 +269,33 @@ function PostChoice({ client, roomId, item, initial }: { client: CompetitionRoom } function DialogShell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) { + const dialogRef = useRef(null); const closeButtonRef = useRef(null); useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; const previousOverflow = document.body.style.overflow; const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') onClose(); + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + return; + } + if (event.key !== 'Tab' || !dialogRef.current) return; + const focusable = [...dialogRef.current.querySelectorAll('*')] + .filter((element) => element.matches( + 'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])', + )); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } }; document.body.style.overflow = 'hidden'; document.addEventListener('keydown', closeOnEscape); @@ -282,12 +303,13 @@ function DialogShell({ title, onClose, children }: { title: string; onClose: () return () => { document.body.style.overflow = previousOverflow; document.removeEventListener('keydown', closeOnEscape); + previouslyFocused?.focus(); }; }, [onClose]); return
{ if (event.target === event.currentTarget) onClose(); }}> -
+
COMPETITION

{title}

From 9e65aa703483d43ffd90557e201871ad13b93716 Mon Sep 17 00:00:00 2001 From: HJ <16863475+hjcud@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:18:34 +0900 Subject: [PATCH 2/5] fix(backtest): isolate result route loading --- e2e/backtest.e2e.ts | 20 ++++++++++++++++++++ src/App.tsx | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/e2e/backtest.e2e.ts b/e2e/backtest.e2e.ts index 0ce82f6..6e9541b 100644 --- a/e2e/backtest.e2e.ts +++ b/e2e/backtest.e2e.ts @@ -129,6 +129,26 @@ test.describe('backtest screens against the /api/v1 contract', () => { expect(await listRequest!.headerValue('authorization')).toBe(`Bearer ${OWNER_TOKEN}`); }); + test('hard navigation does not wait for the competition workspace module', async ({ page }) => { + let operationsModuleRequested = false; + let preferencesRequestPending = false; + await page.route('**/src/views/OperationsViews.tsx*', async () => { + operationsModuleRequested = true; + await new Promise(() => undefined); + }); + await page.route('**/api/v1/account/preferences', async () => { + preferencesRequestPending = true; + await new Promise(() => undefined); + }); + await signIn(page); + + await page.goto(BACKTESTS); + + await expect(page.getByTestId('backtest-live-workspace')).toBeVisible({ timeout: 3_000 }); + await expect.poll(() => preferencesRequestPending).toBe(true); + expect(operationsModuleRequested).toBe(false); + }); + test('opens the new backtest form as a modal with product-styled dropdowns', async ({ page }) => { await page.route('**/api/v1/bots/operations', (route) => route.fulfill({ json: [ diff --git a/src/App.tsx b/src/App.tsx index ad84c45..6d84291 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,7 +31,7 @@ const StrategyHome = lazy(() => import('./views/StrategyViews').then((module) => const BasicEditor = lazy(() => import('./views/StrategyViews').then((module) => ({ default: module.BasicEditor }))); const ProEditorUnavailableView = lazy(() => import('./views/ProEditorUnavailableView').then((module) => ({ default: module.ProEditorUnavailableView }))); const BotsView = lazy(() => import('./views/BotsView').then((module) => ({ default: module.BotsView }))); -const BacktestView = lazy(() => import('./views/OperationsViews').then((module) => ({ default: module.BacktestView }))); +const BacktestView = lazy(() => import('./views/BacktestLiveView').then((module) => ({ default: module.BacktestLiveView }))); const RoomsView = lazy(() => import('./views/OperationsViews').then((module) => ({ default: module.RoomsView }))); const NotificationsView = lazy(() => import('./views/SupportViews').then((module) => ({ default: module.NotificationsView }))); const HelpView = lazy(() => import('./views/SupportViews').then((module) => ({ default: module.HelpView }))); From 3200f4c91c10f4ce8dda61af356b5ec3d5a2c5d8 Mon Sep 17 00:00:00 2001 From: HJ <16863475+hjcud@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:25:03 +0900 Subject: [PATCH 3/5] fix(competition): stabilize Korean schedule periods --- src/components/CompetitionSchedulePicker.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/components/CompetitionSchedulePicker.tsx b/src/components/CompetitionSchedulePicker.tsx index a4492ab..cdbe57b 100644 --- a/src/components/CompetitionSchedulePicker.tsx +++ b/src/components/CompetitionSchedulePicker.tsx @@ -21,6 +21,20 @@ const timePart = (value: string) => value.slice(11, 16); const join = (date: string, time: string) => `${date}T${time}`; const two = (value: number) => String(value).padStart(2, '0'); const isoDate = (year: number, monthIndex: number, day: number) => `${year}-${two(monthIndex + 1)}-${two(day)}`; +const formatMilestone = (value: string, language: 'ko' | 'en') => { + const date = new Date(value); + const formatter = new Intl.DateTimeFormat(language === 'en' ? 'en-US' : 'ko-KR', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + if (language === 'en') return formatter.format(date); + return formatter.formatToParts(date).map((part) => part.type === 'dayPeriod' + ? (date.getHours() < 12 ? '오전' : '오후') + : part.value).join(''); +}; export function CompetitionSchedulePicker({ value, onChange }: { value: CompetitionSchedule; onChange: (value: CompetitionSchedule) => void }) { const { language, t } = useLanguage(); @@ -36,8 +50,6 @@ export function CompetitionSchedulePicker({ value, onChange }: { value: Competit return day >= 1 && day <= dayCount ? day : null; }), [dayCount, firstWeekday]); const weekdays = language === 'en' ? ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] : ['일', '월', '화', '수', '목', '금', '토']; - const locale = language === 'en' ? 'en-US' : 'ko-KR'; - const selectDate = (day: number) => { const selected = isoDate(year, month, day); onChange({ ...value, [active]: join(selected, timePart(value[active])) }); @@ -52,7 +64,7 @@ export function CompetitionSchedulePicker({ value, onChange }: { value: Competit return
{milestones.map((item, index) => )}
From 8289b120778383e8f7b4432a9375325f720c30bc Mon Sep 17 00:00:00 2001 From: HJ <16863475+hjcud@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:14:55 +0900 Subject: [PATCH 4/5] test(backtest): preserve truthful benchmark absence --- e2e/basic-strategy-real.e2e.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/e2e/basic-strategy-real.e2e.ts b/e2e/basic-strategy-real.e2e.ts index 8f85810..497cc72 100644 --- a/e2e/basic-strategy-real.e2e.ts +++ b/e2e/basic-strategy-real.e2e.ts @@ -181,7 +181,13 @@ test('releases missing Basic blocks and renders the real official backtest resul const selectedResult = page.getByRole('region', { name: '선택한 백테스트 결과' }); await expect(selectedResult.getByRole('heading', { name: `${strategyName} 성과 개요` })).toBeVisible(); await expect(selectedResult.getByText('완료', { exact: true }).first()).toBeVisible(); - await expect(selectedResult.getByText('시장 대비 누적 수익률', { exact: true })).toBeVisible(); + const comparisonReady = selectedResult.getByText('시장 대비 누적 수익률', { exact: true }); + const comparisonUnavailable = selectedResult.getByText('서로 비교할 수 있는 실제 데이터 기간이 없습니다.', { exact: true }); + await expect(comparisonReady.or(comparisonUnavailable)).toBeVisible(); + const benchmarkComparison = await comparisonReady.isVisible() ? 'READY' : 'UNAVAILABLE'; + if (benchmarkComparison === 'UNAVAILABLE') { + await expect(selectedResult.getByText(/전략 또는 시장 ETF에 비교할 실제 가격 기록이 없습니다/)).toBeVisible(); + } await expect(page.getByTestId('backtest-live-metrics')).toBeVisible(); expect(completedRun?.resultHash).toMatch(/^[0-9a-f]{64}$/); await assertResponsiveWorkspace(page, 'backtest-live-workspace'); @@ -189,6 +195,13 @@ test('releases missing Basic blocks and renders the real official backtest resul mkdirSync(path.dirname(receiptPath), { recursive: true }); writeFileSync(receiptPath, `${JSON.stringify({ schemaVersion: 1, strategyId, releaseId: released.releaseId, botId: released.botId, - runs: { BASIC: { runId: completedRun?.backtestRunId, terminalState: completedRun?.status, resultChecksum: completedRun?.resultHash } }, + runs: { + BASIC: { + runId: completedRun?.backtestRunId, + terminalState: completedRun?.status, + resultChecksum: completedRun?.resultHash, + benchmarkComparison, + }, + }, }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); }); From 7c4d45238169bd261a76b8d54ddb73d99f49c0f6 Mon Sep 17 00:00:00 2001 From: HJ <16863475+hjcud@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:50:13 +0900 Subject: [PATCH 5/5] test(backtest): require real benchmark comparison --- e2e/basic-strategy-real.e2e.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/e2e/basic-strategy-real.e2e.ts b/e2e/basic-strategy-real.e2e.ts index 497cc72..72e7d0a 100644 --- a/e2e/basic-strategy-real.e2e.ts +++ b/e2e/basic-strategy-real.e2e.ts @@ -182,12 +182,12 @@ test('releases missing Basic blocks and renders the real official backtest resul await expect(selectedResult.getByRole('heading', { name: `${strategyName} 성과 개요` })).toBeVisible(); await expect(selectedResult.getByText('완료', { exact: true }).first()).toBeVisible(); const comparisonReady = selectedResult.getByText('시장 대비 누적 수익률', { exact: true }); - const comparisonUnavailable = selectedResult.getByText('서로 비교할 수 있는 실제 데이터 기간이 없습니다.', { exact: true }); - await expect(comparisonReady.or(comparisonUnavailable)).toBeVisible(); - const benchmarkComparison = await comparisonReady.isVisible() ? 'READY' : 'UNAVAILABLE'; - if (benchmarkComparison === 'UNAVAILABLE') { - await expect(selectedResult.getByText(/전략 또는 시장 ETF에 비교할 실제 가격 기록이 없습니다/)).toBeVisible(); - } + await expect(comparisonReady).toBeVisible(); + const comparisonLegend = selectedResult.getByLabel('성과 비교 범례'); + await expect(comparisonLegend.getByText('S&P 500 (SPY)', { exact: false })).toBeVisible(); + await expect(comparisonLegend.getByText('NASDAQ-100 (QQQ)', { exact: false })).toBeVisible(); + await expect(selectedResult.getByText('실제 비교 기간', { exact: true })).toBeVisible(); + const benchmarkComparison = 'READY'; await expect(page.getByTestId('backtest-live-metrics')).toBeVisible(); expect(completedRun?.resultHash).toMatch(/^[0-9a-f]{64}$/); await assertResponsiveWorkspace(page, 'backtest-live-workspace');