maestro/ui/e2e/tool-request.spec.ts
oss-sync b857c33ef6
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (f6d625db)
2026-06-26 03:35:45 +00:00

98 lines
5.1 KiB
TypeScript

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');
});