Skip to content

Commit 3d49951

Browse files
Apollon77claude
andauthored
test(node): cover Endpoint.getStateOf() overloads on server endpoints (#3685)
* test(node): cover Endpoint.getStateOf() server + client overloads Adds runtime + type-level coverage for `Endpoint.getStateOf()`, which previously had no integration tests beyond a single error case in `ClientGroupTest`. Server-side runtime tests (`EndpointGetServerTest.ts`) exercise all three overloads against `MockServerNode` + `BasicInformationServer`: - `(B)` and `(B, true)` return full behavior state. - `(B, K[])` returns the requested attributes only; empty list returns `{}`. - `(string, string[])` resolves the string-id fallback overload. - Unknown behavior id throws `EndpointBehaviorNotPresentError`. - Unknown attribute on the string overload throws `AttributeNotPresentError`. Client-side runtime tests (`EndpointGetClientTest.ts`) mirror the server suite using `MockSite` + `BasicInformationClient` (the client-cluster behavior, not the server variant) plus a regression test calling `peer.getStateOf(OperationalCredentialsClient, ['fabrics'])` directly on the typed peer reference. Without the source change below, the latter fails to compile because dynamically-added behaviors (added by `ClientStructure` post-commissioning) are not in `ClientNode.RootEndpoint`'s static behavior map. Reproduces the user-reported error: Argument of type 'OperationalCredentialsClientConstructor' is not assignable to parameter of type 'BehaviorOf<RootEndpoint>'. The fix loosens the typed `getStateOf` overload constraints in `Endpoint.ts` from `B extends BehaviorOf<T>` to `B extends Behavior.Type`. The runtime check at line 385 still throws `EndpointBehaviorNotPresentError` for behaviors not present on the endpoint, so safety is unchanged: callers who pass a `Behavior.Type` not actually supported by the endpoint receive the same runtime error as the string-id overload (which never had a compile-time existence check). Return type tightening via `Behavior.StateOf<B>` is preserved. Type-level tests (`EndpointGetTypesTest.ts`) assert each overload's return shape: - `getStateOf(B)` / `getStateOf(B, true)` → `Promise<Behavior.StateOf<B>>`. - `getStateOf(B, K[])` → `Promise<{ readonly [P in K]?: Behavior.StateOf<B>[P] }>`, including a `@ts-expect-error` for unknown keys. - `getStateOf(string, string[])` accepts arbitrary string keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(node): cover fabricFilter option on client read path Verifies the `fabricFilter` option threads through `Endpoint.get()` to the protocol Read on the client path (Endpoint.ts:1069). Server-side reads hardcode `fabricFilter: false` (Endpoint.ts:1120) and ignore the option, so this test covers only the plumbing on the client side: passing `fabricFilter: false` must not throw and must still return a usable slice. Coverage gaps still open (out of scope for this PR): - Client partial-state-on-failure (`EndpointReadFailedError` carrying failed paths + partial slice). The endpoint pre-validates requested attribute names against the cluster's supported elements at `Endpoint.ts:1042` and rejects unknowns with `AttributeNotPresentError` before reaching the protocol read. ClientStructure mirrors the device's supported attribute set on the peer, so the client peer rejects on the same path. Triggering `EndpointReadFailedError` end-to-end requires a protocol-level mock that injects per-attribute `attr-status` failures, which is not yet available in the node test infrastructure. - Server per-attribute mid-read failure has the same root cause and is blocked on the same mock infrastructure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(node): tighten getStateOf overload-2 return-shape assertion Address Copilot review on PR #3685: - The previous `_checkKeysAwait` assignment was one-sided: it only checked that `{ readonly value?: number }` was assignable to `Awaited<typeof _stateOfKeys>`, so an extra optional property creeping into the return type would have slipped through. Switch to the bidirectional `_AssertEqual` helper already used elsewhere in this file so the test fails on either-direction drift. - The comment above the dead-code block claimed it was wrapped in `if (false)`, but the actual guard is `(false as boolean) === true`. Rewrite the comment to match the code and explain why the cast is required (it defeats TypeScript's constant-condition unreachable- code check that a literal `if (false)` would trigger). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(node): replace Endpoint.ts line numbers with method references Address Copilot review on PR #3685: comment in `EndpointGetClientTest` referenced `Endpoint.ts:1069` / `Endpoint.ts:1120`, which rot as the file changes. Replace with stable method-name references (`Endpoint.#performRead` client branch / server branch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ad5873a commit 3d49951

4 files changed

Lines changed: 225 additions & 2 deletions

File tree

packages/node/src/endpoint/Endpoint.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,12 +356,12 @@ export class Endpoint<T extends EndpointType = EndpointType.Empty> {
356356
* When a key-list selector is provided, each returned value may be `undefined` — absent on unsupported
357357
* attributes or those excluded by {@link EndpointReadFailedError}.
358358
*/
359-
getStateOf<B extends BehaviorOf<T>>(
359+
getStateOf<B extends Behavior.Type>(
360360
type: B,
361361
selector?: true,
362362
options?: Endpoint.GetOptions,
363363
): Promise<Behavior.StateOf<B>>;
364-
getStateOf<B extends BehaviorOf<T>, K extends keyof Behavior.StateOf<B>>(
364+
getStateOf<B extends Behavior.Type, K extends keyof Behavior.StateOf<B>>(
365365
type: B,
366366
selector: readonly K[],
367367
options?: Endpoint.GetOptions,

packages/node/test/endpoint/EndpointGetClientTest.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import { BasicInformationClient } from "#behaviors/basic-information";
8+
import { OperationalCredentialsClient } from "#behaviors/operational-credentials";
79
import { Endpoint } from "#endpoint/Endpoint.js";
10+
import { EndpointBehaviorNotPresentError } from "#endpoint/errors.js";
811
import type { EndpointType } from "#endpoint/type/EndpointType.js";
912
import { MockSite } from "../node/mock-site.js";
1013
import { subscribedPeer } from "../node/node-helpers.js";
@@ -63,4 +66,115 @@ describe("Endpoint.get on a client endpoint", () => {
6366
const result = await asEndpoint(peer).get({});
6467
expect(result).to.deep.equal({});
6568
});
69+
70+
// The `fabricFilter` option threads through to the protocol Read on the client branch of
71+
// `Endpoint.#performRead`. The server branch ignores it (the request is built with
72+
// `fabricFilter: false` unconditionally). This test covers the option's plumbing on the
73+
// client path: passing `fabricFilter: false` must not throw and must still return a usable
74+
// slice.
75+
it("accepts the fabricFilter option on the client read path", async () => {
76+
await using site = new MockSite();
77+
const { controller } = await site.addCommissionedPair();
78+
const peer = await subscribedPeer(controller, "peer1");
79+
80+
const result = (await asEndpoint(peer).get(
81+
{ basicInformation: ["vendorName"] },
82+
{ fabricFilter: false },
83+
)) as Record<string, Record<string, unknown>>;
84+
expect(result.basicInformation).to.have.property("vendorName");
85+
});
86+
});
87+
88+
describe("Endpoint.getStateOf on a client endpoint", () => {
89+
before(() => {
90+
MockTime.init();
91+
});
92+
93+
it("returns full behavior state when called with type only (Behavior.Type overload)", async () => {
94+
await using site = new MockSite();
95+
const { controller } = await site.addCommissionedPair();
96+
const peer = await subscribedPeer(controller, "peer1");
97+
98+
const result = (await asEndpoint(peer).getStateOf(BasicInformationClient)) as unknown as Record<
99+
string,
100+
unknown
101+
>;
102+
const cachedBi = (peer as unknown as { state: { basicInformation: Record<string, unknown> } }).state
103+
.basicInformation;
104+
expect(result.vendorName).to.equal(cachedBi.vendorName);
105+
expect(result.productName).to.equal(cachedBi.productName);
106+
});
107+
108+
it("returns selected attributes when called with a key list (Behavior.Type overload)", async () => {
109+
await using site = new MockSite();
110+
const { controller } = await site.addCommissionedPair();
111+
const peer = await subscribedPeer(controller, "peer1");
112+
113+
const result = (await asEndpoint(peer).getStateOf(BasicInformationClient, [
114+
"vendorName",
115+
"productName",
116+
])) as unknown as Record<string, unknown>;
117+
expect(Object.keys(result).sort()).to.deep.equal(["productName", "vendorName"]);
118+
const cachedBi = (peer as unknown as { state: { basicInformation: Record<string, unknown> } }).state
119+
.basicInformation;
120+
expect(result.vendorName).to.equal(cachedBi.vendorName);
121+
expect(result.productName).to.equal(cachedBi.productName);
122+
});
123+
124+
it("supports the string-id overload", async () => {
125+
await using site = new MockSite();
126+
const { controller } = await site.addCommissionedPair();
127+
const peer = await subscribedPeer(controller, "peer1");
128+
129+
const result = (await asEndpoint(peer).getStateOf("basicInformation", ["vendorName"])) as Record<
130+
string,
131+
unknown
132+
>;
133+
expect(Object.keys(result)).to.deep.equal(["vendorName"]);
134+
const cachedBi = (peer as unknown as { state: { basicInformation: Record<string, unknown> } }).state
135+
.basicInformation;
136+
expect(result.vendorName).to.equal(cachedBi.vendorName);
137+
});
138+
139+
it("returns {} when called with an empty attribute list", async () => {
140+
await using site = new MockSite();
141+
const { controller } = await site.addCommissionedPair();
142+
const peer = await subscribedPeer(controller, "peer1");
143+
144+
const result = await asEndpoint(peer).getStateOf("basicInformation", []);
145+
expect(result).to.deep.equal({});
146+
});
147+
148+
it("throws EndpointBehaviorNotPresentError for an unknown behavior id", async () => {
149+
await using site = new MockSite();
150+
const { controller } = await site.addCommissionedPair();
151+
const peer = await subscribedPeer(controller, "peer1");
152+
153+
let threw = false;
154+
try {
155+
await asEndpoint(peer).getStateOf("unknownBehaviorXyz");
156+
} catch (e) {
157+
threw = true;
158+
expect(e).to.be.instanceof(EndpointBehaviorNotPresentError);
159+
}
160+
expect(threw).to.be.true;
161+
});
162+
163+
// ClientNode behaviors are added dynamically by ClientStructure after commissioning, so they
164+
// are not part of `ClientNode.RootEndpoint`'s static behavior map. The Behavior.Type overload of
165+
// `getStateOf()` must therefore accept any `Behavior.Type` (not only `BehaviorOf<T>`) so callers
166+
// can pass cluster behaviors like `OperationalCredentialsClient` directly without an `asEndpoint`
167+
// widening cast.
168+
it("accepts a dynamically-added behavior class without a widening cast (typed call)", async () => {
169+
await using site = new MockSite();
170+
const { controller } = await site.addCommissionedPair();
171+
const peer = await subscribedPeer(controller, "peer1");
172+
173+
const result = (await peer.getStateOf(OperationalCredentialsClient, ["fabrics"])) as unknown as Record<
174+
string,
175+
unknown
176+
>;
177+
expect(Object.keys(result)).to.deep.equal(["fabrics"]);
178+
expect(Array.isArray(result.fabrics)).to.be.true;
179+
});
66180
});

packages/node/test/endpoint/EndpointGetServerTest.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import { BasicInformationServer } from "#behaviors/basic-information";
78
import { AttributeNotPresentError, EndpointBehaviorNotPresentError } from "#endpoint/errors.js";
89
import { MockServerNode } from "../node/mock-server-node.js";
910

@@ -78,3 +79,76 @@ describe("Endpoint.get on a server endpoint", () => {
7879
expect(threw).to.be.true;
7980
});
8081
});
82+
83+
describe("Endpoint.getStateOf on a server endpoint", () => {
84+
let node: MockServerNode;
85+
86+
beforeEach(async () => {
87+
node = new MockServerNode();
88+
await node.construction;
89+
});
90+
91+
afterEach(async () => {
92+
await node.close();
93+
});
94+
95+
it("returns full behavior state when called with type only", async () => {
96+
const result = (await node.getStateOf(BasicInformationServer)) as unknown as Record<string, unknown>;
97+
expect(result).to.be.an("object");
98+
expect(result).to.have.property("vendorId");
99+
expect(result).to.have.property("vendorName");
100+
const state = node.state.basicInformation as unknown as Record<string, unknown>;
101+
expect(result.vendorId).to.equal(state.vendorId);
102+
});
103+
104+
it("returns full behavior state when called with selector=true", async () => {
105+
const result = (await node.getStateOf(BasicInformationServer, true)) as unknown as Record<string, unknown>;
106+
expect(result).to.be.an("object");
107+
expect(result).to.have.property("vendorName");
108+
});
109+
110+
it("returns only requested attributes when given an attribute list", async () => {
111+
const result = (await node.getStateOf(BasicInformationServer, ["vendorId", "vendorName"])) as Record<
112+
string,
113+
unknown
114+
>;
115+
expect(Object.keys(result).sort()).to.deep.equal(["vendorId", "vendorName"]);
116+
const state = node.state.basicInformation as unknown as Record<string, unknown>;
117+
expect(result.vendorId).to.equal(state.vendorId);
118+
expect(result.vendorName).to.equal(state.vendorName);
119+
});
120+
121+
it("returns {} when called with an empty attribute list", async () => {
122+
const result = await node.getStateOf(BasicInformationServer, []);
123+
expect(result).to.deep.equal({});
124+
});
125+
126+
it("supports the string-id overload", async () => {
127+
const result = (await node.getStateOf("basicInformation", ["vendorName"])) as Record<string, unknown>;
128+
expect(Object.keys(result)).to.deep.equal(["vendorName"]);
129+
const state = node.state.basicInformation as unknown as Record<string, unknown>;
130+
expect(result.vendorName).to.equal(state.vendorName);
131+
});
132+
133+
it("throws EndpointBehaviorNotPresentError for an unknown behavior id", async () => {
134+
let threw = false;
135+
try {
136+
await node.getStateOf("unknownBehaviorXyz");
137+
} catch (e) {
138+
threw = true;
139+
expect(e).to.be.instanceof(EndpointBehaviorNotPresentError);
140+
}
141+
expect(threw).to.be.true;
142+
});
143+
144+
it("throws AttributeNotPresentError for an unknown attribute on the string overload", async () => {
145+
let threw = false;
146+
try {
147+
await node.getStateOf("basicInformation", ["nonExistentAttribute"]);
148+
} catch (e) {
149+
threw = true;
150+
expect(e).to.be.instanceof(AttributeNotPresentError);
151+
}
152+
expect(threw).to.be.true;
153+
});
154+
});

packages/node/test/endpoint/EndpointGetTypesTest.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
BehaviorAt,
1010
BehaviorOf,
1111
BehaviorSelection,
12+
Endpoint,
1213
RawBehaviorSelection,
1314
StateSelector,
1415
StateSliceOf,
@@ -120,5 +121,39 @@ describe("Endpoint get type helpers", () => {
120121
fake: Immutable<Partial<Pick<Behavior.StateOf<FakeBehaviorType>, "value">>>;
121122
};
122123
void _checkPickSlice;
124+
125+
// getStateOf overload return types — assert each overload resolves to the documented shape.
126+
// The `(false as boolean) === true` guard keeps the body type-checked but unreachable at
127+
// runtime, so the phantom `_ep`/`_fakeBeh` values are never dereferenced. The cast through
128+
// `boolean` is required to defeat TypeScript's constant-condition unreachable-code check
129+
// that a literal `if (false)` would trigger.
130+
if ((false as boolean) === true) {
131+
const _ep = null as unknown as Endpoint<TestEndpoint>;
132+
const _fakeBeh = null as unknown as FakeBehaviorType;
133+
134+
// Overload 1: (B) and (B, true) → Promise<Behavior.StateOf<B>>
135+
const _stateOfNoSelector: Promise<Behavior.StateOf<FakeBehaviorType>> = _ep.getStateOf(_fakeBeh);
136+
void _stateOfNoSelector;
137+
const _stateOfTrue: Promise<Behavior.StateOf<FakeBehaviorType>> = _ep.getStateOf(_fakeBeh, true);
138+
void _stateOfTrue;
139+
140+
// Overload 2: (B, K[]) → Promise<{ readonly [P in K]?: Behavior.StateOf<B>[P] }>.
141+
// Each selected key is optional because partial-state-on-failure may omit it.
142+
// Use the bidirectional `_AssertEqual` helper so the assertion fails if the resolved
143+
// shape gains or loses properties (a one-sided assignability check would not catch
144+
// an extra optional property creeping into the return type).
145+
const _stateOfKeys = _ep.getStateOf(_fakeBeh, ["value"] as const);
146+
const _checkKeysExact: _AssertEqual<Awaited<typeof _stateOfKeys>, { readonly value?: number }> = true;
147+
void _checkKeysExact;
148+
149+
// Overload 2 must reject keys not in the behavior's State.
150+
// @ts-expect-error - "missing" is not a key of FakeState
151+
const _stateOfBadKey = _ep.getStateOf(_fakeBeh, ["missing"] as const);
152+
void _stateOfBadKey;
153+
154+
// Overload 3: (string, readonly string[]) → Promise<Val.Struct>. Accepts arbitrary string keys.
155+
const _stateOfStringId = _ep.getStateOf("anyId", ["foo", "bar"]);
156+
void _stateOfStringId;
157+
}
123158
});
124159
});

0 commit comments

Comments
 (0)