1import {
2 deepEqual,
3 hasUnresolvedInputs,
4 havePropsChanged,
5 stripEffects,
6 stripUnresolved,
7} from "@/Diff";
8import * as Output from "@/Output";
9import { describe, expect, test } from "alchemy-test";
10import * as Context from "effect/Context";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Redacted from "effect/Redacted";
15
16describe("Diff", { tags: ["unit", "local"] }, () => {
17 describe("havePropsChanged with Redacted values", () => {
18 // Config values yielded in a Worker's init phase (e.g.
19 // `yield* Config.String("MY_VARIABLE")`) land in `props.env`
20 // as `Redacted<string>`. Before unwrapping, every Redacted serialized
21 // to the constant mask `"<redacted>"`, so a changed secret was
22 // invisible to the diff and the Worker never redeployed.
23 test("detects a changed yielded config value in env", () => {
24 const olds = {
25 env: {
26 MY_VARIABLE: Redacted.make("my-variable-abc1234"),
27 },
28 };
29 const news = {
30 env: {
31 MY_VARIABLE: Redacted.make("my-variable-CHANGED"),
32 },
33 };
34 expect(havePropsChanged(olds, news)).toBe(true);
35 });
36
37 test("does not flag an unchanged yielded config value", () => {
38 const olds = {
39 env: {
40 MY_VARIABLE: Redacted.make("my-variable-abc1234"),
41 },
42 };
43 const news = {
44 env: {
45 MY_VARIABLE: Redacted.make("my-variable-abc1234"),
46 },
47 };
48 expect(havePropsChanged(olds, news)).toBe(false);
49 });
50
51 test("detects a changed top-level Redacted value", () => {
52 expect(
53 havePropsChanged(
54 { secret: Redacted.make("a") },
55 { secret: Redacted.make("b") },
56 ),
57 ).toBe(true);
58 });
59
60 test("detects a Redacted value changing to a different inner type", () => {
61 expect(
62 havePropsChanged(
63 { secret: Redacted.make("123") },
64 { secret: Redacted.make(123) },
65 ),
66 ).toBe(true);
67 });
68
69 test("detects a changed Redacted value nested in arrays", () => {
70 expect(
71 havePropsChanged(
72 { secrets: [Redacted.make("a"), Redacted.make("b")] },
73 { secrets: [Redacted.make("a"), Redacted.make("c")] },
74 ),
75 ).toBe(true);
76 });
77
78 test("does not flag unchanged plain env values", () => {
79 expect(
80 havePropsChanged(
81 { env: { MY_VARIABLE: "value" } },
82 { env: { MY_VARIABLE: "value" } },
83 ),
84 ).toBe(false);
85 });
86
87 test("detects changed env when a Redacted value sits alongside plain values", () => {
88 const olds = {
89 env: {
90 MY_VARIABLE: "value",
91 MY_SECRET: Redacted.make("secret-1"),
92 },
93 };
94 const news = {
95 env: {
96 MY_VARIABLE: "value",
97 MY_SECRET: Redacted.make("secret-2"),
98 },
99 };
100 expect(havePropsChanged(olds, news)).toBe(true);
101 });
102 });
103
104 describe("deepEqual with Redacted values", () => {
105 test("distinguishes Redacted values with different inner values", () => {
106 expect(deepEqual(Redacted.make("a"), Redacted.make("b"))).toBe(false);
107 });
108
109 test("equates Redacted values with the same inner value", () => {
110 expect(deepEqual(Redacted.make("a"), Redacted.make("a"))).toBe(true);
111 });
112
113 test("distinguishes Redacted values nested in objects", () => {
114 expect(
115 deepEqual(
116 { secret: Redacted.make("a") },
117 { secret: Redacted.make("b") },
118 ),
119 ).toBe(false);
120 });
121 });
122
123 // effect ≥4.0.0-beta.103's Context is self-referential; every deep walker
124 // must treat Effect/Layer/Context values as leaves instead of recursing
125 // into (or JSON-encoding) their internals (#1082).
126 describe("opaque effect values in props (#1082)", () => {
127 const opaqueProps = () => ({
128 name: "worker",
129 exports: {
130 Store: {
131 kind: "durableObject",
132 constructor: Effect.void,
133 services: Context.empty(),
134 },
135 },
136 layer: Layer.empty,
137 });
138
139 test("hasUnresolvedInputs treats Layer/Context as resolved leaves", () => {
140 expect(
141 hasUnresolvedInputs({ context: Context.empty(), layer: Layer.empty }),
142 ).toBe(false);
143 });
144
145 test("stripUnresolved drops Effect/Layer/Context", () => {
146 const stripped: any = stripUnresolved(opaqueProps());
147 expect(stripped.name).toBe("worker");
148 expect(stripped.exports.Store.kind).toBe("durableObject");
149 expect(stripped.exports.Store.constructor).toBeUndefined();
150 expect(stripped.exports.Store.services).toBeUndefined();
151 expect(stripped.layer).toBeUndefined();
152 // Round-trips through JSON — the commit boundary's contract.
153 expect(() => JSON.stringify(stripped)).not.toThrow();
154 });
155
156 test("stripEffects drops Effect/Layer/Context", () => {
157 const stripped: any = stripEffects(opaqueProps());
158 expect(stripped.exports.Store.constructor).toBeUndefined();
159 expect(stripped.exports.Store.services).toBeUndefined();
160 expect(stripped.layer).toBeUndefined();
161 });
162
163 test("havePropsChanged terminates and compares only plain data", () => {
164 expect(havePropsChanged(opaqueProps(), opaqueProps())).toBe(false);
165 expect(
166 havePropsChanged(opaqueProps(), {
167 ...opaqueProps(),
168 name: "renamed",
169 }),
170 ).toBe(true);
171 });
172
173 test("deepEqual does not recurse into Context internals", () => {
174 expect(
175 deepEqual({ ctx: Context.empty() }, { ctx: Context.empty() }),
176 ).toBe(true);
177 });
178
179 test("havePropsChanged terminates on cyclic plain objects", () => {
180 const make = () => {
181 const cyclic: any = { name: "cycle" };
182 cyclic.self = cyclic;
183 return { config: cyclic };
184 };
185 // The full comparison — stripUnresolved cuts the cycle, so the
186 // JSON.stringify comparison sees identical truncated shapes.
187 expect(havePropsChanged(make(), make())).toBe(false);
188 expect(havePropsChanged(make(), { ...make(), extra: "x" } as any)).toBe(
189 true,
190 );
191 expect(hasUnresolvedInputs(make())).toBe(false);
192 });
193 });
194
195 describe("hasUnresolvedInputs across nesting shapes", () => {
196 test("finds an Output expr deep in arrays-in-objects-in-arrays", () => {
197 const expr = Output.literal("x");
198 expect(
199 hasUnresolvedInputs({ layers: [{ config: { hosts: [expr] } }] }),
200 ).toBe(true);
201 expect(hasUnresolvedInputs({ matrix: [[[expr]]] })).toBe(true);
202 });
203
204 test("finds an Effect deep in nested containers", () => {
205 expect(hasUnresolvedInputs({ a: [{ b: { c: [Effect.void] } }] })).toBe(
206 true,
207 );
208 });
209
210 test("a shared diamond subtree containing an expr is found from either parent", () => {
211 const shared = { dep: Output.literal("x") };
212 expect(hasUnresolvedInputs({ left: shared, right: shared })).toBe(true);
213 expect(hasUnresolvedInputs({ left: { v: 1 }, right: shared })).toBe(true);
214 });
215
216 test("fully-plain deep structures are resolved", () => {
217 expect(
218 hasUnresolvedInputs({ a: [{ b: [[{ c: 1 }]], d: new Date(0) }] }),
219 ).toBe(false);
220 });
221 });
222
223 describe("stripUnresolved semantics", () => {
224 test("preserves Date/Redacted/Duration by identity", () => {
225 const date = new Date(0);
226 const secret = Redacted.make("s");
227 const dur = Duration.seconds(5);
228 const stripped: any = stripUnresolved({ date, secret, dur });
229 expect(stripped.date).toBe(date);
230 expect(stripped.secret).toBe(secret);
231 expect(stripped.dur).toBe(dur);
232 });
233
234 test("drops Output exprs and Effects at any nesting depth", () => {
235 const stripped: any = stripUnresolved({
236 deep: [{ inner: { expr: Output.literal("x") } }],
237 arr: [[Effect.void]],
238 keep: [{ v: 1 }],
239 });
240 expect(stripped.deep[0].inner.expr).toBeUndefined();
241 expect(stripped.arr[0][0]).toBeUndefined();
242 expect(stripped.keep).toEqual([{ v: 1 }]);
243 });
244
245 test("cuts cycles but preserves diamonds", () => {
246 const shared = { v: 1 };
247 const cyclic: any = { name: "c" };
248 cyclic.self = cyclic;
249 const stripped: any = stripUnresolved({
250 left: shared,
251 right: shared,
252 cyc: cyclic,
253 });
254 expect(stripped.left).toEqual({ v: 1 });
255 expect(stripped.right).toEqual({ v: 1 });
256 expect(stripped.cyc.name).toBe("c");
257 expect(stripped.cyc.self).toBeUndefined();
258 expect(() => JSON.stringify(stripped)).not.toThrow();
259 });
260 });
261
262 describe("stripEffects semantics", () => {
263 test("keeps Output exprs intact but drops Effects, deeply", () => {
264 const expr = Output.literal("x");
265 const stripped: any = stripEffects({
266 deep: [{ expr, eff: Effect.void }],
267 });
268 expect(stripped.deep[0].expr).toBe(expr);
269 expect(stripped.deep[0].eff).toBeUndefined();
270 });
271
272 test("preserves Date/Redacted/Duration and cuts cycles", () => {
273 const date = new Date(0);
274 const cyclic: any = { date };
275 cyclic.self = cyclic;
276 const stripped: any = stripEffects({ cyc: cyclic });
277 expect(stripped.cyc.date).toBe(date);
278 expect(stripped.cyc.self).toBeUndefined();
279 });
280 });
281
282 describe("deepEqual / havePropsChanged across nesting shapes", () => {
283 test("deepEqual is key-order insensitive at every depth", () => {
284 expect(
285 deepEqual(
286 { a: [{ x: 1, y: [{ p: 1, q: 2 }] }], b: 2 },
287 { b: 2, a: [{ y: [{ q: 2, p: 1 }], x: 1 }] },
288 ),
289 ).toBe(true);
290 });
291
292 test("deepEqual compares Dates by value", () => {
293 expect(
294 deepEqual({ d: new Date("2027-01-01") }, { d: new Date("2027-01-01") }),
295 ).toBe(true);
296 expect(
297 deepEqual({ d: new Date("2027-01-01") }, { d: new Date("2027-01-02") }),
298 ).toBe(false);
299 });
300
301 test("deepEqual compares Durations by value", () => {
302 expect(
303 deepEqual({ d: Duration.seconds(5) }, { d: Duration.seconds(5) }),
304 ).toBe(true);
305 expect(
306 deepEqual({ d: Duration.seconds(5) }, { d: Duration.seconds(6) }),
307 ).toBe(false);
308 });
309
310 test("deepEqual terminates on cyclic values on either side", () => {
311 const make = () => {
312 const c: any = { v: 1 };
313 c.self = c;
314 return c;
315 };
316 expect(deepEqual({ c: make() }, { c: make() })).toBe(true);
317 });
318
319 test("havePropsChanged detects a change deep in arrays-in-objects-in-arrays", () => {
320 const shape = (url: string) => ({
321 layers: [{ config: { hosts: [{ url }, { url: "static" }] } }],
322 });
323 expect(havePropsChanged(shape("a"), shape("a"))).toBe(false);
324 expect(havePropsChanged(shape("a"), shape("b"))).toBe(true);
325 });
326
327 test("havePropsChanged detects array length and order changes", () => {
328 expect(
329 havePropsChanged({ arr: [[1, 2]] }, { arr: [[2, 1]] } as any),
330 ).toBe(true);
331 expect(havePropsChanged({ arr: [[1]] }, { arr: [[1], []] } as any)).toBe(
332 true,
333 );
334 });
335
336 test("havePropsChanged detects a changed Date", () => {
337 expect(
338 havePropsChanged(
339 { expires: new Date("2027-01-01") },
340 { expires: new Date("2027-01-01") },
341 ),
342 ).toBe(false);
343 expect(
344 havePropsChanged(
345 { expires: new Date("2027-01-01") },
346 { expires: new Date("2028-01-01") },
347 ),
348 ).toBe(true);
349 });
350
351 test("class instances never churn a diff — different instances compare equal", () => {
352 class SdkConfig {
353 constructor(readonly v: number) {}
354 }
355 // Runtime-only wiring: stripped at the commit boundary, so two deploys
356 // constructing fresh instances must not report a phantom change.
357 expect(
358 havePropsChanged(
359 { config: new SdkConfig(1) } as any,
360 { config: new SdkConfig(2) } as any,
361 ),
362 ).toBe(false);
363 });
364 });
365});