import { EnvironmentId, type GitActionProgressEvent, type GitRunStackedActionResult, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import type { AtomCommandResult } from "./runtime.ts"; import { applyVcsActionProgressEvent, beginVcsActionState, consumeVcsActionProgress, createVcsActionManager, createVcsActionTransportId, EMPTY_VCS_ACTION_STATE, getVcsActionTargetKey, normalizeVcsActionProgressEvent, } from "./vcsAction.ts"; const actionId = "action-123"; const action = "commit_push" as const; const cwd = "/repo"; const environmentId = EnvironmentId.make("environment-1"); const result: GitRunStackedActionResult = { action, branch: { status: "skipped_not_requested", }, commit: { status: "created", commitSha: "abc123", subject: "Test commit", }, push: { status: "pushed", branch: "feature", }, pr: { status: "skipped_not_requested", }, toast: { title: "Changes pushed", cta: { kind: "none", }, }, }; function progress(event: T): T { return event; } describe("vcsActionState", () => { it("projects phase and hook progress without owning the async operation", () => { const initial = beginVcsActionState({ operation: "run_change_request", label: "Running source control action", actionId, }); const phase = applyVcsActionProgressEvent( initial, progress({ actionId, action, cwd, kind: "phase_started", phase: "commit", label: "Committing...", }), ); const hook = applyVcsActionProgressEvent( phase, progress({ actionId, action, cwd, kind: "hook_started", hookName: "post-commit", }), ); const output = applyVcsActionProgressEvent( hook, progress({ actionId, action, cwd, kind: "hook_output", hookName: "post-commit", stream: "stdout", text: "hook output", }), ); const finished = applyVcsActionProgressEvent( output, progress({ actionId, action, cwd, kind: "hook_finished", hookName: "post-commit", exitCode: 0, durationMs: 12, }), ); expect(phase).toMatchObject({ isRunning: true, currentLabel: "Committing...", currentPhaseLabel: "Committing...", }); expect(output).toMatchObject({ currentLabel: "Running post-commit...", hookName: "post-commit", lastOutputLine: "hook output", }); expect(finished).toMatchObject({ currentLabel: "Committing...", hookName: null, lastOutputLine: null, }); }); it("retains a terminal action error for presentation", () => { const initial = beginVcsActionState({ operation: "run_change_request", label: "Running source control action", actionId, }); const failed = applyVcsActionProgressEvent( initial, progress({ actionId, action, cwd, kind: "action_failed", phase: null, message: "Push failed.", }), ); expect(failed).toMatchObject({ isRunning: false, operation: "run_change_request", actionId, action, error: "Push failed.", }); }); it("ignores progress after a newer action owns the target", () => { const current = beginVcsActionState({ operation: "pull", label: "Pulling latest changes", actionId: "newer-action", }); expect( applyVcsActionProgressEvent( current, progress({ actionId, action, cwd, kind: "phase_started", phase: "push", label: "Pushing...", }), ), ).toBe(current); }); it("keys presentation state only when the environment and repository are known", () => { expect( getVcsActionTargetKey({ environmentId, cwd, }), ).toBe(JSON.stringify([environmentId, cwd])); expect(getVcsActionTargetKey({ environmentId: null, cwd })).toBeNull(); expect( getVcsActionTargetKey({ environmentId, cwd: null, }), ).toBeNull(); }); it("normalizes progress only for the matching environment-scoped action", () => { const target = { environmentId, cwd }; const otherTarget = { environmentId: EnvironmentId.make("environment-2"), cwd, }; const transportActionId = createVcsActionTransportId(target, actionId); const event = progress({ actionId: createVcsActionTransportId(otherTarget, actionId), action, cwd, kind: "phase_started", phase: "push", label: "Pushing...", }); expect(normalizeVcsActionProgressEvent(target, transportActionId, actionId, event)).toBeNull(); expect( normalizeVcsActionProgressEvent(target, transportActionId, actionId, { ...event, actionId: transportActionId, }), ).toEqual({ ...event, actionId, }); }); it.effect("consumes progress through the terminal event and returns its result", () => Effect.gen(function* () { const target = { environmentId, cwd }; const transportActionId = createVcsActionTransportId(target, actionId); const observed: GitActionProgressEvent[] = []; const events: GitActionProgressEvent[] = [ { actionId: "unrelated-action", action, cwd, kind: "phase_started", phase: "commit", label: "Ignored", }, { actionId: transportActionId, action, cwd, kind: "phase_started", phase: "push", label: "Pushing...", }, { actionId: transportActionId, action, cwd, kind: "action_finished", result, }, ]; const actual = yield* consumeVcsActionProgress(Stream.fromIterable(events), { target, transportActionId, actionId, onProgress: (event) => Effect.sync(() => { observed.push(event); }), }); expect(actual).toEqual(result); expect(observed.map((event) => event.actionId)).toEqual([actionId, actionId]); expect(observed.map((event) => event.kind)).toEqual(["phase_started", "action_finished"]); }), ); it("keys mutation ownership by environment and cwd", () => { const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< EnvironmentRegistry, never >; const manager = createVcsActionManager(runtime); const registry = AtomRegistry.make(); const target = { environmentId, cwd }; const otherTarget = { environmentId: EnvironmentId.make("environment-2"), cwd, }; expect(manager.runStackedAction(target)).toBe(manager.runStackedAction({ ...target })); expect(manager.runStackedAction(target)).not.toBe(manager.runStackedAction(otherTarget)); expect(registry.get(manager.stateAtom(target))).toEqual(EMPTY_VCS_ACTION_STATE); registry.dispose(); }); it("tracks finite mutations without letting an older completion clear newer state", async () => { const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< EnvironmentRegistry, never >; const manager = createVcsActionManager(runtime); const registry = AtomRegistry.make(); const target = { environmentId, cwd }; let finishFirst!: () => void; let failSecond!: (error: Error) => void; const firstAction = new Promise>((resolve) => { finishFirst = () => resolve(AsyncResult.success(undefined)); }); const secondAction = new Promise>((resolve) => { failSecond = (error) => resolve(AsyncResult.failure(Cause.fail(error))); }); const first = manager.track( registry, target, { operation: "pull", label: "Pulling latest changes" }, () => firstAction, ); const firstActionId = registry.get(manager.stateAtom(target)).actionId; const second = manager.track( registry, target, { operation: "switch_ref", label: "Switching branch" }, () => secondAction, ); const secondActionId = registry.get(manager.stateAtom(target)).actionId; finishFirst(); await first; expect(registry.get(manager.stateAtom(target))).toMatchObject({ actionId: secondActionId, isRunning: true, operation: "switch_ref", }); expect(secondActionId).not.toBe(firstActionId); failSecond(new Error("switch failed")); const secondFailure = await second; expect(AsyncResult.isFailure(secondFailure)).toBe(true); expect(registry.get(manager.stateAtom(target))).toMatchObject({ actionId: secondActionId, error: "switch failed", isRunning: false, operation: "switch_ref", }); registry.dispose(); }); });