* 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";
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"];
}
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 };
}
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();
}
}
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);
}
}
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();
}
}
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");
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();
}
}
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);
}
}
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;
});