1import * as NodePath from "@effect/platform-node/NodePath";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as PlatformError from "effect/PlatformError";
9
10import type * as Electron from "electron";
11
12import * as ElectronApp from "../electron/ElectronApp.ts";
13import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
14import * as DesktopAssets from "./DesktopAssets.ts";
15import * as DesktopConfig from "./DesktopConfig.ts";
16import * as DesktopEnvironment from "./DesktopEnvironment.ts";
17
18const defaultEnvironmentInput = {
19 dirname: "/repo/apps/desktop/dist-electron",
20 homeDirectory: "/Users/alice",
21 platform: "darwin",
22 processArch: "arm64",
23 appVersion: "1.2.3",
24 appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar",
25 isPackaged: true,
26 resourcesPath: "/Applications/T3 Code.app/Contents/Resources",
27 runningUnderArm64Translation: false,
28} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
29
30type TestEnvironmentInput = Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> & {
31 readonly env?: Record<string, string | undefined>;
32};
33
34interface ElectronAppCalls {
35 readonly setAboutPanelOptions: Array<Electron.AboutPanelOptionsOptions>;
36 readonly setDockIcon: string[];
37 readonly setName: string[];
38}
39
40const layerElectronApp = (calls: ElectronAppCalls) =>
41 Layer.succeed(ElectronApp.ElectronApp, {
42 metadata: Effect.die("unexpected metadata read"),
43 name: Effect.succeed("T3 Code"),
44 systemLocale: Effect.succeed("en-US"),
45 preferredSystemLanguages: Effect.succeed(["en-US"]),
46 whenReady: Effect.void,
47 requestSingleInstanceLock: Effect.succeed(true),
48 quit: Effect.void,
49 exit: () => Effect.void,
50 relaunch: () => Effect.void,
51 setPath: () => Effect.void,
52 setName: (name) =>
53 Effect.sync(() => {
54 calls.setName.push(name);
55 }),
56 setAboutPanelOptions: (options) =>
57 Effect.sync(() => {
58 calls.setAboutPanelOptions.push(options);
59 }),
60 setAppUserModelId: () => Effect.void,
61 getAppMetrics: Effect.succeed([]),
62 setAsDefaultProtocolClient: () => Effect.succeed(true),
63 setDesktopName: () => Effect.void,
64 setDockIcon: (iconPath) =>
65 Effect.sync(() => {
66 calls.setDockIcon.push(iconPath);
67 }),
68 appendCommandLineSwitch: () => Effect.void,
69 onBeforeQuitForUpdate: () => Effect.void,
70 removeCommandLineSwitch: () => Effect.void,
71 on: () => Effect.void,
72 } satisfies ElectronApp.ElectronApp["Service"]);
73
74const layerAssets = (png: Option.Option<string>) =>
75 Layer.succeed(DesktopAssets.DesktopAssets, {
76 iconPaths: Effect.succeed({
77 ico: Option.none(),
78 icns: Option.none(),
79 png,
80 }),
81 resolveResourcePath: () => Effect.succeedNone,
82 } satisfies DesktopAssets.DesktopAssets["Service"]);
83
84const layerEnvironment = (overrides: TestEnvironmentInput = {}) => {
85 const { env, ...environmentOverrides } = overrides;
86 return DesktopEnvironment.layer({
87 ...defaultEnvironmentInput,
88 ...environmentOverrides,
89 }).pipe(
90 Layer.provide(
91 Layer.mergeAll(
92 NodeServices.layer,
93 NodePath.layerPosix,
94 DesktopConfig.layerTest({
95 ...env,
96 }),
97 ),
98 ),
99 );
100};
101
102const withIdentity = <A, E, R>(
103 effect: Effect.Effect<
104 A,
105 E,
106 | R
107 | DesktopAppIdentity.DesktopAppIdentity
108 | DesktopEnvironment.DesktopEnvironment
109 | FileSystem.FileSystem
110 >,
111 input: {
112 readonly calls?: ElectronAppCalls;
113 readonly environment?: TestEnvironmentInput;
114 readonly legacyPathExists?: boolean;
115 readonly legacyPathProbeError?: PlatformError.PlatformError;
116 readonly packageJson?: string;
117 readonly pngIconPath?: Option.Option<string>;
118 } = {},
119) => {
120 const calls: ElectronAppCalls = input.calls ?? {
121 setAboutPanelOptions: [],
122 setDockIcon: [],
123 setName: [],
124 };
125
126 return effect.pipe(
127 Effect.provide(
128 DesktopAppIdentity.layer.pipe(
129 Layer.provide(NodePath.layerPosix),
130 Layer.provideMerge(
131 FileSystem.layerNoop({
132 exists: (path) =>
133 input.legacyPathProbeError
134 ? Effect.fail(input.legacyPathProbeError)
135 : Effect.succeed(
136 input.legacyPathExists === true && /T3 Code \((Alpha|Dev)\)/.test(path),
137 ),
138 readFileString: () =>
139 Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'),
140 }),
141 ),
142 Layer.provideMerge(layerAssets(input.pngIconPath ?? Option.none())),
143 Layer.provideMerge(layerElectronApp(calls)),
144 Layer.provideMerge(layerEnvironment(input.environment)),
145 ),
146 ),
147 );
148};
149
150describe("DesktopAppIdentity", () => {
151 it.effect("isolates the V2 profile even when the legacy V1 profile exists", () =>
152 withIdentity(
153 Effect.gen(function* () {
154 const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
155 const userDataPath = yield* identity.resolveUserDataPath;
156
157 assert.equal(userDataPath, "/Users/alice/Library/Application Support/t3code-v2");
158 }),
159 { legacyPathExists: true },
160 ),
161 );
162
163 it.effect("keeps using the legacy development profile", () =>
164 withIdentity(
165 Effect.gen(function* () {
166 const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
167 assert.equal(
168 yield* identity.resolveUserDataPath,
169 "/Users/alice/Library/Application Support/T3 Code (Dev)",
170 );
171 }),
172 {
173 legacyPathExists: true,
174 environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } },
175 },
176 ),
177 );
178
179 it.effect.each([
180 { stage: "Beta", environment: {} },
181 {
182 stage: "Nightly",
183 environment: { appVersion: "0.0.43-nightly.20260929.2428" },
184 },
185 {
186 stage: "Dev",
187 environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } },
188 },
189 ])("uses a valid native User-Agent product name for $stage", ({ stage, environment }) => {
190 const calls: ElectronAppCalls = {
191 setAboutPanelOptions: [],
192 setDockIcon: [],
193 setName: [],
194 };
195
196 return withIdentity(
197 Effect.gen(function* () {
198 const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
199 yield* identity.configure;
200
201 const runtimeName = calls.setName[0];
202 assert.isDefined(runtimeName);
203 assert.equal(runtimeName, `T3 Code ${stage}`);
204 assert.match(runtimeName.replaceAll(" ", ""), /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/);
205 assert.equal(calls.setAboutPanelOptions[0]?.applicationName, `T3 Code (${stage})`);
206 }),
207 { calls, environment },
208 );
209 });
210});