2080 lines
102 KiB
TypeScript
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 / visibility
|
|
// 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([]);
|
|
});
|