1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { vi } from "vite-plus/test";
3import {
4 EnvironmentId,
5 AuthSourceControlWriteScope,
6 WS_METHODS,
7 type SourceControlPublishRepositoryResult,
8} from "@t3tools/contracts";
9import { describe, expect, it } from "@effect/vitest";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import * as SubscriptionRef from "effect/SubscriptionRef";
14import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
15
16import {
17 AVAILABLE_CONNECTION_STATE,
18 PrimaryConnectionTarget,
19 type PreparedConnection,
20 type SupervisorConnectionState,
21} from "../connection/model.ts";
22import * as EnvironmentRegistry from "../connection/registry.ts";
23import * as EnvironmentSupervisor from "../connection/supervisor.ts";
24import * as Persistence from "../platform/persistence.ts";
25import { EnvironmentRpcUnavailableError } from "../rpc/client.ts";
26import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
27import type { RpcSession } from "../rpc/session.ts";
28import { createSourceControlEnvironmentAtoms } from "./sourceControl.ts";
29import { vcsRefsCacheStateAtom } from "./vcsRefInvalidation.ts";
30
31const TARGET = new PrimaryConnectionTarget({
32 environmentId: EnvironmentId.make("environment-1"),
33 label: "Test environment",
34 httpBaseUrl: "https://environment.example.test",
35 wsBaseUrl: "wss://environment.example.test",
36});
37
38const PUBLISH_RESULT: SourceControlPublishRepositoryResult = {
39 repository: {
40 provider: SourceControlProviderKind.make("github"),
41 nameWithOwner: "t3tools/t3code",
42 url: "https://github.com/t3tools/t3code",
43 sshUrl: "git@github.com:t3tools/t3code.git",
44 },
45 remoteName: "origin",
46 remoteUrl: "git@github.com:t3tools/t3code.git",
47 branch: "main",
48 upstreamBranch: "origin/main",
49 status: "pushed",
50};
51
52function session(client: WsRpcProtocolClient): RpcSession {
53 return {
54 client,
55 initialConfig: Effect.never,
56 subscribeServerConfig: (input) => client.subscribeServerConfig(input),
57 ready: Effect.void,
58 probe: Effect.void,
59 closed: Effect.never,
60 };
61}
62
63describe("source control environment atoms", () => {
64 it.effect("invalidates cached refs after successful and failed publishing", () =>
65 Effect.scoped(
66 Effect.gen(function* () {
67 const connectionState: SupervisorConnectionState = {
68 ...AVAILABLE_CONNECTION_STATE,
69 desired: true,
70 network: "online",
71 phase: "connected",
72 attempt: 1,
73 generation: 1,
74 };
75 let publishAttempts = 0;
76 const client = {
77 [WS_METHODS.sourceControlPublishRepository]: () => {
78 publishAttempts += 1;
79 return publishAttempts === 1
80 ? Effect.succeed(PUBLISH_RESULT)
81 : Effect.fail(
82 new EnvironmentRpcUnavailableError({
83 environmentId: TARGET.environmentId,
84 message: "push failed after adding the remote",
85 }),
86 );
87 },
88 } as unknown as WsRpcProtocolClient;
89 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
90 target: TARGET,
91 state: yield* SubscriptionRef.make(connectionState),
92 session: yield* SubscriptionRef.make(Option.some(session(client))),
93 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
94 connect: Effect.void,
95 disconnect: Effect.void,
96 retryNow: Effect.void,
97 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
98 const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = (
99 _environmentId,
100 effect,
101 ) => Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor);
102 const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({
103 run,
104 } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]);
105 const removed = new Array<string>();
106 const cache = Persistence.EnvironmentCacheStore.of({
107 loadShell: () => Effect.succeedNone,
108 saveShell: () => Effect.void,
109 loadThread: () => Effect.succeedNone,
110 saveThread: () => Effect.void,
111 removeThread: () => Effect.void,
112 loadServerConfig: () => Effect.succeedNone,
113 saveServerConfig: () => Effect.void,
114 loadVcsRefs: () => Effect.succeedNone,
115 saveVcsRefs: () => Effect.void,
116 removeVcsRefs: (environmentId, cwd) =>
117 Effect.sync(() => {
118 removed.push(`${environmentId}:${cwd}`);
119 }),
120 clearVcsRefs: (environmentId) =>
121 Effect.sync(() => {
122 removed.push(`${environmentId}:*`);
123 }),
124 clear: () => Effect.void,
125 });
126 const runtime = Atom.runtime(
127 Layer.merge(
128 Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
129 Layer.succeed(Persistence.EnvironmentCacheStore, cache),
130 ),
131 );
132 const atoms = createSourceControlEnvironmentAtoms(runtime);
133 const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) =>
134 Effect.sync(() => registry.dispose()),
135 );
136 const state = vcsRefsCacheStateAtom({ environmentId: TARGET.environmentId });
137
138 expect(registry.get(state).revision).toBe(0);
139 const publishResult = yield* Effect.promise(() =>
140 atoms.publishRepository.run(registry, {
141 environmentId: TARGET.environmentId,
142 input: {
143 cwd: "/repo",
144 provider: SourceControlProviderKind.make("github"),
145 repository: "t3tools/t3code",
146 visibility: "private",
147 },
148 }),
149 );
150
151 expect(AsyncResult.isSuccess(publishResult)).toBe(true);
152 expect(registry.get(state).revision).toBe(1);
153 expect(removed).toEqual([`${TARGET.environmentId}:*`]);
154
155 const failedPublish = yield* Effect.promise(() =>
156 atoms.publishRepository.run(registry, {
157 environmentId: TARGET.environmentId,
158 input: {
159 cwd: "/repo",
160 provider: SourceControlProviderKind.make("github"),
161 repository: "t3tools/t3code",
162 visibility: "private",
163 },
164 }),
165 );
166
167 expect(AsyncResult.isFailure(failedPublish)).toBe(true);
168 expect(registry.get(state).revision).toBe(2);
169 expect(removed).toEqual([`${TARGET.environmentId}:*`, `${TARGET.environmentId}:*`]);
170 }),
171 ),
172 );
173});
174
175// Transport fixtures have a source-control-only session; authorization edge cases
176// are exercised by commandPermissions.test.ts.
177vi.mock("./session.ts", () => ({
178 createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }),
179}));
180const grantedSessions = Atom.family((_id: EnvironmentId) =>
181 Atom.make(
182 AsyncResult.success({
183 authenticated: true,
184 auth: {
185 policy: "remote-reachable",
186 bootstrapMethods: [],
187 sessionMethods: [],
188 sessionCookieName: "test",
189 },
190 scopes: [AuthSourceControlWriteScope],
191 }),
192 ),
193);