• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

homebridge / HAP-NodeJS / 29387820810

15 Jul 2026 03:58AM UTC coverage: 66.203% (+0.04%) from 66.161%
29387820810

push

github

web-flow
Fix: test harness rewritten around a connection-owning HAP client, removing the axios dependency. (#1122)

* Fix: test harness rewritten around a connection-owning HAP client, removing the axios dependency.
* Improvement: lifetime-valid close observation, fast closed-connection rejection, and portable loopback dialing in the test harness.

1926 of 3395 branches covered (56.73%)

Branch coverage included in aggregate %.

133 of 148 new or added lines in 4 files covered. (89.86%)

6646 of 9553 relevant lines covered (69.57%)

223.19 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.6
/src/test-utils/HAPHTTPClient.ts
1
import assert from "assert";
4✔
2
import { once } from "events";
4✔
3
import { HeaderObject, HTTPParser } from "http-parser-js";
4✔
4
import { createConnection, Socket } from "net";
4✔
5
import { HAPMimeTypes, PairingStates, PairMethods, TLVValues } from "../internal-types";
6
import { HAPHTTPCode, HAPPairingHTTPCode } from "../lib/HAPServer";
7
import { PairingInformation, PermissionTypes } from "../lib/model/AccessoryInfo";
8
import { HAPEncryption, HAPUsername } from "../lib/util/eventedhttp";
9
import * as hapCrypto from "../lib/util/hapCrypto";
4✔
10
import * as tlv from "../lib/util/tlv";
4✔
11
import {
12
  AccessoriesResponse,
13
  CharacteristicId,
14
  CharacteristicsReadResponse,
15
  CharacteristicsWriteRequest,
16
  CharacteristicsWriteResponse,
17
  PrepareWriteRequest,
18
  ResourceRequest,
19
} from "../types";
20
import { HAPHTTPError } from "./HAPHTTPError";
4✔
21
import { TLVError } from "./tlvError";
4✔
22

23
export interface HTTPResponse<T = Buffer> {
24
  shouldKeepAlive: boolean;
25
  upgrade: boolean;
26
  statusCode: number;
27
  statusMessage: string;
28
  versionMajor: number;
29
  versionMinor: number;
30
  headers: Record<string, string>;
31
  body: T;
32
  trailers: string[];
33
}
34

35
export interface HTTPRequestOptions {
36
  /**
37
   * Content-Type header applied when a request body is present. Defaults to "application/json".
38
   */
39
  contentType?: string;
40
  /**
41
   * Additional request headers appended verbatim to the fixed header block (e.g. an authorization header).
42
   */
43
  headers?: Record<string, string>;
44
}
45

46
/**
47
 * A HAP HTTP client backed by a raw TCP connection it owns for the connection's entire lifetime - the client-side mirror
48
 * of the server's HAPConnection. Owning the socket end-to-end is what allows a single connection to carry the pairing
49
 * handshakes, the encrypted session traffic whose keys are bound to that exact connection, and unsolicited EVENT messages
50
 * the server pushes. A pooled http.Agent socket cannot serve this role: the agent's free-socket read guard destroys a
51
 * pooled connection the moment the server sends unsolicited data.
52
 */
53
export class HAPHTTPClient {
4✔
54
  /**
55
   * Upper bound in milliseconds for awaiting received bytes before failing. Generous relative to the real loopback
56
   * round-trip so it never trips on a healthy connection, while still turning a genuinely absent response into a loud
57
   * timeout instead of a silent empty buffer.
58
   */
59
  private static readonly RECEIVE_TIMEOUT = 2000;
4✔
60

61
  private readonly address: string;
62
  private readonly port: number;
63

64
  private currentSocket?: Socket;
65
  private encryption?: HAPEncryption;
66
  private socketClosed = false;
132✔
67
  // Settles when the underlying socket has fully closed. Captured at connect() so close observation stays valid for the
68
  // client's entire lifetime, even after destroy() has dropped the socket reference.
69
  private socketClosePromise?: Promise<void>;
70
  private everConnected = false;
132✔
71

72
  private currentDataListener?: (data: Buffer) => void;
73
  private dataQueue: Buffer[] = [];
132✔
74
  // Already-decrypted bytes trailing a parsed HTTP response that shared a TCP segment with subsequent data (e.g. a
75
  // coalesced EVENT message). Consumed by popReceiveBuffer before any further (still encrypted) segments.
76
  private leftoverPlaintext?: Buffer;
77

78
  constructor(address: string, port: number) {
79
    this.address = address;
132✔
80
    this.port = port;
132✔
81
  }
82

83
  /**
84
   * Opens the TCP connection this client owns for its entire lifetime and installs the receive machinery. Mirrors the
85
   * server-side HAPConnection setup: Nagle is disabled so small request/response writes are not artificially delayed.
86
   */
87
  async connect(): Promise<void> {
88
    // One client instance models one connection for its entire lifetime - the client-side mirror of the server's
89
    // per-connection HAPConnection - so a client is never reconnected after use. A test needing a fresh connection
90
    // constructs a fresh client, which also keeps all receive state trivially clean.
91
    expect(this.everConnected).toBe(false);
132✔
92
    this.everConnected = true;
132✔
93

94
    const socket = createConnection(this.port, this.address);
132✔
95
    socket.setNoDelay(true);
132✔
96

97
    // A permanent error listener must exist for the socket's lifetime: without one, a late ECONNRESET (e.g. the server
98
    // tearing down first in afterEach) would crash the process. Failures surface loudly through receive timeouts instead.
99
    socket.on("error", () => {});
132✔
100
    this.socketClosePromise = new Promise(resolve => {
132✔
101
      socket.once("close", () => {
132✔
102
        this.socketClosed = true;
128✔
103
        resolve();
128✔
104
      });
105
    });
106

107
    this.currentDataListener = data => {
132✔
108
      this.dataQueue.push(data);
284✔
109
    };
110
    socket.on("data", this.currentDataListener);
132✔
111
    this.currentSocket = socket;
132✔
112

113
    // once() rejects on a socket "error" while waiting, covering the failed-connect path.
114
    await once(socket, "connect");
132✔
115
  }
116

117
  /**
118
   * Destroys the owned connection. Safe to call multiple times and on a never-connected client, so tests can place it in
119
   * finally blocks and afterEach hooks unconditionally.
120
   */
121
  destroy(): void {
122
    if (this.currentSocket) {
136✔
123
      if (this.currentDataListener) {
132!
124
        this.currentSocket.removeListener("data", this.currentDataListener);
132✔
125
        this.currentDataListener = undefined;
132✔
126
      }
127
      this.currentSocket.destroy();
132✔
128
      this.currentSocket = undefined;
132✔
129
    }
130
  }
131

132
  /**
133
   * True once the underlying socket has fully closed - regardless of which side initiated it. Lets tests assert
134
   * server-initiated teardown directly on the connection instead of inferring it from client-library internals.
135
   */
136
  get isClosed(): boolean {
137
    return this.socketClosed;
2✔
138
  }
139

140
  /**
141
   * Resolves once the underlying socket has fully closed, rejecting after {@link timeoutMs} if it never does. Close
142
   * observation is anchored to the promise captured at {@link connect}, so it is valid at any point in the client's
143
   * lifetime - including immediately after {@link destroy} - regardless of which side initiates the teardown.
144
   */
145
  async waitForClose(timeoutMs = HAPHTTPClient.RECEIVE_TIMEOUT): Promise<void> {
4✔
146
    expect(this.socketClosePromise).toBeDefined();
4✔
147

148
    let timeoutId: NodeJS.Timeout | undefined = undefined;
4✔
149

150
    const timeout = new Promise<void>((_resolve, reject) => {
4✔
151
      timeoutId = setTimeout(() => reject(new Error("Timed out awaiting the connection to close.")), timeoutMs);
4✔
152
    });
153

154
    try {
4✔
155
      await Promise.race([this.socketClosePromise, timeout]);
4✔
156
    } finally {
157
      clearTimeout(timeoutId);
4✔
158
    }
159
  }
160

161
  get receiveBufferCount(): number {
162
    return this.dataQueue.length + (this.leftoverPlaintext ? 1 : 0);
310!
163
  }
164

165
  enableEncryption(encryption: HAPEncryption): void {
166
    this.encryption = encryption;
50✔
167
  }
168

169
  disableEncryption(): void {
170
    this.encryption = undefined;
×
171
  }
172

173
  popReceiveBuffer(): Buffer {
174
    expect(this.currentSocket).toBeDefined();
284✔
175
    if (this.leftoverPlaintext) {
284!
NEW
176
      const buffer = this.leftoverPlaintext;
×
NEW
177
      this.leftoverPlaintext = undefined;
×
NEW
178
      return buffer;
×
179
    }
180
    expect(this.dataQueue.length > 0).toBeTruthy();
284✔
181
    const buffer = this.dataQueue.splice(0, 1)[0];
284✔
182
    if (this.encryption) {
284✔
183
      return hapCrypto.layerDecrypt(buffer, this.encryption);
54✔
184
    }
185
    return buffer;
230✔
186
  }
187

188
  /**
189
   * Waits for the next TCP segment to arrive, rejecting once {@link deadline} (an absolute epoch-milliseconds timestamp)
190
   * passes or the connection closes. A closed socket can never deliver the awaited bytes, so failing fast on close beats
191
   * stalling out the timeout and obscuring the real cause.
192
   */
193
  private awaitIncomingData(deadline: number): Promise<void> {
194
    expect(this.currentSocket).toBeDefined();
284✔
195

196
    // A connection that has already closed can never deliver the awaited bytes - reject immediately rather than riding
197
    // out the deadline only to fail with the less specific timeout error.
198
    if (this.socketClosed) {
284!
NEW
199
      return Promise.reject(new Error("Connection closed while awaiting incoming data."));
×
200
    }
201

202
    const socket = this.currentSocket!;
284✔
203

204
    return new Promise((resolve, reject) => {
284✔
205
      let settled = false;
284✔
206

207
      const cleanups: (() => void)[] = [];
284✔
208

209
      // Whichever of the three outcomes settles first tears down the other two registrations, so no timer or listener
210
      // outlives the wait.
211
      const settle = (error?: Error) => {
284✔
212
        if (settled) {
284!
NEW
213
          return;
×
214
        }
215
        settled = true;
284✔
216
        for (const cleanup of cleanups) {
284✔
217
          cleanup();
852✔
218
        }
219
        if (error) {
284!
NEW
220
          reject(error);
×
221
        } else {
222
          resolve();
284✔
223
        }
224
      };
225

226
      const timeoutId = setTimeout(() => settle(new Error("Timed out awaiting incoming data on the connection.")), Math.max(0, deadline - Date.now()));
284✔
227
      const onData = () => settle();
284✔
228
      const onClose = () => settle(new Error("Connection closed while awaiting incoming data."));
284✔
229

230
      cleanups.push(() => clearTimeout(timeoutId));
284✔
231
      cleanups.push(() => socket.removeListener("data", onData));
284✔
232
      cleanups.push(() => socket.removeListener("close", onClose));
284✔
233

234
      socket.on("data", onData);
284✔
235
      socket.on("close", onClose);
284✔
236
    });
237
  }
238

239
  /**
240
   * Resolves once at least {@link count} buffers have arrived - immediately if they already have - and rejects once
241
   * {@link timeoutMs} has elapsed overall or the connection closes while waiting. Callers await the genuine arrival of
242
   * bytes instead of sleeping for a fixed interval and hoping the loopback encrypt/decrypt round-trip has completed by
243
   * then; that is what keeps the suite robust across Node major versions whose event-loop scheduling differs, and it lets
244
   * a genuinely missing response fail loudly instead of silently reading an empty buffer.
245
   */
246
  async waitForData(count = 1, timeoutMs = HAPHTTPClient.RECEIVE_TIMEOUT): Promise<void> {
6!
247
    const deadline = Date.now() + timeoutMs;
8✔
248

249
    while (this.receiveBufferCount < count) {
8✔
250
      await this.awaitIncomingData(deadline);
8✔
251
    }
252
  }
253

254
  /**
255
   * Drains and returns every buffered message as one string once the received data contains {@link marker}, awaiting
256
   * further TCP segments until it does (rejecting once {@link timeoutMs} has elapsed overall or the connection closes).
257
   * HomeKit may coalesce an HTTP response and a trailing EVENT message into a single TCP segment or split them across
258
   * two, so matching on content rather than on a fixed segment count keeps event assertions independent of that
259
   * nondeterminism.
260
   */
261
  async readUntil(marker: string, timeoutMs = HAPHTTPClient.RECEIVE_TIMEOUT): Promise<string> {
2✔
262
    const deadline = Date.now() + timeoutMs;
4✔
263

264
    let received = "";
4✔
265
    while (this.receiveBufferCount > 0) {
4✔
NEW
266
      received += this.popReceiveBuffer().toString();
×
267
    }
268
    while (!received.includes(marker)) {
4✔
269
      await this.awaitIncomingData(deadline);
4✔
270
      while (this.receiveBufferCount > 0) {
4✔
271
        received += this.popReceiveBuffer().toString();
4✔
272
      }
273
    }
274
    return received;
4✔
275
  }
276

277
  formatHTTPRequest(
278
    method: "GET" | "POST" | "PUT" | "DELETE",
279
    route: string,
280
    data?: Buffer,
281
    options: HTTPRequestOptions = {},
70✔
282
  ): Buffer {
283
    const contentType = options.contentType ?? "application/json";
278✔
284

285
    let extraHeaders = "";
278✔
286
    for (const [name, value] of Object.entries(options.headers ?? {})) {
278✔
287
      extraHeaders += name + ": " + value + "\r\n";
8✔
288
    }
289

290
    // A body-less non-GET request is legitimate (e.g. POST /identify) and carries an explicit zero Content-Length so the
291
    // server's parser can frame the message without ambiguity.
292
    const bodyHeaders = data
278✔
293
      ? "Content-Type: " + contentType + "\r\n" + "Content-Length: " + data.length + "\r\n"
294
      : (method === "GET" ? "" : "Content-Length: 0\r\n");
68✔
295

296
    const buffer = Buffer.from(`${method} ${route} HTTP/1.1\r\n` +
278✔
297
      "Accept: application/json, text/plain, */*\r\n" +
298
      "User-Agent: test-util\r\n" +
299
      "Host: " + this.address + ":" + this.port + "\r\n" +
300
      "Connection: keep-alive\r\n" +
301
      extraHeaders +
302
      bodyHeaders +
303
      "\r\n");
304

305
    if (data) {
278✔
306
      return Buffer.concat([buffer, data]);
210✔
307
    }
308

309
    return buffer;
68✔
310
  }
311

312
  async writeHTTPRequest(method: "GET" | "POST" | "PUT" | "DELETE", route: string, data?: Buffer, options?: HTTPRequestOptions): Promise<HTTPResponse> {
313
    const httpRequest = this.formatHTTPRequest(method, route, data, options);
268✔
314
    this.write(httpRequest);
268✔
315
    return this.readHTTPResponse();
268✔
316
  }
317

318
  write(data: Buffer): void {
319
    if (this.encryption) {
278✔
320
      data = hapCrypto.layerEncrypt(data, this.encryption);
54✔
321
    }
322
    expect(this.currentSocket).toBeDefined();
278✔
323
    this.currentSocket!.write(data);
278✔
324
  }
325

326
  /**
327
   * Reads exactly one complete HTTP response off the connection, feeding the parser segment by segment until the message
328
   * completes (rejecting once {@link timeoutMs} has elapsed overall or the connection closes). Parsing incrementally
329
   * removes any assumption about how the runtime segments the response across TCP packets - segmentation is exactly the
330
   * kind of behavior that shifts between Node major versions. Bytes trailing the parsed message (e.g. a coalesced EVENT
331
   * notification) are retained for the next read rather than lost.
332
   */
333
  async readHTTPResponse(timeoutMs = HAPHTTPClient.RECEIVE_TIMEOUT): Promise<HTTPResponse> {
272✔
334
    const deadline = Date.now() + timeoutMs;
272✔
335
    const parser = new HTTPParser(HTTPParser.RESPONSE);
272✔
336

337
    let complete = false;
272✔
338
    // The chunk-relative position at which the message completed. The parser does not stop at message end - it
339
    // reinitializes and keeps consuming trailing bytes as the start of a next message within the same execute() call -
340
    // so the boundary must be captured the moment kOnMessageComplete fires, and the callbacks below must go quiet once
341
    // the message of interest is complete so trailing bytes cannot corrupt the parsed result.
342
    let completedAt = 0;
272✔
343
    let shouldKeepAlive = false;
272✔
344
    let upgrade = false;
272✔
345
    let statusCode = 0;
272✔
346
    let statusMessage = "";
272✔
347
    let versionMajor = 0;
272✔
348
    let versionMinor = 0;
272✔
349
    let headers: HeaderObject = [];
272✔
350
    let trailers: string[] = [];
272✔
351
    const bodyChunks: Buffer[] = [];
272✔
352

353
    parser[HTTPParser.kOnHeadersComplete] = info => {
272✔
354
      if (complete) {
272!
NEW
355
        return;
×
356
      }
357
      shouldKeepAlive = info.shouldKeepAlive;
272✔
358
      upgrade = info.upgrade;
272✔
359
      statusCode = info.statusCode;
272✔
360
      statusMessage = info.statusMessage;
272✔
361
      versionMajor = info.versionMajor;
272✔
362
      versionMinor = info.versionMinor;
272✔
363
      headers = info.headers;
272✔
364
    };
365

366
    parser[HTTPParser.kOnBody] = (chunk, offset, length) => {
272✔
367
      if (complete) {
266!
NEW
368
        return;
×
369
      }
370
      bodyChunks.push(chunk.subarray(offset, offset + length));
266✔
371
    };
372

373
    // that's the event for trailers!
374
    parser[HTTPParser.kOnHeaders] = t => {
272✔
NEW
375
      if (complete) {
×
NEW
376
        return;
×
377
      }
NEW
378
      trailers = t;
×
379
    };
380

381
    parser[HTTPParser.kOnMessageComplete] = () => {
272✔
382
      complete = true;
272✔
383
      // The parser's chunk-relative read position at completion time is the exact message boundary. The property is
384
      // runtime state the type declarations do not surface, hence the narrow structural cast.
385
      completedAt = (parser as unknown as { offset: number }).offset;
272✔
386
    };
387

388
    while (!complete) {
272✔
389
      if (this.receiveBufferCount === 0) {
272!
390
        await this.awaitIncomingData(deadline);
272✔
391
      }
392

393
      const chunk = this.popReceiveBuffer();
272✔
394
      const result = parser.execute(chunk);
272✔
395

396
      if (complete) {
272!
397
        // Bytes beyond the completed message belong to the next message (e.g. a coalesced EVENT notification): retain
398
        // them for the next read. A parse error the parser hit on those trailing bytes is not this response's concern.
399
        if (completedAt < chunk.length) {
272!
NEW
400
          this.leftoverPlaintext = chunk.subarray(completedAt);
×
401
        }
NEW
402
      } else if (typeof result !== "number") {
×
NEW
403
        throw new Error("Failed to parse HTTP response chunk: " + result.message);
×
404
      }
405
    }
406

407
    const body = Buffer.concat(bodyChunks);
272✔
408

409
    return {
272✔
410
      shouldKeepAlive,
411
      upgrade,
412
      statusCode,
413
      statusMessage,
414
      versionMajor,
415
      versionMinor,
416
      headers: this.headersArrayToObject(headers),
417
      body,
418
      trailers,
419
    };
420
  }
421

422
  async sendAddPairingRequest(identifier: HAPUsername, publicKey: Buffer, permission: PermissionTypes): Promise<void> {
423
    const requestTLV = tlv.encode(
2✔
424
      TLVValues.METHOD, PairMethods.ADD_PAIRING,
425
      TLVValues.STATE, PairingStates.M1,
426
      TLVValues.IDENTIFIER, identifier,
427
      TLVValues.PUBLIC_KEY, publicKey,
428
      TLVValues.PERMISSIONS, permission,
429
    );
430

431
    await this.sendPairingsRequest(requestTLV);
2✔
432
  }
433

434
  async sendRemovePairingRequest(identifier: HAPUsername): Promise<void> {
435
    const requestTLV = tlv.encode(
2✔
436
      TLVValues.METHOD, PairMethods.REMOVE_PAIRING,
437
      TLVValues.STATE, PairingStates.M1,
438
      TLVValues.IDENTIFIER, identifier,
439
    );
440

441
    await this.sendPairingsRequest(requestTLV);
2✔
442
  }
443

444
  async sendListPairingsRequest(): Promise<PairingInformation[]> {
445
    const requestTLV = tlv.encode(
2✔
446
      TLVValues.METHOD, PairMethods.LIST_PAIRINGS,
447
      TLVValues.STATE, PairingStates.M1,
448
    );
449

450
    const responseBody = await this.sendPairingsRequest(requestTLV);
2✔
451
    const tlvDataList = tlv.decodeList(responseBody.subarray(3), TLVValues.IDENTIFIER);
2✔
452

453
    const result: PairingInformation[] = [];
2✔
454

455
    for (const element of tlvDataList) {
2✔
456
      result.push({
4✔
457
        username: element[TLVValues.IDENTIFIER].toString(),
458
        publicKey: element[TLVValues.PUBLIC_KEY],
459
        permission: element[TLVValues.PERMISSIONS].readUInt8(0),
460
      });
461
    }
462

463
    return result;
2✔
464
  }
465

466
  private async sendPairingsRequest(requestTLV: Buffer): Promise<Buffer> {
467
    const httpResponse = await this.writeHTTPRequest("POST", "/pairings", requestTLV, { contentType: HAPMimeTypes.PAIRING_TLV8 });
6✔
468

469
    // `/pairings` errors are transported via the tlv8 record
470
    expect(httpResponse.statusCode).toEqual(HAPPairingHTTPCode.OK);
6✔
471
    expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.PAIRING_TLV8);
6✔
472

473
    const tlvData = tlv.decode(httpResponse.body);
6✔
474
    expect(tlvData[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2);
6✔
475

476
    if (tlvData[TLVValues.ERROR_CODE]) {
6!
477
      throw new TLVError(tlvData[TLVValues.ERROR_CODE].readUInt8(0));
×
478
    }
479

480
    // we return the raw buffer because LIST_PAIRINGS has some custom decoding strategies!
481
    return httpResponse.body;
6✔
482
  }
483

484
  public async sendAccessoriesRequest(): Promise<AccessoriesResponse> {
485
    const httpResponse = await this.writeHTTPRequest("GET", "/accessories");
4✔
486
    expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.HAP_JSON);
4✔
487
    const jsonBody = JSON.parse(httpResponse.body.toString());
4✔
488

489
    if (httpResponse.statusCode !== HAPPairingHTTPCode.OK) {
4!
490
      throw new HAPHTTPError(httpResponse.statusCode, jsonBody.status);
×
491
    }
492

493
    return jsonBody;
4✔
494
  }
495

496
  public async sendCharacteristicRead(
497
    ids: CharacteristicId[],
498
    includeMeta?: boolean,
499
    includePerms?: boolean,
500
    includeType?: boolean,
501
    includeEvent?: boolean,
502
  ): Promise<HTTPResponse<CharacteristicsReadResponse>> {
503
    assert(ids.length > 0);
4✔
504
    let query = "?id=" + ids.map(id => id.aid + "." + id.iid).join(",");
8✔
505

506
    if (includeMeta) {
4!
507
      query += "&meta=" + (includeMeta ? "true" : "false");
×
508
    }
509
    if (includePerms) {
4!
510
      query += "&perms=" + (includePerms ? "1" : "0");
×
511
    }
512
    if (includeType) {
4✔
513
      query += "&type=" + (includeType ? "true" : "false");
2!
514
    }
515
    if (includeEvent) {
4✔
516
      query += "&ev=" + (includeEvent ? "1" : "0");
2!
517
    }
518

519
    const httpResponse = await this.writeHTTPRequest("GET", "/characteristics" + query);
4✔
520
    expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.HAP_JSON);
4✔
521

522
    const body = JSON.parse(httpResponse.body.toString());
4✔
523
    if (!httpResponse.statusCode.toString().startsWith("2")) {
4!
524
      throw new HAPHTTPError(httpResponse.statusCode, body.status);
×
525
    }
526

527
    return {
4✔
528
      ...httpResponse,
529
      body: body,
530
    };
531
  }
