import * as NodeServices from "@effect/platform-node/NodeServices";import { expect, it } from "@effect/vitest";import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts";import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";import * as Cause from "effect/Cause";import * as Deferred from "effect/Deferred";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Fiber from "effect/Fiber";import * as Path from "effect/Path";import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ServerConfig from "../config.ts";import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts";import * as ProcessRunner from "../processRunner.ts";import * as ServiceLauncherClient from "./serviceLauncherClient.ts";import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";import * as ServerSelfUpdate from "./selfUpdate.ts"; interface HarnessOptions { readonly mode?: "web" | "desktop"; readonly managed?: boolean; readonly preflight?: "ready" | "blocked"; readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"]; readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"];} const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( options: HarnessOptions = {},) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); const order: string[] = []; const runner = ProcessRunner.ProcessRunner.of({ run: (input) => Effect.gen(function* () { if (input.command === "npm") { order.push("install"); const prefix = input.args[input.args.indexOf("--prefix") + 1]; if (prefix === undefined) return yield* Effect.die("missing npm prefix"); const entry = path.join(prefix, "node_modules", "t3", "dist", "bin.mjs"); yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie); yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie); return { stdout: "", stderr: "", code: ChildProcessSpawner.ExitCode(0), timedOut: false, stdoutTruncated: false, stderrTruncated: false, stdoutInvalidUtf8: false, stderrInvalidUtf8: false, }; } order.push("preflight"); const result = options.preflight === "blocked" ? { status: "blocked", version: "1.1.0", reason: "local update required" } : { status: "ready", version: "1.1.0", launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, }; return { // @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout. stdout: JSON.stringify(result), stderr: "", code: ChildProcessSpawner.ExitCode(0), timedOut: false, stdoutTruncated: false, stderrTruncated: false, stdoutInvalidUtf8: false, stderrInvalidUtf8: false, }; }), }); const launcher = ServiceLauncherClient.ServiceLauncherClient.of({ managed: options.managed ?? true, requestUpdate: options.requestUpdate ?? (() => Effect.sync(() => { order.push("accept"); return "launcher-id"; })), prepareTrial: Effect.sync((): undefined => undefined), }); const config = yield* ServerConfig.ServerConfig.pipe( Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), ); const selfUpdate = yield* ServerSelfUpdate.make().pipe( Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, launcher), Effect.provideService( DesktopAppUpdate.DesktopAppUpdate, options.desktopAppUpdate ?? { available: false, run: () => Effect.die("unexpected desktop app update run"), }, ), Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"), Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })), ); return { selfUpdate, order };}); it.layer(NodeServices.layer)("server self update", (it) => { it.effect("marks running threads at the boot-service handoff", () => Effect.gen(function* () { const events: string[] = []; const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({ mode: "web", selfUpdate: { update: (_input, reportProgress = () => Effect.void) => reportProgress("downloading").pipe( Effect.andThen(reportProgress("installing")), Effect.as({ targetVersion: "1.1.0", method: "boot-service" as const, updateId: "update-id", }), ), commitDesktopUpdate: () => Effect.never, }, prepare: Effect.sync(() => { events.push("prepare"); return [ThreadId.make("thread-running")]; }), clear: () => Effect.sync(() => void events.push("clear")), }); yield* selfUpdate.update({ targetVersion: "1.1.0", continueRunningThreads: true }, (stage) => Effect.sync(() => void events.push(stage)), ); expect(events).toEqual(["downloading", "prepare", "installing"]); }), ); it.effect("marks desktop threads only when the prepared update commits", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-running-desktop"); const events: string[] = []; const commitError = new ServerSelfUpdateError({ reason: "install failed" }); const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({ mode: "desktop", selfUpdate: { update: (_input, reportProgress = () => Effect.void) => reportProgress("installing").pipe( Effect.as({ targetVersion: "1.2.0", method: "desktop-app" as const, desktopUpdateToken: "desktop-token", }), ), commitDesktopUpdate: () => Effect.sync(() => events.push("commit")).pipe(Effect.andThen(Effect.fail(commitError))), }, prepare: Effect.sync(() => { events.push("prepare"); return [threadId]; }), clear: (threadIds) => Effect.sync(() => void events.push(`clear:${threadIds.join(",")}`)), }); yield* selfUpdate.update({ targetVersion: "1.2.0", continueRunningThreads: true }, (stage) => Effect.sync(() => void events.push(stage)), ); expect(events).toEqual(["installing"]); expect(yield* selfUpdate.commitDesktopUpdate("desktop-token").pipe(Effect.flip)).toBe( commitError, ); expect(events).toEqual(["installing", "prepare", "commit", `clear:${threadId}`]); expect(yield* selfUpdate.commitDesktopUpdate("desktop-token").pipe(Effect.flip)).toBe( commitError, ); expect(events).toEqual([ "installing", "prepare", "commit", `clear:${threadId}`, "prepare", "commit", `clear:${threadId}`, ]); }), ); it.effect("reports a failed continuation-marker cleanup", () => Effect.gen(function* () { const updateError = new ServerSelfUpdateError({ reason: "update failed" }); const clearError = new ServerSelfUpdateError({ reason: "marker cleanup failed" }); const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({ mode: "web", selfUpdate: { update: (_input, reportProgress = () => Effect.void) => reportProgress("installing").pipe(Effect.andThen(Effect.fail(updateError))), commitDesktopUpdate: () => Effect.never, }, prepare: Effect.succeed([ThreadId.make("thread-cleanup-failure")]), clear: () => Effect.fail(clearError), }); expect( yield* selfUpdate .update({ targetVersion: "1.1.0", continueRunningThreads: true }) .pipe(Effect.flip), ).toBe(clearError); }), ); it.effect("keeps continuation markers after the boot-service handoff is accepted", () => Effect.gen(function* () { const events: string[] = []; const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({ mode: "web", selfUpdate: { update: ( _input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void, ) => reportProgress("installing").pipe( Effect.andThen(onHandoffAccepted()), Effect.andThen(Effect.interrupt), ), commitDesktopUpdate: () => Effect.never, }, prepare: Effect.sync(() => { events.push("prepare"); return [ThreadId.make("thread-accepted-boot-handoff")]; }), clear: () => Effect.sync(() => void events.push("clear")), }); const exit = yield* selfUpdate .update({ targetVersion: "1.1.0", continueRunningThreads: true }) .pipe(Effect.exit); expect(exit._tag).toBe("Failure"); expect(events).toEqual(["prepare"]); }), ); it.effect("keeps continuation markers after the desktop handoff is accepted", () => Effect.gen(function* () { const events: string[] = []; const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({ mode: "desktop", selfUpdate: { update: () => Effect.succeed({ targetVersion: "1.2.0", method: "desktop-app" as const, desktopUpdateToken: "accepted-desktop-token", }), commitDesktopUpdate: (_requestId, onHandoffAccepted = () => Effect.void) => onHandoffAccepted().pipe(Effect.andThen(Effect.interrupt)), }, prepare: Effect.sync(() => { events.push("prepare"); return [ThreadId.make("thread-accepted-desktop-handoff")]; }), clear: () => Effect.sync(() => void events.push("clear")), }); yield* selfUpdate.update({ targetVersion: "1.2.0", continueRunningThreads: true, }); const exit = yield* selfUpdate .commitDesktopUpdate("accepted-desktop-token") .pipe(Effect.exit); expect(exit._tag).toBe("Failure"); expect(events).toEqual(["prepare"]); }), ); it.effect("clears continuation markers for mixed failure and interrupt causes", () => Effect.gen(function* () { const events: string[] = []; const commitError = new ServerSelfUpdateError({ reason: "install failed" }); const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({ mode: "desktop", selfUpdate: { update: () => Effect.succeed({ targetVersion: "1.2.0", method: "desktop-app" as const, desktopUpdateToken: "failed-desktop-token", }), commitDesktopUpdate: (_requestId, onHandoffAccepted = () => Effect.void) => onHandoffAccepted().pipe( Effect.andThen( Effect.failCause( Cause.fromReasons([ Cause.makeFailReason(commitError), Cause.makeInterruptReason(), ]), ), ), ), }, prepare: Effect.sync(() => [ThreadId.make("thread-failed-desktop-install")]), clear: () => Effect.sync(() => void events.push("clear")), }); yield* selfUpdate.update({ targetVersion: "1.2.0", continueRunningThreads: true, }); const exit = yield* selfUpdate.commitDesktopUpdate("failed-desktop-token").pipe(Effect.exit); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { expect(Cause.hasInterrupts(exit.cause)).toBe(true); expect(Cause.hasInterruptsOnly(exit.cause)).toBe(false); } expect(events).toEqual(["clear"]); }), ); it.effect("stages and preflights before asking the launcher for an update ID", () => Effect.gen(function* () { const { selfUpdate, order } = yield* makeHarness(); expect(yield* selfUpdate.update({ targetVersion: "1.1.0" })).toEqual({ targetVersion: "1.1.0", method: "boot-service", updateId: "launcher-id", }); expect(order).toEqual(["install", "preflight", "accept"]); }), ); it.effect("rejects invalid versions and desktop-managed servers before staging", () => Effect.gen(function* () { const web = yield* makeHarness(); expect( (yield* web.selfUpdate.update({ targetVersion: "latest" }).pipe(Effect.flip)).reason, ).toBe("'latest' is not an exact t3 version."); const desktop = yield* makeHarness({ mode: "desktop" }); expect( (yield* desktop.selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason, ).toContain("desktop app"); expect([...web.order, ...desktop.order]).toEqual([]); }), ); it.effect("delegates desktop-managed updates to the desktop app when available", () => Effect.gen(function* () { const stages: string[] = []; const { selfUpdate, order } = yield* makeHarness({ mode: "desktop", desktopAppUpdate: { available: true, run: (reportProgress) => reportProgress("downloading").pipe( Effect.andThen(reportProgress("installing")), Effect.as({ targetVersion: "1.2.0", method: "desktop-app" as const }), ), commit: () => Effect.never, }, }); const result = yield* selfUpdate.update({ targetVersion: "1.1.0" }, (stage) => Effect.sync(() => void stages.push(stage)), ); expect(result).toEqual({ targetVersion: "1.2.0", method: "desktop-app" }); expect(stages).toEqual(["downloading", "installing"]); // The launcher staging path must not run on the desktop path. expect(order).toEqual([]); }), ); it.effect("preserves the preflight refusal reason", () => Effect.gen(function* () { const { selfUpdate } = yield* makeHarness({ preflight: "blocked" }); expect((yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason).toBe( "local update required", ); }), ); it.effect("allows only one update at a time", () => Effect.gen(function* () { const requested = yield* Deferred.make<void>(); const accepted = yield* Deferred.make<string>(); const { selfUpdate } = yield* makeHarness({ requestUpdate: () => Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(accepted))), }); const first = yield* Effect.forkChild(selfUpdate.update({ targetVersion: "1.1.0" }), { startImmediately: true, }); yield* Deferred.await(requested); expect((yield* selfUpdate.update({ targetVersion: "1.1.1" }).pipe(Effect.flip)).reason).toBe( "A server update is already in progress.", ); yield* Deferred.succeed(accepted, "launcher-id"); expect((yield* Fiber.join(first)).updateId).toBe("launcher-id"); }), );});
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts";
import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";
import * as Cause from "effect/Cause";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Fiber from "effect/Fiber";
import * as Path from "effect/Path";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
import * as ServerConfig from "../config.ts";
import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as ServiceLauncherClient from "./serviceLauncherClient.ts";
import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
import * as ServerSelfUpdate from "./selfUpdate.ts";
interface HarnessOptions {
readonly mode?: "web" | "desktop";
readonly managed?: boolean;
readonly preflight?: "ready" | "blocked";
readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"];
readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"];
}
const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
options: HarnessOptions = {},
) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" });
const order: string[] = [];
const runner = ProcessRunner.ProcessRunner.of({
run: (input) =>
Effect.gen(function* () {
if (input.command === "npm") {
order.push("install");
const prefix = input.args[input.args.indexOf("--prefix") + 1];
if (prefix === undefined) return yield* Effect.die("missing npm prefix");
const entry = path.join(prefix, "node_modules", "t3", "dist", "bin.mjs");
yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie);
yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie);
return {
stdout: "",
stderr: "",
code: ChildProcessSpawner.ExitCode(0),
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
};
}
order.push("preflight");
const result =
options.preflight === "blocked"
? { status: "blocked", version: "1.1.0", reason: "local update required" }
: {
status: "ready",
version: "1.1.0",
launcherProtocol: SERVICE_LAUNCHER_PROTOCOL,
};
return {
// @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout.
stdout: JSON.stringify(result),
stderr: "",
code: ChildProcessSpawner.ExitCode(0),
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
};
}),
});
const launcher = ServiceLauncherClient.ServiceLauncherClient.of({
managed: options.managed ?? true,
requestUpdate:
options.requestUpdate ??
(() =>
Effect.sync(() => {
order.push("accept");
return "launcher-id";
})),
prepareTrial: Effect.sync((): undefined => undefined),
});
const config = yield* ServerConfig.ServerConfig.pipe(
Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
);
const selfUpdate = yield* ServerSelfUpdate.make().pipe(
Effect.provideService(ProcessRunner.ProcessRunner, runner),
Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, launcher),
Effect.provideService(
DesktopAppUpdate.DesktopAppUpdate,
options.desktopAppUpdate ?? {
available: false,
run: () => Effect.die("unexpected desktop app update run"),
},
),
Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"),
Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })),
);
return { selfUpdate, order };
});
it.layer(NodeServices.layer)("server self update", (it) => {
it.effect("marks running threads at the boot-service handoff", () =>
Effect.gen(function* () {
const events: string[] = [];
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
mode: "web",
selfUpdate: {
update: (_input, reportProgress = () => Effect.void) =>
reportProgress("downloading").pipe(
Effect.andThen(reportProgress("installing")),
Effect.as({
targetVersion: "1.1.0",
method: "boot-service" as const,
updateId: "update-id",
}),
),
commitDesktopUpdate: () => Effect.never,
},
prepare: Effect.sync(() => {
events.push("prepare");
return [ThreadId.make("thread-running")];
}),
clear: () => Effect.sync(() => void events.push("clear")),
});
yield* selfUpdate.update({ targetVersion: "1.1.0", continueRunningThreads: true }, (stage) =>
Effect.sync(() => void events.push(stage)),
);
expect(events).toEqual(["downloading", "prepare", "installing"]);
}),
);
it.effect("marks desktop threads only when the prepared update commits", () =>
Effect.gen(function* () {
const threadId = ThreadId.make("thread-running-desktop");
const events: string[] = [];
const commitError = new ServerSelfUpdateError({ reason: "install failed" });
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
mode: "desktop",
selfUpdate: {
update: (_input, reportProgress = () => Effect.void) =>
reportProgress("installing").pipe(
Effect.as({
targetVersion: "1.2.0",
method: "desktop-app" as const,
desktopUpdateToken: "desktop-token",
}),
),
commitDesktopUpdate: () =>
Effect.sync(() => events.push("commit")).pipe(Effect.andThen(Effect.fail(commitError))),
},
prepare: Effect.sync(() => {
events.push("prepare");
return [threadId];
}),
clear: (threadIds) => Effect.sync(() => void events.push(`clear:${threadIds.join(",")}`)),
});
yield* selfUpdate.update({ targetVersion: "1.2.0", continueRunningThreads: true }, (stage) =>
Effect.sync(() => void events.push(stage)),
);
expect(events).toEqual(["installing"]);
expect(yield* selfUpdate.commitDesktopUpdate("desktop-token").pipe(Effect.flip)).toBe(
commitError,
);
expect(events).toEqual(["installing", "prepare", "commit", `clear:${threadId}`]);
expect(yield* selfUpdate.commitDesktopUpdate("desktop-token").pipe(Effect.flip)).toBe(
commitError,
);
expect(events).toEqual([
"installing",
"prepare",
"commit",
`clear:${threadId}`,
"prepare",
"commit",
`clear:${threadId}`,
]);
}),
);
it.effect("reports a failed continuation-marker cleanup", () =>
Effect.gen(function* () {
const updateError = new ServerSelfUpdateError({ reason: "update failed" });
const clearError = new ServerSelfUpdateError({ reason: "marker cleanup failed" });
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
mode: "web",
selfUpdate: {
update: (_input, reportProgress = () => Effect.void) =>
reportProgress("installing").pipe(Effect.andThen(Effect.fail(updateError))),
commitDesktopUpdate: () => Effect.never,
},
prepare: Effect.succeed([ThreadId.make("thread-cleanup-failure")]),
clear: () => Effect.fail(clearError),
});
expect(
yield* selfUpdate
.update({ targetVersion: "1.1.0", continueRunningThreads: true })
.pipe(Effect.flip),
).toBe(clearError);
}),
);
it.effect("keeps continuation markers after the boot-service handoff is accepted", () =>
Effect.gen(function* () {
const events: string[] = [];
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
mode: "web",
selfUpdate: {
update: (
_input,
reportProgress = () => Effect.void,
onHandoffAccepted = () => Effect.void,
) =>
reportProgress("installing").pipe(
Effect.andThen(onHandoffAccepted()),
Effect.andThen(Effect.interrupt),
),
commitDesktopUpdate: () => Effect.never,
},
prepare: Effect.sync(() => {
events.push("prepare");
return [ThreadId.make("thread-accepted-boot-handoff")];
}),
clear: () => Effect.sync(() => void events.push("clear")),
});
const exit = yield* selfUpdate
.update({ targetVersion: "1.1.0", continueRunningThreads: true })
.pipe(Effect.exit);
expect(exit._tag).toBe("Failure");
expect(events).toEqual(["prepare"]);
}),
);
it.effect("keeps continuation markers after the desktop handoff is accepted", () =>
Effect.gen(function* () {
const events: string[] = [];
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
mode: "desktop",
selfUpdate: {
update: () =>
Effect.succeed({
targetVersion: "1.2.0",
method: "desktop-app" as const,
desktopUpdateToken: "accepted-desktop-token",
}),
commitDesktopUpdate: (_requestId, onHandoffAccepted = () => Effect.void) =>
onHandoffAccepted().pipe(Effect.andThen(Effect.interrupt)),
},
prepare: Effect.sync(() => {
events.push("prepare");
return [ThreadId.make("thread-accepted-desktop-handoff")];
}),
clear: () => Effect.sync(() => void events.push("clear")),
});
yield* selfUpdate.update({
targetVersion: "1.2.0",
continueRunningThreads: true,
});
const exit = yield* selfUpdate
.commitDesktopUpdate("accepted-desktop-token")
.pipe(Effect.exit);
expect(exit._tag).toBe("Failure");
expect(events).toEqual(["prepare"]);
}),
);
it.effect("clears continuation markers for mixed failure and interrupt causes", () =>
Effect.gen(function* () {
const events: string[] = [];
const commitError = new ServerSelfUpdateError({ reason: "install failed" });
const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
mode: "desktop",
selfUpdate: {
update: () =>
Effect.succeed({
targetVersion: "1.2.0",
method: "desktop-app" as const,
desktopUpdateToken: "failed-desktop-token",
}),
commitDesktopUpdate: (_requestId, onHandoffAccepted = () => Effect.void) =>
onHandoffAccepted().pipe(
Effect.andThen(
Effect.failCause(
Cause.fromReasons([
Cause.makeFailReason(commitError),
Cause.makeInterruptReason(),
]),
),
),
),
},
prepare: Effect.sync(() => [ThreadId.make("thread-failed-desktop-install")]),
clear: () => Effect.sync(() => void events.push("clear")),
});
yield* selfUpdate.update({
targetVersion: "1.2.0",
continueRunningThreads: true,
});
const exit = yield* selfUpdate.commitDesktopUpdate("failed-desktop-token").pipe(Effect.exit);
expect(exit._tag).toBe("Failure");
if (exit._tag === "Failure") {
expect(Cause.hasInterrupts(exit.cause)).toBe(true);
expect(Cause.hasInterruptsOnly(exit.cause)).toBe(false);
}
expect(events).toEqual(["clear"]);
}),
);
it.effect("stages and preflights before asking the launcher for an update ID", () =>
Effect.gen(function* () {
const { selfUpdate, order } = yield* makeHarness();
expect(yield* selfUpdate.update({ targetVersion: "1.1.0" })).toEqual({
targetVersion: "1.1.0",
method: "boot-service",
updateId: "launcher-id",
});
expect(order).toEqual(["install", "preflight", "accept"]);
}),
);
it.effect("rejects invalid versions and desktop-managed servers before staging", () =>
Effect.gen(function* () {
const web = yield* makeHarness();
expect(
(yield* web.selfUpdate.update({ targetVersion: "latest" }).pipe(Effect.flip)).reason,
).toBe("'latest' is not an exact t3 version.");
const desktop = yield* makeHarness({ mode: "desktop" });
expect(
(yield* desktop.selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason,
).toContain("desktop app");
expect([...web.order, ...desktop.order]).toEqual([]);
}),
);
it.effect("delegates desktop-managed updates to the desktop app when available", () =>
Effect.gen(function* () {
const stages: string[] = [];
const { selfUpdate, order } = yield* makeHarness({
mode: "desktop",
desktopAppUpdate: {
available: true,
run: (reportProgress) =>
reportProgress("downloading").pipe(
Effect.andThen(reportProgress("installing")),
Effect.as({ targetVersion: "1.2.0", method: "desktop-app" as const }),
),
commit: () => Effect.never,
},
});
const result = yield* selfUpdate.update({ targetVersion: "1.1.0" }, (stage) =>
Effect.sync(() => void stages.push(stage)),
);
expect(result).toEqual({ targetVersion: "1.2.0", method: "desktop-app" });
expect(stages).toEqual(["downloading", "installing"]);
// The launcher staging path must not run on the desktop path.
expect(order).toEqual([]);
}),
);
it.effect("preserves the preflight refusal reason", () =>
Effect.gen(function* () {
const { selfUpdate } = yield* makeHarness({ preflight: "blocked" });
expect((yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason).toBe(
"local update required",
);
}),
);
it.effect("allows only one update at a time", () =>
Effect.gen(function* () {
const requested = yield* Deferred.make<void>();
const accepted = yield* Deferred.make<string>();
const { selfUpdate } = yield* makeHarness({
requestUpdate: () =>
Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(accepted))),
});
const first = yield* Effect.forkChild(selfUpdate.update({ targetVersion: "1.1.0" }), {
startImmediately: true,
});
yield* Deferred.await(requested);
expect((yield* selfUpdate.update({ targetVersion: "1.1.1" }).pipe(Effect.flip)).reason).toBe(
"A server update is already in progress.",
);
yield* Deferred.succeed(accepted, "launcher-id");
expect((yield* Fiber.join(first)).updateId).toBe("launcher-id");
}),
);
});