Skip to content

Commit 06b2dea

Browse files
committed
test: move to node:test, add scripts and CI
The three check files each had their own hand-rolled harness - a check() helper built on JSON.stringify, a failures counter, and a process.exit at the end. Running everything meant remembering three separate commands. They now use Node's built-in test runner, so there is no test dependency to install for a public plugin. node --test discovers *.test.ts, strips the types, reports each case individually, and exits non-zero on failure. Per-test names and the assertions behind them are preserved; what changed is the harness and the structure - describe blocks, table-driven cases where the inputs are the same shape, and assert.deepEqual instead of comparing JSON strings, which was silently treating {a: undefined} and {} as equal. npm test the whole suite npm run check typecheck then the suite, which is what CI runs npm run typecheck tsc alone npm run test:watch re-run on change npm run test:coverage line, branch and function coverage 140 checks over three modules at 100% line, branch and function coverage. The last two uncovered branches were both in the settings summary, which was only ever rendered with an unlimited budget and quiet on; there is now a case for the untouched state too. Also adds .github/workflows/ci.yml running npm run check on Node 24 for pushes to main and for pull requests, and an engines field saying Node 22.18 or newer, which is where type stripping became unflagged. Pins solid-js to 1.9.12 in devDependencies. @opentui/solid peer-pins exactly that version, so the previous ^1.9.15 made npm install fail with ERESOLVE - worth catching here rather than leaving a broken lockfile for anyone cloning.
1 parent d2b872b commit 06b2dea

6 files changed

Lines changed: 646 additions & 313 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
check:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: actions/setup-node@v4
14+
with:
15+
node-version: 24
16+
cache: npm
17+
- run: npm ci
18+
- run: npm run check

