opencode-config/tests/test_create_issue_tool.ts
Sergey c491b5fad1
fix(issue): align headings with create-issue validation and add SDD sections (#169)
* fix(issue): align headings with create-issue validation and add SDD sections

* docs(issue): add handoff, ADR, and fix CI for SDD validation

* docs(pr-169): fix handoff sections

---------

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

222 lines
No EOL
11 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-issue.ts — the create-issue custom tool.
*
* Mirror of tests/test_pipeline_status_tool.ts / test_memory_setup_tool.ts:
* the tool is a spawnSync wrapper around `gh issue 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_issue_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_issue_tool.py):
* - test_valid_issue — valid title + body → "Issue created: <url>"
* - test_title_too_long — title >80 chars → error
* - test_missing_kontekst — body missing ## Контекст → error
* - test_missing_zadacha — body missing ## Задача → error
* - test_missing_kriterii — body missing ## Критерии приемки → error
* - test_latin_only_body — body without Cyrillic → error
*/
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-issue.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Создать tool\n\n## Контракты\nTool валидирует 7 секций\n\n## Инварианты\nВсе секции обязательны\n\n## Граничные случаи\nНет секций → error\n\n## Вне scope\nИзменение gh CLI\n\n## Критерии приемки\nTool работает"
describe("create-issue tool", () => {
test("test_valid_issue — valid title + body succeeds", async () => {
mock.module("child_process", () => ({
spawnSync: () => ({ status: 0, stdout: "https://github.com/x/y/issues/1\n", stderr: "" }),
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({
title: "feat(tools): add create-issue tool",
body: VALID_BODY,
}, ctx())
expect(result).toBe("Issue created: https://github.com/x/y/issues/1")
})
test("test_title_too_long — title >80 chars → error", async () => {
mock.module("child_process", () => ({
spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }),
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const longDesc = "x".repeat(81)
const result = await mod.default.execute({
title: `feat(tools): ${longDesc}`,
body: VALID_BODY,
}, ctx())
expect(result).toContain("must match format")
})
test("test_missing_kontekst — 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Сделать\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово",
}, ctx())
expect(result).toContain("## Контекст")
})
test("test_missing_zadacha — 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Контекст\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово",
}, ctx())
expect(result).toContain("## Задача")
})
test("test_missing_kriterii — 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Контекст\n\n## Задача\nСделать\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Вне scope\nНе делаем",
}, ctx())
expect(result).toContain("## Критерии приемки")
})
test("test_sdd_missing_contracts — 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Контекст\n\n## Задача\nСделать\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово",
}, ctx())
expect(result).toContain("## Контракты")
})
test("test_sdd_missing_invariants — 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Контекст\n\n## Задача\nСделать\n\n## Контракты\nКонтракты\n\n## Граничные случаи\nОшибки\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово",
}, ctx())
expect(result).toContain("## Инварианты")
})
test("test_sdd_missing_edge_cases — 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Контекст\n\n## Задача\nСделать\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово",
}, ctx())
expect(result).toContain("## Граничные случаи")
})
test("test_sdd_missing_out_of_scope — body missing ## Вне 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(tools): valid title",
body: "## Контекст\nКонтекст\n\n## Задача\nСделать\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Критерии приемки\nГотово",
}, ctx())
expect(result).toContain("## Вне scope")
})
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: "## Context\nSome\n\n## Task\nDo it\n\n## Acceptance criteria\nDone",
}, ctx())
// Body lacks Russian headings → heading check fires (not Cyrillic check).
expect(result).toContain("## Контекст")
})
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/issues/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("Issue created: https://github.com/foo/bar/issues/1")
expect(capturedArgs[0]).toBe("--repo")
expect(capturedArgs[1]).toBe("foo/bar")
expect(capturedArgs[2]).toBe("issue")
})
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/issues/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("issue")
})
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 issue create failed")
expect(result).toContain("exit 1")
})
})