Skip to content

Commit a3cea88

Browse files
committed
feat: add tests for src/node/app.ts
1 parent 4f3c8a5 commit a3cea88

File tree

4 files changed

+322
-14
lines changed

4 files changed

+322
-14
lines changed

src/node/app.ts

+42-12
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import http from "http"
66
import * as httpolyglot from "httpolyglot"
77
import * as util from "../common/util"
88
import { DefaultedArgs } from "./cli"
9+
import { isNodeJSErrnoException } from "./util"
910
import { handleUpgrade } from "./wsRouter"
1011

1112
/**
@@ -33,21 +34,14 @@ export const createApp = async (args: DefaultedArgs): Promise<[Express, Express,
3334
resolve2()
3435
}
3536
server.on("error", (err) => {
36-
if (!resolved) {
37-
reject(err)
38-
} else {
39-
// Promise resolved earlier so this is an unrelated error.
40-
util.logError(logger, "http server error", err)
41-
}
37+
handleServerError(resolved, err, reject)
4238
})
4339

4440
if (args.socket) {
4541
try {
4642
await fs.unlink(args.socket)
47-
} catch (error) {
48-
if (error.code !== "ENOENT") {
49-
logger.error(error.message)
50-
}
43+
} catch (error: any) {
44+
handleArgsSocketCatchError(error)
5145
}
5246
server.listen(args.socket, resolve)
5347
} else {
@@ -69,10 +63,46 @@ export const createApp = async (args: DefaultedArgs): Promise<[Express, Express,
6963
export const ensureAddress = (server: http.Server): string => {
7064
const addr = server.address()
7165
if (!addr) {
72-
throw new Error("server has no address") // NOTE@jsjoeio test this line
66+
throw new Error("server has no address")
7367
}
7468
if (typeof addr !== "string") {
7569
return `http://${addr.address}:${addr.port}`
7670
}
77-
return addr // NOTE@jsjoeio test this line
71+
return addr
72+
}
73+
74+
/**
75+
* Handles error events from the server.
76+
*
77+
* If the outlying Promise didn't resolve
78+
* then we reject with the error.
79+
*
80+
* Otherwise, we log the error.
81+
*
82+
* We extracted into a function so that we could
83+
* test this logic more easily.
84+
*/
85+
export const handleServerError = (resolved: boolean, err: Error, reject: (err: Error) => void) => {
86+
// Promise didn't resolve earlier so this means it's an error
87+
// that occurs before the server can successfully listen.
88+
// Possibly triggered by listening on an invalid port or socket.
89+
if (!resolved) {
90+
reject(err)
91+
} else {
92+
// Promise resolved earlier so this is an unrelated error.
93+
util.logError(logger, "http server error", err)
94+
}
95+
}
96+
97+
/**
98+
* Handles the error that occurs in the catch block
99+
* after we try fs.unlink(args.socket).
100+
*
101+
* We extracted into a function so that we could
102+
* test this logic more easily.
103+
*/
104+
export const handleArgsSocketCatchError = (error: any) => {
105+
if (!isNodeJSErrnoException(error) || error.code !== "ENOENT") {
106+
logger.error(error.message ? error.message : error)
107+
}
78108
}

src/node/util.ts

