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 { ProjectId } from "./baseSchemas.ts";
7import { OrchestrationProjectShell } from "./orchestrationProject.ts";
8
9import {
10 ProjectFaviconPath,
11 ReceivedProjectIcon,
12 StoredProjectIcon,
13 ProjectReadFileError,
14 ProjectCreatePayload,
15 ProjectUpdatePayload,
16 ProjectMutation,
17 ProjectSearchContentsError,
18 ProjectSearchContentsInput,
19 ProjectSearchEntriesError,
20 ProjectSearchEntriesInput,
21 ProjectWriteFileError,
22} from "./project.ts";
23
24const decodeProjectCreatePayload = Schema.decodeUnknownSync(ProjectCreatePayload);
25const decodeProjectUpdatePayload = Schema.decodeUnknownSync(ProjectUpdatePayload);
26const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation);
27const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput);
28const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput);
29
30describe("project search inputs", () => {
31 it("allows an empty entries query for bounded frecency browsing", () => {
32 const decoded = decodeSearchEntriesInput({
33 cwd: "/workspace",
34 query: " ",
35 limit: 10,
36 kind: "file",
37 });
38 expect(decoded.query).toBe("");
39 });
40
41 it("preserves whitespace in content search queries", () => {
42 const decoded = decodeSearchContentsInput({
43 cwd: "/workspace",
44 query: " foo ",
45 limit: 10,
46 caseSensitive: false,
47 wholeWord: false,
48 useRegex: false,
49 });
50 expect(decoded.query).toBe(" foo ");
51 });
52});
53
54describe("project RPC errors", () => {
55 it("derives stable messages from structured request context while retaining causes", () => {
56 const cause = new Error("sensitive platform detail");
57 const searchError = new ProjectSearchEntriesError({
58 cwd: "/workspace",
59 queryLength: "authorization: Bearer secret-token".length,
60 limit: 20,
61 failure: "search_index_search_failed",
62 normalizedCwd: "/workspace",
63 detail: "index unavailable",
64 cause,
65 });
66 const readError = new ProjectReadFileError({
67 cwd: "/workspace",
68 relativePath: "src/index.ts",
69 failure: "operation_failed",
70 operation: "read",
71 operationPath: "/workspace/src/index.ts",
72 resolvedPath: "/workspace/src/index.ts",
73 cause,
74 });
75
76 expect(searchError.message).toBe("Failed to search workspace entries in '/workspace'.");
77 expect(searchError.message).not.toContain(cause.message);
78 expect(searchError.normalizedCwd).toBe("/workspace");
79 expect(searchError.queryLength).toBe("authorization: Bearer secret-token".length);
80 expect(searchError).not.toHaveProperty("query");
81 expect(searchError.message).not.toMatch(/Bearer|secret-token/);
82 expect(searchError.cause).toBe(cause);
83 expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'.");
84 expect(readError.message).not.toContain(cause.message);
85 expect(readError.cause).toBe(cause);
86
87 const contentSearchError = new ProjectSearchContentsError({
88 cwd: "/workspace",
89 queryLength: "authorization: Bearer secret-token".length,
90 limit: 100,
91 failure: "search_index_search_failed",
92 cause,
93 });
94 expect(contentSearchError.message).toBe("Failed to search workspace contents in '/workspace'.");
95 expect(contentSearchError.message).not.toContain(cause.message);
96 expect(contentSearchError).not.toHaveProperty("query");
97 expect(contentSearchError.cause).toBe(cause);
98 });
99
100 it("decodes legacy message-only errors during rolling upgrades", () => {
101 const decodeSearchError = Schema.decodeUnknownSync(ProjectSearchEntriesError);
102 const decodeWriteError = Schema.decodeUnknownSync(ProjectWriteFileError);
103
104 const searchError = decodeSearchError({
105 _tag: "ProjectSearchEntriesError",
106 message: "Legacy project search failure.",
107 query: "legacy sensitive query",
108 });
109 const writeError = decodeWriteError({
110 _tag: "ProjectWriteFileError",
111 message: "Legacy project write failure.",
112 });
113
114 expect(searchError.message).toBe("Legacy project search failure.");
115 expect(searchError.cwd).toBeUndefined();
116 expect(searchError.queryLength).toBeUndefined();
117 expect(searchError).not.toHaveProperty("query");
118 expect(searchError.failure).toBeUndefined();
119 expect(writeError.message).toBe("Legacy project write failure.");
120 expect(writeError.relativePath).toBeUndefined();
121 expect(writeError.failure).toBeUndefined();
122 });
123});
124
125describe("shared project payloads", () => {
126 it("preserves omitted, false, and null values through RPC envelopes", () => {
127 const create = decodeProjectCreatePayload({
128 title: " Example ",
129 workspaceRoot: "/workspace",
130 createWorkspaceRootIfMissing: false,
131 });
132 const update = decodeProjectUpdatePayload({
133 autoPull: false,
134 defaultModelSelection: null,
135 faviconPath: null,
136 });
137 const envelope = { commandId: "command", projectId: "project" };
138 expect(decodeProjectMutation({ type: "project.create", ...envelope, ...create })).toEqual({
139 type: "project.create",
140 ...envelope,
141 title: "Example",
142 workspaceRoot: "/workspace",
143 createWorkspaceRootIfMissing: false,
144 });
145 expect(decodeProjectMutation({ type: "project.update", ...envelope, ...update })).toEqual({
146 type: "project.update",
147 ...envelope,
148 autoPull: false,
149 defaultModelSelection: null,
150 faviconPath: null,
151 });
152 expect(Object.hasOwn(create, "scripts")).toBe(false);
153 expect(Object.hasOwn(update, "title")).toBe(false);
154 // Internal RPC callers may explicitly supply undefined, as before the extraction.
155 expect(
156 decodeProjectMutation({ type: "project.update", ...envelope, title: undefined }),
157 ).toHaveProperty("title", undefined);
158 });
159});
160
161const decodeFaviconPath = Schema.decodeUnknownEffect(ProjectFaviconPath);
162
163effectIt.effect("project favicon paths accept only supported image files", () =>
164 Effect.gen(function* () {
165 assert.strictEqual(yield* decodeFaviconPath("brand/icon.svg"), "brand/icon.svg");
166 assert.strictEqual(
167 yield* decodeFaviconPath("Resources/AppIcon.icns"),
168 "Resources/AppIcon.icns",
169 );
170 assert.strictEqual((yield* Effect.exit(decodeFaviconPath(".env")))._tag, "Failure");
171 }),
172);
173
174const decodeProjectUpdateEffect = Schema.decodeUnknownEffect(ProjectUpdatePayload);
175const decodeUpdateIcon = (projectIcon: unknown) =>
176 Effect.map(decodeProjectUpdateEffect({ projectIcon }), (update) => update.projectIcon);
177
178effectIt.effect("project icon overrides accept Lucide icons, colors, and emoji", () =>
179 Effect.gen(function* () {
180 const lucide = { kind: "lucide", name: "alarm-clock", color: "violet" } as const;
181 assert.deepEqual(yield* decodeUpdateIcon(lucide), lucide);
182 const emoji = { kind: "emoji", emoji: "👩🏽‍💻" } as const;
183 assert.deepEqual(yield* decodeUpdateIcon(emoji), emoji);
184 const invalid = yield* Effect.exit(
185 decodeUpdateIcon({ kind: "lucide", name: "Alarm Clock", color: "ultraviolet" }),
186 );
187 assert.strictEqual(invalid._tag, "Failure");
188 }),
189);
190
191effectIt.effect("project monograms validate text and palette colors", () =>
192 Effect.gen(function* () {
193 for (const text of ["A", "T3", "É", "文書", "कि", "किखि", "e\u0301"]) {
194 assert.deepEqual(yield* decodeUpdateIcon({ kind: "monogram", color: "violet", text }), {
195 kind: "monogram",
196 text,
197 color: "violet",
198 });
199 }
200 for (const projectIcon of [
201 { kind: "monogram", text: "", color: "blue" },
202 { kind: "monogram", text: "\u0301", color: "blue" },
203 { kind: "monogram", text: "A B", color: "blue" },
204 { kind: "monogram", text: "🚀", color: "blue" },
205 { kind: "monogram", text: "T3", color: "ultraviolet" },
206 ]) {
207 assert.strictEqual((yield* Effect.exit(decodeUpdateIcon(projectIcon)))._tag, "Failure");
208 }
209 }),
210);
211
212const decodeStoredIcon = Schema.decodeUnknownEffect(StoredProjectIcon);
213const encodeStoredIcon = Schema.encodeEffect(StoredProjectIcon);
214const decodeReceivedIcon = Schema.decodeUnknownEffect(ReceivedProjectIcon);
215const encodeReceivedIcon = Schema.encodeEffect(ReceivedProjectIcon);
216const decodeProjectShell = Schema.decodeUnknownEffect(OrchestrationProjectShell);
217
218effectIt.effect("sends and stores icons in their plain shape", () =>
219 Effect.gen(function* () {
220 for (const icon of [
221 { kind: "monogram", text: "क्ष्म", color: "violet" },
222 { kind: "lucide", name: "alarm-clock", color: "blue" },
223 { kind: "emoji", emoji: "🚀" },
224 ] as const) {
225 assert.deepEqual(yield* encodeReceivedIcon(icon), icon);
226 assert.deepEqual(yield* encodeStoredIcon(icon), icon);
227 assert.deepEqual(yield* decodeReceivedIcon(icon), icon);
228 }
229 }),
230);
231
232effectIt.effect("reads monograms stored in the pre-v2 fallback shape", () =>
233 Effect.gen(function* () {
234 const monogram = { kind: "monogram", text: "T3", color: "violet" } as const;
235 const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const;
236 for (const legacy of [
237 { ...fallback, monogramText: "T3" },
238 { ...fallback, monogram: "T3" },
239 ]) {
240 assert.deepEqual(yield* decodeStoredIcon(legacy), monogram);
241 assert.deepEqual(yield* decodeReceivedIcon(legacy), monogram);
242 }
243 }),
244);
245
246effectIt.effect("an icon kind from a newer server shows the default icon", () =>
247 Effect.gen(function* () {
248 assert.isNull(yield* decodeReceivedIcon({ kind: "image", url: "https://example.com/a.png" }));
249 const shell = yield* decodeProjectShell({
250 id: "project-1",
251 title: "Project",
252 workspaceRoot: "/tmp/project",
253 defaultModelSelection: null,
254 scripts: [],
255 projectIcon: { kind: "image", url: "https://example.com/a.png" },
256 createdAt: "2026-01-01T00:00:00.000Z",
257 updatedAt: "2026-01-01T00:00:00.000Z",
258 });
259 assert.isNull(shell.projectIcon);
260 // A known kind with a broken payload still fails.
261 const broken = yield* Effect.exit(decodeReceivedIcon({ kind: "emoji" }));
262 assert.strictEqual(broken._tag, "Failure");
263 }),
264);