1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { expect, it } from "@effect/vitest";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import * as NetService from "@t3tools/shared/Net";
6import * as Cause from "effect/Cause";
7import * as ConfigProvider from "effect/ConfigProvider";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Queue from "effect/Queue";
13import * as Terminal from "effect/Terminal";
14import * as TestConsole from "effect/testing/TestConsole";
15import { Command } from "effect/cli";
16import { afterEach, vi } from "vite-plus/test";
17
18import packageJson from "../../package.json" with { type: "json" };
19import * as BootService from "../cloud/bootService.ts";
20import { upsertServiceEnv } from "./service.ts";
21import { connectEnabled, planRoutes, statusCommand, upCommand } from "./up.ts";
22
23const none = Option.none<boolean>();
24
25it.each([
26 {
27 label: "a new machine starts from T3 Connect and asks",
28 setup: { installed: false, connect: false, lan: false },
29 flags: { connect: none, lan: none },
30 expected: { routes: { connect: true, lan: false }, ask: ["connect", "lan"] },
31 },
32 {
33 label: "a build without T3 Connect never offers it",
34 setup: { installed: false, connect: null, lan: false },
35 flags: { connect: none, lan: none },
36 expected: { routes: { connect: false, lan: false }, ask: ["lan"] },
37 },
38 {
39 label: "a rerun starts from the current setup",
40 setup: { installed: true, connect: false, lan: true },
41 flags: { connect: none, lan: none },
42 expected: { routes: { connect: false, lan: true }, ask: ["connect", "lan"] },
43 },
44 {
45 label: "a route flag on a new machine turns on only that route",
46 setup: { installed: false, connect: false, lan: false },
47 flags: { connect: none, lan: Option.some(true) },
48 expected: { routes: { connect: false, lan: true }, ask: [] },
49 },
50 {
51 label: "a route flag asks nothing and keeps the other route",
52 setup: { installed: true, connect: true, lan: true },
53 flags: { connect: none, lan: Option.some(false) },
54 expected: { routes: { connect: true, lan: false }, ask: [] },
55 },
56])("$label", ({ setup, flags, expected }) => {
57 expect(planRoutes(setup, flags)).toEqual(expected);
58});
59
60const SERVICE = {
61 supported: true,
62 installed: true,
63 current: true,
64 unitPath: "/home/me/.config/systemd/user/t3code.service",
65 logPath: "/home/me/.t3/userdata/logs/boot-service.log",
66} as const;
67
68it.each([
69 {
70 label: "a CLI managed link",
71 cliDesired: true,
72 cliMode: "managed",
73 managedTunnel: false,
74 revoked: false,
75 on: true,
76 },
77 {
78 label: "a link the app made",
79 cliDesired: false,
80 cliMode: "managed",
81 managedTunnel: true,
82 revoked: false,
83 on: true,
84 },
85 {
86 label: "a publish-only link",
87 cliDesired: true,
88 cliMode: "publish_only",
89 managedTunnel: false,
90 revoked: false,
91 on: false,
92 },
93 {
94 label: "a publish-only link with an old tunnel config",
95 cliDesired: true,
96 cliMode: "publish_only",
97 managedTunnel: true,
98 revoked: false,
99 on: false,
100 },
101 {
102 label: "a revoked link",
103 cliDesired: true,
104 cliMode: "managed",
105 managedTunnel: true,
106 revoked: true,
107 on: false,
108 },
109 {
110 label: "no link",
111 cliDesired: false,
112 cliMode: "managed",
113 managedTunnel: false,
114 revoked: false,
115 on: false,
116 },
117] as const)("T3 Connect is on for $label: $on", ({ label: _, on, ...link }) => {
118 expect(connectEnabled(link)).toBe(on);
119});
120
121// Prompts run only when both streams are terminals.
122const asTerminal = Effect.acquireRelease(
123 Effect.sync(() =>
124 [process.stdin, process.stdout].map((stream) => {
125 const descriptor = Object.getOwnPropertyDescriptor(stream, "isTTY");
126 Object.defineProperty(stream, "isTTY", { configurable: true, value: true });
127 return { stream, descriptor };
128 }),
129 ),
130 (saved) =>
131 Effect.sync(() => {
132 for (const { stream, descriptor } of saved) {
133 if (descriptor === undefined) Reflect.deleteProperty(stream, "isTTY");
134 else Object.defineProperty(stream, "isTTY", descriptor);
135 }
136 }),
137);
138
139const answering = (text: string) =>
140 Terminal.make({
141 columns: Effect.succeed(80),
142 rows: Effect.succeed(24),
143 size: Queue.unbounded<Terminal.Size, Cause.Done>(),
144 readInput: Effect.gen(function* () {
145 const input = yield* Queue.unbounded<Terminal.UserInput, Cause.Done>();
146 yield* Queue.offer(input, {
147 input: Option.some(text),
148 key: { name: text, ctrl: false, meta: false, shift: false },
149 });
150 return input;
151 }),
152 readLine: Effect.succeed(text),
153 display: () => Effect.void,
154 });
155
156it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer))("t3 up", (it) => {
157 afterEach(() => vi.restoreAllMocks());
158
159 it.effect.each<{
160 readonly label: string;
161 readonly installed: boolean;
162 readonly status?: Partial<BootService.BootServiceStatus>;
163 readonly installFails?: boolean;
164 readonly restartFails?: boolean;
165 readonly fails?: boolean;
166 readonly reportStatus?: boolean;
167 /** Runs as if in a terminal, answering each question with this text. */
168 readonly answer?: string;
169 readonly prints?: string;
170 readonly omits?: string;
171 readonly json?: object;
172 readonly env: string;
173 readonly args: ReadonlyArray<string>;
174 readonly installs: number;
175 readonly restarts: number;
176 readonly envAfter: string;
177 }>([
178 {
179 label: "nothing changed",
180 installed: true,
181 env: "T3CODE_LAN=false\n",
182 args: ["--no-lan"],
183 installs: 0,
184 restarts: 0,
185 envAfter: "T3CODE_LAN=false\n",
186 },
187 {
188 label: "the local network opens",
189 prints: "Run `t3 pair --base-dir <baseDir>`.",
190 installed: true,
191 env: "T3CODE_LAN=false\n",
192 args: ["--lan"],
193 installs: 0,
194 restarts: 1,
195 envAfter: "T3CODE_LAN=true\n",
196 },
197 {
198 label: "a new service starts with its settings",
199 installed: false,
200 env: "",
201 args: ["--lan"],
202 installs: 1,
203 restarts: 0,
204 envAfter: "T3CODE_LAN=true\n",
205 },
206 {
207 label: "a route turns off with no service to run",
208 installed: false,
209 env: "T3CODE_LAN=true\n",
210 args: ["--no-lan"],
211 installs: 0,
212 restarts: 0,
213 envAfter: "T3CODE_LAN=false\n",
214 },
215 {
216 label: "a stopped newer service starts as it is",
217 installed: true,
218 status: { current: false, installedVersion: "999.0.0", problems: ["service-stopped"] },
219 env: "T3CODE_LAN=false\n",
220 args: ["--yes"],
221 installs: 0,
222 restarts: 1,
223 envAfter: "T3CODE_LAN=false\n",
224 },
225 {
226 label: "a failed update keeps service.env matching the old service",
227 // The old service still runs, so no foreground server is offered.
228 prints: "Fix that, then run `t3 up --base-dir <baseDir>` again.",
229 installed: true,
230 status: { current: false },
231 installFails: true,
232 env: "",
233 args: ["--no-lan"],
234 installs: 0,
235 restarts: 0,
236 envAfter: "",
237 },
238 {
239 label: "a failed restart puts service.env back and restarts on it",
240 installed: true,
241 restartFails: true,
242 env: "T3CODE_LAN=false\n",
243 args: ["--lan"],
244 installs: 0,
245 restarts: 2,
246 envAfter: "T3CODE_LAN=false\n",
247 },
248 {
249 label: "declining an update changes nothing",
250 installed: true,
251 status: { current: false },
252 answer: "n",
253 env: "T3CODE_LAN=false\n",
254 args: ["--lan"],
255 installs: 0,
256 restarts: 0,
257 envAfter: "T3CODE_LAN=false\n",
258 },
259 {
260 label: "a service for another home is refused without a restart",
261 installed: true,
262 status: { current: false, otherBaseDir: "/srv/t3 home" },
263 fails: true,
264 answer: "y",
265 env: "T3CODE_LAN=false\n",
266 args: ["--lan"],
267 installs: 0,
268 restarts: 0,
269 envAfter: "T3CODE_LAN=false\n",
270 },
271 {
272 label: "another home's service supplies no local network default",
273 installed: true,
274 status: { current: false, otherBaseDir: "/srv/t3 home" },
275 reportStatus: true,
276 json: {
277 service: { otherBaseDir: "/srv/t3 home" },
278 lan: { enabled: false, inheritedDefault: false },
279 },
280 env: "",
281 args: ["--json"],
282 installs: 0,
283 restarts: 0,
284 envAfter: "",
285 },
286 {
287 label: "a host override gets no --no-lan hint it would ignore",
288 installed: true,
289 reportStatus: true,
290 prints: "overridden · T3CODE_HOST=0.0.0.0",
291 omits: "t3 up --no-lan",
292 env: "T3CODE_HOST=0.0.0.0\n",
293 args: [],
294 installs: 0,
295 restarts: 0,
296 envAfter: "T3CODE_HOST=0.0.0.0\n",
297 },
298 {
299 label: "no service manager still records the choice",
300 installed: false,
301 status: { supported: false },
302 env: "",
303 args: ["--lan"],
304 installs: 0,
305 restarts: 0,
306 envAfter: "T3CODE_LAN=true\n",
307 },
308 {
309 label: "the old default is kept",
310 installed: true,
311 env: "",
312 args: ["--yes"],
313 installs: 0,
314 restarts: 0,
315 envAfter: "T3CODE_LAN=true\n",
316 },
317 ])("keeps service changes and access settings consistent: $label", (input) =>
318 Effect.gen(function* () {
319 const fs = yield* FileSystem.FileSystem;
320 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-up-test-" });
321 yield* fs.writeFileString(`${baseDir}/service.env`, input.env);
322 let installs = 0;
323 let restarts = 0;
324 const status = {
325 ...SERVICE,
326 installed: input.installed,
327 current: input.installed,
328 ...input.status,
329 };
330 vi.spyOn(BootService, "layer").mockReturnValue(
331 Layer.succeed(
332 BootService.BootService,
333 BootService.BootService.of({
334 status: Effect.succeed(status),
335 withStopped: (operation) => operation,
336 restart: Effect.sync(() => restarts++).pipe(
337 Effect.andThen(
338 input.restartFails === true
339 ? Effect.fail(new BootService.BootServiceCommandError({ step: "restart" }))
340 : Effect.succeed("restarted" as const),
341 ),
342 ),
343 install: () =>
344 status.otherBaseDir !== undefined
345 ? Effect.fail(
346 new BootService.BootServiceOtherBaseDirError({
347 installedBaseDir: status.otherBaseDir,
348 baseDir,
349 }),
350 )
351 : input.installFails === true
352 ? Effect.fail(
353 new BootService.BootServicePrerequisiteError({ problem: "linger-disabled" }),
354 )
355 : Effect.sync(() => {
356 installs++;
357 return {
358 program: ["t3"],
359 baseDir,
360 unitPath: status.unitPath,
361 logPath: status.logPath,
362 };
363 }),
364 uninstall: Effect.succeed(false),
365 }),
366 ),
367 );
368
369 if (input.answer !== undefined) yield* asTerminal;
370 const run = input.reportStatus
371 ? Command.runWith(statusCommand, { version: packageJson.version })
372 : Command.runWith(upCommand, { version: packageJson.version });
373 const { exit, lines } = yield* run(["--base-dir", baseDir, ...input.args]).pipe(
374 Effect.provideService(HostProcess.Environment, {}),
375 input.answer === undefined
376 ? (effect) => effect
377 : Effect.provideService(Terminal.Terminal, answering(input.answer)),
378 Effect.exit,
379 Effect.flatMap((exit) =>
380 Effect.map(TestConsole.logLines, (lines) => ({ exit, lines: lines.map(String) })),
381 ),
382 Effect.provide(
383 Layer.mergeAll(
384 ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
385 Layer.fresh(TestConsole.layer),
386 ),
387 ),
388 );
389 if (input.prints !== undefined) {
390 expect(lines.join("\n")).toContain(input.prints.replace("<baseDir>", baseDir));
391 }
392 if (input.omits !== undefined) {
393 expect(lines.join("\n")).not.toContain(input.omits);
394 }
395 if (input.json !== undefined) {
396 expect(JSON.parse(lines.join("\n"))).toMatchObject(input.json);
397 }
398 expect(exit._tag).toBe(
399 input.restartFails === true || input.fails === true ? "Failure" : "Success",
400 );
401 expect({ installs, restarts }).toEqual({
402 installs: input.installs,
403 restarts: input.restarts,
404 });
405 expect(yield* fs.readFileString(`${baseDir}/service.env`)).toBe(input.envAfter);
406 }),
407 );
408});
409
410it("rewrites only the named service.env keys and keeps the user's lines", () => {
411 const before = ["# my settings", "T3CODE_PORT=4000", "export T3CODE_LAN=0", "T3CODE_LAN=1", ""];
412 expect(upsertServiceEnv(before.join("\n"), { T3CODE_LAN: "true", T3CODE_HOST: undefined })).toBe(
413 ["# my settings", "T3CODE_PORT=4000", "T3CODE_LAN=true", ""].join("\n"),
414 );
415 expect(upsertServiceEnv("", { T3CODE_LAN: "false" })).toBe("T3CODE_LAN=false\n");
416 expect(
417 upsertServiceEnv('T3CODE_LAN="a\nb"\nCUSTOM="x\nT3CODE_LAN=y"\nT3CODE_LAN=true\n', {
418 T3CODE_LAN: "false",
419 }),
420 ).toBe('T3CODE_LAN=false\nCUSTOM="x\nT3CODE_LAN=y"\n');
421 expect(
422 upsertServiceEnv('T3CODE_LAN=\u00a0"unused\nCUSTOM=keep\nEND="\nT3CODE_LAN=true\n', {
423 T3CODE_LAN: "false",
424 }),
425 ).toBe('T3CODE_LAN=false\nCUSTOM=keep\nEND="\n');
426 const long = `CUSTOM="${"\n".repeat(130_000)}"\n`;
427 expect(upsertServiceEnv(`${long}T3CODE_LAN=true\n`, { T3CODE_LAN: "false" })).toBe(
428 `${long}T3CODE_LAN=false\n`,
429 );
430});