1import {
2 hashPackageInstallIdentity,
3 installPackages,
4 installResolvedPackages,
5 matchesPackageRoot,
6 normalizeInstallTargets,
7 npmInstallArgs,
8 npmLockfileArgs,
9 npmPlainInstallArgs,
10 parsePackageRoot,
11 parsePackageRootFromSpecifier,
12 resolveInstallTargets,
13 resolvePackageInstallIdentity,
14} from "@/Bundle/InstalledPackages";
15import * as NodeServices from "@effect/platform-node/NodeServices";
16import { describe, expect, it } from "alchemy-test";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Path from "effect/Path";
20import { strFromU8, unzipSync } from "fflate";
21import { spawnSync } from "node:child_process";
22import { zipCode } from "@/Util/zip";
23
24const integrationEnabled =
25 process.env.ALCHEMY_TEST_LAMBDA_EXTERNAL_PACKAGES === "1" &&
26 spawnSync("npm", ["--version"], { stdio: "ignore" }).status === 0;
27
28const withLockfileFixture = <A, E, R>(
29 options: {
30 readonly prefix: string;
31 readonly packageJson: unknown;
32 readonly lockfileName: string;
33 readonly lockfileContent: string;
34 readonly workspace?: string;
35 },
36 use: (context: {
37 readonly root: string;
38 readonly cwd: string;
39 readonly fs: FileSystem.FileSystem;
40 readonly path: Path.Path;
41 }) => Effect.Effect<A, E, R>,
42) =>
43 Effect.gen(function* () {
44 const fs = yield* FileSystem.FileSystem;
45 const path = yield* Path.Path;
46 const root = yield* fs.makeTempDirectory({ prefix: options.prefix });
47 const cwd =
48 options.workspace === undefined
49 ? root
50 : path.join(root, options.workspace);
51 try {
52 if (cwd !== root) yield* fs.makeDirectory(cwd, { recursive: true });
53 yield* fs.writeFileString(
54 path.join(cwd, "package.json"),
55 JSON.stringify(options.packageJson),
56 );
57 yield* fs.writeFileString(
58 path.join(root, options.lockfileName),
59 options.lockfileContent,
60 );
61 return yield* use({ root, cwd, fs, path });
62 } finally {
63 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
64 }
65 });
66
67describe("Lambda external packages", { tags: ["unit", "local"] }, () => {
68 it("accepts only package roots, not subpaths", () => {
69 expect(parsePackageRoot("sharp")).toBe("sharp");
70 expect(parsePackageRoot("@img/tool")).toBe("@img/tool");
71 expect(parsePackageRoot("sharp/lib/index.js")).toBeUndefined();
72 expect(parsePackageRoot("@img/sharp-linux-arm64/lib")).toBeUndefined();
73 expect(parsePackageRoot("node:fs")).toBeUndefined();
74 expect(parsePackageRoot("./local.js")).toBeUndefined();
75 });
76
77 it("extracts package roots from externalized module ids", () => {
78 expect(parsePackageRootFromSpecifier("heic-convert")).toBe("heic-convert");
79 expect(parsePackageRootFromSpecifier("heic-convert/lib")).toBe(
80 "heic-convert",
81 );
82 expect(parsePackageRootFromSpecifier("@scope/pkg/subpath")).toBe(
83 "@scope/pkg",
84 );
85 expect(parsePackageRootFromSpecifier("node:fs")).toBeUndefined();
86 expect(parsePackageRootFromSpecifier("./local.js")).toBeUndefined();
87 expect(parsePackageRootFromSpecifier("")).toBeUndefined();
88 expect(parsePackageRootFromSpecifier("@scope")).toBeUndefined();
89 });
90
91 it("matches package roots and subpath imports", () => {
92 expect(matchesPackageRoot("sharp", "sharp")).toBe(true);
93 expect(matchesPackageRoot("sharp/lib/index.js", "sharp")).toBe(true);
94 expect(matchesPackageRoot("sharpish", "sharp")).toBe(false);
95 expect(matchesPackageRoot("@scope/pkg", "@scope/pkg")).toBe(true);
96 expect(matchesPackageRoot("@scope/pkg/sub", "@scope/pkg")).toBe(true);
97 });
98
99 it.effect("rejects subpaths in build.install", () =>
100 Effect.gen(function* () {
101 const error = yield* Effect.flip(normalizeInstallTargets(["sharp/lib"]));
102 expect(error.message).toMatch(/Invalid package name/);
103 }),
104 );
105
106 it.effect("normalizes and validates install targets", () =>
107 Effect.gen(function* () {
108 expect(yield* normalizeInstallTargets(["sharp", "pg-native"])).toEqual({
109 sharp: "*",
110 "pg-native": "*",
111 });
112 expect(yield* normalizeInstallTargets({ sharp: "^0.33.5" })).toEqual({
113 sharp: "^0.33.5",
114 });
115 }),
116 );
117
118 it("targets Linux and the Lambda architecture", () => {
119 expect(npmInstallArgs("arm64")).toEqual([
120 "ci",
121 "--force",
122 "--platform=linux",
123 "--os=linux",
124 "--arch=arm64",
125 "--cpu=arm64",
126 "--libc=glibc",
127 ]);
128 expect(npmInstallArgs("x86_64")).toEqual([
129 "ci",
130 "--force",
131 "--platform=linux",
132 "--os=linux",
133 "--arch=x64",
134 "--cpu=x64",
135 "--libc=glibc",
136 ]);
137 expect(npmLockfileArgs("arm64")).toEqual([
138 "install",
139 "--force",
140 "--platform=linux",
141 "--os=linux",
142 "--arch=arm64",
143 "--cpu=arm64",
144 "--libc=glibc",
145 "--package-lock-only",
146 "--ignore-scripts",
147 ]);
148 expect(npmPlainInstallArgs("arm64")).toEqual([
149 "install",
150 "--force",
151 "--platform=linux",
152 "--os=linux",
153 "--arch=arm64",
154 "--cpu=arm64",
155 "--libc=glibc",
156 ]);
157 });
158
159 it.effect("resolves catalog versions from pnpm-workspace.yaml", () =>
160 Effect.gen(function* () {
161 const fs = yield* FileSystem.FileSystem;
162 const path = yield* Path.Path;
163 const root = yield* fs.makeTempDirectory({
164 prefix: "alchemy-external-catalog-",
165 });
166
167 try {
168 yield* fs.writeFileString(
169 path.join(root, "pnpm-workspace.yaml"),
170 ["packages:", " - packages/*", "catalog:", " sharp: ^0.33.5"].join(
171 "\n",
172 ),
173 );
174 yield* fs.writeFileString(
175 path.join(root, "package.json"),
176 JSON.stringify({ dependencies: { sharp: "catalog:" } }),
177 );
178
179 const files = yield* installPackages({
180 cwd: root,
181 install: ["sharp"],
182 architecture: "arm64",
183 runNpmInstall: (directory) =>
184 Effect.gen(function* () {
185 const packageJson = JSON.parse(
186 yield* fs.readFileString(path.join(directory, "package.json")),
187 );
188 expect(packageJson.dependencies.sharp).toBe("^0.33.5");
189 }),
190 });
191
192 expect(files.map((file) => file.path)).toContain("package.json");
193 } finally {
194 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
195 }
196 }).pipe(Effect.provide(NodeServices.layer)),
197 );
198
199 it.effect("resolves named catalog versions from pnpm-workspace.yaml", () =>
200 Effect.gen(function* () {
201 const fs = yield* FileSystem.FileSystem;
202 const path = yield* Path.Path;
203 const root = yield* fs.makeTempDirectory({
204 prefix: "alchemy-external-pnpm-named-catalog-",
205 });
206
207 try {
208 yield* fs.writeFileString(
209 path.join(root, "pnpm-workspace.yaml"),
210 [
211 "packages:",
212 " - packages/*",
213 "catalogs:",
214 " native:",
215 " sharp: ^0.34.5",
216 ].join("\n"),
217 );
218 yield* fs.writeFileString(
219 path.join(root, "package.json"),
220 JSON.stringify({ dependencies: { sharp: "catalog:native" } }),
221 );
222
223 const files = yield* installPackages({
224 cwd: root,
225 install: ["sharp"],
226 architecture: "arm64",
227 runNpmInstall: (directory) =>
228 Effect.gen(function* () {
229 const packageJson = JSON.parse(
230 yield* fs.readFileString(path.join(directory, "package.json")),
231 );
232 expect(packageJson.dependencies.sharp).toBe("^0.34.5");
233 }),
234 });
235
236 expect(files.map((file) => file.path)).toContain("package.json");
237 } finally {
238 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
239 }
240 }).pipe(Effect.provide(NodeServices.layer)),
241 );
242
243 it.effect("resolves catalog versions from Bun workspace metadata", () =>
244 Effect.gen(function* () {
245 const fs = yield* FileSystem.FileSystem;
246 const path = yield* Path.Path;
247 const root = yield* fs.makeTempDirectory({
248 prefix: "alchemy-external-bun-catalog-",
249 });
250
251 try {
252 yield* fs.writeFileString(
253 path.join(root, "package.json"),
254 JSON.stringify({
255 workspaces: {
256 packages: ["packages/*"],
257 catalogs: {
258 native: {
259 sharp: "^0.34.5",
260 },
261 },
262 },
263 dependencies: { sharp: "catalog:native" },
264 }),
265 );
266
267 const files = yield* installPackages({
268 cwd: root,
269 install: ["sharp"],
270 architecture: "arm64",
271 runNpmInstall: (directory) =>
272 Effect.gen(function* () {
273 const packageJson = JSON.parse(
274 yield* fs.readFileString(path.join(directory, "package.json")),
275 );
276 expect(packageJson.dependencies.sharp).toBe("^0.34.5");
277 }),
278 });
279
280 expect(files.map((file) => file.path)).toContain("package.json");
281 } finally {
282 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
283 }
284 }).pipe(Effect.provide(NodeServices.layer)),
285 );
286
287 it.effect("falls back to optional and dev dependency versions", () =>
288 Effect.gen(function* () {
289 const fs = yield* FileSystem.FileSystem;
290 const path = yield* Path.Path;
291 const root = yield* fs.makeTempDirectory({
292 prefix: "alchemy-external-dependency-sections-",
293 });
294
295 try {
296 yield* fs.writeFileString(
297 path.join(root, "package.json"),
298 JSON.stringify({
299 optionalDependencies: { sharp: "^0.34.5" },
300 devDependencies: { "pg-native": "^3.2.0" },
301 }),
302 );
303
304 const files = yield* installPackages({
305 cwd: root,
306 install: ["sharp", "pg-native"],
307 architecture: "x86_64",
308 runNpmInstall: (directory) =>
309 Effect.gen(function* () {
310 const packageJson = JSON.parse(
311 yield* fs.readFileString(path.join(directory, "package.json")),
312 );
313 expect(packageJson.dependencies).toEqual({
314 "pg-native": "^3.2.0",
315 sharp: "^0.34.5",
316 });
317 }),
318 });
319
320 expect(files.map((file) => file.path)).toContain("package.json");
321 } finally {
322 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
323 }
324 }).pipe(Effect.provide(NodeServices.layer)),
325 );
326
327 it.effect("rejects workspace and file dependency protocols", () =>
328 Effect.gen(function* () {
329 const fs = yield* FileSystem.FileSystem;
330 const path = yield* Path.Path;
331 const root = yield* fs.makeTempDirectory({
332 prefix: "alchemy-external-incompatible-protocol-",
333 });
334
335 try {
336 yield* fs.writeFileString(
337 path.join(root, "package.json"),
338 JSON.stringify({
339 dependencies: {
340 sharp: "workspace:*",
341 "pg-native": "file:../pg-native",
342 },
343 }),
344 );
345
346 const workspaceError = yield* installPackages({
347 cwd: root,
348 install: ["sharp"],
349 architecture: "arm64",
350 runNpmInstall: () => Effect.void,
351 }).pipe(Effect.flip);
352 expect(workspaceError.message).toContain("workspace:*");
353
354 const fileError = yield* installPackages({
355 cwd: root,
356 install: ["pg-native"],
357 architecture: "arm64",
358 runNpmInstall: () => Effect.void,
359 }).pipe(Effect.flip);
360 expect(fileError.message).toContain("file:../pg-native");
361 } finally {
362 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
363 }
364 }).pipe(Effect.provide(NodeServices.layer)),
365 );
366
367 it.effect("installs into an isolated artifact and returns its files", () =>
368 Effect.gen(function* () {
369 const fs = yield* FileSystem.FileSystem;
370 const path = yield* Path.Path;
371 const root = yield* fs.makeTempDirectory({
372 prefix: "alchemy-external-source-",
373 });
374 let installDirectory: string | undefined;
375 const installArgs: Array<ReadonlyArray<string>> = [];
376 let artifactPackageJson: unknown;
377
378 try {
379 yield* fs.writeFileString(
380 path.join(root, "package.json"),
381 JSON.stringify({ dependencies: { sharp: "^0.34.5" } }),
382 );
383
384 const files = yield* installPackages({
385 cwd: root,
386 install: ["sharp"],
387 architecture: "arm64",
388 runNpmInstall: (directory, args) =>
389 Effect.gen(function* () {
390 installDirectory = directory;
391 installArgs.push(args);
392 const manifest = yield* fs.readFileString(
393 path.join(directory, "package.json"),
394 );
395 artifactPackageJson = yield* Effect.try(() =>
396 JSON.parse(manifest),
397 );
398
399 const sharpRoot = path.join(directory, "node_modules", "sharp");
400 const sharpBinRoot = path.join(sharpRoot, "bin");
401 const binRoot = path.join(directory, "node_modules", ".bin");
402 const binaryRoot = path.join(
403 directory,
404 "node_modules",
405 "@img",
406 "sharp-linux-arm64",
407 "lib",
408 );
409 const libvipsRoot = path.join(
410 directory,
411 "node_modules",
412 "@img",
413 "sharp-libvips-linux-arm64",
414 "lib",
415 );
416 yield* fs.makeDirectory(sharpRoot, { recursive: true });
417 yield* fs.makeDirectory(sharpBinRoot, { recursive: true });
418 yield* fs.makeDirectory(binRoot, { recursive: true });
419 yield* fs.makeDirectory(binaryRoot, { recursive: true });
420 yield* fs.makeDirectory(libvipsRoot, { recursive: true });
421 yield* fs.writeFileString(
422 path.join(sharpRoot, "package.json"),
423 JSON.stringify({ name: "sharp", version: "0.34.5" }),
424 );
425 const executablePath = path.join(sharpBinRoot, "sharp-tool");
426 yield* fs.writeFileString(executablePath, "#!/bin/sh\n");
427 yield* fs.chmod(executablePath, 0o755);
428 yield* fs.symlink(
429 "../sharp/bin/sharp-tool",
430 path.join(binRoot, "sharp-tool"),
431 );
432 yield* fs.writeFile(
433 path.join(binaryRoot, "sharp.node"),
434 new Uint8Array([0, 1, 2, 3]),
435 );
436 yield* fs.writeFile(
437 path.join(libvipsRoot, "libvips.so"),
438 new Uint8Array([4, 5, 6, 7]),
439 );
440 yield* fs.writeFileString(
441 path.join(directory, "package-lock.json"),
442 "{}",
443 );
444 }),
445 });
446
447 expect(artifactPackageJson).toEqual({
448 private: true,
449 dependencies: { sharp: "^0.34.5" },
450 });
451 // No lockfile in the fixture means nothing to pin, so the install is a
452 // single plain `npm install` instead of the lockfile-generate + ci pair.
453 expect(installArgs).toEqual([npmPlainInstallArgs("arm64")]);
454 expect(files.map((file) => file.path)).toEqual(
455 expect.arrayContaining([
456 "package.json",
457 "package-lock.json",
458 "node_modules/sharp/package.json",
459 "node_modules/sharp/bin/sharp-tool",
460 "node_modules/.bin/sharp-tool",
461 "node_modules/@img/sharp-linux-arm64/lib/sharp.node",
462 "node_modules/@img/sharp-libvips-linux-arm64/lib/libvips.so",
463 ]),
464 );
465 const executable = files.find(
466 (file) => file.path === "node_modules/sharp/bin/sharp-tool",
467 );
468 const symlink = files.find(
469 (file) => file.path === "node_modules/.bin/sharp-tool",
470 );
471 expect(executable?.mode && executable.mode & 0o111).toBe(0o111);
472 expect(symlink?.mode && symlink.mode & 0o170000).toBe(0o120000);
473 const archive = yield* zipCode(
474 "export const handler = () => {};",
475 files,
476 );
477 const zip = unzipSync(archive);
478 expect(
479 zip["node_modules/@img/sharp-linux-arm64/lib/sharp.node"],
480 ).toBeDefined();
481 expect(
482 zip["node_modules/@img/sharp-libvips-linux-arm64/lib/libvips.so"],
483 ).toBeDefined();
484 expect(strFromU8(zip["node_modules/.bin/sharp-tool"]!)).toBe(
485 "../sharp/bin/sharp-tool",
486 );
487 expect(installDirectory).toBeDefined();
488 expect(yield* fs.exists(installDirectory!)).toBe(false);
489 } finally {
490 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
491 }
492 }).pipe(Effect.provide(NodeServices.layer)),
493 );
494
495 it.effect("does not install package.json dependencies unless requested", () =>
496 Effect.gen(function* () {
497 const fs = yield* FileSystem.FileSystem;
498 const path = yield* Path.Path;
499 const root = yield* fs.makeTempDirectory({
500 prefix: "alchemy-external-unrequested-",
501 });
502 let installed = false;
503
504 try {
505 yield* fs.writeFileString(
506 path.join(root, "package.json"),
507 JSON.stringify({ dependencies: { "heic-convert": "^2.1.0" } }),
508 );
509
510 const files = yield* installPackages({
511 cwd: root,
512 architecture: "arm64",
513 runNpmInstall: () =>
514 Effect.sync(() => {
515 installed = true;
516 }),
517 });
518
519 expect(installed).toBe(false);
520 expect(files).toEqual([]);
521 } finally {
522 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
523 }
524 }).pipe(Effect.provide(NodeServices.layer)),
525 );
526
527 it.effect("returns an empty install identity when nothing is requested", () =>
528 Effect.gen(function* () {
529 const fs = yield* FileSystem.FileSystem;
530 const path = yield* Path.Path;
531 const root = yield* fs.makeTempDirectory({
532 prefix: "alchemy-external-empty-identity-",
533 });
534
535 try {
536 yield* fs.writeFileString(
537 path.join(root, "package.json"),
538 JSON.stringify({ dependencies: { sharp: "^0.34.5" } }),
539 );
540 yield* fs.writeFileString(
541 path.join(root, "package-lock.json"),
542 "sharp@0.34.5",
543 );
544
545 expect(
546 yield* resolvePackageInstallIdentity({
547 cwd: root,
548 requested: {},
549 }),
550 ).toEqual({ resolved: {}, overrides: {} });
551 } finally {
552 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
553 }
554 }).pipe(Effect.provide(NodeServices.layer)),
555 );
556
557 it.effect("fails when the source package.json is missing", () =>
558 Effect.gen(function* () {
559 const fs = yield* FileSystem.FileSystem;
560 const root = yield* fs.makeTempDirectory({
561 prefix: "alchemy-external-missing-manifest-",
562 });
563
564 try {
565 const error = yield* installPackages({
566 cwd: root,
567 install: ["sharp"],
568 architecture: "arm64",
569 runNpmInstall: () => Effect.void,
570 }).pipe(Effect.flip);
571 expect(error.message).toContain(
572 `Failed to read package.json for Lambda externals from '${root}'`,
573 );
574 } finally {
575 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
576 }
577 }).pipe(Effect.provide(NodeServices.layer)),
578 );
579
580 it.effect("fails when the nearest lockfile cannot be read", () =>
581 Effect.gen(function* () {
582 const fs = yield* FileSystem.FileSystem;
583 const path = yield* Path.Path;
584 const root = yield* fs.makeTempDirectory({
585 prefix: "alchemy-external-lockfile-read-error-",
586 });
587
588 try {
589 yield* fs.writeFileString(
590 path.join(root, "package.json"),
591 JSON.stringify({ dependencies: { sharp: "^0.34.5" } }),
592 );
593 yield* fs.makeDirectory(path.join(root, "package-lock.json"));
594
595 const error = yield* resolvePackageInstallIdentity({
596 cwd: root,
597 requested: { sharp: "*" },
598 }).pipe(Effect.flip);
599 expect(error.message).toContain(
600 `Failed to read package-manager lockfile for Lambda externals from '${root}'`,
601 );
602 } finally {
603 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
604 }
605 }).pipe(Effect.provide(NodeServices.layer)),
606 );
607
608 it.effect("resolves catalog versions from a top-level Bun catalog", () =>
609 Effect.gen(function* () {
610 const fs = yield* FileSystem.FileSystem;
611 const path = yield* Path.Path;
612 const root = yield* fs.makeTempDirectory({
613 prefix: "alchemy-external-bun-top-level-catalog-",
614 });
615
616 try {
617 yield* fs.writeFileString(
618 path.join(root, "package.json"),
619 JSON.stringify({
620 workspaces: ["packages/*"],
621 catalog: { sharp: "^0.33.5" },
622 dependencies: { sharp: "catalog:" },
623 }),
624 );
625
626 const files = yield* installPackages({
627 cwd: root,
628 install: ["sharp"],
629 architecture: "arm64",
630 runNpmInstall: (directory) =>
631 Effect.gen(function* () {
632 const packageJson = JSON.parse(
633 yield* fs.readFileString(path.join(directory, "package.json")),
634 );
635 expect(packageJson.dependencies.sharp).toBe("^0.33.5");
636 }),
637 });
638
639 expect(files.map((file) => file.path)).toContain("package.json");
640 } finally {
641 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
642 }
643 }).pipe(Effect.provide(NodeServices.layer)),
644 );
645
646 it.effect(
647 "resolves catalog versions from manifest catalogs with package-only workspaces",
648 () =>
649 Effect.gen(function* () {
650 const fs = yield* FileSystem.FileSystem;
651 const path = yield* Path.Path;
652 const root = yield* fs.makeTempDirectory({
653 prefix: "alchemy-external-bun-workspace-packages-only-",
654 });
655
656 try {
657 yield* fs.writeFileString(
658 path.join(root, "package.json"),
659 JSON.stringify({
660 workspaces: { packages: ["packages/*"] },
661 catalog: { sharp: "^0.34.5" },
662 dependencies: { sharp: "catalog:" },
663 }),
664 );
665
666 const files = yield* installPackages({
667 cwd: root,
668 install: ["sharp"],
669 architecture: "arm64",
670 runNpmInstall: (directory) =>
671 Effect.gen(function* () {
672 const packageJson = JSON.parse(
673 yield* fs.readFileString(
674 path.join(directory, "package.json"),
675 ),
676 );
677 expect(packageJson.dependencies.sharp).toBe("^0.34.5");
678 }),
679 });
680
681 expect(files.map((file) => file.path)).toContain("package.json");
682 } finally {
683 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
684 }
685 }).pipe(Effect.provide(NodeServices.layer)),
686 );
687
688 it.effect("fails when a catalog reference cannot be resolved", () =>
689 Effect.gen(function* () {
690 const fs = yield* FileSystem.FileSystem;
691 const path = yield* Path.Path;
692 const root = yield* fs.makeTempDirectory({
693 prefix: "alchemy-external-unresolved-catalog-",
694 });
695
696 try {
697 yield* fs.writeFileString(
698 path.join(root, "package.json"),
699 JSON.stringify({ dependencies: { sharp: "catalog:" } }),
700 );
701
702 const error = yield* resolveInstallTargets({
703 cwd: root,
704 requested: { sharp: "*" },
705 }).pipe(Effect.flip);
706 expect(error.message).toContain(
707 "Could not resolve catalog version for 'sharp'",
708 );
709 } finally {
710 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
711 }
712 }).pipe(Effect.provide(NodeServices.layer)),
713 );
714
715 it.effect("fails when a pnpm catalog entry is missing", () =>
716 Effect.gen(function* () {
717 const fs = yield* FileSystem.FileSystem;
718 const path = yield* Path.Path;
719 const root = yield* fs.makeTempDirectory({
720 prefix: "alchemy-external-missing-pnpm-catalog-entry-",
721 });
722
723 try {
724 yield* fs.writeFileString(
725 path.join(root, "pnpm-workspace.yaml"),
726 ["packages:", " - packages/*", "catalog:", " other: ^1.0.0"].join(
727 "\n",
728 ),
729 );
730 yield* fs.writeFileString(
731 path.join(root, "package.json"),
732 JSON.stringify({ dependencies: { sharp: "catalog:" } }),
733 );
734
735 const error = yield* resolveInstallTargets({
736 cwd: root,
737 requested: { sharp: "*" },
738 }).pipe(Effect.flip);
739 expect(error.message).toContain(
740 "Could not resolve catalog version for 'sharp' (catalog:)",
741 );
742 expect(error.message).toContain("pnpm-workspace.yaml");
743 } finally {
744 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
745 }
746 }).pipe(Effect.provide(NodeServices.layer)),
747 );
748
749 it.effect("omits lockfile fingerprints when no lockfile exists", () =>
750 Effect.gen(function* () {
751 const fs = yield* FileSystem.FileSystem;
752 const path = yield* Path.Path;
753 const root = yield* fs.makeTempDirectory({
754 prefix: "alchemy-external-no-lockfile-",
755 });
756
757 try {
758 yield* fs.writeFileString(
759 path.join(root, "package.json"),
760 JSON.stringify({ dependencies: { sharp: "^0.34.5" } }),
761 );
762
763 expect(
764 yield* resolvePackageInstallIdentity({
765 cwd: root,
766 requested: { sharp: "*" },
767 }),
768 ).toEqual({
769 resolved: { sharp: "^0.34.5" },
770 overrides: {},
771 lockfile: undefined,
772 });
773 } finally {
774 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
775 }
776 }).pipe(Effect.provide(NodeServices.layer)),
777 );
778
779 it.effect("fails when a Bun workspace catalog entry is missing", () =>
780 Effect.gen(function* () {
781 const fs = yield* FileSystem.FileSystem;
782 const path = yield* Path.Path;
783 const root = yield* fs.makeTempDirectory({
784 prefix: "alchemy-external-missing-bun-catalog-entry-",
785 });
786
787 try {
788 yield* fs.writeFileString(
789 path.join(root, "package.json"),
790 JSON.stringify({
791 workspaces: {
792 packages: ["packages/*"],
793 catalogs: {
794 native: {
795 other: "^1.0.0",
796 },
797 },
798 },
799 dependencies: { sharp: "catalog:native" },
800 }),
801 );
802
803 const error = yield* resolveInstallTargets({
804 cwd: root,
805 requested: { sharp: "*" },
806 }).pipe(Effect.flip);
807 expect(error.message).toContain(
808 "Could not resolve catalog version for 'sharp' (catalog:native)",
809 );
810 expect(error.message).toContain("package.json");
811 } finally {
812 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
813 }
814 }).pipe(Effect.provide(NodeServices.layer)),
815 );
816
817 describe.sequential("npm install failures", () => {
818 it.effect(
819 "fails when npm is missing from PATH",
820 () =>
821 Effect.gen(function* () {
822 const fs = yield* FileSystem.FileSystem;
823 const path = yield* Path.Path;
824 const root = yield* fs.makeTempDirectory({
825 prefix: "alchemy-external-missing-npm-",
826 });
827 const originalPath = process.env.PATH;
828
829 try {
830 process.env.PATH = "";
831 yield* fs.writeFileString(
832 path.join(root, "package.json"),
833 JSON.stringify({ dependencies: { sharp: "^0.34.5" } }),
834 );
835
836 const error = yield* installResolvedPackages({
837 resolved: { sharp: "^0.34.5" },
838 architecture: "arm64",
839 }).pipe(Effect.flip);
840 expect(error.message).toContain(
841 "Failed to run 'npm install' for build.install:",
842 );
843 expect(error.message).toMatch(/NotFound|ENOENT/);
844 } finally {
845 process.env.PATH = originalPath;
846 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
847 }
848 }).pipe(Effect.provide(NodeServices.layer)),
849 // Blanking process.env.PATH is process-global: every concurrently
850 // running test that spawns a child process would fail with ENOENT.
851 // `exclusive` takes the runner's whole-process write lock.
852 { exclusive: true },
853 );
854
855 it.effect(
856 "fails when npm install exits non-zero",
857 () =>
858 Effect.gen(function* () {
859 const error = yield* installResolvedPackages({
860 resolved: {
861 "alchemy-nonexistent-external-package-xyz": "1.0.0",
862 },
863 architecture: "arm64",
864 }).pipe(Effect.flip);
865 expect(error.message).toMatch(
866 /npm install for build\.install failed with exit code \d+:/,
867 );
868 }).pipe(Effect.provide(NodeServices.layer)),
869 { timeout: 120_000 },
870 );
871 });
872
873 // `chmod 0o000` cannot make a directory unreadable on Windows, so the
874 // failure under test is unreproducible there.
875 it.effect.skipIf(process.platform === "win32")(
876 "fails when installed package files cannot be read",
877 () =>
878 Effect.gen(function* () {
879 const fs = yield* FileSystem.FileSystem;
880 const path = yield* Path.Path;
881 const root = yield* fs.makeTempDirectory({
882 prefix: "alchemy-external-artifact-read-error-",
883 });
884
885 try {
886 yield* fs.writeFileString(
887 path.join(root, "package.json"),
888 JSON.stringify({ dependencies: { sharp: "^0.34.5" } }),
889 );
890
891 const error = yield* installPackages({
892 cwd: root,
893 install: ["sharp"],
894 architecture: "arm64",
895 runNpmInstall: (directory, args) =>
896 Effect.gen(function* () {
897 if (
898 args[0] === "install" &&
899 args.includes("--package-lock-only")
900 )
901 return;
902 yield* fs.writeFileString(
903 path.join(directory, "package.json"),
904 JSON.stringify({
905 private: true,
906 dependencies: { sharp: "^0.34.5" },
907 }),
908 );
909 yield* fs.writeFileString(
910 path.join(directory, "package-lock.json"),
911 "{}",
912 );
913 yield* fs.chmod(directory, 0o000);
914 }),
915 }).pipe(Effect.flip);
916 expect(error.message).toBe(
917 "Failed to read installed Lambda external packages",
918 );
919 } finally {
920 yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore);
921 }
922 }).pipe(Effect.provide(NodeServices.layer)),
923 );
924
925 it.effect(
926 "pins requested packages to the versions in supported lockfiles",
927 () =>
928 Effect.gen(function* () {
929 const fixtures = [
930 {
931 name: "package-lock.json",
932 content: JSON.stringify({
933 name: "fixture",
934 lockfileVersion: 3,
935 packages: {
936 "": { dependencies: { sharp: "^0.34.0" } },
937 "node_modules/sharp": {
938 version: "0.34.5",
939 dependencies: { semver: "^7.0.0" },
940 },
941 "node_modules/semver": { version: "7.7.3" },
942 },
943 }),
944 },
945 {
946 name: "package-lock.json",
947 content: JSON.stringify({
948 lockfileVersion: 1,
949 dependencies: {
950 sharp: {
951 version: "0.34.5",
952 requires: { semver: "^7.0.0" },
953 },
954 semver: { version: "7.7.3" },
955 },
956 }),
957 },
958 {
959 name: "pnpm-lock.yaml",
960 content: [
961 "lockfileVersion: '9.0'",
962 "importers:",
963 " .:",
964 " dependencies:",
965 " sharp:",
966 " specifier: ^0.34.0",
967 " version: 0.34.5",
968 "snapshots:",
969 " sharp@0.34.5:",
970 " dependencies:",
971 " semver: 7.7.3",
972 " semver@7.7.3: {}",
973 ].join("\n"),
974 },
975 {
976 name: "bun.lock",
977 content: [
978 "{",
979 " // Bun lockfiles use JSONC.",
980 ' "lockfileVersion": 1,',
981 ' "workspaces": {',
982 ' "": { "dependencies": { "sharp": "^0.34.0" } },',
983 " },",
984 ' "packages": {',
985 ' "sharp": ["sharp@0.34.5", "", { "dependencies": { "semver": "^7.0.0" } }],',
986 ' "semver": ["semver@7.7.3", "", {}],',
987 " },",
988 "}",
989 ].join("\n"),
990 },
991 {
992 name: "yarn.lock",
993 content: [
994 "# yarn lockfile v1",
995 "",
996 "sharp@^0.34.0:",
997 ' version "0.34.5"',
998 ' resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.34.5.tgz"',
999 " dependencies:",
1000 ' semver "^7.0.0"',
1001 "semver@^7.0.0:",
1002 ' version "7.7.3"',
1003 ].join("\n"),
1004 },
1005 {
1006 name: "yarn.lock",
1007 content: [
1008 '"sharp@npm:^0.34.0":',
1009 " version: 0.34.5",
1010 ' resolution: "sharp@npm:0.34.5"',
1011 " dependencies:",
1012 ' semver: "npm:^7.0.0"',
1013 '"semver@npm:^7.0.0":',
1014 " version: 7.7.3",
1015 ' resolution: "semver@npm:7.7.3"',
1016 ].join("\n"),
1017 },
1018 {
1019 // v1 with quoted multi-selector entry keys and quoted pair keys.
1020 name: "yarn.lock",
1021 content: [
1022 "# yarn lockfile v1",
1023 "",
1024 '"sharp@^0.34.0", "sharp@~0.34.5":',
1025 ' version "0.34.5"',
1026 ' resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.34.5.tgz#abc123"',
1027 " dependencies:",
1028 ' "semver" "^7.0.0"',
1029 '"semver@^7.0.0", semver@^7.5.0:',
1030 ' version "7.7.3"',
1031 ].join("\n"),
1032 },
1033 ] as const;
1034
1035 for (const fixture of fixtures) {
1036 yield* withLockfileFixture(
1037 {
1038 prefix: `alchemy-external-pinned-${fixture.name}-`,
1039 packageJson: { dependencies: { sharp: "^0.34.0" } },
1040 lockfileName: fixture.name,
1041 lockfileContent: fixture.content,
1042 },
1043 ({ cwd }) =>
1044 Effect.gen(function* () {
1045 const identity = yield* resolvePackageInstallIdentity({
1046 cwd,
1047 requested: { sharp: "*" },
1048 });
1049 expect(identity.resolved, fixture.name).toEqual({
1050 sharp: "0.34.5",
1051 });
1052 expect(identity.overrides, fixture.name).toEqual({
1053 "sharp@0.34.5": { semver: "7.7.3" },
1054 });
1055 }),
1056 );
1057 }
1058 }).pipe(Effect.provide(NodeServices.layer)),
1059 );
1060
1061 it.effect(
1062 "does not replace an explicit install version with a different lock entry",
1063 () =>
1064 withLockfileFixture(
1065 {
1066 prefix: "alchemy-external-explicit-version-",
1067 packageJson: { dependencies: { sharp: "^0.34.0" } },
1068 lockfileName: "package-lock.json",
1069 lockfileContent: JSON.stringify({
1070 name: "fixture",
1071 lockfileVersion: 3,
1072 packages: {
1073 "": { dependencies: { sharp: "^0.34.0" } },
1074 "node_modules/sharp": { version: "0.34.5" },
1075 },
1076 }),
1077 },
1078 ({ cwd }) =>
1079 Effect.gen(function* () {
1080 expect(
1081 yield* resolveInstallTargets({
1082 cwd,
1083 requested: { sharp: "0.33.5" },
1084 }),
1085 ).toEqual({ sharp: "0.33.5" });
1086 }),
1087 ).pipe(Effect.provide(NodeServices.layer)),
1088 );
1089
1090 it.effect(
1091 "pins aliases and transitive dependency edges from package-lock.json",
1092 () =>
1093 withLockfileFixture(
1094 {
1095 prefix: "alchemy-external-package-lock-plan-",
1096 packageJson: {
1097 dependencies: {
1098 alias: "npm:real@^1.0.0",
1099 parent: "^1.0.0",
1100 },
1101 },
1102 lockfileName: "package-lock.json",
1103 lockfileContent: JSON.stringify({
1104 lockfileVersion: 3,
1105 packages: {
1106 "": {
1107 dependencies: {
1108 alias: "npm:real@^1.0.0",
1109 parent: "^1.0.0",
1110 },
1111 },
1112 "node_modules/alias": {
1113 name: "real",
1114 version: "1.2.3",
1115 dependencies: { leaf: "^2.0.0" },
1116 },
1117 "node_modules/parent": {
1118 version: "1.0.0",
1119 dependencies: { leaf: "^2.0.0" },
1120 },
1121 "node_modules/leaf": { version: "2.4.0" },
1122 },
1123 }),
1124 },
1125 ({ cwd }) =>
1126 Effect.gen(function* () {
1127 const identity = yield* resolvePackageInstallIdentity({
1128 cwd,
1129 requested: { alias: "*", parent: "*" },
1130 });
1131 expect(identity.resolved).toEqual({
1132 alias: "npm:real@1.2.3",
1133 parent: "1.0.0",
1134 });
1135 expect(identity.overrides).toEqual({
1136 "real@1.2.3": { leaf: "2.4.0" },
1137 "parent@1.0.0": { leaf: "2.4.0" },
1138 });
1139 }),
1140 ).pipe(Effect.provide(NodeServices.layer)),
1141 );
1142
1143 it.effect("rejects a stale Bun importer entry", () =>
1144 withLockfileFixture(
1145 {
1146 prefix: "alchemy-external-stale-bun-lock-",
1147 packageJson: { dependencies: { sharp: "^0.35.0" } },
1148 lockfileName: "bun.lock",
1149 lockfileContent: JSON.stringify({
1150 lockfileVersion: 1,
1151 workspaces: {
1152 "": { dependencies: { sharp: "^0.34.0" } },
1153 },
1154 packages: { sharp: ["sharp@0.34.5", "", {}] },
1155 }),
1156 },
1157 ({ cwd }) =>
1158 Effect.gen(function* () {
1159 const error = yield* resolveInstallTargets({
1160 cwd,
1161 requested: { sharp: "*" },
1162 }).pipe(Effect.flip);
1163 expect(error.message).toContain("Could not resolve a locked version");
1164 }),
1165 ).pipe(Effect.provide(NodeServices.layer)),
1166 );
1167
1168 it.effect("resolves Bun packages for the current workspace importer", () =>
1169 withLockfileFixture(
1170 {
1171 prefix: "alchemy-external-bun-importer-",
1172 workspace: "packages/app",
1173 packageJson: { dependencies: { sharp: "^0.35.0" } },
1174 lockfileName: "bun.lock",
1175 lockfileContent: JSON.stringify({
1176 lockfileVersion: 1,
1177 workspaces: {
1178 "packages/app": {
1179 name: "app",
1180 dependencies: { sharp: "^0.35.0" },
1181 },
1182 },
1183 packages: {
1184 sharp: ["sharp@0.34.5", "", {}],
1185 "app/sharp": ["sharp@0.35.1", "", {}],
1186 },
1187 }),
1188 },
1189 ({ cwd }) =>
1190 Effect.gen(function* () {
1191 expect(
1192 yield* resolveInstallTargets({
1193 cwd,
1194 requested: { sharp: "*" },
1195 }),
1196 ).toEqual({ sharp: "0.35.1" });
1197 }),
1198 ).pipe(Effect.provide(NodeServices.layer)),
1199 );
1200
1201 it.effect("preserves context-specific Bun dependency resolutions", () =>
1202 withLockfileFixture(
1203 {
1204 prefix: "alchemy-external-bun-context-",
1205 packageJson: { dependencies: { root: "^1.0.0" } },
1206 lockfileName: "bun.lock",
1207 lockfileContent: JSON.stringify({
1208 lockfileVersion: 1,
1209 workspaces: {
1210 "": { dependencies: { root: "^1.0.0" } },
1211 },
1212 packages: {
1213 root: [
1214 "root@1.0.0",
1215 "",
1216 { dependencies: { left: "^1", right: "^1" } },
1217 ],
1218 "root/left": ["left@1.0.0", "", { dependencies: { shared: "^1" } }],
1219 "root/right": [
1220 "right@1.0.0",
1221 "",
1222 { dependencies: { shared: "^1" } },
1223 ],
1224 "root/left/shared": [
1225 "shared@1.0.0",
1226 "",
1227 { dependencies: { leaf: "^1" } },
1228 ],
1229 "root/right/shared": [
1230 "shared@1.0.0",
1231 "",
1232 { dependencies: { leaf: "^1" } },
1233 ],
1234 "root/left/shared/leaf": ["leaf@1.0.0", "", {}],
1235 "root/right/shared/leaf": ["leaf@2.0.0", "", {}],
1236 },
1237 }),
1238 },
1239 ({ cwd }) =>
1240 Effect.gen(function* () {
1241 const identity = yield* resolvePackageInstallIdentity({
1242 cwd,
1243 requested: { root: "*" },
1244 });
1245 // `shared` resolves uniquely under the root so one flat rule covers
1246 // it; only the divergent `leaf` needs per-branch nesting.
1247 expect(identity.overrides).toEqual({
1248 "root@1.0.0": {
1249 left: { ".": "1.0.0", leaf: "1.0.0" },
1250 right: { ".": "1.0.0", leaf: "2.0.0" },
1251 shared: "1.0.0",
1252 },
1253 });
1254 }),
1255 ).pipe(Effect.provide(NodeServices.layer)),
1256 );
1257
1258 it.effect(
1259 "keeps overrides flat and compact for diamond-shaped graphs",
1260 () => {
1261 // 16 levels of diamonds = 2^16 distinct paths. A path-enumerating walk
1262 // would take minutes and emit megabytes; the deduplicated walk must emit
1263 // one flat rule per package.
1264 const levels = 16;
1265 const packages: Record<string, unknown> = {
1266 "": { dependencies: { root: "^1.0.0" } },
1267 "node_modules/root": {
1268 version: "1.0.0",
1269 dependencies: { a1: "^1.0.0", b1: "^1.0.0" },
1270 },
1271 };
1272 for (let i = 1; i <= levels; i++) {
1273 const dependencies =
1274 i === levels
1275 ? {}
1276 : { [`a${i + 1}`]: "^1.0.0", [`b${i + 1}`]: "^1.0.0" };
1277 packages[`node_modules/a${i}`] = { version: "1.0.0", dependencies };
1278 packages[`node_modules/b${i}`] = { version: "1.0.0", dependencies };
1279 }
1280 return withLockfileFixture(
1281 {
1282 prefix: "alchemy-external-diamond-",
1283 packageJson: { dependencies: { root: "^1.0.0" } },
1284 lockfileName: "package-lock.json",
1285 lockfileContent: JSON.stringify({ lockfileVersion: 3, packages }),
1286 },
1287 ({ cwd }) =>
1288 Effect.gen(function* () {
1289 const identity = yield* resolvePackageInstallIdentity({
1290 cwd,
1291 requested: { root: "*" },
1292 });
1293 const rootOverrides = identity.overrides["root@1.0.0"];
1294 expect(typeof rootOverrides).toBe("object");
1295 expect(Object.keys(rootOverrides as object)).toHaveLength(
1296 levels * 2,
1297 );
1298 expect(
1299 Object.values(rootOverrides as Record<string, unknown>).every(
1300 (spec) => spec === "1.0.0",
1301 ),
1302 ).toBe(true);
1303 }),
1304 ).pipe(Effect.provide(NodeServices.layer));
1305 },
1306 );
1307
1308 it.effect("disambiguates pnpm peer-variant resolutions without failing", () =>
1309 withLockfileFixture(
1310 {
1311 prefix: "alchemy-external-pnpm-peer-variants-",
1312 packageJson: { dependencies: { root: "^1.0.0" } },
1313 lockfileName: "pnpm-lock.yaml",
1314 lockfileContent: [
1315 "lockfileVersion: '9.0'",
1316 "importers:",
1317 " .:",
1318 " dependencies:",
1319 " root:",
1320 " specifier: ^1.0.0",
1321 " version: 1.0.0",
1322 "snapshots:",
1323 " root@1.0.0:",
1324 " dependencies:",
1325 " m1: 1.0.0",
1326 " m2: 1.0.0",
1327 " m1@1.0.0:",
1328 " dependencies:",
1329 " p: 1.0.0(x@1.0.0)",
1330 " m2@1.0.0:",
1331 " dependencies:",
1332 " p: 1.0.0(x@2.0.0)",
1333 " p@1.0.0(x@1.0.0):",
1334 " dependencies:",
1335 " x: 1.0.0",
1336 " p@1.0.0(x@2.0.0):",
1337 " dependencies:",
1338 " x: 2.0.0",
1339 " x@1.0.0: {}",
1340 " x@2.0.0: {}",
1341 ].join("\n"),
1342 },
1343 ({ cwd }) =>
1344 Effect.gen(function* () {
1345 const identity = yield* resolvePackageInstallIdentity({
1346 cwd,
1347 requested: { root: "*" },
1348 });
1349 expect(identity.overrides).toEqual({
1350 "root@1.0.0": {
1351 m1: { ".": "1.0.0", x: "1.0.0" },
1352 m2: { ".": "1.0.0", x: "2.0.0" },
1353 p: "1.0.0",
1354 },
1355 });
1356 }),
1357 ).pipe(Effect.provide(NodeServices.layer)),
1358 );
1359
1360 it.effect(
1361 "parses bun.lock JSONC with comments, trailing commas, and tricky strings",
1362 () =>
1363 withLockfileFixture(
1364 {
1365 prefix: "alchemy-external-bun-jsonc-",
1366 packageJson: { dependencies: { weird: "^1.0.0" } },
1367 lockfileName: "bun.lock",
1368 lockfileContent: [
1369 "{",
1370 " /* block",
1371 " comment */",
1372 ' "lockfileVersion": 1,',
1373 ' "workspaces": {',
1374 ' "": { "dependencies": { "weird": "^1.0.0" } }, // line comment',
1375 " },",
1376 ' "packages": {',
1377 ' "weird": ["weird@1.2.3", "", { "bin": { "weird,]": "cli,}.js" } }],',
1378 " },",
1379 "}",
1380 ].join("\n"),
1381 },
1382 ({ cwd }) =>
1383 Effect.gen(function* () {
1384 expect(
1385 yield* resolveInstallTargets({
1386 cwd,
1387 requested: { weird: "*" },
1388 }),
1389 ).toEqual({ weird: "1.2.3" });
1390 }),
1391 ).pipe(Effect.provide(NodeServices.layer)),
1392 );
1393
1394 it.effect(
1395 "includes package-manager lockfiles in the external package identity",
1396 () =>
1397 Effect.gen(function* () {
1398 const lockfileContent = (
1399 lockfileName:
1400 | "bun.lock"
1401 | "package-lock.json"
1402 | "pnpm-lock.yaml"
1403 | "yarn.lock",
1404 version: string,
1405 ) => {
1406 switch (lockfileName) {
1407 case "bun.lock":
1408 return JSON.stringify({
1409 lockfileVersion: 1,
1410 workspaces: {
1411 "": { dependencies: { sharp: "^0.34.5" } },
1412 },
1413 packages: { sharp: [`sharp@${version}`, "", {}] },
1414 });
1415 case "package-lock.json":
1416 return JSON.stringify({
1417 lockfileVersion: 3,
1418 packages: {
1419 "": { dependencies: { sharp: "^0.34.5" } },
1420 "node_modules/sharp": { version },
1421 },
1422 });
1423 case "pnpm-lock.yaml":
1424 return [
1425 "lockfileVersion: '9.0'",
1426 "importers:",
1427 " .:",
1428 " dependencies:",
1429 " sharp:",
1430 " specifier: ^0.34.5",
1431 ` version: ${version}`,
1432 ].join("\n");
1433 case "yarn.lock":
1434 return [
1435 "# yarn lockfile v1",
1436 "",
1437 `sharp@^0.34.5:`,
1438 ` version "${version}"`,
1439 ].join("\n");
1440 }
1441 };
1442
1443 for (const lockfileName of [
1444 "bun.lock",
1445 "package-lock.json",
1446 "pnpm-lock.yaml",
1447 "yarn.lock",
1448 ] as const) {
1449 yield* withLockfileFixture(
1450 {
1451 prefix: "alchemy-external-lockfile-",
1452 packageJson: { dependencies: { sharp: "^0.34.5" } },
1453 lockfileName,
1454 lockfileContent: lockfileContent(lockfileName, "0.34.5"),
1455 },
1456 ({ cwd, fs, path }) =>
1457 Effect.gen(function* () {
1458 const first = yield* resolvePackageInstallIdentity({
1459 cwd,
1460 requested: { sharp: "*" },
1461 });
1462 const firstHash = yield* hashPackageInstallIdentity({
1463 bundleHash: "bundle",
1464 identity: first,
1465 architecture: "arm64",
1466 });
1467
1468 yield* fs.writeFileString(
1469 path.join(cwd, lockfileName),
1470 lockfileContent(lockfileName, "0.34.6"),
1471 );
1472
1473 const second = yield* resolvePackageInstallIdentity({
1474 cwd,
1475 requested: { sharp: "*" },
1476 });
1477 const secondHash = yield* hashPackageInstallIdentity({
1478 bundleHash: "bundle",
1479 identity: second,
1480 architecture: "arm64",
1481 });
1482
1483 expect(first.resolved).not.toEqual(second.resolved);
1484 expect(first.lockfile?.name).toBe(lockfileName);
1485 expect(second.lockfile?.name).toBe(lockfileName);
1486 expect(first.lockfile?.hash).not.toBe(second.lockfile?.hash);
1487 expect(firstHash).not.toBe(secondHash);
1488 }),
1489 );
1490 }
1491 }).pipe(Effect.provide(NodeServices.layer)),
1492 );
1493
1494 it.effect("does not fall back to node_modules for an invalid bun.lockb", () =>
1495 withLockfileFixture(
1496 {
1497 prefix: "alchemy-external-invalid-lockb-",
1498 packageJson: { dependencies: { sharp: "^0.34.5" } },
1499 lockfileName: "bun.lockb",
1500 lockfileContent: "invalid",
1501 },
1502 ({ cwd, fs, path }) =>
1503 Effect.gen(function* () {
1504 const installedPackage = path.join(cwd, "node_modules", "sharp");
1505 yield* fs.makeDirectory(installedPackage, { recursive: true });
1506 yield* fs.writeFileString(
1507 path.join(installedPackage, "package.json"),
1508 JSON.stringify({ name: "sharp", version: "999.0.0" }),
1509 );
1510
1511 const error = yield* resolveInstallTargets({
1512 cwd,
1513 requested: { sharp: "*" },
1514 }).pipe(Effect.flip);
1515 expect(error.message).toContain(
1516 "Failed to inspect legacy Bun lockfile",
1517 );
1518 }),
1519 ).pipe(Effect.provide(NodeServices.layer)),
1520 );
1521});
1522
1523describe.skipIf(!integrationEnabled)(
1524 "Lambda external packages integration",
1525 { tags: ["live"] },
1526 () => {
1527 it.effect(
1528 "npm-installs aliases with pinned transitive dependencies",
1529 () =>
1530 Effect.gen(function* () {
1531 const files = yield* installResolvedPackages({
1532 resolved: { odd: "npm:is-odd@3.0.1" },
1533 overrides: {
1534 "is-odd@3.0.1": { "is-number": "6.0.0" },
1535 },
1536 architecture: "arm64",
1537 });
1538 const aliasManifest = files.find(
1539 (file) => file.path === "node_modules/odd/package.json",
1540 );
1541 const transitiveManifest = files.find(
1542 (file) =>
1543 file.path === "node_modules/is-number/package.json" ||
1544 file.path ===
1545 "node_modules/odd/node_modules/is-number/package.json",
1546 );
1547 expect(aliasManifest).toBeDefined();
1548 expect(transitiveManifest).toBeDefined();
1549 const decoder = new TextDecoder();
1550 const alias = yield* Effect.try(() =>
1551 JSON.parse(decoder.decode(aliasManifest?.content)),
1552 );
1553 const transitive = yield* Effect.try(() =>
1554 JSON.parse(decoder.decode(transitiveManifest?.content)),
1555 );
1556 expect(alias.name).toBe("is-odd");
1557 expect(alias.version).toBe("3.0.1");
1558 expect(transitive.version).toBe("6.0.0");
1559 }).pipe(Effect.provide(NodeServices.layer)),
1560 { timeout: 120_000 },
1561 );
1562
1563 it.effect(
1564 "npm-installs sharp with linux arm64 native binaries",
1565 () =>
1566 withLockfileFixture(
1567 {
1568 prefix: "alchemy-external-sharp-",
1569 packageJson: { dependencies: { sharp: "^0.33.5" } },
1570 lockfileName: "package-lock.json",
1571 lockfileContent: JSON.stringify({
1572 lockfileVersion: 3,
1573 packages: {
1574 "": { dependencies: { sharp: "^0.33.5" } },
1575 "node_modules/sharp": {
1576 version: "0.33.5",
1577 dependencies: { semver: "^7.5.4" },
1578 },
1579 "node_modules/semver": { version: "7.7.3" },
1580 },
1581 }),
1582 },
1583 ({ cwd }) =>
1584 Effect.gen(function* () {
1585 const files = yield* installPackages({
1586 cwd,
1587 install: ["sharp"],
1588 architecture: "arm64",
1589 });
1590
1591 const paths = files.map((file) => file.path);
1592 expect(paths).toContain("node_modules/sharp/package.json");
1593 expect(
1594 paths.some((filePath) =>
1595 filePath.includes(
1596 "node_modules/@img/sharp-linux-arm64/lib/sharp-linux-arm64.node",
1597 ),
1598 ),
1599 ).toBe(true);
1600 expect(
1601 paths.some((filePath) =>
1602 filePath.includes(
1603 "node_modules/@img/sharp-libvips-linux-arm64/lib/libvips",
1604 ),
1605 ),
1606 ).toBe(true);
1607 }),
1608 ).pipe(Effect.provide(NodeServices.layer)),
1609 { timeout: 120_000 },
1610 );
1611 },
1612);