sync: update from private repo (f6d625db)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-26 03:35:45 +00:00
parent 29ccaf1e92
commit b857c33ef6
371 changed files with 31312 additions and 8172 deletions
+122
View File
@@ -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);
});
+147
View File
@@ -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([]);
});
+128
View File
@@ -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
View File
@@ -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([]);
});
+97
View File
@@ -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');
});