Testing against an in-process controller¶
A host maps its own device model onto Matter: which data point becomes
which attribute, which product becomes which device type. That mapping is
the host's code, and the place it breaks is the wire — a controller reads
an attribute the host never fills, or a command the host routes nowhere.
fabrictest
tests it there: it commissions a node of this module with the module's own
controller, in the test process, and lets the test read, write, invoke and
subscribe over the wire — with no chip-tool, no radio and no socket.
The package follows matter.js's testing support: Site is matter.js's
MockSite, a device exposes what MockServerNode exposes, the wire
helpers follow the interaction namespace of node-helpers.ts, and the
default clock is a clocktest.Fake, matter.js's MockTime.
A first test¶
func TestPlugToggles(t *testing.T) {
site := fabrictest.NewSite(t, fabrictest.Options{})
plug, err := device.NewOnOffPlugInUnit(device.Options{
StableKey: endpoint.StringKey("plug:1"), Name: "plug", Clock: site.Clock(),
})
if err != nil {
t.Fatal(err)
}
pair := site.CommissionedPair(fabrictest.PairOptions{
Device: fabrictest.Topology{Bridged: []node.Endpoint{plug}},
})
ep, _ := pair.Device.EndpointOf("plug:1")
ctx := context.Background()
toggle := im.ConcreteCommandPath{Endpoint: ep, Cluster: onoff.ClusterID, Command: onoff.CmdToggle}
if _, err := fabrictest.Invoke(ctx, pair, toggle, nil); err != nil {
t.Fatal(err)
}
onOff := im.ConcreteAttributePath{Endpoint: ep, Cluster: onoff.ClusterID, Attribute: onoff.AttrOnOff,
HasEndpoint: true, HasCluster: true, HasAttribute: true}
got, err := fabrictest.Read(ctx, pair, onOff)
if err != nil || !got.Value.El.Bool {
t.Fatalf("OnOff %+v, %v", got, err)
}
}
CommissionedPair adds a controller and a device to the site and runs the
controller's commissioning flow against the device: PASE, the
GeneralCommissioning and OperationalCredentials commands, CASE over the
device's operational record, CommissioningComplete. The pair's Peer holds
the CASE session and its Interaction speaks over it. The site closes every
host when the test ends.
Source: fabrictest/controller.go (AddController, Commission,
CommissionedPair), fabrictest/device.go (AddDevice, Topology),
fabrictest/guide_test.go (this test, as it runs), fabrictest/site_test.go,
fabrictest/clock_test.go.
What a site runs¶
| Part | What it is | Accessors |
|---|---|---|
| Device | a node.Node serving the Topology: its Bridged endpoints under the node's own Aggregator, or a host Snapshotter (node.Config.Topology). Its credential and endpoint stores are SQLite files in the test's temporary directory; its advertiser is an mdns.Noop unless Configure sets one. Passcode 20202021, discriminator 3840 |
Node, Bridge, Store, Endpoints, EndpointOf, Addr, Advertiser, Act |
| Controller | the module's controller stack: exchange manager, DNS-SD cache, certificate authority, one fabric (id 1 and node id 112233 unless ControllerOptions say otherwise), peer set, subscription registry, commissioner with its CASE responder, client node registry |
Exchanges, Names, CA, Fabric, Peers, Subscriptions, Commissioner, Registry |
| Network | datagrams between the hosts over channels, addressed like UDP: host n (counted from 1 in the order the site adds them) is [fe80::n]:5540, n in hex |
Site.Net |
| DNS-SD | the site answers each controller's queries from every running device's advertiser — the commissionable record while a window is open, an operational record per fabric — with the device's address | — |
Topology.Configure edits the node's configuration before node.New: a
passcode, a product name, an OTA requestor. fabrictest.Endpoint(key,
deviceType, servers...) is a bridged endpoint for Topology.Bridged that
serves cluster servers the test built itself — a feature selection or an
initial state no device-layer constructor offers; Device.EndpointOf(key)
finds its number. Both hosts run the production
code: the device's bridge serves the production transport/udp.Listener
over a socket of the in-memory network, so every datagram it receives,
answers or initiates takes the path a kernel socket would.
Pair.Node returns the controller's client node of the device
(client.Registry.ForAddress): bound to the peer, neither started nor
subscribed.
Source: fabrictest/device.go (config, Start), fabrictest/endpoint.go, fabrictest/controller.go
(start), fabrictest/net.go, fabrictest/mdns.go,
internal/udpseam/udpseam.go, bridge/seam.go (UseListener).
The wire helpers¶
| Helper | Request | Result |
|---|---|---|
Connect |
a CASE session when the peer holds none | the pair's interaction |
Read |
one attribute, fabric-filtered | its AttributeData |
ReadWith |
any interaction.ReadRequest (wildcards, events, filters) |
the whole report |
Events |
event paths | the events in report order |
Write, WriteTimed |
one attribute; timed sends a TimedRequest first | nil |
Invoke, InvokeTimed |
one command | the interaction.Result |
Subscribe |
an interaction.SubscribeRequest |
the subscription, once the device answered |
Exchange |
one IM message as it stands (opcode, TLV payload) — a request the controller refuses to formulate | the device's first answer |
Send |
the same | nothing; for a request the device answers with nothing |
Three helpers shape the pair the requests go over. Pair.Grant(ctx,
privilege) replaces the device's access control list on the pair's fabric
with one CASE entry for the controller at that privilege — a View or
Operate subject, as matter.js's MockServerNode.online({ accessLevel }).
Controller.PASE(ctx, device) opens a PASE session to a device in its
commissioning window and returns a Pair whose Interaction speaks over
Pair.PASESession — the session without fabric of a commissioner before
AddNOC; the wire helpers accept it. Device.OpenWindow(ctx, timeout) opens
a basic commissioning window with the device's own passcode, so a second
controller commissions it onto another fabric.
A status other than Success is an *interaction.StatusError: the IM status,
the cluster status when the device sent one, and the attribute path for an
attribute. An uncommissioned pair returns fabrictest.ErrNotCommissioned.
Source: fabrictest/wire.go, fabrictest/raw.go, fabrictest/access.go,
fabrictest/pase.go, fabrictest/window.go.
Client nodes¶
Site.CommissionedPair commissions with the commissioner alone; the
controller's client node registry holds nothing until a test asks
(Pair.Node). Controller.CommissionNode(ctx, device, opts) commissions as
matter.js's controller.peers.commission does: the device is discovered by
its long discriminator, the registry's node for it is commissioned with
client.CommissionOptions and started. Controller.Discover(ctx, id,
timeout) is the discovery alone, the timeout on the site's clock.
ControllerOptions.Registry edits the registry's configuration — hooks,
AutoStart, a store — before the registry is built, on every start;
ControllerOptions.Attestation sets the commissioner's trust store and
NoAttestationStore commissions without one. The registry itself is not
started: a test that wants AutoStart calls Registry().Start.
Source: fabrictest/node.go, fabrictest/controller.go.
Time¶
A site's default clock is a clocktest.Fake starting at
fabrictest.DefaultStart, and every device and controller of the site runs
on it: the device's ACK pump, MRP, subscription intervals, commissioning
window, persist loop, its cluster servers' timers — including the bridged
endpoints' and the Aggregator's Identify — and the controller's MRP, peer
connection, subscription liveness and registry. A device the host builds
with the device layer joins it through device.Options.Clock, as in the
first test above.
The fake moves only when the test moves it, or inside Site.Run and
Site.Eventually, which every wire helper and Commission use. While the
work has not finished they advance the clock to its next deadline each time
the site has been quiet for the settle period (Options.Settle, default
10 ms): no datagram sent or queued on the network, and no goroutine of the
test process runnable, running or in a system call. A timer the work waits
on — a standalone acknowledgement, a retransmission, a report interval —
then fires; a computation in progress does not see time move under it.
// A change made locally reaches the subscriber once the clock passes the
// subscription's floor.
pair.Device.Act(func(*node.Node) error { plug.OnOff().Set(true); return nil })
err := site.Eventually(ctx, "the report", func() bool { return reported() })
A test that steps the clock itself calls site.Fake().Advance. A ticker
drops a tick its receiver has not taken, as a real one does, so a countdown
on a ticker (Identify) is stepped one period at a time. A site given
Options{Clock: clock.System{}} runs on the wall clock, and Run only runs
the work.
The controller's DNS-SD cache (discovery.Names) keeps its record TTLs on
the wall clock; the site's answers carry 120 s and 4500 s, longer than a
test.
Source: fabrictest/site.go (Run, Eventually, Settle, busy),
node/config.go (Config.Clock), device/options.go (Options.Clock),
endpoint/assembler.go (Config.Clock), node/root.go
(buildAggregatorClusters), controller/discovery/names.go (newNames).
Shaping the network¶
site.Net().Set(fabrictest.NetOptions{Loss: 0.2, Delay: 50 * time.Millisecond, Duplicate: 0.1, Seed: 7})
site.Net().Filter(func(d fabrictest.Datagram) bool { return !dropThisOne(d) })
Loss and Duplicate are probabilities, drawn from Seed; Delay is time
in flight on the site's clock, so a delayed datagram arrives when the clock
passes it. A filter sees every datagram first and drops it by returning
false. With every datagram lost, a read fails once the controller's
retransmissions run out — on the fake clock, so the test does not wait the
retransmission budget in real time.
Source: fabrictest/net.go (NetOptions, Set, Filter, send),
fabrictest/site_test.go (TestNetworkLossAndDelay).
Restart¶
Device.Restart stops the node and starts a new one on the same store and
address, serving the same Topology (the same cluster servers). What the
node persisted survives: fabrics, ACL, subscriptions, endpoint numbers. A
device re-establishing a persisted subscription opens CASE to the
controller, which the site resolves for it, and the controller's CASE
responder adopts the session.
Controller.Restart stops the controller's stack and starts it again on its
stores — the certificate authority, the fabric, the CASE resumption records
and the client node registry's store — so the registry restores its nodes,
as matter.js's controller restarted on its storage. A pair's Peer and
Interaction belong to the stopped stack; after a restart a test uses the
registry's nodes. On loopback sockets the controller gets a new port.
Source: fabrictest/device.go (Restart, siteResolver),
fabrictest/controller.go (Restart),
controller/interaction/live_test.go (TestLiveSustainedSurvivesRestart),
controller/client/live_test.go (TestLiveClientNode).
Sockets¶
Options{Loopback: true} puts every host on a real UDP socket on [::1]
with an ephemeral port; the site still answers DNS-SD from the devices'
advertisers. A test that drives controller parts of its own against a device
of the site — its own exchange manager, a peer set — uses it, often with the
wall clock; Topology.Configure may set the device's listen address
(node.Network.Listen).
Limits¶
- The in-memory network carries UDP only. A device configured to accept TCP
(
node.Network.TCP.Incoming) fails to start on it; useLoopback. - A device is a
node.Node. A host that builds abridge.Bridgeitself tests it withbridgetest.Dial, which commissions over loopback UDP on the wall clock. - BLE commissioning has its own in-memory pair:
controller/ble/bletestwithbridgetest.ServeConn. Runreads goroutine states for the whole test process, so the work of a parallel test in the same binary delays a clock step until it is idle.- Devices carry the CSA test attestation chain, the controllers trust the CSA test PAAs, and every credential is fixed and public: the package is for tests.