1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Deferred from "effect/Deferred";
5import * as Fiber from "effect/Fiber";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as PlatformError from "effect/PlatformError";
9import * as Ref from "effect/Ref";
10import { beforeEach, vi } from "vite-plus/test";
11
12import * as DesktopConfig from "../app/DesktopConfig.ts";
13import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
14import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
15import * as DesktopLocalUi from "./DesktopLocalUi.ts";
16
17const { handle } = vi.hoisted(() => ({ handle: vi.fn() }));
18vi.mock("electron", () => ({
19 net: { fetch: vi.fn() },
20 protocol: { handle, unhandle: vi.fn() },
21}));
22
23const 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
54const 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
73describe("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});