Files
ECC/tests/opencode-plugin-hooks.test.js
T
YogurDungeon b4943a5e4b fix: record skill runs under OpenCode and report unmeasured skills honestly
Under OpenCode the skill-health dashboard had no writer and no honest
fallback, so it reported every discovered skill as healthy (#2463).

Root cause: `post:skill:track` is only wired for Claude Code, where
`posttooluse-dispatcher.js` fans the event out to `skill-run-tracker.js`.
The OpenCode plugin re-implements the harness events instead of reusing that
dispatcher, so nothing ever wrote `skill-runs.jsonl` — and the tracker's
matcher only accepted the exact Claude Code tool name `Skill`, while OpenCode
calls the tool `skill`, so even wiring it would have dropped every event.
Commands that locate ECC through the inlined resolver in
`scripts/lib/resolve-ecc-root.js` also fell back to `~/.claude`, which does
not contain ECC, so they could not find their scripts.

Changes:

- `.opencode/plugins/ecc-hooks.ts`: dispatch `post:skill:track` from
  `tool.execute.after`. The tracker module is imported lazily and behind
  try/catch, matching the existing `changed-files-store` pattern: this file is
  OpenCode's startup entry point, so a static import failure would crash the
  session before any hook runs (#2530). The module is reused rather than
  reimplemented, keeping its identifier validation, length bounds and
  "never persist prompt text" guarantees.
- `scripts/hooks/skill-run-tracker.js`: match the tool name case-insensitively
  so `Skill` and `skill` are both accepted.
- `scripts/lib/skill-evolution/health.js`: split the summary into
  measured/healthy/declining/unmeasured instead of deriving `healthy = total -
  declining`, which turned "no telemetry" into "everything is fine" for every
  skill.
- `scripts/lib/skill-evolution/dashboard.js`: show the breakdown and warn
  explicitly when skills have no recorded runs, since absence of telemetry is
  not a health signal.

Under OpenCode the resolver also needs `CLAUDE_PLUGIN_ROOT`, which only the
plugin can supply: `shell.env` exports it when a root is found, without
replacing a root the user set or one an earlier plugin already put in
`output.env`.

Root discovery is per capability and probes required runtime files — never
optional content. Probing for `skills/continuous-learning-v2` (the
`workflow-quality` module) rejected supported modular installs that ship the
plugin and tracker without that optional module, disabling telemetry and the
root export for exactly those users.

Also fixes an incidental bug: the compiled plugin bundle is ESM, where
`__dirname` is undefined, so the ECC version lookup silently fell back to its
default. The module directory is now derived from `import.meta.url`.

Tests: `tests/opencode-plugin-hooks.test.js` covers the telemetry dispatch
(skill tool recorded, other tools ignored, minimal profile respected), a
modular install without the optional skills module, the root export and both
override paths; `tests/hooks/skill-run-tracker.test.js` covers the
case-insensitive matcher; `tests/lib/skill-evolution.test.js` and
`tests/lib/skill-dashboard.test.js` cover the unmeasured bucket, the warning
and the aged-out-coverage wording.

Refs #2463, #2530
2026-10-07 19:45:21 -05:00

710 lines
27 KiB
JavaScript

/**
* Tests for the published OpenCode hook plugin surface.
*/
const assert = require("node:assert")
const fs = require("node:fs")
const os = require("node:os")
const path = require("node:path")
const { spawnSync } = require("node:child_process")
const { pathToFileURL } = require("node:url")
function runTest(name, fn) {
return Promise.resolve()
.then(fn)
.then(() => {
console.log(` ✓ ${name}`)
return { passed: 1, failed: 0 }
})
.catch((error) => {
console.log(` ✗ ${name}`)
console.error(` ${error.stack || error.message}`)
return { passed: 0, failed: 1 }
})
}
async function loadPlugin() {
const repoRoot = path.join(__dirname, "..")
const buildResult = spawnSync("node", [path.join(repoRoot, "scripts", "build-opencode.js")], {
cwd: repoRoot,
encoding: "utf8",
})
assert.strictEqual(buildResult.status, 0, buildResult.stderr || buildResult.stdout)
const pluginUrl = pathToFileURL(
path.join(repoRoot, ".opencode", "dist", "plugins", "ecc-hooks.js")
).href
return import(pluginUrl)
}
function createClient() {
const logs = []
return {
logs,
app: {
log: ({ body }) => {
logs.push(body)
return Promise.resolve()
},
},
}
}
function createFailingShell() {
const calls = []
const shell = (strings, ...values) => {
calls.push(String.raw({ raw: strings }, ...values))
const error = new Error("OpenCode plugin file probes must not use shell commands")
return {
then: (_resolve, reject) => reject(error),
text: async () => {
throw error
},
}
}
shell.calls = calls
return shell
}
async function withTempProject(files, fn) {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-opencode-plugin-"))
try {
for (const file of files) {
const filePath = path.join(projectDir, file)
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, "")
}
return await fn(projectDir)
} finally {
fs.rmSync(projectDir, { recursive: true, force: true })
}
}
/**
* Build a fake ECC installation root and copy the compiled plugin into it.
*
* Reproduces a *modular* install: the runtime scripts the plugin needs are
* present, but `skills/` is absent because that directory belongs to the optional
* `workflow-quality` module. `options.withSkills` adds it back so the same helper
* can prove the difference is what matters.
*
* @param {{withSkills?: boolean}} [options]
* @returns {string} path to the fake installation root
*/
function createModularInstallRoot(options = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-opencode-install-"))
const distSource = path.join(__dirname, "..", ".opencode", "dist")
const repoRoot = path.join(__dirname, "..")
for (const dir of ["plugins", "tools"]) {
fs.cpSync(path.join(distSource, dir), path.join(root, dir), { recursive: true })
}
// The runtime files an install without `workflow-quality` still ships. Copied
// whole rather than file-by-file: scripts/lib has transitive requires (utils.js
// alone pulls in agent-data-home), and a partial copy would fail the import for
// reasons unrelated to what this test is about.
for (const dir of ["hooks", "lib"]) {
fs.cpSync(path.join(repoRoot, "scripts", dir), path.join(root, "scripts", dir), {
recursive: true,
})
}
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "ecc-universal", version: "0.0.0-test" })
)
// The plugin's tools import @opencode-ai/plugin, which resolves through
// node_modules — a real installation has it, an isolated temp dir would not.
fs.symlinkSync(path.join(repoRoot, "node_modules"), path.join(root, "node_modules"), "junction")
if (options.withSkills) {
fs.mkdirSync(path.join(root, "skills", "continuous-learning-v2"), { recursive: true })
fs.writeFileSync(path.join(root, "skills", "continuous-learning-v2", "SKILL.md"), "# sentinel\n")
}
return root
}
/** Load the compiled plugin from a fake install root instead of the repo dist. */
async function loadPluginFrom(root) {
return import(pathToFileURL(path.join(root, "plugins", "ecc-hooks.js")).href)
}
/**
* Restore environment variables saved with Object.fromEntries of name -> value.
*
* @param {Record<string, string | undefined>} saved
* @returns {void}
*/
function restoreEnv(saved) {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}
/**
* Read the skill-runs sink the tracker wrote under `homeDir`.
*
* The tracker resolves the sink from the *current* home, so the telemetry tests
* move HOME/USERPROFILE to a temp dir and read from there.
*
* @param {string} homeDir
* @returns {Array<Record<string, unknown>>}
*/
function readRecordedRuns(homeDir) {
const runsFilePath = path.join(homeDir, ".claude", "state", "skill-runs.jsonl")
if (!fs.existsSync(runsFilePath)) return []
return fs
.readFileSync(runsFilePath, "utf8")
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
}
async function main() {
console.log("\n=== Testing OpenCode plugin hooks ===\n")
const { ECCHooksPlugin } = await loadPlugin()
const tests = [
[
"plugin initializes and hooks stay usable when plugins/lib is missing",
async () => withTempProject([], async (projectDir) => {
const repoRoot = path.join(__dirname, "..")
const libDir = path.join(repoRoot, ".opencode", "dist", "plugins", "lib")
const backupDir = path.join(
repoRoot,
".opencode",
"dist",
"plugins",
"lib.missing-store-test-backup"
)
fs.renameSync(libDir, backupDir)
try {
const client = createClient()
const $ = createFailingShell()
// Plugin initialization must resolve even though changed-files-store.js
// cannot be found -- it must not throw and crash session startup (#2530).
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
const disabledWarnings = client.logs.filter(
(entry) =>
entry.level === "warn" &&
entry.message.includes("[ECC] changed-files tracking disabled") &&
entry.message.includes("ecc repair --target opencode")
)
assert.strictEqual(
disabledWarnings.length,
1,
"Expected exactly one warning when plugins/lib/changed-files-store.js cannot be loaded"
)
// Every hook that touches the store must remain callable and must not throw.
await hooks["file.edited"]({ path: "src/example.ts" })
await hooks["tool.execute.after"]({ tool: "edit", args: { path: "src/other.ts" } }, {})
await hooks["session.deleted"]()
} finally {
fs.renameSync(backupDir, libDir)
}
}),
],
[
"changed-files tracking records and clears through the plugin hooks",
async () => withTempProject([], async (projectDir) => {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
assert.ok(
!client.logs.some(
(entry) => entry.level === "warn" && entry.message.includes("changed-files tracking disabled")
),
"Did not expect a disabled warning when plugins/lib is present"
)
const storeUrl = pathToFileURL(
path.join(__dirname, "..", ".opencode", "dist", "plugins", "lib", "changed-files-store.js")
).href
const store = await import(storeUrl)
await hooks["file.edited"]({ path: "src/example.ts" })
assert.ok(
store
.getChangedPaths()
.some(
(entry) =>
entry.path === path.normalize("src/example.ts") &&
entry.changeType === "modified"
),
"Expected file.edited to record a change via the plugin hook"
)
await hooks["tool.execute.after"]({ tool: "edit", args: { path: "src/other.ts" } }, {})
assert.ok(
store
.getChangedPaths()
.some((entry) => entry.path === path.normalize("src/other.ts")),
"Expected tool.execute.after to record a change for the edit tool"
)
await hooks["session.deleted"]()
assert.ok(!store.hasChanges(), "Expected session.deleted to clear tracked changes")
}),
],
[
"shell.env detects project markers without shelling out to test -f",
async () => withTempProject(
["pnpm-lock.yaml", "tsconfig.json", "pyproject.toml"],
async (projectDir) => {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
const existingEnv = Object.freeze({ EXISTING_ENV: "preserved" })
const output = { env: existingEnv }
await hooks["shell.env"]({ cwd: projectDir }, output)
const { env } = output
assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`)
assert.strictEqual(env.EXISTING_ENV, "preserved")
assert.notStrictEqual(env, existingEnv)
assert.strictEqual(env.PROJECT_ROOT, projectDir)
assert.strictEqual(env.PACKAGE_MANAGER, "pnpm")
assert.strictEqual(env.DETECTED_LANGUAGES, "typescript,python")
assert.strictEqual(env.PRIMARY_LANGUAGE, "typescript")
// Verify ECC_VERSION is not hardcoded
assert.ok(env.ECC_VERSION !== "1.8.0", "ECC_VERSION should not be hardcoded to 1.8.0")
assert.ok(env.ECC_VERSION.match(/^\d+\.\d+\.\d+$/), "ECC_VERSION should be a valid semver version")
}
),
],
[
"session.created checks CLAUDE.md through fs instead of shell test",
async () => withTempProject(["CLAUDE.md"], async (projectDir) => {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
await hooks["session.created"]()
assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`)
assert.ok(
client.logs.some((entry) => entry.message === "[ECC] Found CLAUDE.md - loading project context"),
"Expected CLAUDE.md detection log"
)
}),
],
[
"session.created ignores directories named CLAUDE.md",
async () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-opencode-plugin-"))
try {
fs.mkdirSync(path.join(projectDir, "CLAUDE.md"))
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
await hooks["session.created"]()
assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`)
assert.ok(
!client.logs.some((entry) => entry.message === "[ECC] Found CLAUDE.md - loading project context"),
"Directory named CLAUDE.md should not be treated as project context"
)
} finally {
fs.rmSync(projectDir, { recursive: true, force: true })
}
},
],
[
"shell.env ignores directories named like lockfiles and language markers",
async () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-opencode-plugin-"))
try {
fs.mkdirSync(path.join(projectDir, "pnpm-lock.yaml"))
fs.mkdirSync(path.join(projectDir, "tsconfig.json"))
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
const output = { env: {} }
await hooks["shell.env"]({ cwd: projectDir }, output)
const { env } = output
assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`)
assert.strictEqual(env.PROJECT_ROOT, projectDir)
assert.ok(!("PACKAGE_MANAGER" in env), "Lockfile directory should not set PACKAGE_MANAGER")
assert.ok(!("DETECTED_LANGUAGES" in env), "Marker directory should not set DETECTED_LANGUAGES")
assert.ok(!("PRIMARY_LANGUAGE" in env), "Marker directory should not set PRIMARY_LANGUAGE")
} finally {
fs.rmSync(projectDir, { recursive: true, force: true })
}
},
],
[
"compacting appends ECC context without replacing the host compaction prompt",
async () => withTempProject([], async (projectDir) => {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
const existingContext = Object.freeze(["Existing plugin context"])
const output = { context: existingContext }
await hooks["experimental.session.compacting"]({ sessionID: "session-1" }, output)
assert.strictEqual(output.context[0], "Existing plugin context")
assert.notStrictEqual(output.context, existingContext)
const prompt = output.prompt ?? ["Default compaction prompt", ...output.context].join("\n\n")
assert.ok(prompt.includes("Default compaction prompt"))
assert.ok(prompt.includes("# ECC Context"))
assert.ok(prompt.includes("Current task status and progress"))
assert.deepStrictEqual($.calls, [])
}),
],
[
"compacting appends ECC guidance to custom prompts, including an empty prompt",
async () => withTempProject([], async (projectDir) => {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
for (const customPrompt of ["Another plugin's custom prompt", ""]) {
const existingContext = Object.freeze(["Existing plugin context"])
const output = { context: existingContext, prompt: customPrompt }
await hooks["experimental.session.compacting"]({ sessionID: "session-1" }, output)
const prompt = output.prompt ?? ["Default compaction prompt", ...output.context].join("\n\n")
assert.ok(prompt.startsWith(`${customPrompt}\n\n`))
assert.ok(prompt.includes("# ECC Context"))
assert.ok(prompt.includes("Current task status and progress"))
assert.strictEqual(output.context, existingContext)
}
assert.deepStrictEqual($.calls, [])
}),
],
[
"permission.ask handles read-only tools correctly",
async () => withTempProject(
[],
async (projectDir) => {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
// Test read-only tools
const readResult = await hooks["permission.ask"]({ tool: "read", args: {} })
assert.strictEqual(readResult.approved, true)
assert.strictEqual(readResult.reason, "Read-only operation")
const globResult = await hooks["permission.ask"]({ tool: "glob", args: {} })
assert.strictEqual(globResult.approved, true)
assert.strictEqual(globResult.reason, "Read-only operation")
const grepResult = await hooks["permission.ask"]({ tool: "grep", args: {} })
assert.strictEqual(grepResult.approved, true)
assert.strictEqual(grepResult.reason, "Read-only operation")
}
),
],
[
"permission.ask handles formatters correctly",
async () => withTempProject(
[],
async (projectDir) => {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
// Test formatter tools - note: args should be the command string, not object
const prettierResult = await hooks["permission.ask"]({
tool: "bash",
args: "npx prettier --write src/index.ts"
})
console.log("prettierResult:", JSON.stringify(prettierResult))
assert.strictEqual(prettierResult.approved, true)
assert.strictEqual(prettierResult.reason, "Formatter execution")
const biomeResult = await hooks["permission.ask"]({
tool: "bash",
args: "npx @biomejs/biome format --write src/index.ts"
})
console.log("biomeResult:", JSON.stringify(biomeResult))
assert.strictEqual(biomeResult.approved, true)
assert.strictEqual(biomeResult.reason, "Formatter execution")
}
),
],
[
"permission.ask handles test execution correctly",
async () => withTempProject(
[],
async (projectDir) => {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
// Test test execution tools
const npmTestResult = await hooks["permission.ask"]({
tool: "bash",
args: { command: "npm test" }
})
assert.strictEqual(npmTestResult.approved, true)
assert.strictEqual(npmTestResult.reason, "Test execution")
const vitestResult = await hooks["permission.ask"]({
tool: "bash",
args: { command: "npx vitest run" }
})
assert.strictEqual(vitestResult.approved, true)
assert.strictEqual(vitestResult.reason, "Test execution")
}
),
],
[
"skill telemetry records a run through the shared tracker (#2463)",
async () => withTempProject([], async (projectDir) => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-opencode-skill-home-"))
const previousHome = process.env.HOME
const previousUserProfile = process.env.USERPROFILE
process.env.HOME = homeDir
process.env.USERPROFILE = homeDir
try {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
assert.ok(
!client.logs.some(
(entry) =>
entry.level === "warn" && entry.message.includes("skill-run telemetry disabled")
),
"Did not expect telemetry to be disabled when scripts/hooks/skill-run-tracker.js is present"
)
// OpenCode names the tool `skill`; the id arrives on `name`.
await hooks["tool.execute.after"]({ tool: "skill", args: { name: "code-review" } }, {})
const records = readRecordedRuns(homeDir)
assert.strictEqual(records.length, 1, "Expected exactly one recorded skill run")
assert.strictEqual(records[0].skill_id, "code-review")
assert.strictEqual(records[0].outcome, "success")
} finally {
restoreEnv({ HOME: previousHome, USERPROFILE: previousUserProfile })
fs.rmSync(homeDir, { recursive: true, force: true })
}
}),
],
[
"skill telemetry records nothing for other tools",
async () => withTempProject([], async (projectDir) => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-opencode-skill-home-"))
const previousHome = process.env.HOME
const previousUserProfile = process.env.USERPROFILE
process.env.HOME = homeDir
process.env.USERPROFILE = homeDir
try {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
await hooks["tool.execute.after"]({ tool: "edit", args: { path: "src/a.ts" } }, {})
await hooks["tool.execute.after"]({ tool: "bash", args: { command: "ls" } }, {})
assert.strictEqual(readRecordedRuns(homeDir).length, 0)
} finally {
restoreEnv({ HOME: previousHome, USERPROFILE: previousUserProfile })
fs.rmSync(homeDir, { recursive: true, force: true })
}
}),
],
[
"skill telemetry respects the minimal hook profile",
async () => withTempProject([], async (projectDir) => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-opencode-skill-home-"))
const previousHome = process.env.HOME
const previousUserProfile = process.env.USERPROFILE
const previousProfile = process.env.ECC_HOOK_PROFILE
process.env.HOME = homeDir
process.env.USERPROFILE = homeDir
process.env.ECC_HOOK_PROFILE = "minimal"
try {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
await hooks["tool.execute.after"]({ tool: "skill", args: { name: "code-review" } }, {})
assert.strictEqual(readRecordedRuns(homeDir).length, 0)
} finally {
restoreEnv({
HOME: previousHome,
USERPROFILE: previousUserProfile,
ECC_HOOK_PROFILE: previousProfile,
})
fs.rmSync(homeDir, { recursive: true, force: true })
}
}),
],
[
"shell.env exports CLAUDE_PLUGIN_ROOT so commands can resolve the ECC root",
async () => withTempProject([], async (projectDir) => {
const previousRoot = process.env.CLAUDE_PLUGIN_ROOT
delete process.env.CLAUDE_PLUGIN_ROOT
try {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
const output = { env: {} }
await hooks["shell.env"]({ cwd: projectDir }, output)
assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`)
assert.ok(output.env.CLAUDE_PLUGIN_ROOT, "Expected CLAUDE_PLUGIN_ROOT to be exported")
// The resolver consumes this variable verbatim, so it must be a
// directory that actually carries the ECC script tree.
const root = output.env.CLAUDE_PLUGIN_ROOT
assert.ok(
fs.existsSync(path.join(root, "scripts", "lib", "resolve-ecc-root.js")),
`Expected ${root} to contain scripts/lib/resolve-ecc-root.js`
)
} finally {
restoreEnv({ CLAUDE_PLUGIN_ROOT: previousRoot })
}
}),
],
[
"shell.env does not override a CLAUDE_PLUGIN_ROOT the user set",
async () => withTempProject([], async (projectDir) => {
const explicitRoot = path.join(projectDir, "explicit-root")
const previousRoot = process.env.CLAUDE_PLUGIN_ROOT
process.env.CLAUDE_PLUGIN_ROOT = explicitRoot
try {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
const output = { env: {} }
await hooks["shell.env"]({ cwd: projectDir }, output)
assert.strictEqual(
output.env.CLAUDE_PLUGIN_ROOT,
undefined,
"the plugin must not inject its own root over one the user set"
)
assert.strictEqual(
process.env.CLAUDE_PLUGIN_ROOT,
explicitRoot,
"the user's value must survive untouched"
)
} finally {
restoreEnv({ CLAUDE_PLUGIN_ROOT: previousRoot })
}
}),
],
[
"shell.env does not clobber a root another plugin already put in output.env",
async () => withTempProject([], async (projectDir) => {
const previousRoot = process.env.CLAUDE_PLUGIN_ROOT
delete process.env.CLAUDE_PLUGIN_ROOT
try {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
// An earlier plugin chose a root. The final merge below spreads our env
// over output.env, so a guard that only checked process.env would
// replace this value.
const output = { env: { CLAUDE_PLUGIN_ROOT: path.join(projectDir, "other-plugin-root") } }
await hooks["shell.env"]({ cwd: projectDir }, output)
assert.strictEqual(
output.env.CLAUDE_PLUGIN_ROOT,
path.join(projectDir, "other-plugin-root"),
"an existing output.env root must be preserved"
)
} finally {
restoreEnv({ CLAUDE_PLUGIN_ROOT: previousRoot })
}
}),
],
[
"telemetry and root export work in a modular install without optional skills",
async () => {
// Regression: root detection probed skills/continuous-learning-v2, which
// belongs to the optional workflow-quality module. An install with
// platform-configs + hooks-runtime but without workflow-quality still
// ships the plugin and the tracker, and it lost both fixes.
const root = createModularInstallRoot()
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-opencode-modular-"))
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-opencode-skill-home-"))
const previousHome = process.env.HOME
const previousUserProfile = process.env.USERPROFILE
const previousRoot = process.env.CLAUDE_PLUGIN_ROOT
process.env.HOME = homeDir
process.env.USERPROFILE = homeDir
delete process.env.CLAUDE_PLUGIN_ROOT
try {
assert.ok(
!fs.existsSync(path.join(root, "skills")),
"the fixture must not contain skills/, or it would not test the modular case"
)
const { ECCHooksPlugin: modularPlugin } = await loadPluginFrom(root)
const client = createClient()
const hooks = await modularPlugin({
client,
$: createFailingShell(),
directory: projectDir,
})
assert.ok(
!client.logs.some(
(entry) =>
entry.level === "warn" && entry.message.includes("skill-run telemetry disabled")
),
"telemetry must stay enabled when the optional skills module is absent"
)
await hooks["tool.execute.after"]({ tool: "skill", args: { name: "code-review" } }, {})
assert.strictEqual(readRecordedRuns(homeDir).length, 1, "expected the run to be recorded")
const output = { env: {} }
await hooks["shell.env"]({ cwd: projectDir }, output)
assert.strictEqual(output.env.CLAUDE_PLUGIN_ROOT, root)
assert.strictEqual(output.env.ECC_VERSION, "0.0.0-test")
} finally {
restoreEnv({
HOME: previousHome,
USERPROFILE: previousUserProfile,
CLAUDE_PLUGIN_ROOT: previousRoot,
})
fs.rmSync(root, { recursive: true, force: true })
fs.rmSync(projectDir, { recursive: true, force: true })
fs.rmSync(homeDir, { recursive: true, force: true })
}
},
],
]
let passed = 0
let failed = 0
for (const [name, fn] of tests) {
const result = await runTest(name, fn)
passed += result.passed
failed += result.failed
}
console.log(`\nPassed: ${passed}`)
console.log(`Failed: ${failed}`)
process.exit(failed > 0 ? 1 : 0)
}
main()