Skip to content
Source
Sign inSign up

projectCommands.test.ts

272 lines10.2 KB

1import { describe, expect, it } from "@effect/vitest";
2import {
3 AuthFilesystemWriteScope,
4 EnvironmentId,
5 ProjectId,
6 ProjectReadFileInput,
7 ProjectReadFileResult,
8 ThreadId,
9 type AuthSessionState,
10 type ScopedProjectRef,
11 WS_METHODS,
12} from "@t3tools/contracts";
13import { vi } from "vite-plus/test";
14import * as SecureRandom from "effect/crypto/SecureRandom";
15import * as Effect from "effect/Effect";
16import * as Deferred from "effect/Deferred";
17import * as Fiber from "effect/Fiber";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as Schema from "effect/Schema";
21import * as SubscriptionRef from "effect/SubscriptionRef";
22import * as Stream from "effect/Stream";
23import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity";
24import { AVAILABLE_CONNECTION_STATE, BearerConnectionTarget } from "../connection/model.ts";
25​
26import * as EnvironmentRegistry from "../connection/registry.ts";
27import * as EnvironmentSupervisor from "../connection/supervisor.ts";
28import type { RpcSession } from "../rpc/session.ts";
29import type { EnvironmentProject } from "./models.ts";
30import { createProjectEnvironmentAtoms } from "./projectCommands.ts";
31​
32const decodeReadFileInput = Schema.decodeUnknownSync(ProjectReadFileInput);
33const decodeReadFileResult = Schema.decodeUnknownSync(ProjectReadFileResult);
34const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
35vi.mock("./session.ts", () => ({
36 createEnvironmentSessionAtoms: () => ({ sessionStateAtom: sessions }),
37}));
38const sessions = Atom.family((_id: EnvironmentId) =>
39 Atom.make<AsyncResult.AsyncResult<AuthSessionState>>(AsyncResult.initial()),
40);
41const PROJECT_ID = ProjectId.make("scratch");
42const PROJECT = {
43 id: PROJECT_ID,
44 environmentId: ENVIRONMENT_ID,
45 workspaceRoot: "/scratch",
46} as EnvironmentProject;
47​
48const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () {
49 const metadataRequests: string[][] = [];
50 const file = { byteLength: 5 };
51 const readResponse = {
52 wait: Effect.void as Effect.Effect<void>,
53 missing: false,
54 };
55 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
56 target: new BearerConnectionTarget({
57 environmentId: ENVIRONMENT_ID,
58 label: "Test",
59 connectionId: "test",
60 }),
61 session: yield* SubscriptionRef.make(
62 Option.some({
63 client: {
64 [WS_METHODS.projectsEnsureScratch]: () => Effect.succeed({ projectId: PROJECT_ID }),
65 [WS_METHODS.projectsReadFile]: () =>
66 Effect.gen(function* () {
67 const byteLength = file.byteLength;
68 yield* readResponse.wait;
69 return decodeReadFileResult(
70 readResponse.missing
71 ? {
72 relativePath: "foo",
73 byteLength: 0,
74 contents: "",
75 truncated: false,
76 exists: false,
77 }
78 : { relativePath: "foo", byteLength, contents: "old", truncated: false },
79 );
80 }),
81 [WS_METHODS.projectsWriteFile]: () =>
82 Effect.sync(() => {
83 file.byteLength = 10;
84 return { relativePath: "foo" };
85 }),
86 [WS_METHODS.filesystemGetMetadata]: (input: { paths: string[] }) =>
87 Effect.sync(() => {
88 metadataRequests.push(input.paths);
89 return {
90 entries: input.paths.map(() =>
91 readResponse.missing
92 ? null
93 : { kind: "file" as const, byteLength: file.byteLength },
94 ),
95 };
96 }),
97 },
98 } as unknown as RpcSession),
99 ),
100 state: yield* SubscriptionRef.make({
101 ...AVAILABLE_CONNECTION_STATE,
102 phase: "connected" as const,
103 }),
104 } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
105 const runtime = Atom.runtime(
106 Layer.mergeAll(
107 Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, {
108 run: (_environmentId, effect) =>
109 Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
110 followStream: (_environmentId, stream) =>
111 Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
112 } as EnvironmentRegistry.EnvironmentRegistry["Service"]),
113 Layer.succeed(
114 SecureRandom.SecureRandom,
115 SecureRandom.make((size) => new Uint8Array(size)),
116 ),
117 ),
118 );
119 const storedProject = Atom.make<EnvironmentProject | null>(null);
120 const commands = createProjectEnvironmentAtoms(runtime, {
121 projectAtom: (ref: ScopedProjectRef) =>
122 ref.environmentId === ENVIRONMENT_ID && ref.projectId === PROJECT_ID
123 ? storedProject
124 : Atom.make(null),
125 });
126 const registry = AtomRegistry.make();
127 yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
128 registry.set(
129 sessions(ENVIRONMENT_ID),
130 AsyncResult.success({
131 authenticated: true,
132 auth: {
133 policy: "remote-reachable" as const,
134 bootstrapMethods: [],
135 sessionMethods: [],
136 sessionCookieName: "test",
137 },
138 scopes: [AuthFilesystemWriteScope],
139 permissions: [AuthFilesystemWriteScope],
140 }),
141 );
142 const openScratch = Effect.promise(() =>
143 commands.openScratch.run(registry, { environmentId: ENVIRONMENT_ID, input: {} }),
144 );
145 return { registry, storedProject, openScratch, commands, metadataRequests, readResponse };
146});
147​
148describe("writeFile metadata", () => {
149 it.effect("preserves optional read results without seeding metadata for an absent file", () =>
150 Effect.gen(function* () {
151 const h = yield* makeHarness();
152 h.readResponse.missing = true;
153 const input = { cwd: "/repo", relativePath: "foo", allowMissing: true };
154 expect.soft(decodeReadFileInput(input)).toEqual(input);
155 const result = yield* AtomRegistry.getResult(
156 h.registry,
157 h.commands.readFile({ environmentId: ENVIRONMENT_ID, input }),
158 { suspendOnWaiting: true },
159 );
160 expect.soft(result).toEqual({
161 relativePath: "foo",
162 byteLength: 0,
163 contents: "",
164 truncated: false,
165 exists: false,
166 });
167 const metadata = h.commands.fileMetadata({
168 environmentId: ENVIRONMENT_ID,
169 input: { path: "/repo/foo" },
170 });
171 expect(
172 yield* AtomRegistry.getResult(h.registry, metadata, { suspendOnWaiting: true }),
173 ).toBeNull();
174 expect(h.metadataRequests).toEqual([["/repo/foo"]]);
175 }),
176 );
177​
178 it.effect("does not restore old metadata from a read completed after a write", () =>
179 Effect.gen(function* () {
180 const h = yield* makeHarness();
181 const threadId = ThreadId.make("stale-read-thread");
182 h.registry.mount(h.commands.fileMetadataThread({ environmentId: ENVIRONMENT_ID, threadId }));
183 const metadata = h.commands.fileMetadata({
184 environmentId: ENVIRONMENT_ID,
185 input: { path: "/repo/foo", threadId },
186 });
187 h.registry.mount(metadata);
188 const readMetadata = () =>
189 AtomRegistry.getResult(h.registry, metadata, { suspendOnWaiting: true });
190 yield* readMetadata();
191 const started = yield* Deferred.make<void>();
192 const release = yield* Deferred.make<void>();
193 h.readResponse.wait = Deferred.succeed(started, undefined).pipe(
194 Effect.andThen(Deferred.await(release)),
195 );
196 const fileRead = yield* AtomRegistry.getResult(
197 h.registry,
198 h.commands.readFile({
199 environmentId: ENVIRONMENT_ID,
200 input: { cwd: "/repo", relativePath: "foo" },
201 }),
202 { suspendOnWaiting: true },
203 ).pipe(Effect.forkChild);
204 yield* Deferred.await(started);
205 const write = yield* Effect.promise(() =>
206 h.commands.writeFile.run(h.registry, {
207 environmentId: ENVIRONMENT_ID,
208 input: { cwd: "/repo", relativePath: "foo", contents: "new" },
209 }),
210 );
211 expect(write._tag).toBe("Success");
212 expect(yield* readMetadata()).toEqual({ kind: "file", byteLength: 10 });
213 yield* Deferred.succeed(release, undefined);
214 expect((yield* Fiber.join(fileRead)).byteLength).toBe(5);
215 h.registry.refresh(metadata);
216 expect(yield* readMetadata()).toEqual({ kind: "file", byteLength: 10 });
217 expect(h.metadataRequests).toHaveLength(2);
218 }),
219 );
220​
221 it.effect("refreshes retained canonical and original paths after an aliased write", () =>
222 Effect.gen(function* () {
223 const h = yield* makeHarness();
224 const threadId = ThreadId.make("write-metadata-thread");
225 h.registry.mount(h.commands.fileMetadataThread({ environmentId: ENVIRONMENT_ID, threadId }));
226 const atoms = ["/repo/foo", "/repo/./foo"].map((path) =>
227 h.commands.fileMetadata({ environmentId: ENVIRONMENT_ID, input: { path, threadId } }),
228 );
229 for (const atom of atoms) h.registry.mount(atom);
230 const read = () =>
231 Effect.forEach(
232 atoms,
233 (atom) => AtomRegistry.getResult(h.registry, atom, { suspendOnWaiting: true }),
234 { concurrency: "unbounded" },
235 );
236 expect(yield* read()).toEqual([
237 { kind: "file", byteLength: 5 },
238 { kind: "file", byteLength: 5 },
239 ]);
240 const result = yield* Effect.promise(() =>
241 h.commands.writeFile.run(h.registry, {
242 environmentId: ENVIRONMENT_ID,
243 input: { cwd: "/repo", relativePath: "./foo", contents: "new" },
244 }),
245 );
246 expect(result).toMatchObject({ _tag: "Success", value: { relativePath: "foo" } });
247 expect(yield* read()).toEqual([
248 { kind: "file", byteLength: 10 },
249 { kind: "file", byteLength: 10 },
250 ]);
251 expect(h.metadataRequests.flat()).toEqual([
252 "/repo/foo",
253 "/repo/./foo",
254 "/repo/foo",
255 "/repo/./foo",
256 ]);
257 }),
258 );
259});
260​
261describe("openScratch", () => {
262 it.effect("resolves once the created project reaches the client store", () =>
263 Effect.gen(function* () {
264 const { registry, storedProject, openScratch } = yield* makeHarness();
265 const opening = yield* Effect.forkChild(openScratch);
266 yield* Effect.yieldNow;
267 registry.set(storedProject, PROJECT);
268 const result = yield* Fiber.join(opening);
269 expect(result).toMatchObject({ _tag: "Success", value: PROJECT });
270 }),
271 );
272});