Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
b58a138
feat(plugin): add `codework plugin add|remove` and declared plugin or…
sanchitrk Sep 17, 2026
62401fd
Merge branch 'dev' into feat-plugin-install
sanchitrk Sep 18, 2026
4585c70
feat(harness): discover settings from the session's host directory, n…
sanchitrk Sep 19, 2026
8cd0de7
feat(harness): install plugins through npm's own libraries instead of…
sanchitrk Sep 19, 2026
2072f45
feat(harness): give plugins a content-addressed store with immutable …
sanchitrk Sep 19, 2026
626f732
feat(harness): split plugin loading from plugin configuration
sanchitrk Sep 19, 2026
7cbb91d
feat(harness): let a session follow the store at each exchange boundary
sanchitrk Sep 19, 2026
d0dbf59
feat(cli): add plugin install, list, check and update
sanchitrk Sep 19, 2026
21bf41f
feat(harness): re-import plugins on demand with plugin reload
sanchitrk Sep 19, 2026
630ce95
feat(harness): keep the file that declared each plugin entry
sanchitrk Sep 19, 2026
e3b71ed
fix(harness): never install at boot, and name the file behind a plugi…
sanchitrk Sep 19, 2026
75c7985
feat(cli): link a session to the host directory its settings come from
sanchitrk Sep 19, 2026
a996367
feat(cli): add plugins to the project a session is linked to
sanchitrk Sep 19, 2026
377885c
fix(harness): read the .npmrc chain where the person is, not where th…
sanchitrk Sep 20, 2026
855bb43
test(harness): install against a registry and a git remote on this ma…
sanchitrk Sep 20, 2026
6a3d7c2
fix(cli): anchor a plugin reference to the session's project, not the…
sanchitrk Sep 20, 2026
a0384e0
fix(harness): refuse a relative directory where a plugin reference or…
sanchitrk Sep 20, 2026
e9e7172
fix(harness): refuse a relative host directory instead of resolving i…
sanchitrk Sep 20, 2026
f678a99
fix: make plugin manager updates atomic
sanchitrk Sep 20, 2026
33f135a
fix: address plugin manager review feedback
sanchitrk Sep 20, 2026
2524618
fix(harness): address plugin manager review issues
sanchitrk Sep 20, 2026
8c64e31
fix(harness): resolve plugin store entries under the registry their d…
sanchitrk Sep 20, 2026
e886eaa
fix(harness): drop retained plugin origins that no longer resolve
sanchitrk Sep 20, 2026
c949919
feat(harness): publish plugin.updated lifecycle events
sanchitrk Sep 20, 2026
e345ead
feat(harness): use session ID when available for plugin event updated
sanchitrk Sep 20, 2026
b8e8b07
tweaks
sanchitrk Sep 22, 2026
f530665
fix(harness): scope settings discovery to sessions; boot reads the us…
sanchitrk Sep 22, 2026
f1583cc
test: improve test coverage
sanchitrk Sep 22, 2026
52124ca
fix(harness): simplify absolute path check - regex checks are complic…
sanchitrk Sep 22, 2026
e3c8f2a
tweaks
sanchitrk Sep 22, 2026
9160d66
feat(plugin): add @codeworksh/plugin, the third-party plugin SDK
sanchitrk Sep 22, 2026
b74afbd
fix(harness): bind the sandbox barrel by name so the built drivers work
sanchitrk Sep 22, 2026
2a2917e
fix(harness): share effect and the plugin SDK with an installed plugin
sanchitrk Sep 22, 2026
294db88
fix(harness): call pacote through its default export so check and upd…
sanchitrk Sep 22, 2026
746500d
feat(harness): report a plugin that ships typescript as plugin-not-co…
sanchitrk Sep 22, 2026
7f970bf
feat(cli): add plugin check --refresh
sanchitrk Sep 22, 2026
d58036a
fix(scripts): drop the development condition from published manifests
sanchitrk Sep 22, 2026
ed63187
test(harness): cover plugins installed from git, end to end
sanchitrk Sep 22, 2026
69ca318
fix(cli): describe hostDir as an anchor, not as where settings come from
sanchitrk Sep 22, 2026
548ddea
fix(cli): name .codework in the link help so the directory is recogni…
sanchitrk Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
test: improve test coverage
  • Loading branch information
