|
| 1 | +import { test, expect, type ElectronApplication, type Page } from "@playwright/test"; |
| 2 | +import fs from "node:fs"; |
| 3 | +import os from "node:os"; |
| 4 | +import path from "node:path"; |
| 5 | +import { launch, seedHome, tid, items } from "./support"; |
| 6 | + |
| 7 | +// Native questions through the real CodexBackend, runner, IPC and UI (#276). The local executable below speaks |
| 8 | +// codex-cli 0.163.0's app-server protocol for `item/tool/requestUserInput` (experimental) the way its schema |
| 9 | +// describes it: questions with ids, headers, options, `isOther` and `isSecret`, answered with `{ answers }`. |
| 10 | +// It is a scripted fixture, not live-model evidence. Its log lives outside MODEX_HOME so the secret check |
| 11 | +// below can read every file Modex wrote. |
| 12 | + |
| 13 | +function fixture(dir: string): string { |
| 14 | + const cli = path.join(dir, "codex-questions.cjs"); |
| 15 | + fs.writeFileSync(cli, `#!${process.execPath} |
| 16 | +const fs = require('node:fs'); |
| 17 | +if (process.argv.includes('--version')) { console.log('codex-cli 0.163.0'); process.exit(0); } |
| 18 | +if (process.argv.includes('login') || process.argv.includes('auth')) { console.log('Logged in using ChatGPT'); process.exit(0); } |
| 19 | +const log = ${JSON.stringify(path.join(dir, "codex-replies.jsonl"))}; |
| 20 | +const emit = (o) => process.stdout.write(JSON.stringify(o) + '\\n'); |
| 21 | +const questions = [ |
| 22 | + { id: 'q-env', header: 'Environment', question: 'Which environment?', isOther: false, isSecret: false, options: [{ label: 'staging', description: 'Safe to try' }, { label: 'production', description: 'Live traffic' }] }, |
| 23 | + { id: 'q-token', header: 'Token', question: 'Paste the deploy token', isOther: true, isSecret: true, options: null }, |
| 24 | +]; |
| 25 | +let turn = 0, asked = 0, open = null; |
| 26 | +const cwds = new Map(); |
| 27 | +require('node:readline').createInterface({ input: process.stdin }).on('line', (line) => { |
| 28 | + const msg = JSON.parse(line); |
| 29 | + const p = msg.params || {}; |
| 30 | + if (msg.method === 'initialize') return emit({ id: msg.id, result: { userAgent: 'codex-questions/0.163.0' } }); |
| 31 | + if (msg.method === 'initialized') return; |
| 32 | + if (msg.method === 'account/read') return emit({ id: msg.id, result: { account: { type: 'chatgpt' }, requiresOpenaiAuth: true } }); |
| 33 | + if (msg.method === 'model/list') return emit({ id: msg.id, result: { data: [{ id: 'fixture', model: 'fixture', displayName: 'Fixture', isDefault: true }], nextCursor: null } }); |
| 34 | + if (msg.method === 'thread/start' || msg.method === 'thread/resume') { |
| 35 | + const id = p.threadId || 'thr-' + cwds.size; |
| 36 | + cwds.set(id, p.cwd || ''); |
| 37 | + return emit({ id: msg.id, result: { thread: { id } } }); |
| 38 | + } |
| 39 | + if (msg.method === 'turn/start' && /modex-title-/.test(cwds.get(p.threadId) || '')) { |
| 40 | + // Thread naming runs in its own folder and asks nothing. |
| 41 | + const id = 'title-' + (++turn); |
| 42 | + emit({ id: msg.id, result: { turn: { id } } }); |
| 43 | + emit({ method: 'item/completed', params: { threadId: p.threadId, turnId: id, item: { id: 'name-' + turn, type: 'agentMessage', text: 'Deploy', phase: 'final_answer' } } }); |
| 44 | + emit({ method: 'turn/completed', params: { threadId: p.threadId, turn: { id, status: 'completed', error: null } } }); |
| 45 | + return; |
| 46 | + } |
| 47 | + if (msg.method === 'turn/start') { |
| 48 | + const id = 'turn-' + (++turn); |
| 49 | + open = { thread: p.threadId, turn: id, request: 900 + (++asked), done: false }; |
| 50 | + emit({ id: msg.id, result: { turn: { id } } }); |
| 51 | + emit({ method: 'turn/started', params: { threadId: p.threadId, turn: { id } } }); |
| 52 | + emit({ id: open.request, method: 'item/tool/requestUserInput', params: { threadId: p.threadId, turnId: id, itemId: 'ask-' + asked, isBlocking: true, autoResolutionMs: null, questions } }); |
| 53 | + return; |
| 54 | + } |
| 55 | + const finish = (status, text) => { |
| 56 | + if (!open || open.done) return; |
| 57 | + open.done = true; |
| 58 | + if (text) emit({ method: 'item/completed', params: { threadId: open.thread, turnId: open.turn, item: { id: 'say-' + turn, type: 'agentMessage', text, phase: 'final_answer' } } }); |
| 59 | + emit({ method: 'turn/completed', params: { threadId: open.thread, turn: { id: open.turn, status, error: null } } }); |
| 60 | + }; |
| 61 | + if (msg.method === 'turn/interrupt') { emit({ id: msg.id, result: {} }); return finish('interrupted'); } |
| 62 | + if (open && msg.id === open.request && !msg.method) { |
| 63 | + fs.appendFileSync(log, JSON.stringify({ request: msg.id, result: msg.result, error: msg.error }) + '\\n'); |
| 64 | + if (msg.result) finish('completed', 'Deploying to ' + msg.result.answers['q-env'].answers[0] + '.'); |
| 65 | + return; |
| 66 | + } |
| 67 | + if (msg.id !== undefined && msg.method) emit({ id: msg.id, error: { code: -32601, message: 'fixture does not implement ' + msg.method } }); |
| 68 | +}); |
| 69 | +`, { mode: 0o755 }); |
| 70 | + return cli; |
| 71 | +} |
| 72 | + |
| 73 | +type Reply = { request: number; result?: { answers: Record<string, { answers: string[] }> }; error?: { code: number; message: string } }; |
| 74 | +const replies = (dir: string): Reply[] => { |
| 75 | + const file = path.join(dir, "codex-replies.jsonl"); |
| 76 | + return fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as Reply) : []; |
| 77 | +}; |
| 78 | +/** Every file under `dir`, read as text. */ |
| 79 | +const everything = (dir: string): string => fs.readdirSync(dir, { recursive: true, withFileTypes: true }) |
| 80 | + .filter((entry) => entry.isFile()).map((entry) => fs.readFileSync(path.join(entry.parentPath, entry.name), "utf8")).join("\n"); |
| 81 | + |
| 82 | +async function send(page: Page, text: string): Promise<void> { |
| 83 | + await page.keyboard.press("Meta+n"); |
| 84 | + await expect(tid(page, "draft-view")).toBeVisible(); |
| 85 | + await tid(page, "composer-input").fill(text); |
| 86 | + await tid(page, "send").click(); |
| 87 | +} |
| 88 | +const currentRow = (page: Page) => page.locator('[data-testid="thread-row"][aria-current="true"]'); |
| 89 | + |
| 90 | +test("Codex questions: the card answers by its schema, the CLI hears once, the secret stays out, Stop withdraws", async () => { |
| 91 | + const { home } = seedHome({ default_backend: "codex", default_mode: "agent" }); |
| 92 | + const logs = fs.mkdtempSync(path.join(os.tmpdir(), "modex-e2e-codex-questions-")); |
| 93 | + const statePath = path.join(home, "app/state.json"); |
| 94 | + const state = JSON.parse(fs.readFileSync(statePath, "utf8")); |
| 95 | + state.settings.codex_bin = fixture(logs); |
| 96 | + fs.writeFileSync(statePath, JSON.stringify(state)); |
| 97 | + let app: ElectronApplication | undefined; |
| 98 | + try { |
| 99 | + const opened = await launch(home); app = opened.app; |
| 100 | + const page = opened.page; |
| 101 | + |
| 102 | + // 1. The card shows each question with its own control; an incomplete answer is refused in place. |
| 103 | + await send(page, "deploy it"); |
| 104 | + const card = items(page, "question").first(); |
| 105 | + await expect(tid(card, "question")).toHaveCount(2); |
| 106 | + await expect(currentRow(page)).toHaveAttribute("data-status", "waiting"); |
| 107 | + await expect(tid(page, "turn-label")).toHaveText(/^Waiting for your answer · \d+s$/); |
| 108 | + await card.getByRole("radio", { name: "staging" }).check(); |
| 109 | + await tid(card, "question-submit").click(); |
| 110 | + await expect(tid(card, "question-error")).toHaveText("Token needs an answer."); |
| 111 | + expect(replies(logs)).toEqual([]); |
| 112 | + const token = card.getByLabel("Token"); |
| 113 | + await expect(token).toHaveAttribute("type", "password"); |
| 114 | + await token.fill("s3cr3t-token"); |
| 115 | + await page.screenshot({ path: test.info().outputPath("codex-question-card.png") }); |
| 116 | + await tid(card, "question-submit").click(); |
| 117 | + |
| 118 | + // 2. Codex hears the answers once; the card shows them with the secret hidden; the turn carries on. |
| 119 | + await expect(tid(card, "question-answer")).toHaveText(["staging", "(hidden)"]); |
| 120 | + await expect(items(page, "assistant").last()).toContainText("Deploying to staging."); |
| 121 | + await expect(currentRow(page)).toHaveAttribute("data-status", "idle"); |
| 122 | + expect(replies(logs)).toEqual([{ request: 901, result: { answers: { "q-env": { answers: ["staging"] }, "q-token": { answers: ["s3cr3t-token"] } } } }]); |
| 123 | + // A second answer (an old window, or a double click) is refused and reaches nothing. |
| 124 | + const threadId = (await currentRow(page).getAttribute("data-thread-id"))!; |
| 125 | + const again = await page.evaluate(async (id) => { |
| 126 | + const list = await window.modex!.invoke("thread:items", { threadId: id }) as { id: string; kind: string }[]; |
| 127 | + const asked = list.find((i) => i.kind === "question")!; |
| 128 | + return window.modex!.invoke("thread:answer-question", { threadId: id, itemId: asked.id, answers: { "q-env": ["production"], "q-token": ["other"] } }); |
| 129 | + }, threadId); |
| 130 | + expect(again).toEqual({ ok: false, reason: "already-resolved" }); |
| 131 | + expect(replies(logs)).toHaveLength(1); |
| 132 | + await page.screenshot({ path: test.info().outputPath("codex-question-answered.png") }); |
| 133 | + |
| 134 | + // 3. Stop while a question waits: the card closes as stopped, and Codex gets an error, never an empty answer. |
| 135 | + await send(page, "deploy again"); |
| 136 | + const second = items(page, "question").first(); |
| 137 | + await expect(tid(second, "question-submit")).toBeVisible(); |
| 138 | + await tid(page, "stop").click(); |
| 139 | + await expect(tid(second, "question-closed")).toHaveText("Not answered — the turn was stopped"); |
| 140 | + await expect(tid(second, "question-submit")).toHaveCount(0); |
| 141 | + await expect(currentRow(page)).toHaveAttribute("data-status", "idle"); |
| 142 | + await expect.poll(() => replies(logs).length).toBe(2); |
| 143 | + expect(replies(logs)[1]).toMatchObject({ request: 902, error: { code: -32000 } }); |
| 144 | + |
| 145 | + // Nothing Modex saved (threads, journal, state) holds the secret. |
| 146 | + expect(everything(path.join(home, "app")).includes("s3cr3t-token")).toBe(false); |
| 147 | + } finally { |
| 148 | + await app?.close(); |
| 149 | + fs.rmSync(logs, { recursive: true, force: true }); |
| 150 | + } |
| 151 | +}); |
0 commit comments