| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376 |
- /**
- * End-to-end smoke test for TUI permission prompt.
- *
- * Uses `ink-testing-library` to render `TuiApp` with a **mock Gateway**
- * that deterministically emits `permission_request` events. Zero external
- * dependencies — no model API, no server, runs in ~3 seconds.
- *
- * Covers: y (allow once), a (allow + remember), n (deny), Esc (abort).
- *
- * Usage:
- * npx tsx scripts/tui-e2e-permission.tsx
- */
- import React from "react";
- import { render } from "ink-testing-library";
- import { TuiApp } from "../src/adapters/channel/tui/app/TuiApp.js";
- import { readPermissionSettings, writePermissionSettings } from "../src/permission/settings.js";
- import type { Gateway, GatewayEvent, GatewaySubmitTurnInput } from "../src/gateway/index.js";
- // ──────────────── Mock Gateway ────────────────
- type PendingPermission = {
- resolve: (d: { decision: "allow" | "deny"; remember?: boolean; reason?: string }) => void;
- };
- const noop = async () => {};
- const stub = <T,>(v: T) => async () => v;
- class MockGateway implements Gateway {
- private pending = new Map<string, PendingPermission>();
- private aborted = false;
- async *submitTurn(input: GatewaySubmitTurnInput): AsyncIterable<GatewayEvent> {
- this.aborted = false;
- yield { type: "turn_started", runId: "run-1" };
- const requestId = `perm-${Date.now()}`;
- const decisionPromise = new Promise<{ decision: "allow" | "deny"; remember?: boolean; reason?: string }>((resolve) => {
- this.pending.set(requestId, { resolve });
- });
- yield {
- type: "permission_request",
- requestId,
- toolName: "dangerous_action",
- payload: { action: input.message },
- };
- const decision = await decisionPromise;
- this.pending.delete(requestId);
- if (this.aborted) {
- yield { type: "turn_completed", usage: {}, finishReason: "completed" } as GatewayEvent;
- return;
- }
- if (decision.decision === "allow") {
- yield { type: "assistant_text_delta", text: "Action executed successfully." };
- yield {
- type: "tool_call_finished",
- toolCallId: "tc-1",
- ok: true,
- resultPreview: "ok",
- toolName: "dangerous_action",
- };
- } else {
- yield { type: "assistant_text_delta", text: "Permission denied by user." };
- }
- yield { type: "turn_completed", usage: {}, finishReason: "completed" } as GatewayEvent;
- }
- async permissionDecide(input: { requestId: string; decision: "allow" | "deny"; remember?: boolean; reason?: string }): Promise<{ delivered: boolean }> {
- const entry = this.pending.get(input.requestId);
- if (!entry) return { delivered: false };
- entry.resolve({ decision: input.decision, remember: input.remember, reason: input.reason });
- return { delivered: true };
- }
- async abortTurn(): Promise<void> {
- this.aborted = true;
- for (const [, entry] of this.pending) {
- entry.resolve({ decision: "deny", reason: "aborted" });
- }
- this.pending.clear();
- }
- listSessions = stub({ sessions: [] as never[] });
- resumeSession = stub({ sessionKey: "s" });
- newSession = stub({ sessionKey: `new-${Date.now()}` });
- closeSession = noop as Gateway["closeSession"];
- describeServer = stub({ mode: "in_process" as const });
- cronCreate = stub({ taskId: "c", task: {} as any, created: true }) as unknown as Gateway["cronCreate"];
- cronList = stub({ tasks: [] }) as Gateway["cronList"];
- cronDelete = stub({ deleted: true }) as Gateway["cronDelete"];
- cronStop = stub({ stopped: true }) as Gateway["cronStop"];
- cronRunNow = stub({ triggered: true }) as unknown as Gateway["cronRunNow"];
- respondElicitation = stub({ delivered: false }) as Gateway["respondElicitation"];
- grantSessionPermission = stub({ granted: false }) as Gateway["grantSessionPermission"];
- readSessionMessages = stub({ messages: [], hasMore: false, session: {} as any }) as unknown as Gateway["readSessionMessages"];
- listProjects = stub({ projects: [] }) as Gateway["listProjects"];
- describeProject = stub({ projectKey: "", name: "", root: "", fullPath: "", sessionCount: 0 }) as unknown as Gateway["describeProject"];
- }
- // ──────────────── helpers ────────────────
- function wait(ms: number): Promise<void> {
- return new Promise((r) => setTimeout(r, ms));
- }
- async function typeString(instance: ReturnType<typeof render>, text: string): Promise<void> {
- for (const ch of text) {
- instance.stdin.write(ch);
- await wait(5);
- }
- }
- async function waitForFrame(
- instance: ReturnType<typeof render>,
- pattern: RegExp,
- timeoutMs: number,
- label: string,
- ): Promise<string> {
- const deadline = Date.now() + timeoutMs;
- while (Date.now() < deadline) {
- const frame = instance.lastFrame() ?? "";
- if (pattern.test(frame)) return frame;
- await wait(50);
- }
- const last = instance.lastFrame() ?? "(empty)";
- throw new Error(`Timeout (${label}). Pattern: ${pattern}\nLast frame:\n${last}`);
- }
- async function waitForNoPattern(
- instance: ReturnType<typeof render>,
- pattern: RegExp,
- timeoutMs: number,
- ): Promise<string> {
- const deadline = Date.now() + timeoutMs;
- while (Date.now() < deadline) {
- const frame = instance.lastFrame() ?? "";
- if (!pattern.test(frame)) return frame;
- await wait(50);
- }
- return instance.lastFrame() ?? "";
- }
- type TestResult = { name: string; pass: boolean; detail: string };
- const results: TestResult[] = [];
- function pass(name: string, detail = "") {
- results.push({ name, pass: true, detail });
- process.stdout.write(` ✓ ${name}\n`);
- }
- function fail(name: string, detail: string) {
- results.push({ name, pass: false, detail });
- process.stderr.write(` ✗ ${name}: ${detail}\n`);
- }
- function renderTui() {
- const gw = new MockGateway();
- const cwd = process.cwd();
- const instance = render(
- <TuiApp gateway={gw} connection="in_process" projectKey={cwd} cwd={cwd} model="mock" />,
- );
- return { instance, gw };
- }
- // ──────────────── Test: y (allow once) ────────────────
- async function testAllowOnce(): Promise<void> {
- const name = "y — allow once";
- process.stdout.write(`\n▸ ${name}\n`);
- const { instance } = renderTui();
- try {
- await wait(100);
- await typeString(instance, "do something dangerous");
- instance.stdin.write("\r");
- const permFrame = await waitForFrame(instance, /Permission required/, 5_000, "permission prompt");
- if (/dangerous_action/.test(permFrame)) {
- pass(`${name}: prompt shows tool name`);
- } else {
- fail(`${name}: prompt shows tool name`, "tool name not in frame");
- }
- if (/\[y\].*\[a\].*\[n\].*\[Esc\]/.test(permFrame)) {
- pass(`${name}: prompt shows all keybindings`);
- } else {
- fail(`${name}: prompt shows keybindings`, `frame: ${permFrame.slice(-200)}`);
- }
- instance.stdin.write("y");
- const afterFrame = await waitForNoPattern(instance, /Permission required/, 3_000);
- if (!/Permission required/.test(afterFrame)) {
- pass(`${name}: prompt dismissed`);
- } else {
- fail(`${name}: prompt dismissed`, "prompt still visible");
- }
- if (/executed successfully/.test(afterFrame)) {
- pass(`${name}: tool executed`);
- } else {
- fail(`${name}: tool executed`, `frame snippet: ${afterFrame.slice(0, 300)}`);
- }
- } finally {
- instance.unmount();
- }
- }
- // ──────────────── Test: a (allow + remember) ────────────────
- async function testAllowRemember(): Promise<void> {
- const name = "a — allow + remember";
- process.stdout.write(`\n▸ ${name}\n`);
- const originalSettings = readPermissionSettings();
- writePermissionSettings({ allowedTools: [], disallowedTools: [], skipPermissions: false });
- const { instance } = renderTui();
- try {
- await wait(100);
- await typeString(instance, "do something memorable");
- instance.stdin.write("\r");
- await waitForFrame(instance, /Permission required/, 5_000, "permission prompt");
- instance.stdin.write("a");
- await waitForNoPattern(instance, /Permission required/, 3_000);
- const updated = readPermissionSettings();
- if (updated.allowedTools.includes("dangerous_action")) {
- pass(`${name}: rule persisted to permissions.json`);
- } else {
- fail(`${name}: rule persisted`, `allowedTools: ${JSON.stringify(updated.allowedTools)}`);
- }
- } finally {
- instance.unmount();
- writePermissionSettings(originalSettings);
- }
- }
- // ──────────────── Test: n (deny) ────────────────
- async function testDeny(): Promise<void> {
- const name = "n — deny";
- process.stdout.write(`\n▸ ${name}\n`);
- const { instance } = renderTui();
- try {
- await wait(100);
- await typeString(instance, "do something denied");
- instance.stdin.write("\r");
- await waitForFrame(instance, /Permission required/, 5_000, "permission prompt");
- instance.stdin.write("n");
- const afterFrame = await waitForNoPattern(instance, /Permission required/, 3_000);
- if (!/Permission required/.test(afterFrame)) {
- pass(`${name}: prompt dismissed`);
- } else {
- fail(`${name}: prompt dismissed`, "still visible");
- }
- if (/denied/.test(afterFrame) || !/executed successfully/.test(afterFrame)) {
- pass(`${name}: tool NOT executed`);
- } else {
- fail(`${name}: tool NOT executed`, "tool appears to have run");
- }
- } finally {
- instance.unmount();
- }
- }
- // ──────────────── Test: Esc (abort) ────────────────
- async function testAbort(): Promise<void> {
- const name = "Esc — abort turn";
- process.stdout.write(`\n▸ ${name}\n`);
- const { instance } = renderTui();
- try {
- await wait(100);
- await typeString(instance, "do something abortable");
- instance.stdin.write("\r");
- await waitForFrame(instance, /Permission required/, 5_000, "permission prompt");
- instance.stdin.write("\x1B"); // Escape
- const afterFrame = await waitForNoPattern(instance, /Permission required/, 3_000);
- if (!/Permission required/.test(afterFrame)) {
- pass(`${name}: prompt dismissed`);
- } else {
- fail(`${name}: prompt dismissed`, "still visible");
- }
- if (!/executed successfully/.test(afterFrame)) {
- pass(`${name}: turn aborted (no tool output)`);
- } else {
- fail(`${name}: turn aborted`, "tool executed despite abort");
- }
- } finally {
- instance.unmount();
- }
- }
- // ──────────────── Test: bypass mode skips prompt ────────────────
- async function testBypassMode(): Promise<void> {
- const name = "/mode bypassPermissions";
- process.stdout.write(`\n▸ ${name}\n`);
- const originalSettings = readPermissionSettings();
- const { instance } = renderTui();
- try {
- await wait(100);
- await typeString(instance, "/mode bypassPermissions");
- instance.stdin.write("\r");
- await wait(200);
- const modeFrame = instance.lastFrame() ?? "";
- if (/bypassPermissions/.test(modeFrame)) {
- pass(`${name}: mode changed`);
- } else {
- fail(`${name}: mode changed`, `frame: ${modeFrame.slice(0, 200)}`);
- }
- const updated = readPermissionSettings();
- if (updated.skipPermissions === true) {
- pass(`${name}: skipPermissions persisted`);
- } else {
- fail(`${name}: skipPermissions persisted`, `got: ${JSON.stringify(updated)}`);
- }
- } finally {
- instance.unmount();
- writePermissionSettings(originalSettings);
- }
- }
- // ──────────────── main ────────────────
- async function main(): Promise<void> {
- process.stdout.write("═══════════════════════════════════════════════\n");
- process.stdout.write(" TUI Permission Prompt — E2E Smoke Test\n");
- process.stdout.write(" (mock gateway, no model API needed)\n");
- process.stdout.write("═══════════════════════════════════════════════\n");
- const tests = [testAllowOnce, testAllowRemember, testDeny, testAbort, testBypassMode];
- for (const test of tests) {
- try {
- await test();
- } catch (error) {
- fail(test.name, error instanceof Error ? error.message : String(error));
- }
- }
- process.stdout.write("\n═══════════════════════════════════════════════\n");
- const passed = results.filter((r) => r.pass).length;
- const failed = results.filter((r) => !r.pass).length;
- process.stdout.write(` Results: ${passed} passed, ${failed} failed (${results.length} total)\n`);
- process.stdout.write("═══════════════════════════════════════════════\n");
- if (failed > 0) {
- process.stdout.write("\nFailed:\n");
- for (const r of results.filter((r) => !r.pass)) {
- process.stdout.write(` ✗ ${r.name}: ${r.detail}\n`);
- }
- process.exitCode = 1;
- }
- }
- main().catch((error) => {
- console.error(error);
- process.exitCode = 1;
- });
|