sanchitrk committed Sep 22, 2026
commit f1583cc230deafb7e9dc74c8f222cdf001003d1e
95 changes: 91 additions & 4 deletions packages/codework/test/plugin.cmd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { tmpdir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vite-plus/test";
import { NAME, npmrc, withRegistry } from "../../harness/test/fixtures/registry.ts";

const cli = fileURLToPath(new URL("../src/index.ts", import.meta.url));
const plugin = (name: string) => fileURLToPath(new URL(`../../../extras/${name}`, import.meta.url));
Expand All @@ -42,11 +43,23 @@ const withProject = async (body: (project: { root: string; home: string; run: Ru
};
type Run = (...args: ReadonlyArray<string>) => SpawnSyncReturns<string>;

const runAsyncResult = (cwd: string, ...args: ReadonlyArray<string>) =>
new Promise<{ readonly status: number | null; readonly stdout: string; readonly stderr: string }>(
(resolve, reject) => {
const child = spawn(process.execPath, ["--conditions=development", cli, ...args], { cwd });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => (stdout += chunk));
child.stderr.on("data", (chunk: string) => (stderr += chunk));
child.on("error", reject);
child.on("close", (status) => resolve({ status, stdout, stderr }));
},
);

const runAsync = (cwd: string, ...args: ReadonlyArray<string>) =>
new Promise<number | null>((resolve) => {
const child = spawn(process.execPath, ["--conditions=development", cli, ...args], { cwd, stdio: "ignore" });
child.on("exit", resolve);
});
runAsyncResult(cwd, ...args).then(({ status }) => status);

/** A fixed generation number, so a published fixture is byte-identical between runs. */
const GENERATION = 1789564800000;
Expand Down Expand Up @@ -384,6 +397,24 @@ describe("codework plugin add/remove", () => {
]);
expect(JSON.parse(readFileSync(settings(root), "utf8")).plugins).toEqual(["./plugins/project.ts"]);
}));

it("lets --user-config-dir outrank both the project and --global targets", () =>
withProject(({ root, home, run }) => {
writeFileSync(settings(root), JSON.stringify({ plugins: ["./project.ts"] }));
mkdirSync(home, { recursive: true });
writeFileSync(join(home, "settings.jsonc"), JSON.stringify({ plugins: ["./global.ts"] }));

const added = run("plugin", "add", plugin("codework-tool-proc"), "-g", "--user-config-dir", "override");
expect(added.status).toBe(0);
const override = join(root, "override", "settings.jsonc");
expect(JSON.parse(readFileSync(override, "utf8")).plugins).toEqual([plugin("codework-tool-proc")]);
expect(JSON.parse(readFileSync(settings(root), "utf8")).plugins).toEqual(["./project.ts"]);
expect(JSON.parse(readFileSync(join(home, "settings.jsonc"), "utf8")).plugins).toEqual(["./global.ts"]);

const removed = run("plugin", "remove", plugin("codework-tool-proc"), "-g", "--user-config-dir", "override");
expect(removed.status).toBe(0);
expect(JSON.parse(readFileSync(override, "utf8")).plugins).toEqual([]);
}));
});

