Skip to content

Commit e1aef59

Browse files
committed
fix(web): satisfy ci lint for ssh terminal flow
1 parent 2db233c commit e1aef59

6 files changed

Lines changed: 130 additions & 100 deletions

File tree

packages/app/src/web/actions-project-create.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,10 @@ import { appendOutputLine, appendOutputLineHandler, notifyProjectEventRateLimit
77
import { type BrowserActionContext, withBusy } from "./actions-shared.js"
88
import { ProjectDetailsSchema } from "./api-schema.js"
99
import { type ApiEvent, loadProjectDetails, type ProjectDetails, startCreateProject } from "./api.js"
10+
import { readEventPayloadString } from "./project-event-payload.js"
1011
import { openProjectEventStream } from "./project-events.js"
1112
import { outputScreen, projectPickerScreen } from "./screen.js"
1213

13-
const readEventPayloadString = (
14-
event: ApiEvent,
15-
key: string
16-
): string | null => {
17-
const payload = event.payload
18-
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
19-
return null
20-
}
21-
const value = Object.entries(payload).find(([name]) => name === key)?.[1]
22-
return typeof value === "string" ? value : null
23-
}
24-
2514
const readCreatedProjectId = (event: ApiEvent): string | null =>
2615
event.type === "project.created" ? readEventPayloadString(event, "projectId") : null
2716

packages/app/src/web/actions-projects.ts

Lines changed: 56 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,34 @@ import {
1313
} from "./actions-shared.js"
1414
import { loadSelectedProjectTasks } from "./actions-tasks.js"
1515
import {
16+
type ApiEvent,
1617
applyAllProjects,
1718
applyProject,
18-
type ApiEvent,
1919
deleteProject,
2020
downAllProjects,
2121
downProject,
2222
loadProjectDetails,
2323
loadProjectLogs,
2424
loadProjectPs,
2525
loadProjectTerminalSession,
26-
startProjectTerminalSession
26+
startProjectTerminalSession,
27+
type TerminalSession
2728
} from "./api.js"
2829
import type { BrowserMenuTag } from "./menu.js"
30+
import { readEventPayloadString } from "./project-event-payload.js"
2931
import { openProjectEventStream } from "./project-events.js"
3032
import { outputScreen } from "./screen.js"
3133
import { buildPendingProjectActiveTerminalSession, buildProjectActiveTerminalSession } from "./terminal.js"
3234

3335
export { submitCreateInputs } from "./actions-project-create.js"
3436

