| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import * as Result from "effect/Result"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | import * as HttpClient from "effect/http/HttpClient"; |
| 9 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 10 | import * as UrlParams from "effect/http/UrlParams"; |
| 11 | import { GroupName, PackageName, type Manifest } from "../src/Manifest.ts"; |
| 12 | import { manifestArtifactName, tarballUrl } from "../src/Protocol.ts"; |
| 13 | import { Policy } from "../src/Registry.ts"; |
| 14 | import { |
| 15 | installTag, |
| 16 | parseInstallPath, |
| 17 | tagsFor, |
| 18 | } from "../src/Registry/Handler.ts"; |
| 19 | import { |
| 20 | selectPackages, |
| 21 | dependencyLevels, |
| 22 | expandBraces, |
| 23 | Group, |
| 24 | rewriteDependencies, |
| 25 | tarballFile, |
| 26 | } from "../src/cli/pack.ts"; |
| 27 | import { publish } from "../src/cli/publish.ts"; |
| 28 | |
| 29 | describe("registry", () => { |
| 30 | const run = { |
| 31 | repo: "alchemy-run/alchemy", |
| 32 | runId: 1, |
| 33 | attempt: 1, |
| 34 | headSha: "abcdef0123456789abcdef0123456789abcdef01", |
| 35 | headBranch: "feat/x", |
| 36 | headRepo: "alchemy-run/alchemy", |
| 37 | pr: 7, |
| 38 | }; |
| 39 | |
| 40 | test("runs on the repository's own commits get every tag", () => { |
| 41 | expect(tagsFor(run)).toEqual([ |
| 42 | run.headSha, |
| 43 | "abcdef0", |
| 44 | "pr:7", |
| 45 | "pr:7:abcdef0", |
| 46 | "branch:feat/x", |
| 47 | ]); |
| 48 | expect(tagsFor({ ...run, pr: null, headBranch: "main" })).toEqual([ |
| 49 | run.headSha, |
| 50 | "abcdef0", |
| 51 | "branch:main", |
| 52 | ]); |
| 53 | expect(installTag(run)).toBe("pr:7:abcdef0"); |
| 54 | expect(installTag({ ...run, pr: null, headBranch: "main" })).toBe( |
| 55 | "abcdef0", |
| 56 | ); |
| 57 | }); |
| 58 | |
| 59 | test("runs from forks get only their pull request revision tag", () => { |
| 60 | const fork = { ...run, headRepo: "someone/alchemy" }; |
| 61 | expect(tagsFor(fork)).toEqual(["pr:7:abcdef0"]); |
| 62 | expect(installTag(fork)).toBe("pr:7:abcdef0"); |
| 63 | expect(tagsFor({ ...fork, pr: null })).toEqual([]); |
| 64 | }); |
| 65 | |
| 66 | test("install paths", () => { |
| 67 | expect(parseInstallPath("/alchemy/pr:7:abcdef0", undefined)).toEqual({ |
| 68 | kind: "tag", |
| 69 | name: "alchemy", |
| 70 | tag: "pr:7:abcdef0", |
| 71 | }); |
| 72 | expect( |
| 73 | parseInstallPath("/@alchemy.run/pkg/branch:feat/x", undefined), |
| 74 | ).toEqual({ kind: "tag", name: "@alchemy.run/pkg", tag: "branch:feat/x" }); |
| 75 | expect(parseInstallPath("/core/abc1234", "@distilled.cloud")).toEqual({ |
| 76 | kind: "tag", |
| 77 | name: "@distilled.cloud/core", |
| 78 | tag: "abc1234", |
| 79 | }); |
| 80 | expect( |
| 81 | parseInstallPath(`/alchemy/-/${"a".repeat(64)}.tgz`, undefined), |
| 82 | ).toEqual({ kind: "tarball", name: "alchemy", sha256: "a".repeat(64) }); |
| 83 | for (const path of ["/", "/alchemy", "/alchemy/", "/alchemy/%E0%A4%A"]) { |
| 84 | expect(parseInstallPath(path, undefined)).toBeUndefined(); |
| 85 | } |
| 86 | }); |
| 87 | |
| 88 | test("tarball file names never collide", () => { |
| 89 | expect(tarballFile("alchemy")).toBe("alchemy.tgz"); |
| 90 | expect(tarballFile("@a/b-c")).toBe("a+b-c.tgz"); |
| 91 | expect(tarballFile("@a-b/c")).toBe("a-b+c.tgz"); |
| 92 | }); |
| 93 | }); |
| 94 | |
| 95 | describe("Policy", () => { |
| 96 | const policy = { |
| 97 | repos: ["alchemy-run/alchemy", "alchemy-run/distilled"], |
| 98 | ttl: Duration.weeks(1), |
| 99 | }; |
| 100 | |
| 101 | test("round-trips through its JSON encoding", () => { |
| 102 | const encoded = Schema.encodeSync(Policy)(policy); |
| 103 | expect(encoded.ttl).toBe(7 * 24 * 60 * 60 * 1000); |
| 104 | expect(Schema.decodeUnknownSync(Policy)(encoded)).toEqual(policy); |
| 105 | }); |
| 106 | }); |
| 107 | |
| 108 | describe("names", () => { |
| 109 | test("package names follow npm's rules for new packages", () => { |
| 110 | const decode = Schema.decodeUnknownSync(PackageName); |
| 111 | for (const name of ["alchemy", "@alchemy.run/pkg", "a-b_c.d~e"]) { |
| 112 | expect(decode(name)).toBe(name); |
| 113 | } |
| 114 | for (const name of [ |
| 115 | "Alchemy", |
| 116 | "@Scope/pkg", |
| 117 | ".hidden", |
| 118 | "_private", |
| 119 | "a b", |
| 120 | "a`b", |
| 121 | "a\nb", |
| 122 | "@scope", |
| 123 | "a/b", |
| 124 | "x".repeat(215), |
| 125 | ]) { |
| 126 | expect(() => decode(name)).toThrow(); |
| 127 | } |
| 128 | }); |
| 129 | |
| 130 | test("group names are plain labels", () => { |
| 131 | const decode = Schema.decodeUnknownSync(GroupName); |
| 132 | expect(decode("Alchemy")).toBe("Alchemy"); |
| 133 | expect(decode("@alchemy.run")).toBe("@alchemy.run"); |
| 134 | expect(decode("Distilled SDKs v2")).toBe("Distilled SDKs v2"); |
| 135 | for (const name of ["", " lead", "a`b", "a\nb", "###", "x".repeat(65)]) { |
| 136 | expect(() => decode(name)).toThrow(); |
| 137 | } |
| 138 | }); |
| 139 | }); |
| 140 | |
| 141 | describe("workspace", () => { |
| 142 | test("expandBraces", () => { |
| 143 | expect(expandBraces("./packages/*")).toEqual(["./packages/*"]); |
| 144 | expect(expandBraces("./packages/{alchemy, pkg}")).toEqual([ |
| 145 | "./packages/alchemy", |
| 146 | "./packages/pkg", |
| 147 | ]); |
| 148 | expect(expandBraces("./{a,b}/packages/{x,y}")).toEqual([ |
| 149 | "./a/packages/x", |
| 150 | "./a/packages/y", |
| 151 | "./b/packages/x", |
| 152 | "./b/packages/y", |
| 153 | ]); |
| 154 | }); |
| 155 | |
| 156 | test("Group", () => { |
| 157 | const decode = Schema.decodeUnknownSync(Group); |
| 158 | const encode = Schema.encodeSync(Group); |
| 159 | expect(decode("Alchemy=./packages/*")).toEqual({ |
| 160 | name: "Alchemy", |
| 161 | pattern: "./packages/*", |
| 162 | collapsed: false, |
| 163 | }); |
| 164 | expect(decode("Distilled[Collapsed]=./submodules/*")).toEqual({ |
| 165 | name: "Distilled", |
| 166 | pattern: "./submodules/*", |
| 167 | collapsed: true, |
| 168 | }); |
| 169 | expect(decode("@alchemy.run[Collapsed]=./packages/{a,b}")).toEqual({ |
| 170 | name: "@alchemy.run", |
| 171 | pattern: "./packages/{a,b}", |
| 172 | collapsed: true, |
| 173 | }); |
| 174 | expect( |
| 175 | encode({ name: "Distilled", pattern: "./submodules/*", collapsed: true }), |
| 176 | ).toBe("Distilled[Collapsed]=./submodules/*"); |
| 177 | for (const spec of ["Distilled[Hidden]=./x", "=x", "Alchemy=", "Alchemy"]) { |
| 178 | expect(() => decode(spec)).toThrow("NAME=GLOB or NAME[Collapsed]=GLOB"); |
| 179 | } |
| 180 | }); |
| 181 | }); |
| 182 | |
| 183 | describe("tarball", () => { |
| 184 | test("tarballUrl strips trailing slashes and keeps scopes", () => { |
| 185 | expect(tarballUrl("https://pkg.ing/", "@alchemy.run/pkg", "abc")).toBe( |
| 186 | "https://pkg.ing/@alchemy.run/pkg/-/abc.tgz", |
| 187 | ); |
| 188 | }); |
| 189 | |
| 190 | test("dependencyLevels orders dependencies first and rejects cycles", async () => { |
| 191 | const levels = await Effect.runPromise( |
| 192 | dependencyLevels( |
| 193 | new Map([ |
| 194 | ["alchemy", new Set(["core", "runtime", "outside"])], |
| 195 | ["runtime", new Set(["utils"])], |
| 196 | ["core", new Set()], |
| 197 | ["utils", new Set()], |
| 198 | ["better-auth", new Set(["alchemy"])], |
| 199 | ]), |
| 200 | ), |
| 201 | ); |
| 202 | expect(levels).toEqual([ |
| 203 | ["core", "utils"], |
| 204 | ["runtime"], |
| 205 | ["alchemy"], |
| 206 | ["better-auth"], |
| 207 | ]); |
| 208 | const cycle = await Effect.runPromise( |
| 209 | Effect.result( |
| 210 | dependencyLevels( |
| 211 | new Map([ |
| 212 | ["a", new Set(["b"])], |
| 213 | ["b", new Set(["a"])], |
| 214 | ]), |
| 215 | ), |
| 216 | ), |
| 217 | ); |
| 218 | expect(cycle._tag).toBe("Failure"); |
| 219 | }); |
| 220 | |
| 221 | test("rewriteDependencies only touches published packages", async () => { |
| 222 | const manifest = JSON.stringify({ |
| 223 | name: "alchemy", |
| 224 | dependencies: { |
| 225 | "@distilled.cloud/core": "1.0.0-rc.8", |
| 226 | effect: "^4.0.0", |
| 227 | }, |
| 228 | peerDependencies: { "@alchemy.run/frontend-frameworks": "2.0.0" }, |
| 229 | exports: { ".": "./src/index.ts" }, |
| 230 | }); |
| 231 | const links = new Map([ |
| 232 | [ |
| 233 | "@distilled.cloud/core", |
| 234 | "https://pkg.ing/@distilled.cloud/core/-/aa.tgz", |
| 235 | ], |
| 236 | [ |
| 237 | "@alchemy.run/frontend-frameworks", |
| 238 | "https://pkg.ing/@alchemy.run/frontend-frameworks/-/bb.tgz", |
| 239 | ], |
| 240 | ]); |
| 241 | const result = await Effect.runPromise( |
| 242 | rewriteDependencies(manifest, links), |
| 243 | ); |
| 244 | const rewritten = JSON.parse(result.text); |
| 245 | expect(Object.keys(rewritten)).toEqual(Object.keys(JSON.parse(manifest))); |
| 246 | expect(rewritten.dependencies).toEqual({ |
| 247 | "@distilled.cloud/core": "https://pkg.ing/@distilled.cloud/core/-/aa.tgz", |
| 248 | effect: "^4.0.0", |
| 249 | }); |
| 250 | expect(rewritten.peerDependencies).toEqual({ |
| 251 | "@alchemy.run/frontend-frameworks": |
| 252 | "https://pkg.ing/@alchemy.run/frontend-frameworks/-/bb.tgz", |
| 253 | }); |
| 254 | expect(rewritten.exports).toEqual({ ".": "./src/index.ts" }); |
| 255 | expect(result.rewrites.map((r) => r.name).sort()).toEqual([ |
| 256 | "@alchemy.run/frontend-frameworks", |
| 257 | "@distilled.cloud/core", |
| 258 | ]); |
| 259 | }); |
| 260 | }); |
| 261 | |
| 262 | describe("Api", () => { |
| 263 | test("manifest artifact name carries the manifest hash", () => { |
| 264 | expect(manifestArtifactName("ab".repeat(32))).toBe( |
| 265 | `pkg-manifest-${"ab".repeat(32)}`, |
| 266 | ); |
| 267 | }); |
| 268 | }); |
| 269 | |
| 270 | const env = { |
| 271 | GITHUB_REPOSITORY: "alchemy-run/alchemy", |
| 272 | GITHUB_RUN_ID: "123", |
| 273 | GITHUB_RUN_ATTEMPT: "2", |
| 274 | }; |
| 275 | const previous = Object.fromEntries( |
| 276 | Object.keys(env).map((key) => [key, process.env[key]]), |
| 277 | ); |
| 278 | beforeAll(() => Object.assign(process.env, env)); |
| 279 | afterAll(() => { |
| 280 | for (const [key, value] of Object.entries(previous)) { |
| 281 | if (value === undefined) delete process.env[key]; |
| 282 | else process.env[key] = value; |
| 283 | } |
| 284 | }); |
| 285 | |
| 286 | const manifest: Manifest = { |
| 287 | version: 1, |
| 288 | groups: [{ name: "Alchemy", collapsed: false }], |
| 289 | registry: "https://pkg.ing", |
| 290 | head: "abcdef0123456789", |
| 291 | packages: ["alchemy", "@alchemy.run/pkg"].map((name, index) => ({ |
| 292 | name, |
| 293 | version: "1.0.0", |
| 294 | group: "Alchemy", |
| 295 | dir: `packages/${index}`, |
| 296 | file: `${index}.tgz`, |
| 297 | sha256: String(index).repeat(64), |
| 298 | size: 3, |
| 299 | })), |
| 300 | }; |
| 301 | const missing = { |
| 302 | _tag: "MissingTarballs", |
| 303 | missing: [ |
| 304 | { name: manifest.packages[1]!.name, sha256: manifest.packages[1]!.sha256 }, |
| 305 | ], |
| 306 | }; |
| 307 | const published = { |
| 308 | packages: manifest.packages.map(({ name, group }) => ({ |
| 309 | name, |
| 310 | group, |
| 311 | url: `https://pkg.ing/${name}/abcdef0`, |
| 312 | tags: ["abcdef0"], |
| 313 | })), |
| 314 | }; |
| 315 | |
| 316 | for (const scenario of [ |
| 317 | "already present", |
| 318 | "upload", |
| 319 | "still missing", |
| 320 | "rejected", |
| 321 | ] as const) { |
| 322 | test(`publish: ${scenario}`, async () => { |
| 323 | const requests: string[] = []; |
| 324 | const reads: string[] = []; |
| 325 | let attempts = 0; |
| 326 | const http = HttpClient.make((request) => |
| 327 | Effect.sync(() => { |
| 328 | const query = UrlParams.toString(request.urlParams); |
| 329 | requests.push( |
| 330 | `${request.method} ${request.url}${query ? `?${query}` : ""}`, |
| 331 | ); |
| 332 | if (request.method === "PUT") { |
| 333 | expect(request.headers["content-length"]).toBe("3"); |
| 334 | return HttpClientResponse.fromWeb( |
| 335 | request, |
| 336 | Response.json({ |
| 337 | ...manifest.packages[1], |
| 338 | uploaded: true, |
| 339 | }), |
| 340 | ); |
| 341 | } |
| 342 | attempts++; |
| 343 | const response = |
| 344 | scenario === "rejected" |
| 345 | ? Response.json( |
| 346 | { _tag: "RunNotInProgress", message: "run is not in progress" }, |
| 347 | { status: 409 }, |
| 348 | ) |
| 349 | : scenario === "still missing" || |
| 350 | (scenario === "upload" && attempts === 1) |
| 351 | ? Response.json(missing, { status: 409 }) |
| 352 | : Response.json(published); |
| 353 | return HttpClientResponse.fromWeb(request, response); |
| 354 | }), |
| 355 | ); |
| 356 | const result = await Effect.runPromise( |
| 357 | publish({ |
| 358 | cwd: "/workspace", |
| 359 | dir: ".pkg", |
| 360 | registry: "https://pkg.ing/", |
| 361 | }).pipe( |
| 362 | Effect.provideService(HttpClient.HttpClient, http), |
| 363 | Effect.provide( |
| 364 | FileSystem.layerNoop({ |
| 365 | readFileString: () => Effect.succeed(JSON.stringify(manifest)), |
| 366 | readFile: (path) => |
| 367 | Effect.sync(() => { |
| 368 | reads.push(path); |
| 369 | return new Uint8Array([1, 2, 3]); |
| 370 | }), |
| 371 | }), |
| 372 | ), |
| 373 | Effect.provide(Path.layer), |
| 374 | Effect.result, |
| 375 | ), |
| 376 | ); |
| 377 | const uploads = scenario === "upload" || scenario === "still missing"; |
| 378 | expect(attempts).toBe(uploads ? 2 : 1); |
| 379 | expect(reads).toEqual(uploads ? ["/workspace/.pkg/1.tgz"] : []); |
| 380 | expect(requests).toEqual( |
| 381 | uploads |
| 382 | ? [ |
| 383 | "POST https://pkg.ing/api/publish", |
| 384 | `PUT https://pkg.ing/api/tarballs/%40alchemy.run%2Fpkg/${"1".repeat(64)}?repo=alchemy-run%2Falchemy&runId=123&attempt=2`, |
| 385 | "POST https://pkg.ing/api/publish", |
| 386 | ] |
| 387 | : ["POST https://pkg.ing/api/publish"], |
| 388 | ); |
| 389 | if (scenario === "still missing" || scenario === "rejected") { |
| 390 | expect(Result.isFailure(result)).toBe(true); |
| 391 | if (Result.isFailure(result)) { |
| 392 | expect(result.failure._tag).toBe("PublishError"); |
| 393 | expect(String(result.failure)).toContain( |
| 394 | scenario === "rejected" |
| 395 | ? "run is not in progress" |
| 396 | : "registry still reports missing tarballs after upload", |
| 397 | ); |
| 398 | } |
| 399 | } else { |
| 400 | expect(Result.getOrThrow(result)).toEqual(published); |
| 401 | } |
| 402 | }); |
| 403 | } |
| 404 | |
| 405 | describe("partial publication", () => { |
| 406 | const packages = ["core", "aws", "cloudflare", "app", "unrelated"].map( |
| 407 | (name) => ({ |
| 408 | name, |
| 409 | version: "1.0.0", |
| 410 | dir: `packages/${name}`, |
| 411 | absDir: `/workspace/packages/${name}`, |
| 412 | group: "SDKs", |
| 413 | }), |
| 414 | ); |
| 415 | const deps = new Map([ |
| 416 | ["core", new Set<string>()], |
| 417 | ["aws", new Set(["core"])], |
| 418 | ["cloudflare", new Set(["core"])], |
| 419 | ["app", new Set(["cloudflare"])], |
| 420 | ["unrelated", new Set<string>()], |
| 421 | ]); |
| 422 | const names = (files: string[], extra: string[] = []) => |
| 423 | selectPackages(packages, deps, files, extra).map((pkg) => pkg.name); |
| 424 | |
| 425 | test("includes transitive dependents and dependencies without unrelated siblings", () => { |
| 426 | expect(names(["packages/cloudflare/src/r2.ts"])).toEqual([ |
| 427 | "core", |
| 428 | "cloudflare", |
| 429 | "app", |
| 430 | ]); |
| 431 | expect( |
| 432 | names(["packages/aws/src/s3.ts", "packages/cloudflare/src/r2.ts"]), |
| 433 | ).toEqual(["core", "aws", "cloudflare", "app"]); |
| 434 | expect(names(["packages/core/src/index.ts"])).toEqual([ |
| 435 | "core", |
| 436 | "aws", |
| 437 | "cloudflare", |
| 438 | "app", |
| 439 | ]); |
| 440 | }); |
| 441 | |
| 442 | test("ignores unrelated paths and respects directory boundaries", () => { |
| 443 | expect(names(["README.md", "packages/aws-other/index.ts"])).toEqual([]); |
| 444 | expect(names([])).toEqual([]); |
| 445 | }); |
| 446 | |
| 447 | test("shared build inputs and configured prefixes select everything", () => { |
| 448 | for (const file of [ |
| 449 | "pnpm-lock.yaml", |
| 450 | "package.json", |
| 451 | ".github/workflows/pkg.yml", |
| 452 | "scripts/build.ts", |
| 453 | ]) { |
| 454 | expect(names([file], ["scripts/**"])).toEqual( |
| 455 | packages.map((pkg) => pkg.name), |
| 456 | ); |
| 457 | } |
| 458 | expect(names(["scripts-other/build.ts"], ["scripts/**"])).toEqual([]); |
| 459 | }); |
| 460 | |
| 461 | test("submodule gitlink changes select contained packages", () => { |
| 462 | expect( |
| 463 | selectPackages( |
| 464 | packages.map((pkg) => ({ ...pkg, dir: `submodules/sdk/${pkg.dir}` })), |
| 465 | deps, |
| 466 | ["submodules/sdk"], |
| 467 | ).length, |
| 468 | ).toBe(packages.length); |
| 469 | }); |
| 470 | |
| 471 | test("dependency cycles terminate selection and are rejected by packing order", async () => { |
| 472 | const cycle = new Map([ |
| 473 | ["core", new Set(["aws"])], |
| 474 | ["aws", new Set(["core"])], |
| 475 | ]); |
| 476 | expect( |
| 477 | selectPackages(packages, cycle, ["packages/aws/index.ts"]).map( |
| 478 | (pkg) => pkg.name, |
| 479 | ), |
| 480 | ).toEqual(["core", "aws"]); |
| 481 | expect( |
| 482 | (await Effect.runPromise(Effect.result(dependencyLevels(cycle))))._tag, |
| 483 | ).toBe("Failure"); |
| 484 | }); |
| 485 | |
| 486 | test("empty manifests do not contact the registry", async () => { |
| 487 | const result = await Effect.runPromise( |
| 488 | publish({ |
| 489 | cwd: "/workspace", |
| 490 | dir: ".pkg", |
| 491 | registry: manifest.registry, |
| 492 | }).pipe( |
| 493 | Effect.provideService( |
| 494 | HttpClient.HttpClient, |
| 495 | HttpClient.make(() => Effect.die("Unexpected registry request")), |
| 496 | ), |
| 497 | Effect.provide( |
| 498 | FileSystem.layerNoop({ |
| 499 | readFileString: () => |
| 500 | Effect.succeed(JSON.stringify({ ...manifest, packages: [] })), |
| 501 | }), |
| 502 | ), |
| 503 | Effect.provide(Path.layer), |
| 504 | ), |
| 505 | ); |
| 506 | expect(result).toEqual({ packages: [] }); |
| 507 | }); |
| 508 | }); |