diff --git a/packages/tool-server/src/tools/flows/flow-actions.ts b/packages/tool-server/src/tools/flows/flow-actions.ts index 326c203485..debd3f88a9 100644 --- a/packages/tool-server/src/tools/flows/flow-actions.ts +++ b/packages/tool-server/src/tools/flows/flow-actions.ts @@ -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 + * 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. @@ -1693,7 +1693,9 @@ async function waitForCondition( ): Promise { const deadline = Date.now() + timeoutMs; + // The last TRUSTED read's matches, with the orientation they were read in. let lastMatches: ReturnType = []; + let lastMatchesOrientation: UiOrientation | undefined; let fetchError: string | undefined; let everMatched = false; // Date.now() of the most recent TRUSTED read — undefined until one lands. @@ -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 ) @@ -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) { @@ -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, }; } diff --git a/packages/tool-server/test/flows/flow-hidden-blank-reads.test.ts b/packages/tool-server/test/flows/flow-hidden-blank-reads.test.ts index a3697b54e3..fd380c4ae1 100644 --- a/packages/tool-server/test/flows/flow-hidden-blank-reads.test.ts +++ b/packages/tool-server/test/flows/flow-hidden-blank-reads.test.ts @@ -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): Promise { + await writeFlow("blank-blip", { + executionPrerequisite: "", + steps: [{ kind: "assert", selector: { identifier: "total" }, ...step }], + } as Parameters[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")' + ); + }); });