Skip to content

Commit 31797f5

Browse files
kpsuperplaneclaude
andcommitted
Add Bun test suite with 144 tests covering pure utils, pilot state, network, and platform integration
- bunfig.toml + bun test scripts; CI workflow runs bun-test + node-smoke on PRs - tests in test/ mirror src/ layout with reusable HAP mocks and factories - 87% line coverage on src/; covers regression surfaces from #99/#159/#175 - minor: assign Service/Characteristic in wiz.ts constructor body (not field init) for [[Define]] semantics - minor: tsconfig useDefineForClassFields=false (matches tsc ES2020 default) - CLAUDE.md gains a Testing section Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1422498 commit 31797f5

22 files changed

Lines changed: 2085 additions & 5 deletions

.github/workflows/test.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: test
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
8+
jobs:
9+
bun-test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: oven-sh/setup-bun@v2
14+
- run: bun install --frozen-lockfile
15+
- run: bun test --coverage
16+
- uses: actions/upload-artifact@v4
17+
if: always()
18+
with:
19+
name: coverage
20+
path: coverage/
21+
22+
node-smoke:
23+
runs-on: ubuntu-latest
24+
strategy:
25+
fail-fast: false
26+
matrix:
27+
node: [18, 20, 22]
28+
steps:
29+
- uses: actions/checkout@v4
30+
- uses: actions/setup-node@v4
31+
with:
32+
node-version: ${{ matrix.node }}
33+
- run: npm ci
34+
- run: npm run build

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ node_modules
22
dist
33
.vscode
44
.DS_Store
5-
test
5+
coverage
6+
test/hbConfig

CLAUDE.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# homebridge-wiz-lan — maintainer guide
2+
3+
This file documents the consistent release ritual for this repo, derived from past tags and merge history. Follow it whenever a PR is merged or a release is cut.
4+
5+
## Release ritual
6+
7+
Releases are always cut as a **separate direct commit to `master`**, never bundled into a PR merge.
8+
9+
### 1. Merge the PR
10+
- Use a true merge commit (no squash).
11+
- Dependabot PRs and human-contributor PRs are both merged the same way.
12+
13+
### 2. Decide whether to release now or batch
14+
- **Release immediately** for user-visible features or fixes.
15+
- **Batch** for dependabot bumps and small follow-ups — fold them into the next human-driven release.
16+
17+
### 3. Cut the release (single direct commit on `master`)
18+
19+
In one commit, update all of:
20+
21+
- **`package.json`** — bump `version` (semver: patch for fix, minor for feature, major for breaking).
22+
- **`CHANGELOG.md`** — add a new section at the top in the existing format:
23+
```
24+
## X.Y.Z
25+
- [FEAT] / [FIX] short description
26+
- Thank you [@handle](https://github.com/handle) for [#NN](https://github.com/kpsuperplane/homebridge-wiz-lan/pull/NN)
27+
```
28+
- Use `[FEAT]` and `[FIX]` prefixes.
29+
- Credit human contributors by handle + PR link. Omit credit for dependabot.
30+
- **`README.md` contributors block** — if this release includes a first-time external contributor, add them under the credits section in the same format as existing entries (`#### [@handle]` then `[#NN title](url)`).
31+
- **`package-lock.json`** — regenerate via `npm install` so it matches the new version.
32+
33+
The commit message is just the version string, e.g. `3.2.6`.
34+
35+
### 4. Tag and push
36+
- Tag the bump commit with **`vX.Y.Z`** (always include the `v` prefix — historical tags were inconsistent; new tags should standardize on `v`).
37+
- Push the commit and the tag to `origin/master`.
38+
- npm publish runs from `.github/workflows/npm-publish.yml` on tag push.
39+
40+
## What NOT to do
41+
- Don't bump `package.json` inside a feature PR — the version bump is a separate maintainer commit on master.
42+
- Don't squash-merge — preserve the merge-commit history.
43+
- Don't tag the merge commit; tag the version-bump commit that follows it.
44+
- Don't credit dependabot in CHANGELOG or README.
45+
- Don't create a release per dependabot PR — batch them.
46+
47+
## Quick reference: file touch list per release
48+
49+
| File | Why |
50+
| --- | --- |
51+
| `package.json` | version bump |
52+
| `package-lock.json` | sync with new version |
53+
| `CHANGELOG.md` | new section with FEAT/FIX bullets and contributor credit |
54+
| `README.md` | add new external contributor to credits block (only if applicable) |
55+
56+
## Testing
57+
58+
- Test runner: **Bun** (`bun test`). Production runtime is still Node — Bun is used only for the test suite. Tests live in `test/`, mirroring the `src/` layout.
59+
- Run locally:
60+
- `bun test` — run the suite.
61+
- `bun test --coverage` — run with coverage (text + lcov reports).
62+
- `bun test --watch` — watch mode for iterative work.
63+
- Required before cutting a release: **`bun test` must pass**. Run it before the version-bump commit in the ritual above.
64+
- Coverage target: **≥80% lines** on `src/` (excluding `src/index.ts`, `src/constants.ts`, `src/types.ts`). Don't lower this — instead, add tests when adding code.
65+
- CI runs `.github/workflows/test.yml` on every PR and push to master. Two jobs:
66+
- `bun-test` runs the suite + coverage upload.
67+
- `node-smoke` runs `npm run build` across Node 18/20/22 to ensure the published JS still compiles on every supported Node line.
68+
- When fixing a regression, add a test that would have caught it. The cache/state and network layers have history — see `test/accessories/WizLight/pilot.test.ts` for examples tied to issues #96/#101/#143/#145/#159.
69+
- Mocking conventions:
70+
- `test/__mocks__/homebridge.ts` provides the Homebridge HAP surface (Service, Characteristic, PlatformAccessory, AdaptiveLightingController).
71+
- `test/__helpers__/factories.ts` provides `makeFakeWiz()`, `makeDevice()`, `makeLightPilot()`, `makeSocketPilot()`, `FakeSocket`.
72+
- For `pilot.ts` tests, stub `src/util/network`'s `getPilot`/`setPilot` via `mock.module` and synthesize replies. For `wiz.ts` tests, mock only `dgram` so the real network functions still operate on the FakeSocket.

