670 lines
28 KiB
TypeScript
670 lines
28 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test"
|
|
import { promises as fs } from "node:fs"
|
|
import os from "node:os"
|
|
import path from "node:path"
|
|
import {
|
|
BunCommandRunner,
|
|
activateLocalCollection,
|
|
inspectCodexDevStatus,
|
|
inspectLocalCollection,
|
|
replaceManagedCollectionLink,
|
|
removeManagedCollectionLink,
|
|
removeCodexDevInstallation,
|
|
removeLocalCollection,
|
|
resolveCodexDevContext,
|
|
runCodexDevCommand,
|
|
switchToLocal,
|
|
switchToRemote,
|
|
type CommandOptions,
|
|
type CommandResult,
|
|
type CommandRunner,
|
|
type InstalledPlugin,
|
|
} from "../src/dev/codex-dev"
|
|
|
|
const tempRoots: string[] = []
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })))
|
|
})
|
|
|
|
async function makeTempRoot(prefix: string): Promise<string> {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
|
|
tempRoots.push(root)
|
|
return root
|
|
}
|
|
|
|
async function runGit(cwd: string, args: string[]): Promise<string> {
|
|
const proc = Bun.spawn(["git", ...args], {
|
|
cwd,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: {
|
|
...process.env,
|
|
GIT_AUTHOR_NAME: "Codex Dev Test",
|
|
GIT_AUTHOR_EMAIL: "codex-dev@example.com",
|
|
GIT_COMMITTER_NAME: "Codex Dev Test",
|
|
GIT_COMMITTER_EMAIL: "codex-dev@example.com",
|
|
},
|
|
})
|
|
const exitCode = await proc.exited
|
|
const stdout = await new Response(proc.stdout).text()
|
|
const stderr = await new Response(proc.stderr).text()
|
|
if (exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${stderr}`)
|
|
return stdout.trim()
|
|
}
|
|
|
|
async function createCeRepo(repoRoot: string): Promise<void> {
|
|
await fs.mkdir(path.join(repoRoot, ".codex-plugin"), { recursive: true })
|
|
await fs.mkdir(path.join(repoRoot, "skills", "ce-alpha"), { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(repoRoot, "package.json"),
|
|
JSON.stringify({ name: "compound-engineering", version: "3.19.0" }) + "\n",
|
|
)
|
|
await fs.writeFile(
|
|
path.join(repoRoot, ".codex-plugin", "plugin.json"),
|
|
JSON.stringify({ name: "compound-engineering", version: "3.19.0", skills: "./skills/" }) + "\n",
|
|
)
|
|
await fs.writeFile(
|
|
path.join(repoRoot, "skills", "ce-alpha", "SKILL.md"),
|
|
"---\nname: ce-alpha\ndescription: Alpha test skill.\n---\n",
|
|
)
|
|
await runGit(repoRoot, ["init", "-b", "main"])
|
|
await runGit(repoRoot, ["add", "."])
|
|
await runGit(repoRoot, ["commit", "-m", "initial"])
|
|
}
|
|
|
|
function testEnv(home: string, codexHome: string): NodeJS.ProcessEnv {
|
|
return { ...process.env, HOME: home, CODEX_HOME: codexHome }
|
|
}
|
|
|
|
const officialMarketplace = {
|
|
name: "compound-engineering-plugin",
|
|
root: "/tmp/compound-engineering-plugin",
|
|
marketplaceSource: {
|
|
sourceType: "git",
|
|
source: "https://github.com/EveryInc/compound-engineering-plugin.git",
|
|
},
|
|
}
|
|
|
|
function plugin(pluginId: string, options: Partial<InstalledPlugin> = {}): InstalledPlugin {
|
|
const marketplaceName = pluginId.split("@")[1]
|
|
return {
|
|
pluginId,
|
|
name: "compound-engineering",
|
|
marketplaceName,
|
|
version: "3.19.0",
|
|
installed: true,
|
|
enabled: true,
|
|
source: { source: "local", path: "/tmp/compound-engineering-plugin" },
|
|
marketplaceSource: {
|
|
sourceType: "git",
|
|
source: "https://github.com/EveryInc/compound-engineering-plugin.git",
|
|
},
|
|
...options,
|
|
}
|
|
}
|
|
|
|
class StatefulCodexRunner implements CommandRunner {
|
|
plugins: InstalledPlugin[]
|
|
marketplaces: typeof officialMarketplace[]
|
|
calls: string[][] = []
|
|
failAdd = false
|
|
failRemove: string | null = null
|
|
beforeFailedRemove?: () => Promise<void>
|
|
|
|
constructor(plugins: InstalledPlugin[] = [], marketplaces: typeof officialMarketplace[] = []) {
|
|
this.plugins = [...plugins]
|
|
this.marketplaces = [...marketplaces]
|
|
}
|
|
|
|
async run(command: string, args: string[], _options?: CommandOptions): Promise<CommandResult> {
|
|
this.calls.push([command, ...args])
|
|
if (command !== "codex") return { exitCode: 127, stdout: "", stderr: "unexpected command" }
|
|
const joined = args.join(" ")
|
|
if (joined === "plugin list --available --json") {
|
|
return { exitCode: 0, stdout: JSON.stringify({ installed: this.plugins, available: [] }), stderr: "" }
|
|
}
|
|
if (joined === "plugin marketplace list --json") {
|
|
return { exitCode: 0, stdout: JSON.stringify({ marketplaces: this.marketplaces }), stderr: "" }
|
|
}
|
|
if (args[0] === "plugin" && args[1] === "remove") {
|
|
if (this.failRemove === args[2]) {
|
|
await this.beforeFailedRemove?.()
|
|
return { exitCode: 1, stdout: "", stderr: "remove failed" }
|
|
}
|
|
this.plugins = this.plugins.filter((entry) => entry.pluginId !== args[2])
|
|
return { exitCode: 0, stdout: "{}", stderr: "" }
|
|
}
|
|
if (joined === "plugin marketplace add EveryInc/compound-engineering-plugin --json") {
|
|
this.marketplaces.push(officialMarketplace)
|
|
return { exitCode: 0, stdout: "{}", stderr: "" }
|
|
}
|
|
if (joined === "plugin marketplace upgrade compound-engineering-plugin --json") {
|
|
return { exitCode: 0, stdout: "{}", stderr: "" }
|
|
}
|
|
if (joined === "plugin add compound-engineering@compound-engineering-plugin --json") {
|
|
if (this.failAdd) return { exitCode: 1, stdout: "", stderr: "install failed" }
|
|
this.plugins = [
|
|
...this.plugins.filter((entry) => entry.pluginId !== "compound-engineering@compound-engineering-plugin"),
|
|
plugin("compound-engineering@compound-engineering-plugin"),
|
|
]
|
|
return { exitCode: 0, stdout: "{}", stderr: "" }
|
|
}
|
|
return { exitCode: 2, stdout: "", stderr: `unexpected args: ${joined}` }
|
|
}
|
|
}
|
|
|
|
describe("Codex local development context", () => {
|
|
test("uses the invoking linked worktree and reports provenance and dirty files", async () => {
|
|
const root = await makeTempRoot("codex-dev-worktree-")
|
|
const primary = path.join(root, "primary repo")
|
|
const linked = path.join(root, "linked worktree with spaces")
|
|
const home = path.join(root, "home")
|
|
const codexHome = path.join(root, "codex profile")
|
|
await fs.mkdir(primary, { recursive: true })
|
|
await fs.mkdir(home, { recursive: true })
|
|
await fs.mkdir(codexHome, { recursive: true })
|
|
await createCeRepo(primary)
|
|
await runGit(primary, ["worktree", "add", "-b", "feat/local", linked])
|
|
await fs.appendFile(path.join(linked, "skills", "ce-alpha", "SKILL.md"), "\nlocal edit\n")
|
|
await fs.mkdir(path.join(linked, "skills", "ce-beta"), { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(linked, "skills", "ce-beta", "SKILL.md"),
|
|
"---\nname: ce-beta\ndescription: Beta test skill.\n---\n",
|
|
)
|
|
|
|
const context = await resolveCodexDevContext(
|
|
path.join(linked, "skills", "ce-alpha"),
|
|
testEnv(home, codexHome),
|
|
new BunCommandRunner(),
|
|
)
|
|
|
|
expect(context.repoRoot).toBe(await fs.realpath(linked))
|
|
expect(context.skillsRoot).toBe(path.join(await fs.realpath(linked), "skills"))
|
|
expect(context.codexHome).toBe(path.resolve(codexHome))
|
|
expect(context.collectionPath).toBe(path.join(path.resolve(codexHome), "skills", "compound-engineering-local"))
|
|
expect(context.linkedWorktree).toBe(true)
|
|
expect(context.branch).toBe("feat/local")
|
|
expect(context.head).toMatch(/^[0-9a-f]{40}$/)
|
|
expect(context.modifiedCount).toBe(1)
|
|
expect(context.untrackedCount).toBe(1)
|
|
expect(context.skillNames).toEqual(["ce-alpha", "ce-beta"])
|
|
})
|
|
|
|
test("rejects a repository that is not Compound Engineering", async () => {
|
|
const root = await makeTempRoot("codex-dev-wrong-repo-")
|
|
const repo = path.join(root, "repo")
|
|
const home = path.join(root, "home")
|
|
const codexHome = path.join(root, "codex-home")
|
|
await fs.mkdir(repo, { recursive: true })
|
|
await fs.mkdir(home, { recursive: true })
|
|
await fs.mkdir(codexHome, { recursive: true })
|
|
await fs.writeFile(path.join(repo, "package.json"), JSON.stringify({ name: "something-else" }))
|
|
await runGit(repo, ["init", "-b", "main"])
|
|
|
|
await expect(
|
|
resolveCodexDevContext(repo, testEnv(home, codexHome), new BunCommandRunner()),
|
|
).rejects.toThrow("not the compound-engineering repository")
|
|
})
|
|
})
|
|
|
|
describe("Codex local skill collection", () => {
|
|
test("creates one collection symlink and unlinking it preserves every source skill", async () => {
|
|
const root = await makeTempRoot("codex-dev-link-")
|
|
const repo = path.join(root, "repo")
|
|
const home = path.join(root, "home")
|
|
const codexHome = path.join(root, "codex-home")
|
|
const unrelatedSkill = path.join(codexHome, "skills", "unrelated", "SKILL.md")
|
|
await fs.mkdir(repo, { recursive: true })
|
|
await fs.mkdir(home, { recursive: true })
|
|
await fs.mkdir(path.dirname(unrelatedSkill), { recursive: true })
|
|
await fs.writeFile(unrelatedSkill, "unrelated\n")
|
|
await createCeRepo(repo)
|
|
const sourceSkill = path.join(repo, "skills", "ce-alpha", "SKILL.md")
|
|
const context = await resolveCodexDevContext(repo, testEnv(home, codexHome), new BunCommandRunner())
|
|
|
|
await activateLocalCollection(context)
|
|
const state = await inspectLocalCollection(context)
|
|
expect(state.kind).toBe("valid")
|
|
expect(await fs.realpath(context.collectionPath)).toBe(await fs.realpath(context.skillsRoot))
|
|
|
|
expect(await removeLocalCollection(context)).toBe(true)
|
|
expect(await fs.readFile(sourceSkill, "utf8")).toContain("name: ce-alpha")
|
|
expect(await fs.readFile(unrelatedSkill, "utf8")).toBe("unrelated\n")
|
|
await expect(fs.lstat(context.collectionPath)).rejects.toMatchObject({ code: "ENOENT" })
|
|
})
|
|
|
|
test("retargets a valid CE collection but refuses regular-path collisions", async () => {
|
|
const root = await makeTempRoot("codex-dev-retarget-")
|
|
const first = path.join(root, "first")
|
|
const second = path.join(root, "second")
|
|
const home = path.join(root, "home")
|
|
const codexHome = path.join(root, "codex-home")
|
|
await fs.mkdir(first, { recursive: true })
|
|
await fs.mkdir(second, { recursive: true })
|
|
await fs.mkdir(home, { recursive: true })
|
|
await fs.mkdir(codexHome, { recursive: true })
|
|
await createCeRepo(first)
|
|
await createCeRepo(second)
|
|
const firstContext = await resolveCodexDevContext(first, testEnv(home, codexHome), new BunCommandRunner())
|
|
const secondContext = await resolveCodexDevContext(second, testEnv(home, codexHome), new BunCommandRunner())
|
|
|
|
await activateLocalCollection(firstContext)
|
|
await activateLocalCollection(secondContext)
|
|
expect(await fs.realpath(secondContext.collectionPath)).toBe(await fs.realpath(secondContext.skillsRoot))
|
|
|
|
await removeLocalCollection(secondContext)
|
|
await fs.writeFile(secondContext.collectionPath, "user-owned\n")
|
|
await expect(activateLocalCollection(firstContext)).rejects.toThrow("not a symlink")
|
|
expect(await fs.readFile(secondContext.collectionPath, "utf8")).toBe("user-owned\n")
|
|
})
|
|
|
|
test("preserves a collision created after an absent collection was inspected", async () => {
|
|
const root = await makeTempRoot("codex-dev-absent-race-")
|
|
const collectionPath = path.join(root, "skills", "compound-engineering-local")
|
|
const target = path.join(root, "checkout", "skills")
|
|
await fs.mkdir(path.dirname(collectionPath), { recursive: true })
|
|
await fs.mkdir(target, { recursive: true })
|
|
|
|
await expect(fs.lstat(collectionPath)).rejects.toMatchObject({ code: "ENOENT" })
|
|
await fs.writeFile(collectionPath, "raced-in user content\n")
|
|
|
|
await expect(
|
|
replaceManagedCollectionLink(collectionPath, target, { kind: "absent" }),
|
|
).rejects.toMatchObject({ code: "EEXIST" })
|
|
expect(await fs.readFile(collectionPath, "utf8")).toBe("raced-in user content\n")
|
|
})
|
|
|
|
test("preserves a valid-link replacement created after the original link was inspected", async () => {
|
|
const root = await makeTempRoot("codex-dev-valid-race-")
|
|
const collectionPath = path.join(root, "skills", "compound-engineering-local")
|
|
const originalTarget = path.join(root, "original", "skills")
|
|
const desiredTarget = path.join(root, "desired", "skills")
|
|
const racedTarget = path.join(root, "raced", "skills")
|
|
await fs.mkdir(path.dirname(collectionPath), { recursive: true })
|
|
await Promise.all([
|
|
fs.mkdir(originalTarget, { recursive: true }),
|
|
fs.mkdir(desiredTarget, { recursive: true }),
|
|
fs.mkdir(racedTarget, { recursive: true }),
|
|
])
|
|
await fs.symlink(originalTarget, collectionPath, "dir")
|
|
const inspectedTarget = await fs.readlink(collectionPath)
|
|
await fs.unlink(collectionPath)
|
|
await fs.symlink(racedTarget, collectionPath, "dir")
|
|
|
|
await expect(
|
|
replaceManagedCollectionLink(collectionPath, desiredTarget, {
|
|
kind: "valid",
|
|
target: inspectedTarget,
|
|
}),
|
|
).rejects.toThrow("changed since it was inspected")
|
|
expect(await fs.readlink(collectionPath)).toBe(racedTarget)
|
|
expect(await fs.realpath(collectionPath)).toBe(await fs.realpath(racedTarget))
|
|
})
|
|
|
|
test("preserves a user-owned file created after a removable link was inspected", async () => {
|
|
const root = await makeTempRoot("codex-dev-remove-race-")
|
|
const collectionPath = path.join(root, "skills", "compound-engineering-local")
|
|
const originalTarget = path.join(root, "checkout", "skills")
|
|
await fs.mkdir(path.dirname(collectionPath), { recursive: true })
|
|
await fs.mkdir(originalTarget, { recursive: true })
|
|
await fs.symlink(originalTarget, collectionPath, "dir")
|
|
const inspectedTarget = await fs.readlink(collectionPath)
|
|
await fs.unlink(collectionPath)
|
|
await fs.writeFile(collectionPath, "raced-in user content\n")
|
|
|
|
await expect(
|
|
removeManagedCollectionLink(collectionPath, inspectedTarget),
|
|
).rejects.toThrow("changed since it was inspected")
|
|
expect((await fs.lstat(collectionPath)).isFile()).toBe(true)
|
|
expect(await fs.readFile(collectionPath, "utf8")).toBe("raced-in user content\n")
|
|
})
|
|
|
|
test("preserves an unrelated symlink created after a removable link was inspected", async () => {
|
|
const root = await makeTempRoot("codex-dev-remove-symlink-race-")
|
|
const collectionPath = path.join(root, "skills", "compound-engineering-local")
|
|
const originalTarget = path.join(root, "checkout", "skills")
|
|
const racedTarget = path.join(root, "user", "skills")
|
|
await fs.mkdir(path.dirname(collectionPath), { recursive: true })
|
|
await Promise.all([
|
|
fs.mkdir(originalTarget, { recursive: true }),
|
|
fs.mkdir(racedTarget, { recursive: true }),
|
|
])
|
|
await fs.symlink(originalTarget, collectionPath, "dir")
|
|
const inspectedTarget = await fs.readlink(collectionPath)
|
|
await fs.unlink(collectionPath)
|
|
await fs.symlink(racedTarget, collectionPath, "dir")
|
|
|
|
await expect(
|
|
removeManagedCollectionLink(collectionPath, inspectedTarget),
|
|
).rejects.toThrow("changed since it was inspected")
|
|
expect(await fs.readlink(collectionPath)).toBe(racedTarget)
|
|
expect(await fs.realpath(collectionPath)).toBe(await fs.realpath(racedTarget))
|
|
})
|
|
|
|
test("preserves a new occupant created after the expected link is atomically taken", async () => {
|
|
const root = await makeTempRoot("codex-dev-remove-after-take-")
|
|
const collectionPath = path.join(root, "skills", "compound-engineering-local")
|
|
const originalTarget = path.join(root, "checkout", "skills")
|
|
await fs.mkdir(path.dirname(collectionPath), { recursive: true })
|
|
await fs.mkdir(originalTarget, { recursive: true })
|
|
await fs.symlink(originalTarget, collectionPath, "dir")
|
|
const inspectedTarget = await fs.readlink(collectionPath)
|
|
let recoveryPath = ""
|
|
|
|
await expect(
|
|
removeManagedCollectionLink(collectionPath, inspectedTarget, {
|
|
onTakenForTest: async (takenPath) => {
|
|
recoveryPath = takenPath
|
|
await fs.writeFile(collectionPath, "new occupant\n")
|
|
},
|
|
}),
|
|
).resolves.toBe(true)
|
|
expect(await fs.readFile(collectionPath, "utf8")).toBe("new occupant\n")
|
|
await expect(fs.lstat(recoveryPath)).rejects.toMatchObject({ code: "ENOENT" })
|
|
})
|
|
|
|
test("retains an unexpected taken entry at a reported recovery path when restoration races", async () => {
|
|
const root = await makeTempRoot("codex-dev-remove-recovery-race-")
|
|
const collectionPath = path.join(root, "skills", "compound-engineering-local")
|
|
const expectedTarget = path.join(root, "checkout", "skills")
|
|
const racedTarget = path.join(root, "user", "skills")
|
|
await fs.mkdir(path.dirname(collectionPath), { recursive: true })
|
|
await Promise.all([
|
|
fs.mkdir(expectedTarget, { recursive: true }),
|
|
fs.mkdir(racedTarget, { recursive: true }),
|
|
])
|
|
await fs.symlink(racedTarget, collectionPath, "dir")
|
|
let recoveryPath = ""
|
|
|
|
const removal = removeManagedCollectionLink(collectionPath, expectedTarget, {
|
|
onTakenForTest: async (takenPath) => {
|
|
recoveryPath = takenPath
|
|
await fs.writeFile(collectionPath, "new occupant\n")
|
|
},
|
|
})
|
|
|
|
let removalError: unknown
|
|
try {
|
|
await removal
|
|
} catch (error) {
|
|
removalError = error
|
|
}
|
|
expect(recoveryPath).not.toBe("")
|
|
expect(removalError).toBeInstanceOf(Error)
|
|
expect((removalError as Error).message).toContain(`preserved at ${recoveryPath}`)
|
|
expect((removalError as Error).message).toContain("the current entry at")
|
|
expect(await fs.readFile(collectionPath, "utf8")).toBe("new occupant\n")
|
|
expect(await fs.readlink(recoveryPath)).toBe(racedTarget)
|
|
})
|
|
|
|
test("reports the recovery path if deterministic post-take test setup fails", async () => {
|
|
const root = await makeTempRoot("codex-dev-remove-test-hook-")
|
|
const collectionPath = path.join(root, "skills", "compound-engineering-local")
|
|
const expectedTarget = path.join(root, "checkout", "skills")
|
|
await fs.mkdir(path.dirname(collectionPath), { recursive: true })
|
|
await fs.mkdir(expectedTarget, { recursive: true })
|
|
await fs.symlink(expectedTarget, collectionPath, "dir")
|
|
let recoveryPath = ""
|
|
|
|
const removal = removeManagedCollectionLink(collectionPath, expectedTarget, {
|
|
onTakenForTest: async (takenPath) => {
|
|
recoveryPath = takenPath
|
|
throw new Error("injected failure")
|
|
},
|
|
})
|
|
|
|
let removalError: unknown
|
|
try {
|
|
await removal
|
|
} catch (error) {
|
|
removalError = error
|
|
}
|
|
expect(removalError).toBeInstanceOf(Error)
|
|
expect((removalError as Error).message).toContain(`preserved at ${recoveryPath}`)
|
|
expect(await fs.readlink(recoveryPath)).toBe(expectedTarget)
|
|
})
|
|
})
|
|
|
|
describe("Codex development installation transitions", () => {
|
|
async function makeContext() {
|
|
const root = await makeTempRoot("codex-dev-transition-")
|
|
const repo = path.join(root, "repo with spaces")
|
|
const home = path.join(root, "home")
|
|
const codexHome = path.join(root, "codex home")
|
|
await fs.mkdir(repo, { recursive: true })
|
|
await fs.mkdir(home, { recursive: true })
|
|
await fs.mkdir(codexHome, { recursive: true })
|
|
await createCeRepo(repo)
|
|
return resolveCodexDevContext(repo, testEnv(home, codexHome), new BunCommandRunner())
|
|
}
|
|
|
|
test("local mode removes every installed CE plugin variant and verifies the live worktree link", async () => {
|
|
const context = await makeContext()
|
|
const runner = new StatefulCodexRunner([
|
|
plugin("compound-engineering@compound-engineering-plugin"),
|
|
plugin("compound-engineering@personal", {
|
|
marketplaceName: "personal",
|
|
source: { source: "local", path: "/some/old/checkout" },
|
|
}),
|
|
])
|
|
|
|
const status = await switchToLocal(context, runner)
|
|
|
|
expect(status.mode).toBe("local")
|
|
expect(status.plugins).toHaveLength(0)
|
|
expect(status.localTarget).toBe(await fs.realpath(context.skillsRoot))
|
|
expect(runner.calls.filter((call) => call[1] === "plugin" && call[2] === "remove")).toEqual([
|
|
["codex", "plugin", "remove", "compound-engineering@compound-engineering-plugin", "--json"],
|
|
["codex", "plugin", "remove", "compound-engineering@personal", "--json"],
|
|
])
|
|
})
|
|
|
|
test("remote mode installs and verifies the official marketplace plugin before unlinking local skills", async () => {
|
|
const context = await makeContext()
|
|
await activateLocalCollection(context)
|
|
const runner = new StatefulCodexRunner([], [officialMarketplace])
|
|
|
|
const status = await switchToRemote(context, runner)
|
|
|
|
expect(status.mode).toBe("remote")
|
|
expect(status.plugins.map((entry) => entry.pluginId)).toEqual([
|
|
"compound-engineering@compound-engineering-plugin",
|
|
])
|
|
expect((await inspectLocalCollection(context)).kind).toBe("absent")
|
|
expect(runner.calls).toContainEqual([
|
|
"codex",
|
|
"plugin",
|
|
"marketplace",
|
|
"upgrade",
|
|
"compound-engineering-plugin",
|
|
"--json",
|
|
])
|
|
})
|
|
|
|
test("status accepts an existing Git-source install during the marketplace migration", async () => {
|
|
const context = await makeContext()
|
|
const runner = new StatefulCodexRunner([
|
|
plugin("compound-engineering@compound-engineering-plugin", {
|
|
source: {
|
|
source: "git",
|
|
url: "https://github.com/EveryInc/compound-engineering-plugin.git",
|
|
},
|
|
}),
|
|
])
|
|
|
|
expect((await inspectCodexDevStatus(context, runner)).mode).toBe("remote")
|
|
})
|
|
|
|
test("keeps local skills active if the remote install fails", async () => {
|
|
const context = await makeContext()
|
|
await activateLocalCollection(context)
|
|
const runner = new StatefulCodexRunner(
|
|
[plugin("compound-engineering@compound-engineering-plugin")],
|
|
[officialMarketplace],
|
|
)
|
|
runner.failAdd = true
|
|
|
|
await expect(switchToRemote(context, runner)).rejects.toThrow("install failed")
|
|
expect((await inspectLocalCollection(context)).kind).toBe("valid")
|
|
expect(runner.plugins).toHaveLength(0)
|
|
})
|
|
|
|
test("restores the prior link state if switching to local cannot remove a plugin", async () => {
|
|
const context = await makeContext()
|
|
const runner = new StatefulCodexRunner([plugin("compound-engineering@personal")])
|
|
runner.failRemove = "compound-engineering@personal"
|
|
|
|
await expect(switchToLocal(context, runner)).rejects.toThrow("remove failed")
|
|
expect((await inspectLocalCollection(context)).kind).toBe("absent")
|
|
})
|
|
|
|
test("removes its newly created link during rollback even if the target becomes broken", async () => {
|
|
const context = await makeContext()
|
|
const runner = new StatefulCodexRunner([plugin("compound-engineering@personal")])
|
|
const movedSkills = `${context.skillsRoot}.moved`
|
|
runner.failRemove = "compound-engineering@personal"
|
|
runner.beforeFailedRemove = async () => {
|
|
await fs.rename(context.skillsRoot, movedSkills)
|
|
}
|
|
|
|
await expect(switchToLocal(context, runner)).rejects.toThrow("remove failed")
|
|
await expect(fs.lstat(context.collectionPath)).rejects.toMatchObject({ code: "ENOENT" })
|
|
expect((await fs.stat(movedSkills)).isDirectory()).toBe(true)
|
|
})
|
|
|
|
test("leaves a replacement symlink in place during rollback", async () => {
|
|
const context = await makeContext()
|
|
const replacement = path.join(path.dirname(context.repoRoot), "replacement skills")
|
|
const runner = new StatefulCodexRunner([plugin("compound-engineering@personal")])
|
|
runner.failRemove = "compound-engineering@personal"
|
|
runner.beforeFailedRemove = async () => {
|
|
await fs.mkdir(replacement, { recursive: true })
|
|
await fs.unlink(context.collectionPath)
|
|
await fs.symlink(replacement, context.collectionPath, "dir")
|
|
}
|
|
|
|
await expect(switchToLocal(context, runner)).rejects.toThrow("remove failed")
|
|
expect(await fs.readlink(context.collectionPath)).toBe(replacement)
|
|
expect(await fs.realpath(context.collectionPath)).toBe(await fs.realpath(replacement))
|
|
})
|
|
|
|
test("reports mixed state and remove clears only CE-managed surfaces", async () => {
|
|
const context = await makeContext()
|
|
await activateLocalCollection(context)
|
|
const runner = new StatefulCodexRunner([plugin("compound-engineering@personal")])
|
|
|
|
expect((await inspectCodexDevStatus(context, runner)).mode).toBe("mixed")
|
|
expect((await removeCodexDevInstallation(context, runner)).mode).toBe("absent")
|
|
})
|
|
|
|
test("status prints mode and worktree provenance without changing Codex state", async () => {
|
|
const context = await makeContext()
|
|
await activateLocalCollection(context)
|
|
const runner = new StatefulCodexRunner()
|
|
const output: string[] = []
|
|
|
|
const exitCode = await runCodexDevCommand(
|
|
"status",
|
|
context.repoRoot,
|
|
context.env,
|
|
runner,
|
|
(line) => output.push(line),
|
|
)
|
|
|
|
expect(exitCode).toBe(0)
|
|
expect(output.join("\n")).toContain("Mode: local")
|
|
expect(output.join("\n")).toContain(`Source checkout: ${context.repoRoot}`)
|
|
expect(output.join("\n")).toContain(`Commit: ${context.head}`)
|
|
expect(runner.calls).toEqual([["codex", "plugin", "list", "--available", "--json"]])
|
|
})
|
|
|
|
test("refresh is local reconciliation and reminds the developer to start a new session", async () => {
|
|
const context = await makeContext()
|
|
const runner = new StatefulCodexRunner([plugin("compound-engineering@personal")])
|
|
const output: string[] = []
|
|
|
|
expect(
|
|
await runCodexDevCommand("refresh", context.repoRoot, context.env, runner, (line) => output.push(line)),
|
|
).toBe(0)
|
|
expect(output.join("\n")).toContain("Mode: local")
|
|
expect(output.join("\n")).toContain("Start a new Codex session")
|
|
})
|
|
})
|
|
|
|
describe("Codex developer workflow contracts", () => {
|
|
test("package.json exposes the repository command", async () => {
|
|
const packageJson = JSON.parse(await fs.readFile(path.join(import.meta.dir, "..", "package.json"), "utf8"))
|
|
expect(packageJson.scripts["codex:dev"]).toBe("bun run scripts/codex-dev.ts")
|
|
})
|
|
|
|
test("developer docs and agent instructions expose every supported mode", async () => {
|
|
const root = path.join(import.meta.dir, "..")
|
|
const [devDocs, agents] = await Promise.all([
|
|
fs.readFile(path.join(root, "docs", "development.md"), "utf8"),
|
|
fs.readFile(path.join(root, "AGENTS.md"), "utf8"),
|
|
])
|
|
for (const command of ["local", "refresh", "status", "remote", "remove"]) {
|
|
expect(devDocs).toContain(`bun run codex:dev -- ${command}`)
|
|
}
|
|
expect(agents).toContain("bun run codex:dev -- local")
|
|
expect(agents).toContain("$CODEX_HOME/skills/compound-engineering-local")
|
|
expect(devDocs).toContain("cached copy of this checkout")
|
|
expect(devDocs).not.toContain("points Compound Engineering back to the public Git repository")
|
|
})
|
|
|
|
test("refuses an unrelated symlink at the managed collection path", async () => {
|
|
const root = await makeTempRoot("codex-dev-unrelated-")
|
|
const repo = path.join(root, "repo")
|
|
const unrelated = path.join(root, "someone-elses-skills")
|
|
const home = path.join(root, "home")
|
|
const codexHome = path.join(root, "codex-home")
|
|
await fs.mkdir(repo, { recursive: true })
|
|
await fs.mkdir(unrelated, { recursive: true })
|
|
await fs.mkdir(home, { recursive: true })
|
|
await fs.mkdir(path.join(codexHome, "skills"), { recursive: true })
|
|
await createCeRepo(repo)
|
|
const context = await resolveCodexDevContext(repo, testEnv(home, codexHome), new BunCommandRunner())
|
|
await fs.symlink(unrelated, context.collectionPath, "dir")
|
|
|
|
await expect(activateLocalCollection(context)).rejects.toThrow("outside a Compound Engineering checkout")
|
|
expect(await fs.realpath(context.collectionPath)).toBe(await fs.realpath(unrelated))
|
|
})
|
|
|
|
test("refuses skill-only mode when the Codex manifest gains another runtime component", async () => {
|
|
const root = await makeTempRoot("codex-dev-runtime-component-")
|
|
const repo = path.join(root, "repo")
|
|
const home = path.join(root, "home")
|
|
const codexHome = path.join(root, "codex-home")
|
|
await fs.mkdir(repo, { recursive: true })
|
|
await fs.mkdir(home, { recursive: true })
|
|
await fs.mkdir(codexHome, { recursive: true })
|
|
await createCeRepo(repo)
|
|
const manifestPath = path.join(repo, ".codex-plugin", "plugin.json")
|
|
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"))
|
|
manifest.mcpServers = { example: { command: "example" } }
|
|
await fs.writeFile(manifestPath, JSON.stringify(manifest))
|
|
|
|
await expect(
|
|
resolveCodexDevContext(repo, testEnv(home, codexHome), new BunCommandRunner()),
|
|
).rejects.toThrow("cannot represent Codex runtime components")
|
|
})
|
|
|
|
test("refuses skill-only mode when default plugin hooks exist outside the manifest", async () => {
|
|
const root = await makeTempRoot("codex-dev-default-hooks-")
|
|
const repo = path.join(root, "repo")
|
|
const home = path.join(root, "home")
|
|
const codexHome = path.join(root, "codex-home")
|
|
await fs.mkdir(repo, { recursive: true })
|
|
await fs.mkdir(home, { recursive: true })
|
|
await fs.mkdir(codexHome, { recursive: true })
|
|
await createCeRepo(repo)
|
|
await fs.mkdir(path.join(repo, "hooks"), { recursive: true })
|
|
await fs.writeFile(path.join(repo, "hooks", "hooks.json"), JSON.stringify({ hooks: {} }))
|
|
|
|
await expect(
|
|
resolveCodexDevContext(repo, testEnv(home, codexHome), new BunCommandRunner()),
|
|
).rejects.toThrow("cannot represent default Codex hooks")
|
|
})
|
|
})
|