Skip to content

Commit feffc8b

Browse files
committed
test(app): add create flow property checks
1 parent 7cf5e22 commit feffc8b

2 files changed

Lines changed: 221 additions & 48 deletions

File tree

packages/app/tests/docker-git/actions-project-create.test.ts

Lines changed: 104 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "@effect/vitest"
22
import { Effect } from "effect"
3+
import * as fc from "fast-check"
34
import { beforeEach, vi } from "vitest"
45

56
import type { CreateInputs } from "../../src/docker-git/menu-types.js"
@@ -72,16 +73,24 @@ const project = {
7273
targetDir: "/home/dev/project"
7374
} satisfies ProjectDetails
7475

75-
const projectCreatedEvent: ApiEvent = {
76+
const projectDetailsWithId = (projectId: string) =>
77+
({
78+
...project,
79+
id: projectId
80+
}) satisfies ProjectDetails
81+
82+
const projectCreatedEventFor = (
83+
createdProject: ReturnType<typeof projectDetailsWithId>
84+
): ApiEvent => ({
7685
at: "2026-05-13T00:00:01.000Z",
7786
payload: {
78-
project,
79-
projectId: project.id
87+
project: createdProject,
88+
projectId: createdProject.id
8089
},
81-
projectId: project.id,
90+
projectId: createdProject.id,
8291
seq: 8,
8392
type: "project.created"
84-
}
93+
})
8594

8695
const readCreateEventHandler = () => {
8796
const handler = openProjectEventStreamMock.mock.calls[0]?.[1]?.onEvent
@@ -91,47 +100,108 @@ const readCreateEventHandler = () => {
91100
return handler
92101
}
93102

103+
const resetCreateMocks = (
104+
projectId = project.id,
105+
cursor = 7
106+
) => {
107+
eventStreamCloseMock.mockReset()
108+
loadProjectDetailsMock.mockReset()
109+
openProjectEventStreamMock.mockReset()
110+
startCreateProjectMock.mockReset()
111+
startCreateProjectMock.mockImplementation(() =>
112+
Effect.succeed({
113+
accepted: true,
114+
cursor,
115+
projectId
116+
})
117+
)
118+
openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock }))
119+
}
120+
121+
const runCreateFlow = (
122+
createdProject: ReturnType<typeof projectDetailsWithId>
123+
) =>
124+
Effect.gen(function*(_) {
125+
const { context, output, reloadDashboard, setMessage } = makeBrowserActionContext()
126+
127+
submitCreateInputs(createInputs, context)
128+
129+
yield* _(waitForAssertion(() => {
130+
expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1)
131+
}))
132+
readCreateEventHandler()(projectCreatedEventFor(createdProject))
133+
134+
yield* _(waitForAssertion(() => {
135+
expect(context.setSelectedProject).toHaveBeenCalledWith(createdProject)
136+
}))
137+
138+
return { context, createdProject, output, reloadDashboard, setMessage }
139+
})
140+
141+
const expectCreateFlowInvariants = (
142+
{
143+
context,
144+
createdProject,
145+
cursor,
146+
reloadDashboard
147+
}: {
148+
readonly context: ReturnType<typeof makeBrowserActionContext>["context"]
149+
readonly createdProject: ReturnType<typeof projectDetailsWithId>
150+
readonly cursor: number
151+
readonly reloadDashboard: ReturnType<typeof makeBrowserActionContext>["reloadDashboard"]
152+
}
153+
) => {
154+
expect(openProjectEventStreamMock).toHaveBeenCalledWith(
155+
createdProject.id,
156+
expect.objectContaining({ initialCursor: cursor })
157+
)
158+
expect(eventStreamCloseMock).toHaveBeenCalledTimes(1)
159+
expect(loadProjectDetailsMock).not.toHaveBeenCalled()
160+
expect(reloadDashboard).toHaveBeenCalledTimes(1)
161+
expect(context.setSelectedProjectId).toHaveBeenCalledWith(createdProject.id)
162+
expect(context.setSelectedProject).toHaveBeenCalledWith(createdProject)
163+
}
164+
94165
describe("browser create project action", () => {
95166
beforeEach(() => {
96-
eventStreamCloseMock.mockReset()
97-
loadProjectDetailsMock.mockReset()
98-
openProjectEventStreamMock.mockReset()
99-
startCreateProjectMock.mockReset()
100-
startCreateProjectMock.mockImplementation(() =>
101-
Effect.succeed({
102-
accepted: true,
103-
cursor: 7,
104-
projectId: project.id
105-
})
106-
)
107-
openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock }))
167+
resetCreateMocks()
108168
})
109169

