diff --git a/UserGuide.md b/UserGuide.md index 35da4767..e484223f 100644 --- a/UserGuide.md +++ b/UserGuide.md @@ -8,4 +8,48 @@ ## Janna - X -## Nate - X +## Nate - Finding related tests of a given file (`/associate`). + +The `/associate` command gives developers the ability to choose specific files in the current codebase and explore how the functions within are being tested. + +### How to use it + +In an Opencode repository session, run `/associate [file path]`. The LLM will run a structured prompt based on the file given by the user. The LLM will then return a markdown file specifying the functions, their locations, the locations of their tests, and the way it tests the model. The response will also note functions that are not tested. + +Some examples of correct command call: + +/associate src/math.ts +/associate ./src/math.ts +/associate "src/files with spaces/math.ts" +/associate 'src/files with spaces/math.ts' +/associate 'absolute/path/to/current-project/src/math.ts +/associate src/link-to-math.ts +/associate + +If a single valid file is included, the LLM will be queried. +- a valid file is inside the project and readable + +### User testing + +1. Start Opencode and ensure a model is configured. Then open a session rooted in the repository that needs inspecting. +2. Run `/associate [file path]` and confirm that it is accepted. +3. Verify that a markdown table is shared in the terminal separated into columns of Function, Source, Test(s), and association. +4. Check that the information for each function is correct including file location and line number. Association is split into direct, indirect, or untested. Manually verify that the claimed relationships are real. + +### Automated tests + +- [`packages/opencode/test/session/prompt.test.ts`](packages/opencode/test/session/prompt.test.ts) has 18 tests. +They cover valid inputs, response structure, potential errors, and exposed meta data. +- [`packages/tui/test/associate.e2e.test.ts`](packages/tui/test/associate.e2e.test.ts) has one end-to-end TUI test using a mock llm and mocked event transports. It covers a normal flow of a /associate call including simulating user inputs, server responses, and ensuring the TUI updates to asynchronous events. The test confirms that the command is recognized and parsed into parts and the response becomes visible in the terminal UI. + + +From Packages/opencode run: +```bash +bun test test/session/prompt.test.ts --test-name-pattern associate +bun typecheck +``` +From packages/tui run: +```bash +bun test test/associate.e2e.test.ts +bun typecheck +``` diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index facf036a..ec3454c8 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -13,7 +13,7 @@ import PROMPT_ASSOCIATE from "./template/associate.txt" import { LegacyEvent } from "@opencode-ai/schema/legacy-event" type State = { - commands: Record + commands: Record } export const Event = { @@ -33,6 +33,10 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Command" }) export type Info = Omit, "template"> & { template: Promise | string } +type RuntimeInfo = Info & { + maxArguments?: number + fileArgument?: boolean +} export function hints(template: string) { const result: string[] = [] @@ -51,7 +55,7 @@ export const Default = { } as const export interface Interface { - readonly get: (name: string) => Effect.Effect + readonly get: (name: string) => Effect.Effect readonly list: () => Effect.Effect } @@ -67,7 +71,7 @@ const layer = Layer.effect( const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) { const cfg = yield* config.get() const bridge = yield* EffectBridge.make() - const commands: Record = {} + const commands: Record = {} commands[Default.INIT] = { name: Default.INIT, @@ -93,6 +97,8 @@ const layer = Layer.effect( description: "associate functions in a file with their tests", source: "command", template: PROMPT_ASSOCIATE, + maxArguments: 1, + fileArgument: true, hints: hints(PROMPT_ASSOCIATE), } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a53bd49d..6da0a0c2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1382,6 +1382,38 @@ const layer = Layer.effect( const raw = input.arguments.match(argsRegex) ?? [] const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) + if (cmd.maxArguments !== undefined && args.length > cmd.maxArguments) { + const error = new NamedError.Unknown({ + message: `Command "/${cmd.name}" accepts at most ${cmd.maxArguments} argument${cmd.maxArguments === 1 ? "" : "s"}.`, + }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + throw error + } + if (cmd.fileArgument && args[0]) { + const ctx = yield* InstanceState.context + const root = yield* fsys.resolve(ctx.worktree) + const filepath = yield* fsys.resolve(path.isAbsolute(args[0]) ? args[0] : path.resolve(root, args[0])) + const relative = path.relative(root, filepath) + if (relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) { + const error = new NamedError.Unknown({ message: `File must be inside the project: "${args[0]}".` }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + throw error + } + if (!(yield* fsys.isFile(filepath))) { + const error = new NamedError.Unknown({ message: `File not found: "${args[0]}".` }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + throw error + } + const readable = yield* fsys.access(filepath, { readable: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ) + if (!readable) { + const error = new NamedError.Unknown({ message: `File is not readable: "${args[0]}".` }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + throw error + } + } const templateCommand = yield* Effect.promise(async () => cmd.template) const placeholders = templateCommand.match(placeholderRegex) ?? [] diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index a28ee1eb..529178a5 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -8,6 +8,7 @@ import { EventV2Bridge } from "@/event-v2-bridge" import { expect } from "bun:test" import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import path from "path" +import { symlink } from "fs/promises" import { fileURLToPath } from "url" import { NamedError } from "@opencode-ai/core/util/error" import { Agent as AgentSvc } from "../../src/agent/agent" @@ -1855,26 +1856,606 @@ unix( 30_000, ) -it.instance("associate command expands the source file argument", () => +it.instance( + "associate command expands the source file argument", + () => + Effect.gen(function* () { + const { dir, llm } = yield* useServerConfig(providerCfg) + yield* writeText(path.join(dir, "src/session/prompt.ts"), "export function prompt() {}") + const { prompt, chat } = yield* boot() + yield* llm.text("done") + + const result = yield* prompt.command({ + sessionID: chat.id, + command: "associate", + arguments: "src/session/prompt.ts", + }) + + expect(result.info.role).toBe("assistant") + const inputs = yield* llm.inputs + const messages = JSON.stringify(inputs.at(-1)?.messages) + expect(messages).toContain("Associate every function in the provided source file") + expect(messages).toContain("Input file: src/session/prompt.ts") + expect(yield* llm.calls).toBe(1) + }), + { git: true }, + 30_000, +) + +it.instance( + "associate command accepts one quoted source file argument", + () => + Effect.gen(function* () { + const { dir, llm } = yield* useServerConfig(providerCfg) + yield* writeText(path.join(dir, "src/session/prompt with spaces.ts"), "export function prompt() {}") + const { prompt, chat } = yield* boot() + yield* llm.text("done") + + const result = yield* prompt.command({ + sessionID: chat.id, + command: "associate", + arguments: '"src/session/prompt with spaces.ts"', + }) + + expect(result.info.role).toBe("assistant") + const inputs = yield* llm.inputs + expect(JSON.stringify(inputs.at(-1)?.messages)).toContain('Input file: \\"src/session/prompt with spaces.ts\\"') + expect(yield* llm.calls).toBe(1) + }), + { git: true }, + 30_000, +) + +it.instance( + "associate command accepts no source file argument", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + yield* llm.text("done") + + const result = yield* prompt.command({ + sessionID: chat.id, + command: "associate", + arguments: "", + }) + + expect(result.info.role).toBe("assistant") + const inputs = yield* llm.inputs + expect(JSON.stringify(inputs.at(-1)?.messages)).toContain( + "If no file was provided, ask the user for one and stop.", + ) + }), + 30_000, +) + +function parseAssociateReport(report: string) { + const lines = report.split("\n") + const table = lines + .filter((line) => line.startsWith("|")) + .map((line) => + line + .split("|") + .slice(1, -1) + .map((cell) => cell.trim()), + ) + const gap = lines.findIndex((line) => line === "## Coverage gaps") + return { + header: table[0] ?? [], + separator: table[1] ?? [], + rows: table.slice(2).map((cells) => ({ + function: cells[0]?.replaceAll("`", "") ?? "", + source: cells[1] ?? "", + tests: cells[2] ?? "", + association: cells[3] ?? "", + })), + gaps: + gap === -1 + ? [] + : lines + .slice(gap + 1) + .map((line) => line.match(/^- `([^`]+)`/i)?.[1]) + .filter((name): name is string => name !== undefined), + hasGaps: gap !== -1, + } +} + +it.instance( + "associate command returns and persists the requested report format", + () => + Effect.gen(function* () { + const { dir, llm } = yield* useServerConfig(providerCfg) + const source = [ + "export function add(a: number, b: number) { return a + b }", + "export function subtract(a: number, b: number) { return a - b }", + "export function divide(a: number, b: number) { return a / b }", + 'export function calculate(operation: "divide", a: number, b: number) { return divide(a, b) }', + ] + const tests = [ + 'import { expect, test } from "bun:test"', + 'import { add, calculate } from "../src/math"', + "", + 'test("adds", () => {', + " expect(add(2, 3)).toBe(5)", + "})", + 'test("divides through calculate", () => {', + ' expect(calculate("divide", 6, 2)).toBe(3)', + "})", + 'test("adds negative numbers", () => {', + " expect(add(-2, -3)).toBe(-5)", + "})", + ] + yield* writeText(path.join(dir, "src/math.ts"), source.join("\n")) + yield* writeText(path.join(dir, "test/math.test.ts"), tests.join("\n")) + const { prompt, chat } = yield* boot() + const report = [ + "| Function | Source | Test(s) | Association |", + "| --- | --- | --- | --- |", + "| `add` | `src/math.ts:1` | `test/math.test.ts:5`
`test/math.test.ts:11` | direct — called by both tests |", + "| `subtract` | `src/math.ts:2` | — | untested — no matching test found |", + "| `divide` | `src/math.ts:3` | `test/math.test.ts:8` | indirect — reached through `calculate` |", + "| `calculate` | `src/math.ts:4` | `test/math.test.ts:8` | direct — called by the test |", + "", + "## Coverage gaps", + "", + "- `subtract` has no associated test.", + ].join("\n") + yield* llm.text(report) + + const result = yield* prompt.command({ + sessionID: chat.id, + command: "associate", + arguments: "src/math.ts", + }) + + expect(result.parts.some((part) => part.type === "text" && part.text === report)).toBe(true) + const stored = yield* MessageV2.get({ sessionID: chat.id, messageID: result.info.id }) + expect(stored.parts.some((part) => part.type === "text" && part.text === report)).toBe(true) + const parsed = parseAssociateReport(report) + expect(parsed.header).toEqual(["Function", "Source", "Test(s)", "Association"]) + expect(parsed.separator).toEqual(["---", "---", "---", "---"]) + expect(parsed.rows.map((row) => row.function)).toEqual(["add", "subtract", "divide", "calculate"]) + expect(new Set(parsed.rows.map((row) => row.function)).size).toBe(parsed.rows.length) + expect(parsed.rows.map((row) => row.association.split(" ")[0])).toEqual([ + "direct", + "untested", + "indirect", + "direct", + ]) + expect(parsed.rows.find((row) => row.function === "add")?.tests.match(/`[^`]+:\d+`/g)).toHaveLength(2) + expect(parsed.rows.find((row) => row.function === "subtract")?.tests).toBe("—") + expect(parsed.hasGaps).toBe(true) + expect(parsed.gaps).toEqual(["subtract"]) + expect(parsed.rows.filter((row) => row.association.startsWith("untested")).map((row) => row.function)).toEqual( + parsed.gaps, + ) + + const citations = parsed.rows.flatMap((row) => + [...`${row.source} ${row.tests}`.matchAll(/`([^`]+):(\d+)`/g)].map((match) => ({ + function: row.function, + path: match[1], + line: Number(match[2]), + source: row.source.includes(match[0]), + })), + ) + yield* Effect.forEach(citations, (citation) => + Effect.gen(function* () { + const file = Bun.file(path.join(dir, citation.path)) + expect(yield* Effect.promise(() => file.exists())).toBe(true) + const line = (yield* Effect.promise(() => file.text())).split("\n")[citation.line - 1] + expect(line?.trim().length).toBeGreaterThan(0) + if (citation.source) expect(line).toContain(citation.function) + }), + ) + + const inputs = yield* llm.inputs + const messages = JSON.stringify(inputs.at(-1)?.messages) + expect(messages).toContain("| Function | Source | Test(s) | Association |") + expect(messages).toContain("direct") + expect(messages).toContain("indirect") + expect(messages).toContain("untested") + expect(messages).toContain("Use file paths and line numbers") + expect(messages).toContain("coverage-gaps section only when untested functions exist") + expect(messages).toContain("Do not edit files or generate tests") + expect(yield* llm.calls).toBe(1) + }), + { git: true }, + 30_000, +) + +it.instance( + "associate command omits coverage gaps when every function is tested", + () => + Effect.gen(function* () { + const { dir, llm } = yield* useServerConfig(providerCfg) + yield* writeText( + path.join(dir, "src/square.ts"), + "export function square(value: number) { return value * value }", + ) + yield* writeText( + path.join(dir, "test/square.test.ts"), + 'import { square } from "../src/square"\nexpect(square(3)).toBe(9)', + ) + const { prompt, chat } = yield* boot() + const report = [ + "| Function | Source | Test(s) | Association |", + "| --- | --- | --- | --- |", + "| `square` | `src/square.ts:1` | `test/square.test.ts:2` | direct — called by the test |", + ].join("\n") + yield* llm.text(report) + + const result = yield* prompt.command({ + sessionID: chat.id, + command: "associate", + arguments: "src/square.ts", + }) + + const text = result.parts.find((part) => part.type === "text")?.text ?? "" + const parsed = parseAssociateReport(text) + expect(parsed.rows).toEqual([ + { + function: "square", + source: "`src/square.ts:1`", + tests: "`test/square.test.ts:2`", + association: "direct — called by the test", + }, + ]) + expect(parsed.hasGaps).toBe(false) + expect(parsed.gaps).toEqual([]) + expect(text).not.toContain("Coverage gaps") + expect(yield* llm.calls).toBe(1) + }), + { git: true }, + 30_000, +) + +it.instance( + "associate command accepts a single-quoted path with surrounding whitespace", + () => + Effect.gen(function* () { + const { dir, llm } = yield* useServerConfig(providerCfg) + yield* writeText(path.join(dir, "src/math with spaces.ts"), "export function add() {}") + const { prompt, chat } = yield* boot() + yield* llm.text("done") + + const result = yield* prompt.command({ + sessionID: chat.id, + command: "associate", + arguments: " 'src/math with spaces.ts' ", + }) + + expect(result.info.role).toBe("assistant") + expect(JSON.stringify((yield* llm.inputs).at(-1)?.messages)).toContain( + "Input file: 'src/math with spaces.ts' ", + ) + expect(yield* llm.calls).toBe(1) + }), + { git: true }, + 30_000, +) + +it.instance("associate command publishes its validation error", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) + const events = yield* EventV2Bridge.Service const { prompt, chat } = yield* boot() - yield* llm.text("done") + const errors: (typeof Session.Event.Error.data.Type)[] = [] + const off = yield* events.listen((event) => { + if (event.type === Session.Event.Error.type) errors.push(event.data as typeof Session.Event.Error.data.Type) + return Effect.void + }) - const result = yield* prompt.command({ + const exit = yield* prompt + .command({ + sessionID: chat.id, + command: "associate", + arguments: "src/foo.ts src/bar.ts", + }) + .pipe(Effect.exit) + yield* off + + expect(Exit.isFailure(exit)).toBe(true) + expect(errors).toHaveLength(1) + expect(errors[0]).toMatchObject({ sessionID: chat.id, - command: "associate", - arguments: "src/session/prompt.ts", + error: { name: "UnknownError", data: { message: 'Command "/associate" accepts at most 1 argument.' } }, }) + expect(yield* llm.calls).toBe(0) + }), +) - expect(result.info.role).toBe("assistant") - const inputs = yield* llm.inputs - const messages = JSON.stringify(inputs.at(-1)?.messages) - expect(messages).toContain("Associate every function in the provided source file") - expect(messages).toContain("Input file: src/session/prompt.ts") +it.instance("associate command exposes its built-in metadata", () => + Effect.gen(function* () { + yield* useServerConfig(providerCfg) + const commands = yield* Command.Service + const associate = (yield* commands.list()).find((command) => command.name === "associate") + + expect(associate).toMatchObject({ + name: "associate", + description: "associate functions in a file with their tests", + source: "command", + hints: ["$ARGUMENTS"], + }) }), ) +it.instance( + "configured associate command overrides built-in validation", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig((url) => ({ + ...providerCfg(url), + command: { + associate: { + description: "custom associate", + template: "Custom associate: $ARGUMENTS", + }, + }, + })) + const { prompt, chat } = yield* boot() + yield* llm.text("done") + + const result = yield* prompt.command({ + sessionID: chat.id, + command: "associate", + arguments: "two arguments", + }) + + expect(result.info.role).toBe("assistant") + expect(JSON.stringify((yield* llm.inputs).at(-1)?.messages)).toContain("Custom associate: two arguments") + expect(yield* llm.calls).toBe(1) + }), + 30_000, +) + +unix( + "associate command accepts an in-project symlink target", + () => + Effect.gen(function* () { + const { dir, llm } = yield* useServerConfig(providerCfg) + const target = path.join(dir, "src/math.ts") + yield* writeText(target, "export function add() {}") + yield* Effect.promise(() => symlink(target, path.join(dir, "math-link.ts"))) + const { prompt, chat } = yield* boot() + yield* llm.text("done") + + const result = yield* prompt.command({ + sessionID: chat.id, + command: "associate", + arguments: "math-link.ts", + }) + + expect(result.info.role).toBe("assistant") + expect(yield* llm.calls).toBe(1) + }), + { git: true }, + 30_000, +) + +unix( + "associate command rejects a symlink target outside the project", + () => + Effect.gen(function* () { + const { dir, llm } = yield* useServerConfig(providerCfg) + const outside = path.join(path.dirname(dir), "outside.ts") + yield* writeText(outside, "export function outside() {}") + yield* Effect.promise(() => symlink(outside, path.join(dir, "outside-link.ts"))) + const { prompt, chat } = yield* boot() + + const exit = yield* prompt + .command({ + sessionID: chat.id, + command: "associate", + arguments: "outside-link.ts", + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(NamedError.Unknown.isInstance(error)).toBe(true) + if (NamedError.Unknown.isInstance(error)) { + expect(error.data.message).toBe('File must be inside the project: "outside-link.ts".') + } + } + expect(yield* llm.calls).toBe(0) + }), + { git: true }, +) + +unix( + "associate command rejects a broken symlink", + () => + Effect.gen(function* () { + const { dir, llm } = yield* useServerConfig(providerCfg) + yield* Effect.promise(() => symlink(path.join(dir, "missing.ts"), path.join(dir, "broken-link.ts"))) + const { prompt, chat } = yield* boot() + + const exit = yield* prompt + .command({ + sessionID: chat.id, + command: "associate", + arguments: "broken-link.ts", + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(NamedError.Unknown.isInstance(error)).toBe(true) + if (NamedError.Unknown.isInstance(error)) { + expect(error.data.message).toBe('File not found: "broken-link.ts".') + } + } + expect(yield* llm.calls).toBe(0) + }), + { git: true }, +) + +it.instance("associate command rejects multiple source file arguments", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + + const exit = yield* prompt + .command({ + sessionID: chat.id, + command: "associate", + arguments: "src/foo.ts src/bar.ts", + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(NamedError.Unknown.isInstance(err)).toBe(true) + if (NamedError.Unknown.isInstance(err)) { + expect(err.data.message).toBe('Command "/associate" accepts at most 1 argument.') + } + } + expect(yield* llm.calls).toBe(0) + }), +) + +it.instance( + "associate command rejects a source file that does not exist", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + + const exit = yield* prompt + .command({ + sessionID: chat.id, + command: "associate", + arguments: "src/missing.ts", + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(NamedError.Unknown.isInstance(err)).toBe(true) + if (NamedError.Unknown.isInstance(err)) { + expect(err.data.message).toBe('File not found: "src/missing.ts".') + } + } + expect(yield* llm.calls).toBe(0) + }), + { git: true }, +) + +it.instance( + "associate command accepts an absolute source file path inside the project", + () => + Effect.gen(function* () { + const { dir, llm } = yield* useServerConfig(providerCfg) + const file = path.join(dir, "src/session/prompt.ts") + yield* writeText(file, "export function prompt() {}") + const { prompt, chat } = yield* boot() + yield* llm.text("done") + + const result = yield* prompt.command({ + sessionID: chat.id, + command: "associate", + arguments: file, + }) + + expect(result.info.role).toBe("assistant") + expect(JSON.stringify((yield* llm.inputs).at(-1)?.messages)).toContain(`Input file: ${file}`) + expect(yield* llm.calls).toBe(1) + }), + { git: true }, +) + +it.instance( + "associate command rejects paths outside the project", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + + const exit = yield* prompt + .command({ + sessionID: chat.id, + command: "associate", + arguments: "../outside.ts", + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(NamedError.Unknown.isInstance(err)).toBe(true) + if (NamedError.Unknown.isInstance(err)) { + expect(err.data.message).toBe('File must be inside the project: "../outside.ts".') + } + } + expect(yield* llm.calls).toBe(0) + }), + { git: true }, +) + +it.instance( + "associate command rejects directories", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + + const exit = yield* prompt + .command({ + sessionID: chat.id, + command: "associate", + arguments: ".", + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(NamedError.Unknown.isInstance(err)).toBe(true) + if (NamedError.Unknown.isInstance(err)) { + expect(err.data.message).toBe('File not found: ".".') + } + } + expect(yield* llm.calls).toBe(0) + }), + { git: true }, +) + +unix( + "associate command rejects an unreadable source file", + () => + Effect.gen(function* () { + const { dir, llm } = yield* useServerConfig(providerCfg) + const fs = yield* FSUtil.Service + const file = path.join(dir, "src/session/prompt.ts") + yield* writeText(file, "export function prompt() {}") + yield* fs.chmod(file, 0) + const { prompt, chat } = yield* boot() + + const exit = yield* prompt + .command({ + sessionID: chat.id, + command: "associate", + arguments: "src/session/prompt.ts", + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(NamedError.Unknown.isInstance(err)).toBe(true) + if (NamedError.Unknown.isInstance(err)) { + expect(err.data.message).toBe('File is not readable: "src/session/prompt.ts".') + } + } + expect(yield* llm.calls).toBe(0) + }), + { git: true }, +) + unixNoLLMServer( "cancel interrupts shell and resolves cleanly", () => diff --git a/packages/tui/test/associate.e2e.test.ts b/packages/tui/test/associate.e2e.test.ts new file mode 100644 index 00000000..38d19dd7 --- /dev/null +++ b/packages/tui/test/associate.e2e.test.ts @@ -0,0 +1,160 @@ +import { expect, mock, test } from "bun:test" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Global } from "@opencode-ai/core/global" +import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" +import { createTestRenderer } from "@opentui/core/testing" +import { Effect } from "effect" +import { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk" +import { createTuiResolvedConfig } from "./fixture/tui-runtime" + +test("e2e: /associate submits the source path and renders the association report", async () => { + const setup = await createTestRenderer({ width: 120, height: 30, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const events = createEventSource() + let api: TuiPluginApi | undefined + let command: Record | undefined + const session = { + id: "dummy", + title: "Associate demo", + slug: "dummy", + projectID: "project", + directory, + version: "0.0.0-test", + time: { created: 0, updated: 0 }, + } + const report = [ + "| Function | Source | Test(s) | Association |", + "| --- | --- | --- | --- |", + "| add | src/math.ts:1 | test/math.test.ts:5 | direct — called by the test |", + "| subtract | src/math.ts:2 | — | untested — no matching test found |", + "", + "## Coverage gaps", + "", + "- subtract has no associated test.", + ].join("\n") + const calls = createFetch((url) => { + if (url.pathname === "/config/providers") + return json({ + providers: [{ id: "test", name: "Test", models: { model: { id: "model", name: "Model" } } }], + default: { test: "model" }, + }) + if (url.pathname === "/agent") return json([{ name: "build", mode: "primary" }]) + if (url.pathname === "/command") + return json([ + { + name: "associate", + description: "associate functions in a file with their tests", + source: "command", + template: "", + hints: ["$ARGUMENTS"], + }, + ]) + if (url.pathname === "/session") return json([session]) + if (url.pathname === "/session/dummy") return json(session) + if (url.pathname === "/session/dummy/message") return json([]) + if (url.pathname === "/session/dummy/todo" || url.pathname === "/session/dummy/diff") return json([]) + }) + const server = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + if (request.method === "POST" && new URL(request.url).pathname === "/session/dummy/command") { + command = await request.json() + const assistant = { + id: "msg_associate", + sessionID: session.id, + role: "assistant" as const, + agent: "build", + modelID: "model", + providerID: "test", + mode: "build", + parentID: "msg_user", + path: { cwd: directory, root: directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, completed: 2 }, + } + const global = (payload: GlobalEvent["payload"]): GlobalEvent => ({ + directory, + project: session.projectID, + payload, + }) + events.emit( + global({ + id: "evt_associate_message", + type: "message.updated", + properties: { sessionID: session.id, info: assistant }, + }), + ) + events.emit( + global({ + id: "evt_associate_part", + type: "message.part.updated", + properties: { + sessionID: session.id, + time: 2, + part: { + id: "prt_associate", + sessionID: session.id, + messageID: assistant.id, + type: "text", + text: report, + }, + }, + }), + ) + return json({ info: assistant, parts: [] }) + } + return calls.fetch(input, init) + }) as typeof fetch + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + url: "http://test", + directory, + config: createTuiResolvedConfig({ plugin_enabled: {} }), + fetch: server, + events: events.source, + args: { continue: true }, + pluginHost: { + async start(input) { + input.runtime.setupSlots(input.api) + api = input.api + started() + }, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + await ready + await setup.renderOnce() + await setup.renderOnce() + + "/associate src/math.ts".split("").forEach((key) => setup.mockInput.pressKey(key)) + await setup.renderOnce() + setup.mockInput.pressEnter() + for (let index = 0; index < 100 && !command; index++) await Bun.sleep(10) + + expect(command).toMatchObject({ command: "associate", arguments: "src/math.ts", agent: "build" }) + for (let index = 0; index < 100; index++) { + await setup.renderOnce() + if (setup.captureCharFrame().includes("subtract")) break + await Bun.sleep(10) + } + expect(setup.captureCharFrame()).toContain("subtract") + expect(setup.captureCharFrame()).toContain("untested") + expect(setup.captureCharFrame()).toContain("Coverage gaps") + + api?.keymap.dispatchCommand("app.exit") + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + mock.restore() + } +}, 30_000)