bunfig.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[test]
2+
# Default off — `bun test` is fast. Enable with `bun test --coverage` or the
3+
# test:coverage npm script.
4+
coverageReporter = ["text", "lcov"]
5+
coverageThreshold = 0.80
6+
coveragePathIgnorePatterns = ["src/index.ts", "src/constants.ts", "src/types.ts"]
7+
preload = ["./test/__helpers__/setup.ts"]

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
"prepublishOnly": "npm run build",
1616
"postpublish": "npm run clean",
1717
"watch": "npm run build && npm link && nodemon",
18-
"test": "echo \"Error: no test specified\" && exit 1"
18+
"test": "bun test",
19+
"test:watch": "bun test --watch",
20+
"test:coverage": "bun test --coverage"
1921
},
2022
"engines": {
2123
"homebridge": "^1.6.0 || ^2.0.0-beta.0",

src/wiz.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import { Config, Device } from "./types";
1313
import { bindSocket, createSocket, registerDiscoveryHandler, sendDiscoveryBroadcast } from "./util/network";
1414

1515
export default class HomebridgeWizLan {
16-
public readonly Service: typeof Service = this.api.hap.Service;
17-
public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic;
16+
public readonly Service: typeof Service;
17+
public readonly Characteristic: typeof Characteristic;
1818

1919
// this is used to track restored cached accessories
2020
public readonly accessories: PlatformAccessory[] = [];
@@ -26,6 +26,11 @@ export default class HomebridgeWizLan {
2626
public readonly config: Config,
2727
public readonly api: API
2828
) {
29+
// Assign after `this.api` is set, not via field initializer — under
30+
// [[Define]] class-field semantics the initializer would run before the
31+
// parameter property is assigned and dereference `undefined.hap`.
32+
this.Service = this.api.hap.Service;
33+
this.Characteristic = this.api.hap.Characteristic;
2934
this.socket = createSocket(this);
3035

3136
// When this event is fired it means Homebridge has restored all cached accessories from disk.

test/__helpers__/factories.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { EventEmitter } from "events";
2+
import { mock } from "bun:test";
3+
import type { Device, Config } from "../../src/types";
4+
import type { Pilot as LightPilot } from "../../src/accessories/WizLight/pilot";
5+
import type { Pilot as SocketPilot } from "../../src/accessories/WizSocket/pilot";
6+
import {
7+
FakeCharacteristicCtors,
8+
FakePlatformAccessory,
9+
FakeServiceCtors,
10+
makeFakeAPI,
11+
makeFakeLogger,
12+
} from "../__mocks__/homebridge";
13+
14+
export const makeDevice = (overrides: Partial<Device> = {}): Device => ({
15+
ip: "10.0.0.42",
16+
mac: "AABBCCDDEEFF",
17+
model: "ESP01_SHRGB_03",
18+
...overrides,
19+
});
20+
21+
export const makeLightPilot = (
22+
overrides: Partial<LightPilot> = {},
23+
): LightPilot => ({
24+
mac: "AABBCCDDEEFF",
25+
rssi: -50,
26+
src: "udp",
27+
state: true,
28+
dimming: 100,
29+
...overrides,
30+
});
31+
32+
export const makeSocketPilot = (
33+
overrides: Partial<SocketPilot> = {},
34+
): SocketPilot => ({
35+
mac: "AABBCCDDEEFF",
36+
rssi: -50,
37+
src: "udp",
38+
state: true,
39+
...overrides,
40+
});
41+
42+
export class FakeSocket extends EventEmitter {
43+
public sent: { msg: string; port: number; ip: string }[] = [];
44+
public send = mock(
45+
(
46+
msg: string | Buffer,
47+
port: number,
48+
ip: string,
49+
cb?: (err: Error | null) => void,
50+
) => {
51+
this.sent.push({ msg: msg.toString(), port, ip });
52+
cb?.(null);
53+
},
54+
);
55+
public bind = mock(
56+
(_port: number, _addr: string, cb?: () => void) => cb?.(),
57+
);
58+
public close = mock(() => {});
59+
public setBroadcast = mock((_: boolean) => {});
60+
public address = () => ({
61+
family: "IPv4",
62+
address: "127.0.0.1",
63+
port: 38900,
64+
});
65+
}
66+
67+
export const makeFakeWiz = (config: Config = {} as Config) => {
68+
const api = makeFakeAPI();
69+
const log = makeFakeLogger();
70+
const socket = new FakeSocket();
71+
const wiz: any = {
72+
log,
73+
config,
74+
api,
75+
socket,
76+
Service: FakeServiceCtors,
77+
Characteristic: FakeCharacteristicCtors,
78+
accessories: [],
79+
initializedAccessories: {},
80+
};
81+
return wiz;
82+
};
83+
84+
export const makeAccessoryWithService = (
85+
serviceKey: "Lightbulb" | "Outlet" | "Television",
86+
displayName = "Test Accessory",
87+
uuid = "uuid-AABBCCDDEEFF",
88+
) => {
89+
const acc = new FakePlatformAccessory(displayName, uuid);
90+
acc.addService(FakeServiceCtors[serviceKey], displayName);
91+
return acc;
92+
};

test/__helpers__/setup.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Preloaded by bunfig.toml. Quiets noisy console output during tests so a
2+
// failing assertion message isn't lost in a flood of logs from production
3+
// code paths that defensively log. Individual tests can still assert against
4+
// calls on the injected `wiz.log` mock — they don't go through console.
5+
const noop = () => {};
6+
console.debug = noop;
7+
console.info = noop;

0 commit comments

Comments
 (0)