Skip to content
Source
Sign inSign up
Open History to see the last commit

project.test.ts

222 lines8.3 KB

1import { assert, it as effectIt } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import { describe, expect, it } from "vite-plus/test";
5​
6import { OrchestrationProjectShell } from "./orchestrationProject.ts";
7​
8import {
9 ProjectFaviconPath,
10 ReceivedProjectIcon,
11 StoredProjectIcon,
12 ProjectReadFileError,
13 ProjectReadFileInput,
14 ProjectReadFileResult,
15 ProjectCreatePayload,
16 ProjectUpdatePayload,
17 ProjectMutation,
18 ProjectSearchContentsError,
19 ProjectSearchEntriesError,
20 ProjectWriteFileError,
21 ProjectWriteFileInput,
22 ProjectWriteFileResult,
23} from "./project.ts";
24​
25const decodeProjectCreatePayload = Schema.decodeUnknownSync(ProjectCreatePayload);
26const decodeProjectUpdatePayload = Schema.decodeUnknownSync(ProjectUpdatePayload);
27const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation);
28​
29describe("project RPC errors", () => {
30 it("keeps whitespace in file paths across the wire", () => {
31 const roundTrip = <S extends Schema.Codec<unknown, unknown>>(schema: S, value: S["Type"]) =>
32 Schema.decodeUnknownSync(schema)(Schema.encodeSync(schema)(value));
33​
34 for (const relativePath of [" ", " report.txt", "report.txt\t"]) {
35 const cwd = "/workspace";
36 const resolvedPath = `${cwd}/${relativePath}`;
37 expect(roundTrip(ProjectReadFileInput, { cwd, relativePath }).relativePath).toBe(
38 relativePath,
39 );
40 expect(
41 roundTrip(ProjectWriteFileInput, { cwd, relativePath, contents: "" }).relativePath,
42 ).toBe(relativePath);
43 expect(
44 roundTrip(ProjectReadFileResult, {
45 relativePath,
46 contents: "",
47 byteLength: 0,
48 truncated: false,
49 }).relativePath,
50 ).toBe(relativePath);
51 expect(roundTrip(ProjectWriteFileResult, { relativePath }).relativePath).toBe(relativePath);
52 for (const FileError of [ProjectReadFileError, ProjectWriteFileError]) {
53 const error = new FileError({
54 cwd,
55 relativePath,
56 failure: "operation_failed",
57 resolvedPath,
58 operationPath: resolvedPath,
59 });
60 expect(roundTrip(FileError, error)).toMatchObject({
61 relativePath,
62 resolvedPath,
63 operationPath: resolvedPath,
64 });
65 }
66 }
67 });
68​
69 it("derives stable messages from structured request context while retaining causes", () => {
70 const cause = new Error("sensitive platform detail");
71 const searchError = new ProjectSearchEntriesError({
72 cwd: "/workspace",
73 queryLength: "authorization: Bearer secret-token".length,
74 limit: 20,
75 failure: "search_index_search_failed",
76 normalizedCwd: "/workspace",
77 detail: "index unavailable",
78 cause,
79 });
80 const readError = new ProjectReadFileError({
81 cwd: "/workspace",
82 relativePath: "src/index.ts",
83 failure: "operation_failed",
84 operation: "read",
85 operationPath: "/workspace/src/index.ts",
86 resolvedPath: "/workspace/src/index.ts",
87 cause,
88 });
89​
90 expect(searchError.message).toBe("Failed to search workspace entries in '/workspace'.");
91 expect(searchError.message).not.toContain(cause.message);
92 expect(searchError.normalizedCwd).toBe("/workspace");
93 expect(searchError.queryLength).toBe("authorization: Bearer secret-token".length);
94 expect(searchError).not.toHaveProperty("query");
95 expect(searchError.message).not.toMatch(/Bearer|secret-token/);
96 expect(searchError.cause).toBe(cause);
97 expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'.");
98 expect(readError.message).not.toContain(cause.message);
99 expect(readError.cause).toBe(cause);
100​
101 const contentSearchError = new ProjectSearchContentsError({
102 cwd: "/workspace",
103 queryLength: "authorization: Bearer secret-token".length,
104 limit: 100,
105 failure: "search_index_search_failed",
106 cause,
107 });
108 expect(contentSearchError.message).toBe("Failed to search workspace contents in '/workspace'.");
109 expect(contentSearchError.message).not.toContain(cause.message);
110 expect(contentSearchError).not.toHaveProperty("query");
111 expect(contentSearchError.cause).toBe(cause);
112 });
113​
114 it("decodes legacy message-only errors during rolling upgrades", () => {
115 const decodeSearchError = Schema.decodeUnknownSync(ProjectSearchEntriesError);
116 const decodeWriteError = Schema.decodeUnknownSync(ProjectWriteFileError);
117​
118 const searchError = decodeSearchError({
119 _tag: "ProjectSearchEntriesError",
120 message: "Legacy project search failure.",
121 query: "legacy sensitive query",
122 });
123 const writeError = decodeWriteError({
124 _tag: "ProjectWriteFileError",
125 message: "Legacy project write failure.",
126 });
127​
128 expect(searchError.message).toBe("Legacy project search failure.");
129 expect(searchError.cwd).toBeUndefined();
130 expect(searchError.queryLength).toBeUndefined();
131 expect(searchError).not.toHaveProperty("query");
132 expect(searchError.failure).toBeUndefined();
133 expect(writeError.message).toBe("Legacy project write failure.");
134 expect(writeError.relativePath).toBeUndefined();
135 expect(writeError.failure).toBeUndefined();
136 });
137});
138​
139describe("shared project payloads", () => {
140 it("preserves omitted, false, and null values through RPC envelopes", () => {
141 const create = decodeProjectCreatePayload({
142 title: " Example ",
143 workspaceRoot: "/workspace",
144 createWorkspaceRootIfMissing: false,
145 });
146 const update = decodeProjectUpdatePayload({
147 autoPull: false,
148 defaultModelSelection: null,
149 faviconPath: null,
150 });
151 const envelope = { commandId: "command", projectId: "project" };
152 expect(decodeProjectMutation({ type: "project.create", ...envelope, ...create })).toEqual({
153 type: "project.create",
154 ...envelope,
155 title: "Example",
156 workspaceRoot: "/workspace",
157 createWorkspaceRootIfMissing: false,
158 });
159 expect(decodeProjectMutation({ type: "project.update", ...envelope, ...update })).toEqual({
160 type: "project.update",
161 ...envelope,
162 autoPull: false,
163 defaultModelSelection: null,
164 faviconPath: null,
165 });
166 expect(Object.hasOwn(create, "scripts")).toBe(false);
167 expect(Object.hasOwn(update, "title")).toBe(false);
168 expect(
169 decodeProjectMutation({ type: "project.update", ...envelope, title: undefined }),
170 ).toHaveProperty("title", undefined);
171 });
172});
173​
174const decodeFaviconPath = Schema.decodeUnknownEffect(ProjectFaviconPath);
175​
176effectIt.effect("project favicon paths accept only supported image files", () =>
177 Effect.gen(function* () {
178 assert.strictEqual(yield* decodeFaviconPath("brand/icon.svg"), "brand/icon.svg");
179 assert.strictEqual(
180 yield* decodeFaviconPath("Resources/AppIcon.icns"),
181 "Resources/AppIcon.icns",
182 );
183 assert.strictEqual((yield* Effect.exit(decodeFaviconPath(".env")))._tag, "Failure");
184 }),
185);
186​
187const decodeStoredIcon = Schema.decodeUnknownEffect(StoredProjectIcon);
188const decodeReceivedIcon = Schema.decodeUnknownEffect(ReceivedProjectIcon);
189const decodeProjectShell = Schema.decodeUnknownEffect(OrchestrationProjectShell);
190​
191effectIt.effect("reads monograms stored in the pre-v2 fallback shape", () =>
192 Effect.gen(function* () {
193 const monogram = { kind: "monogram", text: "T3", color: "violet" } as const;
194 const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const;
195 for (const legacy of [
196 { ...fallback, monogramText: "T3" },
197 { ...fallback, monogram: "T3" },
198 ]) {
199 assert.deepEqual(yield* decodeStoredIcon(legacy), monogram);
200 assert.deepEqual(yield* decodeReceivedIcon(legacy), monogram);
201 }
202 }),
203);
204​
205effectIt.effect("an icon kind from a newer server shows the default icon", () =>
206 Effect.gen(function* () {
207 assert.isNull(yield* decodeReceivedIcon({ kind: "image", url: "https://example.com/a.png" }));
208 const shell = yield* decodeProjectShell({
209 id: "project-1",
210 title: "Project",
211 workspaceRoot: "/tmp/project",
212 defaultModelSelection: null,
213 scripts: [],
214 projectIcon: { kind: "image", url: "https://example.com/a.png" },
215 createdAt: "2026-01-01T00:00:00.000Z",
216 updatedAt: "2026-01-01T00:00:00.000Z",
217 });
218 assert.isNull(shell.projectIcon);
219 const broken = yield* Effect.exit(decodeReceivedIcon({ kind: "emoji" }));
220 assert.strictEqual(broken._tag, "Failure");
221 }),
222);