+9
Original file line numberDiff line numberDiff line change
@@ -524,3 +524,12 @@ export function escapeHtml(unsafe: string): string {
524524
.replace(/"/g, "&quot;")
525525
.replace(/'/g, "&apos;")
526526
}
527+
528+
/**
529+
* A helper function which returns a boolean indicating whether
530+
* the given error is a NodeJS.ErrnoException by checking if
531+
* it has a .code property.
532+
*/
533+
export function isNodeJSErrnoException(error: unknown): error is NodeJS.ErrnoException {
534+
return error instanceof Error && (error as NodeJS.ErrnoException).code !== undefined
535+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`handleServerError should log an error if resolved is true 1`] = `"Cannot read property 'handle' of undefined"`;

test/unit/node/app.test.ts

+268-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,168 @@
1+
import { logger } from "@coder/logger"
2+
import { promises, rmdirSync } from "fs"
13
import * as http from "http"
2-
import { ensureAddress } from "../../../src/node/app"
3-
import { getAvailablePort } from "../../utils/helpers"
4+
import * as https from "https"
5+
import * as path from "path"
6+
import { createApp, ensureAddress, handleArgsSocketCatchError, handleServerError } from "../../../src/node/app"
7+
import { OptionalString, setDefaults } from "../../../src/node/cli"
8+
import { generateCertificate } from "../../../src/node/util"
9+
import { getAvailablePort, tmpdir } from "../../utils/helpers"
10+
11+
describe("createApp", () => {
12+
let spy: jest.SpyInstance
13+
let unlinkSpy: jest.SpyInstance
14+
let port: number
15+
let tmpDirPath: string
16+
let tmpFilePath: string
17+
18+
beforeAll(async () => {
19+
tmpDirPath = await tmpdir("unlink-socket")
20+
tmpFilePath = path.join(tmpDirPath, "unlink-socket-file")
21+
})
22+
23+
beforeEach(async () => {
24+
spy = jest.spyOn(logger, "error")
25+
// NOTE:@jsjoeio
26+
// Be mindful when spying.
27+
// You can't spy on fs functions if you do import * as fs
28+
// You have to import individually, like we do here with promises
29+
// then you can spy on those modules methods, like unlink.
30+
// See: https://github.com/aelbore/esbuild-jest/issues/26#issuecomment-893763840
31+
unlinkSpy = jest.spyOn(promises, "unlink")
32+
port = await getAvailablePort()
33+
})
34+
35+
afterEach(() => {
36+
jest.clearAllMocks()
37+
})
38+
39+
afterAll(() => {
40+
jest.restoreAllMocks()
41+
// Ensure directory was removed
42+
rmdirSync(tmpDirPath, { recursive: true })
43+
})
44+
45+
it("should return an Express app, a WebSockets Express app and an http server", async () => {
46+
const defaultArgs = await setDefaults({
47+
port,
48+
_: [],
49+
})
50+
const [app, wsApp, server] = await createApp(defaultArgs)
51+
52+
// This doesn't check much, but it's a good sanity check
53+
// to ensure we actually get back values from createApp
54+
expect(app).not.toBeNull()
55+
expect(wsApp).not.toBeNull()
56+
expect(server).toBeInstanceOf(http.Server)
57+
58+
// Cleanup
59+
server.close()
60+
})
61+
62+
it("should handle error events on the server", async () => {
63+
const defaultArgs = await setDefaults({
64+
port,
65+
_: [],
66+
})
67+
68+
// This looks funky, but that's because createApp
69+
// returns an array like [app, wsApp, server]
70+
// We only need server which is at index 2
71+
// we do it this way so ESLint is happy that we're
72+
// have no declared variables not being used
73+
const app = await createApp(defaultArgs)
74+
const server = app[2]
75+
76+
const testError = new Error("Test error")
77+
// We can easily test how the server handles errors
78+
// By emitting an error event
79+
// Ref: https://stackoverflow.com/a/33872506/3015595
80+
server.emit("error", testError)
81+
expect(spy).toHaveBeenCalledTimes(1)
82+
expect(spy).toHaveBeenCalledWith(`http server error: ${testError.message} ${testError.stack}`)
83+
84+
// Cleanup
85+
server.close()
86+
})
87+
88+
it("should reject errors that happen before the server can listen", async () => {
89+
// We listen on an invalid port
90+
// causing the app to reject the Promise called at startup
91+
const port = 2
92+
const defaultArgs = await setDefaults({
93+
port,
94+
_: [],
95+
})
96+
97+
async function masterBall() {
98+
const app = await createApp(defaultArgs)
99+
const server = app[2]
100+
101+
const testError = new Error("Test error")
102+
103+
server.emit("error", testError)
104+
105+
// Cleanup
106+
server.close()
107+
}
108+
109+
expect(() => masterBall()).rejects.toThrow(`listen EACCES: permission denied 127.0.0.1:${port}`)
110+
})
111+
112+
it("should unlink a socket before listening on the socket", async () => {
113+
await promises.writeFile(tmpFilePath, "")
114+
const defaultArgs = await setDefaults({
115+
_: [],
116+
socket: tmpFilePath,
117+
})
118+
119+
const app = await createApp(defaultArgs)
120+
const server = app[2]
121+
122+
expect(unlinkSpy).toHaveBeenCalledTimes(1)
123+
server.close()
124+
})
125+
it("should catch errors thrown when unlinking a socket", async () => {
126+
const tmpDir2 = await tmpdir("unlink-socket-error")
127+
const tmpFile = path.join(tmpDir2, "unlink-socket-file")
128+
// await promises.writeFile(tmpFile, "")
129+
const socketPath = tmpFile
130+
const defaultArgs = await setDefaults({
131+
_: [],
132+
socket: socketPath,
133+
})
134+
135+
const app = await createApp(defaultArgs)
136+
const server = app[2]
137+
138+
expect(spy).toHaveBeenCalledTimes(1)
139+
expect(spy).toHaveBeenCalledWith(`ENOENT: no such file or directory, unlink '${socketPath}'`)
140+
141+
server.close()
142+
// Ensure directory was removed
143+
rmdirSync(tmpDir2, { recursive: true })
144+
})
145+
146+
it("should create an https server if args.cert exists", async () => {
147+
const testCertificate = await generateCertificate("localhost")
148+
const cert = new OptionalString(testCertificate.cert)
149+
const defaultArgs = await setDefaults({
150+
port,
151+
cert,
152+
_: [],
153+
["cert-key"]: testCertificate.certKey,
154+
})
155+
const app = await createApp(defaultArgs)
156+
const server = app[2]
157+
158+
// This doesn't check much, but it's a good sanity check
159+
// to ensure we actually get an https.Server
160+
expect(server).toBeInstanceOf(https.Server)
161+
162+
// Cleanup
163+
server.close()
164+
})
165+
})
4166

5167
describe("ensureAddress", () => {
6168
let mockServer: http.Server
@@ -28,3 +190,107 @@ describe("ensureAddress", () => {
28190
expect(address).toBe(`http://localhost:8080`)
29191
})
30192
})
193+
194+
describe("handleServerError", () => {
195+
let spy: jest.SpyInstance
196+
197+
beforeEach(() => {
198+
spy = jest.spyOn(logger, "error")
199+
})
200+
201+
afterEach(() => {
202+
jest.clearAllMocks()
203+
})
204+
205+
afterAll(() => {
206+
jest.restoreAllMocks()
207+
})
208+
209+
it("should call reject if resolved is false", async () => {
210+
const resolved = false
211+
const reject = jest.fn((err: Error) => undefined)
212+
const error = new Error("handleServerError Error")
213+
214+
handleServerError(resolved, error, reject)
215+
216+
expect(reject).toHaveBeenCalledTimes(1)
217+
expect(reject).toHaveBeenCalledWith(error)
218+
})
219+
220+
it("should log an error if resolved is true", async () => {
221+
const resolved = true
222+
const reject = jest.fn((err: Error) => undefined)
223+
const error = new Error("handleServerError Error")
224+
225+
handleServerError(resolved, error, reject)
226+
227+
expect(spy).toHaveBeenCalledTimes(1)
228+
expect(spy).toThrowErrorMatchingSnapshot()
229+
})
230+
})
231+
232+
describe("handleArgsSocketCatchError", () => {
233+
let spy: jest.SpyInstance
234+
235+
beforeEach(() => {
236+
spy = jest.spyOn(logger, "error")
237+
})
238+
239+
afterEach(() => {
240+
jest.clearAllMocks()
241+
})
242+
243+
afterAll(() => {
244+
jest.restoreAllMocks()
245+
})
246+
247+
it("should log an error if its not an NodeJS.ErrnoException", () => {
248+
const error = new Error()
249+
250+
handleArgsSocketCatchError(error)
251+
252+
expect(spy).toHaveBeenCalledTimes(1)
253+
expect(spy).toHaveBeenCalledWith(error)
254+
})
255+
256+
it("should log an error if its not an NodeJS.ErrnoException (and the error has a message)", () => {
257+
const errorMessage = "handleArgsSocketCatchError Error"
258+
const error = new Error(errorMessage)
259+
260+
handleArgsSocketCatchError(error)
261+
262+
expect(spy).toHaveBeenCalledTimes(1)
263+
expect(spy).toHaveBeenCalledWith(errorMessage)
264+
})
265+
266+
it("should not log an error if its a iNodeJS.ErrnoException", () => {
267+
const error: NodeJS.ErrnoException = new Error()
268+
error.code = "ENOENT"
269+
270+
handleArgsSocketCatchError(error)
271+
272+
expect(spy).toHaveBeenCalledTimes(0)
273+
})
274+
275+
it("should log an error if the code is not ENOENT (and the error has a message)", () => {
276+
const errorMessage = "no access"
277+
const error: NodeJS.ErrnoException = new Error()
278+
error.code = "EACCESS"
279+
error.message = errorMessage
280+
281+
handleArgsSocketCatchError(error)
282+
283+
expect(spy).toHaveBeenCalledTimes(1)
284+
expect(spy).toHaveBeenCalledWith(errorMessage)
285+
})
286+
287+
it("should log an error if the code is not ENOENT", () => {
288+
const error: NodeJS.ErrnoException = new Error()
289+
error.code = "EACCESS"
290+
291+
handleArgsSocketCatchError(error)
292+
293+
expect(spy).toHaveBeenCalledTimes(1)
294+
expect(spy).toHaveBeenCalledWith(error)
295+
})
296+
})

0 commit comments

Comments
 (0)