Files
opencode-toolkit/test/installer-project.test.js
T

164 lines
7.5 KiB
JavaScript

import assert from "node:assert/strict"
import { mkdtemp, readFile, readdir, rm, writeFile, mkdir, symlink, lstat, access } from "node:fs/promises"
import { createHash } from "node:crypto"
import os from "node:os"
import path from "node:path"
import test from "node:test"
import { installProject, uninstallProject } from "../dist/installer/project.js"
const PACKAGE_NAME = "@sargatxet/opencode-toolkit"
async function withProject(run) {
const project = await mkdtemp(path.join(os.tmpdir(), "opencode-toolkit-test-"))
try {
await run(project)
} finally {
await rm(project, { recursive: true, force: true })
}
}
test("installs curated content and package registration idempotently", async () => {
await withProject(async (project) => {
await writeFile(path.join(project, "opencode.json"), '{\n // keep this comment\n "model": "provider/model"\n}\n')
const first = await installProject(project)
const second = await installProject(project)
const config = await readFile(path.join(project, "opencode.json"), "utf8")
const installedSkills = await readdir(path.join(project, ".opencode", "skills"))
assert.equal(first.installed.length, 9)
assert.equal(first.pluginConfigured, true)
assert.equal(second.installed.length, 0)
assert.equal(second.updated.length, 0)
assert.deepEqual(installedSkills, [
"toolkit-coding-standards",
"toolkit-security-review",
"toolkit-tdd-workflow",
])
assert.deepEqual(JSON.parse(config.replace(" // keep this comment\n", "")).plugins, [
"./node_modules/@sargatxet/opencode-toolkit",
])
assert.match(await readFile(path.join(project, "opencode.json"), "utf8"), /keep this comment/)
})
})
test("registers a config-relative package path and migrates the previously managed npm entry", async () => {
await withProject(async (project) => {
await mkdir(path.join(project, ".opencode"), { recursive: true })
const configPath = path.join(project, ".opencode", "opencode.json")
await writeFile(configPath, "{}\n")
await installProject(project)
await writeFile(configPath, `{
"plugins": ["${PACKAGE_NAME}"]
}\n`)
await installProject(project)
const config = JSON.parse(await readFile(configPath, "utf8"))
assert.deepEqual(config.plugins, ["../node_modules/@sargatxet/opencode-toolkit"])
const result = await uninstallProject(project)
assert.equal(result.pluginRemoved, true)
assert.deepEqual(JSON.parse(await readFile(configPath, "utf8")).plugins, [])
})
})
test("refuses to overwrite unowned files and leaves installation unchanged", async () => {
await withProject(async (project) => {
const target = path.join(project, ".opencode", "skills", "toolkit-tdd-workflow", "SKILL.md")
await mkdir(path.dirname(target), { recursive: true })
await writeFile(target, "user-authored skill")
await assert.rejects(installProject(project), /collision.*not managed/i)
assert.equal(await readFile(target, "utf8"), "user-authored skill")
await assert.rejects(lstat(path.join(project, ".opencode", ".opencode-toolkit-install.json")), { code: "ENOENT" })
})
})
test("refuses ambiguous projects with both OpenCode config locations", async () => {
await withProject(async (project) => {
await writeFile(path.join(project, "opencode.json"), '{"model":"provider/model"}')
await mkdir(path.join(project, ".opencode"), { recursive: true })
await writeFile(path.join(project, ".opencode", "opencode.json"), '{"plugins":[]}')
await assert.rejects(installProject(project), /both opencode\.json.*\.opencode\/opencode\.json/i)
await assert.rejects(lstat(path.join(project, ".opencode", ".opencode-toolkit-install.json")), { code: "ENOENT" })
assert.equal(await readFile(path.join(project, "opencode.json"), "utf8"), '{"model":"provider/model"}')
})
})
test("uninstall removes owned files and preserves locally modified content", async () => {
await withProject(async (project) => {
await installProject(project)
const preservedPath = path.join(project, ".opencode", "agents", "toolkit-code-reviewer.md")
await writeFile(preservedPath, "local edits")
const result = await uninstallProject(project)
const config = JSON.parse(await readFile(path.join(project, ".opencode", "opencode.json"), "utf8"))
assert.equal(result.removed.length, 8)
assert.deepEqual(result.preserved, [path.join(".opencode", "agents", "toolkit-code-reviewer.md")])
assert.equal(result.pluginRemoved, true)
assert.deepEqual(config.plugins, [])
assert.equal(await readFile(preservedPath, "utf8"), "local edits")
await assert.rejects(lstat(path.join(project, ".opencode", ".opencode-toolkit-install.json")), { code: "ENOENT" })
})
})
test("update removes obsolete unmodified files and keeps edited obsolete files tracked", async () => {
await withProject(async (project) => {
await installProject(project)
const manifestPath = path.join(project, ".opencode", ".opencode-toolkit-install.json")
const manifest = JSON.parse(await readFile(manifestPath, "utf8"))
const removable = path.join(".opencode", "skills", "toolkit-obsolete", "SKILL.md")
const edited = path.join(".opencode", "agents", "toolkit-obsolete.md")
const removableContent = "obsolete but unchanged"
const originalEditedContent = "upstream obsolete content"
await mkdir(path.dirname(path.join(project, removable)), { recursive: true })
await mkdir(path.dirname(path.join(project, edited)), { recursive: true })
await writeFile(path.join(project, removable), removableContent)
await writeFile(path.join(project, edited), "locally edited obsolete content")
const digest = (value) => createHash("sha256").update(value).digest("hex")
manifest.files.push(
{ path: removable, hash: digest(removableContent) },
{ path: edited, hash: digest(originalEditedContent) },
)
await writeFile(manifestPath, JSON.stringify(manifest))
const result = await installProject(project)
assert.deepEqual(result.removed, [removable])
assert.deepEqual(result.preserved, [edited])
await assert.rejects(access(path.join(project, removable)), { code: "ENOENT" })
assert.equal(await readFile(path.join(project, edited), "utf8"), "locally edited obsolete content")
const updatedManifest = JSON.parse(await readFile(manifestPath, "utf8"))
assert.ok(updatedManifest.files.some((entry) => entry.path === edited))
})
})
test("rejects symlinked install directories without writing outside the project", async () => {
await withProject(async (project) => {
const external = await mkdtemp(path.join(os.tmpdir(), "opencode-toolkit-external-"))
try {
await mkdir(path.join(project, ".opencode", "skills"), { recursive: true })
await symlink(external, path.join(project, ".opencode", "skills", "toolkit-coding-standards"), "dir")
await assert.rejects(installProject(project), /symlink/i)
assert.deepEqual(await readdir(external), [])
} finally {
await rm(external, { recursive: true, force: true })
}
})
})
test("rejects a tampered ownership manifest before following paths", async () => {
await withProject(async (project) => {
await installProject(project)
const manifestPath = path.join(project, ".opencode", ".opencode-toolkit-install.json")
const manifest = JSON.parse(await readFile(manifestPath, "utf8"))
manifest.configPath = "../../outside/opencode.json"
await writeFile(manifestPath, JSON.stringify(manifest))
await assert.rejects(uninstallProject(project), /invalid opencode config path/i)
assert.equal((await readdir(path.join(project, ".opencode", "skills"))).length, 3)
})
})