‎README.md‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -427,12 +427,32 @@ OpenCode's V2 plugin API; no code was copied from either project.
427427
git clone https://github.com/zignd/opencode-goal-plugin.git
428428
cd opencode-goal-plugin
429429
npm install
430-
npx tsc -p tsconfig.json
431-
node display.test.ts
432-
node budget.test.ts
433-
node settings.test.ts
430+
431+
npm test # the whole suite
432+
npm run check # typecheck, then the suite — what CI runs
433+
npm run typecheck # tsc alone
434+
npm run test:watch # re-run on change
435+
npm run test:coverage # line, branch and function coverage
434436
```
435437

438+
The suite runs on Node's built-in test runner, so there is no test dependency to install.
439+
`node --test` discovers `*.test.ts` and strips the types, which is why the checks import
440+
`./settings.ts` while the plugin source imports `./settings.js` — the plugin needs `.js`
441+
specifiers for OpenCode's own loader. Where a module is imported by both, the import is
442+
`import type`, which is erased and so has no runtime cost.
443+
444+
Current state: 140 checks over three modules, at 100% line, branch and function coverage.
445+
446+
| Module | What it covers |
447+
| --- | --- |
448+
| `budget.ts` | Turn-budget parsing, the exhaustion gate, formatting, the `maxTurns` option |
449+
| `display.ts` | Which placements are shown, `/goal display` argument parsing |
450+
| `settings.ts` | The other three settings, override resolution, the `/goal settings` summary |
451+
452+
`index.ts` and `tui.tsx` are not unit tested: they are mostly wiring, and the parts that
453+
historically broke were extracted into the three pure modules precisely so they could be.
454+
`npm run check` typechecks both.
455+
436456
Bumping support for a new OpenCode release means bumping `@opencode/plugin` and re-running the
437457
typecheck. `tsconfig.json` carries `jsxImportSource: "@opentui/solid"`, which is what makes the
438458
panel's JSX typecheck.

‎budget.test.ts‎

Lines changed: 124 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,140 @@
11
/**
2-
* Checks for the turn budget. Run with: node budget.test.ts
2+
* The turn budget.
33
*
4-
* The exhaustion test is the one backstop between a runaway loop and a very
5-
* large bill, so it is pinned here rather than living inline in the event loop.
4+
* `isExhausted` is the one backstop between a runaway loop and a very large
5+
* bill, so it is pinned here rather than left inline in the event loop.
66
*/
7+
import { describe, test } from "node:test"
8+
import assert from "node:assert/strict"
79
import {
810
budgetLabel,
911
DEFAULT_BUDGET,
1012
formatTurns,
1113
isExhausted,
1214
parseBudgetArgument,
1315
readBudgetOption,
14-
UNLIMITED_HINT,
1516
UNLIMITED_CAVEAT,
16-
type Budget,
17+
UNLIMITED_HINT,
1718
} from "./budget.ts"
1819

19-
let failures = 0
20+
describe("isExhausted", () => {
21+
test("is not spent at the start", () => {
22+
assert.equal(isExhausted(0, 20), false)
23+
})
24+
25+
test("is not spent one turn under the limit", () => {
26+
assert.equal(isExhausted(19, 20), false)
27+
})
28+
29+
test("is spent exactly at the limit", () => {
30+
assert.equal(isExhausted(20, 20), true)
31+
})
32+
33+
test("is spent past the limit", () => {
34+
assert.equal(isExhausted(21, 20), true)
35+
})
36+
37+
test("a limit of one is spent after one turn", () => {
38+
assert.equal(isExhausted(1, 1), true)
39+
})
40+
41+
for (const turns of [0, 20, 100_000]) {
42+
test(`an unlimited budget is never spent after ${turns} turns`, () => {
43+
assert.equal(isExhausted(turns, null), false)
44+
})
45+
}
46+
})
47+
48+
describe("parseBudgetArgument", () => {
49+
const unlimited = { kind: "budget", budget: null }
50+
51+
for (const word of ["unlimited", "unlim", "unbounded", "infinite", "inf", "none", "no-limit", "∞"]) {
52+
test(`reads "${word}" as unlimited`, () => {
53+
assert.deepEqual(parseBudgetArgument(word), unlimited)
54+
})
55+
}
56+
57+
test("ignores casing and padding", () => {
58+
assert.deepEqual(parseBudgetArgument(" UNLIMITED "), unlimited)
59+
assert.deepEqual(parseBudgetArgument(" ∞ "), unlimited)
60+
})
61+
62+
test("reads a whole number", () => {
63+
assert.deepEqual(parseBudgetArgument("40"), { kind: "budget", budget: 40 })
64+
})
2065

21-
function check(name: string, actual: unknown, expected: unknown) {
22-
const a = JSON.stringify(actual)
23-
const e = JSON.stringify(expected)
24-
if (a === e) {
25-
console.log(` ok ${name}`)
26-
} else {
27-
failures++
28-
console.log(` FAIL ${name}\n expected ${e}\n actual ${a}`)
66+
test("reads default as a clear", () => {
67+
assert.deepEqual(parseBudgetArgument("default"), { kind: "default" })
68+
})
69+
70+
for (const [input, value] of [
71+
["0", "0"],
72+
["-5", "-5"],
73+
["2.5", "2.5"],
74+
["lots", "lots"],
75+
[" ", ""],
76+
]) {
77+
test(`rejects ${JSON.stringify(input)}`, () => {
78+
assert.deepEqual(parseBudgetArgument(input), { kind: "invalid", value })
79+
})
2980
}
30-
}
31-
32-
console.log("isExhausted — the gate that stops a runaway")
33-
check("not spent at the start", isExhausted(0, 20), false)
34-
check("not spent one under", isExhausted(19, 20), false)
35-
check("spent exactly at the limit", isExhausted(20, 20), true)
36-
check("spent past the limit", isExhausted(21, 20), true)
37-
check("a limit of 1 is spent after one turn", isExhausted(1, 1), true)
38-
check("unlimited is never spent at zero", isExhausted(0, null), false)
39-
check("unlimited is never spent at 20", isExhausted(20, null), false)
40-
check("unlimited is never spent at 100000", isExhausted(100000, null), false)
41-
42-
console.log("parseBudgetArgument — /goal budget …")
43-
check("unlimited", parseBudgetArgument("unlimited"), { kind: "budget", budget: null })
44-
check("Unlimited with odd casing", parseBudgetArgument(" UNLIMITED "), { kind: "budget", budget: null })
45-
check("infinite", parseBudgetArgument("infinite"), { kind: "budget", budget: null })
46-
check("none", parseBudgetArgument("none"), { kind: "budget", budget: null })
47-
check("inf is a short alias", parseBudgetArgument("inf"), { kind: "budget", budget: null })
48-
check("unlim is a short alias", parseBudgetArgument("unlim"), { kind: "budget", budget: null })
49-
check("the infinity sign is an alias", parseBudgetArgument("\u221e"), { kind: "budget", budget: null })
50-
check("infinity sign with padding", parseBudgetArgument(" \u221e "), { kind: "budget", budget: null })
51-
check("unlim is not read as a count", parseBudgetArgument("unlim").kind, "budget")
52-
check("a count", parseBudgetArgument("40"), { kind: "budget", budget: 40 })
53-
check("default", parseBudgetArgument("default"), { kind: "default" })
54-
check("zero is not a budget", parseBudgetArgument("0"), { kind: "invalid", value: "0" })
55-
check("a negative count is invalid", parseBudgetArgument("-5"), { kind: "invalid", value: "-5" })
56-
check("a decimal is invalid", parseBudgetArgument("2.5"), { kind: "invalid", value: "2.5" })
57-
check("nonsense is invalid", parseBudgetArgument("lots"), { kind: "invalid", value: "lots" })
58-
check("empty is invalid", parseBudgetArgument(" "), { kind: "invalid", value: "" })
59-
60-
console.log("readBudgetOption — the maxTurns plugin option")
61-
check("absent uses the default", readBudgetOption(undefined), { budget: DEFAULT_BUDGET })
62-
check("a number is used as-is", readBudgetOption(40), { budget: 40 })
63-
check("null means unlimited", readBudgetOption(null), { budget: null })
64-
check("the word unlimited means unlimited", readBudgetOption("unlimited"), { budget: null })
65-
check("zero is rejected with a warning", readBudgetOption(0), {
66-
budget: DEFAULT_BUDGET,
67-
warning:
68-
"maxTurns must be a whole number of at least 1, or \"unlimited\"; got 0. Using 20.",
6981
})
70-
check("a negative is rejected with a warning", readBudgetOption(-3).budget, DEFAULT_BUDGET)
71-
check("a decimal is rejected with a warning", readBudgetOption(1.5).budget, DEFAULT_BUDGET)
72-
check("garbage is rejected with a warning", readBudgetOption("lots").budget, DEFAULT_BUDGET)
73-
check("a warning is actually present", typeof readBudgetOption(0).warning, "string")
74-
75-
console.log("display")
76-
check("label for a count", budgetLabel(20), "20")
77-
check("label when unlimited", budgetLabel(null), "unlimited")
78-
check("counter with a count", formatTurns(3, 20), "3/20")
79-
check("counter when unlimited", formatTurns(3, null), "3/∞")
80-
check("counter at zero when unlimited", formatTurns(0, null), "0/∞")
81-
82-
console.log("misc")
83-
check("the caveat names what still stops it", /judge|stall|repeat/i.test(UNLIMITED_CAVEAT), true)
84-
check("the hint advertises a short alias", /\binf\b/.test(UNLIMITED_HINT), true)
85-
check("the caveat mentions no ceiling", /ceiling/i.test(UNLIMITED_CAVEAT), true)
86-
87-
console.log(failures === 0 ? "\nall passed" : `\n${failures} FAILED`)
88-
process.exit(failures === 0 ? 0 : 1)
82+
83+
describe("readBudgetOption", () => {
84+
test("falls back to the default when absent", () => {
85+
assert.deepEqual(readBudgetOption(undefined), { budget: DEFAULT_BUDGET })
86+
})
87+
88+
test("uses a number as given", () => {
89+
assert.deepEqual(readBudgetOption(40), { budget: 40 })
90+
})
91+
92+
for (const input of [null, "unlimited", "inf", "none", "∞"]) {
93+
test(`reads ${JSON.stringify(input)} as unlimited`, () => {
94+
assert.deepEqual(readBudgetOption(input), { budget: null })
95+
})
96+
}
97+
98+
for (const input of [0, -3, 1.5, "lots"]) {
99+
test(`rejects ${JSON.stringify(input)} and warns`, () => {
100+
const result = readBudgetOption(input)
101+
assert.equal(result.budget, DEFAULT_BUDGET)
102+
assert.equal(typeof result.warning, "string")
103+
assert.match(result.warning ?? "", /maxTurns must be a whole number/)
104+
})
105+
}
106+
})
107+
108+
describe("display", () => {
109+
test("labels a finite budget as a count", () => {
110+
assert.equal(budgetLabel(20), "20")
111+
})
112+
113+
test("labels an unlimited budget in words", () => {
114+
assert.equal(budgetLabel(null), "unlimited")
115+
})
116+
117+
test("formats the counter against a limit", () => {
118+
assert.equal(formatTurns(3, 20), "3/20")
119+
})
120+
121+
test("formats the counter against no limit", () => {
122+
assert.equal(formatTurns(3, null), "3/∞")
123+
assert.equal(formatTurns(0, null), "0/∞")
124+
})
125+
})
126+
127+
describe("wording", () => {
128+
test("the unlimited caveat says what still stops the loop", () => {
129+
assert.match(UNLIMITED_CAVEAT, /judge/i)
130+
assert.match(UNLIMITED_CAVEAT, /stall|repetition/i)
131+
})
132+
133+
test("the unlimited caveat admits there is no ceiling", () => {
134+
assert.match(UNLIMITED_CAVEAT, /ceiling/i)
135+
})
136+
137+
test("the usage hint advertises the short spelling", () => {
138+
assert.match(UNLIMITED_HINT, /\binf\b/)
139+
})
140+
})

0 commit comments

Comments
 (0)