2026-05-27 16:10:46 +09:00
|
|
|
/// <reference path="../../../bun-test.d.ts" />
|
|
|
|
|
/// <reference types="bun-types" />
|
|
|
|
|
|
|
|
|
|
import { describe, expect, test } from "bun:test"
|
|
|
|
|
import { lstat, mkdir, mkdtemp, readdir, readFile, readlink, writeFile } from "node:fs/promises"
|
|
|
|
|
import { tmpdir } from "node:os"
|
|
|
|
|
import { join } from "node:path"
|
|
|
|
|
import { linkCachedPluginAgents } from "./link-cached-plugin-agents"
|
|
|
|
|
|
|
|
|
|
async function makeFixture(): Promise<{ codexHome: string; pluginRoot: string }> {
|
|
|
|
|
const root = await mkdtemp(join(tmpdir(), "omo-codex-agents-"))
|
|
|
|
|
const codexHome = join(root, "codex")
|
|
|
|
|
const pluginRoot = join(root, "plugin")
|
|
|
|
|
await mkdir(join(pluginRoot, "components", "ultrawork", "agents"), { recursive: true })
|
2026-05-29 12:38:22 +09:00
|
|
|
await mkdir(join(pluginRoot, "components", "ulw-loop", "agents"), { recursive: true })
|
2026-05-27 16:10:46 +09:00
|
|
|
await writeFile(
|
|
|
|
|
join(pluginRoot, "components", "ultrawork", "agents", "explorer.toml"),
|
|
|
|
|
'name = "explorer"\n',
|
|
|
|
|
)
|
|
|
|
|
await writeFile(
|
|
|
|
|
join(pluginRoot, "components", "ultrawork", "agents", "librarian.toml"),
|
|
|
|
|
'name = "librarian"\n',
|
|
|
|
|
)
|
|
|
|
|
await writeFile(
|
2026-05-29 12:38:22 +09:00
|
|
|
join(pluginRoot, "components", "ulw-loop", "agents", "planner.toml"),
|
2026-05-27 16:10:46 +09:00
|
|
|
'name = "planner"\n',
|
|
|
|
|
)
|
|
|
|
|
return { codexHome, pluginRoot }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("linkCachedPluginAgents", () => {
|
|
|
|
|
test("creates symlinks on linux that point at the bundled TOMLs", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const { codexHome, pluginRoot } = await makeFixture()
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
expect(linked.map((entry) => entry.name).sort()).toEqual([
|
|
|
|
|
"explorer.toml",
|
|
|
|
|
"librarian.toml",
|
|
|
|
|
"planner.toml",
|
|
|
|
|
])
|
|
|
|
|
for (const entry of linked) {
|
|
|
|
|
const linkStat = await lstat(entry.path)
|
|
|
|
|
expect(linkStat.isSymbolicLink()).toBe(true)
|
|
|
|
|
expect(await readlink(entry.path)).toBe(entry.target)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("creates symlinks on darwin (macOS)", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const { codexHome, pluginRoot } = await makeFixture()
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "darwin" })
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
expect(linked).toHaveLength(3)
|
|
|
|
|
for (const entry of linked) {
|
|
|
|
|
expect((await lstat(entry.path)).isSymbolicLink()).toBe(true)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("creates regular file copies on Windows (no symlinks)", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const { codexHome, pluginRoot } = await makeFixture()
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "win32" })
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
expect(linked).toHaveLength(3)
|
|
|
|
|
for (const entry of linked) {
|
|
|
|
|
const linkStat = await lstat(entry.path)
|
|
|
|
|
expect(linkStat.isSymbolicLink()).toBe(false)
|
|
|
|
|
expect(linkStat.isFile()).toBe(true)
|
|
|
|
|
const content = await readFile(entry.path, "utf8")
|
|
|
|
|
expect(content).toContain(`name = "${entry.name.replace(/\.toml$/, "")}"`)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("replaces stale regular files (legacy sync-agents.py copies) with symlinks on unix", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const { codexHome, pluginRoot } = await makeFixture()
|
|
|
|
|
const agentsDir = join(codexHome, "agents")
|
|
|
|
|
await mkdir(agentsDir, { recursive: true })
|
|
|
|
|
await writeFile(
|
|
|
|
|
join(agentsDir, "explorer.toml"),
|
|
|
|
|
"# stale broken copy with no `name` field, from old sync-agents.py\nmodel = \"old\"\n",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
const linkStat = await lstat(join(agentsDir, "explorer.toml"))
|
|
|
|
|
expect(linkStat.isSymbolicLink()).toBe(true)
|
|
|
|
|
expect(await readlink(join(agentsDir, "explorer.toml"))).toBe(
|
|
|
|
|
join(pluginRoot, "components", "ultrawork", "agents", "explorer.toml"),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("overwrites stale copies on Windows", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const { codexHome, pluginRoot } = await makeFixture()
|
|
|
|
|
const agentsDir = join(codexHome, "agents")
|
|
|
|
|
await mkdir(agentsDir, { recursive: true })
|
|
|
|
|
await writeFile(join(agentsDir, "explorer.toml"), "# stale broken copy\n")
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "win32" })
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
const content = await readFile(join(agentsDir, "explorer.toml"), "utf8")
|
|
|
|
|
expect(content).toContain('name = "explorer"')
|
|
|
|
|
expect(content).not.toContain("stale broken copy")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("writes a manifest under the plugin cache listing installed agent paths for clean uninstall", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const { codexHome, pluginRoot } = await makeFixture()
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
const manifestContent = await readFile(join(pluginRoot, ".installed-agents.json"), "utf8")
|
|
|
|
|
const manifest = JSON.parse(manifestContent) as { agents: string[] }
|
|
|
|
|
expect(manifest.agents.sort()).toEqual([
|
|
|
|
|
join(codexHome, "agents", "explorer.toml"),
|
|
|
|
|
join(codexHome, "agents", "librarian.toml"),
|
|
|
|
|
join(codexHome, "agents", "planner.toml"),
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("is idempotent across re-runs", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const { codexHome, pluginRoot } = await makeFixture()
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
|
|
|
|
await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
|
|
|
|
const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
expect(linked).toHaveLength(3)
|
|
|
|
|
const entries = (await readdir(join(codexHome, "agents"))).sort()
|
|
|
|
|
expect(entries).toEqual(["explorer.toml", "librarian.toml", "planner.toml"])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("discovers TOMLs across multiple component agent directories", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const { codexHome, pluginRoot } = await makeFixture()
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
const targets = linked.map((entry) => entry.target).sort()
|
|
|
|
|
expect(targets).toContain(join(pluginRoot, "components", "ultrawork", "agents", "explorer.toml"))
|
2026-05-29 12:38:22 +09:00
|
|
|
expect(targets).toContain(join(pluginRoot, "components", "ulw-loop", "agents", "planner.toml"))
|
2026-05-27 16:10:46 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("returns empty list when plugin has no bundled agents", async () => {
|
|
|
|
|
// given
|
|
|
|
|
const root = await mkdtemp(join(tmpdir(), "omo-codex-agents-empty-"))
|
|
|
|
|
const codexHome = join(root, "codex")
|
|
|
|
|
const pluginRoot = join(root, "plugin")
|
|
|
|
|
await mkdir(pluginRoot, { recursive: true })
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" })
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
expect(linked).toEqual([])
|
|
|
|
|
const manifest = JSON.parse(
|
|
|
|
|
await readFile(join(pluginRoot, ".installed-agents.json"), "utf8"),
|
|
|
|
|
) as { agents: string[] }
|
|
|
|
|
expect(manifest.agents).toEqual([])
|
|
|
|
|
})
|
2026-05-27 16:34:24 +09:00
|
|
|
|
|
|
|
|
test("auto-detects host platform when platform parameter is omitted", async () => {
|
|
|
|
|
// given - no `platform` argument, so process.platform decides
|
|
|
|
|
const { codexHome, pluginRoot } = await makeFixture()
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
const linked = await linkCachedPluginAgents({ codexHome, pluginRoot })
|
|
|
|
|
|
|
|
|
|
// then - on Unix expect symlinks; on Windows expect file copies
|
|
|
|
|
expect(linked).toHaveLength(3)
|
|
|
|
|
for (const entry of linked) {
|
|
|
|
|
const linkStat = await lstat(entry.path)
|
|
|
|
|
if (process.platform === "win32") {
|
|
|
|
|
expect(linkStat.isSymbolicLink()).toBe(false)
|
|
|
|
|
expect(linkStat.isFile()).toBe(true)
|
|
|
|
|
const content = await readFile(entry.path, "utf8")
|
|
|
|
|
expect(content).toContain(`name = "${entry.name.replace(/\.toml$/, "")}"`)
|
|
|
|
|
} else {
|
|
|
|
|
expect(linkStat.isSymbolicLink()).toBe(true)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-05-27 16:10:46 +09:00
|
|
|
})
|