Practical reference for writing and running tests. All test files live flat in
tests/*.test.ts.
# Run all tests
npm test
# Equivalent to:
node --import tsx --test tests/*.test.ts
# Run a single test file
node --import tsx --test tests/token-counting.test.ts
# Run multiple specific files
node --import tsx --test tests/message-ids.test.ts tests/message-utils.test.tsCurrent baseline: 134 passing tests across 16 test files (with 3 known failures in config-validation.test.ts).
No CI/CD is configured. Tests run locally.
| Layer | Technology | Import |
|---|---|---|
| Test runner | Node.js built-in (node:test) |
import test from "node:test" |
| Assertions | Node.js built-in (node:assert/strict) |
import assert from "node:assert/strict" |
| TypeScript | tsx (on-the-fly transpilation) |
--import tsx flag |
No external test libraries (Jest, Vitest, Mocha) are used. Everything is the Node.js built-in test runner.
assert.equal(actual, expected) // Strict equality
assert.deepEqual(actual, expected) // Deep structural equality
assert.match(string, /regex/) // Regex match
assert.doesNotMatch(string, /regex/) // Regex non-match
assert.rejects(asyncFn, /error pattern/) // Promise rejectionZero external dependencies. Test deterministic logic in isolation.
| Test File | Source Module | What It Tests |
|---|---|---|
token-counting.test.ts |
lib/token-utils.ts |
countAllMessageTokens, countToolTokens, estimateTokensBatch, extractToolContent, extractCompletedToolOutput |
message-ids.test.ts |
lib/message-ids.ts, lib/state/state.ts |
assignMessageRefs, updatePerTurnState (ID reset after native compaction) |
message-utils.test.ts |
lib/messages/query.ts |
isIgnoredUserMessage |
message-priority.test.ts |
lib/messages/priority.ts, lib/messages/inject/inject.ts, lib/messages/inject/utils.ts, lib/messages/prune.ts, lib/messages/utils.ts |
buildPriorityMap, injectMessageIds, applyAnchoredNudges, prune, stripHallucinationsFromString |
input-budget.test.ts |
lib/messages/inject/utils.ts |
computeInputBudget |
host-permissions.test.ts |
lib/host-permissions.ts |
compressDisabledByOpencode, hasExplicitToolPermission, resolveEffectiveCompressPermission |
update.test.ts |
lib/update.ts |
isVersionNewer, isAutoUpdatableSpec, updateRemoveDir |
Mocked dependencies (client, filesystem). Test real module logic end-to-end.
| Test File | Source Module | What It Tests |
|---|---|---|
compress-message.test.ts |
lib/compress/message.ts |
createCompressMessageTool — batch compression, protected content, error handling, notification |
compress-range.test.ts |
lib/compress/range.ts |
createCompressRangeTool — subagent sessions, protected tags, batch notifications, overlap rejection |
compress-range-placeholders.test.ts |
lib/compress/range-utils.ts, lib/compress/state.ts |
parseBlockPlaceholders, injectBlockPlaceholders, validateSummaryPlaceholders, appendMissingBlockSummaries, wrapCompressedSummary |
compression-groups.test.ts |
lib/compress/message.ts, lib/compress/range.ts, lib/commands/decompress.ts, lib/commands/recompress.ts |
Grouped run lifecycle: compress → decompress → recompress across both modes |
compression-targets.test.ts |
lib/commands/compression-targets.ts |
getActiveCompressionTargets — grouping by runId, duration aggregation |
hooks-permission.test.ts |
lib/hooks.ts |
createChatMessageTransformHandler, createCommandExecuteHandler, createTextCompleteHandler, createEventHandler — permission enforcement, hallucination stripping, event timing |
prompts.test.ts |
lib/prompts/store.ts, lib/prompts/system.ts |
PromptStore — defaults, overrides, file-based loading |
token-usage.test.ts |
lib/messages/inject/utils.ts, lib/compress/state.ts, lib/token-utils.ts |
isContextOverLimits, wrapCompressedSummary, getCurrentTokenUsage — context threshold calculation |
Not yet implemented. Will test the complete message transform pipeline from hooks.ts through all stages.
| Source Module | Test File(s) | Key Functions Covered |
|---|---|---|
lib/token-utils.ts |
token-counting.test.ts, token-usage.test.ts |
countAllMessageTokens, countToolTokens, estimateTokensBatch, extractToolContent, extractCompletedToolOutput, getCurrentTokenUsage |
lib/message-ids.ts |
message-ids.test.ts |
assignMessageRefs |
lib/state/state.ts |
registry.test.ts, message-ids.test.ts |
SessionStateRegistry, updatePerTurnState, ensureSessionInitialized |
lib/state/utils.ts |
(indirect via other tests) | isMessageCompacted, serializePruneMessagesState |
lib/messages/query.ts |
message-utils.test.ts |
isIgnoredUserMessage |
lib/messages/shape.ts |
message-utils.test.ts |
isMessageWithInfo (indirect) |
lib/messages/priority.ts |
message-priority.test.ts |
buildPriorityMap |
lib/messages/inject/inject.ts |
message-priority.test.ts |
injectMessageIds |
lib/messages/inject/utils.ts |
input-budget.test.ts, token-usage.test.ts, message-priority.test.ts |
computeInputBudget, isContextOverLimits, applyAnchoredNudges |
lib/messages/prune.ts |
message-priority.test.ts |
prune |
lib/messages/utils.ts |
message-priority.test.ts |
stripHallucinationsFromString |
lib/compress/message.ts |
compress-message.test.ts, compression-groups.test.ts |
createCompressMessageTool |
lib/compress/range.ts |
compress-range.test.ts, compression-groups.test.ts |
createCompressRangeTool |
lib/compress/range-utils.ts |
compress-range-placeholders.test.ts |
parseBlockPlaceholders, injectBlockPlaceholders, validateSummaryPlaceholders, appendMissingBlockSummaries |
lib/compress/state.ts |
compress-range-placeholders.test.ts, token-usage.test.ts |
wrapCompressedSummary |
lib/commands/compression-targets.ts |
compression-targets.test.ts |
getActiveCompressionTargets |
lib/commands/decompress.ts |
compression-groups.test.ts |
handleDecompressCommand |
lib/commands/recompress.ts |
compression-groups.test.ts |
handleRecompressCommand |
lib/hooks.ts |
hooks-permission.test.ts |
createChatMessageTransformHandler, createCommandExecuteHandler, createTextCompleteHandler, createEventHandler |
lib/prompts/store.ts |
prompts.test.ts |
PromptStore |
lib/host-permissions.ts |
host-permissions.test.ts |
compressDisabledByOpencode, resolveEffectiveCompressPermission |
lib/update.ts |
update.test.ts |
isVersionNewer, isAutoUpdatableSpec, updateRemoveDir |
| Source Module | Key Untested Functions | Complexity |
|---|---|---|
lib/config.ts |
Config merging, defaults, validation | ~1125 lines, largest file |
lib/state/persistence.ts |
saveSessionState, loadSessionState, ensureSessionInitialized |
~295 lines, filesystem I/O |
lib/state/utils.ts (direct) |
isMessageCompacted, serializePruneMessagesState, deserializePruneMessagesState, getActiveSummaryTokenUsage |
~358 lines |
lib/messages/prune.ts |
filterCompressedRanges, stripStepMarkers (post-removal) |
~263 lines |
lib/messages/sync.ts |
syncCompressionBlocks — deactivate orphaned blocks |
~130 lines |
lib/messages/inject/inject.ts |
injectCompressNudges, injectMessageIds |
~280 lines |
lib/gc/truncate.ts |
runTruncateGC, truncateSummary |
~83 lines, pure logic |
lib/compress-permission.ts |
compressPermission, syncCompressPermissionState |
~25 lines |
lib/protected-patterns.ts |
matchesGlob, isFilePathProtected, isToolNameProtected, getFilePathsFromParameters |
~128 lines, pure logic |
lib/commands/context.ts |
Context usage display command | Slash command handler |
lib/commands/stats.ts |
Compression statistics command | Slash command handler |
lib/commands/manual.ts |
Manual mode toggle command | Slash command handler |
lib/commands/help.ts |
Help display command | Slash command handler |
lib/ui/notification.ts |
buildMinimalMessage, buildDetailedMessage |
~357 lines |
All tests construct mock data inline using helper functions. There are no shared test fixtures or external data files.
Every test file creates its own buildConfig() helper. The pattern is consistent:
import type { PluginConfig } from "../lib/config"
function buildConfig(mode: "message" | "range" = "message"): PluginConfig {
return {
enabled: true,
debug: false,
pruneNotification: "off",
pruneNotificationType: "toast",
commands: { enabled: true, protectedTools: [] },
experimental: { allowSubAgents: false, customPrompts: false },
protectedFilePatterns: [],
compress: {
mode,
permission: "allow",
showCompression: false,
maxContextLimit: 150000,
minContextLimit: 50000,
nudgeFrequency: 5,
iterationNudgeThreshold: 15,
nudgeForce: "soft",
protectedTools: ["task"],
protectTags: false,
protectUserMessages: false,
},
}
}Variations: Some tests add the gc field, summaryBuffer, or modelMaxLimits depending on what they test.
Two common patterns. The simple one-message builder:
function buildMessage(
id: string,
role: "user" | "assistant",
sessionID: string,
text: string,
created: number,
): WithParts {
const info =
role === "user"
? {
id,
role,
sessionID,
agent: "assistant",
model: { providerID: "anthropic", modelID: "claude-test" },
time: { created },
}
: { id, role, sessionID, agent: "assistant", time: { created } }
return {
info: info as WithParts["info"],
parts: [textPart(id, sessionID, `${id}-part`, text)],
}
}The multi-message builder (buildMessages) returns an array representing a mini-conversation:
function buildMessages(sessionID: string): WithParts[] {
return [
{
info: {
id: "msg-user-1",
role: "user",
sessionID,
agent: "assistant",
model: { providerID: "anthropic", modelID: "claude-test" },
time: { created: 1 },
} as WithParts["info"],
parts: [textPart("msg-user-1", sessionID, "part-1", "Investigate the issue")],
},
{
info: {
id: "msg-assistant-1",
role: "assistant",
sessionID,
agent: "assistant",
time: { created: 2 },
} as WithParts["info"],
parts: [textPart("msg-assistant-1", sessionID, "part-2", "I mapped the code path")],
},
]
}Text part:
function textPart(messageID: string, sessionID: string, id: string, text: string) {
return { id, messageID, sessionID, type: "text" as const, text }
}Tool part:
function toolPart(
messageID: string,
sessionID: string,
callID: string,
toolName: string,
output: string,
) {
return {
id: `${callID}-part`,
messageID,
sessionID,
type: "tool" as const,
tool: toolName,
callID,
state: {
status: "completed" as const,
input: { description: "demo" },
output,
},
}
}Always via the factory function:
import { createSessionState } from "../lib/state"
const state = createSessionState()
state.sessionId = "session-1"Manually populate blocks when needed:
state.prune.messages.blocksById.set(1, {
blockId: 1,
runId: 1,
active: true,
deactivatedByUser: false,
compressedTokens: 0,
summaryTokens: 0,
durationMs: 0,
mode: "message",
topic: "one",
batchTopic: "one",
startId: "m0001",
endId: "m0001",
anchorMessageId: "msg-a",
compressMessageId: "message-1",
compressCallId: "call-1",
includedBlockIds: [],
consumedBlockIds: [],
parentBlockIds: [],
directMessageIds: [],
directToolIds: [],
effectiveMessageIds: ["msg-a"],
effectiveToolIds: [],
createdAt: 1,
summary: "a",
survivedCount: 0,
generation: "young",
})Or use the buildBlock() helper from compression-targets.test.ts:
function buildBlock(blockId: number, runId: number, mode: "range" | "message", durationMs: number): CompressionBlock {
return {
blockId,
runId,
active: true,
deactivatedByUser: false,
compressedTokens: 10,
summaryTokens: 5,
durationMs,
mode,
topic: `topic-${blockId}`,
batchTopic: mode === "message" ? `batch-${runId}` : `topic-${blockId}`,
startId: `m${blockId}`,
endId: `m${blockId}`,
anchorMessageId: `msg-${blockId}`,
compressMessageId: `origin-${runId}`,
includedBlockIds: [],
consumedBlockIds: [],
parentBlockIds: [],
directMessageIds: [`msg-${blockId}`],
directToolIds: [],
effectiveMessageIds: [`msg-${blockId}`],
effectiveToolIds: [],
createdAt: blockId,
summary: `summary-${blockId}`,
survivedCount: 0,
generation: "young",
}
}Tests that call compress tools or hook handlers mock the client parameter as any:
const tool = createCompressRangeTool({
client: {
session: {
messages: async () => ({ data: rawMessages }),
get: async () => ({ data: { parentID: null } }),
},
},
state,
logger,
config: buildConfig(),
prompts: {
reload() {},
getRuntimePrompts() {
return { compressRange: "", compressMessage: "" }
},
},
} as any)For toast notification capture:
const toastCalls: string[] = []
const tool = createCompressRangeTool({
client: {
session: {
messages: async () => ({ data: rawMessages }),
get: async () => ({ data: { parentID: null } }),
},
tui: {
showToast: async ({ body }: { body: { message: string } }) => {
toastCalls.push(body.message)
},
},
},
// ...
} as any)Tests that touch persistence set temp directories:
const testDataHome = join(tmpdir(), `opencode-dcp-tests-${process.pid}`)
const testConfigHome = join(tmpdir(), `opencode-dcp-config-tests-${process.pid}`)
process.env.XDG_DATA_HOME = testDataHome
process.env.XDG_CONFIG_HOME = testConfigHome
mkdirSync(testDataHome, { recursive: true })
mkdirSync(testConfigHome, { recursive: true })- Identify the source module. Read the source file to understand the public API (exported functions).
- Choose the test tier. Is it a pure function (Tier 1), needs mock data (Tier 2), or needs integration (Tier 3)? See the priority table below.
- Create the test file. Name it
tests/{module-name}.test.ts(e.g.,tests/protected-patterns.test.ts). - Write the boilerplate. Use the template below.
- Write test cases. Start with happy path, then edge cases, then error cases.
- Run the test.
node --import tsx --test tests/{module-name}.test.ts - Run all tests.
npm test— verify nothing breaks.
import assert from "node:assert/strict"
import test from "node:test"
// Import what you're testing
import { yourFunction } from "../lib/your-module"
// Optional: import types you need for mock data
import type { PluginConfig } from "../lib/config"
import { createSessionState, type WithParts } from "../lib/state"
// --- Helper functions (copy from existing tests as needed) ---
function buildConfig(): PluginConfig {
return {
enabled: true,
debug: false,
pruneNotification: "off",
pruneNotificationType: "toast",
commands: { enabled: true, protectedTools: [] },
experimental: { allowSubAgents: false, customPrompts: false },
protectedFilePatterns: [],
compress: {
mode: "range",
permission: "allow",
showCompression: false,
maxContextLimit: 150000,
minContextLimit: 50000,
nudgeFrequency: 5,
iterationNudgeThreshold: 15,
nudgeForce: "soft",
protectedTools: [],
protectTags: false,
protectUserMessages: false,
},
}
}
// --- Tests ---
test("yourFunction does X when Y", () => {
// Arrange
const input = /* ... */
// Act
const result = yourFunction(input)
// Assert
assert.equal(result, expected)
})
test("yourFunction handles edge case Z", () => {
// ...
})- Test files:
{module-name}.test.ts— matches the source module path (e.g.,lib/protected-patterns.ts→tests/protected-patterns.test.ts) - Test descriptions: Full sentences describing the behavior, e.g.,
"compress message mode rejects compressed block ids" - Helper functions:
buildConfig(),buildMessage(),buildMessages(),textPart(),toolPart(),buildBlock()
- Copy helpers, don't import them. Every test file defines its own
buildConfig()andbuildMessage()helpers. This keeps tests self-contained and avoids shared mutable state. - Use
as anyfreely for mock client objects. The OpenCode client SDK types are verbose. Tests only implement the methods they call. - Use
Date.nowmocking for timing tests (seehooks-permission.test.tslines 199–381). - Unique session IDs prevent test pollution:
const sessionID = `ses_test_name_${Date.now()}`
Prioritize by ease of testing and impact. Pure functions first, then mock-data tests, then integration.
Quick wins. No mocking needed. Test input → output directly.
| Module | Functions to Test | Why Easy |
|---|---|---|
lib/protected-patterns.ts |
matchesGlob, isFilePathProtected, isToolNameProtected, getFilePathsFromParameters |
Pure string matching, no I/O |
lib/gc/truncate.ts |
runTruncateGC, truncateSummary |
Pure array transformation, inputs/outputs are plain objects |
lib/compress-permission.ts |
compressPermission, syncCompressPermissionState |
Simple delegation, tiny module |
lib/messages/shape.ts |
isMessageWithInfo, filterMessages |
Pure type guards |
lib/messages/query.ts |
isIgnoredUserMessage, getLastUserMessage, messageHasCompress, isProtectedUserMessage |
Pure predicates, just need WithParts objects |
Need SessionState, PluginConfig, or WithParts[] construction. Still no I/O.
| Module | Functions to Test | Mock Data Needed |
|---|---|---|
lib/messages/prune.ts |
filterCompressedRanges, stripStepMarkers |
SessionState with blocks, WithParts[], Logger |
lib/messages/sync.ts |
syncCompressionBlocks |
SessionState with blocks, WithParts[] (partial message list) |
lib/messages/inject/inject.ts |
injectCompressNudges |
Full SessionState + config + messages + prompts |
lib/state/utils.ts |
isMessageCompacted, serializePruneMessagesState, deserializePruneMessagesState |
SessionState, plain objects |
lib/messages/priority.ts |
buildPriorityMap |
Covered, but more edge cases possible |
Need temp directories, file I/O, or multi-module orchestration.
| Module | Functions to Test | Why Hard |
|---|---|---|
lib/config.ts |
Config loading, merging, validation | Filesystem reads, JSONC parsing |
lib/state/persistence.ts |
saveSessionState, loadSessionState, ensureSessionInitialized |
File I/O, JSON serialization |
lib/commands/*.ts |
Command handlers | Full client mock needed, output formatting |
lib/ui/notification.ts |
buildMinimalMessage, buildDetailedMessage |
Needs full SessionState with blocks and stats |
lib/hooks.ts |
Full pipeline integration | Orchestrates all other modules |
-
Forgetting
survivedCountandgenerationonCompressionBlock. The type makes them required. Always includesurvivedCount: 0andgeneration: "young"when building blocks. -
Missing
modelfield on user messageinfo. User messages in the SDK havemodel: { providerID, modelID }. Assistant messages don't. ThebuildMessagehelper handles this, but if you construct manually, include it. -
as WithParts["info"]cast. The SDKMessagetype is complex. All tests cast theinfoobject:info: { ... } as WithParts["info"]. This is normal. -
Date.nowmocking leaks. If you mockDate.now, always restore it in afinallyblock (seehooks-permission.test.ts). -
Session ID uniqueness. Tests that create compress tool calls or persist state should use unique session IDs to avoid cross-test pollution:
`ses_test_${Date.now()}`.