532

533
  public async sendCharacteristicWrite(writeRequest: CharacteristicsWriteRequest): Promise<HTTPResponse<CharacteristicsWriteResponse | undefined>> {
534
    const httpResponse = await this.writeHTTPRequest(
6✔
535
      "PUT", "/characteristics", Buffer.from(JSON.stringify(writeRequest)), { contentType: HAPMimeTypes.HAP_JSON },
536
    );
537
    if (httpResponse.statusCode !== HAPHTTPCode.NO_CONTENT) {
6✔
538
      expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.HAP_JSON);
4✔
539
    }
540

541
    if (!httpResponse.statusCode.toString().startsWith("2")) {
6!
542
      const jsonBody = JSON.parse(httpResponse.body.toString());
×
543
      throw new HAPHTTPError(httpResponse.statusCode, jsonBody.status);
×
544
    }
545

546
    return {
6✔
547
      ...httpResponse,
548
      body: httpResponse.body.length > 0 ? JSON.parse(httpResponse.body.toString()) : undefined,
6✔
549
    };
550
  }
551

552
  public async sendPrepareWrite(prepareWrite: PrepareWriteRequest): Promise<void> {
553
    const httpResponse = await this.writeHTTPRequest(
4✔
554
      "PUT", "/prepare", Buffer.from(JSON.stringify(prepareWrite)), { contentType: HAPMimeTypes.HAP_JSON },
555
    );
556
    expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.HAP_JSON);
