1import type { ScopedThreadRef } from "@t3tools/contracts";
2import { ProjectReadFileError } from "@t3tools/contracts";
3import * as Schema from "effect/Schema";
4import { describe, expect, it } from "vite-plus/test";
5
6import {
7 formatFileCommentRange,
8 normalizeFileCommentRange,
9 remapFileCommentAnnotations,
10} from "./fileCommentAnnotations";
11import {
12 filePreviewReadErrorMessage,
13 isMarkdownPreviewFile,
14 resolveFilePreviewPath,
15 shouldShowFileExplorer,
16 workspaceAssetResource,
17} from "./filePreviewMode";
18
19const decodeReadError = Schema.decodeSync(ProjectReadFileError);
20
21describe("file preview read errors", () => {
22 it.each([
23 ["path_not_file", "The path is a directory or special file, not a regular file."],
24 ["binary_file", "The file is binary and cannot be displayed as text."],
25 ["workspace_path_outside_root", "The requested path is outside the workspace."],
26 ["resolved_path_outside_root", "The path resolves to a location outside the workspace."],
27 [
28 "operation_failed",
29 "The file could not be accessed or read. It may be missing or inaccessible.",
30 ],
31 ] as const)("describes %s without revealing the platform cause", (failure, message) => {
32 const error = new ProjectReadFileError({
33 cwd: "/workspace",
34 relativePath: "workspace/outline.md",
35 failure,
36 operation: "realpath-target",
37 resolvedPath: "/workspace/workspace/outline.md",
38 cause: new Error("EACCES: sensitive platform detail"),
39 });
40 expect(filePreviewReadErrorMessage(error)).toBe(message);
41 });
42
43 it("distinguishes an inaccessible workspace from an inaccessible file", () => {
44 const error = new ProjectReadFileError({
45 cwd: "/workspace",
46 relativePath: "outline.md",
47 failure: "operation_failed",
48 operation: "realpath-workspace-root",
49 operationPath: "/workspace",
50 });
51 expect(filePreviewReadErrorMessage(error)).toBe("The workspace folder could not be accessed.");
52 });
53
54 it("preserves the public message from older servers", () => {
55 const error = decodeReadError({
56 _tag: "ProjectReadFileError",
57 message: "Legacy file read failure.",
58 });
59 expect(filePreviewReadErrorMessage(error)).toBe("Legacy file read failure.");
60 });
61});
62
63describe("file comment annotations", () => {
64 it("normalizes and formats selected line ranges", () => {
65 expect(normalizeFileCommentRange({ start: 16, end: 7 })).toEqual({
66 startLine: 7,
67 endLine: 16,
68 });
69 expect(formatFileCommentRange(7, 7)).toBe("L7");
70 expect(formatFileCommentRange(7, 16)).toBe("L7 to L16");
71 });
72
73 it("keeps an annotation range attached when Pierre remaps its anchor line", () => {
74 expect(
75 remapFileCommentAnnotations([
76 {
77 lineNumber: 20,
78 metadata: {
79 entries: [
80 {
81 id: "comment-1",
82 kind: "comment",
83 startLine: 7,
84 endLine: 16,
85 text: "Keep this guarded.",
86 },
87 ],
88 },
89 },
90 ]),
91 ).toEqual([
92 {
93 lineNumber: 20,
94 metadata: {
95 entries: [
96 {
97 id: "comment-1",
98 kind: "comment",
99 startLine: 11,
100 endLine: 20,
101 text: "Keep this guarded.",
102 },
103 ],
104 },
105 },
106 ]);
107 });
108});
109
110describe("isMarkdownPreviewFile", () => {
111 it("recognizes markdown and MDX files case-insensitively", () => {
112 expect(isMarkdownPreviewFile("README.md")).toBe(true);
113 expect(isMarkdownPreviewFile("docs/guide.MDX")).toBe(true);
114 });
115
116 it("does not treat other text files as markdown", () => {
117 expect(isMarkdownPreviewFile("docs/guide.txt")).toBe(false);
118 expect(isMarkdownPreviewFile("docs/markdown.ts")).toBe(false);
119 });
120});
121
122describe("shouldShowFileExplorer", () => {
123 it("hides the workspace tree for host files and attachments", () => {
124 expect(
125 shouldShowFileExplorer({
126 relativePath: "/tmp/report.pdf",
127 explorerOpen: true,
128 attachmentOpen: false,
129 }),
130 ).toBe(false);
131 expect(
132 shouldShowFileExplorer({
133 relativePath: "report.pdf",
134 explorerOpen: true,
135 attachmentOpen: true,
136 }),
137 ).toBe(false);
138 });
139
140 it("keeps the saved explorer preference for workspace files", () => {
141 expect(
142 shouldShowFileExplorer({
143 relativePath: "docs/report.pdf",
144 explorerOpen: true,
145 attachmentOpen: false,
146 }),
147 ).toBe(true);
148 expect(
149 shouldShowFileExplorer({
150 relativePath: "docs/report.pdf",
151 explorerOpen: false,
152 attachmentOpen: false,
153 }),
154 ).toBe(false);
155 });
156});
157
158describe("resolveFilePreviewPath", () => {
159 it.each([
160 ["/repo/project", null],
161 ["/repo/project/", null],
162 [".", null],
163 [null, null],
164 ["/repo/project/src", "/repo/project/src"],
165 ["/repo/project/src/main.ts", "/repo/project/src/main.ts"],
166 ["src/main.ts", "src/main.ts"],
167 ["/repo/project-other", "/repo/project-other"],
168 ])("opens %s in the appropriate workspace surface", (path, expected) => {
169 const relativePath = resolveFilePreviewPath(path, "/repo/project");
170 expect(relativePath).toBe(expected);
171 if (expected === null) {
172 expect(
173 shouldShowFileExplorer({ relativePath, explorerOpen: false, attachmentOpen: false }),
174 ).toBe(true);
175 }
176 });
177});
178
179describe("workspaceAssetResource", () => {
180 const input = {
181 kind: "workspace-file" as const,
182 threadRef: {
183 environmentId: "env" as ScopedThreadRef["environmentId"],
184 threadId: "thread-1" as ScopedThreadRef["threadId"],
185 },
186 workspaceRoot: "/repo",
187 absolutePath: "/repo/docs/index.html",
188 };
189
190 it("names the thread once the server knows it", () => {
191 expect(workspaceAssetResource({ ...input, draft: false })).toEqual({
192 _tag: "workspace-file",
193 threadId: "thread-1",
194 path: "/repo/docs/index.html",
195 });
196 });
197
198 it("names the workspace root for a draft, which the server cannot resolve from a thread", () => {
199 expect(workspaceAssetResource({ ...input, draft: true })).toEqual({
200 _tag: "draft-workspace-file",
201 cwd: "/repo",
202 path: "/repo/docs/index.html",
203 });
204 });
205});