import { test, expect, type Page } from '@playwright/test'; // ── Shared helpers for the space task/settings parity suite ─────────────────── // // The tests below all start the same way: open Spaces, create a case space, open // it, and (for chat actions) start an inline chat. The first batch of tests in // this file inlines that flow; the feature-parity tests added later reuse these // helpers to stay focused on the action under test. The helpers mirror the // inlined flow exactly (same testids, same waits) — they do NOT relax any // assertion, they just remove copy-paste. // The no-auth + live-session probe allowlist shared by the chat/settings tests. // Identical set to the inline-chat/settings tests above: with auth disabled the // UI's /api/auth/me probe 404s on purpose and falls back to the synthetic local // user; CreateTaskDialog's org/MCP probes 401; the per-task console/browser // status probes 404 with no live session. All benign, not failures. const SPACE_BENIGN_PATHS = new Set([ '/api/auth/me', '/api/users/me/orgs', '/api/mcp/connections', '/api/mcp/servers', '/api/mcp/user-servers', '/api/ssh/connections', ]); const SPACE_BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i; const SPACE_BENIGN_PATH_RE = /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/; /** Attach the standard fatal-error tracker used by every space test and return * the collected list (assert it stays empty at the end of the test). */ function trackFatalErrors(page: Page): string[] { const fatalErrors: string[] = []; page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`)); page.on('response', (res) => { if (res.status() >= 400) { const { pathname } = new URL(res.url()); if ( !SPACE_BENIGN_ASSET.test(pathname) && !SPACE_BENIGN_PATHS.has(pathname) && !SPACE_BENIGN_PATH_RE.test(pathname) ) { fatalErrors.push(`HTTP ${res.status()} ${res.url()}`); } } }); return fatalErrors; } /** Open Spaces, create a case space with a unique title, select it, and return * the detail locator + the title used. Mirrors the inlined create flow. */ async function createAndOpenCaseSpace(page: Page, label: string) { await page.goto('/ui'); await page.getByTestId('nav-spaces').click(); const rail = page.getByTestId('space-rail'); await expect(rail).toBeVisible(); await page.getByTestId('create-space-btn').click(); const titleInput = page.getByTestId('space-title-input'); await expect(titleInput).toBeVisible(); const caseTitle = `${label}-${Date.now()}`; await titleInput.fill(caseTitle); await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); const caseRow = rail .locator('[data-testid="space-row"][data-space-kind="case"]') .filter({ hasText: caseTitle }); await expect(caseRow).toBeVisible(); const spaceId = await caseRow.getAttribute('data-space-id'); expect(spaceId).toBeTruthy(); await caseRow.click(); const detail = page.getByTestId('space-detail'); await expect(detail).toBeVisible(); return { detail, caseTitle, rail, spaceId: spaceId as string }; } /** Start a new inline chat in the currently-open space, returning the inline * conversation locator and the created task id (from the chat= URL param). */ async function startInlineChat(page: Page, body: string) { await page.getByTestId('space-tab-chat').click(); await page.getByTestId('space-new-chat-btn').click(); const composer = page.getByTestId('create-task-body'); await expect(composer).toBeVisible(); await composer.fill(body); await page.getByTestId('create-task-submit').click(); const conversation = page.getByTestId('space-conversation'); await expect(conversation).toBeVisible(); await expect(page).toHaveURL(/[?&]chat=\d+/); const taskId = new URL(page.url()).searchParams.get('chat'); expect(taskId).toBeTruthy(); return { conversation, taskId: taskId as string }; } type TaskDetail = { task?: { latestJob?: { status?: string } | null; shareToken?: string | null; feedbackRating?: string | null; visibility?: string | null; }; }; /** Fetch the task detail. NOTE the API wraps the row under `.task`. */ async function fetchTask(page: Page, taskId: string): Promise { const res = await page.request.get(`/api/local/tasks/${taskId}`); if (!res.ok()) return null; return ((await res.json()) as TaskDetail).task ?? null; } const TERMINAL_JOB_STATES = ['succeeded', 'failed', 'waiting_human', 'cancelled', 'aborted']; /** Poll the task detail until the latest job reaches a terminal state, OR the * window expires. Returns the terminal status, or '' if the job never settled. * * ENV NOTE: a terminal job requires the worker to actually run the job, which * needs a reachable LLM. The E2E env configures NO provider, so the worker * stays unhealthy and the job sits `queued` indefinitely — it never reaches a * terminal state here. Tests that need the terminal branch (Continue / Feedback) * therefore skip when this returns '' (documented at each call site), and run * the full active assertion only when a real LLM IS reachable. */ async function waitForTerminalJob(page: Page, taskId: string, timeout = 30_000): Promise { const deadline = Date.now() + timeout; while (Date.now() < deadline) { const task = await fetchTask(page, taskId); const status = task?.latestJob?.status ?? ''; if (TERMINAL_JOB_STATES.includes(status)) return status; await page.waitForTimeout(1500); } return ''; } // End-to-end smoke for the spaces-foundation UI (plan 4, Task 5). // // Drives the real orchestrator (auth off => synthetic 'local' user). The // personal space is auto-created on the first /api/local/spaces call, so it is // always present in a fresh temp DB. We then create a case space and open it. // // Selectors use data-testid added to the spaces components rather than brittle // Japanese label text, but the visible labels stay Japanese. test('spaces foundation: rail, create case space, open detail tabs', async ({ page }) => { // Track only genuinely fatal client errors. Uncaught JS exceptions // (pageerror) are always fatal. Generic console "Failed to load resource" // messages carry no URL, so we resolve the real culprit from the matching // HTTP response and ignore benign static-asset 404s (favicon, source maps, // PWA manifest) that don't break the flow. const fatalErrors: string[] = []; const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i; // When auth is disabled (our default E2E config), /api/auth/me is NOT mounted, // so the UI's probe 404s on purpose and falls back to the synthetic local // user. That 404 is the expected no-auth signal, not a failure. const benignPaths = new Set(['/api/auth/me']); page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`)); page.on('response', (res) => { if (res.status() >= 400) { const url = res.url(); const { pathname } = new URL(url); if (!benignAsset.test(pathname) && !benignPaths.has(pathname)) { fatalErrors.push(`HTTP ${res.status()} ${url}`); } } }); // 1. Open the app. The server redirects / -> /ui; load /ui directly. await page.goto('/ui'); // App shell is up — the Spaces nav tab is rendered. const navSpaces = page.getByTestId('nav-spaces'); await expect(navSpaces).toBeVisible(); // 2. Navigate to the Spaces tab. await navSpaces.click(); // 3. The space rail renders, with the auto-created personal space pinned. const rail = page.getByTestId('space-rail'); await expect(rail).toBeVisible(); const personalRow = rail.locator('[data-testid="space-row"][data-space-kind="personal"]'); await expect(personalRow).toHaveCount(1); await expect(personalRow).toBeVisible(); // 4. Create a new case space via 新規. await page.getByTestId('create-space-btn').click(); const titleInput = page.getByTestId('space-title-input'); await expect(titleInput).toBeVisible(); const caseTitle = `E2E案件-${Date.now()}`; await titleInput.fill(caseTitle); await page.getByTestId('space-form-submit').click(); // 5. The new case space appears in the rail (and dialog closes). await expect(titleInput).toHaveCount(0); const caseRow = rail .locator('[data-testid="space-row"][data-space-kind="case"]') .filter({ hasText: caseTitle }); await expect(caseRow).toBeVisible(); // 6. Selecting it (creation auto-selects it) shows the SpaceDetail with both tabs. await caseRow.click(); const detail = page.getByTestId('space-detail'); await expect(detail).toBeVisible(); await expect(detail.getByText(caseTitle)).toBeVisible(); await expect(page.getByTestId('space-tab-chat')).toBeVisible(); await expect(page.getByTestId('space-tab-files')).toBeVisible(); // 7. The files tab opens without a fatal error (empty tree is fine). await page.getByTestId('space-tab-files').click(); await expect(page.getByTestId('space-files')).toBeVisible(); // No fatal client errors (uncaught exceptions / non-asset HTTP failures). expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // Mobile (=md both are visible together // (unchanged), so this invariant only holds at narrow widths. test('spaces mobile: rail <-> detail switch with back control at 390px', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); // At phone widths the TopBar collapses its inline nav into a hamburger // drawer, so the nav-spaces tab isn't directly clickable. Navigate via the // URL state (page=spaces) instead — it's the same selection, sans drawer. await page.goto('/ui?page=spaces'); // No space selected: the rail is the visible full-width list; detail hidden. const rail = page.getByTestId('space-rail'); const detail = page.getByTestId('space-detail'); await expect(rail).toBeVisible(); await expect(detail).toBeHidden(); await expect(page.getByTestId('space-back-to-list')).toHaveCount(0); // Create + auto-select a case space. await page.getByTestId('create-space-btn').click(); const titleInput = page.getByTestId('space-title-input'); await expect(titleInput).toBeVisible(); const caseTitle = `E2Eモバイル-${Date.now()}`; await titleInput.fill(caseTitle); await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); // A space is open: detail is now the visible full-width view, rail is hidden, // and the mobile back control is present. await expect(detail).toBeVisible(); await expect(detail.getByText(caseTitle)).toBeVisible(); await expect(rail).toBeHidden(); const back = page.getByTestId('space-back-to-list'); await expect(back).toBeVisible(); // Tapping back clears the selection and returns to the full-width rail. await back.click(); await expect(rail).toBeVisible(); await expect(detail).toBeHidden(); await expect(page.getByTestId('space-back-to-list')).toHaveCount(0); }); // Inline conversation inside a space (plan 6). The user starts a chat from the // space's チャット tab and the conversation (ChatPane: messages + composer) // renders INLINE — without ever navigating to the Tasks page. No LLM is // configured in the E2E env, so the agent never completes; we only assert the // conversation UI is present and the no-navigation invariant holds. test('space inline chat: create + open conversation without leaving for Tasks', async ({ page }) => { const fatalErrors: string[] = []; const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i; // With auth disabled, these probes 401 on purpose (the CreateTaskDialog // fetches orgs + MCP connections which require an authenticated user). The // UI tolerates the 401 and falls back to empty lists, so they are the // expected no-auth signal — not a failure. const benignPaths = new Set(['/api/auth/me', '/api/users/me/orgs', '/api/mcp/connections']); // useVisibleDetailTabs (now used by the inline conversation's detail tabs) // polls per-task console/browser-session status to decide whether to show the // live SSH/Browser tabs. With no live session these 404 by design and the // hook degrades to "tab hidden" — benign, like the no-auth probes above. const benignPathRe = /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/; page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`)); page.on('response', (res) => { if (res.status() >= 400) { const { pathname } = new URL(res.url()); if (!benignAsset.test(pathname) && !benignPaths.has(pathname) && !benignPathRe.test(pathname)) { fatalErrors.push(`HTTP ${res.status()} ${res.url()}`); } } }); // 1. Open the app, go to Spaces, create a case space. await page.goto('/ui'); await page.getByTestId('nav-spaces').click(); const rail = page.getByTestId('space-rail'); await expect(rail).toBeVisible(); await page.getByTestId('create-space-btn').click(); const titleInput = page.getByTestId('space-title-input'); await expect(titleInput).toBeVisible(); const caseTitle = `E2E会話-${Date.now()}`; await titleInput.fill(caseTitle); await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); const caseRow = rail .locator('[data-testid="space-row"][data-space-kind="case"]') .filter({ hasText: caseTitle }); await expect(caseRow).toBeVisible(); await caseRow.click(); const detail = page.getByTestId('space-detail'); await expect(detail).toBeVisible(); // 2. On the チャット tab, start a new chat. await page.getByTestId('space-tab-chat').click(); await page.getByTestId('space-new-chat-btn').click(); const body = page.getByTestId('create-task-body'); await expect(body).toBeVisible(); await body.fill('E2E: inline conversation smoke. これはテスト用の最初のメッセージです。'); await page.getByTestId('create-task-submit').click(); // 3. The inline conversation pane (ChatPane) appears inside the space. // No LLM => the agent won't progress, but the conversation UI renders. const conversation = page.getByTestId('space-conversation'); await expect(conversation).toBeVisible(); // 4. No navigation to the Tasks page. The app stays on the Spaces page // (urlState serializes page=spaces) and sets the chat= param for the // selected inline chat. It must NOT switch to page=tasks (which would // drop page= entirely AND have no chat=). await expect(page).toHaveURL(/[?&]page=spaces(&|$)/); await expect(page).toHaveURL(/[?&]chat=\d+/); expect(page.url()).toContain('chat='); expect(page.url()).not.toContain('page=tasks'); // The Spaces detail (not the Tasks board) is still mounted. await expect(detail).toBeVisible(); // 4b. The inline conversation exposes per-task detail tabs via the single // in-place tab bar (data-testid="space-chat-tabs") so Progress is visible // WITHOUT leaving for Tasks. Clicking 進捗 (Progress = the `activity` tab) // swaps the ProgressTab content IN PLACE (no overlay/✕), then 会話 returns // to the conversation — all while staying on page=spaces with the same // chat= param. (A dedicated test below covers the tab-bar structure.) const progressTab = conversation.getByTestId('space-chat-tab-activity'); await expect(progressTab).toBeVisible(); await progressTab.click(); // In-place ProgressTab content renders inside the shared tabpanel (no Tasks nav). await expect(conversation.getByTestId('progress-tab')).toBeVisible(); await expect(progressTab).toHaveAttribute('aria-selected', 'true'); // No overlay close affordance — the redesign dropped the overlay entirely. await expect(page.getByTestId('detail-panel-close')).toHaveCount(0); // Invariant: still inline on Spaces, same chat, never bounced to Tasks. await expect(page).toHaveURL(/[?&]page=spaces(&|$)/); await expect(page).toHaveURL(/[?&]chat=\d+/); expect(page.url()).not.toContain('page=tasks'); // Return to the conversation via the 会話 tab so the rest of the flow // (creating a second chat) starts from chat again. await conversation.getByTestId('space-chat-tab-chat').click(); await expect(conversation.getByTestId('progress-tab')).toHaveCount(0); // 5. Create a SECOND chat in the same space and assert the conversation // switches when selecting the first one again from the list. const firstChatUrl = new URL(page.url()); const firstChatId = firstChatUrl.searchParams.get('chat'); expect(firstChatId).toBeTruthy(); await page.getByTestId('space-new-chat-btn').click(); const body2 = page.getByTestId('create-task-body'); await expect(body2).toBeVisible(); await body2.fill('E2E: second inline chat. 二つ目のチャットです。'); await page.getByTestId('create-task-submit').click(); // The second chat auto-selects: chat= now points at a different id. await expect .poll(() => new URL(page.url()).searchParams.get('chat')) .not.toBe(firstChatId); await expect(conversation).toBeVisible(); const secondChatId = new URL(page.url()).searchParams.get('chat'); // Selecting the first chat row again switches the conversation back to it. const firstRow = page.locator(`[data-testid="space-chat-row"][data-task-id="${firstChatId}"]`); await expect(firstRow).toBeVisible(); await firstRow.click(); await expect .poll(() => new URL(page.url()).searchParams.get('chat')) .toBe(firstChatId); expect(secondChatId).not.toBe(firstChatId); await expect(conversation).toBeVisible(); // Still on Spaces, still has a chat — never bounced to Tasks. await expect(page).toHaveURL(/[?&]page=spaces(&|$)/); expect(page.url()).not.toContain('page=tasks'); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // Redesigned space Files tab (feat/space-files-explorer). The space-level ファイル // tab is now an icon grid (space-files-grid) with a breadcrumb, an upload button + // hidden , and a dropzone. Uploading a file makes a // space-file-tile appear; clicking a file tile opens the FilePreview modal // (role="dialog") showing the content. Directory tiles navigate (breadcrumb // updates) — covered here only by the no-nav invariant (we stay on page=spaces). // No LLM is needed; this is pure file-management UI, so we never assert agent output. test('space files: icon grid, upload appears as tile, click opens preview modal', async ({ page }) => { const fatalErrors: string[] = []; const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i; // Same no-auth probe allowlist as the other space tests: with auth disabled the // UI's /api/auth/me probe 404s on purpose and falls back to the synthetic local // user — the expected no-auth signal, not a failure. const benignPaths = new Set(['/api/auth/me']); page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`)); page.on('response', (res) => { if (res.status() >= 400) { const { pathname } = new URL(res.url()); if (!benignAsset.test(pathname) && !benignPaths.has(pathname)) { fatalErrors.push(`HTTP ${res.status()} ${res.url()}`); } } }); // 1. Spaces → create a case space → open the space-level ファイル tab. await page.goto('/ui'); await page.getByTestId('nav-spaces').click(); const rail = page.getByTestId('space-rail'); await expect(rail).toBeVisible(); await page.getByTestId('create-space-btn').click(); const titleInput = page.getByTestId('space-title-input'); await expect(titleInput).toBeVisible(); const caseTitle = `E2Eファイル-${Date.now()}`; await titleInput.fill(caseTitle); await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); const caseRow = rail .locator('[data-testid="space-row"][data-space-kind="case"]') .filter({ hasText: caseTitle }); await expect(caseRow).toBeVisible(); await caseRow.click(); const detail = page.getByTestId('space-detail'); await expect(detail).toBeVisible(); await page.getByTestId('space-tab-files').click(); // 2. The grid + breadcrumb render (empty tree is fine). await expect(page.getByTestId('space-files-grid')).toBeVisible(); await expect(page.getByTestId('space-files-breadcrumb')).toBeVisible(); // 3. Upload a LARGE (>150kb) file through the hidden multiple-file input. // This guards the P1 regression where a global express.json() (~100kb) // shadowed the upload route's 50mb parser and 413'd any body >~100kb. // A tiny buffer passed even with the bug; 160kb only passes after the fix. await page .locator('[data-testid="space-files-upload-input"]') .setInputFiles({ name: 'e2e-large.txt', mimeType: 'text/plain', buffer: Buffer.alloc(160 * 1024, 'a'), }); // 4. The large file shows up as a tile (upload + refetch take a moment). const largeTile = page.locator( '[data-testid="space-file-tile"][data-kind="file"][data-name="e2e-large.txt"]', ); await expect(largeTile).toBeVisible({ timeout: 15000 }); // 5. Upload a separate TINY markdown file so the modal-preview assertion stays // cheap and locale-independent. await page .locator('[data-testid="space-files-upload-input"]') .setInputFiles({ name: 'e2e-upload.md', mimeType: 'text/markdown', buffer: Buffer.from('# E2E\nhello'), }); const tile = page.locator( '[data-testid="space-file-tile"][data-kind="file"][data-name="e2e-upload.md"]', ); await expect(tile).toBeVisible({ timeout: 15000 }); // 6. Clicking the tile opens the FilePreview modal (role="dialog") showing the // markdown content; close it by clicking the backdrop (locale-independent — // the ✕ label is translated; the backdrop corner is not). The backdrop is // the dialog's parent; its mousedown+click both land on the backdrop itself, // which is exactly what useBackdropClose requires to close. await tile.click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); await expect(dialog).toContainText('hello'); const backdrop = dialog.locator('xpath=..'); await backdrop.click({ position: { x: 4, y: 4 } }); await expect(dialog).toHaveCount(0); // 6. No-nav invariant: file management never leaves Spaces for the Tasks board. await expect(page).toHaveURL(/[?&]page=spaces(&|$)/); expect(page.url()).not.toContain('page=tasks'); await expect(detail).toBeVisible(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // Feature B (feat/space-gpu-and-filelinks): the space rail carries a collapsible // worker/GPU status footer. The header button (data-testid="space-worker-status-toggle") // is always present in the rail (desktop width, hidden md:flex); clicking it expands // the WorkerStatusWidget panel (data-testid="space-worker-status-panel") in place. // We assert the toggle is visible at desktop width, then click it and assert the // panel content becomes visible (aria-expanded flips true). No LLM is needed — // the widget reads /api/workers/status which is always populated in worker mode. test('space worker status: rail GPU panel toggles open', async ({ page }) => { // 1. Open the app at the default (desktop) viewport and go to Spaces. await page.goto('/ui'); await page.getByTestId('nav-spaces').click(); const rail = page.getByTestId('space-rail'); await expect(rail).toBeVisible(); // 2. The rail-bottom worker/GPU footer + its toggle header are present (the // footer wrapper is always mounted; the toggle is visible at >=md width). const status = page.getByTestId('space-worker-status'); await expect(status).toBeVisible(); const toggle = page.getByTestId('space-worker-status-toggle'); await expect(toggle).toBeVisible(); // Collapsed by default: the inner widget panel is not yet rendered. await expect(toggle).toHaveAttribute('aria-expanded', 'false'); await expect(page.getByTestId('space-worker-status-panel')).toHaveCount(0); // 3. Clicking the toggle expands the panel; the WorkerStatusWidget content // mounts and aria-expanded flips to true. await toggle.click(); await expect(toggle).toHaveAttribute('aria-expanded', 'true'); await expect(page.getByTestId('space-worker-status-panel')).toBeVisible(); }); // AAO Gateway / LiteLLM front: a `proxy: true` worker fans out into a backends[] // tree (collectWorkerStatuses, unit-tested separately). This test pins the *UI* // half: given a proxy worker payload, the widget renders the proxy badge and one // row per backend. The /api/local/dashboard/workers response is stubbed so the // test is deterministic without standing up a real gateway + probe registry. test('space worker status: proxy worker renders per-backend rows (AAO Gateway)', async ({ page }) => { await page.route('**/api/local/dashboard/workers', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ workers: [ { id: 'gw', name: 'gw', roles: ['task'], state: 'running', proxy: true, backends: [ { id: 'backend-a', state: 'running', busySlots: 1, totalSlots: 4, online: true }, { id: 'backend-b', state: 'idle', busySlots: 0, totalSlots: 4, online: true }, ], }, ], }), }), ); await page.goto('/ui'); await page.getByTestId('nav-spaces').click(); await expect(page.getByTestId('space-rail')).toBeVisible(); // Open the footer; the WorkerStatusWidget mounts and fetches the stubbed list. await page.getByTestId('space-worker-status-toggle').click(); await expect(page.getByTestId('space-worker-status-panel')).toBeVisible(); // The proxy worker row + its proxy badge render. await expect(page.getByTestId('worker-row-gw')).toBeVisible(); await expect(page.getByTestId('worker-proxy-badge')).toBeVisible(); // Backends are expanded by default → one row per Gateway backend, with slot caption. await expect(page.getByTestId('worker-backend-backend-a')).toBeVisible(); await expect(page.getByTestId('worker-backend-backend-b')).toBeVisible(); await expect(page.getByTestId('worker-backend-backend-a')).toContainText('1/4'); }); // Feature A (feat/space-gpu-and-filelinks): the inline conversation now wraps its // chat in an OutputPreviewProvider, so clicking a rendered output-path link // () opens the FilePreview // modal (role="dialog") instead of doing nothing. To produce such a link // deterministically WITHOUT an LLM, we post a user comment whose markdown body // contains an output-path link; MarkdownText linkifies it (markdown link whose href // matches OUTPUT_PATH_REGEX => data-output-path anchor). We also create the file // itself (output/note.md, output-only PUT /files/content) so the .md preview resolves // to content rather than a 404 (previewLocalFile fetches content for markdown and // only opens the modal on success). We assert the dialog opens, then close it; we // never assert agent output and we never leave page=spaces. test('space output link: chat output-path link opens preview modal', async ({ page }) => { const fatalErrors: string[] = []; const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i; const benignPaths = new Set(['/api/auth/me', '/api/users/me/orgs', '/api/mcp/connections']); const benignPathRe = /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/; page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`)); page.on('response', (res) => { if (res.status() >= 400) { const { pathname } = new URL(res.url()); if (!benignAsset.test(pathname) && !benignPaths.has(pathname) && !benignPathRe.test(pathname)) { fatalErrors.push(`HTTP ${res.status()} ${res.url()}`); } } }); // 1. Spaces → create a case space → start an inline chat (same flow as above). await page.goto('/ui'); await page.getByTestId('nav-spaces').click(); const rail = page.getByTestId('space-rail'); await expect(rail).toBeVisible(); await page.getByTestId('create-space-btn').click(); const titleInput = page.getByTestId('space-title-input'); await expect(titleInput).toBeVisible(); const caseTitle = `E2Eリンク-${Date.now()}`; await titleInput.fill(caseTitle); await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); const caseRow = rail .locator('[data-testid="space-row"][data-space-kind="case"]') .filter({ hasText: caseTitle }); await expect(caseRow).toBeVisible(); await caseRow.click(); const detail = page.getByTestId('space-detail'); await expect(detail).toBeVisible(); await page.getByTestId('space-tab-chat').click(); await page.getByTestId('space-new-chat-btn').click(); const body = page.getByTestId('create-task-body'); await expect(body).toBeVisible(); await body.fill('E2E: 出力リンクのプレビュー確認。最初のメッセージ。'); await page.getByTestId('create-task-submit').click(); const conversation = page.getByTestId('space-conversation'); await expect(conversation).toBeVisible(); // 2. Capture the created task id from the chat= URL param. await expect(page).toHaveURL(/[?&]chat=\d+/); const taskId = new URL(page.url()).searchParams.get('chat'); expect(taskId).toBeTruthy(); // 3. Create output/note.md via the output-only inline-edit endpoint so the // markdown preview resolves to content. A freshly created chat may have a // queued/running job (which locks the editor with 409); with no LLM the job // settles quickly, so poll the PUT until it succeeds. await expect.poll(async () => { const res = await page.request.put(`/api/local/tasks/${taskId}/files/content`, { data: { section: 'output', path: 'note.md', content: '# E2E note\nhello from output' }, }); return res.status(); }, { timeout: 30_000 }).toBe(200); // 4. Post a user comment whose markdown body carries an output-path link. The // link href (output/note.md) matches OUTPUT_PATH_REGEX, so MarkdownText emits // . const commentRes = await page.request.post(`/api/local/tasks/${taskId}/comments`, { data: { body: '結果はこちら [note.md](output/note.md) です', author: 'user' }, }); expect(commentRes.ok()).toBeTruthy(); // 5. The conversation polls comments; the output-path link appears. Click it. const link = conversation.locator('a.output-path-link[data-output-path="output/note.md"]'); await expect(link).toBeVisible({ timeout: 15_000 }); await link.click(); // 6. The FilePreview modal (role="dialog") opens with the file content. const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); await expect(dialog).toContainText('hello from output'); // Close via the backdrop (locale-independent), same as the files test. const backdrop = dialog.locator('xpath=..'); await backdrop.click({ position: { x: 4, y: 4 } }); await expect(dialog).toHaveCount(0); // 7. No-nav invariant: clicking the link previewed in place, never bounced to Tasks. await expect(page).toHaveURL(/[?&]page=spaces(&|$)/); await expect(page).toHaveURL(/[?&]chat=\d+/); expect(page.url()).not.toContain('page=tasks'); await expect(detail).toBeVisible(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // Redesigned space-chat UX (feat/space-chat-ux). The inline conversation now // carries a SINGLE in-place tab bar (data-testid="space-chat-tabs", role=tablist) // with 会話 (chat) + 概要 (overview) + 進捗 (activity) + トレース (trace) and NO // files tab (files live at the space level). Clicking a non-chat tab swaps the // content IN PLACE — no overlay, no ✕ close button — and 会話 returns to the // conversation. Throughout, we never leave for the Tasks page (page=spaces + // chat= stay put). No LLM is configured in E2E, so we assert UI/structure only, // never agent output. test('space chat tabs: single in-place tab bar, overview in place, back to chat', async ({ page }) => { const fatalErrors: string[] = []; const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i; // Same no-auth + live-session probe allowlist as the inline-chat test above. const benignPaths = new Set(['/api/auth/me', '/api/users/me/orgs', '/api/mcp/connections']); const benignPathRe = /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/; page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`)); page.on('response', (res) => { if (res.status() >= 400) { const { pathname } = new URL(res.url()); if (!benignAsset.test(pathname) && !benignPaths.has(pathname) && !benignPathRe.test(pathname)) { fatalErrors.push(`HTTP ${res.status()} ${res.url()}`); } } }); // 1. Spaces → create a case space → start an inline chat (same flow as above). await page.goto('/ui'); await page.getByTestId('nav-spaces').click(); const rail = page.getByTestId('space-rail'); await expect(rail).toBeVisible(); await page.getByTestId('create-space-btn').click(); const titleInput = page.getByTestId('space-title-input'); await expect(titleInput).toBeVisible(); const caseTitle = `E2Eタブ-${Date.now()}`; await titleInput.fill(caseTitle); await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); const caseRow = rail .locator('[data-testid="space-row"][data-space-kind="case"]') .filter({ hasText: caseTitle }); await expect(caseRow).toBeVisible(); await caseRow.click(); const detail = page.getByTestId('space-detail'); await expect(detail).toBeVisible(); await page.getByTestId('space-tab-chat').click(); await page.getByTestId('space-new-chat-btn').click(); const body = page.getByTestId('create-task-body'); await expect(body).toBeVisible(); await body.fill('E2E: 単一タブの会話。インプレース切替を確認する最初のメッセージ。'); await page.getByTestId('create-task-submit').click(); const conversation = page.getByTestId('space-conversation'); await expect(conversation).toBeVisible(); // 2. The single tab bar (role=tablist) is visible and carries 会話 + 概要 but // NOT a files tab in this bar. const tabs = conversation.getByTestId('space-chat-tabs'); await expect(tabs).toBeVisible(); await expect(tabs).toHaveAttribute('role', 'tablist'); const chatTab = page.getByTestId('space-chat-tab-chat'); const overviewTab = page.getByTestId('space-chat-tab-overview'); await expect(chatTab).toBeVisible(); await expect(overviewTab).toBeVisible(); // No files tab inside this in-place bar (files are a space-level concern). await expect(tabs.getByTestId('space-chat-tab-files')).toHaveCount(0); // 会話 is the default-selected tab; the message composer (textarea) is present. await expect(chatTab).toHaveAttribute('aria-selected', 'true'); await expect(conversation.locator('textarea')).toBeVisible(); // 3. Click 概要 → its content renders IN PLACE. There is NO overlay and NO ✕ // close button (headerless DetailPanel ⇒ no detail-panel-close testid), and // the overview tab becomes aria-selected. The conversation composer is gone. await overviewTab.click(); await expect(overviewTab).toHaveAttribute('aria-selected', 'true'); await expect(chatTab).toHaveAttribute('aria-selected', 'false'); // In place: the shared tabpanel (role=tabpanel) is still mounted inside the // same conversation container — not a separate overlay. await expect(conversation.getByRole('tabpanel')).toBeVisible(); // No overlay close affordance anywhere on the page. await expect(page.getByTestId('detail-panel-close')).toHaveCount(0); // Composer is swapped out (overview content replaced the conversation in place). await expect(conversation.locator('textarea')).toHaveCount(0); // 4. Click 会話 → back to the conversation; composer returns, chat selected. await chatTab.click(); await expect(chatTab).toHaveAttribute('aria-selected', 'true'); await expect(overviewTab).toHaveAttribute('aria-selected', 'false'); await expect(conversation.locator('textarea')).toBeVisible(); // 5. No navigation to Tasks throughout: page=spaces + chat= held the entire time. await expect(page).toHaveURL(/[?&]page=spaces(&|$)/); await expect(page).toHaveURL(/[?&]chat=\d+/); expect(page.url()).toContain('chat='); expect(page.url()).not.toContain('page=tasks'); await expect(detail).toBeVisible(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // Space 設定 tab (feat/space-folder-settings). The SpaceDetail now carries a // third tab "設定" (space-tab-settings) that renders SpaceSettings: a sub-nav // (space-settings-nav-{agents,memory,pieces,skills,mcp,ssh}) + the matching // panel. The file-based panels (agents/memory/pieces/skills) are space-scoped — // their API calls carry ?spaceId=, so AGENTS.md saved here lands under the // space folder, NOT the per-user folder. We prove that by saving unique text via // the AGENTS.md editor (Monaco), reloading, and asserting it persisted. MCP/SSH // are now ALSO per-space (DB-backed, spec §11): selecting either renders its own // space-scoped panel (no more "not space-scoped" disclaimer). No LLM is needed; // this is pure settings UI, so we never assert agent output and never leave // page=spaces. test('space settings tab: AGENTS.md per-space persistence + MCP/SSH panels render', async ({ page }) => { const fatalErrors: string[] = []; const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i; // Same no-auth probe allowlist as the other space tests. With auth disabled the // UI's /api/auth/me probe 404s on purpose; the MCP/SSH panels also probe // connection endpoints which 401/404 under no-auth and degrade to empty lists. const benignPaths = new Set([ '/api/auth/me', '/api/users/me/orgs', '/api/mcp/connections', '/api/mcp/servers', '/api/mcp/user-servers', '/api/ssh/connections', ]); page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`)); page.on('response', (res) => { if (res.status() >= 400) { const { pathname } = new URL(res.url()); if (!benignAsset.test(pathname) && !benignPaths.has(pathname)) { fatalErrors.push(`HTTP ${res.status()} ${res.url()}`); } } }); // 1. Spaces → create a case space → open it. await page.goto('/ui'); await page.getByTestId('nav-spaces').click(); const rail = page.getByTestId('space-rail'); await expect(rail).toBeVisible(); await page.getByTestId('create-space-btn').click(); const titleInput = page.getByTestId('space-title-input'); await expect(titleInput).toBeVisible(); const caseTitle = `E2E設定-${Date.now()}`; await titleInput.fill(caseTitle); await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); const caseRow = rail .locator('[data-testid="space-row"][data-space-kind="case"]') .filter({ hasText: caseTitle }); await expect(caseRow).toBeVisible(); await caseRow.click(); const detail = page.getByTestId('space-detail'); await expect(detail).toBeVisible(); // 2. The 設定 tab is present; clicking it shows the settings sub-nav. const settingsTab = page.getByTestId('space-tab-settings'); await expect(settingsTab).toBeVisible(); await settingsTab.click(); await expect(page.getByTestId('space-settings-nav-agents')).toBeVisible(); await expect(page.getByTestId('space-settings-nav-memory')).toBeVisible(); await expect(page.getByTestId('space-settings-nav-pieces')).toBeVisible(); await expect(page.getByTestId('space-settings-nav-skills')).toBeVisible(); await expect(page.getByTestId('space-settings-nav-mcp')).toBeVisible(); await expect(page.getByTestId('space-settings-nav-ssh')).toBeVisible(); await expect(page.getByTestId('space-settings-nav-browser')).toBeVisible(); // 3. AGENTS.md panel (default-selected section). The Monaco editor mounts; // type unique text into it and Save. A fixed unique marker lets the reload // assertion below be exact and locale-independent. // assertion below be exact and locale-independent. await page.getByTestId('space-settings-nav-agents').click(); const editor = page.getByTestId('monaco-file-editor'); await expect(editor).toBeVisible(); // Monaco loads its editor lazily (@monaco-editor/react shows a loading state // first). Wait for the editor surface, then drive its hidden textarea: click // to focus, then type. The single short line lands in the (always-rendered) // .view-lines so we can assert it back after a reload. const marker = 'AGENTS_SPACE_PERSIST_42'; const monacoSurface = editor.locator('.monaco-editor'); await expect(monacoSurface).toBeVisible({ timeout: 20_000 }); // The editing line (.view-lines) must be rendered before we focus + type. await expect(editor.locator('.view-lines')).toBeVisible({ timeout: 20_000 }); await monacoSurface.click(); // Replace the entire buffer with our unique marker (select-all → type). This // is build-agnostic — it doesn't depend on Monaco's internal textarea class. await page.keyboard.press('ControlOrMeta+A'); await page.keyboard.type(`案件専用の指示 ${marker}`); // The typed marker shows up in the rendered Monaco view. await expect(editor.locator('.view-lines')).toContainText(marker, { timeout: 10_000 }); // The Save button (data-testid="monaco-save") enables once dirty; click it and // wait for the PUT to the space-scoped agents-md endpoint to succeed (200). const saveBtn = page.getByTestId('monaco-save'); await expect(saveBtn).toBeEnabled(); const savePromise = page.waitForResponse( (r) => /\/api\/users\/me\/agents-md\?/.test(r.url()) && r.request().method() === 'PUT', ); await saveBtn.click(); const saveRes = await savePromise; expect(saveRes.status()).toBe(200); // The save URL must carry this space's id — that is what makes it per-space. expect(new URL(saveRes.url()).searchParams.get('spaceId')).toBeTruthy(); // 4. Reload the page, re-open the same space → 設定 → AGENTS.md, and assert the // editor re-hydrates with the saved text (proves server-side persistence // under the space folder; a fresh GET re-fetches ?spaceId=...). await page.reload(); await page.getByTestId('nav-spaces').click(); await expect(rail).toBeVisible(); const caseRowAfter = rail .locator('[data-testid="space-row"][data-space-kind="case"]') .filter({ hasText: caseTitle }); await expect(caseRowAfter).toBeVisible(); await caseRowAfter.click(); await expect(detail).toBeVisible(); await page.getByTestId('space-tab-settings').click(); await page.getByTestId('space-settings-nav-agents').click(); const editorAfter = page.getByTestId('monaco-file-editor'); await expect(editorAfter).toBeVisible(); // The persisted marker shows up in the rendered Monaco view. await expect(editorAfter.locator('.view-lines')).toContainText(marker, { timeout: 15_000 }); // 5. MCP / SSH now render their own per-space panels when selected (the old // "not space-scoped" disclaimer is gone). Light assertion — the space-scoped // panel container mounts for each. (Isolation is proven by the dedicated test // below.) await page.getByTestId('space-settings-nav-mcp').click(); await expect(page.getByTestId('space-mcp-panel')).toBeVisible(); await page.getByTestId('space-settings-nav-ssh').click(); await expect(page.getByTestId('space-ssh-panel')).toBeVisible(); // 6. No-nav invariant: settings is in-place, never leaves Spaces for Tasks. await expect(page).toHaveURL(/[?&]page=spaces(&|$)/); expect(page.url()).not.toContain('page=tasks'); await expect(detail).toBeVisible(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // User Folder tab retirement (chore/remove-userfolder-tab). The top-level // "User Folder" nav tab was removed: everything it hosted (AGENTS.md / memory / // pieces / skills / MCP / SSH / browser) is now reachable from the personal // workspace's 設定 tab (asserted by the settings test above), and the one orphan // — Pets — moved into global Settings as its own section. This test proves the // removal didn't strand anything: (1) no User Folder tab in the TopBar nav, and // (2) the Pets settings section is present and selectable in global Settings. // No LLM needed; this is pure navigation/structure. test('userfolder tab removed: gone from nav, Pets reachable in Settings', async ({ page }) => { await page.goto('/ui'); // 1. The top-level User Folder nav tab no longer exists (testid nav-userfolder // was dropped along with the page id). await expect(page.getByTestId('nav-userfolder')).toHaveCount(0); await expect(page.getByText('User Folder', { exact: true })).toHaveCount(0); // 2. Settings still opens, and the Pets section (relocated here) is present in // the sidebar and selectable. Pets is a user-scoped section, so it shows // even with auth disabled (synthetic local user). await page.getByTestId('nav-settings').click(); await expect(page).toHaveURL(/[?&]page=settings(&|$)/); const petsNav = page.getByTestId('settings-nav-pets'); await expect(petsNav).toBeVisible(); await petsNav.click(); // The relocated Pets panel renders (PetsForm carries data-testid="pets-form"). await expect(page.getByTestId('pets-form')).toBeVisible(); }); // Per-space MCP isolation (feat/space-mcp-ssh-isolation). MCP servers are // DB-backed but scoped to a space: POST /api/mcp/user-servers accepts space_id, // and GET /api/mcp/user-servers?spaceId= returns ONLY that space's servers. // The 設定 → MCP panel lists those (data-testid="space-mcp-list", each row a // "space-mcp-item" carrying data-server-id / data-server-name). // // MCP is the deterministic choice over SSH here: registering an api_key server // needs no OAuth dance and no private key, and the MCP subsystem is mounted in // E2E via a fixed MCP_ENCRYPTION_KEY (see playwright.config.ts). The unreachable // server URL only defers tool discovery (caught server-side), so register still // returns 200. We then prove isolation TWO ways: (a) via the API — the server // shows under ?spaceId=A but NOT ?spaceId=B; and (b) via the UI — it appears in // space A's MCP list but is absent from space B's. No LLM is configured in E2E, // so we assert structure only, never agent output. test('space MCP isolation: server registered in A is visible in A, absent in B', async ({ page }) => { const fatalErrors: string[] = []; const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i; // Same no-auth probe allowlist as the settings test. With auth disabled the // /api/auth/me probe 404s on purpose; the MCP panel also probes the admin // /api/mcp/servers + /api/mcp/connections endpoints which degrade to empty // lists under no-auth, and SSH (mounted alongside in the sub-nav) 404s since // its subsystem stays off — all benign, not failures. const benignPaths = new Set([ '/api/auth/me', '/api/users/me/orgs', '/api/mcp/connections', '/api/mcp/servers', '/api/ssh/connections', ]); page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`)); page.on('response', (res) => { if (res.status() >= 400) { const { pathname } = new URL(res.url()); if (!benignAsset.test(pathname) && !benignPaths.has(pathname)) { fatalErrors.push(`HTTP ${res.status()} ${res.url()}`); } } }); // 1. Create TWO case spaces (A and B) via the same API the create dialog uses. // POST returns 201 with the full space row, so we capture each id. const stamp = Date.now(); const resA = await page.request.post('/api/local/spaces', { data: { title: `E2E-MCP-A-${stamp}` } }); expect(resA.status()).toBe(201); const spaceA = (await resA.json()) as { id: string }; expect(spaceA.id).toBeTruthy(); const resB = await page.request.post('/api/local/spaces', { data: { title: `E2E-MCP-B-${stamp}` } }); expect(resB.status()).toBe(201); const spaceB = (await resB.json()) as { id: string }; expect(spaceB.id).toBeTruthy(); expect(spaceB.id).not.toBe(spaceA.id); // 2. Register an api_key MCP server INTO space A via the UI's register endpoint // (POST /api/mcp/user-servers with space_id). api_key avoids the OAuth flow; // the unreachable URL only defers tool discovery (caught server-side), so // the register still returns ok. const mcpId = `e2e-mcp-${stamp}`; const mcpName = `spaceA-mcp-${stamp}`; const reg = await page.request.post('/api/mcp/user-servers', { data: { id: mcpId, name: mcpName, url: 'https://example.invalid/mcp', authKind: 'api_key', staticToken: 'e2e-static-token', space_id: spaceA.id, }, }); expect(reg.ok(), `register failed: ${reg.status()} ${await reg.text()}`).toBeTruthy(); // 3. API-level isolation: the server appears under ?spaceId=A but NOT ?spaceId=B. const listA = (await (await page.request.get( `/api/mcp/user-servers?spaceId=${encodeURIComponent(spaceA.id)}`, )).json()) as { servers: Array<{ id: string; name: string }> }; expect(listA.servers.some((s) => s.id === mcpId)).toBeTruthy(); const listB = (await (await page.request.get( `/api/mcp/user-servers?spaceId=${encodeURIComponent(spaceB.id)}`, )).json()) as { servers: Array<{ id: string; name: string }> }; expect(listB.servers.some((s) => s.id === mcpId)).toBeFalsy(); // 4. UI: open Spaces, select space A by its id, go to 設定 → MCP. The space MCP // list renders and contains our server (proves the panel reads ?spaceId=A). await page.goto('/ui'); await page.getByTestId('nav-spaces').click(); const rail = page.getByTestId('space-rail'); await expect(rail).toBeVisible(); const rowA = rail.locator(`[data-testid="space-row"][data-space-id="${spaceA.id}"]`); await expect(rowA).toBeVisible(); await rowA.click(); const detail = page.getByTestId('space-detail'); await expect(detail).toBeVisible(); await page.getByTestId('space-tab-settings').click(); await page.getByTestId('space-settings-nav-mcp').click(); // The space-scoped MCP panel mounts and its list carries our per-space item. await expect(page.getByTestId('space-mcp-panel')).toBeVisible(); const listAUi = page.getByTestId('space-mcp-list'); await expect(listAUi).toBeVisible({ timeout: 15_000 }); await expect( listAUi.locator(`[data-testid="space-mcp-item"][data-server-id="${mcpId}"]`), ).toBeVisible({ timeout: 15_000 }); await expect(listAUi).toContainText(mcpName); // 5. THE KEY ASSERTION — open space B → 設定 → MCP. The server registered in A // must NOT appear here. Space B has no per-space MCP servers, so its list // wrapper isn't rendered (empty-state); either way our item must be absent. const rowB = rail.locator(`[data-testid="space-row"][data-space-id="${spaceB.id}"]`); await expect(rowB).toBeVisible(); await rowB.click(); await expect(detail).toBeVisible(); await page.getByTestId('space-tab-settings').click(); await page.getByTestId('space-settings-nav-mcp').click(); // The space-B MCP panel renders, but our server is nowhere in it. await expect(page.getByTestId('space-mcp-panel')).toBeVisible(); await expect( page.locator(`[data-testid="space-mcp-item"][data-server-id="${mcpId}"]`), ).toHaveCount(0); await expect(page.getByTestId('space-mcp-panel')).not.toContainText(mcpName); // No-nav invariant + no fatal client errors. await expect(page).toHaveURL(/[?&]page=spaces(&|$)/); expect(page.url()).not.toContain('page=tasks'); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // ════════════════════════════════════════════════════════════════════════════ // Space task/settings feature parity (feat/space-feature-parity-tests). // // Phase 1 added a per-chat action toolbar (data-testid="space-chat-actions") to // the inline SpaceConversation, with delete / share / continue controls reusing // the SAME implementation as the Tasks-page DetailHeader. These // tests prove each action works INSIDE a space without ever bouncing to the // Tasks page, plus the 概要 (overview) feedback flow and every 設定 sub-tab. // // No LLM is configured in E2E, so a queued job fails fast and settles terminal // (waitForTerminalJob), which is exactly what unlocks Continue + Overview // feedback. Throughout we assert page=spaces is held (the no-nav invariant the // user cares about) and no fatal client errors leak. // ════════════════════════════════════════════════════════════════════════════ // 1. DELETE — the user-reported gap. The space-chat toolbar exposes a delete // button (data-testid="space-chat-delete"); clicking it pops a confirm() // dialog, and on accept the chat is removed and the conversation returns to // the empty list state (the chat row disappears). This guards the exact bug // the user hit: a missing delete button in space chats. test('space chat DELETE: toolbar delete removes the chat and returns to the list', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2E削除'); const { conversation, taskId } = await startInlineChat( page, 'E2E: 削除テスト用チャット。最初のメッセージ。', ); // The per-chat action toolbar and its delete button are present. const actions = conversation.getByTestId('space-chat-actions'); await expect(actions).toBeVisible(); const deleteBtn = conversation.getByTestId('space-chat-delete'); await expect(deleteBtn).toBeVisible(); // The chat row for this task exists in the space's chat list before delete. const chatRow = page.locator(`[data-testid="space-chat-row"][data-task-id="${taskId}"]`); await expect(chatRow).toBeVisible(); // Accept the confirm() dialog that guards the destructive delete. page.once('dialog', (dialog) => { expect(dialog.type()).toBe('confirm'); void dialog.accept(); }); await deleteBtn.click(); // The chat disappears from the list AND the inline conversation closes // (SpaceConversation calls onBack(0) on delete → spaceTaskId null → no // conversation). The chat= URL param is dropped. await expect(chatRow).toHaveCount(0, { timeout: 15_000 }); await expect(conversation).toHaveCount(0); await expect.poll(() => new URL(page.url()).searchParams.get('chat')).toBeFalsy(); // No-nav invariant: deletion stayed on Spaces, never bounced to Tasks. await expect(page).toHaveURL(/[?&]page=spaces(&|$)/); expect(page.url()).not.toContain('page=tasks'); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // 2. CANCEL — start a chat (which queues a job) and cancel it from the ChatPane // stop button, asserting the job lands in the cancelled state. // // The stop button only appears while the job is BUSY (running / dispatching / // waiting_subtasks), and POST /cancel only acts on a running job (it 404s on a // still-queued job). Reaching `running` requires the worker to actually pick // up the job, which needs a reachable LLM. The E2E env configures NO provider, // so the job sits `queued` and the stop button never appears — we skip the // active assertion in that case (clearly reported) and run it for real only // when an LLM IS reachable. Either way we first prove the conversation UI and // no-nav invariant hold. test('space chat CANCEL: ChatPane stop button cancels a running task', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2Eキャンセル'); const { conversation, taskId } = await startInlineChat( page, 'E2E: キャンセルテスト。長めの作業を想定した最初のメッセージ。', ); // The ChatPane stop button shows while the job is busy. Label is i18n // (English in the E2E browser locale, Japanese otherwise), so match either. const stopBtn = conversation.getByRole('button', { name: /^(Stop|停止)$/ }); const becameBusy = await stopBtn .waitFor({ state: 'visible', timeout: 20_000 }) .then(() => true) .catch(() => false); test.skip( !becameBusy, 'no LLM reachable: the worker never runs the job, so it stays queued and the ' + 'ChatPane stop control never appears — cancel needs a running job to act on', ); // Cancel from the ChatPane, then assert the job is cancelled (terminal). await stopBtn.click(); const status = await waitForTerminalJob(page, taskId, 20_000); expect(status).toBe('cancelled'); await expect(page).toHaveURL(/[?&]page=spaces(&|$)/); expect(page.url()).not.toContain('page=tasks'); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // 3. SHARE — the toolbar share button (data-testid="space-chat-share", reusing // ShareButton) publishes a share token. Clicking it issues POST // /api/local/tasks/:id/share and writes the link to the clipboard; the button // swaps to copy/stop affordances. Clipboard isn't observable headless, so we // assert persistence the robust way: the task's shareToken becomes set // server-side (poll the detail API) and the toolbar re-renders with the copy // control (aria-label = "共有リンクをコピー"). test('space chat SHARE: toolbar share publishes a share token', async ({ page, context }) => { // ShareButton's onSuccess copies the link to the clipboard; headless Chromium // denies clipboard writes by default, which would surface as a fatal pageerror. // Grant the permission so the (benign) copy succeeds and the share flow itself // is what we measure. Best-effort: some channels don't expose the permission. await context.grantPermissions(['clipboard-write']).catch(() => {}); const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2E共有'); const { conversation, taskId } = await startInlineChat( page, 'E2E: 共有テスト用チャット。最初のメッセージ。', ); // When unshared, ShareButton puts the testid on the publish