4✔
557

558
    if (httpResponse.statusCode !== HAPHTTPCode.OK) {
4!
559
      const jsonBody = JSON.parse(httpResponse.body.toString());
×
560
      throw new HAPHTTPError(httpResponse.statusCode, jsonBody.status);
×
561
    }
562
  }
563

564
  public async sendResourceRequest(resourceRequest: ResourceRequest): Promise<Buffer> {
565
    const httpResponse = await this.writeHTTPRequest(
2✔
566
      "POST", "/resource", Buffer.from(JSON.stringify(resourceRequest)), { contentType: HAPMimeTypes.HAP_JSON },
567
    );
568

569
    if (httpResponse.statusCode !== HAPHTTPCode.OK) {
2!
570
      expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.HAP_JSON);
×
571
      const jsonBody = JSON.parse(httpResponse.body.toString());
×
572
      throw new HAPHTTPError(httpResponse.statusCode, jsonBody.status);
×
573
    }
574

575
    expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.IMAGE_JPEG);
2✔
576
    return httpResponse.body;
2✔
577
  }
578

579
  private headersArrayToObject(headers: HeaderObject): Record<string, string> {
580
    expect(headers.length % 2).toBe(0);
272✔
581

582
    const result: Record<string, string> = {};
272✔
583

584
    for (let i = 0; i < headers.length; i += 2) {
272✔
585
      result[headers[i]] = headers[i+1];
1,076✔
586
    }
587

588
    return result;
272✔
589
  }
590
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc