maestro/ui/e2e/spaces.spec.ts
oss-sync 77ee3bc426
Some checks are pending
CI / build-and-test (push) Waiting to run
sync: update from private repo (ddadfd71)
2026-07-08 23:35:00 +00:00

2080 lines
102 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test';
// ── Shared helpers for the space task/settings parity suite ───────────────────
//
// The tests below all start the same way: open Spaces, create a case space, open
// it, and (for chat actions) start an inline chat. The first batch of tests in
// this file inlines that flow; the feature-parity tests added later reuse these
// helpers to stay focused on the action under test. The helpers mirror the
// inlined flow exactly (same testids, same waits) — they do NOT relax any
// assertion, they just remove copy-paste.
// The no-auth + live-session probe allowlist shared by the chat/settings tests.
// Identical set to the inline-chat/settings tests above: with auth disabled the
// UI's /api/auth/me probe 404s on purpose and falls back to the synthetic local
// user; CreateTaskDialog's org/MCP probes 401; the per-task console/browser
// status probes 404 with no live session. All benign, not failures.
const SPACE_BENIGN_PATHS = new Set([
'/api/auth/me',
'/api/users/me/orgs',
'/api/mcp/connections',
'/api/mcp/servers',
'/api/mcp/user-servers',
'/api/ssh/connections',
]);
const SPACE_BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
const SPACE_BENIGN_PATH_RE =
/\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
/** Attach the standard fatal-error tracker used by every space test and return
* the collected list (assert it stays empty at the end of the test). */
function trackFatalErrors(page: Page): string[] {
const fatalErrors: string[] = [];
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
page.on('response', (res) => {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
if (
!SPACE_BENIGN_ASSET.test(pathname) &&
!SPACE_BENIGN_PATHS.has(pathname) &&
!SPACE_BENIGN_PATH_RE.test(pathname)
) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
});
return fatalErrors;
}
/** Open Spaces, create a case space with a unique title, select it, and return
* the detail locator + the title used. Mirrors the inlined create flow. */
async function createAndOpenCaseSpace(page: Page, label: string) {
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
const rail = page.getByTestId('space-rail');
await expect(rail).toBeVisible();
await page.getByTestId('create-space-btn').click();
const titleInput = page.getByTestId('space-title-input');
await expect(titleInput).toBeVisible();
const caseTitle = `${label}-${Date.now()}`;
await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click();
await expect(titleInput).toHaveCount(0);
const caseRow = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: caseTitle });
await expect(caseRow).toBeVisible();
const spaceId = await caseRow.getAttribute('data-space-id');
expect(spaceId).toBeTruthy();
await caseRow.click();
const detail = page.getByTestId('space-detail');
await expect(detail).toBeVisible();
return { detail, caseTitle, rail, spaceId: spaceId as string };
}
/** Start a new inline chat in the currently-open space, returning the inline
* conversation locator and the created task id (from the chat= URL param). */
async function startInlineChat(page: Page, body: string) {
await page.getByTestId('space-tab-chat').click();
await page.getByTestId('space-new-chat-btn').click();
const composer = page.getByTestId('create-task-body');
await expect(composer).toBeVisible();
await composer.fill(body);
await page.getByTestId('create-task-submit').click();
const conversation = page.getByTestId('space-conversation');
await expect(conversation).toBeVisible();
await expect(page).toHaveURL(/[?&]chat=\d+/);
const taskId = new URL(page.url()).searchParams.get('chat');
expect(taskId).toBeTruthy();
return { conversation, taskId: taskId as string };
}
type TaskDetail = {
task?: {
latestJob?: { status?: string } | null;
shareToken?: string | null;
feedbackRating?: string | null;
visibility?: string | null;
};
};
/** Fetch the task detail. NOTE the API wraps the row under `.task`. */
async function fetchTask(page: Page, taskId: string): Promise<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('create-space-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. With no space selected the rail is a full-width list; selecting a
// space replaces the rail with a full-width 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 TopBar collapses its inline nav into a hamburger
// drawer, so the nav-spaces tab isn't directly clickable. Navigate via the
// URL state (page=spaces) instead — it's the same selection, sans drawer.
await page.goto('/ui?page=spaces');
// No space selected: the rail is the visible full-width list; detail hidden.
const rail = page.getByTestId('space-rail');
const detail = page.getByTestId('space-detail');
await expect(rail).toBeVisible();
await expect(detail).toBeHidden();
await expect(page.getByTestId('space-back-to-list')).toHaveCount(0);
// Create + auto-select a case space.
await page.getByTestId('create-space-btn').click();
const titleInput = page.getByTestId('space-title-input');
await expect(titleInput).toBeVisible();
const caseTitle = `E2Eモバイル-${Date.now()}`;
await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click();
await expect(titleInput).toHaveCount(0);
// A space is open: detail is now the visible full-width view, rail is hidden,
// and the mobile back control is present.
await expect(detail).toBeVisible();
await expect(detail.getByText(caseTitle)).toBeVisible();
await expect(rail).toBeHidden();
const back = page.getByTestId('space-back-to-list');
await expect(back).toBeVisible();
// Tapping back clears the selection and returns to the full-width rail.
await back.click();
await expect(rail).toBeVisible();
await expect(detail).toBeHidden();
await expect(page.getByTestId('space-back-to-list')).toHaveCount(0);
});
// Inline conversation inside a space (plan 6). The user starts a chat from the
// space's チャット tab and the conversation (ChatPane: messages + composer)
// renders INLINE — without ever navigating to the Tasks page. No LLM is
// configured in the E2E env, so the agent never completes; we only assert the
// conversation UI is present and the no-navigation invariant holds.
test('space inline chat: create + open conversation without leaving for Tasks', async ({ page }) => {
const fatalErrors: string[] = [];
const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
// With auth disabled, these probes 401 on purpose (the CreateTaskDialog
// fetches orgs + MCP connections which require an authenticated user). The
// UI tolerates the 401 and falls back to empty lists, so they are the
// expected no-auth signal — not a failure.
const benignPaths = new Set(['/api/auth/me', '/api/users/me/orgs', '/api/mcp/connections']);
// useVisibleDetailTabs (now used by the inline conversation's detail tabs)
// polls per-task console/browser-session status to decide whether to show the
// live SSH/Browser tabs. With no live session these 404 by design and the
// hook degrades to "tab hidden" — benign, like the no-auth probes above.
const benignPathRe = /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
page.on('response', (res) => {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
if (!benignAsset.test(pathname) && !benignPaths.has(pathname) && !benignPathRe.test(pathname)) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
});
// 1. Open the app, go to Spaces, create a case space.
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
const rail = page.getByTestId('space-rail');
await expect(rail).toBeVisible();
await page.getByTestId('create-space-btn').click();
const titleInput = page.getByTestId('space-title-input');
await expect(titleInput).toBeVisible();
const caseTitle = `E2E会話-${Date.now()}`;
await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click();
await expect(titleInput).toHaveCount(0);
const caseRow = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: caseTitle });
await expect(caseRow).toBeVisible();
await caseRow.click();
const detail = page.getByTestId('space-detail');
await expect(detail).toBeVisible();
// 2. On the チャット tab, start a new chat.
await page.getByTestId('space-tab-chat').click();
await page.getByTestId('space-new-chat-btn').click();
const body = page.getByTestId('create-task-body');
await expect(body).toBeVisible();
await body.fill('E2E: inline conversation smoke. これはテスト用の最初のメッセージです。');
await page.getByTestId('create-task-submit').click();
// 3. The inline conversation pane (ChatPane) appears inside the space.
// No LLM => the agent won't progress, but the conversation UI renders.
const conversation = page.getByTestId('space-conversation');
await expect(conversation).toBeVisible();
// 4. No navigation to the Tasks page. The app stays on the Spaces page
// (urlState serializes page=spaces) and sets the chat= param for the
// selected inline chat. It must NOT switch to page=tasks (which would
// drop page= entirely AND have no chat=).
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
await expect(page).toHaveURL(/[?&]chat=\d+/);
expect(page.url()).toContain('chat=');
expect(page.url()).not.toContain('page=tasks');
// The Spaces detail (not the Tasks board) is still mounted.
await expect(detail).toBeVisible();
// 4b. The inline conversation exposes per-task detail tabs via the single
// in-place tab bar (data-testid="space-chat-tabs") so Progress is visible
// WITHOUT leaving for Tasks. Clicking 進捗 (Progress = the `activity` tab)
// swaps the ProgressTab content IN PLACE (no overlay/✕), then 会話 returns
// to the conversation — all while staying on page=spaces with the same
// chat= param. (A dedicated test below covers the tab-bar structure.)
const progressTab = conversation.getByTestId('space-chat-tab-activity');
await expect(progressTab).toBeVisible();
await progressTab.click();
// In-place ProgressTab content renders inside the shared tabpanel (no Tasks nav).
await expect(conversation.getByTestId('progress-tab')).toBeVisible();
await expect(progressTab).toHaveAttribute('aria-selected', 'true');
// No overlay close affordance — the redesign dropped the overlay entirely.
await expect(page.getByTestId('detail-panel-close')).toHaveCount(0);
// Invariant: still inline on Spaces, same chat, never bounced to Tasks.
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
await expect(page).toHaveURL(/[?&]chat=\d+/);
expect(page.url()).not.toContain('page=tasks');
// Return to the conversation via the 会話 tab so the rest of the flow
// (creating a second chat) starts from chat again.
await conversation.getByTestId('space-chat-tab-chat').click();
await expect(conversation.getByTestId('progress-tab')).toHaveCount(0);
// 5. Create a SECOND chat in the same space and assert the conversation
// switches when selecting the first one again from the list.
const firstChatUrl = new URL(page.url());
const firstChatId = firstChatUrl.searchParams.get('chat');
expect(firstChatId).toBeTruthy();
await page.getByTestId('space-new-chat-btn').click();
const body2 = page.getByTestId('create-task-body');
await expect(body2).toBeVisible();
await body2.fill('E2E: second inline chat. 二つ目のチャットです。');
await page.getByTestId('create-task-submit').click();
// The second chat auto-selects: chat= now points at a different id.
await expect
.poll(() => new URL(page.url()).searchParams.get('chat'))
.not.toBe(firstChatId);
await expect(conversation).toBeVisible();
const secondChatId = new URL(page.url()).searchParams.get('chat');
// Selecting the first chat row again switches the conversation back to it.
const firstRow = page.locator(`[data-testid="space-chat-row"][data-task-id="${firstChatId}"]`);
await expect(firstRow).toBeVisible();
await firstRow.click();
await expect
.poll(() => new URL(page.url()).searchParams.get('chat'))
.toBe(firstChatId);
expect(secondChatId).not.toBe(firstChatId);
await expect(conversation).toBeVisible();
// Still on Spaces, still has a chat — never bounced to Tasks.
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// Redesigned space Files tab (feat/space-files-explorer). The space-level ファイル
// tab is now an icon grid (space-files-grid) with a breadcrumb, an upload button +
// hidden <input type=file multiple>, and a dropzone. Uploading a file makes a
// space-file-tile appear; clicking a file tile opens the FilePreview modal
// (role="dialog") showing the content. Directory tiles navigate (breadcrumb
// updates) — covered here only by the no-nav invariant (we stay on page=spaces).
// No LLM is needed; this is pure file-management UI, so we never assert agent output.
test('space files: icon grid, upload appears as tile, click opens preview modal', async ({ page }) => {
const fatalErrors: string[] = [];
const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
// Same no-auth probe allowlist as the other space tests: with auth disabled the
// UI's /api/auth/me probe 404s on purpose and falls back to the synthetic local
// user — the expected no-auth signal, not a failure.
const benignPaths = new Set(['/api/auth/me']);
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
page.on('response', (res) => {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
if (!benignAsset.test(pathname) && !benignPaths.has(pathname)) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
});
// 1. Spaces → create a case space → open the space-level ファイル tab.
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
const rail = page.getByTestId('space-rail');
await expect(rail).toBeVisible();
await page.getByTestId('create-space-btn').click();
const titleInput = page.getByTestId('space-title-input');
await expect(titleInput).toBeVisible();
const caseTitle = `E2Eファイル-${Date.now()}`;
await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click();
await expect(titleInput).toHaveCount(0);
const caseRow = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: caseTitle });
await expect(caseRow).toBeVisible();
await caseRow.click();
const detail = page.getByTestId('space-detail');
await expect(detail).toBeVisible();
await page.getByTestId('space-tab-files').click();
// 2. The grid + breadcrumb render (empty tree is fine).
await expect(page.getByTestId('space-files-grid')).toBeVisible();
await expect(page.getByTestId('space-files-breadcrumb')).toBeVisible();
// 3. Upload a LARGE (>150kb) file through the hidden multiple-file input.
// This guards the P1 regression where a global express.json() (~100kb)
// shadowed the upload route's 50mb parser and 413'd any body >~100kb.
// A tiny buffer passed even with the bug; 160kb only passes after the fix.
await page
.locator('[data-testid="space-files-upload-input"]')
.setInputFiles({
name: 'e2e-large.txt',
mimeType: 'text/plain',
buffer: Buffer.alloc(160 * 1024, 'a'),
});
// 4. The large file shows up as a tile (upload + refetch take a moment).
const largeTile = page.locator(
'[data-testid="space-file-tile"][data-kind="file"][data-name="e2e-large.txt"]',
);
await expect(largeTile).toBeVisible({ timeout: 15000 });
// 5. Upload a separate TINY markdown file so the modal-preview assertion stays
// cheap and locale-independent.
await page
.locator('[data-testid="space-files-upload-input"]')
.setInputFiles({
name: 'e2e-upload.md',
mimeType: 'text/markdown',
buffer: Buffer.from('# E2E\nhello'),
});
const tile = page.locator(
'[data-testid="space-file-tile"][data-kind="file"][data-name="e2e-upload.md"]',
);
await expect(tile).toBeVisible({ timeout: 15000 });
// 6. Clicking the tile opens the FilePreview modal (role="dialog") showing the
// markdown content; close it by clicking the backdrop (locale-independent —
// the ✕ label is translated; the backdrop corner is not). The backdrop is
// the dialog's parent; its mousedown+click both land on the backdrop itself,
// which is exactly what useBackdropClose requires to close.
await tile.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText('hello');
const backdrop = dialog.locator('xpath=..');
await backdrop.click({ position: { x: 4, y: 4 } });
await expect(dialog).toHaveCount(0);
// 6. No-nav invariant: file management never leaves Spaces for the Tasks board.
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
await expect(detail).toBeVisible();
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// Feature B (feat/space-gpu-and-filelinks): the space rail carries a collapsible
// worker/GPU status footer. The header button (data-testid="space-worker-status-toggle")
// is always present in the rail (desktop width, hidden md:flex); clicking it expands
// the WorkerStatusWidget panel (data-testid="space-worker-status-panel") in place.
// We assert the toggle is visible at desktop width, then click it and assert the
// panel content becomes visible (aria-expanded flips true). No LLM is needed —
// the widget reads /api/workers/status which is always populated in worker mode.
test('space worker status: rail GPU panel toggles open', async ({ page }) => {
// 1. Open the app at the default (desktop) viewport and go to Spaces.
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
const rail = page.getByTestId('space-rail');
await expect(rail).toBeVisible();
// 2. The rail-bottom worker/GPU footer + its toggle header are present (the
// footer wrapper is always mounted; the toggle is visible at >=md width).
const status = page.getByTestId('space-worker-status');
await expect(status).toBeVisible();
const toggle = page.getByTestId('space-worker-status-toggle');
await expect(toggle).toBeVisible();
// Collapsed by default: the inner widget panel is not yet rendered.
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByTestId('space-worker-status-panel')).toHaveCount(0);
// 3. Clicking the toggle expands the panel; the WorkerStatusWidget content
// mounts and aria-expanded flips to true.
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByTestId('space-worker-status-panel')).toBeVisible();
});
// AAO Gateway / LiteLLM front: a `proxy: true` worker fans out into a backends[]
// tree (collectWorkerStatuses, unit-tested separately). This test pins the *UI*
// half: given a proxy worker payload, the widget renders the proxy badge and one
// row per backend. The /api/local/dashboard/workers response is stubbed so the
// test is deterministic without standing up a real gateway + probe registry.
test('space worker status: proxy worker renders per-backend rows (AAO Gateway)', async ({ page }) => {
await page.route('**/api/local/dashboard/workers', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
workers: [
{
id: 'gw', name: 'gw', roles: ['task'], state: 'running', proxy: true,
backends: [
{ id: 'backend-a', state: 'running', busySlots: 1, totalSlots: 4, online: true },
{ id: 'backend-b', state: 'idle', busySlots: 0, totalSlots: 4, online: true },
],
},
],
}),
}),
);
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
await expect(page.getByTestId('space-rail')).toBeVisible();
// Open the footer; the WorkerStatusWidget mounts and fetches the stubbed list.
await page.getByTestId('space-worker-status-toggle').click();
await expect(page.getByTestId('space-worker-status-panel')).toBeVisible();
// The proxy worker row + its proxy badge render.
await expect(page.getByTestId('worker-row-gw')).toBeVisible();
await expect(page.getByTestId('worker-proxy-badge')).toBeVisible();
// Backends are expanded by default → one row per Gateway backend, with slot caption.
await expect(page.getByTestId('worker-backend-backend-a')).toBeVisible();
await expect(page.getByTestId('worker-backend-backend-b')).toBeVisible();
await expect(page.getByTestId('worker-backend-backend-a')).toContainText('1/4');
});
// Feature A (feat/space-gpu-and-filelinks): the inline conversation now wraps its
// chat in an OutputPreviewProvider, so clicking a rendered output-path link
// (<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 preview modal', async ({ page }) => {
const fatalErrors: string[] = [];
const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
const benignPaths = new Set(['/api/auth/me', '/api/users/me/orgs', '/api/mcp/connections']);
const benignPathRe = /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
page.on('response', (res) => {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
if (!benignAsset.test(pathname) && !benignPaths.has(pathname) && !benignPathRe.test(pathname)) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
});
// 1. Spaces → create a case space → start an inline chat (same flow as above).
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
const rail = page.getByTestId('space-rail');
await expect(rail).toBeVisible();
await page.getByTestId('create-space-btn').click();
const titleInput = page.getByTestId('space-title-input');
await expect(titleInput).toBeVisible();
const caseTitle = `E2Eリンク-${Date.now()}`;
await titleInput.fill(caseTitle);
await page.getByTestId('create-space-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 FilePreview modal (role="dialog") opens with the file content.
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText('hello from output');
// Close via the backdrop (locale-independent), same as the files test.
const backdrop = dialog.locator('xpath=..');
await backdrop.click({ position: { x: 4, y: 4 } });
await expect(dialog).toHaveCount(0);
// 7. No-nav invariant: clicking the link previewed in place, never bounced to Tasks.
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
await expect(page).toHaveURL(/[?&]chat=\d+/);
expect(page.url()).not.toContain('page=tasks');
await expect(detail).toBeVisible();
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// Redesigned space-chat UX (feat/space-chat-ux). The inline conversation now
// carries a SINGLE in-place tab bar (data-testid="space-chat-tabs", role=tablist)
// with 会話 (chat) + 概要 (overview) + 進捗 (activity) + トレース (trace) and NO
// files tab (files live at the space level). Clicking a non-chat tab swaps the
// content IN PLACE — no overlay, no ✕ close button — and 会話 returns to the
// conversation. Throughout, we never leave for the Tasks page (page=spaces +
// chat= stay put). No LLM is configured in E2E, so we assert UI/structure only,
// never agent output.
test('space chat tabs: single in-place tab bar, overview in place, back to chat', async ({ page }) => {
const fatalErrors: string[] = [];
const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
// Same no-auth + live-session probe allowlist as the inline-chat test above.
const benignPaths = new Set(['/api/auth/me', '/api/users/me/orgs', '/api/mcp/connections']);
const benignPathRe = /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
page.on('response', (res) => {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
if (!benignAsset.test(pathname) && !benignPaths.has(pathname) && !benignPathRe.test(pathname)) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
});
// 1. Spaces → create a case space → start an inline chat (same flow as above).
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
const rail = page.getByTestId('space-rail');
await expect(rail).toBeVisible();
await page.getByTestId('create-space-btn').click();
const titleInput = page.getByTestId('space-title-input');
await expect(titleInput).toBeVisible();
const caseTitle = `E2Eタブ-${Date.now()}`;
await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click();
await expect(titleInput).toHaveCount(0);
const caseRow = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: caseTitle });
await expect(caseRow).toBeVisible();
await caseRow.click();
const detail = page.getByTestId('space-detail');
await expect(detail).toBeVisible();
await page.getByTestId('space-tab-chat').click();
await page.getByTestId('space-new-chat-btn').click();
const body = page.getByTestId('create-task-body');
await expect(body).toBeVisible();
await body.fill('E2E: 単一タブの会話。インプレース切替を確認する最初のメッセージ。');
await page.getByTestId('create-task-submit').click();
const conversation = page.getByTestId('space-conversation');
await expect(conversation).toBeVisible();
// 2. The single tab bar (role=tablist) is visible and carries 会話 + 概要 but
// NOT a files tab in this bar.
const tabs = conversation.getByTestId('space-chat-tabs');
await expect(tabs).toBeVisible();
await expect(tabs).toHaveAttribute('role', 'tablist');
const chatTab = page.getByTestId('space-chat-tab-chat');
const overviewTab = page.getByTestId('space-chat-tab-overview');
await expect(chatTab).toBeVisible();
await expect(overviewTab).toBeVisible();
// No files tab inside this in-place bar (files are a space-level concern).
await expect(tabs.getByTestId('space-chat-tab-files')).toHaveCount(0);
// 会話 is the default-selected tab; the message composer (textarea) is present.
await expect(chatTab).toHaveAttribute('aria-selected', 'true');
await expect(conversation.locator('textarea')).toBeVisible();
// 3. Click 概要 → its content renders IN PLACE. There is NO overlay and NO ✕
// close button (headerless DetailPanel ⇒ no detail-panel-close testid), and
// the overview tab becomes aria-selected. The conversation composer is gone.
await overviewTab.click();
await expect(overviewTab).toHaveAttribute('aria-selected', 'true');
await expect(chatTab).toHaveAttribute('aria-selected', 'false');
// In place: the shared tabpanel (role=tabpanel) is still mounted inside the
// same conversation container — not a separate overlay.
await expect(conversation.getByRole('tabpanel')).toBeVisible();
// No overlay close affordance anywhere on the page.
await expect(page.getByTestId('detail-panel-close')).toHaveCount(0);
// Composer is swapped out (overview content replaced the conversation in place).
await expect(conversation.locator('textarea')).toHaveCount(0);
// 4. Click 会話 → back to the conversation; composer returns, chat selected.
await chatTab.click();
await expect(chatTab).toHaveAttribute('aria-selected', 'true');
await expect(overviewTab).toHaveAttribute('aria-selected', 'false');
await expect(conversation.locator('textarea')).toBeVisible();
// 5. No navigation to Tasks throughout: page=spaces + chat= held the entire time.
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
await expect(page).toHaveURL(/[?&]chat=\d+/);
expect(page.url()).toContain('chat=');
expect(page.url()).not.toContain('page=tasks');
await expect(detail).toBeVisible();
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// Space 設定 tab (feat/space-folder-settings). The SpaceDetail now carries a
// third tab "設定" (space-tab-settings) that renders SpaceSettings: a sub-nav
// (space-settings-nav-{agents,memory,pieces,skills,mcp,ssh}) + the matching
// panel. The file-based panels (agents/memory/pieces/skills) are space-scoped —
// their API calls carry ?spaceId=<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('create-space-submit').click();
await expect(titleInput).toHaveCount(0);
const caseRow = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: caseTitle });
await expect(caseRow).toBeVisible();
await caseRow.click();
const detail = page.getByTestId('space-detail');
await expect(detail).toBeVisible();
// 2. The 設定 tab is present; clicking it shows the settings sub-nav.
const settingsTab = page.getByTestId('space-tab-settings');
await expect(settingsTab).toBeVisible();
await settingsTab.click();
await expect(page.getByTestId('space-settings-nav-agents')).toBeVisible();
await expect(page.getByTestId('space-settings-nav-memory')).toBeVisible();
await expect(page.getByTestId('space-settings-nav-pieces')).toBeVisible();
await expect(page.getByTestId('space-settings-nav-skills')).toBeVisible();
await expect(page.getByTestId('space-settings-nav-mcp')).toBeVisible();
await expect(page.getByTestId('space-settings-nav-ssh')).toBeVisible();
await expect(page.getByTestId('space-settings-nav-browser')).toBeVisible();
// 3. AGENTS.md panel (default-selected section). The Monaco editor mounts;
// type unique text into it and Save. A fixed unique marker lets the reload
// assertion below be exact and locale-independent.
// assertion below be exact and locale-independent.
await page.getByTestId('space-settings-nav-agents').click();
const editor = page.getByTestId('monaco-file-editor');
await expect(editor).toBeVisible();
// Monaco loads its editor lazily (@monaco-editor/react shows a loading state
// first). Wait for the editor surface, then drive its hidden textarea: click
// to focus, then type. The single short line lands in the (always-rendered)
// .view-lines so we can assert it back after a reload.
const marker = 'AGENTS_SPACE_PERSIST_42';
const monacoSurface = editor.locator('.monaco-editor');
await expect(monacoSurface).toBeVisible({ timeout: 20_000 });
// The editing line (.view-lines) must be rendered before we focus + type.
await expect(editor.locator('.view-lines')).toBeVisible({ timeout: 20_000 });
await monacoSurface.click();
// Replace the entire buffer with our unique marker (select-all → type). This
// is build-agnostic — it doesn't depend on Monaco's internal textarea class.
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.type(`案件専用の指示 ${marker}`);
// The typed marker shows up in the rendered Monaco view.
await expect(editor.locator('.view-lines')).toContainText(marker, { timeout: 10_000 });
// The Save button (data-testid="monaco-save") enables once dirty; click it and
// wait for the PUT to the space-scoped agents-md endpoint to succeed (200).
const saveBtn = page.getByTestId('monaco-save');
await expect(saveBtn).toBeEnabled();
const savePromise = page.waitForResponse(
(r) => /\/api\/users\/me\/agents-md\?/.test(r.url()) && r.request().method() === 'PUT',
);
await saveBtn.click();
const saveRes = await savePromise;
expect(saveRes.status()).toBe(200);
// The save URL must carry this space's id — that is what makes it per-space.
expect(new URL(saveRes.url()).searchParams.get('spaceId')).toBeTruthy();
// 4. Reload the page, re-open the same space → 設定 → AGENTS.md, and assert the
// editor re-hydrates with the saved text (proves server-side persistence
// under the space folder; a fresh GET re-fetches ?spaceId=...).
await page.reload();
await page.getByTestId('nav-spaces').click();
await expect(rail).toBeVisible();
const caseRowAfter = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: caseTitle });
await expect(caseRowAfter).toBeVisible();
await caseRowAfter.click();
await expect(detail).toBeVisible();
await page.getByTestId('space-tab-settings').click();
await page.getByTestId('space-settings-nav-agents').click();
const editorAfter = page.getByTestId('monaco-file-editor');
await expect(editorAfter).toBeVisible();
// The persisted marker shows up in the rendered Monaco view.
await expect(editorAfter.locator('.view-lines')).toContainText(marker, { timeout: 15_000 });
// 5. MCP / SSH now render their own per-space panels when selected (the old
// "not space-scoped" disclaimer is gone). Light assertion — the space-scoped
// panel container mounts for each. (Isolation is proven by the dedicated test
// below.)
await page.getByTestId('space-settings-nav-mcp').click();
await expect(page.getByTestId('space-mcp-panel')).toBeVisible();
await page.getByTestId('space-settings-nav-ssh').click();
await expect(page.getByTestId('space-ssh-panel')).toBeVisible();
// 6. No-nav invariant: settings is in-place, never leaves Spaces for Tasks.
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
await expect(detail).toBeVisible();
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// User Folder tab retirement (chore/remove-userfolder-tab). The top-level
// "User Folder" nav tab was removed: everything it hosted (AGENTS.md / memory /
// pieces / skills / MCP / SSH / browser) is now reachable from the personal
// workspace's 設定 tab (asserted by the settings test above), and the one orphan
// — Pets — moved into global Settings as its own section. This test proves the
// removal didn't strand anything: (1) no User Folder tab in the TopBar nav, and
// (2) the Pets settings section is present and selectable in global Settings.
// No LLM needed; this is pure navigation/structure.
test('userfolder tab removed: gone from nav, Pets reachable in Settings', async ({ page }) => {
await page.goto('/ui');
// 1. The top-level User Folder nav tab no longer exists (testid nav-userfolder
// was dropped along with the page id).
await expect(page.getByTestId('nav-userfolder')).toHaveCount(0);
await expect(page.getByText('User Folder', { exact: true })).toHaveCount(0);
// 2. Settings still opens, and the Pets section (relocated here) is present in
// the sidebar and selectable. Pets is a user-scoped section, so it shows
// even with auth disabled (synthetic local user).
await page.getByTestId('nav-settings').click();
await expect(page).toHaveURL(/[?&]page=settings(&|$)/);
const petsNav = page.getByTestId('settings-nav-pets');
await expect(petsNav).toBeVisible();
await petsNav.click();
// The relocated Pets panel renders (PetsForm carries data-testid="pets-form").
await expect(page.getByTestId('pets-form')).toBeVisible();
});
// Per-space MCP isolation (feat/space-mcp-ssh-isolation). MCP servers are
// DB-backed but scoped to a space: POST /api/mcp/user-servers accepts space_id,
// and GET /api/mcp/user-servers?spaceId=<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 page.getByTestId('space-tab-settings').click();
await page.getByTestId('space-settings-nav-mcp').click();
// The space-scoped MCP panel mounts and its list carries our per-space item.
await expect(page.getByTestId('space-mcp-panel')).toBeVisible();
const listAUi = page.getByTestId('space-mcp-list');
await expect(listAUi).toBeVisible({ timeout: 15_000 });
await expect(
listAUi.locator(`[data-testid="space-mcp-item"][data-server-id="${mcpId}"]`),
).toBeVisible({ timeout: 15_000 });
await expect(listAUi).toContainText(mcpName);
// 5. THE KEY ASSERTION — open space B → 設定 → MCP. The server registered in A
// must NOT appear here. Space B has no per-space MCP servers, so its list
// wrapper isn't rendered (empty-state); either way our item must be absent.
const rowB = rail.locator(`[data-testid="space-row"][data-space-id="${spaceB.id}"]`);
await expect(rowB).toBeVisible();
await rowB.click();
await expect(detail).toBeVisible();
await page.getByTestId('space-tab-settings').click();
await page.getByTestId('space-settings-nav-mcp').click();
// The space-B MCP panel renders, but our server is nowhere in it.
await expect(page.getByTestId('space-mcp-panel')).toBeVisible();
await expect(
page.locator(`[data-testid="space-mcp-item"][data-server-id="${mcpId}"]`),
).toHaveCount(0);
await expect(page.getByTestId('space-mcp-panel')).not.toContainText(mcpName);
// No-nav invariant + no fatal client errors.
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// ════════════════════════════════════════════════════════════════════════════
// Space task/settings feature parity (feat/space-feature-parity-tests).
//
// Phase 1 added a per-chat action toolbar (data-testid="space-chat-actions") to
// the inline SpaceConversation, with delete / share / continue controls reusing
// the SAME implementation as the Tasks-page DetailHeader. These
// tests prove each action works INSIDE a space without ever bouncing to the
// Tasks page, plus the 概要 (overview) feedback flow and every 設定 sub-tab.
//
// No LLM is configured in E2E, so a queued job fails fast and settles terminal
// (waitForTerminalJob), which is exactly what unlocks Continue + Overview
// feedback. Throughout we assert page=spaces is held (the no-nav invariant the
// user cares about) and no fatal client errors leak.
// ════════════════════════════════════════════════════════════════════════════
// 1. DELETE — the user-reported gap. The space-chat toolbar exposes a delete
// button (data-testid="space-chat-delete"); clicking it pops a confirm()
// dialog, and on accept the chat is removed and the conversation returns to
// the empty list state (the chat row disappears). This guards the exact bug
// the user hit: a missing delete button in space chats.
test('space chat DELETE: toolbar delete removes the chat and returns to the list', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await createAndOpenCaseSpace(page, 'E2E削除');
const { conversation, taskId } = await startInlineChat(
page,
'E2E: 削除テスト用チャット。最初のメッセージ。',
);
// The per-chat action toolbar and its delete button are present.
const actions = conversation.getByTestId('space-chat-actions');
await expect(actions).toBeVisible();
const deleteBtn = conversation.getByTestId('space-chat-delete');
await expect(deleteBtn).toBeVisible();
// The chat row for this task exists in the space's chat list before delete.
const chatRow = page.locator(`[data-testid="space-chat-row"][data-task-id="${taskId}"]`);
await expect(chatRow).toBeVisible();
// Accept the confirm() dialog that guards the destructive delete.
page.once('dialog', (dialog) => {
expect(dialog.type()).toBe('confirm');
void dialog.accept();
});
await deleteBtn.click();
// The chat disappears from the list AND the inline conversation closes
// (SpaceConversation calls onBack(0) on delete → spaceTaskId null → no
// conversation). The chat= URL param is dropped.
await expect(chatRow).toHaveCount(0, { timeout: 15_000 });
await expect(conversation).toHaveCount(0);
await expect.poll(() => new URL(page.url()).searchParams.get('chat')).toBeFalsy();
// No-nav invariant: deletion stayed on Spaces, never bounced to Tasks.
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// 2. CANCEL — start a chat (which queues a job) and cancel it from the ChatPane
// stop button, asserting the job lands in the cancelled state.
//
// The stop button only appears while the job is BUSY (running / dispatching /
// waiting_subtasks), and POST /cancel only acts on a running job (it 404s on a
// still-queued job). Reaching `running` requires the worker to actually pick
// up the job, which needs a reachable LLM. The E2E env configures NO provider,
// so the job sits `queued` and the stop button never appears — we skip the
// active assertion in that case (clearly reported) and run it for real only
// when an LLM IS reachable. Either way we first prove the conversation UI and
// no-nav invariant hold.
test('space chat CANCEL: ChatPane stop button cancels a running task', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await createAndOpenCaseSpace(page, 'E2Eキャンセル');
const { conversation, taskId } = await startInlineChat(
page,
'E2E: キャンセルテスト。長めの作業を想定した最初のメッセージ。',
);
// The ChatPane stop button shows while the job is busy. Label is i18n
// (English in the E2E browser locale, Japanese otherwise), so match either.
const stopBtn = conversation.getByRole('button', { name: /^(Stop|停止)$/ });
const becameBusy = await stopBtn
.waitFor({ state: 'visible', timeout: 20_000 })
.then(() => true)
.catch(() => false);
test.skip(
!becameBusy,
'no LLM reachable: the worker never runs the job, so it stays queued and the ' +
'ChatPane stop control never appears — cancel needs a running job to act on',
);
// Cancel from the ChatPane, then assert the job is cancelled (terminal).
await stopBtn.click();
const status = await waitForTerminalJob(page, taskId, 20_000);
expect(status).toBe('cancelled');
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// 3. SHARE — the toolbar share button (data-testid="space-chat-share", reusing
// ShareButton) publishes a share token. Clicking it issues POST
// /api/local/tasks/:id/share and writes the link to the clipboard; the button
// swaps to copy/stop affordances. Clipboard isn't observable headless, so we
// assert persistence the robust way: the task's shareToken becomes set
// server-side (poll the detail API) and the toolbar re-renders with the copy
// control (aria-label = "共有リンクをコピー").
test('space chat SHARE: toolbar share publishes a share token', async ({ page, context }) => {
// ShareButton's onSuccess copies the link to the clipboard; headless Chromium
// denies clipboard writes by default, which would surface as a fatal pageerror.
// Grant the permission so the (benign) copy succeeds and the share flow itself
// is what we measure. Best-effort: some channels don't expose the permission.
await context.grantPermissions(['clipboard-write']).catch(() => {});
const fatalErrors = trackFatalErrors(page);
await createAndOpenCaseSpace(page, 'E2E共有');
const { conversation, taskId } = await startInlineChat(
page,
'E2E: 共有テスト用チャット。最初のメッセージ。',
);
// When unshared, ShareButton puts the testid on the publish <button> itself
// (it switches to a wrapping <div> only once a token exists). So the testid IS
// the publish button here — assert + click it directly.
const share = conversation.getByTestId('space-chat-share');
await expect(share).toBeVisible();
await expect(share).toHaveAttribute('aria-label', /Create public link|公開リンクを発行/);
await share.click();
// The share token is persisted server-side (the source of truth for the link).
await expect
.poll(async () => (await fetchTask(page, taskId))?.shareToken ?? null, { timeout: 15_000 })
.toBeTruthy();
// And the toolbar re-renders into the shared state: a copy-link control now
// exists where the publish button was (aria-label is i18n, match either).
await expect(share.getByRole('button', { name: /Copy share link|共有リンクをコピー/ })).toBeVisible({
timeout: 15_000,
});
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// 4. CONTINUE-WITH-PIECE — the toolbar Continue button (data-testid=
// "space-chat-continue", reusing ContinueButton) 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);
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// 5. VISIBILITY — the toolbar visibility <select> (data-testid="space-chat-visibility")
// drives updateLocalTask({ visibility }). Changing it to "public" persists;
// after a full reload + reselecting the chat, the select retains "public".
// ("org" is intentionally NOT used here: the server rejects org visibility
// (400) unless the user belongs to the scoped org, and the no-auth synthetic
// local user has no orgs — so "public" is the env-appropriate persistence
// proof. The org branch is covered by backend visibility tests.)
test('space chat VISIBILITY: change to public persists across reload', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
const { detail, spaceId } = await createAndOpenCaseSpace(page, 'E2E可視性');
const { conversation, taskId } = await startInlineChat(
page,
'E2E: 可視性テスト用チャット。最初のメッセージ。',
);
const visibility = conversation.getByTestId('space-chat-visibility');
await expect(visibility).toBeVisible();
await expect(visibility).toHaveValue('private');
// Wait for the PATCH so we don't assert before the server commits the change.
// (updateLocalTask uses PATCH /api/local/tasks/:id.)
const patchPromise = page.waitForResponse(
(r) => new RegExp(`/api/local/tasks/${taskId}$`).test(r.url()) && r.request().method() === 'PATCH',
);
await visibility.selectOption('public');
const patchRes = await patchPromise;
expect(patchRes.status()).toBe(200);
await expect(visibility).toHaveValue('public');
// Reload, reopen the same space + chat via URL state (space= selects the space,
// chat= opens the inline conversation), and assert the select retained public
// (proves server-side persistence, not just local component state).
await page.goto(`/ui?page=spaces&space=${spaceId}&chat=${taskId}`);
await expect(detail).toBeVisible();
const visibilityAfter = page.getByTestId('space-conversation').getByTestId('space-chat-visibility');
await expect(visibilityAfter).toBeVisible({ timeout: 15_000 });
await expect(visibilityAfter).toHaveValue('public', { timeout: 15_000 });
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
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: switch to 概要 → OverviewTab renders in place. The
// composer (textarea) is swapped out and the shared tabpanel is mounted.
const overviewTab = conversation.getByTestId('space-chat-tab-overview');
await expect(overviewTab).toBeVisible();
await overviewTab.click();
await expect(overviewTab).toHaveAttribute('aria-selected', 'true');
const panel = conversation.getByRole('tabpanel');
await expect(panel).toBeVisible();
await expect(conversation.locator('textarea')).toHaveCount(0);
// 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');
await conversationAfter.getByTestId('space-chat-tab-overview').click();
await expect(
conversationAfter.getByRole('tabpanel').getByRole('button', { name: /^(Change|変更)$/ }),
).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
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 page.getByTestId('space-tab-settings').click();
// 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.getByText(/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 literal (non-i18n) strings; match the built-in piece list.
await page.getByTestId('space-settings-nav-pieces').click();
await expect(page.getByText('Default', { exact: true })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Custom', { exact: true })).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 <h2>Skills</h2> heading is the panel-mounted marker).
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 });
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
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 page.getByTestId('space-tab-settings').click();
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 page.getByTestId('space-tab-settings').click();
await page.getByTestId('space-settings-nav-memory').click();
await expect(page.getByText(/Memory entries|メモリエントリ/)).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(memName, { exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
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}`;
}
test('space calendar: tab renders month grid + month nav', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await createAndOpenCaseSpace(page, 'E2Eカレンダー表示');
await page.getByTestId('space-tab-calendar').click();
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!);
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
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 page.getByTestId('space-tab-calendar').click();
await expect(page.getByTestId('space-cal-grid')).toBeVisible();
const today = e2eLocalToday();
const todayCell = page.getByTestId(`space-cal-day-${today}`);
// Badge 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();
// The day cell event badge increments to 📌1 after the month query invalidates.
await expect(todayCell.getByText('📌1')).toBeVisible({ timeout: 10_000 });
// Persists across a full reload (server-backed, not just client state).
await page.reload();
await page.getByTestId('space-tab-calendar').click();
await expect(page.getByTestId(`space-cal-day-${today}`).getByText('📌1')).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 page.getByTestId('space-tab-calendar').click();
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);
// Delete: confirm the window.confirm() then assert the row disappears + badge clears.
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 });
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 page.getByTestId('space-tab-calendar').click();
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+/);
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
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 page.getByTestId('space-tab-settings').click();
// メンバー 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();
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
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);
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
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);
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
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 page.getByTestId('space-tab-settings').click();
// 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('このワークスペースにはまだ録画がありません');
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
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.
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
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 inline rename editor, change the title, save.
await page.getByTestId('space-rename').click();
const input = page.getByTestId('space-rename-input');
await expect(input).toBeVisible();
const renamed = `${caseTitle}-改`;
await input.fill(renamed);
await page.getByTestId('space-rename-save').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.
await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
expect(page.url()).not.toContain('page=tasks');
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 page.getByTestId('space-tab-apps').click();
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 page.getByTestId('space-tab-apps').click();
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([]);
});