1import * as PlatformError from "effect/PlatformError";
2import { expect, it } from "@effect/vitest";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
8
9import * as ProcessRunner from "../processRunner.ts";
10import {
11 deviceToolVersions,
12 DEVICE_HUB_VERSION,
13 ensureDeviceHub,
14 isDeviceHubInstalled,
15} from "./DeviceToolchain.ts";
16
17it.effect("failed installation cleans staging and exposes only a safe failure message", () =>
18 Effect.gen(function* () {
19 const fs = yield* FileSystem.FileSystem;
20 const path = yield* Path.Path;
21 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-install-" });
22 const result = {
23 code: ChildProcessSpawner.ExitCode(1),
24 stdout: "",
25 stderr: "registry rejected https://private:credential@example.test/package",
26 timedOut: false,
27 stdoutTruncated: false,
28 stderrTruncated: false,
29 stdoutInvalidUtf8: false,
30 stderrInvalidUtf8: false,
31 };
32 const error = yield* ensureDeviceHub(baseDir).pipe(
33 Effect.provideService(ProcessRunner.ProcessRunner, {
34 run: () => Effect.succeed(result),
35 }),
36 Effect.flip,
37 );
38 expect(error.message).toBe(
39 "Installing expo-device-hub failed while running npm install (exit code 1).",
40 );
41 expect(error.cause).toBe(result);
42 expect(yield* isDeviceHubInstalled(baseDir)).toBe(false);
43 expect(yield* fs.readDirectory(path.join(baseDir, "tools", "expo-device-hub"))).toEqual([]);
44 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
45);
46
47it.effect("inventory reports only completed versions without installing the required version", () =>
48 Effect.gen(function* () {
49 const fs = yield* FileSystem.FileSystem;
50 const path = yield* Path.Path;
51 const base = yield* fs.makeTempDirectoryScoped();
52 for (const [version, sentinel] of [
53 ["0.9.0", "0.9.0"],
54 [DEVICE_HUB_VERSION, "wrong"],
55 [".staging-123", ".staging-123"],
56 ]) {
57 const dir = path.join(base, "tools", "expo-device-hub", version!);
58 yield* fs.makeDirectory(path.join(dir, "node_modules/expo-device-hub/dist/server"), {
59 recursive: true,
60 });
61 yield* fs.writeFileString(
62 path.join(dir, "node_modules/expo-device-hub/dist/server/cli.mjs"),
63 "",
64 );
65 yield* fs.writeFileString(path.join(dir, ".install-complete"), sentinel!);
66 }
67 const tools = yield* deviceToolVersions(base);
68 expect(tools?.hub).toEqual({
69 requiredVersion: DEVICE_HUB_VERSION,
70 installedVersions: ["0.9.0"],
71 runningVersion: null,
72 });
73 expect(tools?.agent.installedVersions).toEqual([]);
74 expect(yield* isDeviceHubInstalled(base)).toBe(false);
75 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
76);
77
78it.effect("unreadable inventory stays unknown instead of reporting no installs", () =>
79 Effect.gen(function* () {
80 const tools = yield* deviceToolVersions("/unreadable");
81 expect(tools).toBeUndefined();
82 }).pipe(
83 Effect.provideService(
84 FileSystem.FileSystem,
85 FileSystem.makeNoop({
86 readDirectory: () =>
87 Effect.fail(
88 PlatformError.systemError({
89 _tag: "PermissionDenied",
90 module: "FileSystem",
91 method: "readDirectory",
92 description: "denied",
93 }),
94 ),
95 }),
96 ),
97 Effect.provide(NodeServices.layer),
98 ),
99);