* 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>
222 lines
No EOL
11 KiB
TypeScript
222 lines
No EOL
11 KiB
TypeScript
/**
|
||
* 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")
|
||
})
|
||
}) |