Skip to content
Merged
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
6 changes: 6 additions & 0 deletions examples/test-app/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
.expo/
node_modules/

# expo prebuild output, regenerated by `expo run:ios` / `expo run:android`
# (what .github/actions/setup-fixture-app does). Untracked and generated, so
# leaving them visible lets a `git commit -a` sweep the whole native project in.
android/
ios/
1 change: 1 addition & 0 deletions examples/test-app/app/(tabs)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default function SettingsRoute() {
diagnosticsState={state.diagnosticsState}
notificationsEnabled={state.notificationsEnabled}
onOpenAccessorySetup={() => router.push('/accessory-setup')}
onOpenInertSurface={() => router.push('/inert')}
onConfirmReset={state.resetLabState}
onLoadDiagnostics={state.loadDiagnostics}
onRetryDiagnostics={state.retryDiagnostics}
Expand Down
1 change: 1 addition & 0 deletions examples/test-app/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function RootLayoutContent() {
>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="accessory-setup" />
<Stack.Screen name="inert" options={{ presentation: 'fullScreenModal' }} />
<Stack.Screen name="product/[productId]" />
</Stack>
{toastMessage ? <ToastViewport message={toastMessage} /> : null}
Expand Down
14 changes: 14 additions & 0 deletions examples/test-app/app/inert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { AppFrame } from '../src/components';
import { InertScreen } from '../src/screens/InertScreen';

// Presented as a fullScreenModal (see app/_layout.tsx) rather than pushed: iOS
// detaches the presenting screen once a full-screen present settles, so Settings
// and the tab bar — which carries live badges — leave the hierarchy entirely.
// The retry signature hashes every node on screen, so that matters.
export default function InertRoute() {
return (
<AppFrame>
<InertScreen />
</AppFrame>
);
}
67 changes: 67 additions & 0 deletions examples/test-app/src/screens/InertScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { StyleSheet, Text, View } from 'react-native';

import { useAppColors, type AppColors } from '../theme';

/**
* A surface on which a tap provably changes nothing.
*
* `retryTapIfNoChange` only re-taps when the post-tap snapshot signature equals
* the pre-tap one, and that signature hashes EVERY node on screen — not just the
* tapped subtree. So any live content anywhere on the screen suppresses the
* retry, which is what made the layer-3 tap-retry scenario a coin flip on the
* home screen (its gesture lab loads a remote image whose arrival lands on either
* side of the tap). See https://github.com/callstack/agent-device/issues/1300.
*
* Everything here is therefore deliberately constrained, and each rule is load-
* bearing for that scenario rather than stylistic:
*
* - no state, effects, timers, or async work — nothing to arrive after capture
* - no images (a remote one loads whenever the network says so)
* - no ScrollView — scroll offset moves node bounds, which are part of the hash
* - nothing pressable, so a tap cannot mutate the screen even by accident
*
* Adding any of those back re-opens #1300. Put dynamic fixtures on another
* screen; this one exists to hold still.
*/
export function InertScreen() {
const colors = useAppColors();
const styles = createStyles(colors);

return (
<View style={styles.content}>
<Text style={styles.title} testID="inert-title">
Inert surface
</Text>
<Text style={styles.body} testID="inert-target">
Tapping this text changes nothing on screen.
</Text>
<Text style={styles.footnote}>
Nothing here reacts to touch, loads, or animates, so a tap leaves the
hierarchy byte-identical and retryTapIfNoChange must re-tap.
</Text>
</View>
);
}

function createStyles(colors: AppColors) {
return StyleSheet.create({
body: {
color: colors.text,
fontSize: 17,
fontWeight: '600',
},
content: {
gap: 16,
},
footnote: {
color: colors.textSoft,
fontSize: 14,
lineHeight: 21,
},
title: {
color: colors.text,
fontSize: 24,
fontWeight: '700',
},
});
}
12 changes: 12 additions & 0 deletions examples/test-app/src/screens/SettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface SettingsScreenProps {
notificationsEnabled: boolean;
reducedMotionEnabled: boolean;
onOpenAccessorySetup: () => void;
onOpenInertSurface: () => void;
onLoadDiagnostics: () => void;
onRetryDiagnostics: () => void;
onSetNotificationsEnabled: (value: boolean) => void;
Expand Down Expand Up @@ -71,6 +72,17 @@ export function SettingsScreen(props: SettingsScreenProps) {
/>
</SectionCard>

<SectionCard
subtitle="Open a surface where a tap provably changes nothing on screen."
title="Inert surface"
>
<ActionButton
label="Open inert surface"
onPress={props.onOpenInertSurface}
testID="open-inert-surface"
/>
</SectionCard>

<SectionCard subtitle="Simple switch rows for durable selectors." title="Preferences">
<ToggleRow
description="Disabled notifications should remain visible in plain snapshots."
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
# Layer-3 device flow. Taps the app's static title, which is NOT interactive, so
# the screen cannot change and the retryIfNoChange path is forced to run. A tap on
# a navigating control (e.g. home-open-form) succeeds on the first attempt and
# never exercises retry at all — the scenario would pass while proving nothing.
# The scenario asserts tapRetries >= 1 from the trace, so a retry regression is
# caught rather than assumed.
# Layer-3 device flow for retryTapIfNoChange.
#
# The engine re-taps only when the post-tap snapshot signature equals the pre-tap
# one, and that signature hashes EVERY node on screen — not just the tapped
# subtree. So a non-interactive target is not enough on its own: any live content
# anywhere on screen makes the engine see "changed" and skip the retry. Tapping
# the home screen's title used to look sufficient and was actually a coin flip
# (#1300) — its gesture lab loads a remote image whose arrival lands on either
# side of the tap depending on the network.
#
# So this drives the fixture's inert surface (examples/test-app/app/inert.tsx),
# a screen with nothing that loads, animates, or reacts to touch, presented as a
# full-screen modal so the tab bar's live badges leave the hierarchy too. On a
# real run the inert screen is 9 nodes against Settings' 55.
#
# Reaching it via the Settings TAB is deliberate: home's "Open settings" button
# sits below the fold, and scrolling to it needs scrollUntilVisible — the engine
# bug tracked in #1299. The tab is always on screen.
#
# There is deliberately no waitForAnimationToEnd before the tap. It is not needed
# — a navigating tap defers a stability requirement that the next tap settles
# before resolving its target, so the baseline signature is already captured on a
# settled screen — and adding one currently FAILS the flow outright (#1326).
appId: com.callstack.agentdevicelab
---
- launchApp:
clearState: true
- assertVisible: Agent Device Tester
- tapOn:
text: Agent Device Tester
text: Settings
- tapOn:
id: open-inert-surface
- assertVisible:
id: inert-title
- tapOn:
id: inert-target
retryTapIfNoChange: true
- assertVisible: Agent Device Tester
- assertVisible:
id: inert-title
44 changes: 31 additions & 13 deletions scripts/maestro-conformance/differential/invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,22 +124,40 @@ test('a trace whose taps record no metric reports no-data rather than passing',
assert.equal(result.status, 'no-data');
});

// tap-retry-if-no-change is parked (#1300): tapRetries measured 0 then 1 across
// identical runs, so it is flaky rather than divergent. A knownDivergence
// declaration assumes the failure reproduces, so declaring a flaky scenario
// would make the schedule flip red at random. Assert it stays out of the active
// set until it has an inert control — re-adding it without one silently returns
// the coin-flip.
test('the flaky retry scenario is parked, not declared as a divergence', () => {
// tap-retry-if-no-change was parked while it was a coin flip (#1300): tapRetries
// measured 0 then 1 across identical runs. It is active again now that it taps
// the fixture's inert surface, and it carries its own detector — without the
// tapRetries invariant the scenario is back to proving nothing, because outcome
// parity passes whether or not the retry ever fires.
test('the retry scenario is active and carries the invariant that makes it mean something', () => {
const retry = DIFFERENTIAL_SCENARIOS.find((s) => s.id === 'tap-retry-if-no-change');
assert.equal(retry, undefined, 'tap-retry-if-no-change must stay parked until #1300 gives it an inert control');
assert.ok(retry, 'tap-retry-if-no-change must stay in the active differential set');
const [invariant, ...rest] = retry?.engineInvariants ?? [];
assert.equal(rest.length, 0);
assert.equal(invariant?.kind, 'metricAtLeast');
assert.equal(invariant?.command, 'tapOn');
assert.equal(invariant?.kind === 'metricAtLeast' && invariant.metric, 'tapRetries');
assert.equal(invariant?.kind === 'metricAtLeast' && invariant.min, 1);
});

// A flaky scenario must be fixed, never waived: knownDivergence assumes the
// failure reproduces, so declaring this one would make the schedule flip between
// green and red at random — the trap #1300 was filed to escape.
test('the retry scenario is not waived by a divergence declaration', () => {
const retry = DIFFERENTIAL_SCENARIOS.find((s) => s.id === 'tap-retry-if-no-change');
assert.equal(retry?.knownDivergence, undefined);
});

// The invariant itself stays implemented and proven, so the fix PR only has to
// re-add the scenario rather than rebuild the detector.
test('the tapRetries invariant remains implemented for when the scenario returns', () => {
assert.equal(evaluateInvariant([stopWithMetrics('tapOn', { tapRetries: 1 })], RETRY_INVARIANT).status, 'held');
assert.equal(evaluateInvariant([stopWithMetrics('tapOn', { tapRetries: 0 })], RETRY_INVARIANT).status, 'violated');
// The flow only forces the retry if it drives the inert surface. Tapping any
// live screen is what made this flaky, and that regression is invisible on the
// unit side — the invariant only goes red on a device.
test('the retry flow drives the inert surface, not a live screen', () => {
const flow = fs.readFileSync(
path.join(import.meta.dirname, 'flows/tap-retry-if-no-change.yaml'),
'utf8',
);
assert.match(flow, /id: open-inert-surface/);
assert.match(flow, /id: inert-target/);
});

// Truncation-vs-rounding is at most 1px and cannot be observed on a device, so
Expand Down
37 changes: 24 additions & 13 deletions scripts/maestro-conformance/differential/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,19 +148,30 @@ export const DIFFERENTIAL_SCENARIOS: DifferentialScenario[] = [
],
divergenceMeans: 'The swipe behaves differently enough on one engine to fail the flow.',
},
// PARKED: tap-retry-if-no-change (flow kept at differential/flows/, invariant
// kept implemented and unit-tested) — https://github.com/callstack/agent-device/issues/1300
//
// It is NOT declared as a knownDivergence, deliberately. tapRetries measured 0
// in run 29504440599 and 1 in run 29510020718 with no change to the flow or
// commit: the tap sometimes holds the hierarchy signature still and sometimes
// does not, because the fixture home screen has live content. A declaration
// assumes the divergence REPRODUCES; a flaky one would flip between
// known-divergence (green) and stale-declaration (red) at random, which is
// worse than no scenario — a coin-flip job teaches people to ignore the
// differential. So retryIfNoChange has no device coverage until the fixture
// offers an inert control, and that absence is tracked rather than disguised
// as a green run.
{
id: 'tap-retry-if-no-change',
flow: 'differential/flows/tap-retry-if-no-change.yaml',
comparesAcrossEngines: 'A tap on an inert target completes on both engines.',
expect: 'pass',
// The whole point of the scenario. Outcome parity cannot see a retry: a tap
// that never re-taps passes just the same, which is how this scenario spent
// its first two runs proving nothing (#1300). The flow taps a surface that
// provably cannot change, so the retry MUST fire; if it does not, either the
// retry path regressed or the fixture stopped being inert, and both are
// things we want to hear about.
engineInvariants: [
{
kind: 'metricAtLeast',
command: 'tapOn',
metric: 'tapRetries',
min: 1,
because:
'a tap on an unchanging screen must re-tap; zero retries means retryIfNoChange never ran and the scenario proved nothing',
},
],
divergenceMeans:
'agent-device stopped re-tapping when the screen holds still (a retryIfNoChange regression), or the fixture surface it taps is no longer inert.',
},
{
id: 'optional-warned-not-failed',
flow: 'differential/flows/optional-warned-not-failed.yaml',
Expand Down
Loading