mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-21 00:58:07 +02:00
Root package.json declared no main or exports, so OpenCode npm plugin resolution (import.meta.resolve) failed and the plugin was silently skipped (#3127). The .opencode TypeScript sources imported siblings with .js specifiers that only exist after compilation, so the home install, which loads the .ts files in place, crashed the tool registry with ERR_MODULE_NOT_FOUND (#3112). Declare main/types/exports on the root package pointing at the compiled plugin entry, switch the sources to .ts specifiers, and enable allowImportingTsExtensions with rewriteRelativeImportExtensions so the emitted dist keeps working .js specifiers. Add smoke tests that build the package, resolve and import the entry by name from a temp install, and verify every in-place relative import resolves. Fixes #3127 Fixes #3112
263 lines
9.6 KiB
JavaScript
263 lines
9.6 KiB
JavaScript
/**
|
|
* Tests for scripts/build-opencode.js
|
|
*/
|
|
|
|
const assert = require("assert")
|
|
const fs = require("fs")
|
|
const os = require("os")
|
|
const path = require("path")
|
|
const { spawnSync } = require("child_process")
|
|
const { getNpmPackEntry } = require("../lib/npm-pack-output")
|
|
|
|
function runTest(name, fn) {
|
|
try {
|
|
fn()
|
|
console.log(` ✓ ${name}`)
|
|
return true
|
|
} catch (error) {
|
|
console.log(` ✗ ${name}`)
|
|
console.error(` ${error.message}`)
|
|
return false
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
console.log("\n=== Testing build-opencode.js ===\n")
|
|
|
|
let passed = 0
|
|
let failed = 0
|
|
|
|
const repoRoot = path.join(__dirname, "..", "..")
|
|
const packageJson = JSON.parse(
|
|
fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")
|
|
)
|
|
const buildScript = path.join(repoRoot, "scripts", "build-opencode.js")
|
|
const distEntry = path.join(repoRoot, ".opencode", "dist", "index.js")
|
|
const tests = [
|
|
["package.json exposes the OpenCode build and prepack hooks", () => {
|
|
assert.strictEqual(packageJson.scripts["build:opencode"], "node scripts/build-opencode.js")
|
|
assert.strictEqual(packageJson.scripts.prepack, "npm run build:opencode")
|
|
assert.ok(packageJson.files.includes(".opencode/"))
|
|
}],
|
|
["build script generates .opencode/dist", () => {
|
|
const result = spawnSync("node", [buildScript], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
})
|
|
assert.strictEqual(result.status, 0, result.stderr)
|
|
assert.ok(fs.existsSync(distEntry), ".opencode/dist/index.js should exist after build")
|
|
}],
|
|
["package.json declares a resolvable OpenCode plugin entry", () => {
|
|
assert.strictEqual(packageJson.main, ".opencode/dist/index.js")
|
|
assert.ok(packageJson.exports, "package.json must declare an exports map")
|
|
assert.deepStrictEqual(packageJson.exports["."], {
|
|
types: "./.opencode/dist/index.d.ts",
|
|
import: "./.opencode/dist/index.js",
|
|
default: "./.opencode/dist/index.js",
|
|
})
|
|
}],
|
|
["installed package resolves and imports its root module by name", () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-opencode-entry-"))
|
|
try {
|
|
fs.mkdirSync(path.join(tempDir, "node_modules"), { recursive: true })
|
|
fs.symlinkSync(
|
|
repoRoot,
|
|
path.join(tempDir, "node_modules", "ecc-universal"),
|
|
process.platform === "win32" ? "junction" : "dir"
|
|
)
|
|
const probe = `
|
|
const resolved = import.meta.resolve("ecc-universal")
|
|
if (!resolved.endsWith("/.opencode/dist/index.js")) {
|
|
throw new Error("unexpected entry resolution: " + resolved)
|
|
}
|
|
const mod = await import("ecc-universal")
|
|
if (Object.keys(mod).join(",") !== "default" || typeof mod.default !== "function") {
|
|
throw new Error("root module must export exactly the plugin function")
|
|
}
|
|
`
|
|
const probePath = path.join(tempDir, "probe.mjs")
|
|
fs.writeFileSync(probePath, probe)
|
|
const result = spawnSync(process.execPath, [probePath], {
|
|
cwd: tempDir,
|
|
encoding: "utf8",
|
|
})
|
|
assert.strictEqual(result.status, 0, result.stderr)
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
}
|
|
}],
|
|
["OpenCode TypeScript sources resolve their relative imports in place", () => {
|
|
const opencodeDir = path.join(repoRoot, ".opencode")
|
|
const sourceFiles = []
|
|
const walk = (dir) => {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const entryPath = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
if (entry.name !== "node_modules" && entry.name !== "dist") walk(entryPath)
|
|
} else if (entry.name.endsWith(".ts")) {
|
|
sourceFiles.push(entryPath)
|
|
}
|
|
}
|
|
}
|
|
walk(opencodeDir)
|
|
assert.ok(sourceFiles.length > 0, "expected OpenCode TypeScript sources")
|
|
const unresolved = []
|
|
for (const sourceFile of sourceFiles) {
|
|
const source = fs.readFileSync(sourceFile, "utf8")
|
|
for (const match of source.matchAll(/(?:from|import)\s*\(?\s*"(\.[^"]+)"/g)) {
|
|
const target = path.resolve(path.dirname(sourceFile), match[1])
|
|
if (!fs.existsSync(target)) {
|
|
unresolved.push(`${path.relative(repoRoot, sourceFile)} -> ${match[1]}`)
|
|
}
|
|
}
|
|
}
|
|
assert.deepStrictEqual(unresolved, [])
|
|
}],
|
|
["built OpenCode entry exports only the plugin function", () => {
|
|
const check = `
|
|
const assert = require("assert")
|
|
const { pathToFileURL } = require("url")
|
|
|
|
async function main() {
|
|
let mod
|
|
try {
|
|
mod = await import(pathToFileURL(process.argv[1]).href)
|
|
} catch (error) {
|
|
console.error(error)
|
|
process.exit(1)
|
|
}
|
|
assert.deepStrictEqual(Object.keys(mod).sort(), ["default"])
|
|
assert.strictEqual(typeof mod.default, "function")
|
|
|
|
let shellCalls = 0
|
|
const plugin = await mod.default({
|
|
client: { app: { log: () => {} } },
|
|
$: async () => {
|
|
shellCalls += 1
|
|
throw new Error("$ must not be called during plugin init")
|
|
},
|
|
directory: process.cwd(),
|
|
worktree: process.cwd(),
|
|
})
|
|
assert.strictEqual(shellCalls, 0, "$ must not be called during plugin init")
|
|
assert.ok(plugin && typeof plugin === "object", "default export must return a plugin record")
|
|
const expectedHooks = [
|
|
"file.edited",
|
|
"tool.execute.after",
|
|
"tool.execute.before",
|
|
"session.created",
|
|
"session.idle",
|
|
"session.deleted",
|
|
"file.watcher.updated",
|
|
"todo.updated",
|
|
"shell.env",
|
|
"experimental.session.compacting",
|
|
"permission.ask",
|
|
]
|
|
for (const hook of expectedHooks) {
|
|
assert.strictEqual(typeof plugin[hook], "function", "missing hook: " + hook)
|
|
}
|
|
assert.ok(plugin.tool && typeof plugin.tool === "object", "plugin record must expose a tool object")
|
|
assert.deepStrictEqual(
|
|
Object.keys(plugin.tool).sort(),
|
|
["changed-files", "dependency-analyzer"],
|
|
"plugin.tool must expose exactly the custom tools"
|
|
)
|
|
for (const toolName of ["changed-files", "dependency-analyzer"]) {
|
|
const toolDefinition = plugin.tool[toolName]
|
|
assert.ok(toolDefinition && typeof toolDefinition === "object", "missing tool: " + toolName)
|
|
assert.strictEqual(typeof toolDefinition.description, "string", toolName + " must declare a description")
|
|
assert.ok(toolDefinition.args && typeof toolDefinition.args === "object", toolName + " must declare args")
|
|
assert.strictEqual(typeof toolDefinition.execute, "function", toolName + " must declare an execute function")
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error)
|
|
process.exit(1)
|
|
})
|
|
`
|
|
const result = spawnSync(process.execPath, ["-e", check, distEntry], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
})
|
|
assert.strictEqual(result.status, 0, result.stderr)
|
|
}],
|
|
["npm pack includes the compiled OpenCode dist payload", () => {
|
|
const result = spawnSync("npm", ["pack", "--dry-run", "--json"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
shell: process.platform === "win32",
|
|
})
|
|
assert.strictEqual(result.status, 0, result.error?.message || result.stderr)
|
|
|
|
const packOutput = JSON.parse(result.stdout)
|
|
const packEntry = getNpmPackEntry(packOutput, packageJson.name)
|
|
const packagedPaths = new Set(packEntry?.files?.map((file) => file.path) ?? [])
|
|
|
|
assert.ok(
|
|
packagedPaths.has(".opencode/dist/index.js"),
|
|
"npm pack should include .opencode/dist/index.js"
|
|
)
|
|
assert.ok(
|
|
packagedPaths.has(".opencode/dist/plugins/index.js"),
|
|
"npm pack should include compiled OpenCode plugin output"
|
|
)
|
|
assert.ok(
|
|
packagedPaths.has(".opencode/dist/tools/index.js"),
|
|
"npm pack should include compiled OpenCode tool output"
|
|
)
|
|
assert.ok(
|
|
packagedPaths.has(".claude-plugin/marketplace.json"),
|
|
"npm pack should include .claude-plugin/marketplace.json"
|
|
)
|
|
assert.ok(
|
|
packagedPaths.has(".claude-plugin/plugin.json"),
|
|
"npm pack should include .claude-plugin/plugin.json"
|
|
)
|
|
assert.ok(
|
|
packagedPaths.has(".codex-plugin/plugin.json"),
|
|
"npm pack should include .codex-plugin/plugin.json"
|
|
)
|
|
assert.ok(
|
|
packagedPaths.has(".agents/plugins/marketplace.json"),
|
|
"npm pack should include .agents/plugins/marketplace.json"
|
|
)
|
|
assert.ok(
|
|
packagedPaths.has(".opencode/package.json"),
|
|
"npm pack should include .opencode/package.json"
|
|
)
|
|
assert.ok(
|
|
packagedPaths.has(".opencode/package-lock.json"),
|
|
"npm pack should include .opencode/package-lock.json"
|
|
)
|
|
assert.ok(
|
|
packagedPaths.has("agent.yaml"),
|
|
"npm pack should include agent.yaml"
|
|
)
|
|
assert.ok(
|
|
packagedPaths.has("AGENTS.md"),
|
|
"npm pack should include AGENTS.md"
|
|
)
|
|
assert.ok(
|
|
packagedPaths.has("VERSION"),
|
|
"npm pack should include VERSION"
|
|
)
|
|
}],
|
|
]
|
|
|
|
for (const [name, fn] of tests) {
|
|
if (runTest(name, fn)) {
|
|
passed += 1
|
|
} else {
|
|
failed += 1
|
|
}
|
|
}
|
|
|
|
console.log(`\nPassed: ${passed}`)
|
|
console.log(`Failed: ${failed}`)
|
|
process.exit(failed > 0 ? 1 : 0)
|
|
}
|
|
|
|
main()
|