import { assert, describe, it } from "@effect/vitest";import * as NodeServices from "@effect/platform-node/NodeServices";import type { DesktopUpdateState } from "@t3tools/contracts";import * as Cause from "effect/Cause";import * as Deferred from "effect/Deferred";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Fiber from "effect/Fiber";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import { TestClock } from "effect/testing"; import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";import * as DesktopConfig from "../app/DesktopConfig.ts";import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";import * as ElectronUpdater from "../electron/ElectronUpdater.ts";import * as ElectronWindow from "../electron/ElectronWindow.ts";import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";import * as DesktopState from "../app/DesktopState.ts";import * as DesktopUpdates from "./DesktopUpdates.ts"; interface UpdatesHarnessOptions { readonly checkForUpdates?: Effect.Effect< void, ElectronUpdater.ElectronUpdaterCheckForUpdatesError >; readonly env?: Record<string, string | undefined>;} const flushCallbacks = Effect.yieldNow; function makeHarness(options: UpdatesHarnessOptions = {}) { let checkCount = 0; let allowDowngrade = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>(); const sentStates: DesktopUpdateState[] = []; const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { const eventListeners = listeners.get(eventName) ?? new Set(); eventListeners.add(listener); listeners.set(eventName, eventListeners); }; const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { const eventListeners = listeners.get(eventName); if (!eventListeners) { return; } eventListeners.delete(listener); if (eventListeners.size === 0) { listeners.delete(eventName); } }; const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, { setFeedURL: (options) => Effect.sync(() => { feedUrls.push(options); }), setAutoDownload: () => Effect.void, setAutoInstallOnAppQuit: () => Effect.void, setChannel: () => Effect.void, setAllowPrerelease: () => Effect.void, allowDowngrade: Effect.sync(() => allowDowngrade), setAllowDowngrade: (value) => Effect.sync(() => { allowDowngrade = value; }), setDisableDifferentialDownload: () => Effect.void, checkForUpdates: Effect.sync(() => { checkCount += 1; }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), downloadUpdate: Effect.void, quitAndInstall: () => Effect.void, on: (eventName, listener) => Effect.acquireRelease( Effect.sync(() => { addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); }), () => Effect.sync(() => { removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); }), ).pipe(Effect.asVoid), } satisfies ElectronUpdater.ElectronUpdaterShape); const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { create: () => Effect.die("unexpected BrowserWindow creation"), main: Effect.succeed(Option.none()), currentMainOrFirst: Effect.succeed(Option.none()), focusedMainOrFirst: Effect.succeed(Option.none()), setMain: () => Effect.void, clearMain: () => Effect.void, reveal: () => Effect.void, sendAll: (_channel, state) => Effect.sync(() => { sentStates.push(state as DesktopUpdateState); }), destroyAll: Effect.void, syncAllAppearance: () => Effect.void, } satisfies ElectronWindow.ElectronWindowShape); const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { start: Effect.void, stop: () => Effect.void, currentConfig: Effect.succeed(Option.none()), snapshot: Effect.succeed({ desiredRunning: false, ready: false, activePid: Option.none(), restartAttempt: 0, restartScheduled: false, }), }); const environmentLayer = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, platform: "darwin", processArch: "x64", appVersion: "1.2.3", appPath: "/repo", isPackaged: true, resourcesPath: "/missing/resources", runningUnderArm64Translation: false, }).pipe( Layer.provide( Layer.mergeAll( NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, T3CODE_DESKTOP_MOCK_UPDATES: "true", T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", ...options.env, }), ), ), ); const layer = DesktopUpdates.layer.pipe( Layer.provideMerge(updaterLayer), Layer.provideMerge(windowLayer), Layer.provideMerge(backendLayer), Layer.provideMerge(DesktopState.layer), Layer.provideMerge(DesktopAppSettings.layer), Layer.provideMerge( DesktopConfig.layerTest({ T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, T3CODE_DESKTOP_MOCK_UPDATES: "true", T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", ...options.env, }), ), Layer.provideMerge(environmentLayer), Layer.provideMerge(NodeServices.layer), ); return { layer, checkCount: () => checkCount, feedUrls: () => feedUrls, listenerCount: () => Array.from(listeners.values()).reduce( (total, eventListeners) => total + eventListeners.size, 0, ), sentStates, emit: (eventName: string, payload?: unknown) => { for (const listener of listeners.get(eventName) ?? []) { listener(payload); } }, };} describe("DesktopUpdates", () => { it.effect("configures the updater and runs startup checks on the test clock", () => { const harness = makeHarness(); return Effect.gen(function* () { yield* Effect.scoped( Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; const state = yield* updates.getState; assert.equal(state.enabled, true); assert.equal(state.status, "idle"); assert.deepEqual(harness.feedUrls(), [ { provider: "generic", url: "http://localhost:4141" }, ]); assert.equal(harness.listenerCount(), 6); assert.equal(harness.checkCount(), 0); yield* TestClock.adjust(Duration.millis(15_000)); assert.equal(harness.checkCount(), 1); }), ); assert.equal(harness.listenerCount(), 0); }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); it.effect("updates and broadcasts state from updater events", () => { const harness = makeHarness(); return Effect.scoped( Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; harness.emit("update-available", { version: "1.2.4" }); yield* flushCallbacks; const state = yield* updates.getState; assert.equal(state.status, "available"); assert.equal(state.availableVersion, "1.2.4"); assert.isNotNull(state.checkedAt); assert.equal(harness.sentStates.at(-1)?.status, "available"); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); it.effect("persists channel changes through the settings service", () => { const harness = makeHarness(); return Effect.scoped( Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; const state = yield* updates.setChannel("nightly"); const persistedSettings = yield* settings.get; assert.equal(state.channel, "nightly"); assert.equal(persistedSettings.updateChannel, "nightly"); assert.equal(persistedSettings.updateChannelConfiguredByUser, true); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); it.effect("does not persist an unchanged update channel as a user preference", () => { const harness = makeHarness(); return Effect.scoped( Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; const state = yield* updates.setChannel("latest"); const persistedSettings = yield* settings.get; assert.equal(state.channel, "latest"); assert.equal(persistedSettings.updateChannel, "latest"); assert.equal(persistedSettings.updateChannelConfiguredByUser, false); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); it.effect("fails channel changes with a typed error while a check is in progress", () => Effect.gen(function* () { const checkStarted = yield* Deferred.make<void>(); const releaseCheck = yield* Deferred.make<void>(); const harness = makeHarness({ checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe( Effect.andThen(Deferred.await(releaseCheck)), ), }); yield* Effect.scoped( Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped); yield* Deferred.await(checkStarted); const exit = yield* Effect.exit(updates.setChannel("nightly")); assert.equal(exit._tag, "Failure"); if (exit._tag === "Failure") { const error = Cause.squash(exit.cause); assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError); assert.equal(error.action, "check"); } yield* Deferred.succeed(releaseCheck, undefined); yield* Fiber.join(checkFiber); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }), );});
import { assert, describe, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import type { DesktopUpdateState } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import { TestClock } from "effect/testing";
import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopState from "../app/DesktopState.ts";
import * as DesktopUpdates from "./DesktopUpdates.ts";
interface UpdatesHarnessOptions {
readonly checkForUpdates?: Effect.Effect<
void,
ElectronUpdater.ElectronUpdaterCheckForUpdatesError
>;
readonly env?: Record<string, string | undefined>;
}
const flushCallbacks = Effect.yieldNow;
function makeHarness(options: UpdatesHarnessOptions = {}) {
let checkCount = 0;
let allowDowngrade = false;
const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = [];
const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>();
const sentStates: DesktopUpdateState[] = [];
const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => {
const eventListeners = listeners.get(eventName) ?? new Set();
eventListeners.add(listener);
listeners.set(eventName, eventListeners);
};
const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => {
const eventListeners = listeners.get(eventName);
if (!eventListeners) {
return;
}
eventListeners.delete(listener);
if (eventListeners.size === 0) {
listeners.delete(eventName);
}
};
const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, {
setFeedURL: (options) =>
Effect.sync(() => {
feedUrls.push(options);
}),
setAutoDownload: () => Effect.void,
setAutoInstallOnAppQuit: () => Effect.void,
setChannel: () => Effect.void,
setAllowPrerelease: () => Effect.void,
allowDowngrade: Effect.sync(() => allowDowngrade),
setAllowDowngrade: (value) =>
Effect.sync(() => {
allowDowngrade = value;
}),
setDisableDifferentialDownload: () => Effect.void,
checkForUpdates: Effect.sync(() => {
checkCount += 1;
}).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)),
downloadUpdate: Effect.void,
quitAndInstall: () => Effect.void,
on: (eventName, listener) =>
Effect.acquireRelease(
Effect.sync(() => {
addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void);
}),
() =>
Effect.sync(() => {
removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void);
}),
).pipe(Effect.asVoid),
} satisfies ElectronUpdater.ElectronUpdaterShape);
const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, {
create: () => Effect.die("unexpected BrowserWindow creation"),
main: Effect.succeed(Option.none()),
currentMainOrFirst: Effect.succeed(Option.none()),
focusedMainOrFirst: Effect.succeed(Option.none()),
setMain: () => Effect.void,
clearMain: () => Effect.void,
reveal: () => Effect.void,
sendAll: (_channel, state) =>
Effect.sync(() => {
sentStates.push(state as DesktopUpdateState);
}),
destroyAll: Effect.void,
syncAllAppearance: () => Effect.void,
} satisfies ElectronWindow.ElectronWindowShape);
const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, {
start: Effect.void,
stop: () => Effect.void,
currentConfig: Effect.succeed(Option.none()),
snapshot: Effect.succeed({
desiredRunning: false,
ready: false,
activePid: Option.none(),
restartAttempt: 0,
restartScheduled: false,
}),
});
const environmentLayer = DesktopEnvironment.layer({
dirname: "/repo/apps/desktop/src",
homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`,
platform: "darwin",
processArch: "x64",
appVersion: "1.2.3",
appPath: "/repo",
isPackaged: true,
resourcesPath: "/missing/resources",
runningUnderArm64Translation: false,
}).pipe(
Layer.provide(
Layer.mergeAll(
NodeServices.layer,
DesktopConfig.layerTest({
T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`,
T3CODE_DESKTOP_MOCK_UPDATES: "true",
T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141",
...options.env,
}),
),
),
);
const layer = DesktopUpdates.layer.pipe(
Layer.provideMerge(updaterLayer),
Layer.provideMerge(windowLayer),
Layer.provideMerge(backendLayer),
Layer.provideMerge(DesktopState.layer),
Layer.provideMerge(DesktopAppSettings.layer),
Layer.provideMerge(
DesktopConfig.layerTest({
T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`,
T3CODE_DESKTOP_MOCK_UPDATES: "true",
T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141",
...options.env,
}),
),
Layer.provideMerge(environmentLayer),
Layer.provideMerge(NodeServices.layer),
);
return {
layer,
checkCount: () => checkCount,
feedUrls: () => feedUrls,
listenerCount: () =>
Array.from(listeners.values()).reduce(
(total, eventListeners) => total + eventListeners.size,
0,
),
sentStates,
emit: (eventName: string, payload?: unknown) => {
for (const listener of listeners.get(eventName) ?? []) {
listener(payload);
}
},
};
}
describe("DesktopUpdates", () => {
it.effect("configures the updater and runs startup checks on the test clock", () => {
const harness = makeHarness();
return Effect.gen(function* () {
yield* Effect.scoped(
Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* updates.configure;
const state = yield* updates.getState;
assert.equal(state.enabled, true);
assert.equal(state.status, "idle");
assert.deepEqual(harness.feedUrls(), [
{ provider: "generic", url: "http://localhost:4141" },
]);
assert.equal(harness.listenerCount(), 6);
assert.equal(harness.checkCount(), 0);
yield* TestClock.adjust(Duration.millis(15_000));
assert.equal(harness.checkCount(), 1);
}),
);
assert.equal(harness.listenerCount(), 0);
}).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});
it.effect("updates and broadcasts state from updater events", () => {
const harness = makeHarness();
return Effect.scoped(
Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* updates.configure;
harness.emit("update-available", { version: "1.2.4" });
yield* flushCallbacks;
const state = yield* updates.getState;
assert.equal(state.status, "available");
assert.equal(state.availableVersion, "1.2.4");
assert.isNotNull(state.checkedAt);
assert.equal(harness.sentStates.at(-1)?.status, "available");
}),
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});
it.effect("persists channel changes through the settings service", () => {
const harness = makeHarness();
return Effect.scoped(
Effect.gen(function* () {
const settings = yield* DesktopAppSettings.DesktopAppSettings;
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* updates.configure;
const state = yield* updates.setChannel("nightly");
const persistedSettings = yield* settings.get;
assert.equal(state.channel, "nightly");
assert.equal(persistedSettings.updateChannel, "nightly");
assert.equal(persistedSettings.updateChannelConfiguredByUser, true);
}),
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});
it.effect("does not persist an unchanged update channel as a user preference", () => {
const harness = makeHarness();
return Effect.scoped(
Effect.gen(function* () {
const settings = yield* DesktopAppSettings.DesktopAppSettings;
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* updates.configure;
const state = yield* updates.setChannel("latest");
const persistedSettings = yield* settings.get;
assert.equal(state.channel, "latest");
assert.equal(persistedSettings.updateChannel, "latest");
assert.equal(persistedSettings.updateChannelConfiguredByUser, false);
}),
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});
it.effect("fails channel changes with a typed error while a check is in progress", () =>
Effect.gen(function* () {
const checkStarted = yield* Deferred.make<void>();
const releaseCheck = yield* Deferred.make<void>();
const harness = makeHarness({
checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseCheck)),
),
});
yield* Effect.scoped(
Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* updates.configure;
const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped);
yield* Deferred.await(checkStarted);
const exit = yield* Effect.exit(updates.setChannel("nightly"));
assert.equal(exit._tag, "Failure");
if (exit._tag === "Failure") {
const error = Cause.squash(exit.cause);
assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError);
assert.equal(error.action, "check");
}
yield* Deferred.succeed(releaseCheck, undefined);
yield* Fiber.join(checkFiber);
}),
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
}),
);
});