import { test, expect, type Page } from '@playwright/test'; import Database from 'better-sqlite3'; import { resolve } from 'node:path'; import { openSpaceTab } from './helpers/spaceTabs'; // ── 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?: { id?: string; status?: 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 nav collapses into a drawer (both shell layouts do // this), so the nav-spaces control 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'); // Startup auto-opens the personal workspace: detail is the visible // full-width view, rail is hidden, and the back control is already present. const rail = page.getByTestId('space-rail'); const detail = page.getByTestId('space-detail'); const back = page.getByTestId('space-back-to-list'); await expect(detail).toBeVisible(); await expect(rail).toBeHidden(); await expect(back).toBeVisible(); // Tapping back clears the selection and returns to the full-width rail. The // auto-open guard is one-shot, so it does not immediately re-select. await back.click(); 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(); 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=). expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); await expect(page).toHaveURL(/[?&]chat=\d+/); expect(page.url()).toContain('chat='); expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); // 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 icon rail // (data-testid="space-chat-rail" — since #1433 step 5 the rail is rendered // at EVERY width, including narrow phone viewports; the horizontal tab // bar and the swipe deck it belonged to were removed) so Progress is visible // WITHOUT leaving for Tasks. Clicking 進捗 (Progress = the `activity` tab) // swaps the ProgressTab content IN PLACE (no overlay/✕); pressing 進捗 a // second time folds that column away and the conversation takes the row // back — all while staying on page=spaces with the same chat= param. // (A dedicated test below covers the rail structure.) // Entry state: a brand-new chat has nothing remembered, so the rail starts // on 概要, opened (RAIL_DEFAULT since #1455 — it used to be 会話). const overviewTab = conversation.getByTestId('space-chat-tab-overview'); await expect(overviewTab).toHaveAttribute('aria-selected', 'true'); 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'); // In place, not on top of the conversation: the chat column keeps its own // space while 進捗 shows, so progress can be read while typing (#1330). await expect(page.getByTestId('space-chat-column')).toBeVisible(); // Invariant: still inline on Spaces, same chat, never bounced to Tasks. await expect(page).toHaveURL(/[?&]chat=\d+/); expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); // Return to the conversation so the rest of the flow (creating a second chat) // starts from chat again. On a wide screen there is no 会話 item to press // (#1455 — the conversation keeps its own column, so an icon for it would be // the same thing twice); the way back is to press the item that is already // picked, which folds its column away (chatRailState.ts nextRailState) and // hands the width back to the conversation. await progressTab.click(); await expect(progressTab).toHaveAttribute('aria-selected', 'false'); await expect(conversation.getByTestId('progress-tab')).toHaveCount(0); await expect(page.getByTestId('space-chat-column')).toBeVisible(); // 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. expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); 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 file in a FLOATING // WINDOW (role="dialog", data-frame="float") showing the content — #1551 stage 2 // routed this tab through the same window funnel as chat, so it is no longer a // covering modal. 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 window', 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 file in a FLOATING WINDOW (role="dialog", // data-frame="float") showing the markdown content. // // This used to be a covering modal closed by clicking its backdrop. #1551 // stage 2 routed this tab through the same window funnel as chat, and the // default open mode is a window (readStoredFilePreviewOpenMode() -> 'float'). // A floating window has NO backdrop on purpose: the whole point is that the // grid behind it stays live, so clicking outside must NOT close it. We assert // that promise here (the grid is still visible) and close with the ✕, which // now carries a testid so this stays locale-independent. // // The covering shape is still reachable (Settings -> "open files in a // window" off) and closing it by backdrop click is pinned in jsdom by // FilePreview.backdrop.dom.test.tsx and // SpaceDetail.spaceFileClose.dom.test.tsx. await tile.click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); await expect(dialog).toContainText('hello'); await expect(dialog).toHaveAttribute('data-frame', 'float'); // The list is not covered — this is what stage 2 was for. await expect(page.getByTestId('space-files-grid')).toBeVisible(); await expect(tile).toBeVisible(); await dialog.getByTestId('file-preview-close').click(); await expect(dialog).toHaveCount(0); // 6. No-nav invariant: file management never leaves Spaces for the Tasks board. expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); await expect(detail).toBeVisible(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // #1448: the worker/GPU view moved out of the Spaces rail into a floating window // opened from the icon at the bottom of the left nav rail. Two things are pinned // here: the old rail footer is gone, and the new launcher opens a window that // survives a click outside it (closing is the × only). No LLM is needed — the // widget reads /api/local/dashboard/workers, populated in worker mode. test('worker status: nav rail launcher opens a floating window', 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 old rail-bottom footer is gone; the launcher lives in the nav rail. await expect(page.getByTestId('space-worker-status')).toHaveCount(0); const launcher = page.getByTestId('worker-status-launcher'); await expect(launcher).toBeVisible(); await expect(launcher).toHaveAttribute('aria-expanded', 'false'); await expect(page.getByTestId('worker-status-window')).toHaveCount(0); // 3. Clicking it opens the window; clicking outside must NOT close it. await launcher.click(); await expect(launcher).toHaveAttribute('aria-expanded', 'true'); const win = page.getByTestId('worker-status-window'); await expect(win).toBeVisible(); await rail.click({ position: { x: 10, y: 10 } }); await expect(win).toBeVisible(); // 4. The × closes it. await page.getByTestId('worker-status-window-close').click(); await expect(page.getByTestId('worker-status-window')).toHaveCount(0); }); // Narrow viewport: the nav rail is folded into the NavDrawer, so the rail // launcher is not rendered at all. The same entry must exist in the drawer, // and — the part jsdom cannot check — the window must land on-screen after the // drawer slides away. The drawer panel is moved with `transform`, which makes it // the containing block for any `position: fixed` descendant; if the window were // rendered inside the drawer it would ride off-screen with it. test('worker status: drawer launcher opens the same window on a narrow screen', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/ui'); // The rail (and its launcher) is gone at this width. await expect(page.getByTestId('worker-status-launcher')).toHaveCount(0); await page.locator('button[aria-controls="nav-drawer"]').first().click(); const drawer = page.locator('#nav-drawer'); await expect(drawer).toHaveAttribute('aria-hidden', 'false'); await page.getByTestId('worker-status-launcher-drawer').click(); // The drawer closes, the window stays. await expect(drawer).toHaveAttribute('aria-hidden', 'true'); const win = page.getByTestId('worker-status-window'); await expect(win).toBeVisible(); const box = await win.boundingBox(); expect(box).not.toBeNull(); expect(box!.x).toBeGreaterThanOrEqual(0); expect(box!.x + box!.width).toBeLessThanOrEqual(390); await page.getByTestId('worker-status-window-close').click(); await expect(page.getByTestId('worker-status-window')).toHaveCount(0); }); // 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('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 floating window; the WorkerStatusWidget mounts and fetches the stub. await page.getByTestId('worker-status-launcher').click(); await expect(page.getByTestId('worker-status-window')).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(); // 細部は畳んだ状態で出る(#1486)。開くまでバックエンドの行は出てこない。 await expect(page.getByTestId('worker-backend-backend-a')).toHaveCount(0); await expect(page.getByTestId('worker-toggle-gw')).toHaveAttribute('aria-expanded', 'false'); // 三角を押すと Gateway のバックエンドが 1 行ずつ並ぶ。 await page.getByTestId('worker-toggle-gw').click(); 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 a floating window without moving the screen', 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 file opens IN A FLOATING WINDOW, and the screen underneath does not // move (#1551 step 1). This is the whole point of the change: the reader // was on some other screen (Delegate, progress, the file list) and must // still be on it after the click. So the rail keeps whatever it had — it // must NOT gain a file item and must NOT switch to it, which is what the // pane route used to do. const floatFrame = page.locator('[data-testid="file-preview-frame"][data-frame="float"]'); await expect(floatFrame).toBeVisible(); await expect(floatFrame).toContainText('hello from output'); // The window names itself a dialog for screen readers but does NOT trap them: // the conversation and the file list behind it stay reachable. await expect(floatFrame).toHaveAttribute('aria-modal', 'false'); await expect(conversation.getByTestId('space-chat-tab-file')).toHaveCount(0); // Nothing landed in the column either — the pane route is not a silent // fallback here. await expect(page.locator('[data-testid="file-preview-frame"][data-frame="pane"]')).toHaveCount(0); // Close via the frame's ✕, matched by testid. Its aria-label differs between the // floating frame (files:close) and the pane (files:closeAria), and both are // translated — the old name regex only matched the ja floating label. await floatFrame.getByTestId('file-preview-close').click(); await expect(page.getByTestId('file-preview-frame')).toHaveCount(0); // The rail never gained a file item, so there is none to drop. await expect(conversation.getByTestId('space-chat-tab-file')).toHaveCount(0); // 7. No-nav invariant: clicking the link previewed in place, never bounced to Tasks. await expect(page).toHaveURL(/[?&]chat=\d+/); expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); await expect(detail).toBeVisible(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // Redesigned space-chat UX (issue #1330; the open file joined the rail in #1433 // step 4; 会話 left the rail on wide screens in #1455). The line-up lives in a // VERTICAL ICON RAIL (data-testid="space-chat-rail", role=tablist, // aria-orientation=vertical) on the far left: 概要 (overview) + 進捗 (activity) + // ファイル (files) + トレース (trace) [+ ブラウザ/SSH while a live session lasts] // [+ the OPEN FILE while one is open] [+ 会話 (chat) ONLY below 768px]. // Each button is icon-only and therefore carries its name in aria-label + title — // for the open file that name is the file name itself. This chat-level ファイル // item is distinct from the space-level files (SpaceDetail.tsx comment: it shows // the chat's own input/output/logs under runs/{taskId}, not the shared space // deliverables). // THIS TEST RUNS AT THE DEFAULT DESKTOP VIEWPORT (1280px), so 会話 must NOT be in // the rail: the conversation already owns its own column at this width, and an // icon for it would put the same thing on screen twice (#1455). The narrow-screen // half — 会話 IS in the rail below 768px — is pinned by the test right after this // one; both halves are needed, otherwise dropping the width check would go // unnoticed in one direction. // The horizontal bar (data-testid="space-chat-tabs") is GONE (#1433 step 5): the // rail owns every switch at every width, so the conversation exposes exactly one // tablist and the bar is never rendered. // SpaceDetail lays the conversation out in columns (#1330): // rail / chat / whatever the rail points at (a detail screen OR the open file — // the two are mutually exclusive). The chat column is always rendered and never // unmounts, so the composer keeps its draft and stays usable while 概要 or 進捗 is // on screen. Throughout, we never leave for the Tasks page (space= + chat= stay // put; no page= param). No LLM is configured in E2E, so we assert UI/structure // only, never agent output. test('space chat rail: no 会話 item on a wide screen, overview open by default, fold gives the row back to the conversation', 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 icon rail (role=tablist, vertical) is visible and carries 概要 — but // NOT 会話, which this width does not line up (#1455). const railTabs = conversation.getByTestId('space-chat-rail'); await expect(railTabs).toBeVisible(); await expect(railTabs).toHaveAttribute('role', 'tablist'); await expect(railTabs).toHaveAttribute('aria-orientation', 'vertical'); const overviewTab = conversation.getByTestId('space-chat-tab-overview'); await expect(overviewTab).toBeVisible(); // 会話 is not an item here: the conversation has its own column at this width, // so an icon for it would be the same thing on screen twice (#1455). Assert on // the whole page, not just the rail — if a second rail ever appears, a 会話 // button anywhere would still be the duplicate this pins down. await expect(page.getByTestId('space-chat-tab-chat')).toHaveCount(0); // The chat-level ファイル item is present too (per-chat I/O/logs, distinct // from the space-level files tab). await expect(railTabs.getByTestId('space-chat-tab-files')).toBeVisible(); // Icon-only controls must still be reachable by name (screen readers). 概要 is // the one to check now — it is in the line-up at every width. await expect(railTabs.getByRole('tab', { name: /^(概要|Overview)$/ })).toBeVisible(); // The horizontal bar was removed in #1433 step 5; it is never rendered. await expect(conversation.getByTestId('space-chat-tabs')).toHaveCount(0); // No file is open, so the rail carries no file item either. await expect(conversation.getByTestId('space-chat-tab-file')).toHaveCount(0); // Exactly one tablist owns the shared tabpanel — the rail. await expect(conversation.getByRole('tablist')).toHaveCount(1); // 概要 is the default-selected item on a fresh chat (RAIL_DEFAULT since #1455 — // it used to be 会話, which is no longer in the line-up here). Nothing is // clicked to get here: this is how the conversation opens. await expect(overviewTab).toHaveAttribute('aria-selected', 'true'); await expect(conversation.locator('textarea')).toBeVisible(); // 3. 概要 opens in its own column beside the conversation, never as an overlay // on top of it. // In place: the shared tabpanel (role=tabpanel) is mounted inside the same // conversation container — not a separate overlay. await expect(conversation.getByRole('tabpanel')).toBeVisible(); // The right column really is on screen (the split that holds it only mounts // while something is in it — SpaceDetail's rightColumnsMounted). await expect(conversation.getByTestId('chat-detail-split')).toBeVisible(); // The conversation keeps its own column: the composer is not merely mounted, // it is on screen and writable while 概要 shows. That is the whole point of // the column layout (#1330) — watch the progress, type the next instruction, // without switching back and forth. THIS is what this test is here to pin. await expect(page.getByTestId('space-chat-column')).toBeVisible(); const composer = conversation.locator('textarea'); await expect(composer).toHaveCount(1); await expect(composer).toBeVisible(); await composer.fill('4 列: 概要を見ながら書けること'); await expect(composer).toHaveValue('4 列: 概要を見ながら書けること'); const chatColumn = page.getByTestId('space-chat-column'); const sharedWidth = (await chatColumn.boundingBox())!.width; // 4. Back to the conversation. There is no 会話 item to press at this width; // the way back is to press the item that is ALREADY picked, which folds its // column away (chatRailState.ts nextRailState) and hands the freed width to // the conversation. The chat column itself never unmounts, so the draft // typed above survives the fold. await overviewTab.click(); await expect(overviewTab).toHaveAttribute('aria-selected', 'false'); // The right column is gone, not merely hidden. await expect(conversation.getByTestId('chat-detail-split')).toHaveCount(0); await expect(chatColumn).toBeVisible(); await expect(composer).toHaveValue('4 列: 概要を見ながら書けること'); // The conversation actually got the row back — it is wider than while it was // sharing with 概要. await expect.poll(async () => (await chatColumn.boundingBox())!.width).toBeGreaterThan(sharedWidth); // 5. Pressing 概要 once more opens it again (a fold is remembered, not sticky), // and the draft still stands. await overviewTab.click(); await expect(overviewTab).toHaveAttribute('aria-selected', 'true'); await expect(conversation.getByTestId('chat-detail-split')).toBeVisible(); await expect(composer).toBeVisible(); await expect(composer).toHaveValue('4 列: 概要を見ながら書けること'); // 6. No navigation to Tasks throughout: page=spaces + chat= held the entire time. expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); await expect(page).toHaveURL(/[?&]chat=\d+/); expect(page.url()).toContain('chat='); expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); await expect(detail).toBeVisible(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // The other half of #1455: 会話 leaves the rail on a WIDE screen only. Below // 768px (useIsMobile) the conversation and whatever the rail points at share one // column, so an item to get back to the conversation is the only way back — it // must be there. The test above pins "absent when wide"; without this one, a // change that drops the 会話 item at every width would sail through green. // // The whole setup runs at the desktop viewport first (the nav rail folds into // the NavDrawer below 768px, which is a different flow and not what is under // test here), then the window is narrowed. useIsMobile listens for matchMedia // changes, so the item appears without a reload. test('space chat rail: 会話 item appears once the window is narrower than 768px', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2E狭幅帯'); const { conversation } = await startInlineChat( page, 'E2E: 狭い画面で会話のアイコンが出ることを確認する最初のメッセージ。', ); const railTabs = conversation.getByTestId('space-chat-rail'); await expect(railTabs).toBeVisible(); // Wide (the project's 1280px default): no 会話 item. await expect(page.getByTestId('space-chat-tab-chat')).toHaveCount(0); // Narrow: it appears. 700px is below the 768px breakpoint and still wide // enough that the conversation stays open (no bounce back to the space list). await page.setViewportSize({ width: 700, height: 900 }); const chatTab = conversation.getByTestId('space-chat-tab-chat'); await expect(chatTab).toBeVisible(); // Icon-only, so it has to carry its name for screen readers. await expect(railTabs.getByRole('tab', { name: /^(会話|Chat)$/ })).toBeVisible(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // Space 設定 tab (feat/space-folder-settings). The SpaceDetail now carries a // 設定 entry (space-tab-settings, folded into the "その他" menu since issue // #1360 stage 1) 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. 設定 は「その他」の中(issue #1360 段 1)。開いて選ぶと sub-nav が出る。 await openSpaceTab(page, 'settings'); 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 openSpaceTab(page, 'settings'); 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. expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); 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 openSpaceTab(page, 'settings'); 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 openSpaceTab(page, 'settings'); 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. expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // ════════════════════════════════════════════════════════════════════════════ // Space task/settings feature parity (feat/space-feature-parity-tests). // // Phase 1 added per-chat delete / share / continue controls to the inline // SpaceConversation. Issue #1433 moved the two that survive (continue + delete) // out of the chat tab bar's right-hand zone (data-testid="space-chat-actions", // which the conversation no longer renders) and down into the composer's button // row, where delete sits at the FAR LEFT — away from send. Locate them by // their own testids under the conversation, never through the retired zone. 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 composer row 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 delete button is present in the conversation (composer row since #1433). const deleteBtn = conversation.getByTestId('space-chat-delete'); await expect(deleteBtn).toBeVisible(); // …in the composer's own action zone, not the retired tab-bar zone. (The // left-most / away-from-send ordering inside that row is pinned by // ui/src/components/spaces/SpaceDetail.composerActions.test.tsx.) await expect(conversation.getByTestId('chat-composer-actions')).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. expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // 2. CANCEL — seed a stable running job in the isolated E2E DB, then exercise // the real ChatPane -> POST /cancel -> persisted terminal state. Waiting for // the stub LLM to claim a job used to skip this check, or race a transient // busy state and time out before the stop button could be clicked. test('space chat CANCEL: ChatPane stop button cancels a running task', async ({ page }) => { const fatalErrors = trackFatalErrors(page); const { spaceId } = await createAndOpenCaseSpace(page, 'E2Eキャンセル'); const created = await page.request.post('/api/local/tasks', { data: { body: 'E2E: キャンセルテスト用の実行中タスク', piece: 'chat', spaceId, workspaceMode: 'persistent', }, }); expect(created.ok()).toBeTruthy(); const taskId = String((await created.json()).task.id); let jobId: string | undefined; await expect.poll(async () => { jobId = (await fetchTask(page, taskId))?.latestJob?.id; return jobId; }).toBeTruthy(); const e2eTmp = process.env.E2E_TMP; if (!e2eTmp) throw new Error('E2E_TMP must be set by playwright.config.ts'); const db = new Database(resolve(e2eTmp, 'e2e.db')); try { db.pragma('busy_timeout = 8000'); const changed = db.prepare("UPDATE jobs SET status = 'running' WHERE id = ?").run(jobId!); expect(changed.changes).toBe(1); } finally { db.close(); } const url = new URL(page.url()); url.searchParams.set('chat', taskId); await page.goto(url.href); const conversation = page.getByTestId('space-conversation'); await expect(conversation).toBeVisible(); const stopBtn = conversation.getByRole('button', { name: /^(Stop|停止)$/ }); await expect(stopBtn).toBeVisible(); const response = page.waitForResponse(res => new URL(res.url()).pathname === '/api/local/tasks/' + taskId + '/cancel' && res.request().method() === 'POST'); await stopBtn.click(); expect((await response).status()).toBe(200); expect(await waitForTerminalJob(page, taskId, 20_000)).toBe('cancelled'); await expect(stopBtn).toHaveCount(0); expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); expect(fatalErrors, 'fatal errors:\n' + fatalErrors.join('\n')).toEqual([]); }); // 4. CONTINUE-WITH-PIECE — the Continue button (data-testid= // "space-chat-continue", reusing ContinueButton, in the composer row since // issue #1433) is gated: disabled until the // latest job is TERMINAL, enabled after. We always assert the wiring that IS // observable without an LLM — the button is present and DISABLED while the job // is non-terminal (queued). Then, IF the job reaches a terminal state (needs a // reachable LLM; the E2E env has none, so this branch is skipped + reported), // we click it and assert ContinueWithPieceDialog opens with piece options. test('space chat CONTINUE: button gated on terminal job; opens ContinueWithPieceDialog when terminal', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2E継続'); const { conversation, taskId } = await startInlineChat( page, 'E2E: 継続テスト用チャット。最初のメッセージ。', ); // Always-verifiable: the Continue button exists and is DISABLED while the job // is non-terminal (proves the terminal-gating in ContinueButton is wired). const continueBtn = conversation.getByTestId('space-chat-continue'); await expect(continueBtn).toBeVisible(); await expect(continueBtn).toBeDisabled(); // Active path: only reachable when the job goes terminal (needs an LLM). const status = await waitForTerminalJob(page, taskId); test.skip( status === '', 'no LLM reachable: the job stays queued (never terminal), so Continue stays ' + 'disabled — the dialog-open assertion needs a terminal job', ); await expect(continueBtn).toBeEnabled({ timeout: 15_000 }); await continueBtn.click(); // ContinueWithPieceDialog opens: the piece was removed when the // scope became fixed, and the static note chip that replaced it was removed in // PR #780 (the scope is documented in help instead). This test guards that no // visibility control sneaks back into the conversation toolbar, while the // action buttons that DO belong there (continue / delete) stay present — so the // assertion can't pass vacuously on a broken/empty toolbar. The auth-mode twin // of this guard lives in ui/e2e-auth/sharing-scope.auth.spec.ts. (History: // issue #782 — the old "change to public persists" test referenced the removed // testid and had been silently dead since the selector was retired. Same trap // again in issue #1338: the positive anchor was space-chat-share, but the // per-task share link was retired in PR #962 — sharing is a space-level feature // now — so the button and its testid went away while this line kept waiting for // it. The anchors are now the two controls that actually ship — they live in // the composer row since issue #1433, and are found by their own testids.) test('space chat VISIBILITY: no visibility control (fixed member-only scope)', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2E可視性'); const { conversation } = await startInlineChat( page, 'E2E: 可視性テスト用チャット。最初のメッセージ。', ); // Positive anchor first: the per-chat actions are rendered (composer row since // issue #1433), so the negative assertions below are checked against a live, // non-empty conversation UI. await expect(conversation.getByTestId('space-chat-continue')).toBeVisible(); await expect(conversation.getByTestId('space-chat-delete')).toBeVisible(); // No visibility selector, no visibility note — scope is fixed and implicit. await expect(conversation.getByTestId('space-chat-visibility')).toHaveCount(0); await expect(conversation.getByTestId('space-chat-visibility-note')).toHaveCount(0); // Still on the spaces view with the chat selected. The current URL scheme // carries space= + chat= params (the legacy page=spaces param is gone; other // tests in this file still assert it and are stale — see issue #782 follow-up). await expect(page).toHaveURL(/[?&]space=[^&]+/); await expect(page).toHaveURL(/[?&]chat=\d+/); expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // 6. FEEDBACK via 概要 tab — the in-place 概要 (overview) tab renders OverviewTab. // We always assert that switching to 概要 swaps the OverviewTab content in // place (the conversation composer disappears; the shared tabpanel mounts) — // verifiable without an LLM. The FeedbackPanel itself only renders for a // COMPLETE (succeeded / failed) job, which needs a reachable LLM; the E2E env // has none, so the rating round-trip is skipped + reported and runs for real // only against a live LLM. test('space chat FEEDBACK: 概要 tab renders OverviewTab; rating round-trip saves + persists', async ({ page }) => { const fatalErrors = trackFatalErrors(page); const { detail, spaceId } = await createAndOpenCaseSpace(page, 'E2Eフィードバック'); const { conversation, taskId } = await startInlineChat( page, 'E2E: フィードバックテスト用チャット。最初のメッセージ。', ); // Always-verifiable: 概要 opens as a right-pane detail panel and the shared // tabpanel is mounted. Nothing is clicked to get there — a fresh chat opens on // 概要 already (RAIL_DEFAULT since #1455; it used to be 会話). Pressing it here // would do the opposite of what this test needs: the item that is already // picked folds its column away (chatRailState.ts nextRailState), taking the // FeedbackPanel below off screen. const overviewTab = conversation.getByTestId('space-chat-tab-overview'); await expect(overviewTab).toBeVisible(); await expect(overviewTab).toHaveAttribute('aria-selected', 'true'); const panel = conversation.getByRole('tabpanel'); await expect(panel).toBeVisible(); // The conversation keeps its own column beside 概要 on desktop (#1330), so the // composer is both mounted and on screen — exactly one textarea. await expect(conversation.locator('textarea')).toHaveCount(1); await expect(conversation.locator('textarea')).toBeVisible(); // Active path: the FeedbackPanel only renders once the job is succeeded/failed. const status = await waitForTerminalJob(page, taskId); test.skip( status !== 'succeeded' && status !== 'failed', 'no LLM reachable: the job never completes (succeeded/failed), so the ' + 'Overview FeedbackPanel does not render — the rating round-trip needs a completed job', ); // The 👍 rating button appears once the job is complete. The 👍 emoji is // locale-independent (the text label beside it is i18n), so match the emoji. const good = panel.getByRole('button', { name: /👍/ }); await expect(good).toBeVisible({ timeout: 15_000 }); await good.click(); // Pick a good-rating tag (GOOD_TAGS are literal JP strings, not i18n), then // submit. The Submit label IS i18n, so match English/Japanese. Wait for the // feedback PUT to commit before asserting the saved state. await panel.getByRole('button', { name: '出力の精度が高い' }).click(); const fbPromise = page.waitForResponse( (r) => /\/api\/local\/tasks\/\d+\/feedback$/.test(r.url()) && r.request().method() === 'PUT', ); await panel.getByRole('button', { name: /^(Submit|送信)$/ }).click(); const fbRes = await fbPromise; expect(fbRes.status()).toBe(200); // Saved → the panel collapses to its summary view: a change button appears // (only rendered when feedback exists and we're not editing). Label is i18n. await expect(panel.getByRole('button', { name: /^(Change|変更)$/ })).toBeVisible({ timeout: 15_000 }); // Persistence: the rating is on the task server-side. await expect .poll(async () => (await fetchTask(page, taskId))?.feedbackRating ?? null) .toBe('good'); // Reload + reopen via URL state → the 概要 panel still shows the saved summary. await page.goto(`/ui?page=spaces&space=${spaceId}&chat=${taskId}`); await expect(detail).toBeVisible(); const conversationAfter = page.getByTestId('space-conversation'); // The rail remembers the pick per chat (localStorage), so 概要 may already be // open here. Pressing the item that is already open does NOT re-open it: it // folds that column away (chatRailState.ts nextRailState), which would take // the FeedbackPanel off screen. So click only when it is not already // selected. const overviewAfter = conversationAfter.getByTestId('space-chat-tab-overview'); if ((await overviewAfter.getAttribute('aria-selected')) !== 'true') { await overviewAfter.click(); } await expect( conversationAfter.getByRole('tabpanel').getByRole('button', { name: /^(Change|変更)$/ }), ).toBeVisible({ timeout: 15_000 }); expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // 7a. SETTINGS sub-tabs render — every 設定 sub-tab (agents/memory/pieces/skills/ // mcp/ssh) opens its space-scoped panel without a crash or fatal 404. The // AGENTS.md Monaco round-trip + MCP/SSH panels are covered by the dedicated // settings test above; here we additionally prove pieces + skills + memory // panels mount their content (stable in-panel markers), so a regression that // blanks any one sub-tab is caught. test('space settings sub-tabs: agents/memory/pieces/skills/mcp/ssh all render their panel', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2E設定タブ'); await openSpaceTab(page, 'settings'); // agents → Monaco editor mounts. await page.getByTestId('space-settings-nav-agents').click(); await expect(page.getByTestId('monaco-file-editor')).toBeVisible({ timeout: 20_000 }); // memory → the MemoryEntriesPanel header + new-entry button render. The header // text is i18n (English in the E2E browser locale, Japanese otherwise). await page.getByTestId('space-settings-nav-memory').click(); await expect(page.getByRole('heading', { name: /Memory entries|メモリエントリ/ })).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole('button', { name: /New entry|新しいエントリ/ })).toBeVisible(); // pieces → the SpacePiecesPanel list (Default / Custom sections) renders. // These section labels are i18n (spaces.json settings.pieces.default/custom). await page.getByTestId('space-settings-nav-pieces').click(); await expect(page.getByText(/^(Default|標準)$/)).toBeVisible({ timeout: 15_000 }); await expect(page.getByText(/^(Custom|カスタム)$/)).toBeVisible(); // skills → the SkillsForm heading renders (the install-from-URL input is // intentionally hidden in space mode — the Git install path isn't space-scoped // yet — so the unique heading is the panel-mounted marker). The heading is now // i18n (settings.json skills.title), and this runner's locale is ja-JP, so the // name has to accept both languages like the Default/Custom checks above. await page.getByTestId('space-settings-nav-skills').click(); await expect(page.getByRole('heading', { name: /^(Skills|スキル)$/ })).toBeVisible({ timeout: 15_000 }); // mcp / ssh → their space-scoped panel containers render. await page.getByTestId('space-settings-nav-mcp').click(); await expect(page.getByTestId('space-mcp-panel')).toBeVisible({ timeout: 15_000 }); await page.getByTestId('space-settings-nav-ssh').click(); await expect(page.getByTestId('space-ssh-panel')).toBeVisible({ timeout: 15_000 }); expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // 7b. MEMORY per-space persistence + isolation — memory is space-scoped: the // panel reads/writes /api/local/memory/entries?spaceId=. We seed an entry // into space A via the same space-scoped API the panel uses, open A's 設定 → // メモリ and assert the entry renders (proves the panel reads ?spaceId=A and // the write persisted to the space folder), then open space B's メモリ and // assert the entry is absent (isolation). Mirrors the MCP-isolation pattern. test('space settings MEMORY: entry saved in space A persists + is absent in space B', async ({ page }) => { const fatalErrors = trackFatalErrors(page); const stamp = Date.now(); const resA = await page.request.post('/api/local/spaces', { data: { title: `E2E-MEM-A-${stamp}` } }); expect(resA.status()).toBe(201); const spaceA = (await resA.json()) as { id: string }; const resB = await page.request.post('/api/local/spaces', { data: { title: `E2E-MEM-B-${stamp}` } }); expect(resB.status()).toBe(201); const spaceB = (await resB.json()) as { id: string }; expect(spaceB.id).not.toBe(spaceA.id); // Seed a memory entry into space A via the space-scoped API (same endpoint the // panel's modal posts to). A unique name lets the UI assertion be exact. const memName = `e2e-mem-${stamp}`; const putRes = await page.request.put( `/api/local/memory/entries/${memName}?spaceId=${encodeURIComponent(spaceA.id)}`, { data: { description: 'E2E space-scoped memory', type: 'reference', body: 'space A only' } }, ); expect(putRes.status(), `memory PUT failed: ${putRes.status()} ${await putRes.text()}`).toBe(200); // Open Spaces, select A → 設定 → メモリ; the seeded entry renders (persisted + // read back via ?spaceId=A). await page.goto('/ui'); await page.getByTestId('nav-spaces').click(); const rail = page.getByTestId('space-rail'); await expect(rail).toBeVisible(); const detail = page.getByTestId('space-detail'); await rail.locator(`[data-testid="space-row"][data-space-id="${spaceA.id}"]`).click(); await expect(detail).toBeVisible(); await openSpaceTab(page, 'settings'); await page.getByTestId('space-settings-nav-memory').click(); await expect(page.getByText(memName, { exact: true })).toBeVisible({ timeout: 15_000 }); // Open space B → 設定 → メモリ; the entry from A must NOT appear (isolation). await rail.locator(`[data-testid="space-row"][data-space-id="${spaceB.id}"]`).click(); await expect(detail).toBeVisible(); await openSpaceTab(page, 'settings'); await page.getByTestId('space-settings-nav-memory').click(); await expect(page.getByRole('heading', { name: /Memory entries|メモリエントリ/ })).toBeVisible({ timeout: 15_000 }); await expect(page.getByText(memName, { exact: true })).toHaveCount(0); expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // ── Space calendar tab (feat/space-calendar, plan stage 4) ─────────────────── // // The per-space カレンダー tab renders a self-built month grid (no external // calendar lib) plus a day panel with three sections (タスク / 変更ファイル / // 予定) and event CRUD. These E2E reuse the shared create-space / inline-chat // helpers above and the same fatal-error tracker — they assert the calendar // behaves on the real orchestrator (auth off => synthetic 'local' user, who is // the space owner and therefore can edit events). // // 'today' is computed in the viewer's local frame exactly like the UI's // localToday(), so the `space-cal-day-{date}` testid matches the cell the UI // renders for today (the grid + day endpoint share the tz_offset). function e2eLocalToday(): string { const d = new Date(); const mm = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${d.getFullYear()}-${mm}-${dd}`; } const ADJACENT_MONTH_CLOCK = new Date('2026-07-15T12:00:00.000Z'); const ADJACENT_MONTH_FIRST = '2026-06-28'; const ADJACENT_MONTH_LAST = '2026-08-08'; async function seedCalendarTaskDate( page: Page, spaceId: string, date: string, body: string, ): Promise { const created = await page.request.post('/api/local/tasks', { data: { body, piece: 'chat', spaceId, workspaceMode: 'persistent' }, }); expect(created.ok()).toBeTruthy(); const taskId = (await created.json()).task.id as number; const e2eTmp = process.env.E2E_TMP; if (!e2eTmp) throw new Error('E2E_TMP must be set by playwright.config.ts'); const db = new Database(resolve(e2eTmp, 'e2e.db')); db.pragma('busy_timeout = 8000'); db.prepare('UPDATE local_tasks SET created_at = ?, updated_at = ? WHERE id = ?') .run(`${date}T12:00:00.000Z`, `${date}T12:00:00.000Z`, taskId); db.close(); return taskId; } async function seedCalendarEvent(page: Page, spaceId: string, date: string, title: string, endDate?: string) { const created = await page.request.post(`/api/local/spaces/${spaceId}/calendar/events`, { data: { date, end_date: endDate, title }, }); expect(created.ok()).toBeTruthy(); return (await created.json()).id as number; } test('space calendar: tab renders month grid + month nav', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2Eカレンダー表示'); await openSpaceTab(page, 'calendar'); await expect(page.getByTestId('space-calendar')).toBeVisible(); await expect(page.getByTestId('space-cal-grid')).toBeVisible(); // Today's cell is present in the current-month grid. const today = e2eLocalToday(); await expect(page.getByTestId(`space-cal-day-${today}`)).toBeVisible(); // Month nav changes the YYYY年M月 header. Capture, step forward, assert it // differs, step back, assert it returns. const title = page.getByTestId('space-cal-title'); const initial = (await title.textContent())?.trim(); expect(initial).toMatch(/\d{4}年\d{1,2}月/); await page.getByTestId('space-cal-next').click(); await expect(title).not.toHaveText(initial!); await page.getByTestId('space-cal-prev').click(); await expect(title).toHaveText(initial!); expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); test('space calendar: add event → list + day badge + persists across reload', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2Eカレンダー予定'); await openSpaceTab(page, 'calendar'); await expect(page.getByTestId('space-cal-grid')).toBeVisible(); const today = e2eLocalToday(); const todayCell = page.getByTestId(`space-cal-day-${today}`); // Baseline: today's event count badge (📌N) is absent before adding. await expect(todayCell.getByText(/^📌/)).toHaveCount(0); // Open today's day panel and add an event. await todayCell.click(); const panel = page.getByTestId('space-cal-day-panel'); await expect(panel).toBeVisible(); await panel.getByTestId('space-cal-add-event-btn').click(); const form = page.getByTestId('space-cal-add-event'); await expect(form).toBeVisible(); const eventTitle = `打合せ-${Date.now()}`; await form.getByTestId('space-cal-event-title').fill(eventTitle); await form.getByTestId('space-cal-event-time').fill('14:30'); await form.getByTestId('space-cal-event-description').fill('E2E: テスト予定'); await form.getByTestId('space-cal-event-save').click(); // The new event appears in the 予定 list (with its time) after invalidation. await expect(panel.getByText(eventTitle, { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(panel.getByText('14:30')).toBeVisible(); // Grab the event id from its panel row so we can assert both month-grid // indicators by id (a single-day event drives TWO separate render paths). const row = panel.locator('[data-testid^="space-cal-event-"]').filter({ hasText: eventTitle }).first(); const eventId = (await row.getAttribute('data-testid'))!.replace('space-cal-event-', ''); expect(eventId).toMatch(/^\d+$/); // Month grid shows BOTH indicators for the single-day event: // - the 📌1 day-cell badge (counts.eventCount path, added in #808 / #804). // exact:true so it pins count===1, not a substring of 📌1x. // - a colSpan-1 week bar (layoutWeekBars path) — a separate code path, so // assert it explicitly (a badge-only check would miss a phantom-bar bug). await expect(todayCell.getByText('📌1', { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId(`space-cal-bar-${eventId}`)).toBeVisible({ timeout: 10_000 }); // Persists across a full reload (server-backed, not just client state): // day-cell badge, week bar, and the day-panel event all come back. await page.reload(); await openSpaceTab(page, 'calendar'); await expect(page.getByTestId(`space-cal-day-${today}`).getByText('📌1', { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId(`space-cal-bar-${eventId}`)).toBeVisible({ timeout: 10_000 }); await page.getByTestId(`space-cal-day-${today}`).click(); await expect(page.getByTestId('space-cal-day-panel').getByText(eventTitle, { exact: true })).toBeVisible({ timeout: 10_000 }); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); test('space calendar: edit + delete an event', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2Eカレンダー編集'); await openSpaceTab(page, 'calendar'); const today = e2eLocalToday(); const todayCell = page.getByTestId(`space-cal-day-${today}`); await todayCell.click(); const panel = page.getByTestId('space-cal-day-panel'); await expect(panel).toBeVisible(); // Create one event. await panel.getByTestId('space-cal-add-event-btn').click(); const origTitle = `元タイトル-${Date.now()}`; await page.getByTestId('space-cal-event-title').fill(origTitle); await page.getByTestId('space-cal-event-save').click(); await expect(panel.getByText(origTitle, { exact: true })).toBeVisible({ timeout: 10_000 }); // Grab the rendered event id from its row testid (space-cal-event-{id}). const row = panel.locator('[data-testid^="space-cal-event-"]').filter({ hasText: origTitle }).first(); const rowTestId = await row.getAttribute('data-testid'); const eventId = rowTestId!.replace('space-cal-event-', ''); expect(eventId).toMatch(/^\d+$/); // Edit: open the edit form, change the title, save. await panel.getByTestId(`space-cal-event-edit-${eventId}`).click(); const newTitle = `編集後-${Date.now()}`; await page.getByTestId('space-cal-event-title').fill(newTitle); await page.getByTestId('space-cal-event-save').click(); await expect(panel.getByText(newTitle, { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(panel.getByText(origTitle, { exact: true })).toHaveCount(0); // While the event exists the day cell carries both month-grid indicators: // the 📌1 badge (eventCount path) and the colSpan-1 week bar (layoutWeekBars // path). Assert both so a regression in either path is caught. await expect(todayCell.getByText('📌1', { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId(`space-cal-bar-${eventId}`)).toBeVisible({ timeout: 10_000 }); // Delete: confirm the window.confirm() then assert the row disappears and // BOTH month-grid indicators clear (badge tracks eventCount, bar comes from // the events array — separate paths, so a delete could orphan one). page.once('dialog', (d) => d.accept()); await panel.getByTestId(`space-cal-event-delete-${eventId}`).click(); await expect(panel.getByTestId(`space-cal-event-${eventId}`)).toHaveCount(0, { timeout: 10_000 }); await expect(todayCell.getByText(/^📌/)).toHaveCount(0, { timeout: 10_000 }); await expect(page.getByTestId(`space-cal-bar-${eventId}`)).toHaveCount(0, { timeout: 10_000 }); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); test('space calendar: day panel lists tasks created in the space', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2Eカレンダータスク'); // Create an inline chat (task) in this space — it is created "today", so it // must surface in today's calendar day panel タスク section. const { taskId } = await startInlineChat(page, 'E2E: カレンダー日詳細用のタスク。'); expect(taskId).toBeTruthy(); // Switch to the calendar tab and open today's cell. await openSpaceTab(page, 'calendar'); await expect(page.getByTestId('space-cal-grid')).toBeVisible(); const today = e2eLocalToday(); const todayCell = page.getByTestId(`space-cal-day-${today}`); // The task badge (💬N) should be present for today. await expect(todayCell.getByText(/^💬/)).toBeVisible({ timeout: 10_000 }); await todayCell.click(); const panel = page.getByTestId('space-cal-day-panel'); await expect(panel).toBeVisible(); // The created task is listed and clicking it opens the chat (switches tab). const taskBtn = panel.getByTestId(`space-cal-task-${taskId}`); await expect(taskBtn).toBeVisible({ timeout: 10_000 }); await taskBtn.click(); await expect(page.getByTestId('space-conversation')).toBeVisible(); await expect(page).toHaveURL(/[?&]chat=\d+/); expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); test('space calendar: adjacent month days show badges, details, and spanning bars', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await page.clock.setFixedTime(ADJACENT_MONTH_CLOCK); const { spaceId } = await createAndOpenCaseSpace(page, 'E2E個別隣接月'); const first = ADJACENT_MONTH_FIRST; const last = ADJACENT_MONTH_LAST; const firstTaskId = await seedCalendarTaskDate(page, spaceId, first, `個別先頭タスク-${Date.now()}`); const lastTaskId = await seedCalendarTaskDate(page, spaceId, last, `個別末尾タスク-${Date.now()}`); const firstEvent = `個別先頭予定-${Date.now()}`; const lastEvent = `個別末尾予定-${Date.now()}`; await seedCalendarEvent(page, spaceId, first, firstEvent); await seedCalendarEvent(page, spaceId, last, lastEvent); const spanningEvent = `個別両境界予定-${Date.now()}`; const spanningId = await seedCalendarEvent(page, spaceId, first, spanningEvent, last); await openSpaceTab(page, 'calendar'); const firstCell = page.getByTestId(`space-cal-day-${first}`); const lastCell = page.getByTestId(`space-cal-day-${last}`); await expect(firstCell.getByText('💬1', { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(firstCell.getByText('📌2', { exact: true })).toBeVisible(); await expect(lastCell.getByText('💬1', { exact: true })).toBeVisible(); await expect(lastCell.getByText('📌2', { exact: true })).toBeVisible(); await expect(page.getByTestId(`space-cal-bar-${spanningId}`)).toHaveCount(6); await firstCell.click(); const panel = page.getByTestId('space-cal-day-panel'); await expect(panel.getByTestId(`space-cal-task-${firstTaskId}`)).toBeVisible(); await expect(panel.getByText(firstEvent, { exact: true })).toBeVisible(); await expect(panel.getByText(spanningEvent, { exact: true })).toBeVisible(); await lastCell.click(); await expect(panel.getByTestId(`space-cal-task-${lastTaskId}`)).toBeVisible(); await expect(panel.getByText(lastEvent, { exact: true })).toBeVisible(); await expect(panel.getByText(spanningEvent, { exact: true })).toBeVisible(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // ── Space members panel (feat/shared-space, stage 3) ───────────────────────── // // The 設定 → メンバー sub-tab renders SpaceMembersPanel. Membership management // (invite/role/remove) is gated on canManage, which in the E2E env (auth OFF) // resolves to TRUE: useAuthState() is `disabled`, so the synthetic 'local' user // can manage. The invite button therefore renders. // // ENV NOTE — no owner row in this harness: a case space created under no-auth is // stored with owner_id = NULL (POST /api/local/spaces: `viewer.id === 'local' ? // null : viewer.id`), and that space owns no space_members rows yet. The members // GET only synthesizes an owner row when space.ownerId is set, so under no-auth // the list is legitimately empty — there is no real user identity to attribute // the owner row to. We therefore assert the realistic no-auth surface: the panel // mounts, the empty list is handled without a crash, and the invite flow shows // the no-auth sharing hint (pickable returns [] -> "認証を有効化すると…"). The // owner-badge / role-select / remove controls are exercised by the API + (auth-on) // component tests; they cannot be reached in this auth-off harness. A future // auth-on harness (a second seeded user) would assert invite -> member appears. // // /api/users/pickable returns 200 with [] in no-auth (mirrors /api/admin/users), // so it is NOT a fatal HTTP error — the shared SPACE_BENIGN_PATHS set is enough. test('space settings MEMBERS: panel renders + invite shows no-auth hint', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2Eメンバー'); await openSpaceTab(page, 'settings'); // メンバー sub-tab → the panel mounts with its header. await page.getByTestId('space-settings-nav-members').click(); const panel = page.getByTestId('space-members-panel'); await expect(panel).toBeVisible({ timeout: 15_000 }); await expect(panel.getByRole('heading', { name: 'メンバー' })).toBeVisible(); // canManage is true (no-auth), so the invite button renders. Clicking it opens // the picker, which — with an empty pickable list — shows the no-auth sharing // hint instead of a user list. const inviteBtn = panel.getByTestId('space-member-invite'); await expect(inviteBtn).toBeVisible(); await inviteBtn.click(); const picker = panel.getByTestId('space-member-picker'); await expect(picker).toBeVisible({ timeout: 15_000 }); await expect(picker.getByText('認証を有効化するとワークスペースを共有できます。')).toBeVisible(); expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // ── Space source library (feat/space-source-library, stage 2) ──────────────── // // The Files tab now renders a curated "ソース" group at the top (testid // space-sources) sourced from source/index.jsonl. Two surfaces are observable // WITHOUT an LLM: // 1. EMPTY-STATE: a fresh space has no source/index.jsonl → the GET on // .../files/content?path=source/index.jsonl 404s, fetchSpaceSourceIndex // swallows it and returns [], and the group shows the empty hint. // NOTE: that 404 is EXPECTED here, so we use a local response tracker that // treats the space source-index content 404 as benign (the shared // trackFatalErrors would otherwise flag it; SPACE_BENIGN_ASSET only covers // .json, not .jsonl). // 2. POPULATED: we seed source/research.md + source/index.jsonl via the space // file upload API (POST /files/upload, path=source), then assert the group // lists the entry with its title and origin host. const b64 = (s: string) => Buffer.from(s, 'utf8').toString('base64'); // The curated ソース list view was removed (#6): a fresh space's ファイル tab no // longer renders the source list or its empty-state. (Previously this asserted // the empty-state was visible; now it must be ABSENT.) The Files grid renders // instead, and the UI no longer fetches source/index.jsonl on open. test('space sources: curated ソース list view (incl. empty-state) is absent on a fresh space', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2Eソース空'); await page.getByTestId('space-tab-files').click(); // The Files grid renders, but the old ソース list view + its empty-state are gone. await expect(page.getByTestId('space-files-grid')).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('space-sources')).toHaveCount(0); await expect(page.getByTestId('space-sources-empty')).toHaveCount(0); expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // The curated ソース list view was removed (#6). The source/ folder + its // index.jsonl provenance STILL accumulate (agent WebFetch/DownloadFile → source/), // but the Files UI no longer renders a special list — source/ is just a normal // folder in the grid. This test proves: (1) the seeded source/ upload succeeds, // (2) the old space-sources list view is gone, (3) source/ is browsable as a // regular folder and its file shows up there. test('space sources: source/ folder still accumulates and is browsable; curated ソース list view removed', async ({ page }) => { const fatalErrors = trackFatalErrors(page); const { spaceId } = await createAndOpenCaseSpace(page, 'E2Eソース'); // Seed a source file + provenance index into the space's source/ dir via the // same upload API the Files tab uses (path=source places files under source/). const indexLine = JSON.stringify({ file: 'research.md', url: 'https://example.com/article', title: 'リサーチ記事タイトル', fetched_at: '2026-06-19T10:00:00Z', tool: 'WebFetch', bytes: 2048, }); const upRes = await page.request.post(`/api/local/spaces/${spaceId}/files/upload`, { data: { path: 'source', files: [ { name: 'research.md', contentBase64: b64('# 調査結果\n\n本文。') }, { name: 'index.jsonl', contentBase64: b64(`${indexLine}\n`) }, ], }, }); expect(upRes.status(), `upload failed: ${upRes.status()} ${await upRes.text()}`).toBe(200); // Open the Files tab. The curated ソース list view (#6) is GONE. await page.getByTestId('space-tab-files').click(); await expect(page.getByTestId('space-files-grid')).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('space-sources')).toHaveCount(0); // source/ appears as a normal folder tile; navigate into it. const sourceDir = page.locator('[data-testid="space-file-tile"][data-kind="directory"][data-name="source"]'); await expect(sourceDir).toBeVisible({ timeout: 15_000 }); await sourceDir.click(); // The seeded research.md shows in the source/ folder (index.jsonl stays hidden // — it is filtered out of the raw file window as before). await expect( page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="research.md"]'), ).toBeVisible({ timeout: 15_000 }); await expect( page.locator('[data-testid="space-file-tile"][data-name="index.jsonl"]'), ).toHaveCount(0); expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // ── Space settings → ブラウザ section (workstream 2, stage 2) ───────────────── // // The 設定 sub-nav now carries a "ブラウザ" section (space-settings-nav-browser) // rendering SpaceBrowserPanel: three groups — ブラウザセッション // (space-browser-sessions), ブラウザマクロ (space-browser-macros), 録画 // (space-browser-recordings). All three are space-scoped (their fetches carry // ?spaceId=). In the auth-off harness there are no real sessions/macros/ // recordings for a fresh space, so we assert the panel mounts and each group // shows its in-product empty state without a crash or fatal 4xx/5xx. The // per-user-DEK "作成者のみ利用可" badge and management gating are exercised by the // component/API tests; this render test guards against a regression that blanks // the whole section. // // ENV NOTE — benign endpoints under no-auth: the panel lists profiles via // GET /api/browser-sessions/profiles?spaceId=… and macros/recordings via // GET /api/users/me/folder/{list}?subdir=…&spaceId=…. Under no-auth the synthetic // 'local' user is the space owner, so these return 200 with empty arrays — no new // benign paths are needed beyond the shared set. The add-session control renders // because canManage is TRUE in the auth-off harness (useAuthState() = disabled). test('space settings BROWSER: panel + three groups render with empty states', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2Eブラウザ'); await openSpaceTab(page, 'settings'); // The ブラウザ sub-tab is present and selecting it mounts SpaceBrowserPanel. const browserNav = page.getByTestId('space-settings-nav-browser'); await expect(browserNav).toBeVisible(); await browserNav.click(); const panel = page.getByTestId('space-browser-panel'); await expect(panel).toBeVisible({ timeout: 15_000 }); // 1. セッション group: header + empty state. const sessions = page.getByTestId('space-browser-sessions'); await expect(sessions).toBeVisible(); await expect(sessions.getByRole('heading', { name: 'ブラウザセッション' })).toBeVisible(); await expect(sessions).toContainText('このワークスペースにはまだブラウザセッションがありません'); // canManage (no-auth) → the add-session control renders. await expect(page.getByTestId('space-browser-add-session')).toBeVisible(); // 2. マクロ group: header + empty state. const macros = page.getByTestId('space-browser-macros'); await expect(macros).toBeVisible(); await expect(macros.getByRole('heading', { name: 'ブラウザマクロ' })).toBeVisible(); await expect(macros).toContainText('このワークスペースにはまだブラウザマクロがありません'); // 3. 録画 group: header + empty state. const recordings = page.getByTestId('space-browser-recordings'); await expect(recordings).toBeVisible(); await expect(recordings.getByRole('heading', { name: '録画' })).toBeVisible(); await expect(recordings).toContainText('このワークスペースにはまだ録画がありません'); expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // ════════════════════════════════════════════════════════════════════════════ // Workspace management + file management (feat/workspace-files-mgmt). // // - File delete with multi-select in the ファイル tab (#8). // - Workspace rename via the header pencil (#8). // - Workspace (case) delete via the header trash → returns to the list (#8). // - The curated ソース list view was removed (#6); source/ still accumulates as // a normal folder, but the SpaceSources list (data-testid="space-sources") is // gone from the Files UI. // All run with auth disabled; no LLM needed (pure management UI). // ════════════════════════════════════════════════════════════════════════════ test('space files DELETE: select a file and delete it (gone); ソース list view is removed', async ({ page }) => { const fatalErrors = trackFatalErrors(page); const { detail } = await createAndOpenCaseSpace(page, 'E2E削除'); await page.getByTestId('space-tab-files').click(); await expect(page.getByTestId('space-files-grid')).toBeVisible(); // The curated ソース list view (#6) must NOT be rendered anymore. await expect(page.getByTestId('space-sources')).toHaveCount(0); // Upload a file so we have something to delete. await page .locator('[data-testid="space-files-upload-input"]') .setInputFiles({ name: 'delete-me.txt', mimeType: 'text/plain', buffer: Buffer.from('bye') }); const tile = page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="delete-me.txt"]'); await expect(tile).toBeVisible({ timeout: 15000 }); // Select it via its checkbox → the bulk 削除 button appears. await page.getByTestId('space-file-select-delete-me.txt').check(); const deleteSelected = page.getByTestId('space-files-delete-selected'); await expect(deleteSelected).toBeVisible(); // Accept the confirm() dialog guarding the destructive delete. page.once('dialog', (dialog) => { expect(dialog.type()).toBe('confirm'); void dialog.accept(); }); await deleteSelected.click(); // The tile disappears after the delete + refetch. await expect(tile).toHaveCount(0, { timeout: 15000 }); // No-nav invariant. expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); await expect(detail).toBeVisible(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); test('space files DELETE: multi-select all + per-file trash icon', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2E複数削除'); await page.getByTestId('space-tab-files').click(); await expect(page.getByTestId('space-files-grid')).toBeVisible(); // Upload two files. await page.locator('[data-testid="space-files-upload-input"]').setInputFiles([ { name: 'one.txt', mimeType: 'text/plain', buffer: Buffer.from('1') }, { name: 'two.txt', mimeType: 'text/plain', buffer: Buffer.from('2') }, ]); await expect( page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="two.txt"]'), ).toBeVisible({ timeout: 15000 }); // "Select all" checks both; the count reflects 2 選択中. await page.getByTestId('space-files-select-all').check(); await expect(page.getByTestId('space-files-selected-count')).toContainText('2'); // Delete the whole selection. page.once('dialog', (d) => void d.accept()); await page.getByTestId('space-files-delete-selected').click(); await expect( page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="one.txt"]'), ).toHaveCount(0, { timeout: 15000 }); await expect( page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="two.txt"]'), ).toHaveCount(0); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); test('space files DOWNLOAD: bulk zip + per-tile download', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2Eダウンロード'); await page.getByTestId('space-tab-files').click(); await expect(page.getByTestId('space-files-grid')).toBeVisible(); // Upload two files to download. await page.locator('[data-testid="space-files-upload-input"]').setInputFiles([ { name: 'one.txt', mimeType: 'text/plain', buffer: Buffer.from('1') }, { name: 'two.txt', mimeType: 'text/plain', buffer: Buffer.from('2') }, ]); await expect( page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="two.txt"]'), ).toBeVisible({ timeout: 15000 }); // Bulk: select all → ダウンロード → a single files.zip download fires. await page.getByTestId('space-files-select-all').check(); const [zipDownload] = await Promise.all([ page.waitForEvent('download'), page.getByTestId('space-files-download-selected').click(), ]); expect(zipDownload.suggestedFilename()).toBe('files.zip'); // Per-tile: the download anchor downloads that single file (name preserved). const [single] = await Promise.all([ page.waitForEvent('download'), page.getByTestId('space-file-download-one.txt').click(), ]); expect(single.suggestedFilename()).toBe('one.txt'); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); test('space RENAME: header pencil renames the workspace and the title updates', async ({ page }) => { const fatalErrors = trackFatalErrors(page); const { detail, caseTitle, rail } = await createAndOpenCaseSpace(page, 'E2E改名'); // Open the header pencil, which reuses the create/edit SpaceFormDialog // (space-title-input pre-filled with the current title, same submit button // as create). There is no dedicated inline rename input/save pair. await page.getByTestId('space-edit').click(); const input = page.getByTestId('space-title-input'); await expect(input).toBeVisible(); const renamed = `${caseTitle}-改`; await input.fill(renamed); await page.getByTestId('space-form-submit').click(); // The header reflects the new title, and the rail row updates too. await expect(detail).toContainText(renamed); await expect( rail.locator('[data-testid="space-row"][data-space-kind="case"]').filter({ hasText: renamed }), ).toBeVisible({ timeout: 15000 }); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); test('space DELETE: case workspace delete returns to the list (selection cleared)', async ({ page }) => { const fatalErrors = trackFatalErrors(page); const { caseTitle, rail } = await createAndOpenCaseSpace(page, 'E2EWS削除'); // Click the header trash → inline confirm → 削除する. await page.getByTestId('space-delete').click(); const confirm = page.getByTestId('space-delete-confirm'); await expect(confirm).toBeVisible(); await confirm.click(); // The space row disappears from the rail and the selection is cleared // (space= URL param dropped → back to the list / empty detail). await expect( rail.locator('[data-testid="space-row"][data-space-kind="case"]').filter({ hasText: caseTitle }), ).toHaveCount(0, { timeout: 15000 }); await expect.poll(() => new URL(page.url()).searchParams.get('space')).toBeFalsy(); // Still on Spaces, never bounced to Tasks. expect(new URL(page.url()).searchParams.get('page')).toBeFalsy(); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // ── Workspace apps tab (feat/workspace-apps-stage2) ────────────────────────── // The "アプリ" tab lists apps = subfolders under apps/ that contain an // index.html, reading an optional app.json for title/description, and launches // the selected one in the sandboxed AppRunner. test('space apps: empty-state on a fresh workspace', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await createAndOpenCaseSpace(page, 'E2EApps空'); await openSpaceTab(page, 'apps'); await expect(page.getByTestId('space-apps')).toBeVisible(); // A fresh case workspace has no apps/ folder → in-product empty-state. await expect(page.getByTestId('space-apps-empty')).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('space-apps-empty')).toContainText('ワークスペース・アプリを作って'); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); test('space apps: seeded app lists with its title and opens in the AppRunner', async ({ page }) => { const fatalErrors = trackFatalErrors(page); const { spaceId } = await createAndOpenCaseSpace(page, 'E2EApps起動'); // Seed apps/demo/index.html (+ app.json) via the restricted write endpoint // (only apps/ + output/ are writable, which is exactly where apps live). const html = 'demo' + '

デモアプリ

'; const w1 = await page.request.post(`/api/local/spaces/${spaceId}/files/write`, { data: { path: 'apps/demo/index.html', content: html }, }); expect(w1.ok()).toBeTruthy(); const w2 = await page.request.post(`/api/local/spaces/${spaceId}/files/write`, { data: { path: 'apps/demo/app.json', content: JSON.stringify({ title: 'デモ・アプリ', description: '起動テスト用' }), }, }); expect(w2.ok()).toBeTruthy(); // Open the アプリ tab and assert the seeded app appears with its manifest title. await openSpaceTab(page, 'apps'); await expect(page.getByTestId('space-apps')).toBeVisible(); const card = page.getByTestId('space-app-demo'); await expect(card).toBeVisible({ timeout: 15_000 }); await expect(card).toContainText('デモ・アプリ'); await expect(card).toContainText('起動テスト用'); // 開く → AppRunner mounts (sandbox iframe overlay). await page.getByTestId('space-app-open-demo').click(); await expect(page.getByTestId('app-runner')).toBeVisible({ timeout: 15_000 }); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); });