History

Add GitHub sync and self-host custom tools wiring

Rhys Sullivan committed 4 months ago

Commit 275446e1 parent 41eb3ae19 files · +1665 −40Browse files

Showing the changes to packages/plugins/apps/src/source/github-source.test.ts (1 of 19 files). Show all

Unified · Split

packages/plugins/apps/src/source/github-source.test.ts+211−0
@@ -0,0 +1,211 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
5import { describe, expect, it } from "vitest";
6import { Effect } from "effect";
7
8import { makeSelfHostAppsRuntime } from "../plugin/self-host-runtime";
9import { makeInMemoryAppsStore, makeTestResolver } from "../testing";
10import { PUBLISH_LIMITS } from "../pipeline/publish";
11import { fetchGitHubSource, syncGitHubSource } from "./github-source";
12
13const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
14
15const toolSource = (name = "ok"): string => `import { defineTool } from "executor:app";
16import { z } from "zod";
17export default defineTool({
18 description: "${name}",
19 input: z.object({ value: z.string().default("${name}") }),
20 async handler(input) { return { value: input.value }; },
21});`;
22
23const makeRuntime = () =>
24 makeSelfHostAppsRuntime({
25 dataDir: mkdtempSync(join(tmpdir(), "apps-gh-src-")),
26 store: makeInMemoryAppsStore(),
27 resolver: makeTestResolver({}),
28 inMemory: true,
29 });
30
31const json = (value: unknown, status = 200): Response =>
32 new Response(JSON.stringify(value), {
33 status,
34 headers: { "content-type": "application/json" },
35 });
36
37const makeGitHubFetch = (input: {
38 readonly files: ReadonlyMap<string, string>;
39 readonly upstreamSha?: string;
40 readonly treeSha?: string;
41}) => {
42 const repoPath = "/repos/acme/tools";
43 const upstreamSha = input.upstreamSha ?? "commit-1";
44 const treeSha = input.treeSha ?? "tree-1";
45 const blobBySha = new Map<string, { path: string; content: string }>();
46 let index = 0;
47 for (const [path, content] of input.files) {
48 blobBySha.set(`blob-${++index}`, { path, content });
49 }
50 let blobCalls = 0;
51 const fetch = (async (rawUrl: string) => {
52 const url = new URL(rawUrl);
53 if (url.pathname === repoPath) return json({ default_branch: "main" });
54 if (url.pathname === `${repoPath}/git/ref/heads%2Fmain`) {
55 return json({ object: { sha: upstreamSha } });
56 }
57 if (url.pathname === `${repoPath}/git/commits/${upstreamSha}`) {
58 return json({ sha: upstreamSha, tree: { sha: treeSha } });
59 }
60 if (url.pathname === `${repoPath}/git/trees/${treeSha}`) {
61 return json({
62 tree: [...blobBySha.entries()].map(([sha, blob]) => ({
63 path: blob.path,
64 type: "blob",
65 sha,
66 size: Buffer.byteLength(blob.content, "utf8"),
67 })),
68 });
69 }
70 const blobPrefix = `${repoPath}/git/blobs/`;
71 if (url.pathname.startsWith(blobPrefix)) {
72 blobCalls++;
73 const sha = decodeURIComponent(url.pathname.slice(blobPrefix.length));
74 const blob = blobBySha.get(sha);
75 if (!blob) return json({ message: "not found" }, 404);
76 return json({
77 encoding: "base64",
78 content: Buffer.from(blob.content, "utf8").toString("base64"),
79 });
80 }
81 return json({ message: "not found" }, 404);
82 }) as typeof globalThis.fetch;
83 return {
84 fetch,
85 blobCalls: () => blobCalls,
86 };
87};
88
89describe("GitHub custom-tools source", () => {
90 it("fetches a repo fileset and publishes provenance, description, and skipped entries", async () => {
91 const files = new Map<string, string>([
92 [
93 "executor.json",
94 JSON.stringify({
95 $schema: "https://example.test/schema",
96 description: "Acme tools",
97 ignored: true,
98 }),
99 ],
100 ["tools/hello.ts", toolSource("hello")],
101 ["workflows/deferred.ts", "export default {};"],
102 ["docs/readme.md", "ignored"],
103 ]);
104 const github = makeGitHubFetch({ files, upstreamSha: "commit-a" });
105 const snapshot = await run(fetchGitHubSource({ repo: "acme/tools", fetch: github.fetch }));
106 expect([...snapshot.files.keys()].sort()).toEqual([
107 "executor.json",
108 "tools/hello.ts",
109 "workflows/deferred.ts",
110 ]);
111 expect(snapshot.description).toBe("Acme tools");
112
113 const host = makeRuntime();
114 const result = await run(
115 syncGitHubSource({
116 runtime: host.runtime,
117 scope: "githubTools",
118 repo: "acme/tools",
119 fetch: github.fetch,
120 }),
121 );
122 expect(result.status).toBe("published");
123 expect(result.tools).toEqual(["hello"]);
124 expect(result.skipped).toEqual([
125 { path: "workflows/deferred.ts", reason: "not supported yet" },
126 ]);
127 const descriptor = await run(host.runtime.getDescriptor("githubTools"));
128 expect(descriptor?.description).toBe("Acme tools");
129 expect(descriptor?.source).toEqual({
130 kind: "github",
131 repo: "acme/tools",
132 ref: "main",
133 upstreamSha: "commit-a",
134 });
135 await host.close();
136 });
137
138 it("reports up-to-date when the upstream commit SHA is unchanged", async () => {
139 const github = makeGitHubFetch({
140 files: new Map([["tools/hello.ts", toolSource("hello")]]),
141 upstreamSha: "same-sha",
142 });
143 const host = makeRuntime();
144 const first = await run(
145 syncGitHubSource({
146 runtime: host.runtime,
147 scope: "githubTools",
148 repo: "acme/tools",
149 fetch: github.fetch,
150 }),
151 );
152 const second = await run(
153 syncGitHubSource({
154 runtime: host.runtime,
155 scope: "githubTools",
156 repo: "acme/tools",
157 fetch: github.fetch,
158 }),
159 );
160 expect(first.status).toBe("published");
161 expect(second).toEqual({
162 status: "up-to-date",
163 upstreamSha: "same-sha",
164 tools: ["hello"],
165 skipped: [],
166 });
167 await host.close();
168 });
169
170 it("rejects oversized trees before fetching blobs", async () => {
171 const files = new Map<string, string>();
172 for (let i = 0; i < PUBLISH_LIMITS.maxFiles + 1; i++) {
173 files.set(`tools/t${i}.ts`, toolSource(`t${i}`));
174 }
175 const github = makeGitHubFetch({ files });
176 const exit = await Effect.runPromiseExit(
177 fetchGitHubSource({ repo: "acme/tools", fetch: github.fetch }),
178 );
179 expect(exit._tag).toBe("Failure");
180 expect(JSON.stringify(exit)).toContain("exceeding the limit");
181 expect(github.blobCalls()).toBe(0);
182 });
183
184 it("returns typed failure data for publish errors", async () => {
185 const github = makeGitHubFetch({
186 files: new Map([
187 [
188 "tools/bad.ts",
189 `import { defineTool } from "executor:app";
190import { chunk } from "lodash";
191export default defineTool({ description: "bad", input: { type: "object" }, async handler(){ return chunk([1], 1); } });`,
192 ],
193 ]),
194 upstreamSha: "bad-sha",
195 });
196 const host = makeRuntime();
197 const result = await run(
198 syncGitHubSource({
199 runtime: host.runtime,
200 scope: "githubTools",
201 repo: "acme/tools",
202 fetch: github.fetch,
203 }),
204 );
205 expect(result.status).toBe("failed");
206 expect(result.upstreamSha).toBe("bad-sha");
207 expect(result.errors?.[0]?.stage).toBe("bundle");
208 expect(result.errors?.[0]?.message).toContain('bare import "lodash" is not allowed');
209 await host.close();
210 });
211});