Skip to content

Commit b157b28

Browse files
committed
Write tests for heartbeats
Test the expected behaviour of the heartbeat module against mocked Nock requests. Return the same promise from `Appsignal.heartbeat()` that was returned by the function given to it as an argument, instead of returning a wrapper promise that emits the heartbeat. Fix a bug where the timestamp was sent in milliseconds instead of seconds.
1 parent e5f81fa commit b157b28

File tree

4 files changed

+292
-13
lines changed

4 files changed

+292
-13
lines changed

.changesets/add-heartbeats-support.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,8 @@ reported to AppSignal, triggering a notification about the missing heartbeat.
3434
The exception will bubble outside of the heartbeat function.
3535

3636
If the function passed to `heartbeat` returns a promise, the finish event will
37-
be reported to AppSignal if the promise resolves, and a wrapped promise will
38-
be returned, which can be awaited. This means that you can use heartbeats to
39-
track the duration of async functions:
37+
be reported to AppSignal if the promise resolves. This means that you can use
38+
heartbeats to track the duration of async functions:
4039

4140
```javascript
4241
import { heartbeat } from "@appsignal/nodejs"

src/__tests__/heartbeat.test.ts

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import nock, { Scope } from "nock"
2+
import { heartbeat, Heartbeat, EventKind } from "../heartbeat"
3+
import { Client, Options } from "../client"
4+
5+
const DEFAULT_CLIENT_CONFIG: Partial<Options> = {
6+
active: true,
7+
name: "Test App",
8+
pushApiKey: "test-push-api-key",
9+
environment: "test",
10+
hostname: "test-hostname"
11+
}
12+
13+
function mockHeartbeatRequest(
14+
kind: EventKind,
15+
{ delay } = { delay: 0 }
16+
): Scope {
17+
return nock("https://appsignal-endpoint.net:443")
18+
.post("/heartbeats/json", body => {
19+
return body.name === "test-heartbeat" && body.kind === kind
20+
})
21+
.query({
22+
api_key: "test-push-api-key",
23+
name: "Test App",
24+
environment: "test",
25+
hostname: "test-hostname"
26+
})
27+
.delay(delay)
28+
.reply(200, "")
29+
}
30+
31+
function nextTick(fn: () => void): Promise<void> {
32+
return new Promise(resolve => {
33+
process.nextTick(() => {
34+
fn()
35+
resolve()
36+
})
37+
})
38+
}
39+
40+
function sleep(ms: number): Promise<void> {
41+
return new Promise(resolve => {
42+
setTimeout(resolve, ms)
43+
})
44+
}
45+
46+
function interceptRequestBody(scope: Scope): Promise<string> {
47+
return new Promise(resolve => {
48+
scope.on("request", (_req, _interceptor, body: string) => {
49+
resolve(body)
50+
})
51+
})
52+
}
53+
54+
describe("Heartbeat", () => {
55+
let client: Client
56+
let theHeartbeat: Heartbeat
57+
58+
beforeAll(() => {
59+
theHeartbeat = new Heartbeat("test-heartbeat")
60+
61+
if (!nock.isActive()) {
62+
nock.activate()
63+
}
64+
})
65+
66+
beforeEach(() => {
67+
client = new Client(DEFAULT_CLIENT_CONFIG)
68+
69+
nock.cleanAll()
70+
nock.disableNetConnect()
71+
})
72+
73+
afterEach(() => {
74+
client.stop()
75+
})
76+
77+
afterAll(() => {
78+
nock.restore()
79+
})
80+
81+
it("does not transmit any events when AppSignal is not active", async () => {
82+
client.stop()
83+
client = new Client({
84+
...DEFAULT_CLIENT_CONFIG,
85+
active: false
86+
})
87+
88+
const startScope = mockHeartbeatRequest("start")
89+
const finishScope = mockHeartbeatRequest("finish")
90+
91+
await expect(theHeartbeat.start()).resolves.toBeUndefined()
92+
await expect(theHeartbeat.finish()).resolves.toBeUndefined()
93+
94+
expect(startScope.isDone()).toBe(false)
95+
expect(finishScope.isDone()).toBe(false)
96+
})
97+
98+
it("heartbeat.start() sends a heartbeat start event", async () => {
99+
const scope = mockHeartbeatRequest("start")
100+
101+
await expect(theHeartbeat.start()).resolves.toBeUndefined()
102+
103+
scope.done()
104+
})
105+
106+
it("heartbeat.finish() sends a heartbeat finish event", async () => {
107+
const scope = mockHeartbeatRequest("finish")
108+
109+
await expect(theHeartbeat.finish()).resolves.toBeUndefined()
110+
111+
scope.done()
112+
})
113+
114+
it("Heartbeat.shutdown() awaits pending heartbeat event promises", async () => {
115+
const startScope = mockHeartbeatRequest("start", { delay: 100 })
116+
const finishScope = mockHeartbeatRequest("finish", { delay: 200 })
117+
118+
let finishPromiseResolved = false
119+
let shutdownPromiseResolved = false
120+
121+
const startPromise = theHeartbeat.start()
122+
123+
theHeartbeat.finish().then(() => {
124+
finishPromiseResolved = true
125+
})
126+
127+
const shutdownPromise = Heartbeat.shutdown().then(() => {
128+
shutdownPromiseResolved = true
129+
})
130+
131+
await expect(startPromise).resolves.toBeUndefined()
132+
133+
// The finish promise should still be pending, so the shutdown promise
134+
// should not be resolved yet.
135+
await nextTick(() => {
136+
expect(finishPromiseResolved).toBe(false)
137+
expect(shutdownPromiseResolved).toBe(false)
138+
})
139+
140+
startScope.done()
141+
142+
// The shutdown promise should not resolve until the finish promise
143+
// resolves.
144+
await expect(shutdownPromise).resolves.toBeUndefined()
145+
146+
await nextTick(() => {
147+
expect(finishPromiseResolved).toBe(true)
148+
})
149+
150+
finishScope.done()
151+
})
152+
153+
describe("Appsignal.heartbeat()", () => {
154+
it("without a function, sends a heartbeat finish event", async () => {
155+
const startScope = mockHeartbeatRequest("start")
156+
const finishScope = mockHeartbeatRequest("finish")
157+
158+
expect(heartbeat("test-heartbeat")).toBeUndefined()
159+
160+
await nextTick(() => {
161+
expect(startScope.isDone()).toBe(false)
162+
finishScope.done()
163+
})
164+
})
165+
166+
describe("with a function", () => {
167+
it("sends heartbeat start and finish events", async () => {
168+
const startScope = mockHeartbeatRequest("start")
169+
const startBody = interceptRequestBody(startScope)
170+
171+
const finishScope = mockHeartbeatRequest("finish")
172+
const finishBody = interceptRequestBody(finishScope)
173+
174+
expect(
175+
heartbeat("test-heartbeat", () => {
176+
const thisSecond = Math.floor(Date.now() / 1000)
177+
178+
// Since this function must be synchronous, we need to deadlock
179+
// until the next second in order to obtain different timestamps
180+
// for the start and finish events.
181+
// eslint-disable-next-line no-constant-condition
182+
while (true) {
183+
if (Math.floor(Date.now() / 1000) != thisSecond) break
184+
}
185+
186+
return "output"
187+
})
188+
).toBe("output")
189+
190+
// Since the function is synchronous and deadlocks, the start and
191+
// finish events' requests are actually initiated simultaneously
192+
// afterwards, when the function finishes and the event loop ticks.
193+
await nextTick(() => {
194+
startScope.done()
195+
finishScope.done()
196+
})
197+
198+
expect(JSON.parse(await finishBody).timestamp).toBeGreaterThan(
199+
JSON.parse(await startBody).timestamp
200+
)
201+
})
202+
203+
it("does not send a finish event when the function throws an error", async () => {
204+
const startScope = mockHeartbeatRequest("start")
205+
const finishScope = mockHeartbeatRequest("finish")
206+
207+
expect(() => {
208+
heartbeat("test-heartbeat", () => {
209+
throw new Error("thrown")
210+
})
211+
}).toThrow("thrown")
212+
213+
await nextTick(() => {
214+
startScope.done()
215+
expect(finishScope.isDone()).toBe(false)
216+
})
217+
})
218+
})
219+
220+
describe("with an async function", () => {
221+
it("sends heartbeat start and finish events", async () => {
222+
const startScope = mockHeartbeatRequest("start")
223+
const startBody = interceptRequestBody(startScope)
224+
225+
const finishScope = mockHeartbeatRequest("finish")
226+
const finishBody = interceptRequestBody(finishScope)
227+
228+
await expect(
229+
heartbeat("test-heartbeat", async () => {
230+
await nextTick(() => {
231+
startScope.done()
232+
expect(finishScope.isDone()).toBe(false)
233+
})
234+
235+
const millisecondsToNextSecond = 1000 - (Date.now() % 1000)
236+
await sleep(millisecondsToNextSecond)
237+
238+
return "output"
239+
})
240+
).resolves.toBe("output")
241+
242+
await nextTick(() => {
243+
startScope.done()
244+
finishScope.done()
245+
})
246+
247+
expect(JSON.parse(await finishBody).timestamp).toBeGreaterThan(
248+
JSON.parse(await startBody).timestamp
249+
)
250+
})
251+
252+
it("does not send a finish event when the promise returned is rejected", async () => {
253+
const startScope = mockHeartbeatRequest("start")
254+
const finishScope = mockHeartbeatRequest("finish")
255+
256+
await expect(
257+
heartbeat("test-heartbeat", async () => {
258+
await nextTick(() => {
259+
startScope.done()
260+
expect(finishScope.isDone()).toBe(false)
261+
})
262+
263+
throw new Error("rejected")
264+
})
265+
).rejects.toThrow("rejected")
266+
267+
await nextTick(() => {
268+
startScope.done()
269+
expect(finishScope.isDone()).toBe(false)
270+
})
271+
})
272+
})
273+
})
274+
})

src/__tests__/transmitter.test.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ describe("Transmitter", () => {
3333
return new Transmitter("https://example.com/foo", "request body")
3434
}
3535

36-
function mockSampleRequest(responseBody: object | string, query = {}): Scope {
36+
function mockSampleRequest(
37+
responseBody: object | string,
38+
query = {}
39+
): Scope {
3740
return nock("https://example.com")
3841
.post("/foo", "request body")
3942
.query({
@@ -62,7 +65,7 @@ describe("Transmitter", () => {
6265
const scope = mockSampleRequest({ json: "response" })
6366

6467
await expectResponse(transmitter.transmit(), { json: "response" })
65-
68+
6669
scope.done()
6770
})
6871

@@ -115,7 +118,9 @@ describe("Transmitter", () => {
115118
const transmitter = new Transmitter("http://example.com/foo")
116119

117120
it("resolves to a response stream on success", async () => {
118-
const scope = nock("http://example.com").get("/foo").reply(200, "response body")
121+
const scope = nock("http://example.com")
122+
.get("/foo")
123+
.reply(200, "response body")
119124

120125
const stream = await transmitter.downloadStream()
121126

@@ -129,7 +134,9 @@ describe("Transmitter", () => {
129134
})
130135

131136
it("rejects if the status code is not successful", async () => {
132-
const scope = nock("http://example.com").get("/foo").reply(404, "not found")
137+
const scope = nock("http://example.com")
138+
.get("/foo")
139+
.reply(404, "not found")
133140

134141
await expect(transmitter.downloadStream()).rejects.toMatchObject({
135142
kind: "statusCode",
@@ -204,7 +211,9 @@ describe("Transmitter", () => {
204211
}
205212

206213
it("performs an HTTP GET request", async () => {
207-
const scope = nock("http://example.invalid").get("/foo").reply(200, "response body")
214+
const scope = nock("http://example.invalid")
215+
.get("/foo")
216+
.reply(200, "response body")
208217

209218
const { callback, onData, onError } = await transmitterRequest(
210219
"GET",

src/heartbeat.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export class Heartbeat {
5151
name: this.name,
5252
id: this.id,
5353
kind: kind,
54-
timestamp: Date.now()
54+
timestamp: Math.floor(Date.now() / 1000)
5555
}
5656
}
5757

@@ -106,10 +106,7 @@ export function heartbeat<T>(name: string, fn?: () => T): T | undefined {
106106
}
107107

108108
if (output instanceof Promise) {
109-
output = output.then(result => {
110-
heartbeat.finish()
111-
return result
112-
}) as typeof output
109+
output.then(() => heartbeat.finish()).catch(() => {})
113110
} else {
114111
heartbeat.finish()
115112
}

0 commit comments

Comments
 (0)