Client test benches
Oda's source ships two benches for testing its interface: a slot bench that renders one part of the interface in isolation, and a whole-client bench that starts the web interface's client plugin set with every call to the host side answered by a mock you script. Both are developer tooling. To choose between them and the other test tiers, see Testing tiers.
The slot bench
The slot bench assembles the real rendering machinery around test-owned doubles: the app's slot renderer, test-owned session and workspace services and a file-upload stub. It exists to prove one feature's interface in isolation.
- Assemble, mount, dispose. A bench is created from its runtime, mounts one feature plugin at a time, and disposes everything together at the end: rendered views, mounted features, the root registration and the test session scopes. Its own mutations are already wrapped for React, so a test does not handle batching itself.
- Mounting checks first. A mount resolves the services the feature declares and checks them before mounting, so a missing service is named instead of leaving the mount suspended. To test what a deliberately delayed service does, mount directly on the context instead.
- Declare, then render. Rendering a slot key needs that key declared on the bench first. The returned view is the renderer's own anchor for the slot: queries are scoped to it, and its update call replaces the owner props. A bench has one declaration, and the root slot accepts one.
- Stores come from the renderer. The store lookup resolves the live store instance the renderer hands the slot's component. It works only after a render root, because before that the host face does not exist; it also refuses for a key with no registration, for an entry that declares no store, and for a session-scope store with no live session binding.
- File upload rejects until replaced. The bench provides a stub whose upload function rejects every call; replace it in a suite that exercises uploads.
- A DOM test runs in the DOM environment. The bench renders real components, so a test that mounts them runs in the jsdom test environment; a mounting test without one fails and says which annotation adds it.
The whole-client bench
The whole-client bench starts the interface the way the app starts it. It loads the client plugin set's modules in the test process and never fetches bundles, replaces the row that owns the connection with a mock transport so every remote call is answered by your mock, and boots through the same boot the app uses.
- Remote namespaces become proxies. The bench drops the generated remote-client row and provides instead one proxy per namespace over the same connection; a namespace gets a proxy when a client plugin declares it or when the mock has a rule for one of its endpoints.
Start takes these options, and repeated start calls share one promise, so asking twice boots one client:
| Option | Default | What it changes |
|---|
mount | false | true mounts the interface into a fresh element in the page body; pass your own element to place it yourself. |
awaitConnected | true | false returns as soon as the client is up, without waiting for the connection. |
connectTimeoutMs | 5000 | The readiness budget: how long the start waits for the connection before failing. |
- A failed start names everything. When the readiness wait runs out, the start fails and names the connection's state, the budget it waited, the calls that found no answer and the streams that were still open.
- Teardown disposes, then checks. Teardown disposes the plugin tree, drops a mount it created and releases the page globals it added, then checks for unanswered calls last, so a missing answer fails the test without skipping the cleanup. When the tree and the check both fail, one error carries both messages.
Which client plugins it starts
The whole-client bench reads the web interface's plugin set from the bundles the app composes, in order: the base layer first, then the layer that adds the web application. The set is composed the way the app composes it, so a bundle change is visible at the next import; nothing is copied.
- A row joins the set when its package is enabled and targets the web platform; the row carries the services it declares and whether it loads immediately.
- Groups count as the app counts them: a group switched off disables every row beneath it, and duplicate names are refused.
- A browser row whose switched-off marker is an expression the reader cannot evaluate is refused.
- A patch that matches nothing fails here, where the app itself only warns.
- The web set differs from the desktop set: the plugin that adds message feedback is in the web set and not the desktop one, and the sidebar browser is in the desktop set and not the web one.
The loading model behind the set is on How client plugins load.
What the mock answers at startup
The mock is loaded with answers for the calls the interface makes while it starts, so a fresh client boots and renders with no sessions, no workspaces and default settings. A spec layers its own answers on top of this table.
| While starting, the client asks for | The mock answers |
|---|
| Its chat list | An empty list. |
| The default workspace setup | Success, with first-use initialisation ineligible. |
| Its settings description | Writable, with no document and no namespaces. |
| The model catalogue | A default model entry. |
| Its preset list | Empty. |
| The account state | Signed out. |
| The follow stream | Declared, so a spec that forgets to script it sees a stream miss rather than a unary one. |
One stream is built into the mock itself: the events stream, which answers every opening with one ready frame that names the client and a host, then stays open. That frame is what lets the assembled client reach connected. Endpoints the startup never touches stay absent, so a new call fails loudly instead of answering nothing.
Scripting the mock
- Rules are named by endpoint. Each rule names one endpoint, a namespace and a method. A unary rule is a default answer returned exactly as given, or a handler that computes the answer from the caller's arguments; a rule that throws or rejects fails the call with that error.
- The envelopes. A success answer is the envelope
{ ok: true, value }; a failure carries { ok: false, error } with a code, a message and details.
- Streams. Declaring an endpoint as a stream lets a script answer its openings: the script receives the open arguments and a handle, pushes items, and the stream stays open after the script returns until the test ends or fails it. A script that throws fails the stream; a declared stream without a script fails an opening as a stream miss.
- Control from the test. Pushing an item to every open stream of an endpoint, ending them and failing them each report how many streams changed. One helper resolves once an endpoint has been opened a given number of times; another resolves once the consumer of every matching stream has pulled everything pushed and is waiting for more. A stream nobody reads never drains.
- An endpoint and its mode own their own mock. Declare an endpoint as a stream before the code under test keeps a reference to its method; each endpoint and mode has its own mock.
- Clearing and resetting. Clearing the mock keeps its answers and queued replies; resetting it drops overrides and restores the initial implementation, which reads the latest registered answer.
- The log records everything. Calls record as
pending, answered or failed; streams as open, ended, failed or cancelled; a request that found no rule records as a miss with its mode, unary or stream. One counter orders calls and stream openings together.
- The carrier face. The same mock answers real connection traffic: a call takes its arguments as an array or as a single object, and opening a stream hands the script the uplink while keeping it out of the recorded arguments.
Failures you will meet
- A call with no rule fails with
no rule for <endpoint> followed by the registered endpoints, and the miss is recorded; teardown then reports the count of unmatched requests with their modes and the registered list.
- A malformed call payload, neither an arguments array nor a single object, is refused.
- An aborted call rejects with
call aborted, or with the abort reason when one was carried.
- A second concurrent read on one stream rejects with
stream has one consumer; a consumer that stops early ends the stream without throwing and records it as cancelled.
- The uplink. A send after the uplink ended is refused with
uplink was ended; an item that is not a lossless JSON value is refused; and a handle on a stream the carrier opened does not own the uplink.
- A plugin set with no connection cannot be waited on: the failure names the missing service.
- A start saved past its test is refused: a start call made after its fixture closed does not run.
Limits
- The mock is an in-process carrier only: values cross by reference and are never checked, and rules match on the endpoint, never on the payload.
- A stream a test takes over itself owns its own iterable and stays outside the mock's stream control and drain helpers.
- An endpoint not declared as a stream is dispatched as a unary call, so a forgotten stream declaration shows as a unary miss.
- The whole-client bench skips the generated remote clients: calls go through its proxies with no wire-level validation or name mapping, and a unary rejection comes back folded the way the client folds a carrier failure (the standard codes
gateway/internal, or gateway/cancelled once the caller's signal was aborted). Assert on the code, never on the error class.
- Two clients in one test process keep separate mocks; the page globals the bench adds are shared and are removed only when the last client disposes.
Questions, answered
Why does mounting a feature report a missing service?
The bench checks the services a feature declares before it mounts, so a missing one is named instead of leaving the mount suspended. Provide the service on the bench first, or stub it. To test what a delayed service does, mount directly on the context.
Why does rendering a slot key refuse until I declare it?
Rendering goes through the bench's frame, which must declare, and thereby authorize, the keys it will render. Declare the key first, or declare a custom frame for your own structure. A bench has one declaration.
Why does the store lookup refuse before I render the root?
The host face a store belongs to exists only inside the installed renderer, so resolve the store after rendering a root. It also refuses for a key with no registration and for an entry that declares no store.
Why is file upload refused in my test?
The bench provides a stub whose upload function rejects every call until a suite replaces it. Replace the upload function in tests that exercise uploads.
Why did the whole-client start fail with a connection-state message?
The start waited for the connection and the wait ran out, 5 seconds by default. The message names the connection's state, the budget, the calls that found no answer and the streams still open, so the usual fix is a rule for a call the startup makes; otherwise give the wait a longer budget.
Why does teardown report unmatched requests?
A call found no rule while the client ran. Teardown checks for unanswered calls last, on purpose, so cleanup still happened; add the missing rule or expect the call. The report names each request's mode and the registered endpoints.
How do I keep a stream under the test's control?
Declare the endpoint as a stream and register a script for its openings. The stream stays open after the script returns until the test ends or fails it; pushing, ending and failing report how many open streams changed; and the wait helpers let you wait for an opening or for a consumer to catch up.
Which client plugins does the whole-client bench start?
The web interface's set, composed from its bundles the same way the app composes it: enabled rows whose package targets the web platform, with a group switched off disabling everything beneath it. The set differs from the desktop one; How client plugins load covers the loading model.
Can two whole-client clients run in the same test process?
Yes. Each start loads its own modules and binds its own mock, so two clients keep separate mocks; the page globals the bench adds are reference counted and go away when the last client disposes.
What is the difference between clearing the mock and resetting it?
Clearing keeps the registered answers and queued replies, so a later call still gets them. Resetting drops overrides and restores the initial implementation, which reads the latest registered answer.