diff --git a/.github/workflows/v1-e2e.yml b/.github/workflows/v1-e2e.yml new file mode 100644 index 0000000..01274d8 --- /dev/null +++ b/.github/workflows/v1-e2e.yml @@ -0,0 +1,31 @@ +name: V1 Headless E2E + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.9" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install OpenCode V1 + run: bun install --global opencode-ai@1.18.3 + + - name: Run V1 headless E2E tests + run: npm run test:v1:e2e + env: + OPENCODE_E2E_AUTH_JSON: ${{ secrets.OPENCODE_E2E_AUTH_JSON }} diff --git a/package.json b/package.json index b879efd..142a23c 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "typecheck:bun": "bun --bun tsc --noEmit", "typecheck:bun-build": "bun build src/index.ts --target=bun", "typecheck:bun-run": "bun run src/index.ts", + "test:v1:e2e": "npm run build && OPENCODE_E2E=1 bun test tests/e2e.v1-headless.test.ts", "dev": "tsc --watch", "prepublishOnly": "npm run build" }, diff --git a/tests/e2e.hooks.test.ts b/tests/e2e.hooks.test.ts deleted file mode 100644 index b20c5c6..0000000 --- a/tests/e2e.hooks.test.ts +++ /dev/null @@ -1,521 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from "bun:test" -import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync, unlinkSync, rmSync } from "fs" -import { join } from "path" -import { homedir } from "os" -import { $ } from "bun" - -const TEST_CONFIG_DIR = join(process.cwd(), "tests", "fixtures", "e2e-config") -const TEST_OPENCODE_SUBDIR = join(TEST_CONFIG_DIR, ".opencode") -const TEST_OPENCODE_CONFIG = join(TEST_CONFIG_DIR, "opencode.jsonc") -const TEST_HOOKS_CONFIG = join(TEST_OPENCODE_SUBDIR, "command-hooks.jsonc") -const TEST_AGENT_DIR = join(TEST_OPENCODE_SUBDIR, "agent") -const LOG_DIR = join(homedir(), ".local", "share", "opencode", "log") -const LOG_WINDOW_MS = 15 * 60 * 1000 -const LOG_FALLBACK_FILES = 3 -const E2E_ENABLED = process.env.OPENCODE_E2E === "1" - -/** - * Check if OpenCode CLI is available and working properly - * Tests both --version and a simple run command to ensure full functionality - */ -async function isOpenCodeAvailable(): Promise { - try { - const whichResult = await $`which opencode 2>&1`.text() - if (!whichResult || whichResult.includes("not found")) { - return false - } - return true - } catch { - return false - } -} - -/** - * Generate a unique ID for test isolation - */ -function generateUniqueId(): string { - return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) -} - -/** - * Get the content of all recent OpenCode log files (modified in last 5 minutes) - * This handles the case where each opencode run creates a new log file - */ -function getRecentLogContent(): string { - if (!existsSync(LOG_DIR)) { - return "" - } - const logs = readdirSync(LOG_DIR) - .map((name) => { - const filePath = join(LOG_DIR, name) - try { - return { - name, - path: filePath, - mtime: statSync(filePath).mtimeMs, - } - } catch { - return null - } - }) - .filter((entry): entry is { name: string; path: string; mtime: number } => entry !== null) - .sort((a, b) => b.mtime - a.mtime) - - if (logs.length === 0) { - return "" - } - - const cutoff = Date.now() - LOG_WINDOW_MS - const recent = logs.filter((file) => file.mtime > cutoff) - const selected = recent.length > 0 ? recent : logs.slice(0, LOG_FALLBACK_FILES) - - return selected - .slice() - .reverse() - .map((file) => { - try { - return readFileSync(file.path, "utf-8") - } catch { - return "" - } - }) - .join("\n") -} - -/** - * Write a test configuration to the test config directory - */ -function writeTestConfig(config: object): void { - if (!existsSync(TEST_OPENCODE_SUBDIR)) { - mkdirSync(TEST_OPENCODE_SUBDIR, { recursive: true }) - } - writeFileSync(TEST_HOOKS_CONFIG, JSON.stringify(config, null, 2)) -} - -/** - * Write OpenCode plugin configuration to enable the plugin in the test config directory - */ -function writeTestOpencodeConfig(): void { - if (!existsSync(TEST_CONFIG_DIR)) { - mkdirSync(TEST_CONFIG_DIR, { recursive: true }) - } - const pluginConfig = { - plugin: [join(process.cwd(), "dist", "index.js")], - } - writeFileSync(TEST_OPENCODE_CONFIG, JSON.stringify(pluginConfig, null, 2)) -} - -function writeTestAgent(name: string, content: string): void { - mkdirSync(TEST_AGENT_DIR, { recursive: true }) - writeFileSync(join(TEST_AGENT_DIR, `${name}.md`), content) -} - -function removeTestAgent(name: string): void { - rmSync(join(TEST_AGENT_DIR, `${name}.md`), { force: true }) -} - -/** - * Poll for log content until a predicate is satisfied or times out. - */ -async function waitForLogMatch( - predicate: (logContent: string) => boolean, - timeoutMs = 10000, - intervalMs = 500 -): Promise { - const start = Date.now() - while (Date.now() - start < timeoutMs) { - const logContent = getRecentLogContent() - if (predicate(logContent)) { - return logContent - } - await new Promise(resolve => setTimeout(resolve, intervalMs)) - } - return getRecentLogContent() -} - -/** - * Run OpenCode with a prompt and capture the stdout response - * This function captures the actual stdout from opencode, not just runs it silently - */ -async function runOpenCode(prompt: string): Promise { - try { - const result = await $`cd ${TEST_CONFIG_DIR} && OPENCODE_CONFIG=${TEST_OPENCODE_CONFIG} timeout 30 opencode -m opencode/big-pickle run ${prompt} 2>&1`.text() - await new Promise(resolve => setTimeout(resolve, 2000)) - - // Ensure we always return a string - const resultStr = typeof result === 'string' ? result : String(result || '') - return resultStr - } catch (e: unknown) { - // Handle error case - ensure we return a string - const error = e as { stdout?: unknown; stderr?: unknown; message?: string } - const errorContent = error.stdout || error.stderr || error.message || "" - return typeof errorContent === 'string' ? errorContent : String(errorContent || "") - } -} - -describe("E2E Hook Behavioral Tests", () => { - let skipTests = false - - beforeAll(async () => { - if (!E2E_ENABLED) { - skipTests = true - console.log("⚠️ Skipping E2E tests: set OPENCODE_E2E=1 to run") - return - } - - // Check if OpenCode is available - skipTests = !(await isOpenCodeAvailable()) - if (skipTests) { - console.log("⚠️ Skipping E2E tests: OpenCode is not available or not working properly") - return - } - - // Enable the plugin in the test opencode config - writeTestOpencodeConfig() - }) - - afterAll(() => { - // Clean up the test config directory (optional - leave for debugging if needed) - // Uncomment the following code to clean up after tests: - /* - if (existsSync(TEST_CONFIG_DIR)) { - try { - const files = readdirSync(TEST_CONFIG_DIR) - for (const file of files) { - unlinkSync(join(TEST_CONFIG_DIR, file)) - } - rmdirSync(TEST_CONFIG_DIR) - } catch (e) { - // Ignore cleanup errors - } - } - */ - }) - - it("Test 1: Inject during tool.execute.after - LLM responds", async () => { - if (skipTests) { - console.log("Skipping: OpenCode not available") - return - } - - console.log("\n=== Test 1: Inject during tool.execute.after - LLM responds ===") - - const uniqueId = generateUniqueId() - console.log(`Unique ID: ${uniqueId}`) - - // Configure a hook that injects a math question after any tool execution - const config = { - tool: [ - { - id: "test1-inject-math", - when: { phase: "after", tool: "*" }, - run: "echo test1_echo_output", - inject: `What is 2+2? Reply ONLY with the number 4, nothing else.`, - }, - ], - } - writeTestConfig(config) - console.log("Config written:", JSON.stringify(config, null, 2)) - - // Run OpenCode with a simple bash command - console.log("Running OpenCode...") - const opencodeResponse = await runOpenCode("use bash to echo hello") - - console.log("OpenCode response received") - console.log(`Response length: ${String(opencodeResponse).length}`) - console.log(`Response preview: ${String(opencodeResponse).substring(0, 200)}...`) - - // Check logs for inject marker - const logContent = getRecentLogContent() - console.log(`Log content length: ${logContent.length}`) - - // Assert: OpenCode response should contain "4" (the LLM should respond to the injected question) - const containsNumber4 = opencodeResponse.includes("4") || opencodeResponse.includes(" four") - console.log(`OpenCode response contains "4": ${containsNumber4}`) - - expect(containsNumber4).toBe(true) - }, 120000) - - it("Test 2: Inject during session.idle - LLM does NOT respond", async () => { - if (skipTests) { - console.log("Skipping: OpenCode not available") - return - } - - console.log("\n=== Test 2: Inject during session.idle - LLM does NOT respond ===") - - const uniqueId = generateUniqueId() - console.log(`Unique ID: ${uniqueId}`) - - // Configure a session hook that injects a message on idle - const config = { - session: [ - { - id: "test2-session-idle", - when: { event: "session.idle" }, - inject: `IGNORE_THIS_IDLE_MESSAGE_${uniqueId}`, - }, - ], - } - writeTestConfig(config) - console.log("Config written:", JSON.stringify(config, null, 2)) - - // Run OpenCode with a simple bash command - console.log("Running OpenCode...") - const opencodeResponse = await runOpenCode("use bash to echo test") - - console.log("OpenCode response received") - console.log(`Response length: ${String(opencodeResponse).length}`) - console.log(`Response preview: ${String(opencodeResponse).substring(0, 200)}...`) - - // Check logs for inject marker - const logContent = getRecentLogContent() - console.log(`Log content length: ${logContent.length}`) - console.log(`Log contains [inject]: ${logContent.includes("[inject]")}`) - console.log(`Log contains unique ID: ${logContent.includes(`IGNORE_THIS_IDLE_MESSAGE_${uniqueId}`)}`) - - // Assert: OpenCode response should NOT contain the ignore message or unique ID - // The idle injection should not alter the immediate model response - const responseStr = String(opencodeResponse) - const containsIgnoreMessage = responseStr.includes(`IGNORE_THIS_IDLE_MESSAGE_${uniqueId}`) - const containsUniqueId = responseStr.includes(uniqueId) - - console.log(`OpenCode response contains IGNORE message: ${containsIgnoreMessage}`) - console.log(`OpenCode response contains unique ID: ${containsUniqueId}`) - - expect(containsIgnoreMessage).toBe(false) - expect(containsUniqueId).toBe(false) - }, 120000) - - it("Test 3: Toast doesn't affect LLM response", async () => { - if (skipTests) { - console.log("Skipping: OpenCode not available") - return - } - - console.log("\n=== Test 3: Toast doesn't affect LLM response ===") - - const uniqueId = generateUniqueId() - console.log(`Unique ID: ${uniqueId}`) - - // Configure a hook that shows a toast before any tool execution - const config = { - tool: [ - { - id: "test3-toast-before", - when: { phase: "before", tool: "*" }, - run: "echo test3_echo_output", - toast: { - title: "Test 3 Toast", - message: `TOAST_MARKER_${uniqueId}`, - variant: "info", - }, - }, - ], - } - writeTestConfig(config) - console.log("Config written:", JSON.stringify(config, null, 2)) - - // Run OpenCode with a prompt asking to describe what it did - console.log("Running OpenCode...") - const opencodeResponse = await runOpenCode("use bash to echo hello and describe what you did") - - console.log("OpenCode response received") - console.log(`Response length: ${opencodeResponse.length}`) - console.log(`Response preview: ${opencodeResponse.substring(0, 200)}...`) - - const logContent = await waitForLogMatch( - content => content.includes(`TOAST_MARKER_${uniqueId}`), - 30000, - 500 - ) - console.log(`Log content length: ${logContent.length}`) - console.log(`Log contains [toast]: ${logContent.includes("[toast]")}`) - console.log(`Log contains toast marker: ${logContent.includes(`TOAST_MARKER_${uniqueId}`)}`) - console.log(`Log sample: ${logContent.substring(0, 500)}...`) - - // Assert: Toast marker should appear in logs but NOT in OpenCode response - const toastInLogs = logContent.includes(`TOAST_MARKER_${uniqueId}`) - const toastInResponse = opencodeResponse.includes(`TOAST_MARKER_${uniqueId}`) - - console.log(`Toast marker in logs: ${toastInLogs}`) - console.log(`Toast marker in OpenCode response: ${toastInResponse}`) - - expect(toastInLogs).toBe(true) - expect(toastInResponse).toBe(false) - }, 120000) - - it("Test 4: stdout template substitution works", async () => { - if (skipTests) { - console.log("Skipping: OpenCode not available") - return - } - - console.log("\n=== Test 4: stdout template substitution works ===") - - const uniqueId = generateUniqueId() - console.log(`Unique ID: ${uniqueId}`) - - // Configure a hook that runs a command and injects with {stdout} substitution - const config = { - tool: [ - { - id: "test4-stdout-subst", - when: { phase: "after", tool: "*" }, - run: `echo CAPTURED_${uniqueId}`, - inject: `Output was: {stdout}`, - }, - ], - } - writeTestConfig(config) - console.log("Config written:", JSON.stringify(config, null, 2)) - - // Run OpenCode with a simple bash command - console.log("Running OpenCode...") - const opencodeResponse = await runOpenCode("use bash to list files") - - console.log("OpenCode response received") - console.log(`Response length: ${opencodeResponse.length}`) - console.log(`Response preview: ${opencodeResponse.substring(0, 200)}...`) - - const logContent = await waitForLogMatch( - content => content.includes(`Output was: CAPTURED_${uniqueId}`), - 15000, - 500 - ) - console.log(`Log content length: ${logContent.length}`) - console.log(`Log contains [inject]: ${logContent.includes("[inject]")}`) - console.log(`Log contains CAPTURED marker: ${logContent.includes(`CAPTURED_${uniqueId}`)}`) - - // Assert: Log should contain "[inject]" with "CAPTURED_{uniqueId}" - // proving that {stdout} was substituted with the actual command output - const hasInjectLog = logContent.includes("[inject]") - const hasCapturedMarker = logContent.includes(`CAPTURED_${uniqueId}`) - const hasStdoutSubstitution = logContent.includes(`Output was: CAPTURED_${uniqueId}`) - - console.log(`Log contains [inject]: ${hasInjectLog}`) - console.log(`Log contains CAPTURED marker: ${hasCapturedMarker}`) - console.log(`Log contains stdout substitution: ${hasStdoutSubstitution}`) - - expect(hasInjectLog).toBe(true) - expect(hasCapturedMarker).toBe(true) - expect(hasStdoutSubstitution).toBe(true) - }, 120000) - - it("Test 5: Hook fires after write tool and creates file", async () => { - if (skipTests) { - console.log("Skipping: OpenCode not available") - return - } - - console.log("\n=== Test 5: Hook fires after write tool and creates file ===") - - const uniqueId = generateUniqueId() - console.log(`Unique ID: ${uniqueId}`) - - // Configure a hook that runs after the write tool - const config = { - tool: [ - { - id: `test-write-hook-${uniqueId}`, - when: { phase: "after", tool: "write" }, - run: "touch goodbye.txt" - }, - ], - } - writeTestConfig(config) - console.log("Config written:", JSON.stringify(config, null, 2)) - - // Run OpenCode with a prompt that requires the write tool. Avoid bash here - // because this test specifically verifies hooks attached to write. - console.log("Running OpenCode...") - const opencodeResponse = await runOpenCode("Use the write tool, not bash, to create a file named hello.txt with exactly this content and no trailing newline: hello") - - console.log("OpenCode response received") - console.log(`Response length: ${opencodeResponse.length}`) - console.log(`Response preview: ${opencodeResponse.substring(0, 200)}...`) - - // Check file existence and content - const helloFilePath = join(TEST_CONFIG_DIR, "hello.txt") - const goodbyeFilePath = join(TEST_CONFIG_DIR, "goodbye.txt") - - // Wait a bit for file system operations to complete - await new Promise(resolve => setTimeout(resolve, 2000)) - - try { - // Assert 1: hello.txt exists - const helloExists = existsSync(helloFilePath) - console.log(`hello.txt exists: ${helloExists}`) - expect(helloExists).toBe(true) - - // Assert 2: hello.txt contains exactly "hello" - if (helloExists) { - const helloContent = readFileSync(helloFilePath, "utf-8") - console.log(`hello.txt content: "${helloContent}"`) - expect(helloContent).toBe("hello") - } - - // Assert 3: goodbye.txt exists (proving the hook ran) - const goodbyeExists = existsSync(goodbyeFilePath) - console.log(`goodbye.txt exists: ${goodbyeExists}`) - expect(goodbyeExists).toBe(true) - - // Assert 4: Logs contain evidence the write tool was used - expect(opencodeResponse.toLowerCase()).toContain("write") - - } finally { - // Clean up test files - try { - if (existsSync(helloFilePath)) { - unlinkSync(helloFilePath) - console.log("Cleaned up hello.txt") - } - if (existsSync(goodbyeFilePath)) { - unlinkSync(goodbyeFilePath) - console.log("Cleaned up goodbye.txt") - } - } catch (e) { - console.log("Cleanup warning:", e) - } - } - }, 120000) - - it("Test 6: Agent frontmatter hooks do not leak to provider options", async () => { - if (skipTests) { - console.log("Skipping: OpenCode not available") - return - } - - console.log("\n=== Test 6: Agent frontmatter hooks do not leak to provider options ===") - const uniqueId = generateUniqueId() - const marker = `FRONTMATTER_HOOK_${uniqueId}` - - writeTestConfig({ tool: [], session: [] }) - writeTestAgent("author", `--- -description: E2E author test agent -mode: subagent -model: cerebras/zai-glm-4.7 -hooks: - after: - - run: "echo ${marker}" - inject: "Frontmatter output: {stdout}" ---- - -Say hi tersely. -`) - - try { - console.log("Running OpenCode...") - const opencodeResponse = await runOpenCode("get author subagent to say hi") - console.log("OpenCode response received") - console.log(`Response preview: ${opencodeResponse.substring(0, 200)}...`) - - const logContent = await waitForLogMatch((content) => content.includes(marker), 20000) - expect(opencodeResponse).not.toContain("property 'hooks' is unsupported") - expect(logContent).toContain(marker) - expect(logContent).toContain("[inject]") - } finally { - removeTestAgent("author") - } - }, 120000) -}) diff --git a/tests/e2e.v1-headless.test.ts b/tests/e2e.v1-headless.test.ts new file mode 100644 index 0000000..75ed004 --- /dev/null +++ b/tests/e2e.v1-headless.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, beforeAll, afterAll } from "bun:test" +import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, mkdtempSync, statSync, unlinkSync, rmSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" + +const REPOSITORY_ROOT = process.cwd() +let TEST_SANDBOX_DIR = "" +let TEST_CONFIG_DIR = "" +let TEST_OPENCODE_SUBDIR = "" +let TEST_OPENCODE_CONFIG = "" +let TEST_HOOKS_CONFIG = "" +let TEST_HOME_DIR = "" +let TEST_XDG_CONFIG_HOME = "" +let TEST_XDG_DATA_HOME = "" +let TEST_XDG_CACHE_HOME = "" +let LOG_DIR = "" +const LOG_WINDOW_MS = 15 * 60 * 1000 +const LOG_FALLBACK_FILES = 3 +const OPENCODE_COMMAND_TIMEOUT_MS = 30_000 +const E2E_ENABLED = process.env.OPENCODE_E2E === "1" +const E2E_MODEL = process.env.OPENCODE_E2E_MODEL ?? "cerebras/zai-glm-4.7" + +function createTestSandbox(): void { + const authJson = process.env.OPENCODE_E2E_AUTH_JSON + if (!authJson) { + throw new Error( + "OPENCODE_E2E_AUTH_JSON is required for the isolated real-host E2E suite." + ) + } + JSON.parse(authJson) + + TEST_SANDBOX_DIR = mkdtempSync(join(tmpdir(), "opencode-command-hooks-e2e-")) + TEST_CONFIG_DIR = join(TEST_SANDBOX_DIR, "project") + TEST_OPENCODE_SUBDIR = join(TEST_CONFIG_DIR, ".opencode") + TEST_OPENCODE_CONFIG = join(TEST_CONFIG_DIR, "opencode.jsonc") + TEST_HOOKS_CONFIG = join(TEST_OPENCODE_SUBDIR, "command-hooks.jsonc") + TEST_HOME_DIR = join(TEST_SANDBOX_DIR, "home") + TEST_XDG_CONFIG_HOME = join(TEST_SANDBOX_DIR, "xdg-config") + TEST_XDG_DATA_HOME = join(TEST_SANDBOX_DIR, "xdg-data") + TEST_XDG_CACHE_HOME = join(TEST_SANDBOX_DIR, "xdg-cache") + LOG_DIR = join(TEST_XDG_DATA_HOME, "opencode", "log") + + mkdirSync(TEST_CONFIG_DIR, { recursive: true }) + mkdirSync(TEST_HOME_DIR, { recursive: true }) + mkdirSync(TEST_XDG_CONFIG_HOME, { recursive: true }) + mkdirSync(TEST_XDG_DATA_HOME, { recursive: true }) + mkdirSync(TEST_XDG_CACHE_HOME, { recursive: true }) + mkdirSync(LOG_DIR, { recursive: true }) + writeFileSync(join(TEST_XDG_DATA_HOME, "opencode", "auth.json"), authJson, { + mode: 0o600, + }) +} + +function getOpenCodeEnvironment(): Record { + return { + ...process.env, + HOME: TEST_HOME_DIR, + XDG_CONFIG_HOME: TEST_XDG_CONFIG_HOME, + XDG_DATA_HOME: TEST_XDG_DATA_HOME, + XDG_CACHE_HOME: TEST_XDG_CACHE_HOME, + OPENCODE_CONFIG: TEST_OPENCODE_CONFIG, + PWD: TEST_CONFIG_DIR, + } +} + +interface OpenCodeCommandResult { + stdout: string + stderr: string + exitCode: number +} + +function formatOpenCodeCommand(args: string[]): string { + return ["opencode", ...args].map(argument => JSON.stringify(argument)).join(" ") +} + +function formatOpenCodeCommandError( + message: string, + args: string[], + stdout: string, + stderr: string +): Error { + return new Error( + `${message}\nCommand: ${formatOpenCodeCommand(args)}\nstdout:\n${stdout}\nstderr:\n${stderr}` + ) +} + +/** + * Run an OpenCode command in the isolated test environment. Command failures + * are rejected so behavioral assertions never inspect failed-process output. + */ +async function runOpenCodeCommand(args: string[]): Promise { + let subprocess: Bun.ReadableSubprocess + try { + subprocess = Bun.spawn(["opencode", ...args], { + cwd: TEST_CONFIG_DIR, + env: getOpenCodeEnvironment(), + stdout: "pipe", + stderr: "pipe", + }) + } catch (error) { + throw new Error( + `Failed to start OpenCode command: ${formatOpenCodeCommand(args)}\n${String(error)}` + ) + } + + let timedOut = false + const timeout = setTimeout(() => { + if (subprocess.exitCode === null) { + timedOut = true + subprocess.kill("SIGKILL") + } + }, OPENCODE_COMMAND_TIMEOUT_MS) + + try { + const [exitCode, stdout, stderr] = await Promise.all([ + subprocess.exited, + new Response(subprocess.stdout).text(), + new Response(subprocess.stderr).text(), + ]) + + if (timedOut) { + throw formatOpenCodeCommandError( + `OpenCode command timed out after ${OPENCODE_COMMAND_TIMEOUT_MS / 1000} seconds.`, + args, + stdout, + stderr + ) + } + + if (exitCode !== 0) { + throw formatOpenCodeCommandError( + `OpenCode command exited with code ${exitCode}.`, + args, + stdout, + stderr + ) + } + + return { stdout, stderr, exitCode } + } finally { + clearTimeout(timeout) + } +} + +/** + * Generate a unique ID for test isolation + */ +function generateUniqueId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) +} + +/** + * Get the content of all recent OpenCode log files (modified in last 5 minutes) + * This handles the case where each opencode run creates a new log file + */ +function getRecentLogContent(): string { + if (!existsSync(LOG_DIR)) { + return "" + } + const logs = readdirSync(LOG_DIR) + .map((name) => { + const filePath = join(LOG_DIR, name) + try { + return { + name, + path: filePath, + mtime: statSync(filePath).mtimeMs, + } + } catch { + return null + } + }) + .filter((entry): entry is { name: string; path: string; mtime: number } => entry !== null) + .sort((a, b) => b.mtime - a.mtime) + + if (logs.length === 0) { + return "" + } + + const cutoff = Date.now() - LOG_WINDOW_MS + const recent = logs.filter((file) => file.mtime > cutoff) + const selected = recent.length > 0 ? recent : logs.slice(0, LOG_FALLBACK_FILES) + + return selected + .slice() + .reverse() + .map((file) => { + try { + return readFileSync(file.path, "utf-8") + } catch { + return "" + } + }) + .join("\n") +} + +/** + * Write a test configuration to the test config directory + */ +function writeTestConfig(config: object): void { + if (!existsSync(TEST_OPENCODE_SUBDIR)) { + mkdirSync(TEST_OPENCODE_SUBDIR, { recursive: true }) + } + writeFileSync(TEST_HOOKS_CONFIG, JSON.stringify(config, null, 2)) +} + +/** + * Write OpenCode plugin configuration to enable the plugin in the test config directory + */ +function writeTestOpencodeConfig(): void { + if (!existsSync(TEST_CONFIG_DIR)) { + mkdirSync(TEST_CONFIG_DIR, { recursive: true }) + } + const pluginConfig = { + plugin: [join(REPOSITORY_ROOT, "dist", "index.js")], + } + writeFileSync(TEST_OPENCODE_CONFIG, JSON.stringify(pluginConfig, null, 2)) +} + +/** + * Poll for log content until a predicate is satisfied or times out. + */ +async function waitForLogMatch( + predicate: (logContent: string) => boolean, + timeoutMs = 10000, + intervalMs = 500 +): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const logContent = getRecentLogContent() + if (predicate(logContent)) { + return logContent + } + await new Promise(resolve => setTimeout(resolve, intervalMs)) + } + return getRecentLogContent() +} + +/** + * Poll for one exact sandbox file effect from a hook. + */ +async function waitForFileContent( + filePath: string, + expectedContent: string, + timeoutMs = 10000, + intervalMs = 500 +): Promise { + const start = Date.now() + let content: string | undefined + while (Date.now() - start < timeoutMs) { + try { + content = readFileSync(filePath, "utf-8") + if (content === expectedContent) { + return content + } + } catch { + content = undefined + } + await new Promise(resolve => setTimeout(resolve, intervalMs)) + } + return content +} + +/** + * Run OpenCode with a prompt and capture its successful output. + */ +async function runOpenCode(prompt: string): Promise { + const result = await runOpenCodeCommand(["-m", E2E_MODEL, "run", prompt]) + await new Promise(resolve => setTimeout(resolve, 2000)) + return `${result.stdout}${result.stderr}` +} + +describe.skipIf(!E2E_ENABLED)("V1 headless real-host E2E", () => { + beforeAll(async () => { + createTestSandbox() + + // Verify the executable works in the same isolated environment as the tests. + await runOpenCodeCommand(["--version"]) + + // Enable the plugin in the test opencode config + writeTestOpencodeConfig() + }) + + afterAll(() => { + if (TEST_SANDBOX_DIR) { + rmSync(TEST_SANDBOX_DIR, { recursive: true, force: true }) + } + }) + + it("runs one write after-hook transaction with isolated observable evidence", async () => { + const uniqueId = generateUniqueId() + const writtenFileName = `e2e-write-${uniqueId}.txt` + const writtenContent = `MODEL_WRITE_${uniqueId}` + const hookProofFileName = `e2e-write-hook-${uniqueId}.txt` + const hookProofContent = `WRITE_AFTER_HOOK_${uniqueId}` + const hookStdout = `WRITE_AFTER_STDOUT_${uniqueId}` + const injectedMarker = `WRITE_AFTER_INJECT_${uniqueId}:${hookStdout}` + const toastMarker = `WRITE_AFTER_TOAST_${uniqueId}` + const writtenFilePath = join(TEST_CONFIG_DIR, writtenFileName) + const hookProofFilePath = join(TEST_CONFIG_DIR, hookProofFileName) + + const config = { + tool: [ + { + id: `e2e-write-after-${uniqueId}`, + when: { phase: "after", tool: "*" }, + run: `printf '%s' '${hookProofContent}' > '${hookProofFileName}'; printf '%s' '${hookStdout}'`, + inject: `WRITE_AFTER_INJECT_${uniqueId}:{stdout}`, + toast: { + title: "E2E write after-hook", + message: toastMarker, + variant: "info", + }, + }, + ], + } + writeTestConfig(config) + + try { + const opencodeResponse = await runOpenCode( + `Use the write tool, not bash, to create ${writtenFileName} with exactly this content and no trailing newline: ${writtenContent}` + ) + expect(await waitForFileContent(writtenFilePath, writtenContent)).toBe(writtenContent) + expect(await waitForFileContent(hookProofFilePath, hookProofContent)).toBe(hookProofContent) + const logContent = await waitForLogMatch(content => content.includes(injectedMarker)) + expect(logContent).toContain(injectedMarker) + expect(opencodeResponse).not.toContain(toastMarker) + } finally { + for (const filePath of [writtenFilePath, hookProofFilePath]) { + if (existsSync(filePath)) { + unlinkSync(filePath) + } + } + } + }, 120000) +}) diff --git a/tests/tool.after-result.test.ts b/tests/tool.after-result.test.ts index 051ee23..9b07513 100644 --- a/tests/tool.after-result.test.ts +++ b/tests/tool.after-result.test.ts @@ -48,18 +48,19 @@ describe("tool after hooks", () => { rmSync(testDir, { recursive: true, force: true }); }); - it("executes tool.execute.after hooks and triggers inject + toast", async () => { + it("sends the fully interpolated after-hook toast payload to the host client", async () => { writeConfig({ tool: [ { id: "after-hook", when: { phase: "after", tool: ["bash"] }, - run: ["echo 'Hook executed'"], + run: ["sh -c 'printf hook-stdout; printf hook-stderr >&2; exit 23'"], inject: "Tool result: {stdout}", toast: { - title: "Hook", - message: "after complete", - variant: "success", + title: "Hook {id} for {tool}", + message: "stdout={stdout}; stderr={stderr}; exit={exitCode}", + variant: "warning", + duration: 4500, }, }, ], @@ -79,15 +80,18 @@ describe("tool after hooks", () => { expect((promptCalls[0].path as { id: string }).id).toBe("s1"); const promptParts = (promptCalls[0].body as { parts: Array<{ text: string }> }).parts; - expect(promptParts[0].text).toContain("Tool result: Hook executed"); - - expect(toastCalls).toHaveLength(1); - expect(toastCalls[0].body).toEqual({ - title: "Hook", - message: "after complete", - variant: "success", - duration: undefined, - }); + expect(promptParts[0].text).toContain("Tool result: hook-stdout"); + + expect(toastCalls).toEqual([ + { + body: { + title: "Hook after-hook for bash", + message: "stdout=hook-stdout; stderr=hook-stderr; exit=23", + variant: "warning", + duration: 4500, + }, + }, + ]); }); it("executes inject-only after hook without run", async () => {