37+
type BrowserRandomSource = {
38+
readonly getRandomValues: (values: Uint8Array) => Uint8Array
39+
readonly randomUUID?: () => string
40+
}
41+
42+
const browserRandomSource = (): BrowserRandomSource => globalThis.crypto
43+
3544
export const loadSelectedProjectInfo = (
3645
context: BrowserActionContext,
3746
options?: {
@@ -79,43 +88,20 @@ const resolveProjectTerminalKey = (
7988
}
8089

8190
const randomHex = (bytes: number): string => {
82-
const getRandomValues = globalThis.crypto?.getRandomValues
83-
if (typeof getRandomValues === "function") {
84-
const values = new Uint8Array(bytes)
85-
getRandomValues.call(globalThis.crypto, values)
86-
return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("")
87-
}
88-
89-
let fallback = ""
90-
while (fallback.length < bytes * 2) {
91-
fallback += Math.floor(Math.random() * 0x1_0000_0000)
92-
.toString(16)
93-
.padStart(8, "0")
94-
}
95-
return fallback.slice(0, bytes * 2)
91+
const values = new Uint8Array(bytes)
92+
browserRandomSource().getRandomValues(values)
93+
return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("")
9694
}
9795

9896
const createPendingTerminalSessionId = (): string => {
99-
const randomUUID = globalThis.crypto?.randomUUID
100-
if (typeof randomUUID === "function") {
101-
return randomUUID.call(globalThis.crypto)
97+
const source = browserRandomSource()
98+
if (source.randomUUID !== undefined) {
99+
return source.randomUUID()
102100
}
103101

104102
return `pending-${Date.now().toString(16)}-${randomHex(8)}`
105103
}
106104

107-
const readEventPayloadString = (
108-
event: ApiEvent,
109-
key: string
110-
): string | null => {
111-
const payload = event.payload
112-
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
113-
return null
114-
}
115-
const value = Object.entries(payload).find(([name]) => name === key)?.[1]
116-
return typeof value === "string" ? value : null
117-
}
118-
119105
const readTerminalSessionCreatedId = (
120106
event: ApiEvent,
121107
requestId: string
@@ -148,6 +134,31 @@ const readTerminalStartupFailure = (
148134
return readEventPayloadString(event, "message") ?? "SSH session startup failed."
149135
}
150136

137+
const addAttachedProjectTerminalSession = (
138+
{
139+
context,
140+
projectDisplayName,
141+
projectId,
142+
projectKey,
143+
session
144+
}: {
145+
readonly context: BrowserActionContext
146+
readonly projectDisplayName: string
147+
readonly projectId: string
148+
readonly projectKey: string
149+
readonly session: TerminalSession
150+
}
151+
): void => {
152+
context.addTerminalSession(buildProjectActiveTerminalSession({
153+
onExit: context.reloadDashboard,
154+
onReady: context.reloadDashboard,
155+
projectDisplayName,
156+
projectId,
157+
projectKey,
158+
session
159+
}))
160+
}
161+
151162
export const connectProjectById = (
152163
projectId: string,
153164
context: BrowserActionContext,
@@ -188,6 +199,11 @@ export const connectProjectById = (
188199
stream?.close()
189200
stream = null
190201
}
202+
const failPendingTerminalSession = (error: string) => {
203+
pendingSessionFinalized = true
204+
appendOutputLine(context, `[error] ${error}`)
205+
context.addTerminalSession(renderPendingTerminalSession(error, "error"))
206+
}
191207
const attachCreatedSession = (sessionId: string) => {
192208
if (attachedSessionId !== null) {
193209
return
@@ -198,23 +214,20 @@ export const connectProjectById = (
198214
effect: loadProjectTerminalSession(resolvedProjectKey, sessionId),
199215
label: "Attaching SSH terminal",
200216
onFailure: (error) => {
201-
pendingSessionFinalized = true
202-
appendOutputLine(context, `[error] ${error}`)
203-
context.addTerminalSession(renderPendingTerminalSession(error, "error"))
217+
failPendingTerminalSession(error)
204218
closeStream()
205219
},
206220
onSuccess: (session) => {
207221
pendingSessionFinalized = true
208222
context.reloadDashboard()
209223
context.closeTerminalSession(pendingSessionId)
210-
context.addTerminalSession(buildProjectActiveTerminalSession({
211-
onExit: context.reloadDashboard,
212-
onReady: context.reloadDashboard,
224+
addAttachedProjectTerminalSession({
225+
context,
213226
projectDisplayName,
214227
projectId,
215228
projectKey: resolvedProjectKey,
216229
session
217-
}))
230+
})
218231
context.setMessage(`Project is ready. SSH terminal is connecting for ${projectDisplayName}.`)
219232
closeStream()
220233
}
@@ -225,9 +238,7 @@ export const connectProjectById = (
225238
effect: startProjectTerminalSession(resolvedProjectKey, pendingSessionId),
226239
label: "Opening SSH terminal",
227240
onFailure: (error) => {
228-
pendingSessionFinalized = true
229-
appendOutputLine(context, `[error] ${error}`)
230-
context.addTerminalSession(renderPendingTerminalSession(error, "error"))
241+
failPendingTerminalSession(error)
231242
},
232243
onSuccess: (accepted) => {
233244
appendOutputLine(context, `[ssh.prepare] SSH terminal request accepted (${accepted.requestId})`)
@@ -237,9 +248,7 @@ export const connectProjectById = (
237248
onEvent: (event) => {
238249
const failure = readTerminalStartupFailure(event, accepted.requestId)
239250
if (failure !== null) {
240-
pendingSessionFinalized = true
241-
appendOutputLine(context, `[error] ${failure}`)
242-
context.addTerminalSession(renderPendingTerminalSession(failure, "error"))
251+
failPendingTerminalSession(failure)
243252
context.setMessage(failure)
244253
closeStream()
245254
return
@@ -306,14 +315,13 @@ export const attachProjectTerminalById = (
306315
effect: loadProjectTerminalSession(resolvedProjectKey, sessionId),
307316
label: "Attaching SSH terminal",
308317
onSuccess: (session) => {
309-
context.addTerminalSession(buildProjectActiveTerminalSession({
310-
onExit: context.reloadDashboard,
311-
onReady: context.reloadDashboard,
318+
addAttachedProjectTerminalSession({
319+
context,
312320
projectDisplayName,
313321
projectId,
314322
projectKey: resolvedProjectKey,
315323
session
316-
}))
324+
})
317325
context.setMessage(`Attached SSH terminal for ${projectDisplayName}.`)
318326
}
319327
})

packages/app/src/web/api-types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ import type {
2323
export type ProjectSummary = Schema.Schema.Type<typeof ProjectSummarySchema>
2424
export type ProjectDetails = Schema.Schema.Type<typeof ProjectDetailsSchema>
2525
export type CreateProjectAcceptedResponse = Schema.Schema.Type<typeof CreateProjectAcceptedResponseSchema>
26-
export type StartProjectTerminalSessionAccepted =
27-
Schema.Schema.Type<typeof StartProjectTerminalSessionAcceptedResponseSchema>
26+
export type StartProjectTerminalSessionAccepted = Schema.Schema.Type<
27+
typeof StartProjectTerminalSessionAcceptedResponseSchema
28+
>
2829
export type ProjectPortForward = Schema.Schema.Type<typeof ProjectPortForwardSchema>
2930
export type ProjectBrowserSession = Schema.Schema.Type<typeof ProjectBrowserSessionSchema>
3031
export type ProjectDatabaseForward = Schema.Schema.Type<typeof ProjectDatabaseForwardSchema>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { ApiEvent } from "./api.js"
2+
3+
export const readEventPayloadString = (
4+
event: ApiEvent,
5+
key: string
6+
): string | null => {
7+
const payload = event.payload
8+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
9+
return null
10+
}
11+
const value = Object.entries(payload).find(([name]) => name === key)?.[1]
12+
return typeof value === "string" ? value : null
13+
}

packages/app/tests/docker-git/actions-projects.test.ts

Lines changed: 56 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,26 @@ import { Effect } from "effect"
33
import { afterEach, beforeEach, vi } from "vitest"
44

55
import { applyProjectById, connectProjectById, runApplyAllProjects } from "../../src/web/actions-projects.js"
6-
import type { ProjectDetails, StartProjectTerminalSessionAccepted, TerminalSession } from "../../src/web/api.js"
6+
import type { BrowserActionContext } from "../../src/web/actions-shared.js"
7+
import type {
8+
applyAllProjects,
9+
applyProject,
10+
loadProjectTerminalSession,
11+
ProjectDetails,
12+
startProjectTerminalSession,
13+
StartProjectTerminalSessionAccepted,
14+
TerminalSession
15+
} from "../../src/web/api.js"
16+
import type { openProjectEventStream } from "../../src/web/project-events.js"
717
import type { ActiveTerminalSession } from "../../src/web/terminal.js"
818
import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js"
919

10-
const applyAllProjectsMock = vi.hoisted(() => vi.fn())
11-
const applyProjectMock = vi.hoisted(() => vi.fn())
12-
const eventStreamCloseMock = vi.hoisted(() => vi.fn())
13-
const loadProjectTerminalSessionMock = vi.hoisted(() => vi.fn())
14-
const openProjectEventStreamMock = vi.hoisted(() => vi.fn())
15-
const startProjectTerminalSessionMock = vi.hoisted(() => vi.fn())
20+
const applyAllProjectsMock = vi.hoisted(() => vi.fn<typeof applyAllProjects>())
21+
const applyProjectMock = vi.hoisted(() => vi.fn<typeof applyProject>())
22+
const eventStreamCloseMock = vi.hoisted(() => vi.fn<() => void>())
23+
const loadProjectTerminalSessionMock = vi.hoisted(() => vi.fn<typeof loadProjectTerminalSession>())
24+
const openProjectEventStreamMock = vi.hoisted(() => vi.fn<typeof openProjectEventStream>())
25+
const startProjectTerminalSessionMock = vi.hoisted(() => vi.fn<typeof startProjectTerminalSession>())
1626

1727
vi.mock("../../src/web/api.js", () => ({
1828
applyAllProjects: applyAllProjectsMock,
@@ -91,6 +101,30 @@ const startTerminalAccepted = (requestId: string): StartProjectTerminalSessionAc
91101
requestId
92102
})
93103

104+
const makeSelectedProjectContext = (overrides: Partial<BrowserActionContext>) =>
105+
makeBrowserActionContext({
106+
...overrides,
107+
selectedProjectId: "project-1",
108+
selectedProjectKey: "octocat/hello-world"
109+
})
110+
111+
const connectProjectAndWaitForStream = (context: BrowserActionContext) =>
112+
Effect.gen(function*(_) {
113+
connectProjectById("project-1", context, "octocat/hello-world")
114+
115+
yield* _(waitForAssertion(() => {
116+
expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1)
117+
}))
118+
})
119+
120+
const readFirstProjectEventHandler = () => {
121+
const handlers = openProjectEventStreamMock.mock.calls[0]?.[1]
122+
if (handlers?.onEvent === undefined) {
123+
throw new Error("missing event handlers")
124+
}
125+
return handlers.onEvent
126+
}
127+
94128
describe("web project actions", () => {
95129
beforeEach(() => {
96130
vi.restoreAllMocks()
@@ -118,25 +152,13 @@ describe("web project actions", () => {
118152
openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock }))
119153
const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>()
120154
const closeTerminalSession = vi.fn<(sessionId: string) => void>()
121-
const { context, reloadDashboard, setMessage } = makeBrowserActionContext({
155+
const { context, reloadDashboard, setMessage } = makeSelectedProjectContext({
122156
addTerminalSession,
123-
closeTerminalSession,
124-
selectedProjectId: "project-1",
125-
selectedProjectKey: "octocat/hello-world"
157+
closeTerminalSession
126158
})
127159

128-
connectProjectById("project-1", context, "octocat/hello-world")
129-
130-
yield* _(waitForAssertion(() => {
131-
expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1)
132-
}))
133-
134-
const handlers = openProjectEventStreamMock.mock.calls[0]?.[1]
135-
if (handlers === undefined || typeof handlers.onEvent !== "function") {
136-
throw new Error("missing event handlers")
137-
}
138-
139-
handlers.onEvent({
160+
yield* _(connectProjectAndWaitForStream(context))
161+
readFirstProjectEventHandler()({
140162
at: "2026-04-21T10:00:01.000Z",
141163
payload: {
142164
phase: "created",
@@ -194,31 +216,28 @@ describe("web project actions", () => {
194216

195217
it.effect("starts SSH terminal creation when randomUUID is unavailable", () =>
196218
Effect.gen(function*(_) {
197-
const dateNowMock = vi.spyOn(Date, "now").mockReturnValue(0x1234)
198-
const mathRandomMock = vi.spyOn(Math, "random").mockReturnValue(0.5)
199-
vi.stubGlobal("crypto", {})
219+
const dateNowMock = vi.spyOn(Date, "now").mockReturnValue(0x12_34)
220+
vi.stubGlobal("crypto", {
221+
getRandomValues: (values: Uint8Array): Uint8Array => {
222+
values.set([0x80, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00])
223+
return values
224+
}
225+
})
200226
startProjectTerminalSessionMock.mockImplementation((_projectKey, requestId: string) =>
201227
Effect.succeed(startTerminalAccepted(requestId))
202228
)
203229
openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock }))
204230
const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>()
205-
const { context } = makeBrowserActionContext({
206-
addTerminalSession,
207-
selectedProjectId: "project-1",
208-
selectedProjectKey: "octocat/hello-world"
231+
const { context } = makeSelectedProjectContext({
232+
addTerminalSession
209233
})
210234

211-
connectProjectById("project-1", context, "octocat/hello-world")
212-
213-
yield* _(waitForAssertion(() => {
214-
expect(startProjectTerminalSessionMock).toHaveBeenCalledTimes(1)
215-
}))
216-
235+
yield* _(connectProjectAndWaitForStream(context))
236+
expect(startProjectTerminalSessionMock).toHaveBeenCalledTimes(1)
217237
const requestId = startProjectTerminalSessionMock.mock.calls[0]?.[1]
218238
expect(requestId).toBe("pending-1234-8000000080000000")
219239
expect(addTerminalSession).toHaveBeenCalledTimes(1)
220240
expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1)
221-
mathRandomMock.mockRestore()
222241
dateNowMock.mockRestore()
223242
}))
224243

packages/app/tests/docker-git/controller.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ describe("controller reachability", () => {
7171

7272
it.effect("detects remote Docker hosts", () =>
7373
Effect.sync(() => {
74-
expect(isRemoteDockerHost()).toBe(false)
74+
expect(isRemoteDockerHost("")).toBe(false)
7575
expect(isRemoteDockerHost("unix:///var/run/docker.sock")).toBe(false)
7676
expect(isRemoteDockerHost("tcp://docker.example.test:2376")).toBe(true)
7777
expect(isRemoteDockerHost("ssh://docker@example.test")).toBe(true)

0 commit comments

Comments
 (0)