| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Fiber from "effect/Fiber"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as PlatformError from "effect/PlatformError"; |
| 9 | import * as Ref from "effect/Ref"; |
| 10 | import { beforeEach, vi } from "vite-plus/test"; |
| 11 | |
| 12 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 13 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 14 | import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; |
| 15 | import * as DesktopLocalUi from "./DesktopLocalUi.ts"; |
| 16 | |
| 17 | const { handle } = vi.hoisted(() => ({ handle: vi.fn() })); |
| 18 | vi.mock("electron", () => ({ |
| 19 | net: { fetch: vi.fn() }, |
| 20 | protocol: { handle, unhandle: vi.fn() }, |
| 21 | })); |
| 22 | |
| 23 | const withUi = <A, E, R>( |
| 24 | test: ( |
| 25 | source: string, |
| 26 | ) => Effect.Effect<A, E, R | FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment>, |
| 27 | ) => |
| 28 | Effect.gen(function* () { |
| 29 | const fs = yield* FileSystem.FileSystem; |
| 30 | const home = yield* fs.makeTempDirectoryScoped(); |
| 31 | const source = `${home}/dev/t3code/apps/web/dist`; |
| 32 | const bundled = `${home}/bundle/apps/server/dist/client`; |
| 33 | yield* fs.makeDirectory(source, { recursive: true }); |
| 34 | yield* fs.makeDirectory(bundled, { recursive: true }); |
| 35 | yield* fs.writeFileString(`${bundled}/index.html`, "bundled"); |
| 36 | |
| 37 | return yield* test(source).pipe( |
| 38 | Effect.provide( |
| 39 | DesktopEnvironment.layer({ |
| 40 | dirname: `${home}/bundle/apps/desktop/dist-electron`, |
| 41 | homeDirectory: home, |
| 42 | platform: "darwin", |
| 43 | processArch: "arm64", |
| 44 | appVersion: "0.0.45", |
| 45 | appPath: `${home}/bundle`, |
| 46 | isPackaged: true, |
| 47 | resourcesPath: `${home}/resources`, |
| 48 | runningUnderArm64Translation: false, |
| 49 | }).pipe(Layer.provideMerge(DesktopConfig.layerTest({ T3CODE_HOME: home }))), |
| 50 | ), |
| 51 | ); |
| 52 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); |
| 53 | |
| 54 | const writeBuild = (source: string, version: string) => |
| 55 | Effect.gen(function* () { |
| 56 | const fs = yield* FileSystem.FileSystem; |
| 57 | yield* fs.makeDirectory(`${source}/.vite`, { recursive: true }); |
| 58 | yield* fs.writeFileString( |
| 59 | `${source}/index.html`, |
| 60 | `<html>${version}<script type="module" src="/app-${version}.js"></script></html>`, |
| 61 | ); |
| 62 | yield* fs.writeFileString(`${source}/app-${version}.js`, `export default "${version}";`); |
| 63 | yield* fs.writeFileString(`${source}/lazy-${version}.js`, `export default "lazy-${version}";`); |
| 64 | yield* fs.writeFileString( |
| 65 | `${source}/.vite/manifest.json`, |
| 66 | JSON.stringify({ |
| 67 | app: { file: `app-${version}.js` }, |
| 68 | lazy: { file: `lazy-${version}.js` }, |
| 69 | }), |
| 70 | ); |
| 71 | }); |
| 72 | |
| 73 | describe("DesktopLocalUi", () => { |
| 74 | beforeEach(() => handle.mockReset()); |
| 75 | |
| 76 | it.effect( |
| 77 | "uses the bundled UI after a full-app update instead of a snapshot from the previous version", |
| 78 | () => |
| 79 | withUi((source) => |
| 80 | Effect.gen(function* () { |
| 81 | const ui = yield* DesktopLocalUi.make; |
| 82 | yield* writeBuild(source, "local"); |
| 83 | yield* ui.apply(source, "local"); |
| 84 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 85 | const updated = yield* DesktopLocalUi.make.pipe( |
| 86 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, { |
| 87 | ...environment, |
| 88 | appVersion: "0.0.2000000000", |
| 89 | }), |
| 90 | ); |
| 91 | assert.deepEqual(yield* updated.assetDirectories, [environment.clientAssetsDir]); |
| 92 | const restarted = yield* DesktopLocalUi.make; |
| 93 | assert.deepEqual(yield* restarted.assetDirectories, yield* ui.assetDirectories); |
| 94 | }), |
| 95 | ), |
| 96 | ); |
| 97 | |
| 98 | it.effect( |
| 99 | "switches the stable renderer origin and retains assets requested by the old page", |
| 100 | () => |
| 101 | withUi((source) => |
| 102 | Effect.gen(function* () { |
| 103 | const fs = yield* FileSystem.FileSystem; |
| 104 | const ui = yield* DesktopLocalUi.make; |
| 105 | let request: ((request: Request) => Promise<Response>) | undefined; |
| 106 | handle.mockImplementation((_scheme, handler) => { |
| 107 | request = handler; |
| 108 | }); |
| 109 | const protocol = yield* ElectronProtocol.make; |
| 110 | yield* protocol.registerDesktopProtocol({ |
| 111 | scheme: "t3code", |
| 112 | clerkFrontendApiHostname: undefined, |
| 113 | assetDirectories: ui.assetDirectories, |
| 114 | }); |
| 115 | const read = (pathname: string) => |
| 116 | Effect.promise(async () => { |
| 117 | const response = await request!(new Request(`t3code://app${pathname}`)); |
| 118 | return { status: response.status, text: await response.text() }; |
| 119 | }); |
| 120 | |
| 121 | assert.equal((yield* read("/settings")).text, "bundled"); |
| 122 | yield* writeBuild(source, "one"); |
| 123 | yield* ui.apply(source, "local"); |
| 124 | const [first] = yield* ui.assetDirectories; |
| 125 | yield* fs.remove(source, { recursive: true }); |
| 126 | assert.include((yield* read("/thread/active")).text, "one"); |
| 127 | assert.equal((yield* read("/app-one.js")).text, 'export default "one";'); |
| 128 | |
| 129 | yield* writeBuild(source, "two"); |
| 130 | yield* ui.apply(source, "local"); |
| 131 | assert.include((yield* read("/thread/active")).text, "two"); |
| 132 | assert.equal((yield* read("/lazy-one.js")).text, 'export default "lazy-one";'); |
| 133 | |
| 134 | yield* writeBuild(source, "three"); |
| 135 | yield* ui.apply(source, "local"); |
| 136 | assert.isFalse(yield* fs.exists(first)); |
| 137 | const resumed = yield* DesktopLocalUi.make; |
| 138 | assert.deepEqual((yield* resumed.assetDirectories)[0], (yield* ui.assetDirectories)[0]); |
| 139 | |
| 140 | yield* ui.reset; |
| 141 | assert.equal((yield* read("/thread/active")).text, "bundled"); |
| 142 | const restored = yield* DesktopLocalUi.make; |
| 143 | assert.equal((yield* restored.assetDirectories)[0], (yield* ui.assetDirectories)[0]); |
| 144 | }), |
| 145 | ), |
| 146 | ); |
| 147 | |
| 148 | it.effect("rejects a partial build without switching away from the current UI", () => |
| 149 | withUi((source) => |
| 150 | Effect.gen(function* () { |
| 151 | const fs = yield* FileSystem.FileSystem; |
| 152 | const ui = yield* DesktopLocalUi.make; |
| 153 | yield* writeBuild(source, "one"); |
| 154 | yield* ui.apply(source, "local"); |
| 155 | const current = yield* ui.assetDirectories; |
| 156 | yield* writeBuild(source, "two"); |
| 157 | yield* fs.remove(`${source}/lazy-two.js`); |
| 158 | |
| 159 | const error = yield* ui.apply(source, "local").pipe(Effect.flip); |
| 160 | assert.include(error.message, "incomplete"); |
| 161 | assert.deepEqual(yield* ui.assetDirectories, current); |
| 162 | const resumed = yield* DesktopLocalUi.make; |
| 163 | assert.equal((yield* resumed.assetDirectories)[0], current[0]); |
| 164 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 165 | const entries = yield* fs.readDirectory( |
| 166 | `${environment.stateDir}/local-ui/${environment.appVersion}`, |
| 167 | ); |
| 168 | assert.equal(entries.filter((name) => name.startsWith("build-")).length, 1); |
| 169 | }), |
| 170 | ), |
| 171 | ); |
| 172 | |
| 173 | it.effect("leaves the current UI active when saving the selection fails", () => |
| 174 | withUi((source) => |
| 175 | Effect.gen(function* () { |
| 176 | const fs = yield* FileSystem.FileSystem; |
| 177 | const fail = yield* Ref.make(false); |
| 178 | const ui = yield* DesktopLocalUi.make.pipe( |
| 179 | Effect.provideService(FileSystem.FileSystem, { |
| 180 | ...fs, |
| 181 | rename: (from, to) => |
| 182 | Effect.gen(function* () { |
| 183 | if (yield* Ref.get(fail)) |
| 184 | return yield* PlatformError.systemError({ |
| 185 | _tag: "PermissionDenied", |
| 186 | module: "FileSystem", |
| 187 | method: "rename", |
| 188 | pathOrDescriptor: to, |
| 189 | }); |
| 190 | yield* fs.rename(from, to); |
| 191 | }), |
| 192 | }), |
| 193 | ); |
| 194 | yield* writeBuild(source, "one"); |
| 195 | yield* ui.apply(source, "local"); |
| 196 | const current = yield* ui.assetDirectories; |
| 197 | yield* writeBuild(source, "two"); |
| 198 | yield* Ref.set(fail, true); |
| 199 | yield* ui.apply(source, "local").pipe(Effect.flip); |
| 200 | assert.deepEqual(yield* ui.assetDirectories, current); |
| 201 | const resumed = yield* DesktopLocalUi.make; |
| 202 | assert.equal((yield* resumed.assetDirectories)[0], current[0]); |
| 203 | }), |
| 204 | ), |
| 205 | ); |
| 206 | |
| 207 | it.effect("rejects an empty application script", () => |
| 208 | withUi((source) => |
| 209 | Effect.gen(function* () { |
| 210 | const fs = yield* FileSystem.FileSystem; |
| 211 | const ui = yield* DesktopLocalUi.make; |
| 212 | const current = yield* ui.assetDirectories; |
| 213 | yield* writeBuild(source, "one"); |
| 214 | yield* fs.writeFileString(`${source}/app-one.js`, ""); |
| 215 | assert.include((yield* ui.apply(source, "local").pipe(Effect.flip)).message, "incomplete"); |
| 216 | assert.deepEqual(yield* ui.assetDirectories, current); |
| 217 | }), |
| 218 | ), |
| 219 | ); |
| 220 | |
| 221 | it.effect("rejects files changed while copying a build", () => |
| 222 | withUi((source) => |
| 223 | Effect.gen(function* () { |
| 224 | const fs = yield* FileSystem.FileSystem; |
| 225 | const ui = yield* DesktopLocalUi.make.pipe( |
| 226 | Effect.provideService(FileSystem.FileSystem, { |
| 227 | ...fs, |
| 228 | copy: (from, to) => |
| 229 | Effect.gen(function* () { |
| 230 | yield* fs.writeFileString(`${source}/app-one.js`, 'export default "partial";'); |
| 231 | yield* fs.copy(from, to); |
| 232 | }), |
| 233 | }), |
| 234 | ); |
| 235 | const current = yield* ui.assetDirectories; |
| 236 | yield* writeBuild(source, "one"); |
| 237 | assert.include((yield* ui.apply(source, "local").pipe(Effect.flip)).message, "incomplete"); |
| 238 | assert.deepEqual(yield* ui.assetDirectories, current); |
| 239 | }), |
| 240 | ), |
| 241 | ); |
| 242 | |
| 243 | it.effect("starts with bundled assets when the saved selection cannot be read", () => |
| 244 | withUi((source) => |
| 245 | Effect.gen(function* () { |
| 246 | const fs = yield* FileSystem.FileSystem; |
| 247 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 248 | const ui = yield* DesktopLocalUi.make; |
| 249 | yield* writeBuild(source, "one"); |
| 250 | yield* ui.apply(source, "local"); |
| 251 | const resumed = yield* DesktopLocalUi.make.pipe( |
| 252 | Effect.provideService(FileSystem.FileSystem, { |
| 253 | ...fs, |
| 254 | readFileString: (file, options) => |
| 255 | file.endsWith("/current") |
| 256 | ? Effect.fail( |
| 257 | PlatformError.systemError({ |
| 258 | _tag: "PermissionDenied", |
| 259 | module: "FileSystem", |
| 260 | method: "readFileString", |
| 261 | pathOrDescriptor: file, |
| 262 | }), |
| 263 | ) |
| 264 | : fs.readFileString(file, options), |
| 265 | }), |
| 266 | ); |
| 267 | assert.deepEqual(yield* resumed.assetDirectories, [environment.clientAssetsDir]); |
| 268 | yield* resumed.reset; |
| 269 | }), |
| 270 | ), |
| 271 | ); |
| 272 | |
| 273 | it.effect("keeps the current UI available while staging and after an interrupted update", () => |
| 274 | withUi((source) => |
| 275 | Effect.gen(function* () { |
| 276 | const fs = yield* FileSystem.FileSystem; |
| 277 | const started = yield* Deferred.make<void>(); |
| 278 | const finish = yield* Deferred.make<void>(); |
| 279 | const ui = yield* DesktopLocalUi.make.pipe( |
| 280 | Effect.provideService(FileSystem.FileSystem, { |
| 281 | ...fs, |
| 282 | copy: (from, to) => |
| 283 | Effect.gen(function* () { |
| 284 | yield* Deferred.succeed(started, undefined); |
| 285 | yield* Deferred.await(finish); |
| 286 | yield* fs.copy(from, to); |
| 287 | }), |
| 288 | }), |
| 289 | ); |
| 290 | yield* writeBuild(source, "one"); |
| 291 | const current = yield* ui.assetDirectories; |
| 292 | const update = yield* ui.apply(source, "local").pipe(Effect.forkChild); |
| 293 | yield* Deferred.await(started); |
| 294 | assert.deepEqual(yield* ui.assetDirectories, current); |
| 295 | yield* Fiber.interrupt(update); |
| 296 | assert.deepEqual(yield* ui.assetDirectories, current); |
| 297 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 298 | const entries = yield* fs.readDirectory( |
| 299 | `${environment.stateDir}/local-ui/${environment.appVersion}`, |
| 300 | ); |
| 301 | assert.isEmpty(entries); |
| 302 | }), |
| 303 | ), |
| 304 | ); |
| 305 | }); |