98 lines
5.1 KiB
TypeScript
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');
|
|
});
|