1// @effect-diagnostics anyUnknownInErrorContext:off - ScratchStack.deploy is typed with `any` in its error channel by Alchemy.
2import { inMemoryState } from "alchemy/State";
3import * as Test from "alchemy/Test/Vitest";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import * as Redacted from "effect/Redacted";
10import { describe, expect, it } from "vite-plus/test";
11import * as NodeServices from "@effect/platform-node/NodeServices";
12
13import { PublishClientConfig, reconcileEnvFile, tokenDigest } from "./clientConfig.ts";
14
15describe("reconcileEnvFile", () => {
16 it("adds every entry to an empty file", () => {
17 expect(reconcileEnvFile("", { A: "1", B: "2" })).toBe("A=1\nB=2\n");
18 });
19
20 it("replaces stale values and keeps unrelated lines", () => {
21 const existing = "KEEP=yes\nA=old\n# comment\n";
22 expect(reconcileEnvFile(existing, { A: "new", B: "2" })).toBe(
23 "KEEP=yes\nA=new\n# comment\nB=2\n",
24 );
25 });
26
27 it("appends a newline before adding to a file without one", () => {
28 expect(reconcileEnvFile("A=1", { B: "2" })).toBe("A=1\nB=2\n");
29 });
30
31 it("collapses duplicate assignments to one, since parseEnv lets the last win", () => {
32 expect(reconcileEnvFile("A=old\nKEEP=1\nA=older\n", { A: "new" })).toBe("A=new\nKEEP=1\n");
33 });
34
35 it("recognises every assignment form parseEnv accepts", () => {
36 expect(reconcileEnvFile("A=old\n export A = stale\nexport A=older\n", { A: "new" })).toBe(
37 "A=new\n",
38 );
39 });
40
41 it("replaces a quoted multi-line value whole", () => {
42 expect(reconcileEnvFile('A="one\ntwo"\nKEEP=1\n', { A: "new" })).toBe("A=new\nKEEP=1\n");
43 expect(reconcileEnvFile("A='x'\nKEEP=1\n", { A: "new" })).toBe("A=new\nKEEP=1\n");
44 });
45
46 it("keeps later lines when a quoted value never closes", () => {
47 expect(reconcileEnvFile('A="one\nKEEP=1\n', { A: "new" })).toBe("A=new\nKEEP=1\n");
48 });
49
50 it("leaves a commented-out assignment alone", () => {
51 expect(reconcileEnvFile("#A=old\n", { A: "new" })).toBe("#A=old\nA=new\n");
52 });
53});
54
55const { test } = Test.make({
56 providers: Layer.empty,
57 state: inMemoryState(),
58});
59
60const clientConfig = (token: string) => ({
61 url: "https://relay.example.com",
62 mobileTracingUrl: "https://axiom.example.com/v1/traces",
63 mobileTracingDataset: "relay-traces",
64 mobileTracingToken: Redacted.make(`mobile-${token}`),
65 clientTracingUrl: "https://axiom.example.com/v1/traces",
66 clientTracingDataset: "relay-traces",
67 clientTracingToken: Redacted.make(`client-${token}`),
68 tokenDigest: tokenDigest([Redacted.make(`mobile-${token}`), Redacted.make(`client-${token}`)]),
69});
70
71describe("PublishClientConfig", () => {
72 test.provider("writes the env file on deploy and skips an unchanged redeploy", (stack) =>
73 Effect.gen(function* () {
74 const fs = yield* FileSystem.FileSystem;
75 const path = yield* Path.Path;
76 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-relay-client-config-" });
77 const target = path.join(dir, "client.env");
78 yield* fs.writeFileString(target, "KEEP=yes\n");
79 const configured = Effect.provide(
80 ConfigProvider.layer(
81 ConfigProvider.fromUnknown({ T3CODE_RELAY_CLIENT_CONFIG_ENV: target }),
82 ),
83 );
84
85 yield* stack.deploy(PublishClientConfig(clientConfig("v1"))).pipe(configured);
86 const first = yield* fs.readFileString(target);
87 expect(first).toContain("KEEP=yes\n");
88 expect(first).toContain("T3CODE_RELAY_URL=https://relay.example.com\n");
89 expect(first).toContain("T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=client-v1\n");
90 expect(first).toContain("T3CODE_MOBILE_OTLP_TRACES_TOKEN=mobile-v1\n");
91
92 // Same input: the action is skipped, so a change made by hand survives.
93 yield* fs.writeFileString(target, `${first}MANUAL=1\n`);
94 yield* stack.deploy(PublishClientConfig(clientConfig("v1"))).pipe(configured);
95 expect(yield* fs.readFileString(target)).toContain("MANUAL=1\n");
96
97 // A rotated token changes the input, so it runs again and replaces the line.
98 yield* stack.deploy(PublishClientConfig(clientConfig("v2"))).pipe(configured);
99 const third = yield* fs.readFileString(target);
100 expect(third).toContain("T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=client-v2\n");
101 expect(third).not.toContain("client-v1");
102 expect(third).toContain("KEEP=yes\n");
103 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
104 );
105
106 test.provider("fails when the worker has no url rather than publishing an empty relay", (stack) =>
107 Effect.gen(function* () {
108 const fs = yield* FileSystem.FileSystem;
109 const path = yield* Path.Path;
110 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-relay-client-config-" });
111 const target = path.join(dir, "client.env");
112 const exit = yield* stack
113 .deploy(PublishClientConfig({ ...clientConfig("v1"), url: undefined }))
114 .pipe(
115 Effect.provide(
116 ConfigProvider.layer(
117 ConfigProvider.fromUnknown({ T3CODE_RELAY_CLIENT_CONFIG_ENV: target }),
118 ),
119 ),
120 Effect.exit,
121 );
122 expect(exit._tag).toBe("Failure");
123 expect(String(exit)).toContain("RelayUrlUnavailableError");
124 expect(yield* fs.exists(target)).toBe(false);
125 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
126 );
127
128 test.provider("refuses a value with a line break rather than corrupting the file", (stack) =>
129 Effect.gen(function* () {
130 const fs = yield* FileSystem.FileSystem;
131 const path = yield* Path.Path;
132 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-relay-client-config-" });
133 const target = path.join(dir, "client.env");
134 const exit = yield* stack
135 .deploy(
136 PublishClientConfig({
137 ...clientConfig("v1"),
138 clientTracingDataset: "relay-traces\nINJECTED=1",
139 }),
140 )
141 .pipe(
142 Effect.provide(
143 ConfigProvider.layer(
144 ConfigProvider.fromUnknown({ T3CODE_RELAY_CLIENT_CONFIG_ENV: target }),
145 ),
146 ),
147 Effect.exit,
148 );
149 expect(exit._tag).toBe("Failure");
150 expect(yield* fs.exists(target)).toBe(false);
151 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
152 );
153});