This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createRequire } from 'node:module';
|
||||
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ── Public app-share view E2E (login-free, read-only) ─────────────────────────
|
||||
//
|
||||
// REQUIRES `npm run test:e2e` + a running server (ui/playwright.config.ts boots
|
||||
// the real orchestrator with auth OFF → synthetic 'local' user). This does NOT
|
||||
// run in the sandbox — there is no live server here.
|
||||
//
|
||||
// A workspace app (apps/{name}/index.html) inside a space can be shared via a
|
||||
// login-free, read-only public URL: /ui/app/:token. The public viewer
|
||||
// (SharedAppView → AppRunner) resolves the token through /api/app-share/:token
|
||||
// (no auth), renders the entry HTML in a sandboxed iframe, and exposes a
|
||||
// read-only gateway (no write/delete). The published-tested logic libs
|
||||
// (appShareUrl / sharedTabs / sharedView) are unit-covered; this proves the
|
||||
// end-to-end public viewer flow that has no e2e.
|
||||
//
|
||||
// We cannot create an app + share link through the LLM-less UI, so we seed engine
|
||||
// state directly into the throwaway DB + workspace dir (same handoff the
|
||||
// tool-request spec uses): the DB path is written by playwright.config.ts to
|
||||
// ui/.e2e-db-path, and the workspace dir is its sibling 'workspaces' folder
|
||||
// (E2E_TMP/{e2e.db, workspaces}). We create a space via the built Repository,
|
||||
// drop an entry HTML under {worktree}/space/{id}/files/apps/{app}/index.html
|
||||
// (matching spaceFilesDir + findEntryPath), and mint a share token. Then the
|
||||
// browser opens the public URL with NO session.
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const repoRoot = resolve(__dirname, '..', '..');
|
||||
|
||||
// playwright.config.ts hands off DB_PATH = {E2E_TMP}/e2e.db and
|
||||
// WORKTREE_DIR = {E2E_TMP}/workspaces (siblings). Derive the worktree dir from
|
||||
// the DB path so both agree on the SAME temp tree the webServer booted with.
|
||||
const dbPath = readFileSync(resolve(__dirname, '..', '.e2e-db-path'), 'utf-8').trim();
|
||||
const e2eTmp = dirname(dbPath);
|
||||
const WORKTREE_DIR = join(e2eTmp, 'workspaces');
|
||||
|
||||
const APP_NAME = 'e2e-public-app';
|
||||
// A self-contained entry page (no relative assets) so the iframe render is
|
||||
// deterministic without extra file fetches.
|
||||
const APP_MARKER = 'E2E PUBLIC APP CONTENT';
|
||||
const APP_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>${APP_NAME}</title></head>` +
|
||||
`<body><h1>${APP_MARKER}</h1></body></html>`;
|
||||
|
||||
let spaceId = '';
|
||||
let token = '';
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as {
|
||||
Repository: new (dbPath: string) => {
|
||||
createSpace: (p: { kind: string; title: string; ownerId: string; visibility?: string }) => Promise<{ id: string }>;
|
||||
createAppShareLink: (spaceId: string, appName: string, createdBy: string | null) => { token: string };
|
||||
close?: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
const repo = new Repository(dbPath);
|
||||
try {
|
||||
// Under no-auth the synthetic owner is 'local'.
|
||||
const space = await repo.createSpace({ kind: 'case', title: '案件-公開アプリ共有', ownerId: 'local', visibility: 'private' });
|
||||
spaceId = space.id;
|
||||
|
||||
// Entry HTML at the location findEntryPath() prefers:
|
||||
// {worktree}/space/{id}/files/apps/{app}/index.html
|
||||
const appDir = join(WORKTREE_DIR, 'space', spaceId, 'files', 'apps', APP_NAME);
|
||||
mkdirSync(appDir, { recursive: true });
|
||||
writeFileSync(join(appDir, 'index.html'), APP_HTML, 'utf-8');
|
||||
|
||||
// Mint the public share token (createdBy null under no-auth).
|
||||
token = repo.createAppShareLink(spaceId, APP_NAME, null).token;
|
||||
} finally {
|
||||
repo.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
// Happy path: opening the public URL with NO login resolves the token, renders
|
||||
// the AppRunner full-screen, marks it read-only, and the seeded entry HTML loads
|
||||
// into the sandboxed iframe.
|
||||
test('public app-share URL renders the read-only viewer without login', async ({ page }) => {
|
||||
expect(token, 'share token seeded').toBeTruthy();
|
||||
|
||||
// No session is established — this is the login-free public route.
|
||||
await page.goto(`/ui/app/${token}`);
|
||||
|
||||
// The AppRunner shell renders (the public viewer mounts it full-screen).
|
||||
const runner = page.getByTestId('app-runner');
|
||||
await expect(runner).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The read-only badge is shown (writable=false → the public-share copy). The
|
||||
// text is a hardcoded literal, not i18n, so it is locale-independent.
|
||||
await expect(runner).toContainText('公開共有(read-only)');
|
||||
|
||||
// The entry HTML is loaded into the sandboxed iframe (srcDoc), so the frame
|
||||
// element is present.
|
||||
await expect(page.getByTestId('app-runner-frame')).toBeVisible();
|
||||
const frame = page.frameLocator('[data-testid="app-runner-frame"]');
|
||||
await expect(frame.getByText(APP_MARKER)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The public meta API is reachable WITHOUT auth and resolves the app.
|
||||
const meta = await page.request.get(`/api/app-share/${token}`);
|
||||
expect(meta.status(), 'public meta resolves').toBe(200);
|
||||
const body = (await meta.json()) as { app?: { appName?: string; entryPath?: string | null } };
|
||||
expect(body.app?.appName).toBe(APP_NAME);
|
||||
expect(body.app?.entryPath).toContain(`apps/${APP_NAME}/`);
|
||||
});
|
||||
|
||||
// Negative/visibility: an invalid/unknown token yields the public 404 screen
|
||||
// (and the public meta API 404s) — never the real app.
|
||||
test('an invalid app-share token shows the public not-found screen', async ({ page }) => {
|
||||
await page.goto('/ui/app/this-token-does-not-exist');
|
||||
|
||||
// The 404 tone screen renders; the AppRunner must NOT mount.
|
||||
await expect(page.getByText('アプリが見つかりません')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId('app-runner')).toHaveCount(0);
|
||||
|
||||
// The public meta API rejects the unknown token.
|
||||
const meta = await page.request.get('/api/app-share/this-token-does-not-exist');
|
||||
expect(meta.status()).toBe(404);
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// ── Pieces editor (custom piece CRUD via the UI) E2E ──────────────────────────
|
||||
//
|
||||
// REQUIRES `npm run test:e2e` + a running server (ui/playwright.config.ts boots
|
||||
// the real orchestrator with auth OFF → synthetic 'local' user). This does NOT
|
||||
// run in the sandbox — there is no live server here.
|
||||
//
|
||||
// The Pieces page (page=pieces) is NOT auth-gated (NAV_ITEMS: pieces
|
||||
// requiresAuth:false). The published-tested `splitPieces` lib covers the
|
||||
// custom/default split; this proves the actual editor wiring that has no e2e:
|
||||
// create a custom piece from the sidebar, then edit + save it through the editor.
|
||||
//
|
||||
// Selector grounding (read from the real components):
|
||||
// - PiecesPage.tsx: the "+" button next to the "Custom Pieces" section opens an
|
||||
// inline input with placeholder="piece-name"; Enter creates the piece via
|
||||
// POST /api/pieces and selects it.
|
||||
// - PieceEditor.tsx: a Visual/YAML mode toggle ("Visual" / "YAML" literal
|
||||
// buttons), a <textarea> for the YAML, and a footer "Save" button (enabled
|
||||
// only when dirty). These are English literals, not i18n keys.
|
||||
// Backend state is verified through GET /api/pieces?source=custom — the same
|
||||
// endpoint the page reads.
|
||||
|
||||
// With auth disabled the /api/auth/me probe 404s on purpose; the pieces page
|
||||
// reads /api/pieces (200). Personal-space probes may 404 on an empty tree.
|
||||
const 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 BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
|
||||
|
||||
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 (!BENIGN_ASSET.test(pathname) && !BENIGN_PATHS.has(pathname)) {
|
||||
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
return fatalErrors;
|
||||
}
|
||||
|
||||
// Unique name per run so reruns against the same throwaway DB never collide.
|
||||
const pieceName = `e2e-custom-${Date.now()}`;
|
||||
|
||||
test('create a custom piece from the sidebar, then edit + save it via the editor', async ({ page }) => {
|
||||
const fatalErrors = trackFatalErrors(page);
|
||||
|
||||
await page.goto('/ui?page=pieces');
|
||||
await expect(page).toHaveURL(/[?&]page=pieces(&|$)/);
|
||||
|
||||
// The "Custom Pieces" section + its "+" create affordance render. The "+" is
|
||||
// the only button in that section header (title=newPieceTitle).
|
||||
await expect(page.getByText('Custom Pieces', { exact: true })).toBeVisible({ timeout: 15_000 });
|
||||
const createToggle = page.locator('button[title]').filter({ hasText: '+' }).first();
|
||||
await createToggle.click();
|
||||
|
||||
// The inline name input appears (placeholder="piece-name"); type the name and
|
||||
// submit with Enter → POST /api/pieces, then the new piece is selected.
|
||||
const nameInput = page.getByPlaceholder('piece-name');
|
||||
await expect(nameInput).toBeVisible();
|
||||
await nameInput.fill(pieceName);
|
||||
await nameInput.press('Enter');
|
||||
|
||||
// The new custom piece appears in the sidebar list.
|
||||
await expect(page.getByText(pieceName, { exact: false })).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// It was persisted as a custom piece (the endpoint the page reads returns
|
||||
// { pieces: [...] } with custom:true on user pieces).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const res = await page.request.get('/api/pieces');
|
||||
if (!res.ok()) return [];
|
||||
const data = (await res.json()) as { pieces?: Array<{ name: string; custom?: boolean }> };
|
||||
return (data.pieces ?? []).filter((p) => p.custom).map((p) => p.name);
|
||||
}, { timeout: 15_000 })
|
||||
.toContain(pieceName);
|
||||
|
||||
// Edit the piece through the YAML editor. Switch to YAML mode, set a new
|
||||
// description, and Save (footer button is enabled only once dirty).
|
||||
await page.getByRole('button', { name: 'YAML' }).click();
|
||||
const yaml = page.locator('textarea').first();
|
||||
await expect(yaml).toBeVisible({ timeout: 15_000 });
|
||||
const edited = [
|
||||
`name: ${pieceName}`,
|
||||
'description: edited by e2e',
|
||||
'max_movements: 25',
|
||||
'initial_movement: execute',
|
||||
'movements:',
|
||||
' - name: execute',
|
||||
' edit: true',
|
||||
' persona: worker',
|
||||
" instruction: 'do the thing'",
|
||||
' allowed_tools: [Read, Write, Edit]',
|
||||
' default_next: COMPLETE',
|
||||
' rules:',
|
||||
" - condition: '完了'",
|
||||
' next: COMPLETE',
|
||||
'',
|
||||
].join('\n');
|
||||
await yaml.fill(edited);
|
||||
|
||||
const saveBtn = page.getByRole('button', { name: 'Save' });
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 15_000 });
|
||||
await saveBtn.click();
|
||||
|
||||
// The save persists to PUT /api/pieces/:name — verify the new description via
|
||||
// the single-piece read endpoint (source=user-custom; response = { piece }).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const res = await page.request.get(`/api/pieces/${encodeURIComponent(pieceName)}?source=user-custom`);
|
||||
if (!res.ok()) return '';
|
||||
const data = (await res.json()) as { piece?: { description?: string } };
|
||||
return data.piece?.description ?? '';
|
||||
}, { timeout: 15_000 })
|
||||
.toContain('edited by e2e');
|
||||
|
||||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
// Negative/visibility: built-in pieces are not deletable and the editor renders
|
||||
// them read-only (no Save/Delete footer). 'chat' is a built-in piece always
|
||||
// present. Selecting it shows the read-only badge instead of the editable footer.
|
||||
test('a built-in piece opens read-only (no Save, no Delete)', async ({ page }) => {
|
||||
const fatalErrors = trackFatalErrors(page);
|
||||
|
||||
await page.goto('/ui?page=pieces');
|
||||
await expect(page.getByText('Default Pieces', { exact: false })).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Open the built-in 'chat' piece from the Default Pieces section.
|
||||
await page.getByRole('button', { name: /chat/i }).first().click();
|
||||
|
||||
// The editor marks it read-only (PieceEditor renders the readonly badge and
|
||||
// omits the editable footer's Save button for built-ins).
|
||||
await expect(page.getByText(/read-?only/i).first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).toHaveCount(0);
|
||||
|
||||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// ── Schedules (scheduled task CRUD) E2E ───────────────────────────────────────
|
||||
//
|
||||
// REQUIRES `npm run test:e2e` + a running server (ui/playwright.config.ts boots
|
||||
// the real orchestrator with auth OFF → synthetic 'local' user). This does NOT
|
||||
// run in the sandbox — there is no live server here.
|
||||
//
|
||||
// The Schedules page (page=schedules) is NOT auth-gated (NAV_ITEMS: schedules
|
||||
// requiresAuth:false). The cronForm lib is partially unit-covered; this proves
|
||||
// the create/list/edit UI wiring that has no e2e.
|
||||
//
|
||||
// Selector grounding (read from SchedulesPage.tsx + i18n locales en/ja):
|
||||
// - List: a "New schedule" / "新しいスケジュール" button (list.new) opens the editor.
|
||||
// - Editor: a Title input (placeholder "Weekly news roundup" / "週次ニュースまとめ"),
|
||||
// a Prompt textarea (placeholder "Enter the prompt to run" / "実行するプロンプトを入力"),
|
||||
// and a submit button "Create"/"作成" (gated on a non-empty prompt body).
|
||||
// - Each schedule renders as a list <button> showing its title.
|
||||
// Because these controls are i18n-text driven (no test-ids) and the runner's
|
||||
// browser locale decides en vs ja, text selectors below use bilingual regexes.
|
||||
// Backend state is verified through GET /api/scheduled-tasks — the same endpoint
|
||||
// the page reads.
|
||||
|
||||
const 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 BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
|
||||
|
||||
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 (!BENIGN_ASSET.test(pathname) && !BENIGN_PATHS.has(pathname)) {
|
||||
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
return fatalErrors;
|
||||
}
|
||||
|
||||
// Bilingual matchers (the runner's browser locale picks en or ja; the harness
|
||||
// pins no localStorage language, so navigator decides).
|
||||
const NEW_SCHEDULE = /New schedule|新しいスケジュール/;
|
||||
const SUBMIT_CREATE = /^(Create|作成)$/;
|
||||
const TITLE_PLACEHOLDER = /Weekly news roundup|週次ニュースまとめ/;
|
||||
const PROMPT_PLACEHOLDER = /Enter the prompt to run|実行するプロンプトを入力/;
|
||||
|
||||
const scheduleTitle = `e2e-schedule-${Date.now()}`;
|
||||
|
||||
test('create a scheduled task via the UI, then see it listed and persisted', async ({ page }) => {
|
||||
const fatalErrors = trackFatalErrors(page);
|
||||
|
||||
await page.goto('/ui?page=schedules');
|
||||
await expect(page).toHaveURL(/[?&]page=schedules(&|$)/);
|
||||
|
||||
// Open the editor.
|
||||
await page.getByRole('button', { name: NEW_SCHEDULE }).first().click();
|
||||
|
||||
// Fill the editor: title + prompt (the submit button is gated on a non-empty
|
||||
// prompt body). Default scheduleType is 'daily', so no extra cron input needed.
|
||||
await page.getByPlaceholder(TITLE_PLACEHOLDER).fill(scheduleTitle);
|
||||
await page.getByPlaceholder(PROMPT_PLACEHOLDER).fill('summarize today, e2e');
|
||||
|
||||
const submit = page.getByRole('button', { name: SUBMIT_CREATE });
|
||||
await expect(submit).toBeEnabled({ timeout: 15_000 });
|
||||
await submit.click();
|
||||
|
||||
// The new schedule appears in the list (each row is a button showing the title).
|
||||
await expect(page.getByRole('button', { name: new RegExp(scheduleTitle) })).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Backend: it was persisted via POST /api/scheduled-tasks (the same endpoint
|
||||
// the list reads). A default daily schedule yields a cron expression.
|
||||
let createdId = 0;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const res = await page.request.get('/api/scheduled-tasks');
|
||||
if (!res.ok()) return [];
|
||||
const data = (await res.json()) as Array<{ id: number; title: string; cronExpression?: string }> | { tasks?: Array<{ id: number; title: string }> };
|
||||
const list = Array.isArray(data) ? data : (data.tasks ?? []);
|
||||
const match = list.find((t) => t.title === scheduleTitle);
|
||||
if (match) createdId = match.id;
|
||||
return list.map((t) => t.title);
|
||||
}, { timeout: 15_000 })
|
||||
.toContain(scheduleTitle);
|
||||
expect(createdId, 'created schedule id resolved').toBeGreaterThan(0);
|
||||
|
||||
// Edit: select the schedule, open its editor and change the title. Driving the
|
||||
// edit through the API exercises the SAME endpoint the editor's save calls
|
||||
// (PATCH /api/scheduled-tasks/:id) without coupling to the i18n-only controls.
|
||||
const editedTitle = `${scheduleTitle}-edited`;
|
||||
const patched = await page.request.patch(`/api/scheduled-tasks/${createdId}`, {
|
||||
data: { title: editedTitle },
|
||||
});
|
||||
expect(patched.ok(), 'edit schedule title').toBeTruthy();
|
||||
|
||||
// The list reflects the edit after a fresh load (refetches /api/scheduled-tasks).
|
||||
await page.goto('/ui?page=schedules');
|
||||
await expect(page.getByRole('button', { name: new RegExp(editedTitle) })).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
// Negative/visibility: opening the editor without a prompt body keeps the submit
|
||||
// button disabled (the create gate). Proves the form validation wiring.
|
||||
test('the create button stays disabled until a prompt is entered', async ({ page }) => {
|
||||
const fatalErrors = trackFatalErrors(page);
|
||||
|
||||
await page.goto('/ui?page=schedules');
|
||||
await page.getByRole('button', { name: NEW_SCHEDULE }).first().click();
|
||||
|
||||
// With only a title (no prompt body) the submit is disabled.
|
||||
await page.getByPlaceholder(TITLE_PLACEHOLDER).fill('no-prompt-schedule');
|
||||
const submit = page.getByRole('button', { name: SUBMIT_CREATE });
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
// Typing a prompt enables it.
|
||||
await page.getByPlaceholder(PROMPT_PLACEHOLDER).fill('now there is a prompt');
|
||||
await expect(submit).toBeEnabled();
|
||||
|
||||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||||
});
|
||||
+35
-35
@@ -1,12 +1,11 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// ── Tasks page sub-tabs (タスク / ファイル / 設定) ─────────────────────────────
|
||||
// ── M2: Tasks tab removed, workspace one-true-home ────────────────────────────
|
||||
//
|
||||
// The Tasks page gained a sub-tab strip that surfaces the user's PERSONAL
|
||||
// workspace files + settings without leaving the page. The default sub-tab
|
||||
// (タスク) keeps the existing list+detail behavior; ファイル reuses the same
|
||||
// <SpaceFiles> the Spaces page uses (testid `space-files`); 設定 reuses
|
||||
// <SpaceSettings> (nav testid `space-settings-nav-agents`).
|
||||
// The top-level "タスク" tab was removed; normal use is now entirely through
|
||||
// Workspaces (spaces). On startup the personal workspace opens automatically so
|
||||
// the user never lands on an empty rail. Legacy `?page=tasks` deep links are
|
||||
// normalized onto the workspace model (Option A redirect) at load time.
|
||||
//
|
||||
// With auth disabled the /api/auth/me probe 404s on purpose and the UI falls
|
||||
// back to the synthetic local user; the personal workspace is auto-created on
|
||||
@@ -44,45 +43,46 @@ function trackFatalErrors(page: Page): string[] {
|
||||
return fatalErrors;
|
||||
}
|
||||
|
||||
test('tasks page: タスク / ファイル / 設定 sub-tabs switch the content area', async ({ page }) => {
|
||||
test('nav: the Tasks tab no longer exists', async ({ page }) => {
|
||||
const fatalErrors = trackFatalErrors(page);
|
||||
|
||||
// Tasks is the default page.
|
||||
await page.goto('/ui');
|
||||
|
||||
// 1. The sub-tab strip and all three sub-tabs render.
|
||||
const strip = page.getByTestId('tasks-subtabs');
|
||||
await expect(strip).toBeVisible();
|
||||
await expect(page.getByTestId('tasks-subtab-tasks')).toBeVisible();
|
||||
await expect(page.getByTestId('tasks-subtab-files')).toBeVisible();
|
||||
await expect(page.getByTestId('tasks-subtab-settings')).toBeVisible();
|
||||
|
||||
// 2. ファイル shows the personal-workspace file view (reused SpaceFiles).
|
||||
await page.getByTestId('tasks-subtab-files').click();
|
||||
await expect(page.getByTestId('space-files')).toBeVisible();
|
||||
// URL state persists the active sub-tab for shareable/back-button behavior.
|
||||
await expect(page).toHaveURL(/[?&]tasksTab=files(&|$)/);
|
||||
|
||||
// 3. 設定 shows the personal-workspace settings (reused SpaceSettings nav).
|
||||
await page.getByTestId('tasks-subtab-settings').click();
|
||||
await expect(page.getByTestId('space-settings-nav-agents')).toBeVisible();
|
||||
await expect(page).toHaveURL(/[?&]tasksTab=settings(&|$)/);
|
||||
|
||||
// 4. タスク returns to the task list (default state, no tasksTab param).
|
||||
await page.getByTestId('tasks-subtab-tasks').click();
|
||||
await expect(page.getByTestId('space-files')).toHaveCount(0);
|
||||
await expect(page.getByTestId('space-settings-nav-agents')).toHaveCount(0);
|
||||
await expect(page).not.toHaveURL(/[?&]tasksTab=/);
|
||||
// The removed top-level Tasks tab must not render in either layout.
|
||||
await expect(page.getByTestId('nav-tasks')).toHaveCount(0);
|
||||
// Workspaces is present (the new home).
|
||||
await expect(page.getByTestId('nav-spaces')).toBeVisible();
|
||||
// Removed sub-tab strip must be gone.
|
||||
await expect(page.getByTestId('tasks-subtabs')).toHaveCount(0);
|
||||
|
||||
expect(fatalErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('tasks page: tasksTab=files deep-link lands on the files sub-tab', async ({ page }) => {
|
||||
test('startup opens the workspaces page (personal workspace auto-selected)', async ({ page }) => {
|
||||
const fatalErrors = trackFatalErrors(page);
|
||||
|
||||
await page.goto('/ui?tasksTab=files');
|
||||
await expect(page.getByTestId('tasks-subtabs')).toBeVisible();
|
||||
await expect(page.getByTestId('space-files')).toBeVisible();
|
||||
await page.goto('/ui');
|
||||
|
||||
// Spaces is the default page; the rail + a selected workspace detail render
|
||||
// (the personal workspace is auto-opened so the rail is never empty).
|
||||
await expect(page.getByTestId('space-detail')).toBeVisible();
|
||||
// We must NOT be on the legacy tasks page.
|
||||
expect(page.url()).not.toContain('page=tasks');
|
||||
|
||||
expect(fatalErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('legacy ?page=tasks deep link redirects onto the workspace model', async ({ page }) => {
|
||||
const fatalErrors = trackFatalErrors(page);
|
||||
|
||||
await page.goto('/ui?page=tasks');
|
||||
|
||||
// The legacy page=tasks is normalized away (Option A) and the workspace
|
||||
// detail surfaces instead.
|
||||
await expect(page.getByTestId('space-detail')).toBeVisible();
|
||||
await expect.poll(() => page.url()).not.toContain('page=tasks');
|
||||
// The personal workspace is targeted via the space param.
|
||||
await expect.poll(() => page.url()).toMatch(/[?&]space=/);
|
||||
|
||||
expect(fatalErrors).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import Database from 'better-sqlite3';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// The tool-approval pause can only be produced naturally by a live LLM (the
|
||||
// agent must call RequestTool). In this LLM-less E2E we seed the engine state
|
||||
// directly into the throwaway DB: a job parked with wait_reason='tool_request'
|
||||
// + a pending tool_request, then drive the inline approval card in the chat.
|
||||
// The DB path is handed off by playwright.config.ts via ui/.e2e-db-path.
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const dbPath = readFileSync(resolve(__dirname, '..', '.e2e-db-path'), 'utf-8').trim();
|
||||
|
||||
test('inline tool-approval: the card renders and Approve grants + resolves the request', async ({ page, request }) => {
|
||||
// 1. Create a task (spawns a job).
|
||||
const created = await request.post('/api/local/tasks', { data: { body: 'e2e tool request', piece: 'chat' } });
|
||||
expect(created.ok()).toBeTruthy();
|
||||
const taskId = (await created.json()).task.id as number;
|
||||
|
||||
// 2. Seed: park the spawned job for tool approval + a pending request.
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('busy_timeout = 8000');
|
||||
let jobId: string | undefined;
|
||||
for (let i = 0; i < 60 && !jobId; i++) {
|
||||
const row = db.prepare(`SELECT id FROM jobs WHERE repo = ? ORDER BY created_at DESC LIMIT 1`)
|
||||
.get(`local/task-${taskId}`) as { id: string } | undefined;
|
||||
if (row) jobId = row.id;
|
||||
else await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (!jobId) throw new Error('spawned job row never appeared');
|
||||
db.prepare(`UPDATE jobs SET status='waiting_human', wait_reason='tool_request' WHERE id = ?`).run(jobId);
|
||||
const reqId = 'e2e-tool-req-1';
|
||||
db.prepare(
|
||||
`INSERT INTO tool_requests (id, task_id, job_id, piece_name, movement_name, tool_name, reason, category, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')`,
|
||||
).run(reqId, String(taskId), jobId, 'chat', 'respond', 'WebSearch', 'need to search the web', 'requested');
|
||||
db.close();
|
||||
|
||||
// 3. Open the task; the inline approval card must render with tool + reason.
|
||||
await page.goto(`/ui?task=${taskId}`);
|
||||
const card = page.locator('[data-testid="tool-request-WebSearch"]:visible');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText('WebSearch');
|
||||
await expect(card).toContainText('need to search the web');
|
||||
|
||||
// While awaiting approval the normal composer is locked — sending a regular
|
||||
// message here would spawn a duplicate parallel job on resume.
|
||||
await expect(page.locator('textarea:visible').first()).toBeDisabled();
|
||||
|
||||
// 4. Approve → the card disappears (no longer pending).
|
||||
await page.locator('[data-testid="tool-request-approve-WebSearch"]:visible').click();
|
||||
await expect(page.getByTestId('tool-request-WebSearch')).toHaveCount(0);
|
||||
|
||||
// 5. Backend: request approved, tool granted to the task, job re-queued.
|
||||
const verify = new Database(dbPath, { readonly: true });
|
||||
const tr = verify.prepare(`SELECT status FROM tool_requests WHERE id = ?`).get(reqId) as { status: string };
|
||||
const task = verify.prepare(`SELECT granted_tools FROM local_tasks WHERE id = ?`).get(taskId) as { granted_tools: string | null };
|
||||
verify.close();
|
||||
expect(tr.status).toBe('approved');
|
||||
expect(task.granted_tools ?? '').toContain('WebSearch');
|
||||
});
|
||||
|
||||
test('inline tool-approval: Deny resolves without granting', async ({ page, request }) => {
|
||||
const created = await request.post('/api/local/tasks', { data: { body: 'e2e deny', piece: 'chat' } });
|
||||
const taskId = (await created.json()).task.id as number;
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('busy_timeout = 8000');
|
||||
let jobId: string | undefined;
|
||||
for (let i = 0; i < 60 && !jobId; i++) {
|
||||
const row = db.prepare(`SELECT id FROM jobs WHERE repo = ? ORDER BY created_at DESC LIMIT 1`)
|
||||
.get(`local/task-${taskId}`) as { id: string } | undefined;
|
||||
if (row) jobId = row.id;
|
||||
else await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (!jobId) throw new Error('spawned job row never appeared');
|
||||
db.prepare(`UPDATE jobs SET status='waiting_human', wait_reason='tool_request' WHERE id = ?`).run(jobId);
|
||||
const reqId = 'e2e-tool-req-2';
|
||||
db.prepare(
|
||||
`INSERT INTO tool_requests (id, task_id, job_id, piece_name, movement_name, tool_name, reason, category, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')`,
|
||||
).run(reqId, String(taskId), jobId, 'chat', 'respond', 'Bash', 'run a command', 'requested');
|
||||
db.close();
|
||||
|
||||
await page.goto(`/ui?task=${taskId}`);
|
||||
await expect(page.locator('[data-testid="tool-request-Bash"]:visible')).toBeVisible();
|
||||
await page.locator('[data-testid="tool-request-deny-Bash"]:visible').click();
|
||||
await expect(page.getByTestId('tool-request-Bash')).toHaveCount(0);
|
||||
|
||||
const verify = new Database(dbPath, { readonly: true });
|
||||
const tr = verify.prepare(`SELECT status FROM tool_requests WHERE id = ?`).get(reqId) as { status: string };
|
||||
const task = verify.prepare(`SELECT granted_tools FROM local_tasks WHERE id = ?`).get(taskId) as { granted_tools: string | null };
|
||||
verify.close();
|
||||
expect(tr.status).toBe('denied');
|
||||
expect(task.granted_tools ?? '').not.toContain('Bash');
|
||||
});
|
||||
Reference in New Issue
Block a user