1import { UserInputError } from "@/Cli/commands/errors.ts";
2import {
3 resolveConfig,
4 include,
5 exclude,
6 validateSelectionOptions,
7} from "@/Cli/commands/flags.ts";
8import { DevOptions } from "@/Cli/DevOptions.ts";
9import * as Stacks from "@/Alchemist/routes/stack.ts";
10import * as Alchemist from "@/Alchemist/Runtime.ts";
11import { PlatformServices } from "@/Util/PlatformServices.ts";
12import * as Option from "effect/Option";
13import * as Schema from "effect/Schema";
14import * as Exit from "effect/Exit";
15import * as Cause from "effect/Cause";
16import * as Effect from "effect/Effect";
17import * as Result from "effect/Result";
18import { describe, expect, it } from "alchemy-test";
19
20describe("stack command config paths", { tags: ["unit", "local"] }, () => {
21 it.effect("uses the positional config path", () =>
22 Effect.gen(function* () {
23 const args = yield* resolveConfig({
24 config: undefined,
25 configPath: "infra.ts",
26 });
27 expect(args.main).toBe("infra.ts");
28 }),
29 );
30
31 it.effect("uses --config", () =>
32 Effect.gen(function* () {
33 const args = yield* resolveConfig({
34 config: "infra.ts",
35 configPath: undefined,
36 });
37 expect(args.main).toBe("infra.ts");
38 }),
39 );
40
41 it.effect("defaults to alchemy.run.ts", () =>
42 Effect.gen(function* () {
43 const args = yield* resolveConfig({
44 config: undefined,
45 configPath: undefined,
46 });
47 expect(args.main).toBe("alchemy.run.ts");
48 }),
49 );
50
51 it.effect("rejects using the positional path and --config together", () =>
52 Effect.gen(function* () {
53 const result = yield* resolveConfig({
54 config: "flag.ts",
55 configPath: "positional.ts",
56 }).pipe(Effect.result);
57
58 expect(Result.isFailure(result)).toBe(true);
59 if (Result.isFailure(result)) {
60 expect(result.failure).toBeInstanceOf(UserInputError);
61 expect(result.failure.message).toContain("not both");
62 }
63 }),
64 );
65});
66
67describe("stack selection options", { tags: ["unit", "local"] }, () => {
68 for (const [name, flag] of [
69 ["include", include],
70 ["exclude", exclude],
71 ] as const) {
72 for (const [values, expected] of [
73 [[], undefined],
74 [
75 ["Branch,Password", "Namespace/**"],
76 ["Branch,Password", "Namespace/**"],
77 ],
78 [[""], [""]],
79 [["Branch,"], ["Branch,"]],
80 [
81 [" App/* ", "App/*", "App/*"],
82 [" App/* ", "App/*", "App/*"],
83 ],
84 ] as const) {
85 it.effect(
86 `parses repeated --${name} patterns ${JSON.stringify(values)}`,
87 () =>
88 Effect.gen(function* () {
89 const [, parsed] = yield* flag.parse({
90 arguments: [],
91 flags: values.length ? { [name]: [...values] } : {},
92 });
93 expect(parsed).toEqual(expected);
94 }).pipe(Effect.provide(PlatformServices)),
95 );
96 }
97 }
98
99 it.effect(
100 "retains include and exclude through dev reload JSON roundtrips",
101 () =>
102 Effect.gen(function* () {
103 for (const include of [
104 undefined,
105 [],
106 ["Branch,Password", "Namespace/**"],
107 ]) {
108 for (const exclude of [
109 undefined,
110 [],
111 ["Namespace/Legacy", "**/.Private"],
112 ]) {
113 const options = {
114 main: "alchemy.run.ts",
115 stage: "test",
116 envFile: Option.none(),
117 force: false,
118 include,
119 exclude,
120 };
121 const wire = yield* Schema.encodeEffect(DevOptions)(options);
122 const decoded = yield* Schema.decodeUnknownEffect(DevOptions)(
123 JSON.parse(JSON.stringify(wire)),
124 );
125 expect(decoded.include).toEqual(include);
126 expect(decoded.exclude).toEqual(exclude);
127 }
128 }
129 }),
130 );
131
132 for (const selection of [
133 { include: ["Branch"] },
134 { exclude: ["Branch"] },
135 { include: [] },
136 { exclude: [] },
137 ]) {
138 for (const combination of [{ destroy: true }, { detectDrift: true }]) {
139 it.effect(
140 `rejects ${JSON.stringify(selection)} with ${JSON.stringify(combination)}`,
141 () =>
142 Effect.gen(function* () {
143 const result = yield* validateSelectionOptions({
144 ...selection,
145 ...combination,
146 }).pipe(Effect.result);
147 expect(Result.isFailure(result)).toBe(true);
148 yield* validateSelectionOptions(combination);
149 }),
150 );
151 }
152 it.effect(
153 `rejects Alchemist destroy ${JSON.stringify(selection)} before opening a stack session`,
154 () =>
155 Effect.gen(function* () {
156 const exit = yield* Stacks.plan({
157 target: { entrypoint: "does-not-exist.ts", stage: "test" },
158 operation: "destroy",
159 ...selection,
160 }).pipe(Effect.exit);
161 expect(Exit.isFailure(exit)).toBe(true);
162 if (Exit.isFailure(exit))
163 expect(Cause.pretty(exit.cause)).toContain(
164 "Filtered destroy is not supported",
165 );
166 }).pipe(Effect.provide(Alchemist.layer()), Effect.scoped),
167 );
168 }
169});