Skip to content

Commit c61aaf9

Browse files
committed
fix(web): satisfy full app checks
1 parent 5693970 commit c61aaf9

7 files changed

Lines changed: 94 additions & 91 deletions

File tree

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/src/web/actions-project-create.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,14 @@ import { Either } from "effect"
33

44
import { createProjectDraftFromInputs } from "../docker-git/menu-create-shared.js"
55
import type { CreateInputs } from "../docker-git/menu-types.js"
6+
import { readEventPayloadString } from "./actions-event-payload.js"
67
import { appendOutputLine, appendOutputLineHandler, notifyProjectEventRateLimit } from "./actions-output.js"
78
import { type BrowserActionContext, withBusy } from "./actions-shared.js"
89
import { ProjectDetailsSchema } from "./api-schema.js"
910
import { type ApiEvent, loadProjectDetails, type ProjectDetails, startCreateProject } from "./api.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: 38 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { openSelectedProjectBrowser } from "./actions-browser.js"
22
import { openSelectedProjectDatabaseEditor } from "./actions-databases.js"
3+
import { readEventPayloadString } from "./actions-event-payload.js"
34
import { appendOutputLine, appendOutputLineHandler, notifyProjectEventRateLimit } from "./actions-output.js"
45
import { openSelectedProjectPort } from "./actions-port-forwards.js"
56
import {
@@ -13,9 +14,9 @@ import {
1314
} from "./actions-shared.js"
1415
import { loadSelectedProjectTasks } from "./actions-tasks.js"
1516
import {
17+
type ApiEvent,
1618
applyAllProjects,
1719
applyProject,
18-
type ApiEvent,
1920
deleteProject,
2021
downAllProjects,
2122
downProject,
@@ -78,42 +79,39 @@ const resolveProjectTerminalKey = (
7879
return null
7980
}
8081

81-
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-
}
82+
type ProjectCrypto = Crypto & {
83+
readonly randomUUID?: () => string
84+
}
8885

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)
86+
const randomHex = (bytes: number): string => {
87+
const values = new Uint8Array(bytes)
88+
globalThis.crypto.getRandomValues(values)
89+
return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("")
9690
}
9791