110170
it.effect("clones a project through the browser menu create flow", () =>
111171
Effect.gen(function*(_) {
112-
const { context, output, reloadDashboard, setMessage } = makeBrowserActionContext()
113-
114-
submitCreateInputs(createInputs, context)
115-
116-
yield* _(waitForAssertion(() => {
117-
expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1)
118-
}))
119-
readCreateEventHandler()(projectCreatedEvent)
120-
121-
yield* _(waitForAssertion(() => {
122-
expect(context.setSelectedProject).toHaveBeenCalledWith(project)
123-
}))
172+
const { context, createdProject, output, reloadDashboard, setMessage } = yield* _(
173+
runCreateFlow(projectDetailsWithId(project.id))
174+
)
124175

125176
expect(startCreateProjectMock).toHaveBeenCalledWith(expectedCreateDraft)
126-
expect(openProjectEventStreamMock).toHaveBeenCalledWith(project.id, expect.objectContaining({ initialCursor: 7 }))
127-
expect(eventStreamCloseMock).toHaveBeenCalledTimes(1)
128-
expect(loadProjectDetailsMock).not.toHaveBeenCalled()
129-
expect(reloadDashboard).toHaveBeenCalledTimes(1)
130-
expect(context.setSelectedProjectId).toHaveBeenCalledWith(project.id)
177+
expectCreateFlowInvariants({ context, createdProject, cursor: 7, reloadDashboard })
131178
expect(context.setSelectedMenuIndex).toHaveBeenCalledWith(1)
132179
expect(setMessage).toHaveBeenLastCalledWith("Created octocat/Hello-World.")
133180
expect(output()).toContain("[create] Project creation requested")
134181
expect(output()).toContain("[create] Project accepted: project-1")
135182
expect(output()).toContain("[create] Project created")
136183
}))
184+
185+
it.effect("preserves create event invariants for generated project ids and cursors", () =>
186+
Effect.tryPromise({
187+
catch: (error) => error,
188+
try: () =>
189+
fc.assert(
190+
fc.asyncProperty(
191+
fc.uuid(),
192+
fc.integer({ min: 0, max: 10_000 }),
193+
(projectId, cursor) =>
194+
Effect.runPromise(
195+
Effect.gen(function*(_) {
196+
resetCreateMocks(projectId, cursor)
197+
const createdProject = projectDetailsWithId(projectId)
198+
const { context, reloadDashboard } = yield* _(runCreateFlow(createdProject))
199+
200+
expectCreateFlowInvariants({ context, createdProject, cursor, reloadDashboard })
201+
})
202+
)
203+
),
204+
{ numRuns: 25 }
205+
)
206+
}))
137207
})

packages/app/tests/docker-git/app-ready-create.test.ts

Lines changed: 117 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import * as fc from "fast-check"
12
import type { Dispatch, SetStateAction } from "react"
23
import { beforeEach, describe, expect, it, vi } from "vitest"
34

