1import { isResolved } from "@/Diff.ts";
2import * as Plan from "@/Plan";
3import { Platform, type Main, type PlatformProps } from "@/Platform.ts";
4import * as Provider from "@/Provider.ts";
5import { Resource } from "@/Resource";
6import {
7 createHostRuntimeContext,
8 type HostRuntimeContext,
9 ServerHost,
10} from "@/Server/Process.ts";
11import * as Stack from "@/Stack";
12import { Stage } from "@/Stage";
13import { inMemoryState, State } from "@/State";
14import * as Test from "@/Test/Alchemy";
15import { expect } from "alchemy-test";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as Ref from "effect/Ref";
19import * as HttpServerResponse from "effect/http/HttpServerResponse";
20
21// A minimal hosted Platform (like AWS.ECS.Task / AWS.EC2.Instance) whose
22// runtime context is built by the shared `createHostRuntimeContext`. Its
23// provider is a no-op so the plan never touches the cloud.
24interface HostProps extends PlatformProps {
25 main?: string;
26}
27
28interface Host extends Resource<"Test.Host", HostProps, { ok: boolean }> {}
29
30type HostServices = ServerHost;
31type HostShape = Main<HostServices>;
32
33const Host: Platform<Host, HostServices, HostShape, HostRuntimeContext> =
34 Platform("Test.Host", {
35 createRuntimeContext: createHostRuntimeContext("Test.Host"),
36 });
37
38const hostProvider = () =>
39 Provider.succeed(Host, {
40 list: () => Effect.succeed([]),
41 diff: Effect.fn(function* ({ news }) {
42 if (!isResolved(news)) return undefined;
43 }),
44 reconcile: Effect.fn(function* ({ output }) {
45 return output ?? { ok: true };
46 }),
47 delete: Effect.fn(function* () {}),
48 });
49
50const { test } = Test.make({
51 providers: hostProvider(),
52 state: inMemoryState(),
53});
54
55const makePlan = <A, Err, Req>(
56 effect: Effect.Effect<A, Err, Req>,
57): Effect.Effect<Plan.Plan<A>, Err, State> =>
58 effect.pipe(
59 // @ts-expect-error - Stack.make's typing erases R unsoundly here
60 Stack.make({
61 name: "test",
62 providers: Layer.empty,
63 state: inMemoryState(),
64 }),
65 Effect.provideService(Stage, "test"),
66 Effect.flatMap((stackSpec: any) => Plan.make(stackSpec)),
67 Effect.provide(hostProvider()),
68 );
69
70// Unit-level: the shared host context exposes run / serve / exports and folds
71// both run-registered loops and serve-registered handlers into a single
72// `exports.program` that the generated container/instance entry runs.
73test(
74 "createHostRuntimeContext collects run + serve into exports.program",
75 Effect.gen(function* () {
76 const ctx = createHostRuntimeContext("Test.Host")("my-host");
77
78 expect(ctx.Type).toBe("Test.Host");
79 expect(typeof ctx.run).toBe("function");
80 expect(typeof ctx.serve).toBe("function");
81 expect(typeof ctx.get).toBe("function");
82 expect(typeof ctx.set).toBe("function");
83
84 const ran = yield* Ref.make<string[]>([]);
85 yield* ctx.run(Ref.update(ran, (xs) => [...xs, "loop"]));
86 // serve registers an HTTP handler runner; with no HttpServer bound it is a
87 // harmless no-op, so the program completes without crashing.
88 yield* ctx.serve(Effect.succeed(HttpServerResponse.text("ok")));
89
90 const { program } = yield* ctx.exports;
91 yield* program;
92
93 expect(yield* Ref.get(ran)).toEqual(["loop"]);
94 }),
95 { tags: ["unit", "local"] },
96);
97
98// Regression for #706: the generated container/instance entrypoint resolves the
99// long-running program via `RuntimeContext.exports` (an Effect) and then runs
100// `.program`. `exports` is an Effect, so the entry MUST flat-map it before
101// touching `.program` — reaching for `exports.program` directly yields
102// `undefined`. This asserts both the (wrong) direct access and the (correct)
103// resolved access, mirroring the entry's `flatMap(exports) → flatMap(.program)`.
104test(
105 "exports must be resolved before reading .program (generated-entry contract)",
106 Effect.gen(function* () {
107 const ran = yield* Ref.make(false);
108 const ctx = createHostRuntimeContext("Test.Host")("entry");
109 yield* ctx.run(Ref.set(ran, true));
110
111 // Direct access — what the broken entry did — is undefined.
112 expect((ctx.exports as { program?: unknown }).program).toBeUndefined();
113
114 // Correct access: resolve the Effect, then run the program.
115 const { program } = yield* ctx.exports;
116 yield* program;
117 expect(yield* Ref.get(ran)).toBe(true);
118 }),
119 { tags: ["unit", "local"] },
120);
121
122// Regression for #706: a hosted Platform program can `yield* ServerHost` and
123// call `host.run(...)` during plan. Before the fix this died with
124// "Service not found: Alchemy::ServerHost". Returning `{ fetch }` additionally
125// exercises the host `serve` path (previously "No serve handler").
126test(
127 "Platform provides ServerHost to a hosted program during plan",
128 Effect.gen(function* () {
129 const plan = yield* Host(
130 "MyHost",
131 { main: "index.ts" },
132 Effect.gen(function* () {
133 const host = yield* ServerHost;
134 // The long-running loop — only registered during plan, never run.
135 yield* host.run(Effect.void);
136 return {
137 fetch: Effect.succeed(HttpServerResponse.text("ok")),
138 };
139 }),
140 ).pipe(makePlan);
141
142 expect(plan.resources["MyHost"]?.action).toBe("create");
143 }),
144 { tags: ["unit", "local"] },
145);