403 lines · 15.2 KB · TypeScript
RawDownload
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");    }),  );});