1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 isFirstRunWorkspaceProvenanceAuthoritative,
5 isFreshFirstRunWorkspace,
6 resolveFirstRunDecision,
7 resolveHostedFirstRunDecision,
8 transitionFirstRunGateState,
9} from "./firstRun.logic";
10
11const freshWorkspace = {
12 enabled: true,
13 hydrated: true,
14 completed: false,
15 bootstrapped: true,
16 authoritative: true,
17 workspaceAuthoritative: true,
18 workspaceProvenanceAuthoritative: true,
19 catalogReady: true,
20 serverConfigAvailable: true,
21 workspaceFresh: true,
22 projectCount: 1,
23 threadCount: 1,
24} as const;
25
26describe("resolveFirstRunDecision", () => {
27 it("opens the wizard for an authoritative fresh workspace", () => {
28 expect(resolveFirstRunDecision(freshWorkspace)).toEqual({
29 decision: "wizard",
30 persistCompletion: false,
31 });
32 });
33
34 it("does not permanently complete onboarding from cached project counts", () => {
35 expect(
36 resolveFirstRunDecision({
37 ...freshWorkspace,
38 authoritative: false,
39 projectCount: 3,
40 workspaceFresh: false,
41 }),
42 ).toEqual({
43 decision: "app",
44 persistCompletion: false,
45 });
46 });
47
48 it("backfills completion once existing projects are confirmed by the server", () => {
49 expect(
50 resolveFirstRunDecision({
51 ...freshWorkspace,
52 projectCount: 3,
53 workspaceFresh: false,
54 }),
55 ).toEqual({
56 decision: "app",
57 persistCompletion: true,
58 });
59 });
60
61 it("does not complete onboarding while another environment is still bootstrapping", () => {
62 expect(
63 resolveFirstRunDecision({
64 ...freshWorkspace,
65 bootstrapped: false,
66 projectCount: 3,
67 workspaceFresh: false,
68 }),
69 ).toEqual({
70 decision: "app",
71 persistCompletion: false,
72 });
73 });
74
75 it("waits for managed environments before treating a workspace as new", () => {
76 expect(resolveFirstRunDecision({ ...freshWorkspace, catalogReady: false })).toEqual({
77 decision: "pending",
78 persistCompletion: false,
79 });
80 });
81
82 it("does not complete onboarding before the environment catalog is ready", () => {
83 expect(
84 resolveFirstRunDecision({
85 ...freshWorkspace,
86 catalogReady: false,
87 projectCount: 3,
88 workspaceFresh: false,
89 }),
90 ).toEqual({
91 decision: "app",
92 persistCompletion: false,
93 });
94 });
95
96 it("does not complete onboarding before the server configuration is available", () => {
97 expect(
98 resolveFirstRunDecision({
99 ...freshWorkspace,
100 projectCount: 3,
101 serverConfigAvailable: false,
102 workspaceFresh: false,
103 }),
104 ).toEqual({
105 decision: "app",
106 persistCompletion: false,
107 });
108 });
109
110 it("does not complete onboarding from cached remote projects", () => {
111 expect(
112 resolveFirstRunDecision({
113 ...freshWorkspace,
114 projectCount: 3,
115 workspaceAuthoritative: false,
116 workspaceFresh: false,
117 }),
118 ).toEqual({
119 decision: "app",
120 persistCompletion: false,
121 });
122 });
123
124 it("does not complete onboarding from a single cached remote project", () => {
125 expect(
126 resolveFirstRunDecision({
127 ...freshWorkspace,
128 workspaceAuthoritative: false,
129 workspaceFresh: false,
130 }),
131 ).toEqual({
132 decision: "app",
133 persistCompletion: false,
134 });
135 });
136
137 it("waits for live data before judging a single cached project", () => {
138 expect(
139 resolveFirstRunDecision({
140 ...freshWorkspace,
141 authoritative: false,
142 workspaceFresh: false,
143 }),
144 ).toEqual({
145 decision: "pending",
146 persistCompletion: false,
147 });
148 });
149
150 it("waits for the completed bootstrap welcome before judging a nonempty workspace", () => {
151 expect(
152 resolveFirstRunDecision({
153 ...freshWorkspace,
154 workspaceProvenanceAuthoritative: false,
155 }),
156 ).toEqual({
157 decision: "pending",
158 persistCompletion: false,
159 });
160 });
161
162 it("waits when the initial welcome is pending and opens the wizard after completion", () => {
163 const pendingProvenance = isFirstRunWorkspaceProvenanceAuthoritative({
164 welcomeReceived: true,
165 bootstrapStatus: "pending",
166 });
167 expect(
168 resolveFirstRunDecision({
169 ...freshWorkspace,
170 workspaceProvenanceAuthoritative: pendingProvenance,
171 }),
172 ).toEqual({ decision: "pending", persistCompletion: false });
173
174 const completedProvenance = isFirstRunWorkspaceProvenanceAuthoritative({
175 welcomeReceived: true,
176 bootstrapStatus: "complete",
177 });
178 expect(
179 resolveFirstRunDecision({
180 ...freshWorkspace,
181 workspaceProvenanceAuthoritative: completedProvenance,
182 }),
183 ).toEqual({ decision: "wizard", persistCompletion: false });
184 });
185
186 it("does not wait for server data after onboarding is already complete", () => {
187 expect(
188 resolveFirstRunDecision({
189 ...freshWorkspace,
190 authoritative: false,
191 bootstrapped: false,
192 completed: true,
193 serverConfigAvailable: false,
194 }),
195 ).toEqual({
196 decision: "app",
197 persistCompletion: false,
198 });
199 });
200});
201
202describe("isFirstRunWorkspaceProvenanceAuthoritative", () => {
203 it("waits for cwd bootstrap when the initial catalog is empty", () => {
204 expect(
205 isFirstRunWorkspaceProvenanceAuthoritative({
206 welcomeReceived: true,
207 bootstrapStatus: "pending",
208 }),
209 ).toBe(false);
210 });
211
212 it("accepts an empty catalog after cwd bootstrap completes", () => {
213 expect(
214 isFirstRunWorkspaceProvenanceAuthoritative({
215 welcomeReceived: true,
216 bootstrapStatus: "complete",
217 }),
218 ).toBe(true);
219 });
220
221 it("waits for a welcome before treating an empty catalog as final", () => {
222 expect(
223 isFirstRunWorkspaceProvenanceAuthoritative({
224 welcomeReceived: false,
225 bootstrapStatus: null,
226 }),
227 ).toBe(false);
228 });
229
230 it("accepts a legacy welcome without bootstrap status", () => {
231 expect(
232 isFirstRunWorkspaceProvenanceAuthoritative({
233 welcomeReceived: true,
234 bootstrapStatus: null,
235 }),
236 ).toBe(true);
237 });
238});
239
240describe("transitionFirstRunGateState", () => {
241 it("shows recovery without mounting the app when evidence stalls", () => {
242 expect(
243 transitionFirstRunGateState({ decision: "pending", stalled: false }, { type: "timeout" }),
244 ).toEqual({ decision: "pending", stalled: true });
245 });
246
247 it.each(["app", "wizard"] as const)(
248 "resolves stalled recovery to %s only after authoritative evidence",
249 (decision) => {
250 expect(
251 transitionFirstRunGateState(
252 { decision: "pending", stalled: true },
253 { type: "evidence", decision },
254 ),
255 ).toEqual({ decision, stalled: false });
256 },
257 );
258
259 it("keeps recovery visible while evidence remains pending", () => {
260 const state = { decision: "pending", stalled: true } as const;
261 expect(transitionFirstRunGateState(state, { type: "evidence", decision: "pending" })).toBe(
262 state,
263 );
264 });
265
266 it("allows authoritative wizard evidence to replace an app decision", () => {
267 expect(
268 transitionFirstRunGateState(
269 { decision: "app", stalled: false },
270 { type: "evidence", decision: "wizard" },
271 ),
272 ).toEqual({ decision: "wizard", stalled: false });
273 });
274});
275
276describe("resolveHostedFirstRunDecision", () => {
277 it("keeps the shell hidden until client settings are hydrated", () => {
278 expect(
279 resolveHostedFirstRunDecision({
280 hydrated: false,
281 completed: false,
282 catalogReady: true,
283 environmentCount: 0,
284 }),
285 ).toEqual({
286 decision: "pending",
287 persistCompletion: false,
288 });
289 });
290
291 it("waits for the saved environment catalog before judging a hosted install", () => {
292 expect(
293 resolveHostedFirstRunDecision({
294 hydrated: true,
295 completed: false,
296 catalogReady: false,
297 environmentCount: 0,
298 }),
299 ).toEqual({
300 decision: "pending",
301 persistCompletion: false,
302 });
303 });
304
305 it("opens onboarding when a hosted install has no saved environments", () => {
306 expect(
307 resolveHostedFirstRunDecision({
308 hydrated: true,
309 completed: false,
310 catalogReady: true,
311 environmentCount: 0,
312 }),
313 ).toEqual({
314 decision: "wizard",
315 persistCompletion: false,
316 });
317 });
318
319 it("keeps Connections reachable for a remote-only desktop predating onboarding", () => {
320 expect(
321 resolveHostedFirstRunDecision({
322 hydrated: true,
323 completed: false,
324 catalogReady: true,
325 environmentCount: 0,
326 localEnvironmentDisabled: true,
327 }),
328 ).toEqual({ decision: "app", persistCompletion: true });
329 });
330
331 it("backfills onboarding for a hosted install with saved environments", () => {
332 expect(
333 resolveHostedFirstRunDecision({
334 hydrated: true,
335 completed: false,
336 catalogReady: true,
337 environmentCount: 1,
338 }),
339 ).toEqual({
340 decision: "app",
341 persistCompletion: true,
342 });
343 });
344
345 it("opens the app immediately after hosted onboarding is complete", () => {
346 expect(
347 resolveHostedFirstRunDecision({
348 hydrated: true,
349 completed: true,
350 catalogReady: false,
351 environmentCount: 0,
352 }),
353 ).toEqual({
354 decision: "app",
355 persistCompletion: false,
356 });
357 });
358});
359
360const primaryEnvironmentId = "primary-environment";
361const bootstrapProject = {
362 id: "bootstrap-project",
363 environmentId: primaryEnvironmentId,
364 workspaceRoot: "/projects/current",
365};
366const bootstrapThread = {
367 id: "bootstrap-thread",
368 projectId: bootstrapProject.id,
369 environmentId: primaryEnvironmentId,
370 latestRun: null,
371 latestUserMessageAt: null,
372 runtime: null,
373};
374
375describe("isFreshFirstRunWorkspace", () => {
376 it("accepts an empty workspace", () => {
377 expect(
378 isFreshFirstRunWorkspace({
379 primaryEnvironmentId,
380 serverCwd: "/projects/current",
381 projects: [],
382 threads: [],
383 }),
384 ).toBe(true);
385 });
386
387 it("accepts only the unused project and thread created from the server cwd", () => {
388 expect(
389 isFreshFirstRunWorkspace({
390 primaryEnvironmentId,
391 serverCwd: "/projects/current/",
392 bootstrapProjectId: bootstrapProject.id,
393 bootstrapThreadId: bootstrapThread.id,
394 bootstrapProjectCreated: true,
395 bootstrapThreadCreated: true,
396 projects: [bootstrapProject],
397 threads: [bootstrapThread],
398 }),
399 ).toBe(true);
400 });
401
402 it("rejects an existing unused cwd project and thread reused by startup", () => {
403 expect(
404 isFreshFirstRunWorkspace({
405 primaryEnvironmentId,
406 serverCwd: "/projects/current",
407 bootstrapProjectId: bootstrapProject.id,
408 bootstrapThreadId: bootstrapThread.id,
409 bootstrapProjectCreated: false,
410 bootstrapThreadCreated: false,
411 projects: [bootstrapProject],
412 threads: [bootstrapThread],
413 }),
414 ).toBe(false);
415 });
416
417 it("rejects a nonempty workspace when an older server omits creation provenance", () => {
418 expect(
419 isFreshFirstRunWorkspace({
420 primaryEnvironmentId,
421 serverCwd: "/projects/current",
422 bootstrapProjectId: bootstrapProject.id,
423 bootstrapThreadId: bootstrapThread.id,
424 projects: [bootstrapProject],
425 threads: [bootstrapThread],
426 }),
427 ).toBe(false);
428 });
429
430 it("normalizes Windows project paths before checking the bootstrap workspace", () => {
431 expect(
432 isFreshFirstRunWorkspace({
433 primaryEnvironmentId,
434 serverCwd: "C:\\Projects\\Current\\",
435 bootstrapProjectId: bootstrapProject.id,
436 bootstrapThreadId: bootstrapThread.id,
437 bootstrapProjectCreated: true,
438 bootstrapThreadCreated: true,
439 projects: [{ ...bootstrapProject, workspaceRoot: "c:/projects/current" }],
440 threads: [bootstrapThread],
441 }),
442 ).toBe(true);
443 });
444
445 it("rejects projects from another environment even when their paths match", () => {
446 expect(
447 isFreshFirstRunWorkspace({
448 primaryEnvironmentId,
449 serverCwd: "/projects/current",
450 projects: [{ ...bootstrapProject, environmentId: "remote-environment" }],
451 threads: [],
452 }),
453 ).toBe(false);
454 });
455
456 it("rejects threads from another environment", () => {
457 expect(
458 isFreshFirstRunWorkspace({
459 primaryEnvironmentId,
460 serverCwd: "/projects/current",
461 projects: [bootstrapProject],
462 threads: [{ ...bootstrapThread, environmentId: "remote-environment" }],
463 }),
464 ).toBe(false);
465 });
466
467 it("rejects a thread that does not belong to the bootstrap project", () => {
468 expect(
469 isFreshFirstRunWorkspace({
470 primaryEnvironmentId,
471 serverCwd: "/projects/current",
472 projects: [bootstrapProject],
473 threads: [{ ...bootstrapThread, projectId: "another-project" }],
474 }),
475 ).toBe(false);
476 });
477
478 it("rejects a thread when there is no bootstrap project", () => {
479 expect(
480 isFreshFirstRunWorkspace({
481 primaryEnvironmentId,
482 serverCwd: "/projects/current",
483 projects: [],
484 threads: [bootstrapThread],
485 }),
486 ).toBe(false);
487 });
488
489 it("rejects a bootstrap thread that already has a user message", () => {
490 expect(
491 isFreshFirstRunWorkspace({
492 primaryEnvironmentId,
493 serverCwd: "/projects/current",
494 projects: [bootstrapProject],
495 threads: [
496 {
497 ...bootstrapThread,
498 latestUserMessageAt: "2026-08-23T12:00:00.000Z",
499 },
500 ],
501 }),
502 ).toBe(false);
503 });
504
505 it("rejects a bootstrap thread that has started a turn", () => {
506 expect(
507 isFreshFirstRunWorkspace({
508 primaryEnvironmentId,
509 serverCwd: "/projects/current",
510 projects: [bootstrapProject],
511 threads: [{ ...bootstrapThread, latestRun: { id: "first-run" } }],
512 }),
513 ).toBe(false);
514 });
515
516 it("rejects a bootstrap thread that has a provider session", () => {
517 expect(
518 isFreshFirstRunWorkspace({
519 primaryEnvironmentId,
520 serverCwd: "/projects/current",
521 projects: [bootstrapProject],
522 threads: [{ ...bootstrapThread, runtime: { status: "ready" } }],
523 }),
524 ).toBe(false);
525 });
526});