diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 55bcaa76c..6de544718 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -123,7 +123,9 @@ jobs: You are a general-purpose, meticulous CI code-review agent. Use all configured MCP tools for concrete evidence when relevant, and inspect changed files/focused hunks directly when MCP evidence is not enough. Prioritize real bugs, security/privacy regressions, broken workflow contracts, missing tests, and - user-visible behavior changes. + user-visible behavior changes. Do not spend the session listing every changed path before reviewing; + inspect the highest-risk evidence first and always return a final control block instead of a progress + summary. Return only the requested review body. EOF @@ -211,6 +213,25 @@ jobs: "lsp": "deny", "external_directory": "deny" } + }, + "ci-review-fallback": { + "description": "Expanded read-only CI pull request reviewer fallback", + "mode": "primary", + "prompt": "{file:./ci-review-prompt.md}", + "steps": 12, + "permission": { + "edit": "deny", + "bash": "deny", + "read": "allow", + "grep": "allow", + "glob": "allow", + "list": "allow", + "task": "deny", + "webfetch": "deny", + "websearch": "deny", + "lsp": "deny", + "external_directory": "deny" + } } }, "provider": { @@ -280,6 +301,8 @@ jobs: cat >"$prompt_file" < $(sed -n '1,240p' "$OPENCODE_EVIDENCE_FILE") @@ -298,7 +321,7 @@ jobs: cd "$OPENCODE_REVIEW_WORKDIR" opencode_json_file="${OPENCODE_OUTPUT_FILE}.jsonl" opencode_export_file="${OPENCODE_OUTPUT_FILE}.session.json" - timeout 300 opencode run "$(cat "$prompt_file")" \ + timeout 540 opencode run "$(cat "$prompt_file")" \ --pure \ --agent ci-review \ --model "$MODEL" \ @@ -365,6 +388,8 @@ jobs: cat >"$prompt_file" < $(sed -n '1,240p' "$OPENCODE_EVIDENCE_FILE") @@ -385,7 +410,7 @@ jobs: opencode_export_file="${OPENCODE_OUTPUT_FILE}.session.json" timeout 300 opencode run "$(cat "$prompt_file")" \ --pure \ - --agent ci-review \ + --agent ci-review-fallback \ --model "$MODEL" \ --format json \ --title "PR #${PR_NUMBER} OpenCode bounded fallback review ${MODEL}" >"$opencode_json_file" @@ -450,6 +475,8 @@ jobs: cat >"$prompt_file" < $(sed -n '1,240p' "$OPENCODE_EVIDENCE_FILE") @@ -470,7 +497,7 @@ jobs: opencode_export_file="${OPENCODE_OUTPUT_FILE}.session.json" timeout 300 opencode run "$(cat "$prompt_file")" \ --pure \ - --agent ci-review \ + --agent ci-review-fallback \ --model "$MODEL" \ --format json \ --title "PR #${PR_NUMBER} OpenCode bounded fallback review ${MODEL}" >"$opencode_json_file" diff --git a/frontend/src/app/data/page.test.tsx b/frontend/src/app/data/page.test.tsx index 6d2b258bf..c0dceadb2 100644 --- a/frontend/src/app/data/page.test.tsx +++ b/frontend/src/app/data/page.test.tsx @@ -497,6 +497,63 @@ describe("DataPage", () => { expect(container.textContent).not.toContain("demo_user"); }); + it("sanitizes WebDAV source labels that contain opaque source ids", async () => { + localStorage.setItem("naruon_session_token", "signed-webdav-source-label-session"); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path === "/api/data/quality-surface") return jsonResponse(dataQualitySurface); + if (path === "/api/webdav/accounts") { + void init; + return jsonResponse([ + { + source_id: "webdav_src_primary", + display_label: "WebDAV source webdav_src_primary", + writeback_enabled: true, + etag: "etag-webdav-primary", + }, + ]); + } + if (path === "/api/webdav/folders") return jsonResponse([]); + if (path === "/api/webdav/writeback-intent") { + return jsonResponse({ + intent: "writeback", + source_id: "webdav_src_primary", + target_label: "WebDAV source webdav_src_primary", + requires_if_match: true, + if_match: "etag-webdav-primary", + provenance: "server-authoritative", + }); + } + throw new Error(`Unhandled fetch: ${path}`); + }); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + expect(container.textContent).toContain("WebDAV 저장소 1"); + expect(container.textContent).not.toContain("WebDAV source webdav_src_primary"); + expect(container.textContent).not.toContain("webdav_src_primary"); + + const button = Array.from(container.querySelectorAll("button")).find((candidate) => + candidate.textContent?.includes("WebDAV 반영 의도 점검"), + ); + expect(button).toBeDefined(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("서버 확인"); + expect(container.textContent).toContain("WebDAV 저장소 1"); + expect(container.textContent).not.toContain("WebDAV source webdav_src_primary"); + expect(container.textContent).not.toContain("webdav_src_primary"); + }); + it("lets the user choose a specific WebDAV source and distinguishes If-Match conflicts", async () => { localStorage.setItem("naruon_session_token", "signed-webdav-conflict-session"); const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { diff --git a/frontend/src/components/DataLayout.tsx b/frontend/src/components/DataLayout.tsx index efef53f10..698fbda4b 100644 --- a/frontend/src/components/DataLayout.tsx +++ b/frontend/src/components/DataLayout.tsx @@ -18,6 +18,13 @@ type WebdavWritebackIntentResponse = { type WritebackStatus = 'idle' | 'loading' | 'success' | 'no_source' | 'fetch_error' | 'conflict' | 'auth' | 'error'; type WebdavAccountStatus = 'loading' | 'ready' | 'error'; +type WebdavAccount = { + source_id: string; + display_label: string; + writeback_enabled: boolean; + etag?: string | null; +}; + type UniqueThreadIntentResponse = { status: string; candidates_checked: number; @@ -189,6 +196,27 @@ function getSourceReadinessLabel(account: { writeback_enabled: boolean; etag?: s return account.etag ? '쓰기 가능 · 충돌 검사용 ETag 준비' : '쓰기 가능 · ETag 확인 필요'; } +function getWebdavAccountLabel(account: WebdavAccount, index: number) { + const label = account.display_label.trim(); + if (!label || label.includes(account.source_id) || /^WebDAV source/i.test(label)) { + return `WebDAV 저장소 ${index + 1}`; + } + return label; +} + +function getWritebackTargetLabel(result: WebdavWritebackIntentResponse, accounts: WebdavAccount[]) { + const accountIndex = result.source_id ? accounts.findIndex((account) => account.source_id === result.source_id) : -1; + if (accountIndex >= 0) { + return getWebdavAccountLabel(accounts[accountIndex], accountIndex); + } + + const label = result.target_label?.trim(); + if (!label || (result.source_id && label.includes(result.source_id)) || /^WebDAV source/i.test(label)) { + return '선택된 원본'; + } + return label; +} + function getSurfaceStatusClass(status: SurfaceStatusCode | QualityStatusCode) { switch (status) { case 'ready': @@ -209,13 +237,6 @@ function getSurfaceStatusClass(status: SurfaceStatusCode | QualityStatusCode) { export function DataLayout() { const [activeTab, setActiveTab] = useState<'문서 저장소' | '수집 파이프라인' | '임베딩' | '품질 점검'>('문서 저장소'); - interface WebdavAccount { - source_id: string; - display_label: string; - writeback_enabled: boolean; - etag?: string | null; - } - interface ProjectFolder { folder_uid: string; project_name: string; @@ -392,7 +413,7 @@ export function DataLayout() {
- {webdavAccounts.map((account) => { + {webdavAccounts.map((account, index) => { const accountSelected = selectedWebdavAccount?.source_id === account.source_id; return (
원본 선택
-
{writebackResult.target_label ?? '선택된 원본'}
+
{getWritebackTargetLabel(writebackResult, webdavAccounts)}
충돌 조건
diff --git a/frontend/tests/e2e/dashboard-branding.spec.ts b/frontend/tests/e2e/dashboard-branding.spec.ts index 4c78309e1..ad73b8a8c 100644 --- a/frontend/tests/e2e/dashboard-branding.spec.ts +++ b/frontend/tests/e2e/dashboard-branding.spec.ts @@ -273,14 +273,14 @@ for (const viewport of responsiveViewports) { for (const destination of [ { path: '/mail', heading: '메일을 선택하세요', marker: { name: '받은편지함' } }, - { path: '/calendar', heading: '일정 관리', marker: { text: 'CalDAV/CardDAV/WebDAV writeback intent' } }, + { path: '/calendar', heading: '일정 관리', marker: { text: '고객 원본 일정 반영 의도' } }, { path: '/tasks', heading: '실행 항목 추적', marker: { name: '리소스 배정 검토 회의' } }, - { path: '/data', heading: '데이터와 파일', marker: { text: '중복 반입과 thread 정리' } }, + { path: '/data', heading: '데이터와 파일', marker: { text: '중복 메일 스레드 정리 의도' } }, { path: '/search', heading: '맥락 검색', marker: { name: '관계 그래프와 타임라인' } }, - { path: '/security', heading: '보안과 관리자', marker: { text: '관리자 경계' } }, + { path: '/security', heading: '보안과 관리자', marker: { text: '원본 연결 RBAC / ABAC' } }, { path: '/projects', heading: '프로젝트 워크스페이스', marker: { name: '의사결정 로그' } }, { path: '/ai-hub', heading: 'AI 허브', marker: { name: '실행 항목' } }, - { path: '/settings', heading: '설정 (Settings)', marker: { text: 'Self-hosted Runner' } }, + { path: '/settings', heading: '설정', marker: { name: '워크스페이스 설정' } }, ] as const) { test(`renders the ${destination.path} workspace destination without horizontal overflow`, async ({ page }) => { await page.setViewportSize({ width: 1280, height: 1024 }); @@ -288,7 +288,7 @@ for (const destination of [ await page.goto(destination.path); - await expect(page.getByRole('heading', { name: destination.heading })).toBeVisible(); + await expect(page.getByRole('heading', { name: destination.heading, exact: true })).toBeVisible(); if ('name' in destination.marker) { await expect(page.getByRole('heading', { name: destination.marker.name }).first()).toBeVisible(); } else { @@ -336,9 +336,10 @@ test('renders source-backed Projects workspace with signed API headers and mobil await expect(page.getByRole('heading', { name: '프로젝트 워크스페이스' })).toBeVisible(); await expect(page.getByText('Naruon Roadmap 2026').first()).toBeVisible(); await expect(page.getByText('webdav_folder_roadmap')).toHaveCount(0); - await expect(page.getByText('provider_write_executed=false').first()).toBeVisible(); + await expect(page.getByText('provider_write_executed=false')).toHaveCount(0); + await expect(page.getByText('상태: 연결 준비').first()).toBeVisible(); await expect(page.getByText('리소스 배정 검토 회의').first()).toBeVisible(); - await expect(page.getByText('순차 DB id는 화면에 노출하지 않습니다').first()).toBeVisible(); + await expect(page.getByText('외부 저장소 쓰기는 별도 승인 전까지 실행하지 않습니다.').first()).toBeVisible(); const desktopOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(desktopOverflow).toBeLessThanOrEqual(1); await page.screenshot({ path: testInfo.outputPath('projects-source-backed-desktop.png'), fullPage: false }); @@ -403,41 +404,47 @@ test('renders Security governance access audit sharing and policy with signed AP } await expect(page.getByRole('heading', { name: '보안과 관리자' })).toBeVisible(); - await expect(page.getByText('Source-linked RBAC / ABAC')).toBeVisible(); - await expect(page.getByRole('row', { name: /webdav_src_primary/ })).toBeVisible(); + await expect(page.getByText('원본 연결 RBAC / ABAC')).toBeVisible(); + await expect(page.getByRole('row', { name: /WebDAV 저장소 1/ })).toBeVisible(); + await expect(page.getByText('webdav_src_primary')).toHaveCount(0); await expect(page.getByText('곧 제공됩니다')).toHaveCount(0); await page.screenshot({ path: testInfo.outputPath('security-governance-desktop-access.png'), fullPage: false }); await page.getByRole('button', { name: '감사 로그' }).click(); - await expect(page.getByText('audit_evt_provider_update')).toBeVisible(); - await expect(page.getByText('llm_provider:provider_primary')).toBeVisible(); - await expect(page.getByText('connector_evt_heartbeat')).toBeVisible(); + await expect(page.getByText('서버 감사 로그')).toBeVisible(); + await expect(page.getByText('보안 설정 변경이 서버 감사 근거로 기록되었습니다.')).toBeVisible(); + await expect(page.getByText('서버 관측 이벤트')).toBeVisible(); + await expect(page.getByText('audit_evt_provider_update')).toHaveCount(0); + await expect(page.getByText('llm_provider:provider_primary')).toHaveCount(0); + await expect(page.getByText('connector_evt_heartbeat')).toHaveCount(0); await page.screenshot({ path: testInfo.outputPath('security-governance-desktop-audit.png'), fullPage: false }); await page.getByRole('button', { name: '외부 공유' }).click(); - await expect(page.getByText('WebDAV repository writeback boundary')).toBeVisible(); + await expect(page.getByText('WebDAV 저장소 쓰기 경계')).toBeVisible(); + await expect(page.getByText('외부 쓰기 실행 안 함').first()).toBeVisible(); await page.getByRole('button', { name: '정책' }).click(); - await expect(page.getByText('Deny-first policy order')).toBeVisible(); - await expect(page.getByText('Cross-organization provider secret')).toBeVisible(); + await expect(page.getByText('차단 우선 정책 순서')).toBeVisible(); + await expect(page.getByText('교차 조직 제공자 secret')).toBeVisible(); const desktopOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(desktopOverflow).toBeLessThanOrEqual(1); await page.screenshot({ path: testInfo.outputPath('security-governance-desktop-policy.png'), fullPage: false }); await page.setViewportSize({ width: 768, height: 1024 }); await page.goto('/security'); - await expect(page.getByText('Source-linked RBAC / ABAC')).toBeVisible(); + await expect(page.getByText('원본 연결 RBAC / ABAC')).toBeVisible(); await page.getByRole('button', { name: '정책' }).click(); - await expect(page.getByText('RBAC allow after ABAC denies')).toBeVisible(); + await expect(page.getByText('ABAC 차단 후 RBAC 허용')).toBeVisible(); const tabletOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(tabletOverflow).toBeLessThanOrEqual(1); await page.screenshot({ path: testInfo.outputPath('security-governance-tablet-policy.png'), fullPage: false }); await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/security'); - await expect(page.getByText('Source-linked RBAC / ABAC')).toBeVisible(); - const mobileWebdavSource = page.locator('article', { hasText: 'webdav_src_primary' }).first(); + await expect(page.getByText('원본 연결 RBAC / ABAC')).toBeVisible(); + const mobileWebdavSource = page.locator('article', { hasText: 'WebDAV 저장소 1' }).first(); await mobileWebdavSource.scrollIntoViewIfNeeded(); await expect(mobileWebdavSource).toBeVisible(); + await expect(page.getByText('webdav_src_primary')).toHaveCount(0); const mobileOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(mobileOverflow).toBeLessThanOrEqual(1); await page.screenshot({ path: testInfo.outputPath('security-governance-mobile-access.png'), fullPage: false }); @@ -496,23 +503,26 @@ test('renders Data quality surface across viewports with signed API headers', as } await expect(page.getByRole('heading', { name: '데이터와 파일' })).toBeVisible(); - await expect(page.getByText('data.quality_surface.viewed')).toBeVisible(); + await expect(page.getByText('감사 근거 기록됨')).toBeVisible(); await expect(page.getByText('최근 파일/첨부 자산')).toBeVisible(); const assetList = page.getByLabel('문서 저장소 파일 자산'); await expect(assetList.getByRole('button', { name: /roadmap\.pdf/ })).toBeVisible(); const assetDetail = page.getByLabel('선택한 파일 자산 상세'); await expect(assetDetail.getByRole('heading', { name: 'roadmap.pdf' })).toBeVisible(); - await expect(assetDetail.getByText('asset_repository_ready')).toBeVisible(); - await expect(assetDetail.getByText('thread_repository_ready')).toBeVisible(); + await expect(assetDetail.getByText('원본 메일/스레드 근거 연결')).toBeVisible(); + await expect(assetDetail.getByText('asset_repository_ready')).toHaveCount(0); + await expect(assetDetail.getByText('thread_repository_ready')).toHaveCount(0); await assetList.getByRole('button', { name: /blank-notes\.md/ }).click(); await expect(assetDetail.getByRole('heading', { name: 'blank-notes.md' })).toBeVisible(); - await expect(assetDetail.getByText('thread_missing')).toBeVisible(); + await expect(assetDetail.getByText('본문 추출 대기')).toBeVisible(); + await expect(assetDetail.getByText('thread_missing')).toHaveCount(0); await expect(assetDetail.getByText('content extraction pending, canonical thread pending')).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('data-quality-desktop-repository-assets.png'), fullPage: false }); - await expect(page.getByText('connector_evt_data_quality')).toBeVisible(); + await expect(page.getByText('outbound connector heartbeat received')).toBeVisible(); + await expect(page.getByText('connector_evt_data_quality')).toHaveCount(0); await page.getByRole('button', { name: '수집 파이프라인' }).click(); await expect(page.getByText('4 emails and 3 attachments')).toBeVisible(); - await expect(page.getByText('emails.embedding, attachments.embedding')).toBeVisible(); + await expect(page.getByText('원본 근거 연결됨').first()).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('data-quality-desktop-pipeline.png'), fullPage: false }); await page.getByRole('button', { name: '임베딩' }).click(); @@ -532,7 +542,8 @@ test('renders Data quality surface across viewports with signed API headers', as await page.setViewportSize({ width: 768, height: 1024 }); await page.goto('/data'); - await expect(page.getByText('data.quality_surface.viewed')).toBeVisible(); + await expect(page.getByText('감사 근거 기록됨')).toBeVisible(); + await expect(page.getByText('data.quality_surface.viewed')).toHaveCount(0); await page.getByRole('button', { name: '수집 파이프라인' }).click(); await expect(page.getByText('Connector observability')).toBeVisible(); const tabletOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); @@ -541,7 +552,8 @@ test('renders Data quality surface across viewports with signed API headers', as await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/data'); - await expect(page.getByText('data.quality_surface.viewed')).toBeVisible(); + await expect(page.getByText('감사 근거 기록됨')).toBeVisible(); + await expect(page.getByText('data.quality_surface.viewed')).toHaveCount(0); await page.getByRole('button', { name: '품질 점검' }).click(); const mobileQualityCard = page.locator('article', { hasText: 'Dedupe fingerprint' }).first(); await mobileQualityCard.scrollIntoViewIfNeeded(); @@ -657,7 +669,7 @@ test('updates source-linked task ticket status with signed API headers', async ( await page.goto('/tasks'); await expect(page.getByRole('heading', { name: '실행 항목 추적' })).toBeVisible(); - await expect(page.getByRole('region', { name: 'source-linked ticket status board' })).toBeVisible(); + await expect(page.getByRole('region', { name: '원본 연결 티켓 상태 보드' })).toBeVisible(); await page.getByRole('button', { name: '리소스 배정 검토 회의 상태를 완료로 변경' }).click(); const requestHeaders = (await patchRequest).headers(); expect(requestHeaders.authorization).toBe(`Bearer ${expectedNaruonToken}`); @@ -674,7 +686,7 @@ test('updates source-linked task ticket status with signed API headers', async ( await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/tasks'); await expect(page.getByRole('heading', { name: '실행 항목 추적' })).toBeVisible(); - await expect(page.getByRole('region', { name: 'source-linked ticket status board' })).toBeVisible(); + await expect(page.getByRole('region', { name: '원본 연결 티켓 상태 보드' })).toBeVisible(); await expect(page.getByText('실제 티켓 큐')).toBeVisible(); await expect(page.getByText('5개 티켓 연결')).toBeVisible(); const mobileOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); @@ -728,8 +740,9 @@ test('creates pending reply SLA task escalation with signed API headers', async expect(desktopRequest.postDataJSON()).toEqual({ overdue_hours: 48 }); await expect(page.getByText('1개 답변 SLA 티켓을 생성했습니다. 2개 대기 메일을 48시간 기준으로 확인했습니다.')).toBeVisible(); - await expect(page.getByRole('heading', { name: '답변 SLA 확인: 벤더 계약 답변 요청' })).toBeVisible(); - const escalatedDesktopTask = page.getByRole('article').filter({ hasText: '답변 SLA 확인: 벤더 계약 답변 요청' }); + const desktopTicketList = page.getByLabel('원본 연결 티켓 목록'); + await expect(desktopTicketList.getByRole('heading', { name: '답변 SLA 확인: 벤더 계약 답변 요청' })).toBeVisible(); + const escalatedDesktopTask = desktopTicketList.getByRole('article').filter({ hasText: '답변 SLA 확인: 벤더 계약 답변 요청' }); await expect(escalatedDesktopTask.getByText('차단 · 긴급')).toBeVisible(); const desktopOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(desktopOverflow).toBeLessThanOrEqual(1); @@ -748,8 +761,9 @@ test('creates pending reply SLA task escalation with signed API headers', async for (const headerName of publicIdentityHeaders) { expect(mobileRequestHeaders[headerName]).toBeUndefined(); } - await expect(page.getByRole('heading', { name: '답변 SLA 확인: 벤더 계약 답변 요청' })).toBeVisible(); - await page.getByRole('heading', { name: '답변 SLA 확인: 벤더 계약 답변 요청' }).scrollIntoViewIfNeeded(); + const mobileTicketList = page.getByLabel('원본 연결 티켓 목록'); + await expect(mobileTicketList.getByRole('heading', { name: '답변 SLA 확인: 벤더 계약 답변 요청' })).toBeVisible(); + await mobileTicketList.getByRole('heading', { name: '답변 SLA 확인: 벤더 계약 답변 요청' }).scrollIntoViewIfNeeded(); const mobileOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(mobileOverflow).toBeLessThanOrEqual(1); await page.screenshot({ path: testInfo.outputPath('reply-sla-escalation-mobile.png'), fullPage: false }); @@ -791,8 +805,8 @@ test('creates self-sent knowledge WebDAV intent with signed API headers', async await page.goto('/tasks'); await expect(page.getByRole('heading', { name: '실행 항목 추적' })).toBeVisible(); - await expect(page.getByRole('region', { name: 'self-sent knowledge WebDAV materialization' })).toBeVisible(); - await page.getByRole('button', { name: '나에게 보낸 지식 메모 정리 WebDAV 지식 노트 intent 생성' }).click(); + await expect(page.getByRole('region', { name: '나에게 보낸 지식 메일 WebDAV 의도' })).toBeVisible(); + await page.getByRole('button', { name: '나에게 보낸 지식 메모 정리 WebDAV 지식 노트 의도 생성' }).click(); const request = await desktopIntentRequest; const requestHeaders = request.headers(); expect(requestHeaders.authorization).toBe(`Bearer ${expectedNaruonToken}`); @@ -801,9 +815,11 @@ test('creates self-sent knowledge WebDAV intent with signed API headers', async } expect(request.postDataJSON()).toEqual({ source_task_id: 'task-self-knowledge' }); - await expect(page.getByText('/Naruon/Notes/task-self-knowledge.md')).toBeVisible(); - await expect(page.getByText('provider_write_executed=false')).toBeVisible(); - await expect(page.getByText('webdav.self_sent_knowledge_intent.created')).toBeVisible(); + await expect(page.getByText('WebDAV/Notes 의도 준비')).toBeVisible(); + await expect(page.getByText('의도만 기록')).toBeVisible(); + await expect(page.getByText('provider_write_executed=false')).toHaveCount(0); + await expect(page.getByText('기록됨')).toBeVisible(); + await expect(page.getByText('webdav.self_sent_knowledge_intent.created')).toHaveCount(0); const desktopOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(desktopOverflow).toBeLessThanOrEqual(1); await page.screenshot({ path: testInfo.outputPath('self-sent-knowledge-webdav-intent-desktop.png'), fullPage: false }); @@ -815,11 +831,12 @@ test('creates self-sent knowledge WebDAV intent with signed API headers', async }); await page.goto('/tasks'); await expect(page.getByRole('heading', { name: '실행 항목 추적' })).toBeVisible(); - await page.getByRole('button', { name: '나에게 보낸 지식 메모 정리 WebDAV 지식 노트 intent 생성' }).click(); + await page.getByRole('button', { name: '나에게 보낸 지식 메모 정리 WebDAV 지식 노트 의도 생성' }).click(); const mobileRequest = await mobileIntentRequest; expect(mobileRequest.headers().authorization).toBe(`Bearer ${expectedNaruonToken}`); - await expect(page.getByText('/Naruon/Notes/task-self-knowledge.md')).toBeVisible(); - await page.getByText('webdav.self_sent_knowledge_intent.created').scrollIntoViewIfNeeded(); + await expect(page.getByText('WebDAV/Notes 의도 준비')).toBeVisible(); + await page.getByText('WebDAV/Notes 의도 준비').scrollIntoViewIfNeeded(); + await expect(page.getByText('webdav.self_sent_knowledge_intent.created')).toHaveCount(0); await page.locator('main').nth(1).evaluate((scroller) => { scroller.scrollTop += 96; }); @@ -848,21 +865,22 @@ test('renders the settings self-hosted connector manifest with mobile scrolling' await page.goto('/settings'); await page.getByRole('button', { name: '개발자' }).first().click(); - await expect(page.getByText('Self-hosted connector manifest')).toBeVisible(); + await expect(page.getByRole('region', { name: 'Self-hosted connector manifest' })).toBeVisible(); + await expect(page.getByText('Self-hosted connector 등록 상태')).toBeVisible(); await expect(page.getByText('Naruon은 메일 서버가 아닙니다')).toBeVisible(); - await expect(page.getByText('naruon.net', { exact: true })).toBeVisible(); - await expect(page.getByText('ci_smoke_only')).toBeVisible(); - await expect(page.getByText('smtp_server')).toBeVisible(); - await expect(page.getByText('Connector health & APM signals')).toBeVisible(); - await expect(page.getByText('observability.operational_signals.viewed')).toBeVisible(); - await expect(page.getByText('otel-collector:4317')).toBeVisible(); - await expect(page.getByText('Recent connector signals')).toBeVisible(); - await expect(page.getByText('outbound runner heartbeat received')).toBeVisible(); - await expect(page.getByText('instrumentation_pending')).toBeVisible(); + await expect(page.getByText('naruon.net')).toBeVisible(); + await expect(page.getByText('검증용 연결')).toBeVisible(); + await expect(page.getByText('SMTP 서버 역할 금지')).toBeVisible(); + await expect(page.getByText('Connector 상태와 APM 신호')).toBeVisible(); + await expect(page.getByText('감사 근거 기록됨')).toBeVisible(); + await expect(page.getByText('OTel endpoint')).toBeVisible(); + await expect(page.getByText('최근 connector 신호')).toBeVisible(); + await expect(page.getByText('서버가 runner 하트비트를 관측했습니다.')).toBeVisible(); + await expect(page.getByText('계측 준비')).toBeVisible(); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(overflow).toBeLessThanOrEqual(1); await page.screenshot({ path: testInfo.outputPath('settings-connector-apm-mobile.png'), fullPage: false }); - await page.getByText('Recent connector signals').scrollIntoViewIfNeeded(); + await page.getByText('최근 connector 신호').scrollIntoViewIfNeeded(); await page.screenshot({ path: testInfo.outputPath('settings-connector-apm-mobile-history.png'), fullPage: false }); const scrollMetrics = await page.evaluate(() => { @@ -897,14 +915,14 @@ test('renders settings connector APM signals across desktop and tablet', async ( await page.goto('/settings'); await page.getByRole('button', { name: '개발자' }).first().click(); - await expect(page.getByText('Connector health & APM signals')).toBeVisible(); - await expect(page.getByText('observability.operational_signals.viewed')).toBeVisible(); - await expect(page.getByText('otel-collector:4317')).toBeVisible(); - await expect(page.getByText('Recent connector signals')).toBeVisible(); + await expect(page.getByText('Connector 상태와 APM 신호')).toBeVisible(); + await expect(page.getByText('감사 근거 기록됨')).toBeVisible(); + await expect(page.getByText('OTel endpoint')).toBeVisible(); + await expect(page.getByText('최근 connector 신호')).toBeVisible(); await expect(page.getByText('Connector heartbeat')).toBeVisible(); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(overflow).toBeLessThanOrEqual(1); - await page.getByText('outbound runner heartbeat received').scrollIntoViewIfNeeded(); + await page.getByText('서버가 runner 하트비트를 관측했습니다.').scrollIntoViewIfNeeded(); await page.screenshot({ path: testInfo.outputPath(`settings-connector-apm-${viewport.name}.png`), fullPage: false }); } }); @@ -938,15 +956,16 @@ test('renders source-backed mail account settings across desktop tablet and mobi await page.goto('/settings'); await page.getByRole('button', { name: '연결 계정' }).first().click(); - await expect(page.getByText('고객 지정 Provider')).toBeVisible(); + await expect(page.getByText('고객 지정 연결')).toBeVisible(); await expect(page.getByText('Naruon은 메일함 용량이나 SMTP/IMAP 서버를 제공하지 않습니다')).toBeVisible(); await expect(page.getByText('smtp.example.com:587')).toBeVisible(); await expect(page.getByText('imap.example.com:993')).toBeVisible(); await expect(page.getByText('pop3.example.com:995')).toBeVisible(); await expect(page.getByText('OAuth 로그인')).toBeVisible(); - await expect(page.getByText('Source readiness')).toBeVisible(); - await expect(page.getByText('caldav-primary', { exact: true })).toBeVisible(); - await expect(page.getByLabel('Provider source readiness').getByText('webdav_src_primary', { exact: true })).toBeVisible(); + await expect(page.getByText('원본 연결 준비 상태')).toBeVisible(); + await expect(page.getByText('Customer CalDAV 일정 원본 1')).toBeVisible(); + await expect(page.getByLabel('Provider source readiness').getByText('WebDAV 저장소 1', { exact: true })).toBeVisible(); + await expect(page.getByText('webdav_src_primary')).toHaveCount(0); await expect(page.getByText('저장된 secret 유지').first()).toBeVisible(); if (viewport.name === 'desktop') { @@ -1028,8 +1047,10 @@ test('renders calendar writeback intent status without direct provider writes', }, expectedNaruonToken); await page.goto('/calendar'); - await expect(page.getByText('Customer CalDAV').first()).toBeVisible(); - await expect(page.getByText('etag=etag-caldav-primary')).toBeVisible(); + await expect(page.getByText('일정 원본 1').first()).toBeVisible(); + await expect(page.getByText('CalDAV 원본').first()).toBeVisible(); + await expect(page.getByText('충돌 토큰 있음')).toBeVisible(); + await expect(page.getByText('etag=etag-caldav-primary')).toHaveCount(0); const desktopWritebackRequest = page.waitForRequest((request) => { const url = new URL(request.url()); return url.pathname === '/api/calendar/writeback-intent' && request.method() === 'POST'; @@ -1047,10 +1068,12 @@ test('renders calendar writeback intent status without direct provider writes', expect(desktopRequestHeaders[headerName]).toBeUndefined(); } - await expect(page.getByText('customer_owned')).toBeVisible(); - await expect(page.getByText('caldav', { exact: true })).toBeVisible(); - await expect(page.getByRole('status').getByText('caldav-primary')).toBeVisible(); - await expect(page.getByText('calendar.writeback_intent.created')).toBeVisible(); + await expect(page.getByText('고객 원본 계정 반영')).toBeVisible(); + await expect(page.getByText('CalDAV 원본 선택됨')).toBeVisible(); + await expect(page.getByRole('status').getByText('선택한 일정 원본')).toBeVisible(); + await expect(page.getByRole('status').getByText('기록됨')).toBeVisible(); + await expect(page.getByText('calendar.writeback_intent.created')).toHaveCount(0); + await expect(page.getByText('caldav-primary')).toHaveCount(0); await expect(page.getByText('동기화 완료')).toHaveCount(0); const desktopOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(desktopOverflow).toBeLessThanOrEqual(1); @@ -1059,7 +1082,7 @@ test('renders calendar writeback intent status without direct provider writes', await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/calendar'); await expect(page.getByRole('heading', { name: '일정 관리' })).toBeVisible(); - await expect(page.getByText('caldav-primary').first()).toBeVisible(); + await expect(page.getByText('일정 원본 1').first()).toBeVisible(); const mobileWritebackRequest = page.waitForRequest((request) => { const url = new URL(request.url()); return url.pathname === '/api/calendar/writeback-intent' && request.method() === 'POST'; @@ -1076,7 +1099,8 @@ test('renders calendar writeback intent status without direct provider writes', for (const headerName of publicIdentityHeaders) { expect(mobileRequestHeaders[headerName]).toBeUndefined(); } - await expect(page.getByText('customer_owned')).toBeVisible(); + await expect(page.getByText('고객 원본 계정 반영')).toBeVisible(); + await expect(page.getByText('caldav-primary')).toHaveCount(0); const mobileOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(mobileOverflow).toBeLessThanOrEqual(1); await page.screenshot({ path: testInfo.outputPath('calendar-writeback-intent-mobile.png'), fullPage: false }); @@ -1127,12 +1151,12 @@ test('renders data WebDAV writeback intent status without direct provider writes for (const headerName of publicIdentityHeaders) { expect(desktopAccountsHeaders[headerName]).toBeUndefined(); } - await expect(page.getByText('etag=etag-webdav-primary')).toBeVisible(); + await expect(page.getByText('쓰기 가능 · 충돌 검사용 ETag 준비')).toBeVisible(); const desktopWritebackRequest = page.waitForRequest((request) => { const url = new URL(request.url()); return url.pathname === '/api/webdav/writeback-intent' && request.method() === 'POST'; }); - await page.getByRole('button', { name: 'WebDAV intent 승인 점검' }).click(); + await page.getByRole('button', { name: 'WebDAV 반영 의도 점검' }).click(); const desktopWritebackCall = await desktopWritebackRequest; const desktopHeaders = desktopWritebackCall.headers(); expect(desktopHeaders.authorization).toBe(`Bearer ${expectedNaruonToken}`); @@ -1141,11 +1165,12 @@ test('renders data WebDAV writeback intent status without direct provider writes expect(desktopHeaders[headerName]).toBeUndefined(); } - await expect(page.getByText('writeback', { exact: true })).toBeVisible(); - await expect(page.getByText('server-authoritative')).toBeVisible(); - await expect(page.getByText('WebDAV source webdav_src_primary').first()).toBeVisible(); + await expect(page.getByText('원본 반영 의도')).toBeVisible(); + await expect(page.getByText('서버 확인')).toBeVisible(); + await expect(page.getByText('WebDAV 저장소 1').first()).toBeVisible(); + await expect(page.getByText('WebDAV source webdav_src_primary')).toHaveCount(0); await expect(page.getByText('https://webdav.naruon.net')).toHaveCount(0); - await expect(page.getByText('etag-webdav-primary').first()).toBeVisible(); + await expect(page.getByText('etag-webdav-primary')).toHaveCount(0); const desktopOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(desktopOverflow).toBeLessThanOrEqual(1); await page.screenshot({ path: testInfo.outputPath('data-webdav-writeback-intent-desktop.png'), fullPage: false }); @@ -1157,7 +1182,7 @@ test('renders data WebDAV writeback intent status without direct provider writes const url = new URL(request.url()); return url.pathname === '/api/webdav/writeback-intent' && request.method() === 'POST'; }); - await page.getByRole('button', { name: 'WebDAV intent 승인 점검' }).click(); + await page.getByRole('button', { name: 'WebDAV 반영 의도 점검' }).click(); const mobileWritebackCall = await mobileWritebackRequest; const mobileHeaders = mobileWritebackCall.headers(); expect(mobileHeaders.authorization).toBe(`Bearer ${expectedNaruonToken}`); @@ -1165,8 +1190,8 @@ test('renders data WebDAV writeback intent status without direct provider writes for (const headerName of publicIdentityHeaders) { expect(mobileHeaders[headerName]).toBeUndefined(); } - await expect(page.getByText('server-authoritative')).toBeVisible(); - await page.getByText('server-authoritative').scrollIntoViewIfNeeded(); + await expect(page.getByText('서버 확인')).toBeVisible(); + await page.getByText('서버 확인').scrollIntoViewIfNeeded(); await page.mouse.wheel(0, 140); const mobileOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(mobileOverflow).toBeLessThanOrEqual(1); @@ -1212,7 +1237,7 @@ test('renders unique email canonical thread intent with signed API headers', asy const url = new URL(request.url()); return url.pathname === '/api/emails/unique-thread-intent' && request.method() === 'POST'; }); - await page.getByRole('button', { name: '중복 메일 thread intent 점검' }).click(); + await page.getByRole('button', { name: '중복 메일 스레드 의도 점검' }).click(); const desktopRequest = await desktopIntentRequest; const desktopHeaders = desktopRequest.headers(); expect(desktopHeaders.authorization).toBe(`Bearer ${expectedNaruonToken}`); @@ -1220,9 +1245,13 @@ test('renders unique email canonical thread intent with signed API headers', asy expect(desktopHeaders[headerName]).toBeUndefined(); } expect(desktopRequest.postDataJSON().candidates).toHaveLength(2); - await expect(page.getByText('email.unique_thread_intent.created')).toBeVisible(); - await expect(page.getByLabel('unique email canonical thread').getByText('provider_write_executed=false')).toBeVisible(); - await expect(page.getByText(/fingerprint.*thread-q2-root/)).toBeVisible(); + const desktopUniqueThreadSection = page.getByLabel('중복 메일 canonical 스레드 의도'); + await expect(desktopUniqueThreadSection.getByText('기록됨')).toBeVisible(); + await expect(desktopUniqueThreadSection.getByText('의도만 기록')).toBeVisible(); + await expect(page.getByText('email.unique_thread_intent.created')).toHaveCount(0); + await expect(page.getByText('provider_write_executed=false')).toHaveCount(0); + await expect(page.getByText('본문 fingerprint 근거')).toBeVisible(); + await expect(page.getByText('thread-q2-root')).toHaveCount(0); const desktopOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(desktopOverflow).toBeLessThanOrEqual(1); await page.screenshot({ path: testInfo.outputPath('data-unique-thread-intent-desktop.png'), fullPage: false }); @@ -1234,14 +1263,16 @@ test('renders unique email canonical thread intent with signed API headers', asy const url = new URL(request.url()); return url.pathname === '/api/emails/unique-thread-intent' && request.method() === 'POST'; }); - await page.getByRole('button', { name: '중복 메일 thread intent 점검' }).click(); + await page.getByRole('button', { name: '중복 메일 스레드 의도 점검' }).click(); const mobileHeaders = (await mobileIntentRequest).headers(); expect(mobileHeaders.authorization).toBe(`Bearer ${expectedNaruonToken}`); for (const headerName of publicIdentityHeaders) { expect(mobileHeaders[headerName]).toBeUndefined(); } - await expect(page.getByText('thread-q2-root').first()).toBeVisible(); - await page.getByText('email.unique_thread_intent.created').scrollIntoViewIfNeeded(); + const mobileUniqueThreadSection = page.getByLabel('중복 메일 canonical 스레드 의도'); + await expect(mobileUniqueThreadSection.getByText('본문 fingerprint 근거')).toBeVisible(); + await expect(page.getByText('thread-q2-root')).toHaveCount(0); + await mobileUniqueThreadSection.getByText('기록됨').scrollIntoViewIfNeeded(); const mobileOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(mobileOverflow).toBeLessThanOrEqual(1); await page.screenshot({ path: testInfo.outputPath('data-unique-thread-intent-mobile.png'), fullPage: false }); diff --git a/frontend/tests/e2e/dashboard-flows.spec.ts b/frontend/tests/e2e/dashboard-flows.spec.ts index 7c77800c9..1f8968fc0 100644 --- a/frontend/tests/e2e/dashboard-flows.spec.ts +++ b/frontend/tests/e2e/dashboard-flows.spec.ts @@ -13,10 +13,10 @@ test('connects inbox selection to summary, execution, reply, calendar, and graph await expect(page.getByText('출시 일정, 마케팅 계획, 파트너 미팅')).toBeVisible(); await expect(page.getByText('2개 실행 항목')).toBeVisible(); await expect(page.getByText('2개 메시지').nth(1)).toBeVisible(); - await expect(page.getByText('1개 노드와 1개 관계')).toBeVisible(); + await expect(page.getByText('2개 노드와 1개 관계')).toBeVisible(); await page.getByRole('button', { name: '일정 반영' }).last().click(); - await expect(page.getByText('2개 일정 writeback intent를 Customer CalDAV 원본에 요청했습니다.')).toBeVisible(); + await expect(page.getByText('2개 일정 반영 의도를 선택한 원본 계정에 요청했습니다.')).toBeVisible(); await page.getByRole('button', { name: 'AI 답장 초안' }).last().click(); await expect(page.getByLabel('답장 초안')).toHaveValue('검토 후 일정과 우선순위를 정리해 공유드리겠습니다.'); @@ -32,7 +32,7 @@ test('submits branded inbox search against the search API', async ({ page }, tes await page.goto('/'); await page.getByRole('button', { name: '메일함 바로가기' }).first().click(); const desktopWorkspace = page.getByRole('region', { name: '데스크톱 메일 작업공간' }); - await desktopWorkspace.getByLabel('Search emails').fill('출시'); + await desktopWorkspace.getByLabel('메일 검색').fill('출시'); await desktopWorkspace.getByRole('button', { name: '검색' }).click(); await expect(desktopWorkspace.getByText('Q2 출시 계획 및 우선순위 조정')).toBeVisible(); diff --git a/frontend/tests/e2e/live-smoke.spec.ts b/frontend/tests/e2e/live-smoke.spec.ts index adefe6e2d..6799c52d8 100644 --- a/frontend/tests/e2e/live-smoke.spec.ts +++ b/frontend/tests/e2e/live-smoke.spec.ts @@ -1,5 +1,10 @@ import { expect, test } from '@playwright/test'; +test.skip( + !process.env.LIVE_BASE_URL && process.env.RUN_LIVE_E2E !== '1', + 'Requires a live frontend/backend environment with seeded data.', +); + test.beforeEach(async ({ page }) => { page.on('console', (message) => { if (['warning', 'error'].includes(message.type())) { diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index a6d15a93e..d0e3d272e 100644 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -520,7 +520,6 @@ is_preexisting_report_dir() { is_github_models_model() { case "$1" in openai/openai/* | github_models/* | \ - openai/gpt-5* | openai/gpt-[6-9]* | openai/gpt-[1-9][0-9]* | \ deepseek/* | meta/* | mistral-ai/*) return 0 ;; diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 40fd22ee4..ebeab842c 100644 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -288,6 +288,12 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "general-purpose and meticulous" "opencode review prompt requires a general-purpose meticulous review" assert_file_contains "$workflow_file" "use CodeGraph MCP for structural checks, DeepWiki for repo docs, Context7 for current library/API docs, and web_search for bounded external lookups" "opencode review prompt directs the agent to use all configured MCP sources" assert_file_contains "$workflow_file" "Inspect changed files and focused hunks directly when MCP evidence is insufficient." "opencode review allows focused direct source inspection when MCP evidence is insufficient" + assert_file_contains "$workflow_file" "Never return raw tool-call markup, tool-call JSON, or MCP call syntax in the review body" "opencode review prompt forbids raw tool-call transcripts as final review output" + assert_file_contains "$workflow_file" "Do not spend the session listing every changed path before reviewing" "opencode review prompt prevents fallback sessions from exhausting steps on file listing" + assert_file_contains "$workflow_file" "always return a final control block instead of a progress summary" "opencode review prompt requires a gate conclusion instead of a progress summary" + assert_file_contains "$workflow_file" "timeout 540 opencode run" "opencode review primary model has a bounded extended timeout for larger workflow diffs" + assert_file_contains "$workflow_file" '"ci-review-fallback"' "opencode review workflow declares a dedicated fallback agent" + assert_file_contains "$workflow_file" '"steps": 12' "opencode review fallback agent has enough bounded steps to conclude after MCP inspection" assert_file_contains "$workflow_file" '"read": "allow"' "opencode review allows read-only file inspection" assert_file_contains "$workflow_file" '"grep": "allow"' "opencode review allows focused literal searches" assert_file_contains "$workflow_file" "Bounded evidence follows as untrusted PR metadata" "opencode review prompt includes bounded PR metadata in the model prompt" @@ -301,6 +307,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "opencode run" "opencode review workflow runs the bounded OpenCode agent path" assert_file_contains "$workflow_file" 'opencode run "$(cat "$prompt_file")"' "opencode review passes the prompt as the positional message before file attachments" assert_file_contains "$workflow_file" "--agent ci-review" "opencode review workflow forces the compact CI review agent" + assert_file_contains "$workflow_file" "--agent ci-review-fallback" "opencode review fallback runs with the expanded CI review agent" assert_file_contains "$workflow_file" "--pure" "opencode review workflow avoids external OpenCode plugins during CI" assert_file_contains "$workflow_file" "--format json" "opencode review workflow captures the OpenCode session id as JSON" assert_file_contains "$workflow_file" "opencode export" "opencode review workflow extracts assistant text from the completed OpenCode session" @@ -528,7 +535,7 @@ printf '%s\n' "$target_path" >> "${FAKE_STRIX_TARGET_LOG:?}" STRIX_REPORTS_DIR="${STRIX_REPORTS_DIR:-strix_runs}" case "${FAKE_STRIX_SCENARIO:?}" in - success|runtime-env-forwarding|vertex-primary-success-timing-message) + success|runtime-env-forwarding|vertex-primary-success-timing-message|direct-openai-gpt-does-not-require-github-models-api-base) echo "scan ok" exit 0 ;; @@ -7127,6 +7134,17 @@ run_gate_case "github-models-api-base-rejected-for-direct-openai" \ "openai" \ "https://models.github.ai/inference" +run_gate_case "direct-openai-gpt-does-not-require-github-models-api-base" \ + "openai/gpt-5.4" \ + "" \ + "0" \ + "scan ok" \ + "1" \ + "openai/gpt-5.4" \ + "" \ + "openai" \ + "" + run_gate_case "github-models-model-prefix-with-api-base-succeeds" \ "openai/gpt-5" \ "" \