5+
import { deriveRepoPathParts, resolveRepoInput } from "../../src/docker-git/frontend-lib/core/domain.js"
46
import {
57
type CreateFlowView,
68
createInitialFlowView,
@@ -23,6 +25,72 @@ const validGithubStatus: GithubAuthStatus = {
2325
tokens: [{ key: "default", label: "default", login: "octocat", status: "valid" }]
2426
}
2527

28+
const githubNameChars = [
29+
"a",
30+
"b",
31+
"c",
32+
"d",
33+
"e",
34+
"f",
35+
"g",
36+
"h",
37+
"i",
38+
"j",
39+
"k",
40+
"l",
41+
"m",
42+
"n",
43+
"o",
44+
"p",
45+
"q",
46+
"r",
47+
"s",
48+
"t",
49+
"u",
50+
"v",
51+
"w",
52+
"x",
53+
"y",
54+
"z",
55+
"0",
56+
"1",
57+
"2",
58+
"3",
59+
"4",
60+
"5",
61+
"6",
62+
"7",
63+
"8",
64+
"9",
65+
"-"
66+
] as const
67+
68+
const githubSegmentArbitrary = fc
69+
.array(fc.constantFrom(...githubNameChars), { minLength: 1, maxLength: 12 })
70+
.map((chars) => chars.join(""))
71+
.filter((value) => !value.startsWith("-") && !value.endsWith("-"))
72+
73+
const repositoryCreateInputArbitrary = fc.record({
74+
branch: fc.option(githubSegmentArbitrary, { nil: null }),
75+
owner: githubSegmentArbitrary,
76+
repo: githubSegmentArbitrary
77+
}).map(({ branch, owner, repo }) => ({
78+
expectedRepoRef: branch ?? "main",
79+
repoUrl: branch === null
80+
? `https://github.com/${owner}/${repo}`
81+
: `https://github.com/${owner}/${repo}/tree/${branch}`
82+
}))
83+
84+
const defaultQuickCreateInputs = {
85+
cpuLimit: "",
86+
enableMcpPlaywright: false,
87+
force: false,
88+
forceEnv: false,
89+
gpu: "none",
90+
ramLimit: "",
91+
runUp: true
92+
} satisfies Omit<CreateInputs, "outDir" | "repoRef" | "repoUrl">
93+
2694
const createSetCreateViewSpy = () => {
2795
const spy = vi.fn<(value: SetStateAction<CreateFlowView>) => void>()
2896
const setCreateView: Dispatch<SetStateAction<CreateFlowView>> = spy
@@ -58,6 +126,32 @@ const submitCreateBuffer = (
58126
return { context, setCreateViewSpy }
59127
}
60128

129+
const requireSubmittedCreateInputs = (): CreateInputs => {
130+
const inputs = submitCreateInputsMock.mock.calls[0]?.[0]
131+
if (inputs === undefined) {
132+
throw new Error("Expected submitted CreateInputs.")
133+
}
134+
return inputs
135+
}
136+
137+
const expectQuickCreateInputs = (
138+
expected: Pick<CreateInputs, "outDir" | "repoRef" | "repoUrl">
139+
) => {
140+
expect(requireSubmittedCreateInputs()).toEqual(
141+
{
142+
...defaultQuickCreateInputs,
143+
...expected
144+
} satisfies CreateInputs
145+
)
146+
}
147+
148+
const expectCreateViewReset = (setCreateViewSpy: ReturnType<typeof submitCreateBuffer>["setCreateViewSpy"]) => {
149+
expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual(createInitialFlowView())
150+
}
151+
152+
const expectedOutDirForRepoUrl = (repoUrl: string): string =>
153+
`/home/dev/.docker-git/${deriveRepoPathParts(resolveRepoInput(repoUrl).repoUrl).pathParts.join("/")}`
154+
61155
describe("app-ready-create", () => {
62156
beforeEach(() => {
63157
submitCreateInputsMock.mockReset()
@@ -102,20 +196,29 @@ describe("app-ready-create", () => {
102196
)
103197

104198
expect(submitCreateInputsMock).toHaveBeenCalledTimes(1)
105-
expect(submitCreateInputsMock.mock.calls[0]?.[0]).toEqual(
106-
{
107-
cpuLimit: "",
108-
enableMcpPlaywright: false,
109-
force: false,
110-
forceEnv: false,
111-
gpu: "none",
112-
outDir: "/home/dev/.docker-git/octocat/hello-world",
113-
ramLimit: "",
114-
repoRef: "feature-x",
115-
repoUrl: "https://github.com/octocat/Hello-World/tree/feature-x",
116-
runUp: true
117-
} satisfies CreateInputs
199+
expectQuickCreateInputs({
200+
outDir: "/home/dev/.docker-git/octocat/hello-world",
201+
repoRef: "feature-x",
202+
repoUrl: "https://github.com/octocat/Hello-World/tree/feature-x"
203+
})
204+
expectCreateViewReset(setCreateViewSpy)
205+
})
206+
207+
it("preserves quick create repo url to out dir invariants for generated GitHub repos", () => {
208+
fc.assert(
209+
fc.property(repositoryCreateInputArbitrary, ({ expectedRepoRef, repoUrl }) => {
210+
submitCreateInputsMock.mockReset()
211+
const { setCreateViewSpy } = submitCreateBuffer(repoUrl, { quickCreate: true })
212+
213+
expect(submitCreateInputsMock).toHaveBeenCalledTimes(1)
214+
expectQuickCreateInputs({
215+
outDir: expectedOutDirForRepoUrl(repoUrl),
216+
repoRef: expectedRepoRef,
217+
repoUrl
218+
})
219+
expectCreateViewReset(setCreateViewSpy)
220+
}),
221+
{ numRuns: 50 }
118222
)
119-
expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual(createInitialFlowView())
120223
})
121224
})

0 commit comments

Comments
 (0)