- Node.js
>= 24(CI runs the version in.node-version, and the floor in a job of its own),pnpm(v12). The suite refuses to start below the floor and names both versions: below it,node:sqlite,Intl.DurationFormatand the ICU strings the tests pin differ, and the failures read like regressions. - A checkout of this repo; for live testing, a DeepSeek Harness profile (see README for the
link:setup)
All tasks go through pnpm (which delegates to the Vite+ toolchain):
pnpm install # install dependencies
pnpm run check # format + lint + types; must be zero *errors* (warnings are reported, not gating)
pnpm run test # Vitest suite, must be fully green and deterministic
pnpm run test:coverage # the same run, with the coverage ratchet enforced
pnpm run test:shuffle # the same run in a random order; reproduce a failure with --sequence.seed=<n>
pnpm run test:mock # gateway mock suite: offline, loopback only, runs in CI
pnpm run build # vp pack + client rename -> lib/index.mjs, lib/index.d.mts, lib/client.js
pnpm run test:e2e # opt-in end-to-end suite; talks to the real OpenCode gateway
pnpm run catalog:shim # is src/catalog-data.ts current? (-- --write regenerates it from models.dev)
pnpm run limits:shim # is src/go-limits-data.ts current? (-- --write regenerates it)
pnpm run cli:version # is the pinned OpenCode CLI version the published one?Note: in some shells
pnpm execstalls; invoke the binary directly if so:node node_modules/.pnpm/vite-plus@*/node_modules/vite-plus/bin/vp <cmd>.
pnpm run test:e2e runs test/e2e/**/*.e2e.ts through its own config (vitest.e2e.config.ts), so pnpm run test stays a fully offline, deterministic unit run. Nothing here is collected by the unit suite.
OPENCODE_E2E=1 OPENCODE_API_KEY=… OPENCODE_GO_API_KEY=… pnpm run test:e2eOPENCODE_E2E=1is required; without it every case skips, so a barepnpm run test:e2eis a safe no-op.OPENCODE_API_KEY(Zen) arms the live/modelsenrichment case and the paid protocol-routing cases;OPENCODE_GO_API_KEY(Go plan) arms the live/usagecase. Each block skips when its key is absent, which is why the CIe2ejob stays green on fork PRs (secrets are not exposed to them) while still checking the endpoints answer.- The free-tier and unmetered routing cases run keyless on purpose: measured 2026-10-06, a paid model answers
401 AuthErroron every endpoint without a credential, so keyless probing cannot tell them apart at all. Keyless discrimination is real only for the free classes. OPENCODE_ZEN_BASE_URL/OPENCODE_GO_BASE_URLretarget the suite at a mirror.
The e2e files, one job each:
opencode-live.e2e.ts— the only place the plugin meets the real API. It catches what a stub cannot: the vendor changing the payload. The meter parses/zen/go/v1/usageand the picker consumes an enriched/modelslisting, so both shapes are asserted against the live service. The unkeyed cases assert reachability (never a 404) and that our header set does not change the gateway's verdict on a request — compared against the same call without it, rather than hardcoding a status the vendor may tighten.patched-fetch-headers.e2e.ts— a localnode:httplistener, so the outgoing header set crosses a real socket. The live gateway cannot echo a request back; everywhere else in the suite the injected headers are asserted against a capturedfetch.protocol-routing.e2e.ts— one turn per protocol against the live gateway: the vendor-side claim that a model'sprovider_npmnames its wire format, which a unit test can only read back from our own table.refusal-classification.e2e.ts— the host's error classifier fed the gateway's measured refusals over a realnode:httpsocket, chunked bodies included, after asserting the installed bundle still carries the branches the verdicts rely on.dispatch-levels.e2e.ts— no network: the installed pi's own level rule against both decorators across the shipped catalog, so every reasoning level the picker offers is one dispatch accepts.
The web profile wires this checkout with link:, so builds are picked up like this:
- Host (
lib/index.mjs) — restart required. The base bundle shipshmr root: [](config watches only,**/node_modulesignored) and the loader caches ESM imports per process, so a linked package is never re-imported after boot. After everypnpm run build, restartdsh web— otherwise the profile silently keeps running the module from boot time. Symptom of a stale host: the plugin row is active but the settings card shows "This plugin is not loaded, so it cannot be configured." —dsh-settingsis filtering on the old module'sConfigschema. - Client (
lib/client.js) — refresh suffices. The client bundle is re-served from disk on every page load; a browser refresh picks it up. - Verify after a restart: the Plugins page shows the OpenCode Patch card, and its configure view renders every card field (
CARD_FIELDSinsrc/settings-fields.ts; theCONFIG_ONLY_FIELDSknobs stay off the card by design) and no unavailable line.node --experimental-strip-types scripts/check.tscatches a stalelib/before you do (src/newer thanlib/).
- 100% strict TypeScript. No
anyleaks, noasassertions insrc/— narrowunknownwithin-operator type guards (isRecord,isUnknownArray, …). - Zero errors; warnings are reported, not gating (the
pnpm run checkline above).release:gateand CI fail on an error; warnings become gating in phase P12 ofdocs/improvement-plan.md, so do not add one. Theerrortier is reserved for defects: async safety and throw contracts. If a rule fights a correct pattern (e.g. sync Promise wrappers that preserveAsyncLocalStoragecontext), prefer a targetedoxlint-disablecomment with justification over weakening the rule globally. - Sync-over-async for context propagation.
withStoreiterators and thefetchpatch intentionally return promises from non-asyncfunctions soals.run()keeps turn context without an extra tick. Don't "fix" these intoasync. - Style: arrow-function consts (not
functiondeclarations), dot notation, explicit=== undefinedchecks,oxfmtformatting.
- Deterministic only. No
Math.random(), no fixedsleep()waits. Async file assertions poll with a deadline (waitForFileContent). - No secret fixtures. Session IDs are derived at runtime (
openCodeSessionIdFor) or read fromOPENCODE_SESSION_ID; never hardcodeses_…or API keys.lib/and*.logstay gitignored. - Restore globals. Tests that touch
globalThis.fetchorprocess.envmust restore them inafterEach(vi.unstubAllGlobals(),delete process.env.…). - Offline by construction.
test/support/offline.tsrefuses every non-loopback request in the unit and mock suites. The catalog's background revalidation used to reachmodels.opencode.aidozens of times per run, so a networked runner's results could depend on the vendor's live catalog. A case that means to fetch stubsfetchitself. - No order dependence.
test/support/reset-process-state.tsclears the plugin's module-level stores (captured keys, session spend, the live catalog) after every test. A new module-level store gets a reset next to its siblings.pnpm run test:shuffleis how to check, and CI runs it. - Tests reach
srcthroughtest/support/src.ts. It re-exports, from each defining module, everything a test needs, so a module move or a barrel change edits one file instead of thirty. A new test imports from./support/src.ts(../support/src.tsundertest/mockandtest/e2e); add a missing name there rather than importing asrcpath. The exceptions arevi.mock("../src/x.ts")targets (and theirimportOriginal<typeof import(…)>type), which must name the module; fresh-module re-imports aftervi.resetModules(), whose subject is module identity; the architecture and public-API tests, whose subject is thesrclayout and the barrel; the jsdom (client) tests, which import the defining module directly, becausesrc.tsre-exports the host graph (node:sqlite, pi,lifecycle.ts) and the next rule keeps that out of client tests; and the setup filetest/support/reset-process-state.ts, which runs before the jsdom tests too.test/architecture.test.tsfails on a static or side-effectsrcimport anywhere else, on a dynamicimport("../src/…")in a file that never callsvi.resetModules(), and on a jsdom test that value-imports the door. Importing the door loads the wholesrcgraph, host and client, so a module that fails to load fails every test that comes through it. - Host-only fixtures stay out of shared helpers.
test/test-helpers.tsis imported by the jsdom (client) tests, so anything that needsnode:sqliteor the filesystem belongs intest/support/, asopencode-store-fixture.tsdoes. - A test for a fix fails without it. Before committing a regression test, revert the fix locally and watch it fail.
- Meaningful coverage. Every config field in the
SPECSregister (src/settings-fields.ts) needs both the on and off path where it has one; every passthrough claim needs a non-OpenCode URL test proving headers are untouched; helper functions exported fromusage-ui.tsare asserted directly, not re-derived in the test. - A component with hooks needs a real mount. Calling
Component(props)returns an element and runs none of its state, so a hook-bearing component can have twenty passing cases and still be untested —usage-pill.tsxsat at 9% exactly that way, with the poll loop, retry and dismissal all uncovered.test/usage-pill-mount.test.tsxis the answer: a// @vitest-environment jsdompragma scoped to that one file, so the rest of the suite keeps the fast node environment and the zero-dependency element-tree style.
pnpm run test:coverage runs the same suite with thresholds from vite.config.ts. They are a ratchet, not a target: they sit at the level the suite actually reaches, so they fail when coverage DROPS and nothing else. Two rules follow.
- Raise them when you genuinely add coverage — a PR that lifts statements by a few points should lift the threshold with it, or the next contributor inherits a gate nobody re-measured.
- Never set one above the current measurement. That does not make the suite better; it makes every subsequent PR fail until someone deletes tests.
src/index.ts is excluded from the report — it is a pure re-export barrel, and the coverage tools attribute an untaken re-export line to whichever file re-exports it, so leaving it in reports a hole nobody can fill while hiding real ones. responses-provider.ts and responses-routes.ts carry their own per-file floors: the mount has four host contracts to survive, and a well-covered average must not be able to hide it.
The catalog freshness job runs three checks against what the vendor publishes: catalog:shim (the bundled catalog, src/catalog-data.ts), limits:shim (the Go allowances, src/go-limits-data.ts) and cli:version (the pinned CLI version in src/identity.ts). The vendor moves all three most days, so what a run does with a mismatch depends on whether the change under test could have caused it.
- A pull request is gated on a check only when its own diff touches that check's inputs. The inputs are the check's script and every local module it loads, transitively, plus
src/identity.tsforcli:version, which the script reads as data. The edges are the ones Node loads: the source's types are erased by Node's own stripper and the specifiers read by a real lexer, so a whole-statementimport typeloads nothing and is no edge, and an import written in a string or template is not one either; anything else is, so the set reaches further than the generator's own folder: the catalog parser loadssrc/config.ts, so a settings change gatescatalog:shim.scripts/freshness-scope.tsderives the inputs from the sources and answersgateorreportfor the diff (git diff --name-only HEAD^1 HEADon the merge ref). Changesrc/models-catalog.tsand forget to regenerate, and the pull request fails; that omission is what the gate exists to catch. - One half of
cli:versiongates every pull request. The script checks two things: that every copy of the CLI version insrc/,test/,docs/,scripts/andREADME.mdagrees withsrc/identity.ts, and that the pin is the published version. The first is offline and skips the two generated tables (they hold the vendor's data, not copies the repository maintains), so only the repository can break it, andpnpm run cli:version -- --copiesruns on every pull request as a gate. Only the registry comparison is scoped as above, and when it only warns, the warning quotes the script's ownerror:line. - Otherwise the check reports. A stale file is a
::warning::, the job stays green, and the nightly run regenerates both files and opens the refresh as a pull request of its own. A stale CLI pin is only warned about, by every run including the nightly; bumping it is a human pull request tosrc/identity.tsand its copies, which is then gated, because the pin is an input. Vendor drift is not a regression your pull request introduced, so it is not a reason to fail it. - The generated files are never inputs. A pull request that carries only a refresh reports. One that changes
src/catalog-data.tsorsrc/go-limits-data.tswithout touching what generates it gets a warning naming the file, because those files are never edited by hand. - A push to
mainreports. The nightly run regenerates the two generated files and opens the refresh as a pull request; for the CLI version it warns. A manual dispatch only reports the CLI version. None of these paths gates, for the same reason. - The gates fail closed. Each runs unless the scope step answered
report, so a lost answer gates every pull request rather than skipping the check.
To see the answer for your branch before CI does:
git diff --name-only main... \
| node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/freshness-scope.ts catalog:shim- Use conventional commits (
feat:,fix:) — release-please opens the version-bump + changelog PR automatically; merging it tags and creates the GitHub Release, which fires OIDC publishing to npm and GitHub Packages. - Avoid manual releases: release-please anchors on the latest GitHub Release, not the latest tag, so a bare tag is invisible to it (
AGENTS.md, "Releasing"). If one is unavoidable, bumpversioninpackage.json, commit, tag, push the tag, and create the GitHub Release in the same step (gh release create vX.Y.Z --generate-notes). Never touchCHANGELOG.md: release-please owns it. - On every release, refresh the Verified on DeepSeek Harness line and its matrix under "Compatibility & Verification" in both READMEs after smoke-testing: registry install resolves, plugin loads in the DSH Web profile, and a free-tier Zen call succeeds.
README.mdis consumer-facing: problem-first, install, UI config, troubleshooting. No dev internals.CHANGELOG.mdis the per-version record.AGENTS.mdis the agent-facing project brief — keep it specific to this repo.