1import { imageSourceKind, validateImageSource } from "@/AWS/ECR/ImageSource.ts";
2import {
3 buildFinalDockerfile,
4 containerEnvPreamble,
5 validateContainerImageProps,
6} from "@/Cloudflare/Containers/ContainerBundle.ts";
7import * as Dockerfile from "@/Docker/Dockerfile.ts";
8import * as Output from "@/Output.ts";
9import { describe, expect, it, test } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import * as Result from "effect/Result";
12
13describe(
14 "Dockerfile.inline",
15 { tags: ["unit", "provider:docker", "provider:docker:dockerfile", "local"] },
16 () => {
17 test("no interpolations produce plain string content", () => {
18 const df = Dockerfile.inline`FROM oven/bun:1
19RUN apt-get install -y ffmpeg`;
20 expect(Dockerfile.isInlineDockerfile(df)).toBe(true);
21 expect(typeof df.content).toBe("string");
22 expect(df.content).toContain("FROM oven/bun:1");
23 expect(df.content).toContain("RUN apt-get install -y ffmpeg");
24 });
25
26 test("content is deterministic across constructions", () => {
27 const a = Dockerfile.inline`FROM oven/bun:1`;
28 const b = Dockerfile.inline`FROM oven/bun:1`;
29 expect(a.content).toBe(b.content);
30 });
31
32 test("interpolations produce Output content (dependency edge)", () => {
33 const df = Dockerfile.inline`FROM ${Output.literal("oven/bun:1")}
34RUN echo hi`;
35 expect(Dockerfile.isInlineDockerfile(df)).toBe(true);
36 expect(Output.isOutput(df.content)).toBe(true);
37 });
38
39 test("isInlineDockerfile rejects path strings", () => {
40 expect(Dockerfile.isInlineDockerfile("./Dockerfile")).toBe(false);
41 expect(Dockerfile.isInlineDockerfile(undefined)).toBe(false);
42 expect(Dockerfile.isInlineDockerfile(null)).toBe(false);
43 });
44 },
45);
46
47describe(
48 "AWS ImageSource composition",
49 {
50 tags: [
51 "unit",
52 "provider:aws",
53 "provider:aws:ecr",
54 "provider:docker",
55 "provider:docker:dockerfile",
56 "local",
57 ],
58 },
59 () => {
60 test("main wins as the source kind; other fields describe its environment", () => {
61 expect(imageSourceKind({ main: "./index.ts" })).toBe("main");
62 expect(imageSourceKind({ main: "./index.ts", image: "oven/bun:1" })).toBe(
63 "main",
64 );
65 expect(imageSourceKind({ image: "busybox:stable" })).toBe("image");
66 expect(imageSourceKind({ context: "./app" })).toBe("context");
67 expect(
68 imageSourceKind({ dockerfile: Dockerfile.inline`FROM alpine` }),
69 ).toBe("context");
70 expect(imageSourceKind({})).toBeUndefined();
71 });
72
73 it.effect("validateImageSource dies on exclusivity violations", () =>
74 Effect.gen(function* () {
75 const dies = (source: Parameters<typeof validateImageSource>[1]) =>
76 Effect.gen(function* () {
77 const result = yield* Effect.result(
78 validateImageSource("T", source).pipe(
79 Effect.catchDefect((defect) => Effect.fail(defect as Error)),
80 ),
81 );
82 return Result.isFailure(result);
83 });
84
85 expect(
86 yield* dies({ image: "busybox", dockerfile: "./Dockerfile" }),
87 ).toBe(true);
88 expect(yield* dies({ image: "busybox", context: "./app" })).toBe(true);
89 expect(
90 yield* dies({
91 dockerfile: Dockerfile.inline`FROM alpine`,
92 context: "./app",
93 }),
94 ).toBe(true);
95 // Valid combinations pass.
96 expect(yield* dies({ main: "./i.ts", image: "oven/bun:1" })).toBe(
97 false,
98 );
99 expect(
100 yield* dies({
101 main: "./i.ts",
102 dockerfile: Dockerfile.inline`FROM x`,
103 }),
104 ).toBe(false);
105 expect(
106 yield* dies({ context: "./app", dockerfile: "./app/Dockerfile" }),
107 ).toBe(false);
108 }),
109 );
110 },
111);
112
113/**
114 * Run an Effect that may die and report whether it did — validation
115 * helpers surface invalid props as defects (`Effect.die`), never typed
116 * errors or raw throws.
117 */
118const dies = <A>(effect: Effect.Effect<A>) =>
119 Effect.gen(function* () {
120 const result = yield* Effect.result(
121 effect.pipe(Effect.catchDefect((defect) => Effect.fail(defect as Error))),
122 );
123 return Result.isFailure(result);
124 });
125
126describe(
127 "Cloudflare container environment composition",
128 {
129 tags: [
130 "unit",
131 "provider:cloudflare",
132 "provider:cloudflare:container",
133 "provider:docker",
134 "provider:docker:dockerfile",
135 "local",
136 ],
137 },
138 () => {
139 it.effect("containerEnvPreamble: image ref becomes FROM line", () =>
140 Effect.gen(function* () {
141 expect(yield* containerEnvPreamble({ image: "oven/bun:1" })).toBe(
142 "FROM oven/bun:1",
143 );
144 expect(yield* containerEnvPreamble({})).toBeUndefined();
145 }),
146 );
147
148 it.effect("containerEnvPreamble: inline content used verbatim", () =>
149 Effect.gen(function* () {
150 const preamble = yield* containerEnvPreamble({
151 dockerfile: Dockerfile.inline`FROM oven/bun:1
152RUN apt-get install -y ffmpeg`,
153 });
154 expect(preamble).toContain("RUN apt-get install -y ffmpeg");
155 }),
156 );
157
158 it.effect(
159 "containerEnvPreamble: dies on Dockerfile content passed as image",
160 () =>
161 Effect.gen(function* () {
162 expect(
163 yield* dies(
164 containerEnvPreamble({ image: "FROM oven/bun:1\nRUN echo hi" }),
165 ),
166 ).toBe(true);
167 }),
168 );
169
170 test("buildFinalDockerfile layers the bundle on top of the preamble", () => {
171 const dockerfile = buildFinalDockerfile(
172 "FROM oven/bun:1\nRUN apt-get install -y ffmpeg",
173 "bun",
174 );
175 const lines = dockerfile.split("\n");
176 expect(lines[0]).toBe("FROM oven/bun:1");
177 expect(lines[1]).toBe("RUN apt-get install -y ffmpeg");
178 expect(dockerfile).toContain("COPY index.mjs /app/index.mjs");
179 expect(dockerfile).toContain('ENTRYPOINT ["bun", "/app/index.mjs"]');
180 });
181
182 test("buildFinalDockerfile falls back to the runtime default base", () => {
183 expect(buildFinalDockerfile(undefined, "bun").split("\n")[0]).toBe(
184 "FROM oven/bun:1",
185 );
186 expect(buildFinalDockerfile(undefined, "node").split("\n")[0]).toBe(
187 "FROM node:22-slim",
188 );
189 });
190
191 it.effect("validateContainerImageProps enforces exclusivity", () =>
192 Effect.gen(function* () {
193 // main + image and main + inline dockerfile are the two environments.
194 expect(
195 yield* dies(
196 validateContainerImageProps({
197 main: "./i.ts",
198 image: "oven/bun:1",
199 }),
200 ),
201 ).toBe(false);
202 expect(
203 yield* dies(
204 validateContainerImageProps({
205 main: "./i.ts",
206 dockerfile: Dockerfile.inline`FROM x`,
207 }),
208 ),
209 ).toBe(false);
210 expect(
211 yield* dies(
212 validateContainerImageProps({
213 main: "./i.ts",
214 image: "oven/bun:1",
215 dockerfile: Dockerfile.inline`FROM x`,
216 }),
217 ),
218 ).toBe(true);
219 // A path dockerfile cannot be an environment on Cloudflare.
220 expect(
221 yield* dies(
222 validateContainerImageProps({
223 main: "./i.ts",
224 dockerfile: "./Dockerfile",
225 }),
226 ),
227 ).toBe(true);
228 expect(
229 yield* dies(
230 validateContainerImageProps({ main: "./i.ts", context: "./app" }),
231 ),
232 ).toBe(true);
233 // Without main: image is exclusive with dockerfile/context.
234 expect(
235 yield* dies(
236 validateContainerImageProps({
237 image: "busybox",
238 dockerfile: "./f",
239 }),
240 ),
241 ).toBe(true);
242 expect(
243 yield* dies(
244 validateContainerImageProps({ image: "busybox", context: "./app" }),
245 ),
246 ).toBe(true);
247 // Inline content has no build context.
248 expect(
249 yield* dies(
250 validateContainerImageProps({
251 dockerfile: Dockerfile.inline`FROM x`,
252 context: "./app",
253 }),
254 ),
255 ).toBe(true);
256 // The plain external build stays valid.
257 expect(
258 yield* dies(
259 validateContainerImageProps({
260 context: "./app",
261 dockerfile: "./app/Dockerfile",
262 }),
263 ),
264 ).toBe(false);
265 }),
266 );
267 },
268);