A Target names an element an automation addresses. The automation depends on the name. The ways to find the element
live beside the name, in targets/, in a fixed priority order. This page explains why that split exists and how
resolution behaves.
#Why elements are named
An automation never holds a selector. orders.submit is a name. The buttons that could satisfy it are the Target's
own business. The reason is maintenance. A selector copied into a step breaks the first time the page is rebuilt.
The break stays invisible until a run fails. A name plus a short list of locators keeps the fragile part in one file a
reviewer can read. It also lets the same name serve many transactions.
orders.openOrder shows the second half of that. It is one Target, resolved with per-use arguments.
target('orders.openOrder', {
description: 'Row link that opens an order from the pending-orders table',
strategies: [
strategy(
'Orders table row link named by the order id (args.id)',
(page, args) => page.getByRole('link', { name: args?.id ?? '' }),
),
],
});
The automation asks for orders.openOrder with { args: { id: tx.id } } and gets the link for that order. One name,
many rows.
#The ordered fallbacks
A Target holds strategies, an ordered list. Each strategy is a reason and a locator function. The resolver tries them
strictly in the order an author wrote them. It probes a strategy by waiting for the element to be visible, 2000 ms by
default, then moves to the next on timeout.
orders.submit carries three strategies on purpose. It matches "Submit Order" first, falls back to a plain "Submit"
that the demo renders for one order and finally to the app team's data-testid hook. The first that is visible wins.
No fallback is synthesised at run time: a strategy exists because a person wrote it and gave a reason.
target('orders.submit', {
description: 'Submit button on the order detail form',
strategies: [
strategy('Primary action button "Submit Order"', (page) => page.getByRole('button', { name: 'Submit Order' })),
strategy('Variant page rendering plain "Submit"', (page) => page.getByRole('button', { name: 'Submit' })),
strategy('Stable data-testid hook owned by the app team', (page) => page.getByTestId('submit-order')),
],
});
#Determinism and observability
Resolution is deterministic in the sense that matters: the same page state yields the same match, because the order is
authored and the probes are fixed. It is observable because the result carries which priority matched and the reason
given for it. useTarget writes that into the run log.
const submit = await useTarget(ctx, 'orders.submit');
// matchedPriority: 2, strategyLabel: 'Variant page rendering plain "Submit"'
When nothing matches, the resolver throws TargetNotFoundError listing every authored strategy and its reason.
It says that TaskSultan never invents selectors. That refusal is the useful part: it tells a reader which definitions were
tried, not only that a click failed.
#Desktop targets are a different shape
A web locator is an expression Playwright evaluates against a live page. A desktop locator is not. It is a fact the
Recorder measured and proved: { controlType, name, automationId, within }, addressed through Windows UI Automation.
Desktop target files use createDesktopTargetRegistry. A definition carrying no name, no automationId and no
anchor relation is refused at build time, because the only search it could make is the whole desktop.
#Things that catch people out
- A new target does nothing until its module is registered in
targets/index.ts. Registering the same id twice throws. useTargetthrows when the element is absent. When absence is a legitimate answer, a state probe, useuseTargetIfPresentinstead: it returnsnulland records the negative result.- The default probe is 2000 ms per strategy. A page that takes longer than that to render will fail all strategies and look like a bad Target.