merge origin/master into llm-recording-and-analysis

both sides independently fixed the same three bugs, so each one had to pick a
winner rather than keep both implementations.

extractor encoding: master's recordableValue in worker.go wins over ours in
marshal.go, since master's is pinned by extractor_encoding_test.go and ours had
no tests. our error semantics stay: encodeExtractorValue still returns an error
instead of nil, so an extractor cannot vanish from the trace silently.

apply errors: only the residual generic branch takes master's unconfirmed copy,
where the device may have committed the action before the call failed. the
finer branches that know nothing was dispatched keep lastAction = nil, and our
actionSkipReason taxonomy stays alongside master's held/skippedVerification.

selector matching: our matchAttr with matchSelectorKind wins over master's
match, since ours also handles idPrefix. matchSelector now calls it, which git
did not flag as a conflict and left calling a function our side had deleted.

the ltl doc comment takes master's correction: an unbounded eventually that
never fires IS violated at run end.
This commit is contained in:
pj committed 2026-08-16 18:10:55 +05:30
commit 6e85cac8b3
130 files changed
+12772 -1617

No files matched your search

+4
View File
@@ -4,6 +4,7 @@ export type {
Action,
ActionGenerator,
AttrSelector,
Direction,
DoubleTapAction,
EventuallyFormula,
ExceptionRecord,
@@ -12,11 +13,14 @@ export type {
InputTextAction,
Key,
KnownAttrSelectors,
LastAction,
LogEntry,
LongPressAction,
Point,
PressKeyAction,
RawAttrs,
Sampler,
ScrollAction,
SelectorPath,
Snapshots,
State,
+6 -5
View File
@@ -12,11 +12,12 @@ export function next(predicate: () => boolean): Formula {
return globalThis.__sanderling__.next(predicate);
}
// An unbounded `eventually` never forces a violation within a finite run.
// Prefer `.within(n, unit)` when you want the verifier to fail a property
// that stalls. `"steps"` counts observed steps rather than wall-clock time,
// which is what keeps the window the same size across runs of different
// speeds.
// An unbounded `eventually` that never fires is violated when the run ends,
// with the reason "eventually never satisfied", so a goal the run does not
// reach is a violation every time. `.within(n, unit)` convicts at the step the
// window closes instead of at run end. `"steps"` counts observed steps rather
// than wall-clock time, which is what keeps the window the same size across
// runs of different speeds.
export function eventually(predicate: () => boolean): EventuallyFormula {
return globalThis.__sanderling__.eventually(predicate);
}
+20 -1
View File
@@ -115,10 +115,29 @@ export interface ExceptionRecord {
unixMillis?: number;
}
/**
* The previous step's action as the runner reports it. `applied` is true when
* the runner saw the dispatch succeed and null when the apply call failed with
* the gesture possibly already delivered: an RPC deadline can fire after the
* tap landed. Null is unknown, not "it did not happen" (`state.lastAction` is
* itself null for that), so a property attributing an effect to this action
* has to decline unless `applied` is true.
*
* `relaunched` is true when the runner had to bring the app back to the
* foreground after this action, so the previous reading and the current one
* straddle a restart. The action itself still happened; what a property cannot
* assume across it is that app state ran continuously between the two readings,
* and one demanding an effect of this action has to decline. Null is "not
* reported", which is weaker than "the app never restarted": a target whose
* foreground the runner cannot read never relaunches the app and cannot promise
* that either.
*/
export type LastAction = Action & { applied: true | null; relaunched: true | null };
export interface State {
snapshots: Snapshots;
ax: AccessibilityTree;
lastAction: Action | null;
lastAction: LastAction | null;
time: number;
logs: readonly LogEntry[];
exceptions: readonly ExceptionRecord[];
+23 -4
View File
@@ -306,13 +306,18 @@ function selectorFromString(selector: string): { css?: string; xpath?: string }
// (Compose for Web mounts its canvas and its whole accessibility tree inside a
// shadow root on the mount element) keeps its entire UI on the far side of one:
// without this a spec sees four nodes and can neither enumerate a target nor
// resolve a testTag. Light-DOM matches come first, then shadow content in walk
// order. XPath has no equivalent, so `text:` selectors stop at the boundary.
// resolve a testTag. Matches come back in the order expandShadowContent walks
// and buildTree (internal/driver/chrome/driver.go) emits: a host, then that
// host's shadow content, then the host's light children. Sweeping the light DOM
// first and descending afterwards put a shadow-hosted match behind a later
// light-DOM one, so find() answered with a different element on each host.
// XPath has no equivalent, so `text:` selectors stop at the boundary.
function deepQueryAll(selector: string, root: ParentNode): Element[] {
const found: Element[] = [];
const visit = (scope: ParentNode): void => {
for (const element of Array.from(scope.querySelectorAll(selector))) found.push(element);
const matched = new Set<Element>(Array.from(scope.querySelectorAll(selector)));
for (const element of Array.from(scope.querySelectorAll<HTMLElement>("*"))) {
if (matched.has(element)) found.push(element);
if (element.shadowRoot) visit(element.shadowRoot);
}
};
@@ -615,6 +620,15 @@ if (typeof globalThis.addEventListener === "function") {
// that reads state.lastAction vacuously true on web.
let lastAction: unknown = null;
// logs is what the driver captured between the previous step and this one,
// pushed in by the Go runner (via __sanderlingSetLogs__) before each extractor
// evaluation, in the shape internal/verifier/marshal.go builds for goja. The
// page cannot derive it: console output reaches the runner over CDP and nothing
// in the page reads it back. Hardcoding [] here, as this file used to, makes
// every spec property that reads state.logs vacuously true on web, the default
// noLogcatErrors included, because the page's reading is the one that wins.
let logs: unknown[] = [];
function buildState(): unknown {
return {
snapshots: {},
@@ -623,7 +637,7 @@ function buildState(): unknown {
window,
lastAction,
time: 0,
logs: [],
logs,
exceptions: capturedExceptions.slice(),
};
}
@@ -672,6 +686,11 @@ defineLockedGlobal("__sanderlingSetLastAction__", (value: unknown) => {
lastAction = value ?? null;
});
// The host calls this once per step too, alongside __sanderlingSetLastAction__.
defineLockedGlobal("__sanderlingSetLogs__", (value: unknown) => {
logs = Array.isArray(value) ? value : [];
});
// The host reads the same buffer buildState puts behind state.exceptions, so
// the goja-side state.exceptions is the page's list rather than the empty one
// it held before, and the trace records an error surface an offline oracle can