9892
const createPendingTerminalSessionId = (): string => {
99-
const randomUUID = globalThis.crypto?.randomUUID
100-
if (typeof randomUUID === "function") {
101-
return randomUUID.call(globalThis.crypto)
93+
const crypto = globalThis.crypto as ProjectCrypto
94+
if (typeof crypto.randomUUID === "function") {
95+
return crypto.randomUUID()
10296
}
10397

10498
return `pending-${Date.now().toString(16)}-${randomHex(8)}`
10599
}
106100

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
101+
type ProjectActiveTerminalSessionArgs = Omit<
102+
Parameters<typeof buildProjectActiveTerminalSession>[0],
103+
"onExit" | "onReady"
104+
>
105+
106+
const addProjectTerminalSession = (
107+
context: BrowserActionContext,
108+
args: ProjectActiveTerminalSessionArgs
109+
) => {
110+
context.addTerminalSession(buildProjectActiveTerminalSession({
111+
...args,
112+
onExit: context.reloadDashboard,
113+
onReady: context.reloadDashboard
114+
}))
117115
}
118116

119117
const readTerminalSessionCreatedId = (
@@ -188,6 +186,11 @@ export const connectProjectById = (
188186
stream?.close()
189187
stream = null
190188
}
189+
const showPendingTerminalError = (error: string) => {
190+
pendingSessionFinalized = true
191+
appendOutputLine(context, `[error] ${error}`)
192+
context.addTerminalSession(renderPendingTerminalSession(error, "error"))
193+
}
191194
const attachCreatedSession = (sessionId: string) => {
192195
if (attachedSessionId !== null) {
193196
return
@@ -198,23 +201,19 @@ export const connectProjectById = (
198201
effect: loadProjectTerminalSession(resolvedProjectKey, sessionId),
199202
label: "Attaching SSH terminal",
200203
onFailure: (error) => {
201-
pendingSessionFinalized = true
202-
appendOutputLine(context, `[error] ${error}`)
203-
context.addTerminalSession(renderPendingTerminalSession(error, "error"))
204+
showPendingTerminalError(error)
204205
closeStream()
205206
},
206207
onSuccess: (session) => {
207208
pendingSessionFinalized = true
208209
context.reloadDashboard()
209210
context.closeTerminalSession(pendingSessionId)
210-
context.addTerminalSession(buildProjectActiveTerminalSession({
211-
onExit: context.reloadDashboard,
212-
onReady: context.reloadDashboard,
211+
addProjectTerminalSession(context, {
213212
projectDisplayName,
214213
projectId,
215214
projectKey: resolvedProjectKey,
216215
session
217-
}))
216+
})
218217
context.setMessage(`Project is ready. SSH terminal is connecting for ${projectDisplayName}.`)
219218
closeStream()
220219
}
@@ -225,9 +224,7 @@ export const connectProjectById = (
225224
effect: startProjectTerminalSession(resolvedProjectKey, pendingSessionId),
226225
label: "Opening SSH terminal",
227226
onFailure: (error) => {
228-
pendingSessionFinalized = true
229-
appendOutputLine(context, `[error] ${error}`)
230-
context.addTerminalSession(renderPendingTerminalSession(error, "error"))
227+
showPendingTerminalError(error)
231228
},
232229
onSuccess: (accepted) => {
233230
appendOutputLine(context, `[ssh.prepare] SSH terminal request accepted (${accepted.requestId})`)
@@ -237,9 +234,7 @@ export const connectProjectById = (
237234
onEvent: (event) => {
238235
const failure = readTerminalStartupFailure(event, accepted.requestId)
239236
if (failure !== null) {
240-
pendingSessionFinalized = true
241-
appendOutputLine(context, `[error] ${failure}`)
242-
context.addTerminalSession(renderPendingTerminalSession(failure, "error"))
237+
showPendingTerminalError(failure)
243238
context.setMessage(failure)
244239
closeStream()
245240
return
@@ -306,14 +301,12 @@ export const attachProjectTerminalById = (
306301
effect: loadProjectTerminalSession(resolvedProjectKey, sessionId),
307302
label: "Attaching SSH terminal",
308303
onSuccess: (session) => {
309-
context.addTerminalSession(buildProjectActiveTerminalSession({
310-
onExit: context.reloadDashboard,
311-
onReady: context.reloadDashboard,
304+
addProjectTerminalSession(context, {
312305
projectDisplayName,
313306
projectId,
314307
projectKey: resolvedProjectKey,
315308
session
316-
}))
309+
})
317310
context.setMessage(`Attached SSH terminal for ${projectDisplayName}.`)
318311
}
319312
})

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>

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

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,25 @@ 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 {
7+
ProjectDetails,
8+
startProjectTerminalSession,
9+
StartProjectTerminalSessionAccepted,
10+
TerminalSession
11+
} from "../../src/web/api.js"
12+
import type { openProjectEventStream } from "../../src/web/project-events.js"
713
import type { ActiveTerminalSession } from "../../src/web/terminal.js"
814
import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js"
915

16+
type OpenProjectEventStream = typeof openProjectEventStream
17+
type StartProjectTerminalSession = typeof startProjectTerminalSession
18+
1019
const applyAllProjectsMock = vi.hoisted(() => vi.fn())
1120
const applyProjectMock = vi.hoisted(() => vi.fn())
1221
const eventStreamCloseMock = vi.hoisted(() => vi.fn())
1322
const loadProjectTerminalSessionMock = vi.hoisted(() => vi.fn())
14-
const openProjectEventStreamMock = vi.hoisted(() => vi.fn())
15-
const startProjectTerminalSessionMock = vi.hoisted(() => vi.fn())
23+
const openProjectEventStreamMock = vi.hoisted(() => vi.fn<OpenProjectEventStream>())
24+
const startProjectTerminalSessionMock = vi.hoisted(() => vi.fn<StartProjectTerminalSession>())
1625

1726
vi.mock("../../src/web/api.js", () => ({
1827
applyAllProjects: applyAllProjectsMock,
@@ -91,6 +100,15 @@ const startTerminalAccepted = (requestId: string): StartProjectTerminalSessionAc
91100
requestId
92101
})
93102

103+
const makeSelectedProjectActionContext = (
104+
overrides: Parameters<typeof makeBrowserActionContext>[0] = {}
105+
) =>
106+
makeBrowserActionContext({
107+
selectedProjectId: "project-1",
108+
selectedProjectKey: "octocat/hello-world",
109+
...overrides
110+
})
111+
94112
describe("web project actions", () => {
95113
beforeEach(() => {
96114
vi.restoreAllMocks()
@@ -118,11 +136,9 @@ describe("web project actions", () => {
118136
openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock }))
119137
const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>()
120138
const closeTerminalSession = vi.fn<(sessionId: string) => void>()
121-
const { context, reloadDashboard, setMessage } = makeBrowserActionContext({
139+
const { context, reloadDashboard, setMessage } = makeSelectedProjectActionContext({
122140
addTerminalSession,
123-
closeTerminalSession,
124-
selectedProjectId: "project-1",
125-
selectedProjectKey: "octocat/hello-world"
141+
closeTerminalSession
126142
})
127143

128144
connectProjectById("project-1", context, "octocat/hello-world")
@@ -194,19 +210,20 @@ describe("web project actions", () => {
194210

195211
it.effect("starts SSH terminal creation when randomUUID is unavailable", () =>
196212
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", {})
213+
const dateNowMock = vi.spyOn(Date, "now").mockReturnValue(0x12_34)
214+
const deterministicBytes = Uint8Array.from([0x80, 0, 0, 0, 0x80, 0, 0, 0])
215+
vi.stubGlobal("crypto", {
216+
getRandomValues: (values: Uint8Array) => {
217+
values.set(deterministicBytes.subarray(0, values.length))
218+
return values
219+
}
220+
})
200221
startProjectTerminalSessionMock.mockImplementation((_projectKey, requestId: string) =>
201222
Effect.succeed(startTerminalAccepted(requestId))
202223
)
203224
openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock }))
204225
const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>()
205-
const { context } = makeBrowserActionContext({
206-
addTerminalSession,
207-
selectedProjectId: "project-1",
208-
selectedProjectKey: "octocat/hello-world"
209-
})
226+
const { context } = makeSelectedProjectActionContext({ addTerminalSession })
210227

211228
connectProjectById("project-1", context, "octocat/hello-world")
212229

@@ -218,7 +235,6 @@ describe("web project actions", () => {
218235
expect(requestId).toBe("pending-1234-8000000080000000")
219236
expect(addTerminalSession).toHaveBeenCalledTimes(1)
220237
expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1)
221-
mathRandomMock.mockRestore()
222238
dateNowMock.mockRestore()
223239
}))
224240

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)

packages/app/tests/docker-git/terminal-inline-images-core.test.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -72,25 +72,16 @@ describe("terminal inline image output", () => {
7272
const cache = new Map<string, string>()
7373
const blob = new Blob(["image"], { type: "image/png" })
7474
const imagePath = "/var/data/example.png"
75-
76-
expect(cacheTerminalInlineImageBlob(cache, imagePath, "https://api/image", blob)).toEqual({
75+
const expectedEntry = {
7776
_tag: "AvailableTerminalInlineImage",
7877
displayUrl: "blob:terminal-image",
7978
fetchUrl: "https://api/image",
8079
path: imagePath
81-
})
82-
expect(cachedTerminalInlineImageEntry(cache, imagePath, "https://api/image")).toEqual({
83-
_tag: "AvailableTerminalInlineImage",
84-
displayUrl: "blob:terminal-image",
85-
fetchUrl: "https://api/image",
86-
path: imagePath
87-
})
88-
expect(cacheTerminalInlineImageBlob(cache, imagePath, "https://api/image", blob)).toEqual({
89-
_tag: "AvailableTerminalInlineImage",
90-
displayUrl: "blob:terminal-image",
91-
fetchUrl: "https://api/image",
92-
path: imagePath
93-
})
80+
}
81+
82+
expect(cacheTerminalInlineImageBlob(cache, imagePath, "https://api/image", blob)).toEqual(expectedEntry)
83+
expect(cachedTerminalInlineImageEntry(cache, imagePath, "https://api/image")).toEqual(expectedEntry)
84+
expect(cacheTerminalInlineImageBlob(cache, imagePath, "https://api/image", blob)).toEqual(expectedEntry)
9485
expect(createObjectUrl).toHaveBeenCalledTimes(1)
9586

9687
revokeTerminalInlineImageObjectUrlCache(cache)

0 commit comments

Comments
 (0)