opencode-config/tests/test_create_pr_tool.ts
Sergey 15fc7d014d
refactor(tools): add repo parameter + shared module + tunnel tests (#65)
* refactor(tools): extract shared module for gh spawnSync logic

* feat(tools): add repo parameter to 5 GitHub tools

* test(tools): add tunnel tool tests

* test(tools): add repo parameter test cases for 5 tools

* docs(handoff): scaffold handoff and ADR for PR

* docs(handoff): set PR number

* docs(project-map): update after PR#65 structural changes

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-25 19:19:19 +03:00

180 lines
No EOL
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tests for .opencode/tools/create-pr.ts — the create-pr custom tool.
*
* Mirror of tests/test_pipeline_status_tool.ts / test_memory_setup_tool.ts:
* the tool is a spawnSync wrapper around `gh pr create` with title/body
* validation.
*
* Runtime note: opencode ships a standalone binary with Bun bundled inside;
* there is no separate `bun` CLI on the host (CI runner uses node + pytest).
* The CI runs the equivalent Python tests in tests/test_create_pr_tool.py
* via the JS loader tests/_ts_loader.mjs (exec_stub_json mode for multi-arg
* tools). This file documents the intended TS-side test cases and is
* runnable under `bun test` once a bun runtime is available on the host.
*
* Test cases (mirror tests/test_create_pr_tool.py):
* - test_valid_pr — valid title + body → "PR created: <url>"
* - test_missing_scope — title without scope → error
* - test_missing_chto_sdelano — body missing ## Что сделано → error
* - test_missing_pochemu — body missing ## Почему → error
* - test_latin_only_body — body without Cyrillic → error
* - test_issue_linkage — issue_number → body gets Closes #N
*/
import { describe, test, expect, mock } from "bun:test" with { type: "'bun-test'" }
import { spawnSync } from "child_process"
import path from "path"
const TOOL_SRC = path.resolve(import.meta.dir, "..", ".opencode", "tools", "create-pr.ts")
function ctx() {
return {
sessionID: "t", messageID: "t", agent: "t",
directory: ".", worktree: ".",
abort: new AbortController().signal,
metadata() {}, async ask() {},
}
}
const VALID_BODY = "## Что сделано\nДобавлен tool\n\n## Почему\nНужна валидация"
describe("create-pr tool", () => {
test("test_valid_pr — valid title + body succeeds", async () => {
mock.module("child_process", () => ({
spawnSync: () => ({ status: 0, stdout: "https://github.com/x/y/pull/1\n", stderr: "" }),
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({
title: "feat(tools): add create-pr tool",
body: VALID_BODY,
}, ctx())
expect(result).toBe("PR created: https://github.com/x/y/pull/1")
})
test("test_missing_scope — title without scope → error", async () => {
mock.module("child_process", () => ({
spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }),
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({
title: "feat: no scope",
body: VALID_BODY,
}, ctx())
expect(result).toContain("must match format")
})
test("test_missing_chto_sdelano — body missing ## Что сделано → error", async () => {
mock.module("child_process", () => ({
spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }),
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({
title: "feat(tools): valid title",
body: "## Почему\nПотому что",
}, ctx())
expect(result).toContain("## Что сделано")
})
test("test_missing_pochemu — body missing ## Почему → error", async () => {
mock.module("child_process", () => ({
spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }),
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({
title: "feat(tools): valid title",
body: "## Что сделано\nСделано",
}, ctx())
expect(result).toContain("## Почему")
})
test("test_latin_only_body — body without Cyrillic → error", async () => {
// NOTE: spec validation order checks headings (## Что сделано, ## Почему)
// BEFORE the Cyrillic check. Since the headings themselves are Cyrillic,
// a body that passes the heading checks always passes the Cyrillic check.
// Therefore a Latin-only body (no Cyrillic) also lacks the Russian headings
// and fails on the heading check first. The Cyrillic check is effectively
// dead code given the heading checks — documented as spec issue in handoff.
mock.module("child_process", () => ({
spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }),
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({
title: "feat(tools): valid title",
body: "## What done\nSomething\n\n## Why\nBecause",
}, ctx())
// Body lacks Russian headings → heading check fires (not Cyrillic check).
expect(result).toContain("## Что сделано")
})
test("test_issue_linkage — issue_number appends Closes #N to body", async () => {
let capturedArgs
mock.module("child_process", () => ({
spawnSync: (_cmd, args) => {
capturedArgs = args
return { status: 0, stdout: "https://github.com/x/y/pull/5\n", stderr: "" }
},
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({
title: "feat(tools): valid title",
body: VALID_BODY,
issue_number: 37,
}, ctx())
expect(result).toBe("PR created: https://github.com/x/y/pull/5")
// body is the 4th arg (after --title, title, --body)
const bodyArg = capturedArgs[capturedArgs.indexOf("--body") + 1]
expect(bodyArg).toContain("Closes #37")
})
test("test_repo_explicit — repo='foo/bar' prepends --repo to gh argv", async () => {
let capturedArgs
mock.module("child_process", () => ({
spawnSync: (_cmd, args) => {
capturedArgs = args
return { status: 0, stdout: "https://github.com/foo/bar/pull/1\n", stderr: "" }
},
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({
title: "feat(tools): valid title",
body: VALID_BODY,
repo: "foo/bar",
}, ctx())
expect(result).toBe("PR created: https://github.com/foo/bar/pull/1")
expect(capturedArgs[0]).toBe("--repo")
expect(capturedArgs[1]).toBe("foo/bar")
expect(capturedArgs[2]).toBe("pr")
})
test("test_repo_omitted — no --repo flag (auto-detect from cwd)", async () => {
let capturedArgs
mock.module("child_process", () => ({
spawnSync: (_cmd, args) => {
capturedArgs = args
return { status: 0, stdout: "https://github.com/x/y/pull/1\n", stderr: "" }
},
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
await mod.default.execute({
title: "feat(tools): valid title",
body: VALID_BODY,
}, ctx())
// Backward-compat: --repo MUST NOT be present when repo arg omitted.
expect(capturedArgs).not.toContain("--repo")
expect(capturedArgs[0]).toBe("pr")
})
test("test_repo_invalid — invalid repo + gh failure → tool error", async () => {
mock.module("child_process", () => ({
spawnSync: () => ({ status: 1, stdout: "", stderr: 'expected the "owner/repo" format' }),
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({
title: "feat(tools): valid title",
body: VALID_BODY,
repo: "not-a-valid-repo",
}, ctx())
expect(result).toContain("gh pr create failed")
expect(result).toContain("exit 1")
})
})