Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions packages/tool-server/src/tools/flows/flow-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,11 @@ export interface ActionEnv {
* frame space, for the directives that turn a UI-space direction into it
* (`swipe: down`, `scroll-to`; see `flow-orientation.ts`) and for the
* selector relations and picks that go by reading order (`after`, `next`,
* `any: true`, a `text` condition; see `ui-tree-match.ts`). Written by every
* read that goes through {@link readFlowTree} — the settle a swipe or a
* scroll round makes before it dispatches is the read that fills it, and
* every selector resolves against the tree of the read that just filled it
* — and one holder per run, shared with nested `run:` flows like
* `any: true`; see `ui-tree-match.ts`). Written by every read that goes
Comment thread
latekvo marked this conversation as resolved.
Resolved
* through {@link readFlowTree} — the settle a swipe or a scroll round makes
* before it dispatches is the read that fills it, and every selector
* resolves against the tree of the read that just filled it — and one
* holder per run, shared with nested `run:` flows like
* {@link ActionEnv.treeOutage}. Absent for a caller that builds an
* `ActionEnv` by hand, which leaves every direction, and reading order, in
* the frame space.
Expand Down Expand Up @@ -1693,7 +1693,9 @@ async function waitForCondition(
): Promise<DirectiveOutcome> {
const deadline = Date.now() + timeoutMs;

// The last TRUSTED read's matches, with the orientation they were read in.
let lastMatches: ReturnType<typeof findAll> = [];
let lastMatchesOrientation: UiOrientation | undefined;
let fetchError: string | undefined;
let everMatched = false;
// Date.now() of the most recent TRUSTED read — undefined until one lands.
Expand All @@ -1708,18 +1710,22 @@ async function waitForCondition(
if (env.signal?.aborted) return ABORTED_OUTCOME;
try {
const data = await readFlowTree(env);
lastMatches = flowFindAll(data.tree, step.selector, data.uiOrientation);
const matches = flowFindAll(data.tree, step.selector, data.uiOrientation);
fetchError = undefined;
everMatched ||= lastMatches.length > 0;
everMatched ||= matches.length > 0;
const blind = isBlindRead(data, everMatched);
if (!blind) lastTrustedReadAt = Date.now();
if (!blind) {
lastMatches = matches;
lastMatchesOrientation = data.uiOrientation;
lastTrustedReadAt = Date.now();
}
lastReadTrusted = !blind;
if (
!blind &&
evaluateCondition(
step.condition,
step.expectedText,
lastMatches,
matches,
step.textMatch,
data.uiOrientation
)
Expand All @@ -1729,7 +1735,7 @@ async function waitForCondition(
} catch (err) {
fetchError = err instanceof Error ? err.message : String(err);
// A throw is as blind as an empty tree — `lastMatches` still holds the
// previous successful read, which must not pass for current evidence.
// previous trusted read, which must not pass for current evidence.
lastReadTrusted = false;
}
if (Date.now() >= deadline) {
Expand Down Expand Up @@ -1813,9 +1819,7 @@ async function waitForCondition(
step.expectedText,
step.textMatch,
lastMatches,
// The read `lastMatches` came from is the last one that answered, and
// only a read that answers writes the orientation.
readingOrientation(env)
lastMatchesOrientation
) + blipNote,
};
}
Expand Down
90 changes: 90 additions & 0 deletions packages/tool-server/test/flows/flow-hidden-blank-reads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,4 +354,94 @@ describe("dark-tail diagnostics (non-hidden conditions)", () => {
/final poll could not read the UI tree: native devtools disconnected/
);
});

// Trusted reads of `nodes` in `trustedOrientation` until 950ms into the 1s
// assert window, then empty trees: blind once the selector has matched, and
// inside the dark-tail tolerance.
function blankFrom950ms(
nodes: DescribeNode[],
trustedOrientation?: DescribeTreeData["uiOrientation"],
blankOrientation?: DescribeTreeData["uiOrientation"]
): void {
let firstReadAt: number | undefined;
currentFetch = () => {
firstReadAt ??= Date.now();
return Date.now() - firstReadAt >= 950
? { tree: screen([]), source: "native-devtools", uiOrientation: blankOrientation }
: { tree: screen(nodes), source: "native-devtools", uiOrientation: trustedOrientation };
};
}

async function assertOnce(step: Record<string, unknown>): Promise<FlowRunResult> {
await writeFlow("blank-blip", {
executionPrerequisite: "",
steps: [{ kind: "assert", selector: { identifier: "total" }, ...step }],
} as Parameters<typeof serializeFlow>[0]);
return run("blank-blip");
}

it("assert text: an empty final poll within the tolerance keeps the trusted text mismatch", async () => {
blankFrom950ms([
n({
identifier: "total",
label: "Total $41.50",
frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 },
}),
]);

const result = await assertOnce({
condition: "text",
expectedText: "Total $42.00",
textMatch: "equals",
});

expect(result.steps[0].status).toBe("fail");
expect(result.steps[0].reason).toBe(
'element matched id="total" but its text was "Total $41.50" (wanted to equal "Total $42.00")'
);
});

it("assert visible: an empty final poll within the tolerance keeps the trusted zero-area match", async () => {
blankFrom950ms([
n({ identifier: "total", label: "Total", frame: { x: 0.1, y: 0.1, width: 0, height: 0 } }),
]);

const result = await assertOnce({ condition: "visible" });

expect(result.steps[0].status).toBe("fail");
expect(result.steps[0].reason).toBe(
'element(s) matched id="total" but none was visible (zero-area frame)'
);
});

it("assert text: the reason picks among the trusted matches in their own read's orientation", async () => {
// landscapeLeft reading order puts "Left" first; read in portrait (or the frame space), "Top" would lead.
blankFrom950ms(
[
n({
identifier: "total",
label: "Left",
frame: { x: 0.05, y: 0.6, width: 0.2, height: 0.1 },
}),
n({
identifier: "total",
label: "Top",
frame: { x: 0.7, y: 0.1, width: 0.2, height: 0.1 },
}),
],
"landscapeLeft",
"portrait"
);

const result = await assertOnce({
condition: "text",
expectedText: "Total $42.00",
textMatch: "equals",
});

expect(result.steps[0].status).toBe("fail");
expect(result.steps[0].reason).toBe(
'element matched id="total" but its text was "Left" (wanted to equal "Total $42.00")'
);
});
});
Loading