tui-e2e-permission.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. /**
  2. * End-to-end smoke test for TUI permission prompt.
  3. *
  4. * Uses `ink-testing-library` to render `TuiApp` with a **mock Gateway**
  5. * that deterministically emits `permission_request` events. Zero external
  6. * dependencies — no model API, no server, runs in ~3 seconds.
  7. *
  8. * Covers: y (allow once), a (allow + remember), n (deny), Esc (abort).
  9. *
  10. * Usage:
  11. * npx tsx scripts/tui-e2e-permission.tsx
  12. */
  13. import React from "react";
  14. import { render } from "ink-testing-library";
  15. import { TuiApp } from "../src/adapters/channel/tui/app/TuiApp.js";
  16. import { readPermissionSettings, writePermissionSettings } from "../src/permission/settings.js";
  17. import type { Gateway, GatewayEvent, GatewaySubmitTurnInput } from "../src/gateway/index.js";
  18. // ──────────────── Mock Gateway ────────────────
  19. type PendingPermission = {
  20. resolve: (d: { decision: "allow" | "deny"; remember?: boolean; reason?: string }) => void;
  21. };
  22. const noop = async () => {};
  23. const stub = <T,>(v: T) => async () => v;
  24. class MockGateway implements Gateway {
  25. private pending = new Map<string, PendingPermission>();
  26. private aborted = false;
  27. async *submitTurn(input: GatewaySubmitTurnInput): AsyncIterable<GatewayEvent> {
  28. this.aborted = false;
  29. yield { type: "turn_started", runId: "run-1" };
  30. const requestId = `perm-${Date.now()}`;
  31. const decisionPromise = new Promise<{ decision: "allow" | "deny"; remember?: boolean; reason?: string }>((resolve) => {
  32. this.pending.set(requestId, { resolve });
  33. });
  34. yield {
  35. type: "permission_request",
  36. requestId,
  37. toolName: "dangerous_action",
  38. payload: { action: input.message },
  39. };
  40. const decision = await decisionPromise;
  41. this.pending.delete(requestId);
  42. if (this.aborted) {
  43. yield { type: "turn_completed", usage: {}, finishReason: "completed" } as GatewayEvent;
  44. return;
  45. }
  46. if (decision.decision === "allow") {
  47. yield { type: "assistant_text_delta", text: "Action executed successfully." };
  48. yield {
  49. type: "tool_call_finished",
  50. toolCallId: "tc-1",
  51. ok: true,
  52. resultPreview: "ok",
  53. toolName: "dangerous_action",
  54. };
  55. } else {
  56. yield { type: "assistant_text_delta", text: "Permission denied by user." };
  57. }
  58. yield { type: "turn_completed", usage: {}, finishReason: "completed" } as GatewayEvent;
  59. }
  60. async permissionDecide(input: { requestId: string; decision: "allow" | "deny"; remember?: boolean; reason?: string }): Promise<{ delivered: boolean }> {
  61. const entry = this.pending.get(input.requestId);
  62. if (!entry) return { delivered: false };
  63. entry.resolve({ decision: input.decision, remember: input.remember, reason: input.reason });
  64. return { delivered: true };
  65. }
  66. async abortTurn(): Promise<void> {
  67. this.aborted = true;
  68. for (const [, entry] of this.pending) {
  69. entry.resolve({ decision: "deny", reason: "aborted" });
  70. }
  71. this.pending.clear();
  72. }
  73. listSessions = stub({ sessions: [] as never[] });
  74. resumeSession = stub({ sessionKey: "s" });
  75. newSession = stub({ sessionKey: `new-${Date.now()}` });
  76. closeSession = noop as Gateway["closeSession"];
  77. describeServer = stub({ mode: "in_process" as const });
  78. cronCreate = stub({ taskId: "c", task: {} as any, created: true }) as unknown as Gateway["cronCreate"];
  79. cronList = stub({ tasks: [] }) as Gateway["cronList"];
  80. cronDelete = stub({ deleted: true }) as Gateway["cronDelete"];
  81. cronStop = stub({ stopped: true }) as Gateway["cronStop"];
  82. cronRunNow = stub({ triggered: true }) as unknown as Gateway["cronRunNow"];
  83. respondElicitation = stub({ delivered: false }) as Gateway["respondElicitation"];
  84. grantSessionPermission = stub({ granted: false }) as Gateway["grantSessionPermission"];
  85. readSessionMessages = stub({ messages: [], hasMore: false, session: {} as any }) as unknown as Gateway["readSessionMessages"];
  86. listProjects = stub({ projects: [] }) as Gateway["listProjects"];
  87. describeProject = stub({ projectKey: "", name: "", root: "", fullPath: "", sessionCount: 0 }) as unknown as Gateway["describeProject"];
  88. }
  89. // ──────────────── helpers ────────────────
  90. function wait(ms: number): Promise<void> {
  91. return new Promise((r) => setTimeout(r, ms));
  92. }
  93. async function typeString(instance: ReturnType<typeof render>, text: string): Promise<void> {
  94. for (const ch of text) {
  95. instance.stdin.write(ch);
  96. await wait(5);
  97. }
  98. }
  99. async function waitForFrame(
  100. instance: ReturnType<typeof render>,
  101. pattern: RegExp,
  102. timeoutMs: number,
  103. label: string,
  104. ): Promise<string> {
  105. const deadline = Date.now() + timeoutMs;
  106. while (Date.now() < deadline) {
  107. const frame = instance.lastFrame() ?? "";
  108. if (pattern.test(frame)) return frame;
  109. await wait(50);
  110. }
  111. const last = instance.lastFrame() ?? "(empty)";
  112. throw new Error(`Timeout (${label}). Pattern: ${pattern}\nLast frame:\n${last}`);
  113. }
  114. async function waitForNoPattern(
  115. instance: ReturnType<typeof render>,
  116. pattern: RegExp,
  117. timeoutMs: number,
  118. ): Promise<string> {
  119. const deadline = Date.now() + timeoutMs;
  120. while (Date.now() < deadline) {
  121. const frame = instance.lastFrame() ?? "";
  122. if (!pattern.test(frame)) return frame;
  123. await wait(50);
  124. }
  125. return instance.lastFrame() ?? "";
  126. }
  127. type TestResult = { name: string; pass: boolean; detail: string };
  128. const results: TestResult[] = [];
  129. function pass(name: string, detail = "") {
  130. results.push({ name, pass: true, detail });
  131. process.stdout.write(` ✓ ${name}\n`);
  132. }
  133. function fail(name: string, detail: string) {
  134. results.push({ name, pass: false, detail });
  135. process.stderr.write(` ✗ ${name}: ${detail}\n`);
  136. }
  137. function renderTui() {
  138. const gw = new MockGateway();
  139. const cwd = process.cwd();
  140. const instance = render(
  141. <TuiApp gateway={gw} connection="in_process" projectKey={cwd} cwd={cwd} model="mock" />,
  142. );
  143. return { instance, gw };
  144. }
  145. // ──────────────── Test: y (allow once) ────────────────
  146. async function testAllowOnce(): Promise<void> {
  147. const name = "y — allow once";
  148. process.stdout.write(`\n▸ ${name}\n`);
  149. const { instance } = renderTui();
  150. try {
  151. await wait(100);
  152. await typeString(instance, "do something dangerous");
  153. instance.stdin.write("\r");
  154. const permFrame = await waitForFrame(instance, /Permission required/, 5_000, "permission prompt");
  155. if (/dangerous_action/.test(permFrame)) {
  156. pass(`${name}: prompt shows tool name`);
  157. } else {
  158. fail(`${name}: prompt shows tool name`, "tool name not in frame");
  159. }
  160. if (/\[y\].*\[a\].*\[n\].*\[Esc\]/.test(permFrame)) {
  161. pass(`${name}: prompt shows all keybindings`);
  162. } else {
  163. fail(`${name}: prompt shows keybindings`, `frame: ${permFrame.slice(-200)}`);
  164. }
  165. instance.stdin.write("y");
  166. const afterFrame = await waitForNoPattern(instance, /Permission required/, 3_000);
  167. if (!/Permission required/.test(afterFrame)) {
  168. pass(`${name}: prompt dismissed`);
  169. } else {
  170. fail(`${name}: prompt dismissed`, "prompt still visible");
  171. }
  172. if (/executed successfully/.test(afterFrame)) {
  173. pass(`${name}: tool executed`);
  174. } else {
  175. fail(`${name}: tool executed`, `frame snippet: ${afterFrame.slice(0, 300)}`);
  176. }
  177. } finally {
  178. instance.unmount();
  179. }
  180. }
  181. // ──────────────── Test: a (allow + remember) ────────────────
  182. async function testAllowRemember(): Promise<void> {
  183. const name = "a — allow + remember";
  184. process.stdout.write(`\n▸ ${name}\n`);
  185. const originalSettings = readPermissionSettings();
  186. writePermissionSettings({ allowedTools: [], disallowedTools: [], skipPermissions: false });
  187. const { instance } = renderTui();
  188. try {
  189. await wait(100);
  190. await typeString(instance, "do something memorable");
  191. instance.stdin.write("\r");
  192. await waitForFrame(instance, /Permission required/, 5_000, "permission prompt");
  193. instance.stdin.write("a");
  194. await waitForNoPattern(instance, /Permission required/, 3_000);
  195. const updated = readPermissionSettings();
  196. if (updated.allowedTools.includes("dangerous_action")) {
  197. pass(`${name}: rule persisted to permissions.json`);
  198. } else {
  199. fail(`${name}: rule persisted`, `allowedTools: ${JSON.stringify(updated.allowedTools)}`);
  200. }
  201. } finally {
  202. instance.unmount();
  203. writePermissionSettings(originalSettings);
  204. }
  205. }
  206. // ──────────────── Test: n (deny) ────────────────
  207. async function testDeny(): Promise<void> {
  208. const name = "n — deny";
  209. process.stdout.write(`\n▸ ${name}\n`);
  210. const { instance } = renderTui();
  211. try {
  212. await wait(100);
  213. await typeString(instance, "do something denied");
  214. instance.stdin.write("\r");
  215. await waitForFrame(instance, /Permission required/, 5_000, "permission prompt");
  216. instance.stdin.write("n");
  217. const afterFrame = await waitForNoPattern(instance, /Permission required/, 3_000);
  218. if (!/Permission required/.test(afterFrame)) {
  219. pass(`${name}: prompt dismissed`);
  220. } else {
  221. fail(`${name}: prompt dismissed`, "still visible");
  222. }
  223. if (/denied/.test(afterFrame) || !/executed successfully/.test(afterFrame)) {
  224. pass(`${name}: tool NOT executed`);
  225. } else {
  226. fail(`${name}: tool NOT executed`, "tool appears to have run");
  227. }
  228. } finally {
  229. instance.unmount();
  230. }
  231. }
  232. // ──────────────── Test: Esc (abort) ────────────────
  233. async function testAbort(): Promise<void> {
  234. const name = "Esc — abort turn";
  235. process.stdout.write(`\n▸ ${name}\n`);
  236. const { instance } = renderTui();
  237. try {
  238. await wait(100);
  239. await typeString(instance, "do something abortable");
  240. instance.stdin.write("\r");
  241. await waitForFrame(instance, /Permission required/, 5_000, "permission prompt");
  242. instance.stdin.write("\x1B"); // Escape
  243. const afterFrame = await waitForNoPattern(instance, /Permission required/, 3_000);
  244. if (!/Permission required/.test(afterFrame)) {
  245. pass(`${name}: prompt dismissed`);
  246. } else {
  247. fail(`${name}: prompt dismissed`, "still visible");
  248. }
  249. if (!/executed successfully/.test(afterFrame)) {
  250. pass(`${name}: turn aborted (no tool output)`);
  251. } else {
  252. fail(`${name}: turn aborted`, "tool executed despite abort");
  253. }
  254. } finally {
  255. instance.unmount();
  256. }
  257. }
  258. // ──────────────── Test: bypass mode skips prompt ────────────────
  259. async function testBypassMode(): Promise<void> {
  260. const name = "/mode bypassPermissions";
  261. process.stdout.write(`\n▸ ${name}\n`);
  262. const originalSettings = readPermissionSettings();
  263. const { instance } = renderTui();
  264. try {
  265. await wait(100);
  266. await typeString(instance, "/mode bypassPermissions");
  267. instance.stdin.write("\r");
  268. await wait(200);
  269. const modeFrame = instance.lastFrame() ?? "";
  270. if (/bypassPermissions/.test(modeFrame)) {
  271. pass(`${name}: mode changed`);
  272. } else {
  273. fail(`${name}: mode changed`, `frame: ${modeFrame.slice(0, 200)}`);
  274. }
  275. const updated = readPermissionSettings();
  276. if (updated.skipPermissions === true) {
  277. pass(`${name}: skipPermissions persisted`);
  278. } else {
  279. fail(`${name}: skipPermissions persisted`, `got: ${JSON.stringify(updated)}`);
  280. }
  281. } finally {
  282. instance.unmount();
  283. writePermissionSettings(originalSettings);
  284. }
  285. }
  286. // ──────────────── main ────────────────
  287. async function main(): Promise<void> {
  288. process.stdout.write("═══════════════════════════════════════════════\n");
  289. process.stdout.write(" TUI Permission Prompt — E2E Smoke Test\n");
  290. process.stdout.write(" (mock gateway, no model API needed)\n");
  291. process.stdout.write("═══════════════════════════════════════════════\n");
  292. const tests = [testAllowOnce, testAllowRemember, testDeny, testAbort, testBypassMode];
  293. for (const test of tests) {
  294. try {
  295. await test();
  296. } catch (error) {
  297. fail(test.name, error instanceof Error ? error.message : String(error));
  298. }
  299. }
  300. process.stdout.write("\n═══════════════════════════════════════════════\n");
  301. const passed = results.filter((r) => r.pass).length;
  302. const failed = results.filter((r) => !r.pass).length;
  303. process.stdout.write(` Results: ${passed} passed, ${failed} failed (${results.length} total)\n`);
  304. process.stdout.write("═══════════════════════════════════════════════\n");
  305. if (failed > 0) {
  306. process.stdout.write("\nFailed:\n");
  307. for (const r of results.filter((r) => !r.pass)) {
  308. process.stdout.write(` ✗ ${r.name}: ${r.detail}\n`);
  309. }
  310. process.exitCode = 1;
  311. }
  312. }
  313. main().catch((error) => {
  314. console.error(error);
  315. process.exitCode = 1;
  316. });