1import * as Effect from "effect/Effect";
2import * as Config from "effect/Config";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Result from "effect/Result";
7import { fileURLToPath } from "node:url";
8import path from "pathe";
9import { describe, expect, test } from "alchemy-test";
10import {
11 collectAuthProviders,
12 buildStackProviders,
13 DEFAULT_ENTRYPOINT,
14 importStack,
15 open,
16 routeCacheLayer,
17 StackModuleLoader,
18} from "@/Alchemist/Session.ts";
19import { Secrets, Stack, inMemoryState } from "@/index.ts";
20import * as CliKit from "@/Cli/CliKit/index.ts";
21import { evalStack } from "../../src/Stack";
22import * as TestCore from "../../src/Test/Core";
23import { TestLayers } from "../test.resources";
24
25const fixtureAbsolutePath = fileURLToPath(
26 import.meta.resolve("./fixtures/import-stack-fixture.ts"),
27);
28const fixtureRelativePath = path.relative(process.cwd(), fixtureAbsolutePath);
29
30const runFixture = (path: string) =>
31 TestCore.run(
32 importStack(path).pipe(
33 Effect.flatMap((stackEffect) =>
34 evalStack(stackEffect, (stack) => Effect.succeed(stack.output), {
35 stage: "test",
36 }),
37 ),
38 ),
39 {
40 providers: TestLayers(),
41 },
42 );
43
44describe("importStack", { tags: ["unit", "local"] }, () => {
45 test("--env-file is rejected when the stack declares its own secrets", () =>
46 TestCore.run(
47 Effect.gen(function* () {
48 const fs = yield* FileSystem.FileSystem;
49 const dir = yield* fs.makeTempDirectoryScoped();
50 const envFile = path.join(dir, "cli.env");
51 yield* fs.writeFileString(envFile, "ALCHEMY_DOTENV_SESSION_VALUE=cli");
52 const stack = Stack(
53 "session-secrets-declared",
54 {
55 state: inMemoryState(),
56 providers: Layer.empty,
57 secrets: [Secrets.DotEnv({ path: envFile })],
58 },
59 Config.String("ALCHEMY_DOTENV_SESSION_VALUE"),
60 );
61 const result = yield* open({
62 entrypoint: fixtureAbsolutePath,
63 stage: "prod",
64 envFile,
65 }).pipe(
66 Effect.result,
67 Effect.provideService(StackModuleLoader, {
68 import: async () => ({ default: stack }),
69 }),
70 );
71 expect(Result.isFailure(result)).toBe(true);
72 if (Result.isFailure(result)) {
73 expect(String(result.failure)).toContain(
74 "--env-file cannot be combined with a stack that declares `secrets`",
75 );
76 }
77 }),
78 { providers: TestLayers() },
79 ));
80
81 test("--env-file and --profile steer the default secrets for sessions and provider-only builds", () =>
82 TestCore.run(
83 Effect.gen(function* () {
84 const fs = yield* FileSystem.FileSystem;
85 const dir = yield* fs.makeTempDirectoryScoped();
86 const envFile = path.join(dir, "cli.env");
87 yield* fs.writeFileString(
88 envFile,
89 "ALCHEMY_DOTENV_SESSION_VALUE=cli\nALCHEMY_PROFILE=file-profile",
90 );
91 const observations: string[][] = [];
92 const stack = Stack(
93 "session-secrets-default",
94 {
95 state: inMemoryState(),
96 providers: Layer.effectDiscard(
97 Effect.gen(function* () {
98 observations.push([
99 yield* Config.String("ALCHEMY_DOTENV_SESSION_VALUE"),
100 yield* Config.String("ALCHEMY_PROFILE"),
101 ]);
102 }).pipe(Effect.orDie),
103 ),
104 },
105 Config.String("ALCHEMY_DOTENV_SESSION_VALUE"),
106 );
107 yield* Effect.gen(function* () {
108 const session = yield* open({
109 entrypoint: fixtureAbsolutePath,
110 stage: "prod",
111 envFile,
112 profile: "cli-profile",
113 });
114 // The flag's file replaces `.env` in the default list...
115 expect(session.stack.output).toBe("cli");
116 const providers = yield* buildStackProviders({
117 main: fixtureAbsolutePath,
118 envFile: Option.some(envFile),
119 profile: "cli-profile",
120 });
121 expect(
122 yield* Config.String("ALCHEMY_DOTENV_SESSION_VALUE").pipe(
123 Effect.provide(providers.context),
124 ),
125 ).toBe("cli");
126 // ...and --profile beats the ALCHEMY_PROFILE the file sets.
127 expect(observations).toEqual([
128 ["cli", "cli-profile"],
129 ["cli", "cli-profile"],
130 ]);
131 }).pipe(
132 Effect.provideService(StackModuleLoader, {
133 import: async () => ({ default: stack }),
134 }),
135 );
136 }),
137 { providers: TestLayers() },
138 ));
139
140 test("loads stack entrypoint via relative path", () =>
141 expect(runFixture(fixtureRelativePath)).resolves.toBe(
142 "import-stack-fixture",
143 ));
144
145 test("loads stack entrypoint via absolute path", () =>
146 expect(runFixture(fixtureAbsolutePath)).resolves.toBe(
147 "import-stack-fixture",
148 ));
149
150 test("memoizes an opened stack session within a command scope", async () => {
151 const [first, second] = await TestCore.run(
152 Effect.all([
153 open({ entrypoint: fixtureAbsolutePath, stage: "test" }),
154 open({ entrypoint: fixtureAbsolutePath, stage: "test" }),
155 ]).pipe(
156 Effect.provide(routeCacheLayer),
157 Effect.provide(CliKit.layer({ input: false })),
158 ),
159 { providers: TestLayers() },
160 );
161
162 expect(second).toBe(first);
163 });
164
165 test("memoizes an auth registry within a command scope", async () => {
166 const options = {
167 main: DEFAULT_ENTRYPOINT,
168 envFile: Option.none<string>(),
169 profile: "default",
170 };
171 const [first, second] = await TestCore.run(
172 Effect.all([
173 collectAuthProviders(options),
174 collectAuthProviders(options),
175 ]).pipe(
176 Effect.provide(routeCacheLayer),
177 Effect.provide(CliKit.layer({ input: false })),
178 ),
179 { providers: TestLayers() },
180 );
181
182 expect(second).toBe(first);
183 });
184
185 test("reports a missing stack entrypoint as a user-facing error", async () => {
186 const result = await TestCore.run(
187 importStack(
188 path.join(import.meta.dirname, "missing-alchemy.run.ts"),
189 ).pipe(Effect.result),
190 { providers: TestLayers() },
191 );
192
193 expect(Result.isFailure(result)).toBe(true);
194 if (Result.isFailure(result)) {
195 expect(result.failure._tag).toBe("StackEntrypointError");
196 expect(result.failure.message).toContain("does not exist");
197 expect(result.failure.message).toContain("--config <path>");
198 }
199 });
200});