| 1 | import type { ServerConfig } from "@t3tools/contracts"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as DateTime from "effect/DateTime"; |
| 6 | import { Atom, AtomRegistry } from "effect/reactivity"; |
| 7 | |
| 8 | import { PrimaryConnectionTarget } from "../connection/model.ts"; |
| 9 | import type { EnvironmentShellState } from "./shell.ts"; |
| 10 | import { createEnvironmentServerConfigsAtom, createEnvironmentShellSummaryAtom } from "./shell.ts"; |
| 11 | import { v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; |
| 12 | |
| 13 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 14 | const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-2"); |
| 15 | |
| 16 | function environmentEntry(environmentId: EnvironmentId, label: string) { |
| 17 | return { |
| 18 | target: new PrimaryConnectionTarget({ |
| 19 | environmentId, |
| 20 | label, |
| 21 | httpBaseUrl: `https://${environmentId}.example.test`, |
| 22 | wsBaseUrl: `wss://${environmentId}.example.test`, |
| 23 | }), |
| 24 | profile: Option.none(), |
| 25 | enabled: true, |
| 26 | }; |
| 27 | } |
| 28 | |
| 29 | function shellState(input: { |
| 30 | readonly status: EnvironmentShellState["status"]; |
| 31 | readonly updatedAt?: string; |
| 32 | readonly error?: string; |
| 33 | readonly snapshotSequence?: number; |
| 34 | }): EnvironmentShellState { |
| 35 | return { |
| 36 | snapshot: |
| 37 | input.updatedAt === undefined |
| 38 | ? Option.none() |
| 39 | : Option.some({ |
| 40 | schemaVersion: 1, |
| 41 | snapshotSequence: input.snapshotSequence ?? 1, |
| 42 | projects: [], |
| 43 | threads: [ |
| 44 | { |
| 45 | ...v2ThreadShell, |
| 46 | updatedAt: DateTime.makeUnsafe(input.updatedAt), |
| 47 | }, |
| 48 | ], |
| 49 | archivedThreads: [], |
| 50 | }), |
| 51 | status: input.status, |
| 52 | error: input.error === undefined ? Option.none() : Option.some(input.error), |
| 53 | }; |
| 54 | } |
| 55 | |
| 56 | function makeHarness() { |
| 57 | const shellStateAtoms = Atom.family((environmentId: EnvironmentId) => |
| 58 | Atom.make<EnvironmentShellState>( |
| 59 | environmentId === ENVIRONMENT_ID |
| 60 | ? shellState({ |
| 61 | status: "cached", |
| 62 | updatedAt: "2026-06-01T00:00:00.000Z", |
| 63 | }) |
| 64 | : shellState({ |
| 65 | status: "synchronizing", |
| 66 | updatedAt: "2026-06-02T00:00:00.000Z", |
| 67 | error: "Retrying.", |
| 68 | }), |
| 69 | ), |
| 70 | ); |
| 71 | const configAtoms = Atom.family((_environmentId: EnvironmentId) => |
| 72 | Atom.make<ServerConfig | null>(null), |
| 73 | ); |
| 74 | const catalogValueAtom = Atom.make({ |
| 75 | isReady: true, |
| 76 | entries: new Map([ |
| 77 | [ENVIRONMENT_ID, environmentEntry(ENVIRONMENT_ID, "Environment")], |
| 78 | [OTHER_ENVIRONMENT_ID, environmentEntry(OTHER_ENVIRONMENT_ID, "Other environment")], |
| 79 | ]), |
| 80 | }); |
| 81 | const summaryAtom = createEnvironmentShellSummaryAtom({ |
| 82 | catalogValueAtom, |
| 83 | shellStateValueAtom: shellStateAtoms, |
| 84 | }); |
| 85 | const serverConfigsAtom = createEnvironmentServerConfigsAtom({ |
| 86 | catalogValueAtom, |
| 87 | serverConfigValueAtom: configAtoms, |
| 88 | }); |
| 89 | |
| 90 | return { |
| 91 | catalogValueAtom, |
| 92 | registry: AtomRegistry.make(), |
| 93 | shellStateAtom: shellStateAtoms, |
| 94 | configAtom: configAtoms, |
| 95 | summaryAtom, |
| 96 | serverConfigsAtom, |
| 97 | }; |
| 98 | } |
| 99 | |
| 100 | describe("environment shell projections", () => { |
| 101 | it("summarizes shell state and preserves identity when only irrelevant snapshot data changes", () => { |
| 102 | const harness = makeHarness(); |
| 103 | const summary = harness.registry.get(harness.summaryAtom); |
| 104 | |
| 105 | expect(summary).toEqual({ |
| 106 | hasSnapshot: true, |
| 107 | hasSynchronizingShell: true, |
| 108 | hasCachedShell: true, |
| 109 | hasLiveShell: false, |
| 110 | firstError: "Retrying.", |
| 111 | }); |
| 112 | |
| 113 | harness.registry.set( |
| 114 | harness.shellStateAtom(ENVIRONMENT_ID), |
| 115 | shellState({ |
| 116 | status: "cached", |
| 117 | updatedAt: "2026-06-01T00:00:00.000Z", |
| 118 | snapshotSequence: 2, |
| 119 | }), |
| 120 | ); |
| 121 | |
| 122 | expect(harness.registry.get(harness.summaryAtom)).toBe(summary); |
| 123 | }); |
| 124 | |
| 125 | it("does not notify summary subscribers when thread timestamps advance", () => { |
| 126 | const harness = makeHarness(); |
| 127 | const initial = harness.registry.get(harness.summaryAtom); |
| 128 | let changes = 0; |
| 129 | const unsubscribe = harness.registry.subscribe(harness.summaryAtom, () => changes++); |
| 130 | try { |
| 131 | for (let index = 0; index < 20; index++) { |
| 132 | harness.registry.set( |
| 133 | harness.shellStateAtom(ENVIRONMENT_ID), |
| 134 | shellState({ |
| 135 | status: "cached", |
| 136 | updatedAt: `2026-07-01T00:00:${String(index).padStart(2, "0")}.000Z`, |
| 137 | snapshotSequence: index + 2, |
| 138 | }), |
| 139 | ); |
| 140 | expect(harness.registry.get(harness.summaryAtom)).toBe(initial); |
| 141 | } |
| 142 | expect(changes).toBe(0); |
| 143 | } finally { |
| 144 | unsubscribe(); |
| 145 | harness.registry.dispose(); |
| 146 | } |
| 147 | }); |
| 148 | |
| 149 | it("notifies for shell availability, status, errors, and enabled environment changes", () => { |
| 150 | const harness = makeHarness(); |
| 151 | let changes = 0; |
| 152 | const unsubscribe = harness.registry.subscribe(harness.summaryAtom, () => changes++); |
| 153 | const setCatalog = (entries: ReturnType<typeof environmentEntry>[]) => { |
| 154 | harness.registry.set(harness.catalogValueAtom, { |
| 155 | isReady: true, |
| 156 | entries: new Map(entries.map((entry) => [entry.target.environmentId, entry])), |
| 157 | }); |
| 158 | return harness.registry.get(harness.summaryAtom); |
| 159 | }; |
| 160 | try { |
| 161 | expect(setCatalog([])).toEqual({ |
| 162 | hasSnapshot: false, |
| 163 | hasSynchronizingShell: false, |
| 164 | hasCachedShell: false, |
| 165 | hasLiveShell: false, |
| 166 | firstError: null, |
| 167 | }); |
| 168 | expect(setCatalog([environmentEntry(ENVIRONMENT_ID, "Environment")])).toMatchObject({ |
| 169 | hasSnapshot: true, |
| 170 | hasCachedShell: true, |
| 171 | }); |
| 172 | harness.registry.set(harness.shellStateAtom(ENVIRONMENT_ID), shellState({ status: "empty" })); |
| 173 | expect(harness.registry.get(harness.summaryAtom)).toMatchObject({ |
| 174 | hasSnapshot: false, |
| 175 | hasCachedShell: false, |
| 176 | }); |
| 177 | harness.registry.set( |
| 178 | harness.shellStateAtom(ENVIRONMENT_ID), |
| 179 | shellState({ status: "synchronizing", error: "Retrying." }), |
| 180 | ); |
| 181 | expect(harness.registry.get(harness.summaryAtom)).toMatchObject({ |
| 182 | hasSnapshot: false, |
| 183 | hasSynchronizingShell: true, |
| 184 | firstError: "Retrying.", |
| 185 | }); |
| 186 | harness.registry.set( |
| 187 | harness.shellStateAtom(ENVIRONMENT_ID), |
| 188 | shellState({ status: "live", updatedAt: "2026-07-01T00:00:00.000Z" }), |
| 189 | ); |
| 190 | expect(harness.registry.get(harness.summaryAtom)).toMatchObject({ |
| 191 | hasSnapshot: true, |
| 192 | hasSynchronizingShell: false, |
| 193 | hasLiveShell: true, |
| 194 | firstError: null, |
| 195 | }); |
| 196 | harness.registry.set( |
| 197 | harness.shellStateAtom(ENVIRONMENT_ID), |
| 198 | shellState({ |
| 199 | status: "live", |
| 200 | updatedAt: "2026-07-01T00:00:00.000Z", |
| 201 | error: "Disconnected.", |
| 202 | }), |
| 203 | ); |
| 204 | expect(harness.registry.get(harness.summaryAtom).firstError).toBe("Disconnected."); |
| 205 | harness.registry.set( |
| 206 | harness.shellStateAtom(ENVIRONMENT_ID), |
| 207 | shellState({ status: "live", updatedAt: "2026-07-01T00:00:00.000Z" }), |
| 208 | ); |
| 209 | expect(harness.registry.get(harness.summaryAtom).firstError).toBeNull(); |
| 210 | expect( |
| 211 | setCatalog([{ ...environmentEntry(ENVIRONMENT_ID, "Environment"), enabled: false }]), |
| 212 | ).toMatchObject({ hasSnapshot: false, hasLiveShell: false }); |
| 213 | expect(changes).toBe(8); |
| 214 | } finally { |
| 215 | unsubscribe(); |
| 216 | harness.registry.dispose(); |
| 217 | } |
| 218 | }); |
| 219 | |
| 220 | it("preserves server-config map identity until a config reference changes", () => { |
| 221 | const harness = makeHarness(); |
| 222 | const empty = harness.registry.get(harness.serverConfigsAtom); |
| 223 | const config = { cwd: "/repo" } as ServerConfig; |
| 224 | |
| 225 | harness.registry.set(harness.configAtom(ENVIRONMENT_ID), config); |
| 226 | const withConfig = harness.registry.get(harness.serverConfigsAtom); |
| 227 | |
| 228 | expect(withConfig).not.toBe(empty); |
| 229 | expect(withConfig.get(ENVIRONMENT_ID)).toBe(config); |
| 230 | |
| 231 | harness.registry.set(harness.configAtom(ENVIRONMENT_ID), config); |
| 232 | expect(harness.registry.get(harness.serverConfigsAtom)).toBe(withConfig); |
| 233 | }); |
| 234 | }); |