import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { Effect } from "effect";
import { makeSelfHostAppsRuntime } from "../plugin/self-host-runtime";
import { makeInMemoryAppsStore, makeTestResolver } from "../testing";
import { PUBLISH_LIMITS } from "../pipeline/publish";
import { fetchGitHubSource, syncGitHubSource } from "./github-source";
const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect);
const toolSource = (name = "ok"): string => `import { defineTool } from "executor:app";
import { z } from "zod";
export default defineTool({
description: "${name}",
input: z.object({ value: z.string().default("${name}") }),
async handler(input) { return { value: input.value }; },
});`;
const makeRuntime = () =>
makeSelfHostAppsRuntime({
dataDir: mkdtempSync(join(tmpdir(), "apps-gh-src-")),
store: makeInMemoryAppsStore(),
resolver: makeTestResolver({}),
inMemory: true,
});
const json = (value: unknown, status = 200): Response =>
new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json" },
});
const makeGitHubFetch = (input: {
readonly files: ReadonlyMap;
readonly upstreamSha?: string;
readonly treeSha?: string;
}) => {
const repoPath = "/repos/acme/tools";
const upstreamSha = input.upstreamSha ?? "commit-1";
const treeSha = input.treeSha ?? "tree-1";
const blobBySha = new Map();
let index = 0;
for (const [path, content] of input.files) {
blobBySha.set(`blob-${++index}`, { path, content });
}
let blobCalls = 0;
const fetch = (async (rawUrl: string) => {
const url = new URL(rawUrl);
if (url.pathname === repoPath) return json({ default_branch: "main" });
if (url.pathname === `${repoPath}/git/ref/heads%2Fmain`) {
return json({ object: { sha: upstreamSha } });
}
if (url.pathname === `${repoPath}/git/commits/${upstreamSha}`) {
return json({ sha: upstreamSha, tree: { sha: treeSha } });
}
if (url.pathname === `${repoPath}/git/trees/${treeSha}`) {
return json({
tree: [...blobBySha.entries()].map(([sha, blob]) => ({
path: blob.path,
type: "blob",
sha,
size: Buffer.byteLength(blob.content, "utf8"),
})),
});
}
const blobPrefix = `${repoPath}/git/blobs/`;
if (url.pathname.startsWith(blobPrefix)) {
blobCalls++;
const sha = decodeURIComponent(url.pathname.slice(blobPrefix.length));
const blob = blobBySha.get(sha);
if (!blob) return json({ message: "not found" }, 404);
return json({
encoding: "base64",
content: Buffer.from(blob.content, "utf8").toString("base64"),
});
}
return json({ message: "not found" }, 404);
}) as typeof globalThis.fetch;
return {
fetch,
blobCalls: () => blobCalls,
};
};
describe("GitHub custom-tools source", () => {
it("fetches a repo fileset and publishes provenance, description, and skipped entries", async () => {
const files = new Map([
[
"executor.json",
JSON.stringify({
$schema: "https://example.test/schema",
description: "Acme tools",
ignored: true,
}),
],
["tools/hello.ts", toolSource("hello")],
["workflows/deferred.ts", "export default {};"],
["docs/readme.md", "ignored"],
]);
const github = makeGitHubFetch({ files, upstreamSha: "commit-a" });
const snapshot = await run(fetchGitHubSource({ repo: "acme/tools", fetch: github.fetch }));
expect([...snapshot.files.keys()].sort()).toEqual([
"executor.json",
"tools/hello.ts",
"workflows/deferred.ts",
]);
expect(snapshot.description).toBe("Acme tools");
const host = makeRuntime();
const result = await run(
syncGitHubSource({
runtime: host.runtime,
scope: "githubTools",
repo: "acme/tools",
fetch: github.fetch,
}),
);
expect(result.status).toBe("published");
expect(result.tools).toEqual(["hello"]);
expect(result.skipped).toEqual([
{ path: "workflows/deferred.ts", reason: "not supported yet" },
]);
const descriptor = await run(host.runtime.getDescriptor("githubTools"));
expect(descriptor?.description).toBe("Acme tools");
expect(descriptor?.source).toEqual({
kind: "github",
repo: "acme/tools",
ref: "main",
upstreamSha: "commit-a",
});
await host.close();
});
it("reports up-to-date when the upstream commit SHA is unchanged", async () => {
const github = makeGitHubFetch({
files: new Map([["tools/hello.ts", toolSource("hello")]]),
upstreamSha: "same-sha",
});
const host = makeRuntime();
const first = await run(
syncGitHubSource({
runtime: host.runtime,
scope: "githubTools",
repo: "acme/tools",
fetch: github.fetch,
}),
);
const second = await run(
syncGitHubSource({
runtime: host.runtime,
scope: "githubTools",
repo: "acme/tools",
fetch: github.fetch,
}),
);
expect(first.status).toBe("published");
expect(second).toEqual({
status: "up-to-date",
upstreamSha: "same-sha",
tools: ["hello"],
skipped: [],
});
await host.close();
});
it("rejects oversized trees before fetching blobs", async () => {
const files = new Map();
for (let i = 0; i < PUBLISH_LIMITS.maxFiles + 1; i++) {
files.set(`tools/t${i}.ts`, toolSource(`t${i}`));
}
const github = makeGitHubFetch({ files });
const exit = await Effect.runPromiseExit(
fetchGitHubSource({ repo: "acme/tools", fetch: github.fetch }),
);
expect(exit._tag).toBe("Failure");
expect(JSON.stringify(exit)).toContain("exceeding the limit");
expect(github.blobCalls()).toBe(0);
});
it("returns typed failure data for publish errors", async () => {
const github = makeGitHubFetch({
files: new Map([
[
"tools/bad.ts",
`import { defineTool } from "executor:app";
import { chunk } from "lodash";
export default defineTool({ description: "bad", input: { type: "object" }, async handler(){ return chunk([1], 1); } });`,
],
]),
upstreamSha: "bad-sha",
});
const host = makeRuntime();
const result = await run(
syncGitHubSource({
runtime: host.runtime,
scope: "githubTools",
repo: "acme/tools",
fetch: github.fetch,
}),
);
expect(result.status).toBe("failed");
expect(result.upstreamSha).toBe("bad-sha");
expect(result.errors?.[0]?.stage).toBe("bundle");
expect(result.errors?.[0]?.message).toContain('bare import "lodash" is not allowed');
await host.close();
});
});