| 1 | import * as NodePath from "@effect/platform-node/NodePath"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { assert, describe, it } from "@effect/vitest"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import * as PlatformError from "effect/PlatformError"; |
| 9 | |
| 10 | import type * as Electron from "electron"; |
| 11 | |
| 12 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 13 | import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; |
| 14 | import * as DesktopAssets from "./DesktopAssets.ts"; |
| 15 | import * as DesktopConfig from "./DesktopConfig.ts"; |
| 16 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 17 | import * as DesktopUserData from "./DesktopUserData.ts"; |
| 18 | |
| 19 | const defaultEnvironmentInput = { |
| 20 | dirname: "/repo/apps/desktop/dist-electron", |
| 21 | homeDirectory: "/Users/alice", |
| 22 | platform: "darwin", |
| 23 | processArch: "arm64", |
| 24 | appVersion: "1.2.3", |
| 25 | appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", |
| 26 | isPackaged: true, |
| 27 | resourcesPath: "/Applications/T3 Code.app/Contents/Resources", |
| 28 | runningUnderArm64Translation: false, |
| 29 | } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; |
| 30 | |
| 31 | type TestEnvironmentInput = Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> & { |
| 32 | readonly env?: Record<string, string | undefined>; |
| 33 | }; |
| 34 | |
| 35 | interface ElectronAppCalls { |
| 36 | readonly setAboutPanelOptions: Array<Electron.AboutPanelOptionsOptions>; |
| 37 | readonly setDockIcon: string[]; |
| 38 | readonly setName: string[]; |
| 39 | } |
| 40 | |
| 41 | const layerElectronApp = (calls: ElectronAppCalls) => |
| 42 | Layer.succeed(ElectronApp.ElectronApp, { |
| 43 | metadata: Effect.die("unexpected metadata read"), |
| 44 | name: Effect.succeed("T3 Code"), |
| 45 | systemLocale: Effect.succeed("en-US"), |
| 46 | whenReady: Effect.void, |
| 47 | quit: Effect.void, |
| 48 | exit: () => Effect.void, |
| 49 | relaunch: () => Effect.void, |
| 50 | setPath: () => Effect.void, |
| 51 | setName: (name) => |
| 52 | Effect.sync(() => { |
| 53 | calls.setName.push(name); |
| 54 | }), |
| 55 | setAboutPanelOptions: (options) => |
| 56 | Effect.sync(() => { |
| 57 | calls.setAboutPanelOptions.push(options); |
| 58 | }), |
| 59 | setAppUserModelId: () => Effect.void, |
| 60 | getAppMetrics: Effect.succeed([]), |
| 61 | setAsDefaultProtocolClient: () => Effect.succeed(true), |
| 62 | setDesktopName: () => Effect.void, |
| 63 | setDockIcon: (iconPath) => |
| 64 | Effect.sync(() => { |
| 65 | calls.setDockIcon.push(iconPath); |
| 66 | }), |
| 67 | appendCommandLineSwitch: () => Effect.void, |
| 68 | onBeforeQuitForUpdate: () => Effect.void, |
| 69 | removeCommandLineSwitch: () => Effect.void, |
| 70 | on: () => Effect.void, |
| 71 | } satisfies ElectronApp.ElectronApp["Service"]); |
| 72 | |
| 73 | const layerAssets = (png: Option.Option<string>) => |
| 74 | Layer.succeed(DesktopAssets.DesktopAssets, { |
| 75 | iconPaths: Effect.succeed({ |
| 76 | ico: Option.none(), |
| 77 | icns: Option.none(), |
| 78 | png, |
| 79 | }), |
| 80 | resolveResourcePath: () => Effect.succeedNone, |
| 81 | } satisfies DesktopAssets.DesktopAssets["Service"]); |
| 82 | |
| 83 | const layerEnvironment = (overrides: TestEnvironmentInput = {}) => { |
| 84 | const { env, ...environmentOverrides } = overrides; |
| 85 | return DesktopEnvironment.layer({ |
| 86 | ...defaultEnvironmentInput, |
| 87 | ...environmentOverrides, |
| 88 | }).pipe( |
| 89 | Layer.provide( |
| 90 | Layer.mergeAll( |
| 91 | NodeServices.layer, |
| 92 | NodePath.layerPosix, |
| 93 | DesktopConfig.layerTest({ |
| 94 | ...env, |
| 95 | }), |
| 96 | ), |
| 97 | ), |
| 98 | ); |
| 99 | }; |
| 100 | |
| 101 | const withIdentity = <A, E, R>( |
| 102 | effect: Effect.Effect< |
| 103 | A, |
| 104 | E, |
| 105 | | R |
| 106 | | DesktopAppIdentity.DesktopAppIdentity |
| 107 | | DesktopEnvironment.DesktopEnvironment |
| 108 | | FileSystem.FileSystem |
| 109 | >, |
| 110 | input: { |
| 111 | readonly calls?: ElectronAppCalls; |
| 112 | readonly environment?: TestEnvironmentInput; |
| 113 | readonly legacyPathExists?: boolean; |
| 114 | readonly legacyPathProbeError?: PlatformError.PlatformError; |
| 115 | readonly packageJson?: string; |
| 116 | readonly pngIconPath?: Option.Option<string>; |
| 117 | } = {}, |
| 118 | ) => { |
| 119 | const calls: ElectronAppCalls = input.calls ?? { |
| 120 | setAboutPanelOptions: [], |
| 121 | setDockIcon: [], |
| 122 | setName: [], |
| 123 | }; |
| 124 | |
| 125 | return effect.pipe( |
| 126 | Effect.provide( |
| 127 | DesktopAppIdentity.layer.pipe( |
| 128 | Layer.provide(NodePath.layerPosix), |
| 129 | Layer.provideMerge( |
| 130 | FileSystem.layerNoop({ |
| 131 | exists: (path) => |
| 132 | input.legacyPathProbeError |
| 133 | ? Effect.fail(input.legacyPathProbeError) |
| 134 | : Effect.succeed( |
| 135 | input.legacyPathExists === true && /T3 Code \((Alpha|Dev)\)/.test(path), |
| 136 | ), |
| 137 | readFileString: () => |
| 138 | Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'), |
| 139 | }), |
| 140 | ), |
| 141 | Layer.provideMerge(layerAssets(input.pngIconPath ?? Option.none())), |
| 142 | Layer.provideMerge(layerElectronApp(calls)), |
| 143 | Layer.provideMerge(layerEnvironment(input.environment)), |
| 144 | ), |
| 145 | ), |
| 146 | ); |
| 147 | }; |
| 148 | |
| 149 | describe("DesktopAppIdentity", () => { |
| 150 | it.effect("isolates the V2 profile even when the legacy V1 profile exists", () => |
| 151 | withIdentity( |
| 152 | Effect.gen(function* () { |
| 153 | const identity = yield* DesktopAppIdentity.DesktopAppIdentity; |
| 154 | const userDataPath = yield* identity.resolveUserDataPath; |
| 155 | |
| 156 | assert.equal(userDataPath, "/Users/alice/Library/Application Support/t3code-v2"); |
| 157 | }), |
| 158 | { legacyPathExists: true }, |
| 159 | ), |
| 160 | ); |
| 161 | |
| 162 | it.effect("keeps using the legacy development profile", () => |
| 163 | withIdentity( |
| 164 | Effect.gen(function* () { |
| 165 | const identity = yield* DesktopAppIdentity.DesktopAppIdentity; |
| 166 | assert.equal( |
| 167 | yield* identity.resolveUserDataPath, |
| 168 | "/Users/alice/Library/Application Support/T3 Code (Dev)", |
| 169 | ); |
| 170 | }), |
| 171 | { |
| 172 | legacyPathExists: true, |
| 173 | environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } }, |
| 174 | }, |
| 175 | ), |
| 176 | ); |
| 177 | |
| 178 | it.effect("preserves failures while inspecting the legacy userData path", () => { |
| 179 | const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Dev)"; |
| 180 | const cause = PlatformError.systemError({ |
| 181 | _tag: "PermissionDenied", |
| 182 | module: "FileSystem", |
| 183 | method: "exists", |
| 184 | description: "permission denied", |
| 185 | pathOrDescriptor: legacyPath, |
| 186 | }); |
| 187 | |
| 188 | return withIdentity( |
| 189 | Effect.gen(function* () { |
| 190 | const identity = yield* DesktopAppIdentity.DesktopAppIdentity; |
| 191 | const error = yield* identity.resolveUserDataPath.pipe(Effect.flip); |
| 192 | |
| 193 | assert.instanceOf(error, DesktopUserData.DesktopUserDataInitializationError); |
| 194 | assert.equal(error.resourcePath, legacyPath); |
| 195 | assert.strictEqual(error.cause, cause); |
| 196 | assert.equal( |
| 197 | error.message, |
| 198 | `Could not initialize Electron user data during inspect at ${legacyPath} (PermissionDenied).`, |
| 199 | ); |
| 200 | }), |
| 201 | { |
| 202 | legacyPathProbeError: cause, |
| 203 | environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } }, |
| 204 | }, |
| 205 | ); |
| 206 | }); |
| 207 | |
| 208 | it.effect("configures app identity from the environment commit override", () => { |
| 209 | const calls: ElectronAppCalls = { |
| 210 | setAboutPanelOptions: [], |
| 211 | setDockIcon: [], |
| 212 | setName: [], |
| 213 | }; |
| 214 | |
| 215 | return withIdentity( |
| 216 | Effect.gen(function* () { |
| 217 | const identity = yield* DesktopAppIdentity.DesktopAppIdentity; |
| 218 | yield* identity.configure; |
| 219 | |
| 220 | assert.deepEqual(calls.setName, ["T3 Code Alpha"]); |
| 221 | assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "The Knife"); |
| 222 | assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); |
| 223 | assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); |
| 224 | // Packaged: the bundle's own icon stands, so a custom one the user |
| 225 | // attached survives. |
| 226 | assert.deepEqual(calls.setDockIcon, []); |
| 227 | }), |
| 228 | { |
| 229 | calls, |
| 230 | environment: { |
| 231 | env: { |
| 232 | T3CODE_COMMIT_HASH: "0123456789abcdef", |
| 233 | }, |
| 234 | }, |
| 235 | pngIconPath: Option.some("/icon.png"), |
| 236 | }, |
| 237 | ); |
| 238 | }); |
| 239 | |
| 240 | it.effect.each([ |
| 241 | { stage: "Alpha", environment: {} }, |
| 242 | { |
| 243 | stage: "Nightly", |
| 244 | environment: { appVersion: "0.0.43-nightly.20260929.2428" }, |
| 245 | }, |
| 246 | { |
| 247 | stage: "Dev", |
| 248 | environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } }, |
| 249 | }, |
| 250 | ])( |
| 251 | "preserves the credential identity and valid native User-Agent for $stage", |
| 252 | ({ stage, environment }) => { |
| 253 | const calls: ElectronAppCalls = { |
| 254 | setAboutPanelOptions: [], |
| 255 | setDockIcon: [], |
| 256 | setName: [], |
| 257 | }; |
| 258 | |
| 259 | return withIdentity( |
| 260 | Effect.gen(function* () { |
| 261 | const identity = yield* DesktopAppIdentity.DesktopAppIdentity; |
| 262 | yield* identity.configure; |
| 263 | |
| 264 | const runtimeName = calls.setName[0]; |
| 265 | assert.isDefined(runtimeName); |
| 266 | assert.equal(runtimeName, `T3 Code ${stage}`); |
| 267 | // RFC 9110's token grammar, after Electron removes ASCII spaces. |
| 268 | assert.match(runtimeName.replaceAll(" ", ""), /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/); |
| 269 | assert.equal( |
| 270 | calls.setAboutPanelOptions[0]?.applicationName, |
| 271 | stage === "Alpha" ? "The Knife" : `The Knife (${stage})`, |
| 272 | ); |
| 273 | }), |
| 274 | { calls, environment }, |
| 275 | ); |
| 276 | }, |
| 277 | ); |
| 278 | |
| 279 | it.effect("sets the dock icon only when running unpackaged", () => { |
| 280 | const calls: ElectronAppCalls = { |
| 281 | setAboutPanelOptions: [], |
| 282 | setDockIcon: [], |
| 283 | setName: [], |
| 284 | }; |
| 285 | |
| 286 | return withIdentity( |
| 287 | Effect.gen(function* () { |
| 288 | const identity = yield* DesktopAppIdentity.DesktopAppIdentity; |
| 289 | yield* identity.configure; |
| 290 | |
| 291 | // Electron shows a generic icon for an unpackaged run, which is the |
| 292 | // reason this call exists at all. |
| 293 | assert.deepEqual(calls.setDockIcon, ["/icon.png"]); |
| 294 | }), |
| 295 | { |
| 296 | calls, |
| 297 | environment: { isPackaged: false }, |
| 298 | pngIconPath: Option.some("/icon.png"), |
| 299 | }, |
| 300 | ); |
| 301 | }); |
| 302 | }); |