Files
maestro/ui/e2e/spaces.spec.ts
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

2327 lines
117 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<TaskDetail['task'] | null> {
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<string> {
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) layout: the rail and detail must NOT sit side-by-side on a
// phone. Startup auto-opens the personal workspace (App.tsx's one-shot
// personalSpaceAutoOpened guard), so detail is the initial full-width view;
// backing out returns to the full-width rail (the guard does not re-fire) and
// selecting another space replaces the rail with detail plus a
// "← ワークスペース一覧" back control that returns to the rail. At >=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 <input type=file multiple>, 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
// (<a class="output-path-link" data-output-path="output/...">) 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
// <a class="output-path-link" data-output-path="output/note.md">.
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=<id>, 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=<id> 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 <select> + instruction textarea
// (stable ids) are present, and the select carries ≥1 piece option.
const pieceSelect = page.locator('#continue-piece');
await expect(pieceSelect).toBeVisible();
await expect(page.locator('#continue-instruction')).toBeVisible();
await expect.poll(async () => pieceSelect.locator('option').count()).toBeGreaterThan(0);
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// 5. VISIBILITY — space chats have a FIXED scope: always visible to the space's
// members, never user-selectable. The visibility <select> 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=<id>. 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<number> {
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=<id>). 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 =
'<!doctype html><meta charset="utf-8"><title>demo</title>' +
'<body><h1 id="hdr">デモアプリ</h1><script>document.getElementById("hdr").textContent="ready";</script>';
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([]);
});