describe("codework plugin install/list/check", () => {
Expand Down Expand Up @@ -496,12 +527,68 @@ describe("codework plugin install/list/check", () => {
expect(installed.stdout).toContain("1 local");
}));

it(
"installs and reports mutable plugin states through the CLI",
() =>
withProject(async ({ root, home }) => {
const spec = `${NAME}@^1.0.0`;
await withRegistry(
join(root, "registry"),
async (registry) => {
await npmrc(root, registry);
writeFileSync(settings(root), JSON.stringify({ plugins: [spec] }));

const missing = await runAsyncResult(root, "plugin", "check", "--home", home);
expect(missing.status).toBe(0);
expect(missing.stdout).toContain(`${spec} plugin-not-installed`);

const installed = await runAsyncResult(root, "plugin", "install", "--home", home);
expect(installed.status, installed.stderr).toBe(0);
expect(installed.stdout).toContain(`Installed ${spec} (fixture.tool.cli)`);
expect(installed.stdout).toContain("1 installed");

const present = await runAsyncResult(root, "plugin", "install", "--home", home);
expect(present.status).toBe(0);
expect(present.stdout).toContain("0 installed, 1 already present");
},
{ pluginId: "fixture.tool.cli" },
);

// Persist the result a successful remote check would have written. `check` must render
// that cross-process cache without opening the now-closed fixture registry.
const indexPath = join(home, "cache", "plugins", "v2", "index.json");
const index = JSON.parse(readFileSync(indexPath, "utf8")) as {
entries: Record<string, Record<string, unknown>>;
};
const record = Object.values(index.entries)[0];
expect(record).toBeDefined();
Object.assign(record ?? {}, { checkedAt: statSync(indexPath).mtimeMs, outdated: true, available: "1.1.0" });
writeFileSync(indexPath, JSON.stringify(index));

const checked = await runAsyncResult(root, "plugin", "check", "--home", home);
expect(checked.status, JSON.stringify(checked)).toBe(0);
expect(checked.stdout).toContain(`${spec} 1.0.0 -> 1.1.0`);
expect(checked.stdout).toContain("1 update available");

// `update` always refreshes the remote answer. The fixture is deliberately closed, so
// this covers per-entry failure reporting and the command's non-zero exit contract.
const updated = await runAsyncResult(root, "plugin", "update", "--home", home);
expect(updated.status).toBe(1);
expect(updated.stdout).toContain(`error[plugin-resolve-failed]`);
expect(updated.stdout).toContain("Nothing to update.");
}),
120_000,
);

it("skips local entries when checking for updates", () =>
withProject(({ root, run }) => {
// No revision to compare, so `check` reports nothing rather than calling it current.
writeFileSync(settings(root), JSON.stringify({ plugins: [plugin("codework-tool-proc")] }));
const checked = run("plugin", "check");
expect(checked.status).toBe(0);
expect(checked.stdout).toContain("Everything is up to date.");
const updated = run("plugin", "update");
expect(updated.status).toBe(0);
expect(updated.stdout).toContain("Nothing to update.");
}));
});
71 changes: 71 additions & 0 deletions packages/codework/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ process.env.CODEWORK_MODELS_FILE ??= fileURLToPath(new URL("../../../models.gen.
const exec = promisify(execFile);
const cli = fileURLToPath(new URL("../src/index.ts", import.meta.url));

const execCli = async (args: ReadonlyArray<string>, cwd?: string) => {
try {
const result = await exec(process.execPath, ["--conditions=development", cli, ...args], {
...(cwd === undefined ? {} : { cwd }),
timeout: 10_000,
});
return { status: 0, stdout: result.stdout, stderr: result.stderr };
} catch (error) {
const failure = error as { readonly code?: number; readonly stdout?: string; readonly stderr?: string };
return {
status: failure.code ?? 1,
stdout: failure.stdout ?? "",
stderr: failure.stderr ?? "",
};
}
};

const homes: string[] = [];
const layer = (options: Harness.Options = {}) => {
const home = mkdtempSync(join(tmpdir(), "codework-server-"));
Expand Down Expand Up @@ -415,6 +432,60 @@ const connectedClient = Effect.gen(function* () {
});

describe("WebSocket client", () => {
it("runs session link and unlink through the CLI", () => {
const project = realpathSync(mkdtempSync(join(tmpdir(), "codework-cli-link-")));
homes.push(project);
return Effect.gen(function* () {
const server = yield* HttpServer.HttpServer;
if (server.address._tag === "UnixPathAddress") return yield* Effect.die("Expected TCP listener");
const url = `ws://127.0.0.1:${server.address.port}/rpc`;
const rpc = yield* connectedClient;
const session = yield* rpc["session.create"]({});

const linked = yield* Effect.promise(() =>
execCli(["session", "link", session.id, project, "--server", url], project),
);
expect(linked.status).toBe(0);
expect(linked.stdout).toContain(`Linked ${session.id} to ${project}`);
expect((yield* rpc["session.info"]({ sessionId: session.id })).hostDir).toBe(project);

const unlinked = yield* Effect.promise(() =>
execCli(["session", "link", session.id, "--unlink", "--server", url], project),
);
expect(unlinked.status).toBe(0);
expect(unlinked.stdout).toContain(`Unlinked ${session.id}`);
expect((yield* rpc["session.info"]({ sessionId: session.id })).hostDir).toBeUndefined();
}).pipe(Effect.scoped, Effect.provide(websocket()), Effect.timeout("20 seconds"), Effect.runPromise);
});

it("reports plugin reload success and failure through the CLI", () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "codework-cli-reload-")));
homes.push(root);
const module = join(root, "plugin.mjs");
writeFileSync(module, 'export default { id: "acme.tool.reload", kind: "tool", setup() {} };\n');

return Effect.gen(function* () {
const server = yield* HttpServer.HttpServer;
if (server.address._tag === "UnixPathAddress") return yield* Effect.die("Expected TCP listener");
const url = `ws://127.0.0.1:${server.address.port}/rpc`;

const succeeded = yield* Effect.promise(() => execCli(["plugin", "reload", "--server", url], root));
expect(succeeded.status).toBe(0);
expect(succeeded.stdout).toMatch(/^Reloaded \d+ plugins?\.\n$/);

yield* Effect.sync(() => writeFileSync(module, "export default { nope: true };\n"));
const failed = yield* Effect.promise(() => execCli(["plugin", "reload", "--server", url], root));
expect(failed.status).toBe(1);
expect(failed.stdout).toContain("Reload failed, keeping");
expect(failed.stdout).toContain("Missing key");
}).pipe(
Effect.scoped,
Effect.provide(websocket({ plugins: [module] })),
Effect.timeout("20 seconds"),
Effect.runPromise,
);
});

it("subscribes before a fast prompt and renders through completion, including continuation", () =>
Effect.gen(function* () {
const rpc = yield* connectedClient;
Expand Down
3 changes: 2 additions & 1 deletion packages/harness/test/fixtures/registry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* @effect-diagnostics nodeBuiltinImport:off -- this fixture is a loopback registry backed by Node APIs. */
import { execFile } from "node:child_process";
import { createServer, type Server } from "node:http";
import { mkdir, readFile, writeFile } from "node:fs/promises";
Expand Down Expand Up @@ -39,7 +40,7 @@ const pack = async (directory: string, version: string, scripts: Record<string,
);
await writeFile(
join(root, "index.js"),
`export default { id: ${JSON.stringify(pluginId)}, version: "${version}" };\n`,
`export default { id: ${JSON.stringify(pluginId)}, version: "${version}", kind: "tool", setup() {} };\n`,
);
await run("tar", ["-czf", "package.tgz", "package"], { cwd: join(directory, version) });
return readFile(join(directory, version, "package.tgz"));
Expand Down
11 changes: 7 additions & 4 deletions packages/harness/test/plugin.npm.live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ import { parse, type Fetchable } from "../src/plugin/source.ts";
* that reads a cache is not a staleness check.
*
* Both reach the npm registry and github, so this file fails when they are unreachable. The
* packages are chosen to be tiny and long-stable.
* packages are chosen to be tiny and long-stable. Opt in with `CODEWORK_PLUGIN_LIVE=1` so the
* default suite stays hermetic.
*/

const live = process.env.CODEWORK_PLUGIN_LIVE === "1" ? describe : describe.skip;

const withStaging = (body: (input: { into: string; home: string }) => Promise<void>) =>
Effect.runPromise(
Effect.promise(async () => {
Expand All @@ -49,7 +52,7 @@ const withStaging = (body: (input: { into: string; home: string }) => Promise<vo

const fetchable = (spec: string) => Effect.runSync(parse(spec, "/unused")) as Fetchable;

describe("the npm toolchain, against the real registry", () => {
live("the npm toolchain, against the real registry", () => {
it("installs an exact version and reports it as the revision", { timeout: 120_000 }, async () =>
withStaging(async ({ into, home }) => {
const>
Expand Down Expand Up @@ -111,7 +114,7 @@ describe("the npm toolchain, against the real registry", () => {
);
});

describe("the store, against the real registry", () => {
live("the store, against the real registry", () => {
it("installs, publishes and resolves a real package end to end", { timeout: 180_000 }, async () =>
withStaging(async ({ home }) => {
const>
Expand Down Expand Up @@ -150,7 +153,7 @@ describe("the store, against the real registry", () => {
* fetcher and the same `@npmcli/git` spawn, so an authenticated `probe` is an authenticated
* install.
*/
describe("a git remote the ambient credentials cannot reach", () => {
live("a git remote the ambient credentials cannot reach", () => {
it("fails instead of waiting for a password", { timeout: 120_000 }, async () =>
withStaging(async ({ into, home }) => {
/*
Expand Down