Summary#
A control's contract points three ways: props in, events out, commands in. All three are for the app — they are how it drives the control and hears back from it. None of them answers the question a test asks: is the document dirty right now? how many matches did the search find? what is selected?
Those facts reach the screen, if at all, as pixels — a dot beside a filename, a tinted range inside a contenteditable, a count the app happened to choose to render. A test could only assert them by reading rendered text, which measures the control's styling rather than its behaviour and breaks the day somebody edits a label. For a control that paints into a canvas, or one running under a headless DOM that lays nothing out, there is no text to read at all.
A probe { } block is the control author's answer: a small, typed surface of facts the control publishes about
itself, asserted with Assert.Probe.
This is how you test a control you did not write. The alternative is reverse-engineering somebody else's DOM and coupling every assertion to internals their next release is free to change.
The block is optional, and a control that declares none is unaffected.
Signature#
control <Name> {
probe {
<ScalarType> <fieldName>;
…
}
}Assert.Probe("<ControlName>", "<fieldName>", <expected>);Description#
Declaring what a test may read — the probe block#
control MarkdownEditor {
contractVersion "1.1"
participation headless
props { string title; }
events { dirty(bool isDirty); }
probe {
/// Unsaved edits are pending.
bool dirty;
/// How many find-hits the document currently holds.
int matches;
/// What is highlighted right now, or nothing when the selection is empty.
string? selection;
}
}A field's type is a comparable scalar — bool, int, long, double, decimal, string, and their nullable
forms. An assertion compares one value against one expected value, so a shape or an array has nothing it could be
compared to; a control that wants to publish a structure publishes the parts of it a test can name, one field each.
A default is refused. A prop has a resting value because the app may decline to set one. A probe field is what is true at the instant it is read, so a default would be a value the control reports while stating nothing — and every assertion against it would pass, including against a shim that never answers at all.
A probe is not an event#
dirty above is both an event and a probe field, and that is not a duplication.
An event is "this just changed". A probe is "this is true now". A test that had to listen for a notification in order to learn a resting fact could not ask the question at all until the control happened to change its mind — so a page that opens with unsaved work, or a search already showing results, would be unassertable.
Publish as an event what the app must react to; publish as a probe what somebody may need to ask.
The shim must implement it#
The block generates a probe() method on what mount RETURNS, typed from the declaration:
export interface MarkdownEditorHandle extends ControlHandle<MarkdownEditorProps> {
probe(): {
dirty: boolean;
matches: number;
selection?: string | null;
};
}export function mount(el, props, host) {
return {
update(next) { … },
destroy() { … },
probe() {
return {
dirty,
matches: findState.hits.length,
selection: selected.length > 0 ? selected : null,
};
},
};
}Three properties are part of the contract:
- It is a METHOD, never a property. A probe is what is true at the instant it is read. A property would be read once at mount and then handed out as a snapshot that stopped being true minutes ago.
- It must not change anything. Read from live state; do not dispatch, do not move a selection, do not save. A probe that perturbed what it was measuring would prove nothing about it.
- It must be cheap. It is called on demand, once per assertion.
The platform checks it when it takes the handle — at the mount that produced it, exactly as it checks update,
destroy and the command bag. A control that declares a probe { } block and returns no probe()
fails there, naming the fields it published. The alternative is that the shortfall is found by whoever first asks,
which is a test, whose refusal then reads as "this control reports nothing" — a sentence about the control's
behaviour, for a defect in its handle.
Asserting on a probe in a UI test#
Ui.Visit("/doc");
Ui.Fill("Body", "some new text");
Assert.Probe("MarkdownEditor", "dirty", true);
Assert.Probe("MarkdownEditor", "matches", 3);
Assert.Probe("MarkdownEditor", "selection", null);Three operands — the control, the field, the expected value — the same shape as
Assert.Cell(row, column, expected).
The control is named by its declared name, which is what the app wrote at the call site. A mount element carries
no accessible name of its own, so when a page renders the same control twice they are told apart by
within:.
null means the control reports nothing for this field, which is a different statement from the empty string: a
caret sitting in a document selects nothing at all, and an assertion for "" must not pass on it.
Each way it can fail says something different, because each has a different fix:
| What is wrong | What you are told |
|---|---|
| No such control is mounted | Which controls are mounted here |
The control implements no probe() | That its shim owes one — not that the field is misspelled |
| It does not report that field | The fields it does report |
| It reports a different value | What it actually said |
Two instances of one control#
A page may legitimately render the same control twice. within: narrows by which container each one mounted into —
the same argument every other UI assertion takes:
Assert.Probe("MarkdownEditor", "dirty", true, within: "Draft");
Assert.Probe("MarkdownEditor", "dirty", false, within: "Published");Without a scope, two mounted instances are an ambiguity and are refused rather than resolved by picking the first — which would answer a question nobody asked, and differently depending on render order.
Which facts belong in a probe?#
Publish what a test needs and a screen does not carry. A probe field is part of the contract like everything else in the block, so it can be relied on and versioned — and it is equally a decision to leave something out.
Prefer a prop for something the app sets, an event for something it must react to, a command for something it asks for, and a probe field for something it (or its tests) may need to know.
Do not mirror the props back: the app already has those values, and reporting them says nothing about whether the
control did anything with them. dirty is worth publishing precisely because nothing outside the control knows it.
Examples#
A chart that reports what it actually drew — the classic case, because a canvas has no DOM to read:
control LineChart {
contractVersion "1.1"
participation opaque
props { string title; }
probe {
/// How many series were plotted after filtering.
int seriesDrawn;
/// The point the pointer is currently over, or nothing.
string? hoveredPoint;
/// The chart finished its entry animation and is at rest.
bool settled;
}
}Nothing here is visible to a DOM query: the chart is one <canvas>. Without a probe, the only assertable fact about
it is that the element exists — which is true of a chart that drew nothing.
See also#
- control — foreign UI controls (charts, grids, maps) — the
controlblock these are declared in - commands — the verbs a control accepts — the verbs a control accepts, the mirror of its events
- Ui — drive the app's UI from a test — the UI test surface
Assert.Probebelongs to