+39
-200
@@ -1,15 +1,5 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -24,7 +14,11 @@ const unrelated = {
|
||||
permissions: { shell: "ask" },
|
||||
mcp: {
|
||||
servers: {
|
||||
codegraph: { type: "local", command: ["user-codegraph"], codemode: true },
|
||||
codegraph: {
|
||||
type: "local",
|
||||
command: ["user-codegraph"],
|
||||
codemode: true,
|
||||
},
|
||||
browser: { type: "local", command: ["user-browser"] },
|
||||
},
|
||||
},
|
||||
@@ -83,13 +77,20 @@ async function fixture() {
|
||||
join(source, "dist", "tui.js"),
|
||||
'export default { id: "stanp-opencode.quota-tui", setup() {} };',
|
||||
);
|
||||
const built = await Bun.build({ entrypoints: [helper.pathname], target: "bun", format: "esm" });
|
||||
const built = await Bun.build({
|
||||
entrypoints: [helper.pathname],
|
||||
target: "bun",
|
||||
format: "esm",
|
||||
});
|
||||
expect(built.success).toBe(true);
|
||||
await Bun.write(join(source, "dist", "install.js"), built.outputs[0]!);
|
||||
await writeFile(join(source, "package.json"), '{"type":"module"}');
|
||||
await writeFile(
|
||||
join(config, "opencode.json"),
|
||||
JSON.stringify({ ...unrelated, plugins: [{ package: "before", options: { keep: true } }] }),
|
||||
JSON.stringify({
|
||||
...unrelated,
|
||||
plugins: [{ package: "before", options: { keep: true } }],
|
||||
}),
|
||||
);
|
||||
await writeFile(join(config, "opencode.jsonc"), "// untouched user config\n{}\n");
|
||||
return { directory, source, config };
|
||||
@@ -98,20 +99,13 @@ async function contents(config: string) {
|
||||
return JSON.parse(await readFile(join(config, "opencode.json"), "utf8"));
|
||||
}
|
||||
|
||||
for (const selection of [
|
||||
"agents",
|
||||
"quota",
|
||||
"agents,quota",
|
||||
"agents,multi-work",
|
||||
"agents,multi-work,quota",
|
||||
"optchat",
|
||||
"agents,multi-work,quota,optchat",
|
||||
]) {
|
||||
for (const selection of ["quota", "agents,multi-work,quota,optchat"]) {
|
||||
test(`installs valid subset ${selection}, preserving unrelated config and MCP`, async () => {
|
||||
const f = await fixture();
|
||||
await install(f.source, f.config, selection);
|
||||
const result = await contents(f.config);
|
||||
expect(result).toMatchObject(unrelated);
|
||||
expect(result.experimental === undefined).toBe(selection === "quota");
|
||||
expect(result.plugins.map((entry: { package: string }) => entry.package)).toEqual([
|
||||
"before",
|
||||
...selection.split(",").map((name) => `./plugins/stanp-opencode/${name}`),
|
||||
@@ -148,96 +142,7 @@ test("upgrades retain selection and options; explicit selection replaces only ow
|
||||
]);
|
||||
});
|
||||
|
||||
test("mixed string/object registrations upgrade without flags and preserve unrelated entries verbatim", async () => {
|
||||
const f = await fixture();
|
||||
const before = "unrelated-plugin";
|
||||
const after = { package: "unrelated-object", options: { keep: [1, true, "value"] } };
|
||||
await writeFile(
|
||||
join(f.config, "opencode.json"),
|
||||
JSON.stringify({
|
||||
...unrelated,
|
||||
plugins: [before, { package: "./plugins/stanp-opencode/quota" }, after],
|
||||
}),
|
||||
);
|
||||
await install(f.source, f.config);
|
||||
expect((await contents(f.config)).plugins).toEqual([
|
||||
before,
|
||||
{ package: "./plugins/stanp-opencode/quota" },
|
||||
after,
|
||||
]);
|
||||
await install(f.source, f.config, "agents,quota");
|
||||
const updated = await contents(f.config);
|
||||
expect(updated.plugins).toEqual([
|
||||
before,
|
||||
{
|
||||
package: "./plugins/stanp-opencode/agents",
|
||||
options: empty,
|
||||
},
|
||||
{ package: "./plugins/stanp-opencode/quota" },
|
||||
after,
|
||||
]);
|
||||
expect(updated).toMatchObject(unrelated);
|
||||
});
|
||||
|
||||
test("owned string registrations retain their selection and explicit selection removes mixed duplicates", async () => {
|
||||
const f = await fixture();
|
||||
await writeFile(
|
||||
join(f.config, "opencode.json"),
|
||||
JSON.stringify({
|
||||
plugins: [
|
||||
"before",
|
||||
"./plugins/stanp-opencode/agents",
|
||||
"./plugins/stanp-opencode/multi-work",
|
||||
"./plugins/stanp-opencode/quota",
|
||||
{ package: "stanp-opencode/quota" },
|
||||
"after",
|
||||
],
|
||||
}),
|
||||
);
|
||||
await install(f.source, f.config);
|
||||
const retained = await contents(f.config);
|
||||
expect(retained.plugins).toEqual([
|
||||
"before",
|
||||
{
|
||||
package: "./plugins/stanp-opencode/agents",
|
||||
options: empty,
|
||||
},
|
||||
{ package: "./plugins/stanp-opencode/multi-work" },
|
||||
{ package: "./plugins/stanp-opencode/quota" },
|
||||
"after",
|
||||
]);
|
||||
await install(f.source, f.config, "quota");
|
||||
expect((await contents(f.config)).plugins).toEqual([
|
||||
"before",
|
||||
{ package: "./plugins/stanp-opencode/quota" },
|
||||
"after",
|
||||
]);
|
||||
});
|
||||
|
||||
for (const target of ["./plugins/stanp-opencode", "stanp-opencode@1.0.0", "absolute"]) {
|
||||
test(`owned monolith string ${target} upgrades without a selection`, async () => {
|
||||
const f = await fixture();
|
||||
const packageName =
|
||||
target === "absolute" ? join(f.config, "plugins", "stanp-opencode") : target;
|
||||
await writeFile(
|
||||
join(f.config, "opencode.json"),
|
||||
JSON.stringify({ plugins: ["before", packageName, "after"] }),
|
||||
);
|
||||
await install(f.source, f.config);
|
||||
expect((await contents(f.config)).plugins).toEqual([
|
||||
"before",
|
||||
{
|
||||
package: "./plugins/stanp-opencode/agents",
|
||||
options: empty,
|
||||
},
|
||||
{ package: "./plugins/stanp-opencode/multi-work" },
|
||||
{ package: "./plugins/stanp-opencode/quota" },
|
||||
"after",
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
test("installer writes empty option skeletons, fills only missing keys, and is idempotent", async () => {
|
||||
test("installer writes empty option skeletons and subagent_depth, fills only missing keys, and is idempotent", async () => {
|
||||
const f = await fixture();
|
||||
await install(f.source, f.config, "agents,multi-work,quota,optchat");
|
||||
const fresh = await contents(f.config);
|
||||
@@ -250,7 +155,11 @@ test("installer writes empty option skeletons, fills only missing keys, and is i
|
||||
options: { compactionModel: "", dataDir: "" },
|
||||
},
|
||||
]);
|
||||
fresh.plugins[1].options = { agents: { "fast-lo": "openai/gpt-6-luna#low" }, extra: 1 };
|
||||
expect(fresh.experimental).toEqual({ subagent_depth: 2 });
|
||||
fresh.plugins[1].options = {
|
||||
agents: { "fast-lo": "openai/gpt-6-luna#low" },
|
||||
extra: 1,
|
||||
};
|
||||
fresh.plugins[4].options = { compactionModel: "acme/cheap" };
|
||||
await writeFile(join(f.config, "opencode.json"), JSON.stringify(fresh));
|
||||
await install(f.source, f.config);
|
||||
@@ -259,40 +168,35 @@ test("installer writes empty option skeletons, fills only missing keys, and is i
|
||||
agents: { ...empty.agents, "fast-lo": "openai/gpt-6-luna#low" },
|
||||
extra: 1,
|
||||
});
|
||||
expect(filled.plugins[4].options).toEqual({ compactionModel: "acme/cheap", dataDir: "" });
|
||||
expect(filled.plugins[4].options).toEqual({
|
||||
compactionModel: "acme/cheap",
|
||||
dataDir: "",
|
||||
});
|
||||
expect(filled).toMatchObject(unrelated);
|
||||
await install(f.source, f.config);
|
||||
expect(await contents(f.config)).toEqual(filled);
|
||||
});
|
||||
|
||||
test("agents installs set experimental.subagent_depth to at least 2", async () => {
|
||||
const f = await fixture();
|
||||
await install(f.source, f.config, "quota");
|
||||
expect((await contents(f.config)).experimental).toBeUndefined();
|
||||
await install(f.source, f.config, "agents");
|
||||
const fresh = await contents(f.config);
|
||||
expect(fresh.experimental).toEqual({ subagent_depth: 2 });
|
||||
expect(fresh).toMatchObject(unrelated);
|
||||
for (const [depth, expected] of [
|
||||
[1, 2],
|
||||
[3, 3],
|
||||
]) {
|
||||
await writeFile(
|
||||
join(f.config, "opencode.json"),
|
||||
JSON.stringify({ ...fresh, experimental: { subagent_depth: depth, other: true } }),
|
||||
JSON.stringify({
|
||||
...filled,
|
||||
experimental: { subagent_depth: depth, other: true },
|
||||
}),
|
||||
);
|
||||
await install(f.source, f.config);
|
||||
const result = await contents(f.config);
|
||||
expect(result.experimental).toEqual({ subagent_depth: expected, other: true });
|
||||
await install(f.source, f.config);
|
||||
expect(await contents(f.config)).toEqual(result);
|
||||
expect((await contents(f.config)).experimental).toEqual({
|
||||
subagent_depth: expected,
|
||||
other: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("local installer retains executable permissions and supports direct invocation", async () => {
|
||||
test("local installer supports direct invocation", async () => {
|
||||
const f = await fixture();
|
||||
const path = new URL("../install-local.sh", import.meta.url).pathname;
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o755);
|
||||
const result = await run([path, "--plugins", "multi-work"], f.directory);
|
||||
expect(result.exitCode).toBe(2);
|
||||
expect(result.stderr).toContain("Multi-work requires Agents");
|
||||
@@ -348,43 +252,6 @@ test("noninteractive fresh installs require a selection; invalid selections leav
|
||||
expect(await readFile(join(f.config, "opencode.json"), "utf8")).toBe(original);
|
||||
});
|
||||
|
||||
test("interactive Multi-work-only selection offers Agents", async () => {
|
||||
const f = await fixture();
|
||||
const command = `${process.execPath} '${helper.pathname}' '${f.source}' '${f.config}'`;
|
||||
const child = Bun.spawn(["script", "-qec", command, "/dev/null"], {
|
||||
cwd: f.directory,
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
timeout: 10_000,
|
||||
});
|
||||
let output = "";
|
||||
let selected = false;
|
||||
let confirmed = false;
|
||||
const reader = child.stdout.getReader();
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
output += new TextDecoder().decode(chunk.value);
|
||||
if (!selected && output.includes("Plugins (")) {
|
||||
child.stdin.write("multi-work\n");
|
||||
selected = true;
|
||||
}
|
||||
if (!confirmed && output.includes("Add Agents?")) {
|
||||
child.stdin.write("y\n");
|
||||
confirmed = true;
|
||||
}
|
||||
}
|
||||
expect(await child.exited, output).toBe(0);
|
||||
child.stdin.end();
|
||||
expect(output).toContain("Add Agents?");
|
||||
expect(
|
||||
(await contents(f.config)).plugins
|
||||
.slice(1)
|
||||
.map((entry: { package: string }) => entry.package),
|
||||
).toEqual(["./plugins/stanp-opencode/agents", "./plugins/stanp-opencode/multi-work"]);
|
||||
});
|
||||
|
||||
test("artifact validation failure leaves prior installation and config intact", async () => {
|
||||
const f = await fixture();
|
||||
await install(f.source, f.config, "quota");
|
||||
@@ -406,36 +273,6 @@ test("artifact validation failure leaves prior installation and config intact",
|
||||
expect(await readdir(join(f.config, "plugins"))).toEqual(["stanp-opencode"]);
|
||||
});
|
||||
|
||||
test("replaces an owned installation symlink without touching its target or unrelated plugin directories", async () => {
|
||||
const f = await fixture();
|
||||
const external = join(f.directory, "old installation");
|
||||
const sibling = join(f.config, "plugins", "unrelated");
|
||||
await mkdir(external);
|
||||
await mkdir(sibling, { recursive: true });
|
||||
await writeFile(join(external, "keep.txt"), "old work is untouched");
|
||||
await writeFile(join(sibling, "index.js"), "unrelated plugin");
|
||||
await symlink(external, join(f.config, "plugins", "stanp-opencode"));
|
||||
await install(f.source, f.config, "quota");
|
||||
expect(await readFile(join(external, "keep.txt"), "utf8")).toBe("old work is untouched");
|
||||
expect(await readFile(join(sibling, "index.js"), "utf8")).toBe("unrelated plugin");
|
||||
expect(
|
||||
await Bun.file(join(f.config, "plugins", "stanp-opencode", "quota", "index.js")).exists(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("both shell installers reject explicit Multi-work-only selection before doing work", async () => {
|
||||
const f = await fixture();
|
||||
for (const file of ["install.sh", "install-local.sh"]) {
|
||||
const result = await run(
|
||||
["bash", new URL(`../${file}`, import.meta.url).pathname, "--plugins", "multi-work"],
|
||||
f.directory,
|
||||
);
|
||||
expect(result.exitCode).toBe(2);
|
||||
expect(result.stderr).toContain("Multi-work requires Agents");
|
||||
}
|
||||
expect((await contents(f.config)).plugins).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("local shell installer builds its checkout and upgrades mixed entries without flags", async () => {
|
||||
const f = await fixture();
|
||||
const after = { package: "unrelated-object", options: { keep: true } };
|
||||
@@ -481,7 +318,9 @@ for (const validChecksum of [true, false])
|
||||
if (validChecksum)
|
||||
await writeFile(
|
||||
join(f.config, "opencode.json"),
|
||||
JSON.stringify({ plugins: ["unrelated-plugin", "./plugins/stanp-opencode/quota"] }),
|
||||
JSON.stringify({
|
||||
plugins: ["unrelated-plugin", "./plugins/stanp-opencode/quota"],
|
||||
}),
|
||||
);
|
||||
const archiveRoot = join(f.directory, "archive");
|
||||
await mkdir(archiveRoot);
|
||||
|
||||
Reference in New Issue
Block a user