2327 lines
117 KiB
TypeScript
2327 lines
117 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
||
import Database from 'better-sqlite3';
|
||
import { resolve } from 'node:path';
|
||
import { openSpaceTab } from './helpers/spaceTabs';
|
||
|
||
// ── Shared helpers for the space task/settings parity suite ───────────────────
|
||
//
|
||
// The tests below all start the same way: open Spaces, create a case space, open
|
||
// it, and (for chat actions) start an inline chat. The first batch of tests in
|
||
// this file inlines that flow; the feature-parity tests added later reuse these
|
||
// helpers to stay focused on the action under test. The helpers mirror the
|
||
// inlined flow exactly (same testids, same waits) — they do NOT relax any
|
||
// assertion, they just remove copy-paste.
|
||
|
||
// The no-auth + live-session probe allowlist shared by the chat/settings tests.
|
||
// Identical set to the inline-chat/settings tests above: with auth disabled the
|
||
// UI's /api/auth/me probe 404s on purpose and falls back to the synthetic local
|
||
// user; CreateTaskDialog's org/MCP probes 401; the per-task console/browser
|
||
// status probes 404 with no live session. All benign, not failures.
|
||
const SPACE_BENIGN_PATHS = new Set([
|
||
'/api/auth/me',
|
||
'/api/users/me/orgs',
|
||
'/api/mcp/connections',
|
||
'/api/mcp/servers',
|
||
'/api/mcp/user-servers',
|
||
'/api/ssh/connections',
|
||
]);
|
||
const SPACE_BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
|
||
const SPACE_BENIGN_PATH_RE =
|
||
/\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
|
||
|
||
/** Attach the standard fatal-error tracker used by every space test and return
|
||
* the collected list (assert it stays empty at the end of the test). */
|
||
function trackFatalErrors(page: Page): string[] {
|
||
const fatalErrors: string[] = [];
|
||
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
|
||
page.on('response', (res) => {
|
||
if (res.status() >= 400) {
|
||
const { pathname } = new URL(res.url());
|
||
if (
|
||
!SPACE_BENIGN_ASSET.test(pathname) &&
|
||
!SPACE_BENIGN_PATHS.has(pathname) &&
|
||
!SPACE_BENIGN_PATH_RE.test(pathname)
|
||
) {
|
||
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
|
||
}
|
||
}
|
||
});
|
||
return fatalErrors;
|
||
}
|
||
|
||
/** Open Spaces, create a case space with a unique title, select it, and return
|
||
* the detail locator + the title used. Mirrors the inlined create flow. */
|
||
async function createAndOpenCaseSpace(page: Page, label: string) {
|
||
await page.goto('/ui');
|
||
await page.getByTestId('nav-spaces').click();
|
||
const rail = page.getByTestId('space-rail');
|
||
await expect(rail).toBeVisible();
|
||
|
||
await page.getByTestId('create-space-btn').click();
|
||
const titleInput = page.getByTestId('space-title-input');
|
||
await expect(titleInput).toBeVisible();
|
||
const caseTitle = `${label}-${Date.now()}`;
|
||
await titleInput.fill(caseTitle);
|
||
await page.getByTestId('space-form-submit').click();
|
||
await expect(titleInput).toHaveCount(0);
|
||
|
||
const caseRow = rail
|
||
.locator('[data-testid="space-row"][data-space-kind="case"]')
|
||
.filter({ hasText: caseTitle });
|
||
await expect(caseRow).toBeVisible();
|
||
const spaceId = await caseRow.getAttribute('data-space-id');
|
||
expect(spaceId).toBeTruthy();
|
||
await caseRow.click();
|
||
|
||
const detail = page.getByTestId('space-detail');
|
||
await expect(detail).toBeVisible();
|
||
return { detail, caseTitle, rail, spaceId: spaceId as string };
|
||
}
|
||
|
||
/** Start a new inline chat in the currently-open space, returning the inline
|
||
* conversation locator and the created task id (from the chat= URL param). */
|
||
async function startInlineChat(page: Page, body: string) {
|
||
await page.getByTestId('space-tab-chat').click();
|
||
await page.getByTestId('space-new-chat-btn').click();
|
||
const composer = page.getByTestId('create-task-body');
|
||
await expect(composer).toBeVisible();
|
||
await composer.fill(body);
|
||
await page.getByTestId('create-task-submit').click();
|
||
|
||
const conversation = page.getByTestId('space-conversation');
|
||
await expect(conversation).toBeVisible();
|
||
await expect(page).toHaveURL(/[?&]chat=\d+/);
|
||
const taskId = new URL(page.url()).searchParams.get('chat');
|
||
expect(taskId).toBeTruthy();
|
||
return { conversation, taskId: taskId as string };
|
||
}
|
||
|
||
type TaskDetail = {
|
||
task?: {
|
||
latestJob?: { id?: string; status?: string } | null;
|
||
feedbackRating?: string | null;
|
||
visibility?: string | null;
|
||
};
|
||
};
|
||
|
||
/** Fetch the task detail. NOTE the API wraps the row under `.task`. */
|
||
async function fetchTask(page: Page, taskId: string): Promise<TaskDetail['task'] | null> {
|
||
const res = await page.request.get(`/api/local/tasks/${taskId}`);
|
||
if (!res.ok()) return null;
|
||
return ((await res.json()) as TaskDetail).task ?? null;
|
||
}
|
||
|
||
const TERMINAL_JOB_STATES = ['succeeded', 'failed', 'waiting_human', 'cancelled', 'aborted'];
|
||
|
||
/** Poll the task detail until the latest job reaches a terminal state, OR the
|
||
* window expires. Returns the terminal status, or '' if the job never settled.
|
||
*
|
||
* ENV NOTE: a terminal job requires the worker to actually run the job, which
|
||
* needs a reachable LLM. The E2E env configures NO provider, so the worker
|
||
* stays unhealthy and the job sits `queued` indefinitely — it never reaches a
|
||
* terminal state here. Tests that need the terminal branch (Continue / Feedback)
|
||
* therefore skip when this returns '' (documented at each call site), and run
|
||
* the full active assertion only when a real LLM IS reachable. */
|
||
async function waitForTerminalJob(page: Page, taskId: string, timeout = 30_000): Promise<string> {
|
||
const deadline = Date.now() + timeout;
|
||
while (Date.now() < deadline) {
|
||
const task = await fetchTask(page, taskId);
|
||
const status = task?.latestJob?.status ?? '';
|
||
if (TERMINAL_JOB_STATES.includes(status)) return status;
|
||
await page.waitForTimeout(1500);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// End-to-end smoke for the spaces-foundation UI (plan 4, Task 5).
|
||
//
|
||
// Drives the real orchestrator (auth off => synthetic 'local' user). The
|
||
// personal space is auto-created on the first /api/local/spaces call, so it is
|
||
// always present in a fresh temp DB. We then create a case space and open it.
|
||
//
|
||
// Selectors use data-testid added to the spaces components rather than brittle
|
||
// Japanese label text, but the visible labels stay Japanese.
|
||
|
||
test('spaces foundation: rail, create case space, open detail tabs', async ({ page }) => {
|
||
// Track only genuinely fatal client errors. Uncaught JS exceptions
|
||
// (pageerror) are always fatal. Generic console "Failed to load resource"
|
||
// messages carry no URL, so we resolve the real culprit from the matching
|
||
// HTTP response and ignore benign static-asset 404s (favicon, source maps,
|
||
// PWA manifest) that don't break the flow.
|
||
const fatalErrors: string[] = [];
|
||
const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
|
||
// When auth is disabled (our default E2E config), /api/auth/me is NOT mounted,
|
||
// so the UI's probe 404s on purpose and falls back to the synthetic local
|
||
// user. That 404 is the expected no-auth signal, not a failure.
|
||
const benignPaths = new Set(['/api/auth/me']);
|
||
|
||
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
|
||
page.on('response', (res) => {
|
||
if (res.status() >= 400) {
|
||
const url = res.url();
|
||
const { pathname } = new URL(url);
|
||
if (!benignAsset.test(pathname) && !benignPaths.has(pathname)) {
|
||
fatalErrors.push(`HTTP ${res.status()} ${url}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 1. Open the app. The server redirects / -> /ui; load /ui directly.
|
||
await page.goto('/ui');
|
||
|
||
// App shell is up — the Spaces nav tab is rendered.
|
||
const navSpaces = page.getByTestId('nav-spaces');
|
||
await expect(navSpaces).toBeVisible();
|
||
|
||
// 2. Navigate to the Spaces tab.
|
||
await navSpaces.click();
|
||
|
||
// 3. The space rail renders, with the auto-created personal space pinned.
|
||
const rail = page.getByTestId('space-rail');
|
||
await expect(rail).toBeVisible();
|
||
|
||
const personalRow = rail.locator('[data-testid="space-row"][data-space-kind="personal"]');
|
||
await expect(personalRow).toHaveCount(1);
|
||
await expect(personalRow).toBeVisible();
|
||
|
||
// 4. Create a new case space via 新規.
|
||
await page.getByTestId('create-space-btn').click();
|
||
|
||
const titleInput = page.getByTestId('space-title-input');
|
||
await expect(titleInput).toBeVisible();
|
||
const caseTitle = `E2E案件-${Date.now()}`;
|
||
await titleInput.fill(caseTitle);
|
||
|
||
await page.getByTestId('space-form-submit').click();
|
||
|
||
// 5. The new case space appears in the rail (and dialog closes).
|
||
await expect(titleInput).toHaveCount(0);
|
||
const caseRow = rail
|
||
.locator('[data-testid="space-row"][data-space-kind="case"]')
|
||
.filter({ hasText: caseTitle });
|
||
await expect(caseRow).toBeVisible();
|
||
|
||
// 6. Selecting it (creation auto-selects it) shows the SpaceDetail with both tabs.
|
||
await caseRow.click();
|
||
const detail = page.getByTestId('space-detail');
|
||
await expect(detail).toBeVisible();
|
||
await expect(detail.getByText(caseTitle)).toBeVisible();
|
||
|
||
await expect(page.getByTestId('space-tab-chat')).toBeVisible();
|
||
await expect(page.getByTestId('space-tab-files')).toBeVisible();
|
||
|
||
// 7. The files tab opens without a fatal error (empty tree is fine).
|
||
await page.getByTestId('space-tab-files').click();
|
||
await expect(page.getByTestId('space-files')).toBeVisible();
|
||
|
||
// No fatal client errors (uncaught exceptions / non-asset HTTP failures).
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// Mobile (<md) layout: the rail and detail must NOT sit side-by-side on a
|
||
// phone. Startup auto-opens the personal workspace (App.tsx's one-shot
|
||
// personalSpaceAutoOpened guard), so detail is the initial full-width view;
|
||
// backing out returns to the full-width rail (the guard does not re-fire) and
|
||
// selecting another space replaces the rail with detail plus a
|
||
// "← ワークスペース一覧" back control that returns to the rail. At >=md both
|
||
// are visible together (unchanged), so this invariant only holds at narrow
|
||
// widths.
|
||
test('spaces mobile: rail <-> detail switch with back control at 390px', async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
|
||
// At phone widths the nav collapses into a drawer (both shell layouts do
|
||
// this), so the nav-spaces control isn't directly clickable. Navigate via
|
||
// the URL state (page=spaces) instead — it's the same selection, sans drawer.
|
||
await page.goto('/ui?page=spaces');
|
||
|
||
// Startup auto-opens the personal workspace: detail is the visible
|
||
// full-width view, rail is hidden, and the back control is already present.
|
||
const rail = page.getByTestId('space-rail');
|
||
const detail = page.getByTestId('space-detail');
|
||
const back = page.getByTestId('space-back-to-list');
|
||
await expect(detail).toBeVisible();
|
||
await expect(rail).toBeHidden();
|
||
await expect(back).toBeVisible();
|
||
|
||
// Tapping back clears the selection and returns to the full-width rail. The
|
||
// auto-open guard is one-shot, so it does not immediately re-select.
|
||
await back.click();
|
||
await expect(rail).toBeVisible();
|
||
await expect(detail).toBeHidden();
|
||
await expect(page.getByTestId('space-back-to-list')).toHaveCount(0);
|
||
|
||
// Create + auto-select a case space.
|
||
await page.getByTestId('create-space-btn').click();
|
||
const titleInput = page.getByTestId('space-title-input');
|
||
await expect(titleInput).toBeVisible();
|
||
const caseTitle = `E2Eモバイル-${Date.now()}`;
|
||
await titleInput.fill(caseTitle);
|
||
await page.getByTestId('space-form-submit').click();
|
||
await expect(titleInput).toHaveCount(0);
|
||
|
||
// A space is open: detail is now the visible full-width view, rail is hidden,
|
||
// and the mobile back control is present.
|
||
await expect(detail).toBeVisible();
|
||
await expect(detail.getByText(caseTitle)).toBeVisible();
|
||
await expect(rail).toBeHidden();
|
||
await expect(back).toBeVisible();
|
||
|
||
// Tapping back clears the selection and returns to the full-width rail.
|
||
await back.click();
|
||
await expect(rail).toBeVisible();
|
||
await expect(detail).toBeHidden();
|
||
await expect(page.getByTestId('space-back-to-list')).toHaveCount(0);
|
||
});
|
||
|
||
// Inline conversation inside a space (plan 6). The user starts a chat from the
|
||
// space's チャット tab and the conversation (ChatPane: messages + composer)
|
||
// renders INLINE — without ever navigating to the Tasks page. No LLM is
|
||
// configured in the E2E env, so the agent never completes; we only assert the
|
||
// conversation UI is present and the no-navigation invariant holds.
|
||
test('space inline chat: create + open conversation without leaving for Tasks', async ({ page }) => {
|
||
const fatalErrors: string[] = [];
|
||
const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
|
||
// With auth disabled, these probes 401 on purpose (the CreateTaskDialog
|
||
// fetches orgs + MCP connections which require an authenticated user). The
|
||
// UI tolerates the 401 and falls back to empty lists, so they are the
|
||
// expected no-auth signal — not a failure.
|
||
const benignPaths = new Set(['/api/auth/me', '/api/users/me/orgs', '/api/mcp/connections']);
|
||
// useVisibleDetailTabs (now used by the inline conversation's detail tabs)
|
||
// polls per-task console/browser-session status to decide whether to show the
|
||
// live SSH/Browser tabs. With no live session these 404 by design and the
|
||
// hook degrades to "tab hidden" — benign, like the no-auth probes above.
|
||
const benignPathRe = /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
|
||
|
||
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
|
||
page.on('response', (res) => {
|
||
if (res.status() >= 400) {
|
||
const { pathname } = new URL(res.url());
|
||
if (!benignAsset.test(pathname) && !benignPaths.has(pathname) && !benignPathRe.test(pathname)) {
|
||
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 1. Open the app, go to Spaces, create a case space.
|
||
await page.goto('/ui');
|
||
await page.getByTestId('nav-spaces').click();
|
||
|
||
const rail = page.getByTestId('space-rail');
|
||
await expect(rail).toBeVisible();
|
||
|
||
await page.getByTestId('create-space-btn').click();
|
||
const titleInput = page.getByTestId('space-title-input');
|
||
await expect(titleInput).toBeVisible();
|
||
const caseTitle = `E2E会話-${Date.now()}`;
|
||
await titleInput.fill(caseTitle);
|
||
await page.getByTestId('space-form-submit').click();
|
||
await expect(titleInput).toHaveCount(0);
|
||
|
||
const caseRow = rail
|
||
.locator('[data-testid="space-row"][data-space-kind="case"]')
|
||
.filter({ hasText: caseTitle });
|
||
await expect(caseRow).toBeVisible();
|
||
await caseRow.click();
|
||
|
||
const detail = page.getByTestId('space-detail');
|
||
await expect(detail).toBeVisible();
|
||
|
||
// 2. On the チャット tab, start a new chat.
|
||
await page.getByTestId('space-tab-chat').click();
|
||
await page.getByTestId('space-new-chat-btn').click();
|
||
|
||
const body = page.getByTestId('create-task-body');
|
||
await expect(body).toBeVisible();
|
||
await body.fill('E2E: inline conversation smoke. これはテスト用の最初のメッセージです。');
|
||
await page.getByTestId('create-task-submit').click();
|
||
|
||
// 3. The inline conversation pane (ChatPane) appears inside the space.
|
||
// No LLM => the agent won't progress, but the conversation UI renders.
|
||
const conversation = page.getByTestId('space-conversation');
|
||
await expect(conversation).toBeVisible();
|
||
|
||
// 4. No navigation to the Tasks page. The app stays on the Spaces page
|
||
// (urlState serializes page=spaces) and sets the chat= param for the
|
||
// selected inline chat. It must NOT switch to page=tasks (which would
|
||
// drop page= entirely AND have no chat=).
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
await expect(page).toHaveURL(/[?&]chat=\d+/);
|
||
expect(page.url()).toContain('chat=');
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
// The Spaces detail (not the Tasks board) is still mounted.
|
||
await expect(detail).toBeVisible();
|
||
|
||
// 4b. The inline conversation exposes per-task detail tabs via the icon rail
|
||
// (data-testid="space-chat-rail" — since #1433 step 5 the rail is rendered
|
||
// at EVERY width, including narrow phone viewports; the horizontal tab
|
||
// bar and the swipe deck it belonged to were removed) so Progress is visible
|
||
// WITHOUT leaving for Tasks. Clicking 進捗 (Progress = the `activity` tab)
|
||
// swaps the ProgressTab content IN PLACE (no overlay/✕); pressing 進捗 a
|
||
// second time folds that column away and the conversation takes the row
|
||
// back — all while staying on page=spaces with the same chat= param.
|
||
// (A dedicated test below covers the rail structure.)
|
||
// Entry state: a brand-new chat has nothing remembered, so the rail starts
|
||
// on 概要, opened (RAIL_DEFAULT since #1455 — it used to be 会話).
|
||
const overviewTab = conversation.getByTestId('space-chat-tab-overview');
|
||
await expect(overviewTab).toHaveAttribute('aria-selected', 'true');
|
||
const progressTab = conversation.getByTestId('space-chat-tab-activity');
|
||
await expect(progressTab).toBeVisible();
|
||
await progressTab.click();
|
||
// In-place ProgressTab content renders inside the shared tabpanel (no Tasks nav).
|
||
await expect(conversation.getByTestId('progress-tab')).toBeVisible();
|
||
await expect(progressTab).toHaveAttribute('aria-selected', 'true');
|
||
// In place, not on top of the conversation: the chat column keeps its own
|
||
// space while 進捗 shows, so progress can be read while typing (#1330).
|
||
await expect(page.getByTestId('space-chat-column')).toBeVisible();
|
||
// Invariant: still inline on Spaces, same chat, never bounced to Tasks.
|
||
await expect(page).toHaveURL(/[?&]chat=\d+/);
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
// Return to the conversation so the rest of the flow (creating a second chat)
|
||
// starts from chat again. On a wide screen there is no 会話 item to press
|
||
// (#1455 — the conversation keeps its own column, so an icon for it would be
|
||
// the same thing twice); the way back is to press the item that is already
|
||
// picked, which folds its column away (chatRailState.ts nextRailState) and
|
||
// hands the width back to the conversation.
|
||
await progressTab.click();
|
||
await expect(progressTab).toHaveAttribute('aria-selected', 'false');
|
||
await expect(conversation.getByTestId('progress-tab')).toHaveCount(0);
|
||
await expect(page.getByTestId('space-chat-column')).toBeVisible();
|
||
|
||
// 5. Create a SECOND chat in the same space and assert the conversation
|
||
// switches when selecting the first one again from the list.
|
||
const firstChatUrl = new URL(page.url());
|
||
const firstChatId = firstChatUrl.searchParams.get('chat');
|
||
expect(firstChatId).toBeTruthy();
|
||
|
||
await page.getByTestId('space-new-chat-btn').click();
|
||
const body2 = page.getByTestId('create-task-body');
|
||
await expect(body2).toBeVisible();
|
||
await body2.fill('E2E: second inline chat. 二つ目のチャットです。');
|
||
await page.getByTestId('create-task-submit').click();
|
||
|
||
// The second chat auto-selects: chat= now points at a different id.
|
||
await expect
|
||
.poll(() => new URL(page.url()).searchParams.get('chat'))
|
||
.not.toBe(firstChatId);
|
||
await expect(conversation).toBeVisible();
|
||
const secondChatId = new URL(page.url()).searchParams.get('chat');
|
||
|
||
// Selecting the first chat row again switches the conversation back to it.
|
||
const firstRow = page.locator(`[data-testid="space-chat-row"][data-task-id="${firstChatId}"]`);
|
||
await expect(firstRow).toBeVisible();
|
||
await firstRow.click();
|
||
await expect
|
||
.poll(() => new URL(page.url()).searchParams.get('chat'))
|
||
.toBe(firstChatId);
|
||
expect(secondChatId).not.toBe(firstChatId);
|
||
await expect(conversation).toBeVisible();
|
||
// Still on Spaces, still has a chat — never bounced to Tasks.
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// Redesigned space Files tab (feat/space-files-explorer). The space-level ファイル
|
||
// tab is now an icon grid (space-files-grid) with a breadcrumb, an upload button +
|
||
// hidden <input type=file multiple>, and a dropzone. Uploading a file makes a
|
||
// space-file-tile appear; clicking a file tile opens the file in a FLOATING
|
||
// WINDOW (role="dialog", data-frame="float") showing the content — #1551 stage 2
|
||
// routed this tab through the same window funnel as chat, so it is no longer a
|
||
// covering modal. Directory tiles navigate (breadcrumb updates) — covered here
|
||
// only by the no-nav invariant (we stay on page=spaces).
|
||
// No LLM is needed; this is pure file-management UI, so we never assert agent output.
|
||
test('space files: icon grid, upload appears as tile, click opens preview window', async ({ page }) => {
|
||
const fatalErrors: string[] = [];
|
||
const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
|
||
// Same no-auth probe allowlist as the other space tests: with auth disabled the
|
||
// UI's /api/auth/me probe 404s on purpose and falls back to the synthetic local
|
||
// user — the expected no-auth signal, not a failure.
|
||
const benignPaths = new Set(['/api/auth/me']);
|
||
|
||
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
|
||
page.on('response', (res) => {
|
||
if (res.status() >= 400) {
|
||
const { pathname } = new URL(res.url());
|
||
if (!benignAsset.test(pathname) && !benignPaths.has(pathname)) {
|
||
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 1. Spaces → create a case space → open the space-level ファイル tab.
|
||
await page.goto('/ui');
|
||
await page.getByTestId('nav-spaces').click();
|
||
|
||
const rail = page.getByTestId('space-rail');
|
||
await expect(rail).toBeVisible();
|
||
|
||
await page.getByTestId('create-space-btn').click();
|
||
const titleInput = page.getByTestId('space-title-input');
|
||
await expect(titleInput).toBeVisible();
|
||
const caseTitle = `E2Eファイル-${Date.now()}`;
|
||
await titleInput.fill(caseTitle);
|
||
await page.getByTestId('space-form-submit').click();
|
||
await expect(titleInput).toHaveCount(0);
|
||
|
||
const caseRow = rail
|
||
.locator('[data-testid="space-row"][data-space-kind="case"]')
|
||
.filter({ hasText: caseTitle });
|
||
await expect(caseRow).toBeVisible();
|
||
await caseRow.click();
|
||
|
||
const detail = page.getByTestId('space-detail');
|
||
await expect(detail).toBeVisible();
|
||
|
||
await page.getByTestId('space-tab-files').click();
|
||
|
||
// 2. The grid + breadcrumb render (empty tree is fine).
|
||
await expect(page.getByTestId('space-files-grid')).toBeVisible();
|
||
await expect(page.getByTestId('space-files-breadcrumb')).toBeVisible();
|
||
|
||
// 3. Upload a LARGE (>150kb) file through the hidden multiple-file input.
|
||
// This guards the P1 regression where a global express.json() (~100kb)
|
||
// shadowed the upload route's 50mb parser and 413'd any body >~100kb.
|
||
// A tiny buffer passed even with the bug; 160kb only passes after the fix.
|
||
await page
|
||
.locator('[data-testid="space-files-upload-input"]')
|
||
.setInputFiles({
|
||
name: 'e2e-large.txt',
|
||
mimeType: 'text/plain',
|
||
buffer: Buffer.alloc(160 * 1024, 'a'),
|
||
});
|
||
|
||
// 4. The large file shows up as a tile (upload + refetch take a moment).
|
||
const largeTile = page.locator(
|
||
'[data-testid="space-file-tile"][data-kind="file"][data-name="e2e-large.txt"]',
|
||
);
|
||
await expect(largeTile).toBeVisible({ timeout: 15000 });
|
||
|
||
// 5. Upload a separate TINY markdown file so the modal-preview assertion stays
|
||
// cheap and locale-independent.
|
||
await page
|
||
.locator('[data-testid="space-files-upload-input"]')
|
||
.setInputFiles({
|
||
name: 'e2e-upload.md',
|
||
mimeType: 'text/markdown',
|
||
buffer: Buffer.from('# E2E\nhello'),
|
||
});
|
||
const tile = page.locator(
|
||
'[data-testid="space-file-tile"][data-kind="file"][data-name="e2e-upload.md"]',
|
||
);
|
||
await expect(tile).toBeVisible({ timeout: 15000 });
|
||
|
||
// 6. Clicking the tile opens the file in a FLOATING WINDOW (role="dialog",
|
||
// data-frame="float") showing the markdown content.
|
||
//
|
||
// This used to be a covering modal closed by clicking its backdrop. #1551
|
||
// stage 2 routed this tab through the same window funnel as chat, and the
|
||
// default open mode is a window (readStoredFilePreviewOpenMode() -> 'float').
|
||
// A floating window has NO backdrop on purpose: the whole point is that the
|
||
// grid behind it stays live, so clicking outside must NOT close it. We assert
|
||
// that promise here (the grid is still visible) and close with the ✕, which
|
||
// now carries a testid so this stays locale-independent.
|
||
//
|
||
// The covering shape is still reachable (Settings -> "open files in a
|
||
// window" off) and closing it by backdrop click is pinned in jsdom by
|
||
// FilePreview.backdrop.dom.test.tsx and
|
||
// SpaceDetail.spaceFileClose.dom.test.tsx.
|
||
await tile.click();
|
||
const dialog = page.getByRole('dialog');
|
||
await expect(dialog).toBeVisible();
|
||
await expect(dialog).toContainText('hello');
|
||
await expect(dialog).toHaveAttribute('data-frame', 'float');
|
||
// The list is not covered — this is what stage 2 was for.
|
||
await expect(page.getByTestId('space-files-grid')).toBeVisible();
|
||
await expect(tile).toBeVisible();
|
||
await dialog.getByTestId('file-preview-close').click();
|
||
await expect(dialog).toHaveCount(0);
|
||
|
||
// 6. No-nav invariant: file management never leaves Spaces for the Tasks board.
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
await expect(detail).toBeVisible();
|
||
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// #1448: the worker/GPU view moved out of the Spaces rail into a floating window
|
||
// opened from the icon at the bottom of the left nav rail. Two things are pinned
|
||
// here: the old rail footer is gone, and the new launcher opens a window that
|
||
// survives a click outside it (closing is the × only). No LLM is needed — the
|
||
// widget reads /api/local/dashboard/workers, populated in worker mode.
|
||
test('worker status: nav rail launcher opens a floating window', async ({ page }) => {
|
||
// 1. Open the app at the default (desktop) viewport and go to Spaces.
|
||
await page.goto('/ui');
|
||
await page.getByTestId('nav-spaces').click();
|
||
|
||
const rail = page.getByTestId('space-rail');
|
||
await expect(rail).toBeVisible();
|
||
|
||
// 2. The old rail-bottom footer is gone; the launcher lives in the nav rail.
|
||
await expect(page.getByTestId('space-worker-status')).toHaveCount(0);
|
||
const launcher = page.getByTestId('worker-status-launcher');
|
||
await expect(launcher).toBeVisible();
|
||
await expect(launcher).toHaveAttribute('aria-expanded', 'false');
|
||
await expect(page.getByTestId('worker-status-window')).toHaveCount(0);
|
||
|
||
// 3. Clicking it opens the window; clicking outside must NOT close it.
|
||
await launcher.click();
|
||
await expect(launcher).toHaveAttribute('aria-expanded', 'true');
|
||
const win = page.getByTestId('worker-status-window');
|
||
await expect(win).toBeVisible();
|
||
await rail.click({ position: { x: 10, y: 10 } });
|
||
await expect(win).toBeVisible();
|
||
|
||
// 4. The × closes it.
|
||
await page.getByTestId('worker-status-window-close').click();
|
||
await expect(page.getByTestId('worker-status-window')).toHaveCount(0);
|
||
});
|
||
|
||
// Narrow viewport: the nav rail is folded into the NavDrawer, so the rail
|
||
// launcher is not rendered at all. The same entry must exist in the drawer,
|
||
// and — the part jsdom cannot check — the window must land on-screen after the
|
||
// drawer slides away. The drawer panel is moved with `transform`, which makes it
|
||
// the containing block for any `position: fixed` descendant; if the window were
|
||
// rendered inside the drawer it would ride off-screen with it.
|
||
test('worker status: drawer launcher opens the same window on a narrow screen', async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.goto('/ui');
|
||
|
||
// The rail (and its launcher) is gone at this width.
|
||
await expect(page.getByTestId('worker-status-launcher')).toHaveCount(0);
|
||
|
||
await page.locator('button[aria-controls="nav-drawer"]').first().click();
|
||
const drawer = page.locator('#nav-drawer');
|
||
await expect(drawer).toHaveAttribute('aria-hidden', 'false');
|
||
|
||
await page.getByTestId('worker-status-launcher-drawer').click();
|
||
|
||
// The drawer closes, the window stays.
|
||
await expect(drawer).toHaveAttribute('aria-hidden', 'true');
|
||
const win = page.getByTestId('worker-status-window');
|
||
await expect(win).toBeVisible();
|
||
|
||
const box = await win.boundingBox();
|
||
expect(box).not.toBeNull();
|
||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
|
||
|
||
await page.getByTestId('worker-status-window-close').click();
|
||
await expect(page.getByTestId('worker-status-window')).toHaveCount(0);
|
||
});
|
||
|
||
// AAO Gateway / LiteLLM front: a `proxy: true` worker fans out into a backends[]
|
||
// tree (collectWorkerStatuses, unit-tested separately). This test pins the *UI*
|
||
// half: given a proxy worker payload, the widget renders the proxy badge and one
|
||
// row per backend. The /api/local/dashboard/workers response is stubbed so the
|
||
// test is deterministic without standing up a real gateway + probe registry.
|
||
test('worker status: proxy worker renders per-backend rows (AAO Gateway)', async ({ page }) => {
|
||
await page.route('**/api/local/dashboard/workers', (route) =>
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({
|
||
workers: [
|
||
{
|
||
id: 'gw', name: 'gw', roles: ['task'], state: 'running', proxy: true,
|
||
backends: [
|
||
{ id: 'backend-a', state: 'running', busySlots: 1, totalSlots: 4, online: true },
|
||
{ id: 'backend-b', state: 'idle', busySlots: 0, totalSlots: 4, online: true },
|
||
],
|
||
},
|
||
],
|
||
}),
|
||
}),
|
||
);
|
||
|
||
await page.goto('/ui');
|
||
await page.getByTestId('nav-spaces').click();
|
||
await expect(page.getByTestId('space-rail')).toBeVisible();
|
||
|
||
// Open the floating window; the WorkerStatusWidget mounts and fetches the stub.
|
||
await page.getByTestId('worker-status-launcher').click();
|
||
await expect(page.getByTestId('worker-status-window')).toBeVisible();
|
||
|
||
// The proxy worker row + its proxy badge render.
|
||
await expect(page.getByTestId('worker-row-gw')).toBeVisible();
|
||
await expect(page.getByTestId('worker-proxy-badge')).toBeVisible();
|
||
|
||
// 細部は畳んだ状態で出る(#1486)。開くまでバックエンドの行は出てこない。
|
||
await expect(page.getByTestId('worker-backend-backend-a')).toHaveCount(0);
|
||
await expect(page.getByTestId('worker-toggle-gw')).toHaveAttribute('aria-expanded', 'false');
|
||
|
||
// 三角を押すと Gateway のバックエンドが 1 行ずつ並ぶ。
|
||
await page.getByTestId('worker-toggle-gw').click();
|
||
await expect(page.getByTestId('worker-backend-backend-a')).toBeVisible();
|
||
await expect(page.getByTestId('worker-backend-backend-b')).toBeVisible();
|
||
await expect(page.getByTestId('worker-backend-backend-a')).toContainText('1/4');
|
||
});
|
||
|
||
// Feature A (feat/space-gpu-and-filelinks): the inline conversation now wraps its
|
||
// chat in an OutputPreviewProvider, so clicking a rendered output-path link
|
||
// (<a class="output-path-link" data-output-path="output/...">) opens the FilePreview
|
||
// modal (role="dialog") instead of doing nothing. To produce such a link
|
||
// deterministically WITHOUT an LLM, we post a user comment whose markdown body
|
||
// contains an output-path link; MarkdownText linkifies it (markdown link whose href
|
||
// matches OUTPUT_PATH_REGEX => data-output-path anchor). We also create the file
|
||
// itself (output/note.md, output-only PUT /files/content) so the .md preview resolves
|
||
// to content rather than a 404 (previewLocalFile fetches content for markdown and
|
||
// only opens the modal on success). We assert the dialog opens, then close it; we
|
||
// never assert agent output and we never leave page=spaces.
|
||
test('space output link: chat output-path link opens a floating window without moving the screen', async ({ page }) => {
|
||
const fatalErrors: string[] = [];
|
||
const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
|
||
const benignPaths = new Set(['/api/auth/me', '/api/users/me/orgs', '/api/mcp/connections']);
|
||
const benignPathRe = /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
|
||
|
||
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
|
||
page.on('response', (res) => {
|
||
if (res.status() >= 400) {
|
||
const { pathname } = new URL(res.url());
|
||
if (!benignAsset.test(pathname) && !benignPaths.has(pathname) && !benignPathRe.test(pathname)) {
|
||
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 1. Spaces → create a case space → start an inline chat (same flow as above).
|
||
await page.goto('/ui');
|
||
await page.getByTestId('nav-spaces').click();
|
||
|
||
const rail = page.getByTestId('space-rail');
|
||
await expect(rail).toBeVisible();
|
||
|
||
await page.getByTestId('create-space-btn').click();
|
||
const titleInput = page.getByTestId('space-title-input');
|
||
await expect(titleInput).toBeVisible();
|
||
const caseTitle = `E2Eリンク-${Date.now()}`;
|
||
await titleInput.fill(caseTitle);
|
||
await page.getByTestId('space-form-submit').click();
|
||
await expect(titleInput).toHaveCount(0);
|
||
|
||
const caseRow = rail
|
||
.locator('[data-testid="space-row"][data-space-kind="case"]')
|
||
.filter({ hasText: caseTitle });
|
||
await expect(caseRow).toBeVisible();
|
||
await caseRow.click();
|
||
|
||
const detail = page.getByTestId('space-detail');
|
||
await expect(detail).toBeVisible();
|
||
|
||
await page.getByTestId('space-tab-chat').click();
|
||
await page.getByTestId('space-new-chat-btn').click();
|
||
const body = page.getByTestId('create-task-body');
|
||
await expect(body).toBeVisible();
|
||
await body.fill('E2E: 出力リンクのプレビュー確認。最初のメッセージ。');
|
||
await page.getByTestId('create-task-submit').click();
|
||
|
||
const conversation = page.getByTestId('space-conversation');
|
||
await expect(conversation).toBeVisible();
|
||
|
||
// 2. Capture the created task id from the chat= URL param.
|
||
await expect(page).toHaveURL(/[?&]chat=\d+/);
|
||
const taskId = new URL(page.url()).searchParams.get('chat');
|
||
expect(taskId).toBeTruthy();
|
||
|
||
// 3. Create output/note.md via the output-only inline-edit endpoint so the
|
||
// markdown preview resolves to content. A freshly created chat may have a
|
||
// queued/running job (which locks the editor with 409); with no LLM the job
|
||
// settles quickly, so poll the PUT until it succeeds.
|
||
await expect.poll(async () => {
|
||
const res = await page.request.put(`/api/local/tasks/${taskId}/files/content`, {
|
||
data: { section: 'output', path: 'note.md', content: '# E2E note\nhello from output' },
|
||
});
|
||
return res.status();
|
||
}, { timeout: 30_000 }).toBe(200);
|
||
|
||
// 4. Post a user comment whose markdown body carries an output-path link. The
|
||
// link href (output/note.md) matches OUTPUT_PATH_REGEX, so MarkdownText emits
|
||
// <a class="output-path-link" data-output-path="output/note.md">.
|
||
const commentRes = await page.request.post(`/api/local/tasks/${taskId}/comments`, {
|
||
data: { body: '結果はこちら [note.md](output/note.md) です', author: 'user' },
|
||
});
|
||
expect(commentRes.ok()).toBeTruthy();
|
||
|
||
// 5. The conversation polls comments; the output-path link appears. Click it.
|
||
const link = conversation.locator('a.output-path-link[data-output-path="output/note.md"]');
|
||
await expect(link).toBeVisible({ timeout: 15_000 });
|
||
await link.click();
|
||
|
||
// 6. The file opens IN A FLOATING WINDOW, and the screen underneath does not
|
||
// move (#1551 step 1). This is the whole point of the change: the reader
|
||
// was on some other screen (Delegate, progress, the file list) and must
|
||
// still be on it after the click. So the rail keeps whatever it had — it
|
||
// must NOT gain a file item and must NOT switch to it, which is what the
|
||
// pane route used to do.
|
||
const floatFrame = page.locator('[data-testid="file-preview-frame"][data-frame="float"]');
|
||
await expect(floatFrame).toBeVisible();
|
||
await expect(floatFrame).toContainText('hello from output');
|
||
// The window names itself a dialog for screen readers but does NOT trap them:
|
||
// the conversation and the file list behind it stay reachable.
|
||
await expect(floatFrame).toHaveAttribute('aria-modal', 'false');
|
||
await expect(conversation.getByTestId('space-chat-tab-file')).toHaveCount(0);
|
||
// Nothing landed in the column either — the pane route is not a silent
|
||
// fallback here.
|
||
await expect(page.locator('[data-testid="file-preview-frame"][data-frame="pane"]')).toHaveCount(0);
|
||
|
||
// Close via the frame's ✕, matched by testid. Its aria-label differs between the
|
||
// floating frame (files:close) and the pane (files:closeAria), and both are
|
||
// translated — the old name regex only matched the ja floating label.
|
||
await floatFrame.getByTestId('file-preview-close').click();
|
||
await expect(page.getByTestId('file-preview-frame')).toHaveCount(0);
|
||
// The rail never gained a file item, so there is none to drop.
|
||
await expect(conversation.getByTestId('space-chat-tab-file')).toHaveCount(0);
|
||
|
||
// 7. No-nav invariant: clicking the link previewed in place, never bounced to Tasks.
|
||
await expect(page).toHaveURL(/[?&]chat=\d+/);
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
await expect(detail).toBeVisible();
|
||
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// Redesigned space-chat UX (issue #1330; the open file joined the rail in #1433
|
||
// step 4; 会話 left the rail on wide screens in #1455). The line-up lives in a
|
||
// VERTICAL ICON RAIL (data-testid="space-chat-rail", role=tablist,
|
||
// aria-orientation=vertical) on the far left: 概要 (overview) + 進捗 (activity) +
|
||
// ファイル (files) + トレース (trace) [+ ブラウザ/SSH while a live session lasts]
|
||
// [+ the OPEN FILE while one is open] [+ 会話 (chat) ONLY below 768px].
|
||
// Each button is icon-only and therefore carries its name in aria-label + title —
|
||
// for the open file that name is the file name itself. This chat-level ファイル
|
||
// item is distinct from the space-level files (SpaceDetail.tsx comment: it shows
|
||
// the chat's own input/output/logs under runs/{taskId}, not the shared space
|
||
// deliverables).
|
||
// THIS TEST RUNS AT THE DEFAULT DESKTOP VIEWPORT (1280px), so 会話 must NOT be in
|
||
// the rail: the conversation already owns its own column at this width, and an
|
||
// icon for it would put the same thing on screen twice (#1455). The narrow-screen
|
||
// half — 会話 IS in the rail below 768px — is pinned by the test right after this
|
||
// one; both halves are needed, otherwise dropping the width check would go
|
||
// unnoticed in one direction.
|
||
// The horizontal bar (data-testid="space-chat-tabs") is GONE (#1433 step 5): the
|
||
// rail owns every switch at every width, so the conversation exposes exactly one
|
||
// tablist and the bar is never rendered.
|
||
// SpaceDetail lays the conversation out in columns (#1330):
|
||
// rail / chat / whatever the rail points at (a detail screen OR the open file —
|
||
// the two are mutually exclusive). The chat column is always rendered and never
|
||
// unmounts, so the composer keeps its draft and stays usable while 概要 or 進捗 is
|
||
// on screen. Throughout, we never leave for the Tasks page (space= + chat= stay
|
||
// put; no page= param). No LLM is configured in E2E, so we assert UI/structure
|
||
// only, never agent output.
|
||
test('space chat rail: no 会話 item on a wide screen, overview open by default, fold gives the row back to the conversation', async ({ page }) => {
|
||
const fatalErrors: string[] = [];
|
||
const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
|
||
// Same no-auth + live-session probe allowlist as the inline-chat test above.
|
||
const benignPaths = new Set(['/api/auth/me', '/api/users/me/orgs', '/api/mcp/connections']);
|
||
const benignPathRe = /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
|
||
|
||
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
|
||
page.on('response', (res) => {
|
||
if (res.status() >= 400) {
|
||
const { pathname } = new URL(res.url());
|
||
if (!benignAsset.test(pathname) && !benignPaths.has(pathname) && !benignPathRe.test(pathname)) {
|
||
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 1. Spaces → create a case space → start an inline chat (same flow as above).
|
||
await page.goto('/ui');
|
||
await page.getByTestId('nav-spaces').click();
|
||
|
||
const rail = page.getByTestId('space-rail');
|
||
await expect(rail).toBeVisible();
|
||
|
||
await page.getByTestId('create-space-btn').click();
|
||
const titleInput = page.getByTestId('space-title-input');
|
||
await expect(titleInput).toBeVisible();
|
||
const caseTitle = `E2Eタブ-${Date.now()}`;
|
||
await titleInput.fill(caseTitle);
|
||
await page.getByTestId('space-form-submit').click();
|
||
await expect(titleInput).toHaveCount(0);
|
||
|
||
const caseRow = rail
|
||
.locator('[data-testid="space-row"][data-space-kind="case"]')
|
||
.filter({ hasText: caseTitle });
|
||
await expect(caseRow).toBeVisible();
|
||
await caseRow.click();
|
||
|
||
const detail = page.getByTestId('space-detail');
|
||
await expect(detail).toBeVisible();
|
||
|
||
await page.getByTestId('space-tab-chat').click();
|
||
await page.getByTestId('space-new-chat-btn').click();
|
||
const body = page.getByTestId('create-task-body');
|
||
await expect(body).toBeVisible();
|
||
await body.fill('E2E: 単一タブの会話。インプレース切替を確認する最初のメッセージ。');
|
||
await page.getByTestId('create-task-submit').click();
|
||
|
||
const conversation = page.getByTestId('space-conversation');
|
||
await expect(conversation).toBeVisible();
|
||
|
||
// 2. The icon rail (role=tablist, vertical) is visible and carries 概要 — but
|
||
// NOT 会話, which this width does not line up (#1455).
|
||
const railTabs = conversation.getByTestId('space-chat-rail');
|
||
await expect(railTabs).toBeVisible();
|
||
await expect(railTabs).toHaveAttribute('role', 'tablist');
|
||
await expect(railTabs).toHaveAttribute('aria-orientation', 'vertical');
|
||
const overviewTab = conversation.getByTestId('space-chat-tab-overview');
|
||
await expect(overviewTab).toBeVisible();
|
||
// 会話 is not an item here: the conversation has its own column at this width,
|
||
// so an icon for it would be the same thing on screen twice (#1455). Assert on
|
||
// the whole page, not just the rail — if a second rail ever appears, a 会話
|
||
// button anywhere would still be the duplicate this pins down.
|
||
await expect(page.getByTestId('space-chat-tab-chat')).toHaveCount(0);
|
||
// The chat-level ファイル item is present too (per-chat I/O/logs, distinct
|
||
// from the space-level files tab).
|
||
await expect(railTabs.getByTestId('space-chat-tab-files')).toBeVisible();
|
||
// Icon-only controls must still be reachable by name (screen readers). 概要 is
|
||
// the one to check now — it is in the line-up at every width.
|
||
await expect(railTabs.getByRole('tab', { name: /^(概要|Overview)$/ })).toBeVisible();
|
||
// The horizontal bar was removed in #1433 step 5; it is never rendered.
|
||
await expect(conversation.getByTestId('space-chat-tabs')).toHaveCount(0);
|
||
// No file is open, so the rail carries no file item either.
|
||
await expect(conversation.getByTestId('space-chat-tab-file')).toHaveCount(0);
|
||
// Exactly one tablist owns the shared tabpanel — the rail.
|
||
await expect(conversation.getByRole('tablist')).toHaveCount(1);
|
||
// 概要 is the default-selected item on a fresh chat (RAIL_DEFAULT since #1455 —
|
||
// it used to be 会話, which is no longer in the line-up here). Nothing is
|
||
// clicked to get here: this is how the conversation opens.
|
||
await expect(overviewTab).toHaveAttribute('aria-selected', 'true');
|
||
await expect(conversation.locator('textarea')).toBeVisible();
|
||
|
||
// 3. 概要 opens in its own column beside the conversation, never as an overlay
|
||
// on top of it.
|
||
// In place: the shared tabpanel (role=tabpanel) is mounted inside the same
|
||
// conversation container — not a separate overlay.
|
||
await expect(conversation.getByRole('tabpanel')).toBeVisible();
|
||
// The right column really is on screen (the split that holds it only mounts
|
||
// while something is in it — SpaceDetail's rightColumnsMounted).
|
||
await expect(conversation.getByTestId('chat-detail-split')).toBeVisible();
|
||
// The conversation keeps its own column: the composer is not merely mounted,
|
||
// it is on screen and writable while 概要 shows. That is the whole point of
|
||
// the column layout (#1330) — watch the progress, type the next instruction,
|
||
// without switching back and forth. THIS is what this test is here to pin.
|
||
await expect(page.getByTestId('space-chat-column')).toBeVisible();
|
||
const composer = conversation.locator('textarea');
|
||
await expect(composer).toHaveCount(1);
|
||
await expect(composer).toBeVisible();
|
||
await composer.fill('4 列: 概要を見ながら書けること');
|
||
await expect(composer).toHaveValue('4 列: 概要を見ながら書けること');
|
||
const chatColumn = page.getByTestId('space-chat-column');
|
||
const sharedWidth = (await chatColumn.boundingBox())!.width;
|
||
|
||
// 4. Back to the conversation. There is no 会話 item to press at this width;
|
||
// the way back is to press the item that is ALREADY picked, which folds its
|
||
// column away (chatRailState.ts nextRailState) and hands the freed width to
|
||
// the conversation. The chat column itself never unmounts, so the draft
|
||
// typed above survives the fold.
|
||
await overviewTab.click();
|
||
await expect(overviewTab).toHaveAttribute('aria-selected', 'false');
|
||
// The right column is gone, not merely hidden.
|
||
await expect(conversation.getByTestId('chat-detail-split')).toHaveCount(0);
|
||
await expect(chatColumn).toBeVisible();
|
||
await expect(composer).toHaveValue('4 列: 概要を見ながら書けること');
|
||
// The conversation actually got the row back — it is wider than while it was
|
||
// sharing with 概要.
|
||
await expect.poll(async () => (await chatColumn.boundingBox())!.width).toBeGreaterThan(sharedWidth);
|
||
|
||
// 5. Pressing 概要 once more opens it again (a fold is remembered, not sticky),
|
||
// and the draft still stands.
|
||
await overviewTab.click();
|
||
await expect(overviewTab).toHaveAttribute('aria-selected', 'true');
|
||
await expect(conversation.getByTestId('chat-detail-split')).toBeVisible();
|
||
await expect(composer).toBeVisible();
|
||
await expect(composer).toHaveValue('4 列: 概要を見ながら書けること');
|
||
|
||
// 6. No navigation to Tasks throughout: page=spaces + chat= held the entire time.
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
await expect(page).toHaveURL(/[?&]chat=\d+/);
|
||
expect(page.url()).toContain('chat=');
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
await expect(detail).toBeVisible();
|
||
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// The other half of #1455: 会話 leaves the rail on a WIDE screen only. Below
|
||
// 768px (useIsMobile) the conversation and whatever the rail points at share one
|
||
// column, so an item to get back to the conversation is the only way back — it
|
||
// must be there. The test above pins "absent when wide"; without this one, a
|
||
// change that drops the 会話 item at every width would sail through green.
|
||
//
|
||
// The whole setup runs at the desktop viewport first (the nav rail folds into
|
||
// the NavDrawer below 768px, which is a different flow and not what is under
|
||
// test here), then the window is narrowed. useIsMobile listens for matchMedia
|
||
// changes, so the item appears without a reload.
|
||
test('space chat rail: 会話 item appears once the window is narrower than 768px', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
|
||
await createAndOpenCaseSpace(page, 'E2E狭幅帯');
|
||
const { conversation } = await startInlineChat(
|
||
page,
|
||
'E2E: 狭い画面で会話のアイコンが出ることを確認する最初のメッセージ。',
|
||
);
|
||
|
||
const railTabs = conversation.getByTestId('space-chat-rail');
|
||
await expect(railTabs).toBeVisible();
|
||
// Wide (the project's 1280px default): no 会話 item.
|
||
await expect(page.getByTestId('space-chat-tab-chat')).toHaveCount(0);
|
||
|
||
// Narrow: it appears. 700px is below the 768px breakpoint and still wide
|
||
// enough that the conversation stays open (no bounce back to the space list).
|
||
await page.setViewportSize({ width: 700, height: 900 });
|
||
const chatTab = conversation.getByTestId('space-chat-tab-chat');
|
||
await expect(chatTab).toBeVisible();
|
||
// Icon-only, so it has to carry its name for screen readers.
|
||
await expect(railTabs.getByRole('tab', { name: /^(会話|Chat)$/ })).toBeVisible();
|
||
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// Space 設定 tab (feat/space-folder-settings). The SpaceDetail now carries a
|
||
// 設定 entry (space-tab-settings, folded into the "その他" menu since issue
|
||
// #1360 stage 1) that renders SpaceSettings: a sub-nav
|
||
// (space-settings-nav-{agents,memory,pieces,skills,mcp,ssh}) + the matching
|
||
// panel. The file-based panels (agents/memory/pieces/skills) are space-scoped —
|
||
// their API calls carry ?spaceId=<id>, so AGENTS.md saved here lands under the
|
||
// space folder, NOT the per-user folder. We prove that by saving unique text via
|
||
// the AGENTS.md editor (Monaco), reloading, and asserting it persisted. MCP/SSH
|
||
// are now ALSO per-space (DB-backed, spec §11): selecting either renders its own
|
||
// space-scoped panel (no more "not space-scoped" disclaimer). No LLM is needed;
|
||
// this is pure settings UI, so we never assert agent output and never leave
|
||
// page=spaces.
|
||
test('space settings tab: AGENTS.md per-space persistence + MCP/SSH panels render', async ({ page }) => {
|
||
const fatalErrors: string[] = [];
|
||
const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
|
||
// Same no-auth probe allowlist as the other space tests. With auth disabled the
|
||
// UI's /api/auth/me probe 404s on purpose; the MCP/SSH panels also probe
|
||
// connection endpoints which 401/404 under no-auth and degrade to empty lists.
|
||
const benignPaths = new Set([
|
||
'/api/auth/me',
|
||
'/api/users/me/orgs',
|
||
'/api/mcp/connections',
|
||
'/api/mcp/servers',
|
||
'/api/mcp/user-servers',
|
||
'/api/ssh/connections',
|
||
]);
|
||
|
||
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
|
||
page.on('response', (res) => {
|
||
if (res.status() >= 400) {
|
||
const { pathname } = new URL(res.url());
|
||
if (!benignAsset.test(pathname) && !benignPaths.has(pathname)) {
|
||
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 1. Spaces → create a case space → open it.
|
||
await page.goto('/ui');
|
||
await page.getByTestId('nav-spaces').click();
|
||
|
||
const rail = page.getByTestId('space-rail');
|
||
await expect(rail).toBeVisible();
|
||
|
||
await page.getByTestId('create-space-btn').click();
|
||
const titleInput = page.getByTestId('space-title-input');
|
||
await expect(titleInput).toBeVisible();
|
||
const caseTitle = `E2E設定-${Date.now()}`;
|
||
await titleInput.fill(caseTitle);
|
||
await page.getByTestId('space-form-submit').click();
|
||
await expect(titleInput).toHaveCount(0);
|
||
|
||
const caseRow = rail
|
||
.locator('[data-testid="space-row"][data-space-kind="case"]')
|
||
.filter({ hasText: caseTitle });
|
||
await expect(caseRow).toBeVisible();
|
||
await caseRow.click();
|
||
|
||
const detail = page.getByTestId('space-detail');
|
||
await expect(detail).toBeVisible();
|
||
|
||
// 2. 設定 は「その他」の中(issue #1360 段 1)。開いて選ぶと sub-nav が出る。
|
||
await openSpaceTab(page, 'settings');
|
||
|
||
await expect(page.getByTestId('space-settings-nav-agents')).toBeVisible();
|
||
await expect(page.getByTestId('space-settings-nav-memory')).toBeVisible();
|
||
await expect(page.getByTestId('space-settings-nav-pieces')).toBeVisible();
|
||
await expect(page.getByTestId('space-settings-nav-skills')).toBeVisible();
|
||
await expect(page.getByTestId('space-settings-nav-mcp')).toBeVisible();
|
||
await expect(page.getByTestId('space-settings-nav-ssh')).toBeVisible();
|
||
await expect(page.getByTestId('space-settings-nav-browser')).toBeVisible();
|
||
|
||
// 3. AGENTS.md panel (default-selected section). The Monaco editor mounts;
|
||
// type unique text into it and Save. A fixed unique marker lets the reload
|
||
// assertion below be exact and locale-independent.
|
||
// assertion below be exact and locale-independent.
|
||
await page.getByTestId('space-settings-nav-agents').click();
|
||
const editor = page.getByTestId('monaco-file-editor');
|
||
await expect(editor).toBeVisible();
|
||
|
||
// Monaco loads its editor lazily (@monaco-editor/react shows a loading state
|
||
// first). Wait for the editor surface, then drive its hidden textarea: click
|
||
// to focus, then type. The single short line lands in the (always-rendered)
|
||
// .view-lines so we can assert it back after a reload.
|
||
const marker = 'AGENTS_SPACE_PERSIST_42';
|
||
const monacoSurface = editor.locator('.monaco-editor');
|
||
await expect(monacoSurface).toBeVisible({ timeout: 20_000 });
|
||
// The editing line (.view-lines) must be rendered before we focus + type.
|
||
await expect(editor.locator('.view-lines')).toBeVisible({ timeout: 20_000 });
|
||
await monacoSurface.click();
|
||
// Replace the entire buffer with our unique marker (select-all → type). This
|
||
// is build-agnostic — it doesn't depend on Monaco's internal textarea class.
|
||
await page.keyboard.press('ControlOrMeta+A');
|
||
await page.keyboard.type(`案件専用の指示 ${marker}`);
|
||
// The typed marker shows up in the rendered Monaco view.
|
||
await expect(editor.locator('.view-lines')).toContainText(marker, { timeout: 10_000 });
|
||
|
||
// The Save button (data-testid="monaco-save") enables once dirty; click it and
|
||
// wait for the PUT to the space-scoped agents-md endpoint to succeed (200).
|
||
const saveBtn = page.getByTestId('monaco-save');
|
||
await expect(saveBtn).toBeEnabled();
|
||
const savePromise = page.waitForResponse(
|
||
(r) => /\/api\/users\/me\/agents-md\?/.test(r.url()) && r.request().method() === 'PUT',
|
||
);
|
||
await saveBtn.click();
|
||
const saveRes = await savePromise;
|
||
expect(saveRes.status()).toBe(200);
|
||
// The save URL must carry this space's id — that is what makes it per-space.
|
||
expect(new URL(saveRes.url()).searchParams.get('spaceId')).toBeTruthy();
|
||
|
||
// 4. Reload the page, re-open the same space → 設定 → AGENTS.md, and assert the
|
||
// editor re-hydrates with the saved text (proves server-side persistence
|
||
// under the space folder; a fresh GET re-fetches ?spaceId=...).
|
||
await page.reload();
|
||
await page.getByTestId('nav-spaces').click();
|
||
await expect(rail).toBeVisible();
|
||
const caseRowAfter = rail
|
||
.locator('[data-testid="space-row"][data-space-kind="case"]')
|
||
.filter({ hasText: caseTitle });
|
||
await expect(caseRowAfter).toBeVisible();
|
||
await caseRowAfter.click();
|
||
await expect(detail).toBeVisible();
|
||
await openSpaceTab(page, 'settings');
|
||
await page.getByTestId('space-settings-nav-agents').click();
|
||
|
||
const editorAfter = page.getByTestId('monaco-file-editor');
|
||
await expect(editorAfter).toBeVisible();
|
||
// The persisted marker shows up in the rendered Monaco view.
|
||
await expect(editorAfter.locator('.view-lines')).toContainText(marker, { timeout: 15_000 });
|
||
|
||
// 5. MCP / SSH now render their own per-space panels when selected (the old
|
||
// "not space-scoped" disclaimer is gone). Light assertion — the space-scoped
|
||
// panel container mounts for each. (Isolation is proven by the dedicated test
|
||
// below.)
|
||
await page.getByTestId('space-settings-nav-mcp').click();
|
||
await expect(page.getByTestId('space-mcp-panel')).toBeVisible();
|
||
await page.getByTestId('space-settings-nav-ssh').click();
|
||
await expect(page.getByTestId('space-ssh-panel')).toBeVisible();
|
||
|
||
// 6. No-nav invariant: settings is in-place, never leaves Spaces for Tasks.
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
await expect(detail).toBeVisible();
|
||
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// User Folder tab retirement (chore/remove-userfolder-tab). The top-level
|
||
// "User Folder" nav tab was removed: everything it hosted (AGENTS.md / memory /
|
||
// pieces / skills / MCP / SSH / browser) is now reachable from the personal
|
||
// workspace's 設定 tab (asserted by the settings test above), and the one orphan
|
||
// — Pets — moved into global Settings as its own section. This test proves the
|
||
// removal didn't strand anything: (1) no User Folder tab in the TopBar nav, and
|
||
// (2) the Pets settings section is present and selectable in global Settings.
|
||
// No LLM needed; this is pure navigation/structure.
|
||
test('userfolder tab removed: gone from nav, Pets reachable in Settings', async ({ page }) => {
|
||
await page.goto('/ui');
|
||
|
||
// 1. The top-level User Folder nav tab no longer exists (testid nav-userfolder
|
||
// was dropped along with the page id).
|
||
await expect(page.getByTestId('nav-userfolder')).toHaveCount(0);
|
||
await expect(page.getByText('User Folder', { exact: true })).toHaveCount(0);
|
||
|
||
// 2. Settings still opens, and the Pets section (relocated here) is present in
|
||
// the sidebar and selectable. Pets is a user-scoped section, so it shows
|
||
// even with auth disabled (synthetic local user).
|
||
await page.getByTestId('nav-settings').click();
|
||
await expect(page).toHaveURL(/[?&]page=settings(&|$)/);
|
||
|
||
const petsNav = page.getByTestId('settings-nav-pets');
|
||
await expect(petsNav).toBeVisible();
|
||
await petsNav.click();
|
||
// The relocated Pets panel renders (PetsForm carries data-testid="pets-form").
|
||
await expect(page.getByTestId('pets-form')).toBeVisible();
|
||
});
|
||
|
||
// Per-space MCP isolation (feat/space-mcp-ssh-isolation). MCP servers are
|
||
// DB-backed but scoped to a space: POST /api/mcp/user-servers accepts space_id,
|
||
// and GET /api/mcp/user-servers?spaceId=<id> returns ONLY that space's servers.
|
||
// The 設定 → MCP panel lists those (data-testid="space-mcp-list", each row a
|
||
// "space-mcp-item" carrying data-server-id / data-server-name).
|
||
//
|
||
// MCP is the deterministic choice over SSH here: registering an api_key server
|
||
// needs no OAuth dance and no private key, and the MCP subsystem is mounted in
|
||
// E2E via a fixed MCP_ENCRYPTION_KEY (see playwright.config.ts). The unreachable
|
||
// server URL only defers tool discovery (caught server-side), so register still
|
||
// returns 200. We then prove isolation TWO ways: (a) via the API — the server
|
||
// shows under ?spaceId=A but NOT ?spaceId=B; and (b) via the UI — it appears in
|
||
// space A's MCP list but is absent from space B's. No LLM is configured in E2E,
|
||
// so we assert structure only, never agent output.
|
||
test('space MCP isolation: server registered in A is visible in A, absent in B', async ({ page }) => {
|
||
const fatalErrors: string[] = [];
|
||
const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
|
||
// Same no-auth probe allowlist as the settings test. With auth disabled the
|
||
// /api/auth/me probe 404s on purpose; the MCP panel also probes the admin
|
||
// /api/mcp/servers + /api/mcp/connections endpoints which degrade to empty
|
||
// lists under no-auth, and SSH (mounted alongside in the sub-nav) 404s since
|
||
// its subsystem stays off — all benign, not failures.
|
||
const benignPaths = new Set([
|
||
'/api/auth/me',
|
||
'/api/users/me/orgs',
|
||
'/api/mcp/connections',
|
||
'/api/mcp/servers',
|
||
'/api/ssh/connections',
|
||
]);
|
||
|
||
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
|
||
page.on('response', (res) => {
|
||
if (res.status() >= 400) {
|
||
const { pathname } = new URL(res.url());
|
||
if (!benignAsset.test(pathname) && !benignPaths.has(pathname)) {
|
||
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 1. Create TWO case spaces (A and B) via the same API the create dialog uses.
|
||
// POST returns 201 with the full space row, so we capture each id.
|
||
const stamp = Date.now();
|
||
const resA = await page.request.post('/api/local/spaces', { data: { title: `E2E-MCP-A-${stamp}` } });
|
||
expect(resA.status()).toBe(201);
|
||
const spaceA = (await resA.json()) as { id: string };
|
||
expect(spaceA.id).toBeTruthy();
|
||
|
||
const resB = await page.request.post('/api/local/spaces', { data: { title: `E2E-MCP-B-${stamp}` } });
|
||
expect(resB.status()).toBe(201);
|
||
const spaceB = (await resB.json()) as { id: string };
|
||
expect(spaceB.id).toBeTruthy();
|
||
expect(spaceB.id).not.toBe(spaceA.id);
|
||
|
||
// 2. Register an api_key MCP server INTO space A via the UI's register endpoint
|
||
// (POST /api/mcp/user-servers with space_id). api_key avoids the OAuth flow;
|
||
// the unreachable URL only defers tool discovery (caught server-side), so
|
||
// the register still returns ok.
|
||
const mcpId = `e2e-mcp-${stamp}`;
|
||
const mcpName = `spaceA-mcp-${stamp}`;
|
||
const reg = await page.request.post('/api/mcp/user-servers', {
|
||
data: {
|
||
id: mcpId,
|
||
name: mcpName,
|
||
url: 'https://example.invalid/mcp',
|
||
authKind: 'api_key',
|
||
staticToken: 'e2e-static-token',
|
||
space_id: spaceA.id,
|
||
},
|
||
});
|
||
expect(reg.ok(), `register failed: ${reg.status()} ${await reg.text()}`).toBeTruthy();
|
||
|
||
// 3. API-level isolation: the server appears under ?spaceId=A but NOT ?spaceId=B.
|
||
const listA = (await (await page.request.get(
|
||
`/api/mcp/user-servers?spaceId=${encodeURIComponent(spaceA.id)}`,
|
||
)).json()) as { servers: Array<{ id: string; name: string }> };
|
||
expect(listA.servers.some((s) => s.id === mcpId)).toBeTruthy();
|
||
|
||
const listB = (await (await page.request.get(
|
||
`/api/mcp/user-servers?spaceId=${encodeURIComponent(spaceB.id)}`,
|
||
)).json()) as { servers: Array<{ id: string; name: string }> };
|
||
expect(listB.servers.some((s) => s.id === mcpId)).toBeFalsy();
|
||
|
||
// 4. UI: open Spaces, select space A by its id, go to 設定 → MCP. The space MCP
|
||
// list renders and contains our server (proves the panel reads ?spaceId=A).
|
||
await page.goto('/ui');
|
||
await page.getByTestId('nav-spaces').click();
|
||
const rail = page.getByTestId('space-rail');
|
||
await expect(rail).toBeVisible();
|
||
|
||
const rowA = rail.locator(`[data-testid="space-row"][data-space-id="${spaceA.id}"]`);
|
||
await expect(rowA).toBeVisible();
|
||
await rowA.click();
|
||
|
||
const detail = page.getByTestId('space-detail');
|
||
await expect(detail).toBeVisible();
|
||
await openSpaceTab(page, 'settings');
|
||
await page.getByTestId('space-settings-nav-mcp').click();
|
||
|
||
// The space-scoped MCP panel mounts and its list carries our per-space item.
|
||
await expect(page.getByTestId('space-mcp-panel')).toBeVisible();
|
||
const listAUi = page.getByTestId('space-mcp-list');
|
||
await expect(listAUi).toBeVisible({ timeout: 15_000 });
|
||
await expect(
|
||
listAUi.locator(`[data-testid="space-mcp-item"][data-server-id="${mcpId}"]`),
|
||
).toBeVisible({ timeout: 15_000 });
|
||
await expect(listAUi).toContainText(mcpName);
|
||
|
||
// 5. THE KEY ASSERTION — open space B → 設定 → MCP. The server registered in A
|
||
// must NOT appear here. Space B has no per-space MCP servers, so its list
|
||
// wrapper isn't rendered (empty-state); either way our item must be absent.
|
||
const rowB = rail.locator(`[data-testid="space-row"][data-space-id="${spaceB.id}"]`);
|
||
await expect(rowB).toBeVisible();
|
||
await rowB.click();
|
||
await expect(detail).toBeVisible();
|
||
await openSpaceTab(page, 'settings');
|
||
await page.getByTestId('space-settings-nav-mcp').click();
|
||
|
||
// The space-B MCP panel renders, but our server is nowhere in it.
|
||
await expect(page.getByTestId('space-mcp-panel')).toBeVisible();
|
||
await expect(
|
||
page.locator(`[data-testid="space-mcp-item"][data-server-id="${mcpId}"]`),
|
||
).toHaveCount(0);
|
||
await expect(page.getByTestId('space-mcp-panel')).not.toContainText(mcpName);
|
||
|
||
// No-nav invariant + no fatal client errors.
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Space task/settings feature parity (feat/space-feature-parity-tests).
|
||
//
|
||
// Phase 1 added per-chat delete / share / continue controls to the inline
|
||
// SpaceConversation. Issue #1433 moved the two that survive (continue + delete)
|
||
// out of the chat tab bar's right-hand zone (data-testid="space-chat-actions",
|
||
// which the conversation no longer renders) and down into the composer's button
|
||
// row, where delete sits at the FAR LEFT — away from send. Locate them by
|
||
// their own testids under the conversation, never through the retired zone. These tests
|
||
// prove each action works INSIDE a space without ever bouncing to the Tasks
|
||
// page, plus the 概要 (overview) feedback flow and every 設定 sub-tab.
|
||
//
|
||
// No LLM is configured in E2E, so a queued job fails fast and settles terminal
|
||
// (waitForTerminalJob), which is exactly what unlocks Continue + Overview
|
||
// feedback. Throughout we assert page=spaces is held (the no-nav invariant the
|
||
// user cares about) and no fatal client errors leak.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
// 1. DELETE — the user-reported gap. The composer row exposes a delete button
|
||
// (data-testid="space-chat-delete"); clicking it pops a confirm() dialog, and
|
||
// on accept the chat is removed and the conversation returns to the empty
|
||
// list state (the chat row disappears). This guards the exact bug the user
|
||
// hit: a missing delete button in space chats.
|
||
test('space chat DELETE: toolbar delete removes the chat and returns to the list', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
|
||
await createAndOpenCaseSpace(page, 'E2E削除');
|
||
const { conversation, taskId } = await startInlineChat(
|
||
page,
|
||
'E2E: 削除テスト用チャット。最初のメッセージ。',
|
||
);
|
||
|
||
// The delete button is present in the conversation (composer row since #1433).
|
||
const deleteBtn = conversation.getByTestId('space-chat-delete');
|
||
await expect(deleteBtn).toBeVisible();
|
||
// …in the composer's own action zone, not the retired tab-bar zone. (The
|
||
// left-most / away-from-send ordering inside that row is pinned by
|
||
// ui/src/components/spaces/SpaceDetail.composerActions.test.tsx.)
|
||
await expect(conversation.getByTestId('chat-composer-actions')).toBeVisible();
|
||
|
||
// The chat row for this task exists in the space's chat list before delete.
|
||
const chatRow = page.locator(`[data-testid="space-chat-row"][data-task-id="${taskId}"]`);
|
||
await expect(chatRow).toBeVisible();
|
||
|
||
// Accept the confirm() dialog that guards the destructive delete.
|
||
page.once('dialog', (dialog) => {
|
||
expect(dialog.type()).toBe('confirm');
|
||
void dialog.accept();
|
||
});
|
||
await deleteBtn.click();
|
||
|
||
// The chat disappears from the list AND the inline conversation closes
|
||
// (SpaceConversation calls onBack(0) on delete → spaceTaskId null → no
|
||
// conversation). The chat= URL param is dropped.
|
||
await expect(chatRow).toHaveCount(0, { timeout: 15_000 });
|
||
await expect(conversation).toHaveCount(0);
|
||
await expect.poll(() => new URL(page.url()).searchParams.get('chat')).toBeFalsy();
|
||
|
||
// No-nav invariant: deletion stayed on Spaces, never bounced to Tasks.
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// 2. CANCEL — seed a stable running job in the isolated E2E DB, then exercise
|
||
// the real ChatPane -> POST /cancel -> persisted terminal state. Waiting for
|
||
// the stub LLM to claim a job used to skip this check, or race a transient
|
||
// busy state and time out before the stop button could be clicked.
|
||
test('space chat CANCEL: ChatPane stop button cancels a running task', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
const { spaceId } = await createAndOpenCaseSpace(page, 'E2Eキャンセル');
|
||
const created = await page.request.post('/api/local/tasks', {
|
||
data: {
|
||
body: 'E2E: キャンセルテスト用の実行中タスク',
|
||
piece: 'chat', spaceId, workspaceMode: 'persistent',
|
||
},
|
||
});
|
||
expect(created.ok()).toBeTruthy();
|
||
const taskId = String((await created.json()).task.id);
|
||
let jobId: string | undefined;
|
||
await expect.poll(async () => {
|
||
jobId = (await fetchTask(page, taskId))?.latestJob?.id;
|
||
return jobId;
|
||
}).toBeTruthy();
|
||
|
||
const e2eTmp = process.env.E2E_TMP;
|
||
if (!e2eTmp) throw new Error('E2E_TMP must be set by playwright.config.ts');
|
||
const db = new Database(resolve(e2eTmp, 'e2e.db'));
|
||
try {
|
||
db.pragma('busy_timeout = 8000');
|
||
const changed = db.prepare("UPDATE jobs SET status = 'running' WHERE id = ?").run(jobId!);
|
||
expect(changed.changes).toBe(1);
|
||
} finally {
|
||
db.close();
|
||
}
|
||
|
||
const url = new URL(page.url());
|
||
url.searchParams.set('chat', taskId);
|
||
await page.goto(url.href);
|
||
const conversation = page.getByTestId('space-conversation');
|
||
await expect(conversation).toBeVisible();
|
||
const stopBtn = conversation.getByRole('button', { name: /^(Stop|停止)$/ });
|
||
await expect(stopBtn).toBeVisible();
|
||
const response = page.waitForResponse(res =>
|
||
new URL(res.url()).pathname === '/api/local/tasks/' + taskId + '/cancel'
|
||
&& res.request().method() === 'POST');
|
||
await stopBtn.click();
|
||
expect((await response).status()).toBe(200);
|
||
expect(await waitForTerminalJob(page, taskId, 20_000)).toBe('cancelled');
|
||
await expect(stopBtn).toHaveCount(0);
|
||
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
expect(fatalErrors, 'fatal errors:\n' + fatalErrors.join('\n')).toEqual([]);
|
||
});
|
||
|
||
// 4. CONTINUE-WITH-PIECE — the Continue button (data-testid=
|
||
// "space-chat-continue", reusing ContinueButton, in the composer row since
|
||
// issue #1433) is gated: disabled until the
|
||
// latest job is TERMINAL, enabled after. We always assert the wiring that IS
|
||
// observable without an LLM — the button is present and DISABLED while the job
|
||
// is non-terminal (queued). Then, IF the job reaches a terminal state (needs a
|
||
// reachable LLM; the E2E env has none, so this branch is skipped + reported),
|
||
// we click it and assert ContinueWithPieceDialog opens with piece options.
|
||
test('space chat CONTINUE: button gated on terminal job; opens ContinueWithPieceDialog when terminal', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
|
||
await createAndOpenCaseSpace(page, 'E2E継続');
|
||
const { conversation, taskId } = await startInlineChat(
|
||
page,
|
||
'E2E: 継続テスト用チャット。最初のメッセージ。',
|
||
);
|
||
|
||
// Always-verifiable: the Continue button exists and is DISABLED while the job
|
||
// is non-terminal (proves the terminal-gating in ContinueButton is wired).
|
||
const continueBtn = conversation.getByTestId('space-chat-continue');
|
||
await expect(continueBtn).toBeVisible();
|
||
await expect(continueBtn).toBeDisabled();
|
||
|
||
// Active path: only reachable when the job goes terminal (needs an LLM).
|
||
const status = await waitForTerminalJob(page, taskId);
|
||
test.skip(
|
||
status === '',
|
||
'no LLM reachable: the job stays queued (never terminal), so Continue stays ' +
|
||
'disabled — the dialog-open assertion needs a terminal job',
|
||
);
|
||
|
||
await expect(continueBtn).toBeEnabled({ timeout: 15_000 });
|
||
await continueBtn.click();
|
||
|
||
// ContinueWithPieceDialog opens: the piece <select> + instruction textarea
|
||
// (stable ids) are present, and the select carries ≥1 piece option.
|
||
const pieceSelect = page.locator('#continue-piece');
|
||
await expect(pieceSelect).toBeVisible();
|
||
await expect(page.locator('#continue-instruction')).toBeVisible();
|
||
await expect.poll(async () => pieceSelect.locator('option').count()).toBeGreaterThan(0);
|
||
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// 5. VISIBILITY — space chats have a FIXED scope: always visible to the space's
|
||
// members, never user-selectable. The visibility <select> was removed when the
|
||
// scope became fixed, and the static note chip that replaced it was removed in
|
||
// PR #780 (the scope is documented in help instead). This test guards that no
|
||
// visibility control sneaks back into the conversation toolbar, while the
|
||
// action buttons that DO belong there (continue / delete) stay present — so the
|
||
// assertion can't pass vacuously on a broken/empty toolbar. The auth-mode twin
|
||
// of this guard lives in ui/e2e-auth/sharing-scope.auth.spec.ts. (History:
|
||
// issue #782 — the old "change to public persists" test referenced the removed
|
||
// testid and had been silently dead since the selector was retired. Same trap
|
||
// again in issue #1338: the positive anchor was space-chat-share, but the
|
||
// per-task share link was retired in PR #962 — sharing is a space-level feature
|
||
// now — so the button and its testid went away while this line kept waiting for
|
||
// it. The anchors are now the two controls that actually ship — they live in
|
||
// the composer row since issue #1433, and are found by their own testids.)
|
||
test('space chat VISIBILITY: no visibility control (fixed member-only scope)', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
|
||
await createAndOpenCaseSpace(page, 'E2E可視性');
|
||
const { conversation } = await startInlineChat(
|
||
page,
|
||
'E2E: 可視性テスト用チャット。最初のメッセージ。',
|
||
);
|
||
|
||
// Positive anchor first: the per-chat actions are rendered (composer row since
|
||
// issue #1433), so the negative assertions below are checked against a live,
|
||
// non-empty conversation UI.
|
||
await expect(conversation.getByTestId('space-chat-continue')).toBeVisible();
|
||
await expect(conversation.getByTestId('space-chat-delete')).toBeVisible();
|
||
|
||
// No visibility selector, no visibility note — scope is fixed and implicit.
|
||
await expect(conversation.getByTestId('space-chat-visibility')).toHaveCount(0);
|
||
await expect(conversation.getByTestId('space-chat-visibility-note')).toHaveCount(0);
|
||
|
||
// Still on the spaces view with the chat selected. The current URL scheme
|
||
// carries space= + chat= params (the legacy page=spaces param is gone; other
|
||
// tests in this file still assert it and are stale — see issue #782 follow-up).
|
||
await expect(page).toHaveURL(/[?&]space=[^&]+/);
|
||
await expect(page).toHaveURL(/[?&]chat=\d+/);
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// 6. FEEDBACK via 概要 tab — the in-place 概要 (overview) tab renders OverviewTab.
|
||
// We always assert that switching to 概要 swaps the OverviewTab content in
|
||
// place (the conversation composer disappears; the shared tabpanel mounts) —
|
||
// verifiable without an LLM. The FeedbackPanel itself only renders for a
|
||
// COMPLETE (succeeded / failed) job, which needs a reachable LLM; the E2E env
|
||
// has none, so the rating round-trip is skipped + reported and runs for real
|
||
// only against a live LLM.
|
||
test('space chat FEEDBACK: 概要 tab renders OverviewTab; rating round-trip saves + persists', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
|
||
const { detail, spaceId } = await createAndOpenCaseSpace(page, 'E2Eフィードバック');
|
||
const { conversation, taskId } = await startInlineChat(
|
||
page,
|
||
'E2E: フィードバックテスト用チャット。最初のメッセージ。',
|
||
);
|
||
|
||
// Always-verifiable: 概要 opens as a right-pane detail panel and the shared
|
||
// tabpanel is mounted. Nothing is clicked to get there — a fresh chat opens on
|
||
// 概要 already (RAIL_DEFAULT since #1455; it used to be 会話). Pressing it here
|
||
// would do the opposite of what this test needs: the item that is already
|
||
// picked folds its column away (chatRailState.ts nextRailState), taking the
|
||
// FeedbackPanel below off screen.
|
||
const overviewTab = conversation.getByTestId('space-chat-tab-overview');
|
||
await expect(overviewTab).toBeVisible();
|
||
await expect(overviewTab).toHaveAttribute('aria-selected', 'true');
|
||
const panel = conversation.getByRole('tabpanel');
|
||
await expect(panel).toBeVisible();
|
||
// The conversation keeps its own column beside 概要 on desktop (#1330), so the
|
||
// composer is both mounted and on screen — exactly one textarea.
|
||
await expect(conversation.locator('textarea')).toHaveCount(1);
|
||
await expect(conversation.locator('textarea')).toBeVisible();
|
||
|
||
// Active path: the FeedbackPanel only renders once the job is succeeded/failed.
|
||
const status = await waitForTerminalJob(page, taskId);
|
||
test.skip(
|
||
status !== 'succeeded' && status !== 'failed',
|
||
'no LLM reachable: the job never completes (succeeded/failed), so the ' +
|
||
'Overview FeedbackPanel does not render — the rating round-trip needs a completed job',
|
||
);
|
||
|
||
// The 👍 rating button appears once the job is complete. The 👍 emoji is
|
||
// locale-independent (the text label beside it is i18n), so match the emoji.
|
||
const good = panel.getByRole('button', { name: /👍/ });
|
||
await expect(good).toBeVisible({ timeout: 15_000 });
|
||
await good.click();
|
||
// Pick a good-rating tag (GOOD_TAGS are literal JP strings, not i18n), then
|
||
// submit. The Submit label IS i18n, so match English/Japanese. Wait for the
|
||
// feedback PUT to commit before asserting the saved state.
|
||
await panel.getByRole('button', { name: '出力の精度が高い' }).click();
|
||
const fbPromise = page.waitForResponse(
|
||
(r) => /\/api\/local\/tasks\/\d+\/feedback$/.test(r.url()) && r.request().method() === 'PUT',
|
||
);
|
||
await panel.getByRole('button', { name: /^(Submit|送信)$/ }).click();
|
||
const fbRes = await fbPromise;
|
||
expect(fbRes.status()).toBe(200);
|
||
|
||
// Saved → the panel collapses to its summary view: a change button appears
|
||
// (only rendered when feedback exists and we're not editing). Label is i18n.
|
||
await expect(panel.getByRole('button', { name: /^(Change|変更)$/ })).toBeVisible({ timeout: 15_000 });
|
||
|
||
// Persistence: the rating is on the task server-side.
|
||
await expect
|
||
.poll(async () => (await fetchTask(page, taskId))?.feedbackRating ?? null)
|
||
.toBe('good');
|
||
|
||
// Reload + reopen via URL state → the 概要 panel still shows the saved summary.
|
||
await page.goto(`/ui?page=spaces&space=${spaceId}&chat=${taskId}`);
|
||
await expect(detail).toBeVisible();
|
||
const conversationAfter = page.getByTestId('space-conversation');
|
||
// The rail remembers the pick per chat (localStorage), so 概要 may already be
|
||
// open here. Pressing the item that is already open does NOT re-open it: it
|
||
// folds that column away (chatRailState.ts nextRailState), which would take
|
||
// the FeedbackPanel off screen. So click only when it is not already
|
||
// selected.
|
||
const overviewAfter = conversationAfter.getByTestId('space-chat-tab-overview');
|
||
if ((await overviewAfter.getAttribute('aria-selected')) !== 'true') {
|
||
await overviewAfter.click();
|
||
}
|
||
await expect(
|
||
conversationAfter.getByRole('tabpanel').getByRole('button', { name: /^(Change|変更)$/ }),
|
||
).toBeVisible({ timeout: 15_000 });
|
||
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// 7a. SETTINGS sub-tabs render — every 設定 sub-tab (agents/memory/pieces/skills/
|
||
// mcp/ssh) opens its space-scoped panel without a crash or fatal 404. The
|
||
// AGENTS.md Monaco round-trip + MCP/SSH panels are covered by the dedicated
|
||
// settings test above; here we additionally prove pieces + skills + memory
|
||
// panels mount their content (stable in-panel markers), so a regression that
|
||
// blanks any one sub-tab is caught.
|
||
test('space settings sub-tabs: agents/memory/pieces/skills/mcp/ssh all render their panel', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
|
||
await createAndOpenCaseSpace(page, 'E2E設定タブ');
|
||
await openSpaceTab(page, 'settings');
|
||
|
||
// agents → Monaco editor mounts.
|
||
await page.getByTestId('space-settings-nav-agents').click();
|
||
await expect(page.getByTestId('monaco-file-editor')).toBeVisible({ timeout: 20_000 });
|
||
|
||
// memory → the MemoryEntriesPanel header + new-entry button render. The header
|
||
// text is i18n (English in the E2E browser locale, Japanese otherwise).
|
||
await page.getByTestId('space-settings-nav-memory').click();
|
||
await expect(page.getByRole('heading', { name: /Memory entries|メモリエントリ/ })).toBeVisible({ timeout: 15_000 });
|
||
await expect(page.getByRole('button', { name: /New entry|新しいエントリ/ })).toBeVisible();
|
||
|
||
// pieces → the SpacePiecesPanel list (Default / Custom sections) renders.
|
||
// These section labels are i18n (spaces.json settings.pieces.default/custom).
|
||
await page.getByTestId('space-settings-nav-pieces').click();
|
||
await expect(page.getByText(/^(Default|標準)$/)).toBeVisible({ timeout: 15_000 });
|
||
await expect(page.getByText(/^(Custom|カスタム)$/)).toBeVisible();
|
||
|
||
// skills → the SkillsForm heading renders (the install-from-URL input is
|
||
// intentionally hidden in space mode — the Git install path isn't space-scoped
|
||
// yet — so the unique heading is the panel-mounted marker). The heading is now
|
||
// i18n (settings.json skills.title), and this runner's locale is ja-JP, so the
|
||
// name has to accept both languages like the Default/Custom checks above.
|
||
await page.getByTestId('space-settings-nav-skills').click();
|
||
await expect(page.getByRole('heading', { name: /^(Skills|スキル)$/ })).toBeVisible({ timeout: 15_000 });
|
||
|
||
// mcp / ssh → their space-scoped panel containers render.
|
||
await page.getByTestId('space-settings-nav-mcp').click();
|
||
await expect(page.getByTestId('space-mcp-panel')).toBeVisible({ timeout: 15_000 });
|
||
await page.getByTestId('space-settings-nav-ssh').click();
|
||
await expect(page.getByTestId('space-ssh-panel')).toBeVisible({ timeout: 15_000 });
|
||
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// 7b. MEMORY per-space persistence + isolation — memory is space-scoped: the
|
||
// panel reads/writes /api/local/memory/entries?spaceId=<id>. We seed an entry
|
||
// into space A via the same space-scoped API the panel uses, open A's 設定 →
|
||
// メモリ and assert the entry renders (proves the panel reads ?spaceId=A and
|
||
// the write persisted to the space folder), then open space B's メモリ and
|
||
// assert the entry is absent (isolation). Mirrors the MCP-isolation pattern.
|
||
test('space settings MEMORY: entry saved in space A persists + is absent in space B', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
|
||
const stamp = Date.now();
|
||
const resA = await page.request.post('/api/local/spaces', { data: { title: `E2E-MEM-A-${stamp}` } });
|
||
expect(resA.status()).toBe(201);
|
||
const spaceA = (await resA.json()) as { id: string };
|
||
const resB = await page.request.post('/api/local/spaces', { data: { title: `E2E-MEM-B-${stamp}` } });
|
||
expect(resB.status()).toBe(201);
|
||
const spaceB = (await resB.json()) as { id: string };
|
||
expect(spaceB.id).not.toBe(spaceA.id);
|
||
|
||
// Seed a memory entry into space A via the space-scoped API (same endpoint the
|
||
// panel's modal posts to). A unique name lets the UI assertion be exact.
|
||
const memName = `e2e-mem-${stamp}`;
|
||
const putRes = await page.request.put(
|
||
`/api/local/memory/entries/${memName}?spaceId=${encodeURIComponent(spaceA.id)}`,
|
||
{ data: { description: 'E2E space-scoped memory', type: 'reference', body: 'space A only' } },
|
||
);
|
||
expect(putRes.status(), `memory PUT failed: ${putRes.status()} ${await putRes.text()}`).toBe(200);
|
||
|
||
// Open Spaces, select A → 設定 → メモリ; the seeded entry renders (persisted +
|
||
// read back via ?spaceId=A).
|
||
await page.goto('/ui');
|
||
await page.getByTestId('nav-spaces').click();
|
||
const rail = page.getByTestId('space-rail');
|
||
await expect(rail).toBeVisible();
|
||
|
||
const detail = page.getByTestId('space-detail');
|
||
await rail.locator(`[data-testid="space-row"][data-space-id="${spaceA.id}"]`).click();
|
||
await expect(detail).toBeVisible();
|
||
await openSpaceTab(page, 'settings');
|
||
await page.getByTestId('space-settings-nav-memory').click();
|
||
await expect(page.getByText(memName, { exact: true })).toBeVisible({ timeout: 15_000 });
|
||
|
||
// Open space B → 設定 → メモリ; the entry from A must NOT appear (isolation).
|
||
await rail.locator(`[data-testid="space-row"][data-space-id="${spaceB.id}"]`).click();
|
||
await expect(detail).toBeVisible();
|
||
await openSpaceTab(page, 'settings');
|
||
await page.getByTestId('space-settings-nav-memory').click();
|
||
await expect(page.getByRole('heading', { name: /Memory entries|メモリエントリ/ })).toBeVisible({ timeout: 15_000 });
|
||
await expect(page.getByText(memName, { exact: true })).toHaveCount(0);
|
||
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// ── Space calendar tab (feat/space-calendar, plan stage 4) ───────────────────
|
||
//
|
||
// The per-space カレンダー tab renders a self-built month grid (no external
|
||
// calendar lib) plus a day panel with three sections (タスク / 変更ファイル /
|
||
// 予定) and event CRUD. These E2E reuse the shared create-space / inline-chat
|
||
// helpers above and the same fatal-error tracker — they assert the calendar
|
||
// behaves on the real orchestrator (auth off => synthetic 'local' user, who is
|
||
// the space owner and therefore can edit events).
|
||
//
|
||
// 'today' is computed in the viewer's local frame exactly like the UI's
|
||
// localToday(), so the `space-cal-day-{date}` testid matches the cell the UI
|
||
// renders for today (the grid + day endpoint share the tz_offset).
|
||
function e2eLocalToday(): string {
|
||
const d = new Date();
|
||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||
const dd = String(d.getDate()).padStart(2, '0');
|
||
return `${d.getFullYear()}-${mm}-${dd}`;
|
||
}
|
||
|
||
const ADJACENT_MONTH_CLOCK = new Date('2026-07-15T12:00:00.000Z');
|
||
const ADJACENT_MONTH_FIRST = '2026-06-28';
|
||
const ADJACENT_MONTH_LAST = '2026-08-08';
|
||
|
||
async function seedCalendarTaskDate(
|
||
page: Page,
|
||
spaceId: string,
|
||
date: string,
|
||
body: string,
|
||
): Promise<number> {
|
||
const created = await page.request.post('/api/local/tasks', {
|
||
data: { body, piece: 'chat', spaceId, workspaceMode: 'persistent' },
|
||
});
|
||
expect(created.ok()).toBeTruthy();
|
||
const taskId = (await created.json()).task.id as number;
|
||
const e2eTmp = process.env.E2E_TMP;
|
||
if (!e2eTmp) throw new Error('E2E_TMP must be set by playwright.config.ts');
|
||
const db = new Database(resolve(e2eTmp, 'e2e.db'));
|
||
db.pragma('busy_timeout = 8000');
|
||
db.prepare('UPDATE local_tasks SET created_at = ?, updated_at = ? WHERE id = ?')
|
||
.run(`${date}T12:00:00.000Z`, `${date}T12:00:00.000Z`, taskId);
|
||
db.close();
|
||
return taskId;
|
||
}
|
||
|
||
async function seedCalendarEvent(page: Page, spaceId: string, date: string, title: string, endDate?: string) {
|
||
const created = await page.request.post(`/api/local/spaces/${spaceId}/calendar/events`, {
|
||
data: { date, end_date: endDate, title },
|
||
});
|
||
expect(created.ok()).toBeTruthy();
|
||
return (await created.json()).id as number;
|
||
}
|
||
|
||
test('space calendar: tab renders month grid + month nav', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
await createAndOpenCaseSpace(page, 'E2Eカレンダー表示');
|
||
|
||
await openSpaceTab(page, 'calendar');
|
||
await expect(page.getByTestId('space-calendar')).toBeVisible();
|
||
await expect(page.getByTestId('space-cal-grid')).toBeVisible();
|
||
|
||
// Today's cell is present in the current-month grid.
|
||
const today = e2eLocalToday();
|
||
await expect(page.getByTestId(`space-cal-day-${today}`)).toBeVisible();
|
||
|
||
// Month nav changes the YYYY年M月 header. Capture, step forward, assert it
|
||
// differs, step back, assert it returns.
|
||
const title = page.getByTestId('space-cal-title');
|
||
const initial = (await title.textContent())?.trim();
|
||
expect(initial).toMatch(/\d{4}年\d{1,2}月/);
|
||
await page.getByTestId('space-cal-next').click();
|
||
await expect(title).not.toHaveText(initial!);
|
||
await page.getByTestId('space-cal-prev').click();
|
||
await expect(title).toHaveText(initial!);
|
||
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
test('space calendar: add event → list + day badge + persists across reload', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
await createAndOpenCaseSpace(page, 'E2Eカレンダー予定');
|
||
|
||
await openSpaceTab(page, 'calendar');
|
||
await expect(page.getByTestId('space-cal-grid')).toBeVisible();
|
||
|
||
const today = e2eLocalToday();
|
||
const todayCell = page.getByTestId(`space-cal-day-${today}`);
|
||
// Baseline: today's event count badge (📌N) is absent before adding.
|
||
await expect(todayCell.getByText(/^📌/)).toHaveCount(0);
|
||
|
||
// Open today's day panel and add an event.
|
||
await todayCell.click();
|
||
const panel = page.getByTestId('space-cal-day-panel');
|
||
await expect(panel).toBeVisible();
|
||
|
||
await panel.getByTestId('space-cal-add-event-btn').click();
|
||
const form = page.getByTestId('space-cal-add-event');
|
||
await expect(form).toBeVisible();
|
||
const eventTitle = `打合せ-${Date.now()}`;
|
||
await form.getByTestId('space-cal-event-title').fill(eventTitle);
|
||
await form.getByTestId('space-cal-event-time').fill('14:30');
|
||
await form.getByTestId('space-cal-event-description').fill('E2E: テスト予定');
|
||
await form.getByTestId('space-cal-event-save').click();
|
||
|
||
// The new event appears in the 予定 list (with its time) after invalidation.
|
||
await expect(panel.getByText(eventTitle, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||
await expect(panel.getByText('14:30')).toBeVisible();
|
||
|
||
// Grab the event id from its panel row so we can assert both month-grid
|
||
// indicators by id (a single-day event drives TWO separate render paths).
|
||
const row = panel.locator('[data-testid^="space-cal-event-"]').filter({ hasText: eventTitle }).first();
|
||
const eventId = (await row.getAttribute('data-testid'))!.replace('space-cal-event-', '');
|
||
expect(eventId).toMatch(/^\d+$/);
|
||
|
||
// Month grid shows BOTH indicators for the single-day event:
|
||
// - the 📌1 day-cell badge (counts.eventCount path, added in #808 / #804).
|
||
// exact:true so it pins count===1, not a substring of 📌1x.
|
||
// - a colSpan-1 week bar (layoutWeekBars path) — a separate code path, so
|
||
// assert it explicitly (a badge-only check would miss a phantom-bar bug).
|
||
await expect(todayCell.getByText('📌1', { exact: true })).toBeVisible({ timeout: 10_000 });
|
||
await expect(page.getByTestId(`space-cal-bar-${eventId}`)).toBeVisible({ timeout: 10_000 });
|
||
|
||
// Persists across a full reload (server-backed, not just client state):
|
||
// day-cell badge, week bar, and the day-panel event all come back.
|
||
await page.reload();
|
||
await openSpaceTab(page, 'calendar');
|
||
await expect(page.getByTestId(`space-cal-day-${today}`).getByText('📌1', { exact: true })).toBeVisible({ timeout: 10_000 });
|
||
await expect(page.getByTestId(`space-cal-bar-${eventId}`)).toBeVisible({ timeout: 10_000 });
|
||
await page.getByTestId(`space-cal-day-${today}`).click();
|
||
await expect(page.getByTestId('space-cal-day-panel').getByText(eventTitle, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
test('space calendar: edit + delete an event', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
await createAndOpenCaseSpace(page, 'E2Eカレンダー編集');
|
||
|
||
await openSpaceTab(page, 'calendar');
|
||
const today = e2eLocalToday();
|
||
const todayCell = page.getByTestId(`space-cal-day-${today}`);
|
||
await todayCell.click();
|
||
const panel = page.getByTestId('space-cal-day-panel');
|
||
await expect(panel).toBeVisible();
|
||
|
||
// Create one event.
|
||
await panel.getByTestId('space-cal-add-event-btn').click();
|
||
const origTitle = `元タイトル-${Date.now()}`;
|
||
await page.getByTestId('space-cal-event-title').fill(origTitle);
|
||
await page.getByTestId('space-cal-event-save').click();
|
||
await expect(panel.getByText(origTitle, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||
|
||
// Grab the rendered event id from its row testid (space-cal-event-{id}).
|
||
const row = panel.locator('[data-testid^="space-cal-event-"]').filter({ hasText: origTitle }).first();
|
||
const rowTestId = await row.getAttribute('data-testid');
|
||
const eventId = rowTestId!.replace('space-cal-event-', '');
|
||
expect(eventId).toMatch(/^\d+$/);
|
||
|
||
// Edit: open the edit form, change the title, save.
|
||
await panel.getByTestId(`space-cal-event-edit-${eventId}`).click();
|
||
const newTitle = `編集後-${Date.now()}`;
|
||
await page.getByTestId('space-cal-event-title').fill(newTitle);
|
||
await page.getByTestId('space-cal-event-save').click();
|
||
await expect(panel.getByText(newTitle, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||
await expect(panel.getByText(origTitle, { exact: true })).toHaveCount(0);
|
||
// While the event exists the day cell carries both month-grid indicators:
|
||
// the 📌1 badge (eventCount path) and the colSpan-1 week bar (layoutWeekBars
|
||
// path). Assert both so a regression in either path is caught.
|
||
await expect(todayCell.getByText('📌1', { exact: true })).toBeVisible({ timeout: 10_000 });
|
||
await expect(page.getByTestId(`space-cal-bar-${eventId}`)).toBeVisible({ timeout: 10_000 });
|
||
|
||
// Delete: confirm the window.confirm() then assert the row disappears and
|
||
// BOTH month-grid indicators clear (badge tracks eventCount, bar comes from
|
||
// the events array — separate paths, so a delete could orphan one).
|
||
page.once('dialog', (d) => d.accept());
|
||
await panel.getByTestId(`space-cal-event-delete-${eventId}`).click();
|
||
await expect(panel.getByTestId(`space-cal-event-${eventId}`)).toHaveCount(0, { timeout: 10_000 });
|
||
await expect(todayCell.getByText(/^📌/)).toHaveCount(0, { timeout: 10_000 });
|
||
await expect(page.getByTestId(`space-cal-bar-${eventId}`)).toHaveCount(0, { timeout: 10_000 });
|
||
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
test('space calendar: day panel lists tasks created in the space', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
await createAndOpenCaseSpace(page, 'E2Eカレンダータスク');
|
||
|
||
// Create an inline chat (task) in this space — it is created "today", so it
|
||
// must surface in today's calendar day panel タスク section.
|
||
const { taskId } = await startInlineChat(page, 'E2E: カレンダー日詳細用のタスク。');
|
||
expect(taskId).toBeTruthy();
|
||
|
||
// Switch to the calendar tab and open today's cell.
|
||
await openSpaceTab(page, 'calendar');
|
||
await expect(page.getByTestId('space-cal-grid')).toBeVisible();
|
||
const today = e2eLocalToday();
|
||
const todayCell = page.getByTestId(`space-cal-day-${today}`);
|
||
// The task badge (💬N) should be present for today.
|
||
await expect(todayCell.getByText(/^💬/)).toBeVisible({ timeout: 10_000 });
|
||
await todayCell.click();
|
||
|
||
const panel = page.getByTestId('space-cal-day-panel');
|
||
await expect(panel).toBeVisible();
|
||
// The created task is listed and clicking it opens the chat (switches tab).
|
||
const taskBtn = panel.getByTestId(`space-cal-task-${taskId}`);
|
||
await expect(taskBtn).toBeVisible({ timeout: 10_000 });
|
||
await taskBtn.click();
|
||
await expect(page.getByTestId('space-conversation')).toBeVisible();
|
||
await expect(page).toHaveURL(/[?&]chat=\d+/);
|
||
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
test('space calendar: adjacent month days show badges, details, and spanning bars', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
await page.clock.setFixedTime(ADJACENT_MONTH_CLOCK);
|
||
const { spaceId } = await createAndOpenCaseSpace(page, 'E2E個別隣接月');
|
||
const first = ADJACENT_MONTH_FIRST;
|
||
const last = ADJACENT_MONTH_LAST;
|
||
|
||
const firstTaskId = await seedCalendarTaskDate(page, spaceId, first, `個別先頭タスク-${Date.now()}`);
|
||
const lastTaskId = await seedCalendarTaskDate(page, spaceId, last, `個別末尾タスク-${Date.now()}`);
|
||
const firstEvent = `個別先頭予定-${Date.now()}`;
|
||
const lastEvent = `個別末尾予定-${Date.now()}`;
|
||
await seedCalendarEvent(page, spaceId, first, firstEvent);
|
||
await seedCalendarEvent(page, spaceId, last, lastEvent);
|
||
const spanningEvent = `個別両境界予定-${Date.now()}`;
|
||
const spanningId = await seedCalendarEvent(page, spaceId, first, spanningEvent, last);
|
||
|
||
await openSpaceTab(page, 'calendar');
|
||
const firstCell = page.getByTestId(`space-cal-day-${first}`);
|
||
const lastCell = page.getByTestId(`space-cal-day-${last}`);
|
||
await expect(firstCell.getByText('💬1', { exact: true })).toBeVisible({ timeout: 10_000 });
|
||
await expect(firstCell.getByText('📌2', { exact: true })).toBeVisible();
|
||
await expect(lastCell.getByText('💬1', { exact: true })).toBeVisible();
|
||
await expect(lastCell.getByText('📌2', { exact: true })).toBeVisible();
|
||
await expect(page.getByTestId(`space-cal-bar-${spanningId}`)).toHaveCount(6);
|
||
|
||
await firstCell.click();
|
||
const panel = page.getByTestId('space-cal-day-panel');
|
||
await expect(panel.getByTestId(`space-cal-task-${firstTaskId}`)).toBeVisible();
|
||
await expect(panel.getByText(firstEvent, { exact: true })).toBeVisible();
|
||
await expect(panel.getByText(spanningEvent, { exact: true })).toBeVisible();
|
||
await lastCell.click();
|
||
await expect(panel.getByTestId(`space-cal-task-${lastTaskId}`)).toBeVisible();
|
||
await expect(panel.getByText(lastEvent, { exact: true })).toBeVisible();
|
||
await expect(panel.getByText(spanningEvent, { exact: true })).toBeVisible();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// ── Space members panel (feat/shared-space, stage 3) ─────────────────────────
|
||
//
|
||
// The 設定 → メンバー sub-tab renders SpaceMembersPanel. Membership management
|
||
// (invite/role/remove) is gated on canManage, which in the E2E env (auth OFF)
|
||
// resolves to TRUE: useAuthState() is `disabled`, so the synthetic 'local' user
|
||
// can manage. The invite button therefore renders.
|
||
//
|
||
// ENV NOTE — no owner row in this harness: a case space created under no-auth is
|
||
// stored with owner_id = NULL (POST /api/local/spaces: `viewer.id === 'local' ?
|
||
// null : viewer.id`), and that space owns no space_members rows yet. The members
|
||
// GET only synthesizes an owner row when space.ownerId is set, so under no-auth
|
||
// the list is legitimately empty — there is no real user identity to attribute
|
||
// the owner row to. We therefore assert the realistic no-auth surface: the panel
|
||
// mounts, the empty list is handled without a crash, and the invite flow shows
|
||
// the no-auth sharing hint (pickable returns [] -> "認証を有効化すると…"). The
|
||
// owner-badge / role-select / remove controls are exercised by the API + (auth-on)
|
||
// component tests; they cannot be reached in this auth-off harness. A future
|
||
// auth-on harness (a second seeded user) would assert invite -> member appears.
|
||
//
|
||
// /api/users/pickable returns 200 with [] in no-auth (mirrors /api/admin/users),
|
||
// so it is NOT a fatal HTTP error — the shared SPACE_BENIGN_PATHS set is enough.
|
||
test('space settings MEMBERS: panel renders + invite shows no-auth hint', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
|
||
await createAndOpenCaseSpace(page, 'E2Eメンバー');
|
||
await openSpaceTab(page, 'settings');
|
||
|
||
// メンバー sub-tab → the panel mounts with its header.
|
||
await page.getByTestId('space-settings-nav-members').click();
|
||
const panel = page.getByTestId('space-members-panel');
|
||
await expect(panel).toBeVisible({ timeout: 15_000 });
|
||
await expect(panel.getByRole('heading', { name: 'メンバー' })).toBeVisible();
|
||
|
||
// canManage is true (no-auth), so the invite button renders. Clicking it opens
|
||
// the picker, which — with an empty pickable list — shows the no-auth sharing
|
||
// hint instead of a user list.
|
||
const inviteBtn = panel.getByTestId('space-member-invite');
|
||
await expect(inviteBtn).toBeVisible();
|
||
await inviteBtn.click();
|
||
const picker = panel.getByTestId('space-member-picker');
|
||
await expect(picker).toBeVisible({ timeout: 15_000 });
|
||
await expect(picker.getByText('認証を有効化するとワークスペースを共有できます。')).toBeVisible();
|
||
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// ── Space source library (feat/space-source-library, stage 2) ────────────────
|
||
//
|
||
// The Files tab now renders a curated "ソース" group at the top (testid
|
||
// space-sources) sourced from source/index.jsonl. Two surfaces are observable
|
||
// WITHOUT an LLM:
|
||
// 1. EMPTY-STATE: a fresh space has no source/index.jsonl → the GET on
|
||
// .../files/content?path=source/index.jsonl 404s, fetchSpaceSourceIndex
|
||
// swallows it and returns [], and the group shows the empty hint.
|
||
// NOTE: that 404 is EXPECTED here, so we use a local response tracker that
|
||
// treats the space source-index content 404 as benign (the shared
|
||
// trackFatalErrors would otherwise flag it; SPACE_BENIGN_ASSET only covers
|
||
// .json, not .jsonl).
|
||
// 2. POPULATED: we seed source/research.md + source/index.jsonl via the space
|
||
// file upload API (POST /files/upload, path=source), then assert the group
|
||
// lists the entry with its title and origin host.
|
||
const b64 = (s: string) => Buffer.from(s, 'utf8').toString('base64');
|
||
|
||
// The curated ソース list view was removed (#6): a fresh space's ファイル tab no
|
||
// longer renders the source list or its empty-state. (Previously this asserted
|
||
// the empty-state was visible; now it must be ABSENT.) The Files grid renders
|
||
// instead, and the UI no longer fetches source/index.jsonl on open.
|
||
test('space sources: curated ソース list view (incl. empty-state) is absent on a fresh space', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
|
||
await createAndOpenCaseSpace(page, 'E2Eソース空');
|
||
await page.getByTestId('space-tab-files').click();
|
||
|
||
// The Files grid renders, but the old ソース list view + its empty-state are gone.
|
||
await expect(page.getByTestId('space-files-grid')).toBeVisible({ timeout: 15_000 });
|
||
await expect(page.getByTestId('space-sources')).toHaveCount(0);
|
||
await expect(page.getByTestId('space-sources-empty')).toHaveCount(0);
|
||
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// The curated ソース list view was removed (#6). The source/ folder + its
|
||
// index.jsonl provenance STILL accumulate (agent WebFetch/DownloadFile → source/),
|
||
// but the Files UI no longer renders a special list — source/ is just a normal
|
||
// folder in the grid. This test proves: (1) the seeded source/ upload succeeds,
|
||
// (2) the old space-sources list view is gone, (3) source/ is browsable as a
|
||
// regular folder and its file shows up there.
|
||
test('space sources: source/ folder still accumulates and is browsable; curated ソース list view removed', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
|
||
const { spaceId } = await createAndOpenCaseSpace(page, 'E2Eソース');
|
||
|
||
// Seed a source file + provenance index into the space's source/ dir via the
|
||
// same upload API the Files tab uses (path=source places files under source/).
|
||
const indexLine = JSON.stringify({
|
||
file: 'research.md',
|
||
url: 'https://example.com/article',
|
||
title: 'リサーチ記事タイトル',
|
||
fetched_at: '2026-06-19T10:00:00Z',
|
||
tool: 'WebFetch',
|
||
bytes: 2048,
|
||
});
|
||
const upRes = await page.request.post(`/api/local/spaces/${spaceId}/files/upload`, {
|
||
data: {
|
||
path: 'source',
|
||
files: [
|
||
{ name: 'research.md', contentBase64: b64('# 調査結果\n\n本文。') },
|
||
{ name: 'index.jsonl', contentBase64: b64(`${indexLine}\n`) },
|
||
],
|
||
},
|
||
});
|
||
expect(upRes.status(), `upload failed: ${upRes.status()} ${await upRes.text()}`).toBe(200);
|
||
|
||
// Open the Files tab. The curated ソース list view (#6) is GONE.
|
||
await page.getByTestId('space-tab-files').click();
|
||
await expect(page.getByTestId('space-files-grid')).toBeVisible({ timeout: 15_000 });
|
||
await expect(page.getByTestId('space-sources')).toHaveCount(0);
|
||
|
||
// source/ appears as a normal folder tile; navigate into it.
|
||
const sourceDir = page.locator('[data-testid="space-file-tile"][data-kind="directory"][data-name="source"]');
|
||
await expect(sourceDir).toBeVisible({ timeout: 15_000 });
|
||
await sourceDir.click();
|
||
|
||
// The seeded research.md shows in the source/ folder (index.jsonl stays hidden
|
||
// — it is filtered out of the raw file window as before).
|
||
await expect(
|
||
page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="research.md"]'),
|
||
).toBeVisible({ timeout: 15_000 });
|
||
await expect(
|
||
page.locator('[data-testid="space-file-tile"][data-name="index.jsonl"]'),
|
||
).toHaveCount(0);
|
||
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// ── Space settings → ブラウザ section (workstream 2, stage 2) ─────────────────
|
||
//
|
||
// The 設定 sub-nav now carries a "ブラウザ" section (space-settings-nav-browser)
|
||
// rendering SpaceBrowserPanel: three groups — ブラウザセッション
|
||
// (space-browser-sessions), ブラウザマクロ (space-browser-macros), 録画
|
||
// (space-browser-recordings). All three are space-scoped (their fetches carry
|
||
// ?spaceId=<id>). In the auth-off harness there are no real sessions/macros/
|
||
// recordings for a fresh space, so we assert the panel mounts and each group
|
||
// shows its in-product empty state without a crash or fatal 4xx/5xx. The
|
||
// per-user-DEK "作成者のみ利用可" badge and management gating are exercised by the
|
||
// component/API tests; this render test guards against a regression that blanks
|
||
// the whole section.
|
||
//
|
||
// ENV NOTE — benign endpoints under no-auth: the panel lists profiles via
|
||
// GET /api/browser-sessions/profiles?spaceId=… and macros/recordings via
|
||
// GET /api/users/me/folder/{list}?subdir=…&spaceId=…. Under no-auth the synthetic
|
||
// 'local' user is the space owner, so these return 200 with empty arrays — no new
|
||
// benign paths are needed beyond the shared set. The add-session control renders
|
||
// because canManage is TRUE in the auth-off harness (useAuthState() = disabled).
|
||
test('space settings BROWSER: panel + three groups render with empty states', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
|
||
await createAndOpenCaseSpace(page, 'E2Eブラウザ');
|
||
await openSpaceTab(page, 'settings');
|
||
|
||
// The ブラウザ sub-tab is present and selecting it mounts SpaceBrowserPanel.
|
||
const browserNav = page.getByTestId('space-settings-nav-browser');
|
||
await expect(browserNav).toBeVisible();
|
||
await browserNav.click();
|
||
|
||
const panel = page.getByTestId('space-browser-panel');
|
||
await expect(panel).toBeVisible({ timeout: 15_000 });
|
||
|
||
// 1. セッション group: header + empty state.
|
||
const sessions = page.getByTestId('space-browser-sessions');
|
||
await expect(sessions).toBeVisible();
|
||
await expect(sessions.getByRole('heading', { name: 'ブラウザセッション' })).toBeVisible();
|
||
await expect(sessions).toContainText('このワークスペースにはまだブラウザセッションがありません');
|
||
// canManage (no-auth) → the add-session control renders.
|
||
await expect(page.getByTestId('space-browser-add-session')).toBeVisible();
|
||
|
||
// 2. マクロ group: header + empty state.
|
||
const macros = page.getByTestId('space-browser-macros');
|
||
await expect(macros).toBeVisible();
|
||
await expect(macros.getByRole('heading', { name: 'ブラウザマクロ' })).toBeVisible();
|
||
await expect(macros).toContainText('このワークスペースにはまだブラウザマクロがありません');
|
||
|
||
// 3. 録画 group: header + empty state.
|
||
const recordings = page.getByTestId('space-browser-recordings');
|
||
await expect(recordings).toBeVisible();
|
||
await expect(recordings.getByRole('heading', { name: '録画' })).toBeVisible();
|
||
await expect(recordings).toContainText('このワークスペースにはまだ録画がありません');
|
||
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Workspace management + file management (feat/workspace-files-mgmt).
|
||
//
|
||
// - File delete with multi-select in the ファイル tab (#8).
|
||
// - Workspace rename via the header pencil (#8).
|
||
// - Workspace (case) delete via the header trash → returns to the list (#8).
|
||
// - The curated ソース list view was removed (#6); source/ still accumulates as
|
||
// a normal folder, but the SpaceSources list (data-testid="space-sources") is
|
||
// gone from the Files UI.
|
||
// All run with auth disabled; no LLM needed (pure management UI).
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
test('space files DELETE: select a file and delete it (gone); ソース list view is removed', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
const { detail } = await createAndOpenCaseSpace(page, 'E2E削除');
|
||
|
||
await page.getByTestId('space-tab-files').click();
|
||
await expect(page.getByTestId('space-files-grid')).toBeVisible();
|
||
|
||
// The curated ソース list view (#6) must NOT be rendered anymore.
|
||
await expect(page.getByTestId('space-sources')).toHaveCount(0);
|
||
|
||
// Upload a file so we have something to delete.
|
||
await page
|
||
.locator('[data-testid="space-files-upload-input"]')
|
||
.setInputFiles({ name: 'delete-me.txt', mimeType: 'text/plain', buffer: Buffer.from('bye') });
|
||
const tile = page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="delete-me.txt"]');
|
||
await expect(tile).toBeVisible({ timeout: 15000 });
|
||
|
||
// Select it via its checkbox → the bulk 削除 button appears.
|
||
await page.getByTestId('space-file-select-delete-me.txt').check();
|
||
const deleteSelected = page.getByTestId('space-files-delete-selected');
|
||
await expect(deleteSelected).toBeVisible();
|
||
|
||
// Accept the confirm() dialog guarding the destructive delete.
|
||
page.once('dialog', (dialog) => { expect(dialog.type()).toBe('confirm'); void dialog.accept(); });
|
||
await deleteSelected.click();
|
||
|
||
// The tile disappears after the delete + refetch.
|
||
await expect(tile).toHaveCount(0, { timeout: 15000 });
|
||
|
||
// No-nav invariant.
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
await expect(detail).toBeVisible();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
test('space files DELETE: multi-select all + per-file trash icon', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
await createAndOpenCaseSpace(page, 'E2E複数削除');
|
||
await page.getByTestId('space-tab-files').click();
|
||
await expect(page.getByTestId('space-files-grid')).toBeVisible();
|
||
|
||
// Upload two files.
|
||
await page.locator('[data-testid="space-files-upload-input"]').setInputFiles([
|
||
{ name: 'one.txt', mimeType: 'text/plain', buffer: Buffer.from('1') },
|
||
{ name: 'two.txt', mimeType: 'text/plain', buffer: Buffer.from('2') },
|
||
]);
|
||
await expect(
|
||
page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="two.txt"]'),
|
||
).toBeVisible({ timeout: 15000 });
|
||
|
||
// "Select all" checks both; the count reflects 2 選択中.
|
||
await page.getByTestId('space-files-select-all').check();
|
||
await expect(page.getByTestId('space-files-selected-count')).toContainText('2');
|
||
|
||
// Delete the whole selection.
|
||
page.once('dialog', (d) => void d.accept());
|
||
await page.getByTestId('space-files-delete-selected').click();
|
||
await expect(
|
||
page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="one.txt"]'),
|
||
).toHaveCount(0, { timeout: 15000 });
|
||
await expect(
|
||
page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="two.txt"]'),
|
||
).toHaveCount(0);
|
||
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
test('space files DOWNLOAD: bulk zip + per-tile download', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
await createAndOpenCaseSpace(page, 'E2Eダウンロード');
|
||
await page.getByTestId('space-tab-files').click();
|
||
await expect(page.getByTestId('space-files-grid')).toBeVisible();
|
||
|
||
// Upload two files to download.
|
||
await page.locator('[data-testid="space-files-upload-input"]').setInputFiles([
|
||
{ name: 'one.txt', mimeType: 'text/plain', buffer: Buffer.from('1') },
|
||
{ name: 'two.txt', mimeType: 'text/plain', buffer: Buffer.from('2') },
|
||
]);
|
||
await expect(
|
||
page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="two.txt"]'),
|
||
).toBeVisible({ timeout: 15000 });
|
||
|
||
// Bulk: select all → ダウンロード → a single files.zip download fires.
|
||
await page.getByTestId('space-files-select-all').check();
|
||
const [zipDownload] = await Promise.all([
|
||
page.waitForEvent('download'),
|
||
page.getByTestId('space-files-download-selected').click(),
|
||
]);
|
||
expect(zipDownload.suggestedFilename()).toBe('files.zip');
|
||
|
||
// Per-tile: the download anchor downloads that single file (name preserved).
|
||
const [single] = await Promise.all([
|
||
page.waitForEvent('download'),
|
||
page.getByTestId('space-file-download-one.txt').click(),
|
||
]);
|
||
expect(single.suggestedFilename()).toBe('one.txt');
|
||
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
test('space RENAME: header pencil renames the workspace and the title updates', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
const { detail, caseTitle, rail } = await createAndOpenCaseSpace(page, 'E2E改名');
|
||
|
||
// Open the header pencil, which reuses the create/edit SpaceFormDialog
|
||
// (space-title-input pre-filled with the current title, same submit button
|
||
// as create). There is no dedicated inline rename input/save pair.
|
||
await page.getByTestId('space-edit').click();
|
||
const input = page.getByTestId('space-title-input');
|
||
await expect(input).toBeVisible();
|
||
const renamed = `${caseTitle}-改`;
|
||
await input.fill(renamed);
|
||
await page.getByTestId('space-form-submit').click();
|
||
|
||
// The header reflects the new title, and the rail row updates too.
|
||
await expect(detail).toContainText(renamed);
|
||
await expect(
|
||
rail.locator('[data-testid="space-row"][data-space-kind="case"]').filter({ hasText: renamed }),
|
||
).toBeVisible({ timeout: 15000 });
|
||
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
test('space DELETE: case workspace delete returns to the list (selection cleared)', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
const { caseTitle, rail } = await createAndOpenCaseSpace(page, 'E2EWS削除');
|
||
|
||
// Click the header trash → inline confirm → 削除する.
|
||
await page.getByTestId('space-delete').click();
|
||
const confirm = page.getByTestId('space-delete-confirm');
|
||
await expect(confirm).toBeVisible();
|
||
await confirm.click();
|
||
|
||
// The space row disappears from the rail and the selection is cleared
|
||
// (space= URL param dropped → back to the list / empty detail).
|
||
await expect(
|
||
rail.locator('[data-testid="space-row"][data-space-kind="case"]').filter({ hasText: caseTitle }),
|
||
).toHaveCount(0, { timeout: 15000 });
|
||
await expect.poll(() => new URL(page.url()).searchParams.get('space')).toBeFalsy();
|
||
|
||
// Still on Spaces, never bounced to Tasks.
|
||
expect(new URL(page.url()).searchParams.get('page')).toBeFalsy();
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
// ── Workspace apps tab (feat/workspace-apps-stage2) ──────────────────────────
|
||
// The "アプリ" tab lists apps = subfolders under apps/ that contain an
|
||
// index.html, reading an optional app.json for title/description, and launches
|
||
// the selected one in the sandboxed AppRunner.
|
||
|
||
test('space apps: empty-state on a fresh workspace', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
await createAndOpenCaseSpace(page, 'E2EApps空');
|
||
|
||
await openSpaceTab(page, 'apps');
|
||
await expect(page.getByTestId('space-apps')).toBeVisible();
|
||
// A fresh case workspace has no apps/ folder → in-product empty-state.
|
||
await expect(page.getByTestId('space-apps-empty')).toBeVisible({ timeout: 15_000 });
|
||
await expect(page.getByTestId('space-apps-empty')).toContainText('ワークスペース・アプリを作って');
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|
||
|
||
test('space apps: seeded app lists with its title and opens in the AppRunner', async ({ page }) => {
|
||
const fatalErrors = trackFatalErrors(page);
|
||
const { spaceId } = await createAndOpenCaseSpace(page, 'E2EApps起動');
|
||
|
||
// Seed apps/demo/index.html (+ app.json) via the restricted write endpoint
|
||
// (only apps/ + output/ are writable, which is exactly where apps live).
|
||
const html =
|
||
'<!doctype html><meta charset="utf-8"><title>demo</title>' +
|
||
'<body><h1 id="hdr">デモアプリ</h1><script>document.getElementById("hdr").textContent="ready";</script>';
|
||
const w1 = await page.request.post(`/api/local/spaces/${spaceId}/files/write`, {
|
||
data: { path: 'apps/demo/index.html', content: html },
|
||
});
|
||
expect(w1.ok()).toBeTruthy();
|
||
const w2 = await page.request.post(`/api/local/spaces/${spaceId}/files/write`, {
|
||
data: {
|
||
path: 'apps/demo/app.json',
|
||
content: JSON.stringify({ title: 'デモ・アプリ', description: '起動テスト用' }),
|
||
},
|
||
});
|
||
expect(w2.ok()).toBeTruthy();
|
||
|
||
// Open the アプリ tab and assert the seeded app appears with its manifest title.
|
||
await openSpaceTab(page, 'apps');
|
||
await expect(page.getByTestId('space-apps')).toBeVisible();
|
||
const card = page.getByTestId('space-app-demo');
|
||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||
await expect(card).toContainText('デモ・アプリ');
|
||
await expect(card).toContainText('起動テスト用');
|
||
|
||
// 開く → AppRunner mounts (sandbox iframe overlay).
|
||
await page.getByTestId('space-app-open-demo').click();
|
||
await expect(page.getByTestId('app-runner')).toBeVisible({ timeout: 15_000 });
|
||
|
||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||
});
|