1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3import {
4 EnvironmentId,
5 ProjectId,
6 CommandId,
7 SourceControlDiscoveryResult,
8} from "@t3tools/contracts";
9import * as Option from "effect/Option";
10
11import {
12 buildAddProjectRemoteSourceReadiness,
13 buildProjectCreateCommand,
14 canCreateProjectInEnvironment,
15 findExistingAddProject,
16 getAddProjectInitialQuery,
17 getCloneDestinationBrowsePath,
18 getCloneDestinationPath,
19 getCloneDirectoryName,
20 getDefaultCloneUrl,
21 getNewProjectPublishTarget,
22 getNewProjectPublishTargets,
23 normalizePastedCloneUrl,
24 resolveAddProjectPath,
25 sortAddProjectProviderSources,
26} from "./projects.ts";
27import type { EnvironmentProject } from "../state/models.ts";
28
29describe("add project shared logic", () => {
30 it("only allows project creation in connected environments", () => {
31 expect(canCreateProjectInEnvironment("connected")).toBe(true);
32 expect(canCreateProjectInEnvironment("available")).toBe(false);
33 expect(canCreateProjectInEnvironment("offline")).toBe(false);
34 expect(canCreateProjectInEnvironment("connecting")).toBe(false);
35 expect(canCreateProjectInEnvironment("reconnecting")).toBe(false);
36 expect(canCreateProjectInEnvironment("error")).toBe(false);
37 });
38
39 it("resolves initial browse paths from settings", () => {
40 expect(getAddProjectInitialQuery("")).toBe("~/");
41 expect(getAddProjectInitialQuery("/work")).toBe("/work/");
42 expect(getAddProjectInitialQuery("C:\\work")).toBe("C:\\work\\");
43 });
44
45 it("derives the clone folder name from the repository name with owner", () => {
46 expect(getCloneDirectoryName("owner/repo")).toBe("repo");
47 expect(getCloneDirectoryName("org/project/repo")).toBe("repo");
48 expect(getCloneDirectoryName("repo")).toBe("repo");
49 expect(getCloneDirectoryName("owner/repo/")).toBe("repo");
50 expect(getCloneDirectoryName("")).toBe("");
51 expect(getCloneDirectoryName(null)).toBe("");
52 });
53
54 it("routes owner/repository shorthand to GitHub over HTTPS", () => {
55 expect(normalizePastedCloneUrl("imputnet/helium")).toBe(
56 "https://github.com/imputnet/helium.git",
57 );
58 expect(normalizePastedCloneUrl(" pingdotgg/t3code ")).toBe(
59 "https://github.com/pingdotgg/t3code.git",
60 );
61 });
62
63 it("keeps explicit clone URLs and local paths unchanged", () => {
64 expect(normalizePastedCloneUrl("https://gitlab.com/group/project.git")).toBe(
65 "https://gitlab.com/group/project.git",
66 );
67 expect(normalizePastedCloneUrl("git@github.com:owner/repo.git")).toBe(
68 "git@github.com:owner/repo.git",
69 );
70 expect(normalizePastedCloneUrl("group/subgroup/project")).toBe("group/subgroup/project");
71 expect(normalizePastedCloneUrl("/srv/git/repo.git")).toBe("/srv/git/repo.git");
72 });
73
74 it("uses HTTPS for repositories selected through a provider", () => {
75 expect(
76 getDefaultCloneUrl({
77 provider: SourceControlProviderKind.make("github"),
78 url: "https://github.com/imputnet/helium",
79 sshUrl: "git@github.com:imputnet/helium.git",
80 }),
81 ).toBe("https://github.com/imputnet/helium");
82 expect(
83 getDefaultCloneUrl({
84 provider: SourceControlProviderKind.make("forgejo"),
85 url: "https://forgejo.example.test:8443/owner/repo.git",
86 sshUrl: "ssh://git@forgejo.example.test:2222/owner/repo.git",
87 }),
88 ).toBe("https://forgejo.example.test:8443/owner/repo.git");
89 });
90
91 it("preserves existing clone transport behavior for other providers", () => {
92 expect(
93 getDefaultCloneUrl({
94 provider: SourceControlProviderKind.make("gitlab"),
95 url: "https://gitlab.com/group/project.git",
96 sshUrl: "git@gitlab.com:group/project.git",
97 }),
98 ).toBe("git@gitlab.com:group/project.git");
99 });
100
101 it("derives the clone folder name from any pasted clone URL", () => {
102 expect(getCloneDirectoryName("https://github.com/owner/repo.git")).toBe("repo");
103 expect(getCloneDirectoryName("https://github.com/owner/repo")).toBe("repo");
104 expect(getCloneDirectoryName("https://github.com/owner/repo/")).toBe("repo");
105 expect(getCloneDirectoryName("git@github.com:owner/repo.git")).toBe("repo");
106 expect(getCloneDirectoryName("ssh://git@github.com:22/owner/repo.git")).toBe("repo");
107 expect(getCloneDirectoryName("https://user@bitbucket.org/owner/repo.git")).toBe("repo");
108 expect(getCloneDirectoryName("https://dev.azure.com/org/project/_git/repo")).toBe("repo");
109 expect(getCloneDirectoryName("https://github.com/owner/repo.git?ref=main#readme")).toBe("repo");
110 expect(getCloneDirectoryName("/srv/git/repo.git")).toBe("repo");
111 expect(getCloneDirectoryName("C:\\src\\repo.git")).toBe("repo");
112 expect(getCloneDirectoryName("git@github.com:repo.git")).toBe("repo");
113 expect(getCloneDirectoryName(" https://github.com/owner/repo.git ")).toBe("repo");
114 });
115
116 it("keeps a numeric repository name that sits on a path", () => {
117 expect(getCloneDirectoryName("https://github.com/acme/123.git")).toBe("123");
118 expect(getCloneDirectoryName("https://github.com/acme/123")).toBe("123");
119 expect(getCloneDirectoryName("git@github.com:acme/123.git")).toBe("123");
120 });
121
122 it("proposes no clone folder for a link that names no repository", () => {
123 expect(getCloneDirectoryName("https://github.com/")).toBe("");
124 expect(getCloneDirectoryName("https://github.com")).toBe("");
125 expect(getCloneDirectoryName("git@github.com:")).toBe("");
126 expect(getCloneDirectoryName("ssh://git@github.com:22")).toBe("");
127 expect(getCloneDirectoryName("https://")).toBe("");
128 });
129
130 it("proposes the clone destination inside the selected directory", () => {
131 expect(getCloneDestinationPath("~/Projects/", "repo")).toBe("~/Projects/repo");
132 expect(getCloneDestinationPath("~/Projects", "repo")).toBe("~/Projects/repo");
133 expect(getCloneDestinationPath("C:\\work\\", "repo")).toBe("C:\\work\\repo");
134 expect(getCloneDestinationPath("~/Projects/", null)).toBe("~/Projects/");
135 expect(getCloneDestinationPath("~/Projects/", "")).toBe("~/Projects/");
136 });
137
138 it("keeps pinned clone destinations anchored to the browsed directory", () => {
139 expect(
140 getCloneDestinationBrowsePath({
141 browseDirectoryPath: "~/Projects/",
142 selectedDirectoryName: "work",
143 cloneDirectoryName: "repo",
144 caseSensitive: true,
145 }),
146 ).toBe("~/Projects/work/repo");
147 expect(
148 getCloneDestinationBrowsePath({
149 browseDirectoryPath: "~/Projects/",
150 selectedDirectoryName: "repo",
151 cloneDirectoryName: "repo",
152 caseSensitive: true,
153 }),
154 ).toBe("~/Projects/repo/");
155 expect(
156 getCloneDestinationBrowsePath({
157 browseDirectoryPath: "C:\\Projects\\",
158 selectedDirectoryName: "Repo",
159 cloneDirectoryName: "repo",
160 caseSensitive: false,
161 }),
162 ).toBe("C:\\Projects\\Repo\\");
163 });
164
165 it("rejects unsupported windows paths on non-windows environments", () => {
166 expect(
167 resolveAddProjectPath({
168 rawPath: "C:\\repo",
169 platform: "MacIntel",
170 currentProjectCwd: null,
171 }),
172 ).toEqual({
173 ok: false,
174 error: "Windows-style paths are only supported on Windows environments.",
175 });
176 });
177
178 it("resolves relative paths from the active project cwd", () => {
179 expect(
180 resolveAddProjectPath({
181 rawPath: "../next",
182 platform: "Linux",
183 currentProjectCwd: "/work/current",
184 }),
185 ).toEqual({ ok: true, path: "/work/next" });
186 });
187
188 it("marks authenticated source control providers as ready", () => {
189 const discovery: SourceControlDiscoveryResult = {
190 versionControlSystems: [],
191 sourceControlProviders: [
192 {
193 kind: SourceControlProviderKind.make("github"),
194 label: "GitHub",
195 status: "available",
196 installHint: "Install gh",
197 version: Option.some("1.0.0"),
198 detail: Option.none(),
199 auth: {
200 status: "authenticated",
201 account: Option.some("octo"),
202 host: Option.some("github.com"),
203 detail: Option.none(),
204 },
205 },
206 {
207 kind: SourceControlProviderKind.make("gitlab"),
208 label: "GitLab",
209 status: "available",
210 installHint: "Install glab",
211 version: Option.some("1.0.0"),
212 detail: Option.none(),
213 auth: {
214 status: "unauthenticated",
215 account: Option.none(),
216 host: Option.none(),
217 detail: Option.some("Run glab auth login"),
218 },
219 },
220 ],
221 };
222
223 const readiness = buildAddProjectRemoteSourceReadiness(discovery);
224 expect(readiness("url").ready).toBe(true);
225 expect(readiness(SourceControlProviderKind.make("github")).ready).toBe(true);
226 expect(readiness(SourceControlProviderKind.make("gitlab"))).toEqual({
227 ready: false,
228 hint: "Run glab auth login",
229 });
230 expect(sortAddProjectProviderSources(readiness)[0]).toBe("github");
231 });
232
233 it("finds existing projects by normalized path in the target environment", () => {
234 const env = EnvironmentId.make("env");
235 const other = EnvironmentId.make("other");
236 const projects: EnvironmentProject[] = [
237 {
238 environmentId: other,
239 id: ProjectId.make("same-path-other-env"),
240 title: "Other",
241 workspaceRoot: "/repo",
242 createdAt: "2026-01-01T00:00:00.000Z",
243 updatedAt: "2026-01-01T00:00:00.000Z",
244 repositoryIdentity: null,
245 defaultModelSelection: null,
246 scripts: [],
247 },
248 {
249 environmentId: env,
250 id: ProjectId.make("project"),
251 title: "Repo",
252 workspaceRoot: "/repo/",
253 createdAt: "2026-01-01T00:00:00.000Z",
254 updatedAt: "2026-01-01T00:00:00.000Z",
255 repositoryIdentity: null,
256 defaultModelSelection: null,
257 scripts: [],
258 },
259 ];
260
261 expect(findExistingAddProject({ projects, environmentId: env, path: "/repo" })?.id).toBe(
262 "project",
263 );
264 });
265
266 it("builds the V2 project.create mutation", () => {
267 expect(
268 buildProjectCreateCommand({
269 commandId: CommandId.make("command"),
270 projectId: ProjectId.make("project"),
271 workspaceRoot: "/work/repo",
272 }),
273 ).toMatchObject({
274 type: "project.create",
275 commandId: "command",
276 projectId: "project",
277 title: "repo",
278 workspaceRoot: "/work/repo",
279 createWorkspaceRootIfMissing: true,
280 defaultModelSelection: null,
281 });
282 });
283});
284
285describe("new project publish targets", () => {
286 const readyHost = (kind: string, account: string | null) => ({
287 kind: SourceControlProviderKind.make(kind),
288 label: kind,
289 status: "available" as const,
290 installHint: "",
291 version: Option.none(),
292 detail: Option.none(),
293 auth: {
294 status: "authenticated" as const,
295 account: Option.fromNullOr(account),
296 host: Option.none(),
297 detail: Option.none(),
298 },
299 });
300
301 it("offers ready hosts that can place the repository, GitHub first, and keeps the pick", () => {
302 const targets = getNewProjectPublishTargets({
303 versionControlSystems: [],
304 sourceControlProviders: [
305 readyHost("gitlab", "group"),
306 readyHost("github", null),
307 readyHost("azure-devops", "me"),
308 ],
309 });
310 expect(targets.map((target) => target.definition.kind)).toEqual(["github", "gitlab"]);
311 expect(getNewProjectPublishTarget(targets, null)?.definition.kind).toBe("github");
312 expect(
313 getNewProjectPublishTarget(targets, SourceControlProviderKind.make("gitlab"))?.owner,
314 ).toBe("group");
315 });
316});