@buoy-gg/redux 7.0.30 → 7.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/lib/commonjs/index.js +25 -0
  2. package/lib/commonjs/redux/components/ReduxActionButton.js +3 -1
  3. package/lib/commonjs/redux/components/ReduxActionDetailContent.js +60 -6
  4. package/lib/commonjs/redux/components/ReduxActionInfoView.js +61 -12
  5. package/lib/commonjs/redux/components/ReduxActionItem.js +20 -5
  6. package/lib/commonjs/redux/components/ReduxDetailViewToggle.js +7 -0
  7. package/lib/commonjs/redux/components/ReduxModal.js +168 -32
  8. package/lib/commonjs/redux/utils/activeStoreRegistry.js +31 -0
  9. package/lib/commonjs/redux/utils/autoInstrument.js +23 -40
  10. package/lib/commonjs/redux/utils/buoyReduxMiddleware.js +36 -9
  11. package/lib/commonjs/redux/utils/devtoolsHook.js +4 -0
  12. package/lib/commonjs/redux/utils/testIDs.js +75 -0
  13. package/lib/module/index.js +5 -0
  14. package/lib/module/redux/components/ReduxActionButton.js +3 -1
  15. package/lib/module/redux/components/ReduxActionDetailContent.js +60 -6
  16. package/lib/module/redux/components/ReduxActionInfoView.js +63 -14
  17. package/lib/module/redux/components/ReduxActionItem.js +20 -5
  18. package/lib/module/redux/components/ReduxDetailViewToggle.js +7 -0
  19. package/lib/module/redux/components/ReduxModal.js +170 -33
  20. package/lib/module/redux/utils/activeStoreRegistry.js +29 -0
  21. package/lib/module/redux/utils/autoInstrument.js +8 -40
  22. package/lib/module/redux/utils/buoyReduxMiddleware.js +32 -10
  23. package/lib/module/redux/utils/devtoolsHook.js +5 -1
  24. package/lib/module/redux/utils/testIDs.js +68 -0
  25. package/lib/typescript/index.d.ts +1 -0
  26. package/lib/typescript/index.d.ts.map +1 -1
  27. package/lib/typescript/redux/components/ReduxActionButton.d.ts +2 -1
  28. package/lib/typescript/redux/components/ReduxActionButton.d.ts.map +1 -1
  29. package/lib/typescript/redux/components/ReduxActionDetailContent.d.ts.map +1 -1
  30. package/lib/typescript/redux/components/ReduxActionInfoView.d.ts.map +1 -1
  31. package/lib/typescript/redux/components/ReduxActionItem.d.ts +15 -0
  32. package/lib/typescript/redux/components/ReduxActionItem.d.ts.map +1 -1
  33. package/lib/typescript/redux/components/ReduxDetailViewToggle.d.ts.map +1 -1
  34. package/lib/typescript/redux/components/ReduxModal.d.ts.map +1 -1
  35. package/lib/typescript/redux/utils/activeStoreRegistry.d.ts +27 -0
  36. package/lib/typescript/redux/utils/activeStoreRegistry.d.ts.map +1 -1
  37. package/lib/typescript/redux/utils/autoInstrument.d.ts +7 -11
  38. package/lib/typescript/redux/utils/autoInstrument.d.ts.map +1 -1
  39. package/lib/typescript/redux/utils/buoyReduxMiddleware.d.ts +17 -3
  40. package/lib/typescript/redux/utils/buoyReduxMiddleware.d.ts.map +1 -1
  41. package/lib/typescript/redux/utils/devtoolsHook.d.ts.map +1 -1
  42. package/lib/typescript/redux/utils/testIDs.d.ts +51 -0
  43. package/lib/typescript/redux/utils/testIDs.d.ts.map +1 -0
  44. package/package.json +4 -4
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.REDUX_TEST_ID = void 0;
7
+ exports.actionRowTestID = actionRowTestID;
8
+ exports.buildActionRowTestIDs = buildActionRowTestIDs;
9
+ exports.slugForTestID = slugForTestID;
10
+ /**
11
+ * Redux tool test handles.
12
+ *
13
+ * Every tappable and every value the e2e suite reads back is addressed
14
+ * `buoy-redux-<thing>`. One rule in here needs explaining, because it is the
15
+ * opposite of what the Events tool does and the difference is not cosmetic:
16
+ *
17
+ * **A redux row's handle carries an ORDINAL.** The Events tool can address a
18
+ * row by what it is about, because an event is a thing that happened to a
19
+ * named subject — one key, one URL, one atom. A redux action is not: an app
20
+ * dispatches `counter/increment` fifty times and every one of them is a
21
+ * separate, equally real row. Addressing them by type alone would give fifty
22
+ * views one handle, and Detox fails a *matcher* that hits more than one
23
+ * element — so the suite would break on `toExist()`, before any assertion,
24
+ * with an error that says nothing about duplicate actions.
25
+ *
26
+ * So the handle is `buoy-redux-row-<slug(type)>-<n>`, where n counts back from
27
+ * the newest row of that type (1 = newest). It is deliberately relative to the
28
+ * list rather than absolute: an action has no identity of its own that a host
29
+ * could know in advance — `id` is a timestamp plus a per-process counter, and
30
+ * the counter's value depends on how many actions the app happened to dispatch
31
+ * before the test looked. What a test CAN know is "the increment I just fired
32
+ * is the newest increment", which is exactly what `-1` means.
33
+ *
34
+ * The consequence, and it is the same one the Events tool documents for its
35
+ * positional-free handles: read and tap in one beat. A row's ordinal changes
36
+ * the moment another action of that type arrives.
37
+ *
38
+ * The suite mirrors these rules in CommonJS (`example-dev-build/utils/
39
+ * reduxBench.js`) and asserts the two agree — Detox runs in Node on the host
40
+ * and cannot import TypeScript.
41
+ */
42
+
43
+ /** Prefix every handle in this tool shares. */
44
+ const REDUX_TEST_ID = exports.REDUX_TEST_ID = "buoy-redux";
45
+
46
+ /** `counter/increment` → `counter-increment`. Shared with every Buoy tool. */
47
+ function slugForTestID(value) {
48
+ return String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
49
+ }
50
+
51
+ /**
52
+ * Handle for one action row: `buoy-redux-row-counter-increment-1`.
53
+ *
54
+ * `ordinal` is 1-based and counts back from the newest row of this type.
55
+ */
56
+ function actionRowTestID(type, ordinal) {
57
+ return `${REDUX_TEST_ID}-row-${slugForTestID(type)}-${ordinal}`;
58
+ }
59
+
60
+ /**
61
+ * Row handle per action id for one rendered list, newest-first.
62
+ *
63
+ * Built once for the whole list rather than per row, because an ordinal is a
64
+ * fact about the list and a row cannot compute its own.
65
+ */
66
+ function buildActionRowTestIDs(actions) {
67
+ const seen = new Map();
68
+ const handles = new Map();
69
+ for (const action of actions) {
70
+ const ordinal = (seen.get(action.type) ?? 0) + 1;
71
+ seen.set(action.type, ordinal);
72
+ handles.set(action.id, actionRowTestID(action.type, ordinal));
73
+ }
74
+ return handles;
75
+ }
@@ -109,6 +109,11 @@ export { ReduxIcon } from "./redux/components/ReduxIcon";
109
109
  // FloatingDevTools; exported for hosts that mount devtools their own way.
110
110
  export { ReduxAutoInstrument } from "./redux/components/ReduxAutoInstrument";
111
111
 
112
+ // =============================================================================
113
+ // TEST HANDLES (shared with the e2e suite and the website's live tour)
114
+ // =============================================================================
115
+ export { REDUX_TEST_ID, actionRowTestID, buildActionRowTestIDs, slugForTestID } from "./redux/utils/testIDs";
116
+
112
117
  // =============================================================================
113
118
  // TYPES
114
119
  // =============================================================================
@@ -54,10 +54,12 @@ export function ReduxActionButton({
54
54
  onPress,
55
55
  text,
56
56
  type,
57
- disabled = false
57
+ disabled = false,
58
+ testID
58
59
  }) {
59
60
  const config = buttonConfigs[type];
60
61
  return /*#__PURE__*/_jsxs(TouchableOpacity, {
62
+ testID: testID,
61
63
  disabled: disabled,
62
64
  onPress: onPress,
63
65
  style: [styles.button, {
@@ -16,6 +16,8 @@ import { DataViewer, SplitDiffViewer, TreeDiffViewer, diffThemes } from "@buoy-g
16
16
  import { ReduxDetailViewToggle } from "./ReduxDetailViewToggle";
17
17
  import { ReduxActionInfoView } from "./ReduxActionInfoView";
18
18
  import { DiffModeTabs, CompareBar } from "@buoy-gg/shared-ui";
19
+ import { REDUX_TEST_ID } from "../utils/testIDs";
20
+ import { isEvictedState, STATE_RETENTION_LIMIT } from "../utils/reduxActionStore";
19
21
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
20
22
  /**
21
23
  * Get color for action type
@@ -85,6 +87,20 @@ export function ReduxActionDetailContent({
85
87
  const [activeView, setActiveView] = useState(initialView);
86
88
  const [diffMode, setDiffMode] = useState("tree");
87
89
  const totalActions = actions.length;
90
+ /**
91
+ * The state trees this action was recorded with are gone.
92
+ *
93
+ * Not a failure: the store keeps raw prev/next state for the most recent
94
+ * actions only, because with structural sharing broken (a store switch, a
95
+ * rehydration) each retained action pins a distinct multi-MB subtree and a
96
+ * few dozen of them are enough to OOM-kill a large app. But it has to be
97
+ * SAID. The sentinel left behind is an ordinary object, so a state view that
98
+ * just rendered it showed `{ __buoyStateEvicted: true, … }` under the
99
+ * heading "STATE AFTER ACTION" — which reads as the app's actual state — and
100
+ * a diff of two identical sentinels reported "no changes", which is a lie
101
+ * about the app rather than about the recording.
102
+ */
103
+ const stateEvicted = isEvictedState(action.nextState);
88
104
  const actionColor = getActionColor(action.type);
89
105
  const actionLabel = getActionLabel(action.type);
90
106
  const stateType = getValueType(parseValue(action.nextState));
@@ -135,9 +151,26 @@ export function ReduxActionDetailContent({
135
151
 
136
152
  // Render state view
137
153
  const renderStateView = useCallback(() => {
154
+ if (stateEvicted) {
155
+ return /*#__PURE__*/_jsxs(View, {
156
+ style: styles.contentCard,
157
+ testID: `${REDUX_TEST_ID}-state-evicted`,
158
+ children: [/*#__PURE__*/_jsx(View, {
159
+ style: styles.valueHeader,
160
+ children: /*#__PURE__*/_jsx(Text, {
161
+ style: styles.valueLabel,
162
+ children: "STATE AFTER ACTION"
163
+ })
164
+ }), /*#__PURE__*/_jsx(Text, {
165
+ style: styles.evictedText,
166
+ children: `Not retained. Raw state is kept for the ${STATE_RETENTION_LIMIT} most recent actions, so this action's before/after trees have been released.`
167
+ })]
168
+ });
169
+ }
138
170
  const parsed = parseValue(action.nextState);
139
171
  return /*#__PURE__*/_jsxs(View, {
140
172
  style: styles.contentCard,
173
+ testID: `${REDUX_TEST_ID}-state-card`,
141
174
  children: [/*#__PURE__*/_jsxs(View, {
142
175
  style: styles.valueHeader,
143
176
  children: [/*#__PURE__*/_jsx(Text, {
@@ -159,6 +192,7 @@ export function ReduxActionDetailContent({
159
192
  style: styles.typeBadge,
160
193
  children: /*#__PURE__*/_jsx(Text, {
161
194
  style: styles.typeText,
195
+ testID: `${REDUX_TEST_ID}-state-type`,
162
196
  children: stateType.toUpperCase()
163
197
  })
164
198
  })]
@@ -174,7 +208,7 @@ export function ReduxActionDetailContent({
174
208
  })
175
209
  })]
176
210
  });
177
- }, [action.nextState, actionColor, actionLabel, stateType]);
211
+ }, [action.nextState, actionColor, actionLabel, stateType, stateEvicted]);
178
212
 
179
213
  // Render diff content
180
214
  const renderDiffContent = useCallback(() => {
@@ -214,7 +248,15 @@ export function ReduxActionDetailContent({
214
248
  const handleFooterNext = useCallback(() => {
215
249
  onIndexChange(Math.max(0, selectedIndex - 1));
216
250
  }, [selectedIndex, onIndexChange]);
217
- const diffDisabled = totalActions <= 1;
251
+
252
+ // A diff needs two real trees, and both live on THIS action
253
+ // (prevState/nextState) — no siblings needed, so the action count says
254
+ // nothing (gating on it greyed DIFF out for every redux event opened from
255
+ // the Events timeline, which passes a one-element array). Eviction of
256
+ // EITHER side does disable it: two identical sentinels diff cleanly to
257
+ // "no changes", which reads as a lie.
258
+ const diffDisabled = stateEvicted || isEvictedState(action.prevState) || action.prevState === undefined;
259
+ const showInternalFooter = !disableInternalFooter && totalActions > 1;
218
260
  return /*#__PURE__*/_jsxs(View, {
219
261
  style: styles.container,
220
262
  children: [/*#__PURE__*/_jsx(ReduxDetailViewToggle, {
@@ -222,7 +264,7 @@ export function ReduxActionDetailContent({
222
264
  onViewChange: setActiveView,
223
265
  diffDisabled: diffDisabled
224
266
  }), /*#__PURE__*/_jsxs(View, {
225
- style: styles.contentArea,
267
+ style: [styles.contentArea, showInternalFooter && styles.contentAreaFooterPad],
226
268
  children: [activeView === "action" && /*#__PURE__*/_jsx(ReduxActionInfoView, {
227
269
  action: action
228
270
  }), activeView === "state" && /*#__PURE__*/_jsx(View, {
@@ -230,19 +272,22 @@ export function ReduxActionDetailContent({
230
272
  children: renderStateView()
231
273
  }), activeView === "diff" && /*#__PURE__*/_jsxs(View, {
232
274
  style: styles.diffContainer,
275
+ testID: `${REDUX_TEST_ID}-diff`,
233
276
  children: [/*#__PURE__*/_jsx(DiffModeTabs, {
234
277
  tabs: DIFF_MODE_TABS,
235
278
  activeTab: diffMode,
236
- onTabChange: setDiffMode
279
+ onTabChange: setDiffMode,
280
+ testIDPrefix: `${REDUX_TEST_ID}-diff-tab`
237
281
  }), /*#__PURE__*/_jsx(CompareBar, {
238
282
  leftEvent: leftEvent,
239
283
  rightEvent: rightEvent
240
284
  }), /*#__PURE__*/_jsx(View, {
241
285
  style: styles.diffContent,
286
+ testID: `${REDUX_TEST_ID}-diff-content`,
242
287
  children: renderDiffContent()
243
288
  })]
244
289
  })]
245
- }), !disableInternalFooter && totalActions > 1 && /*#__PURE__*/_jsx(EventStepperFooter, {
290
+ }), showInternalFooter && /*#__PURE__*/_jsx(EventStepperFooter, {
246
291
  currentIndex: chronologicalNumber - 1,
247
292
  totalItems: totalActions,
248
293
  onPrevious: handleFooterPrevious,
@@ -282,7 +327,11 @@ const styles = StyleSheet.create({
282
327
  backgroundColor: "transparent"
283
328
  },
284
329
  contentArea: {
285
- flex: 1,
330
+ flex: 1
331
+ },
332
+ // Clearance for the absolute EventStepperFooter — only when it renders,
333
+ // otherwise hosts like the Events tool get a dead band at the sheet bottom.
334
+ contentAreaFooterPad: {
286
335
  paddingBottom: 96
287
336
  },
288
337
  // State view styles
@@ -346,6 +395,11 @@ const styles = StyleSheet.create({
346
395
  borderColor: night.color.border,
347
396
  padding: 8
348
397
  },
398
+ evictedText: {
399
+ fontSize: 12,
400
+ color: night.color.textSecondary,
401
+ lineHeight: 18
402
+ },
349
403
  valueText: {
350
404
  fontSize: 12,
351
405
  color: night.color.text,
@@ -15,9 +15,10 @@ import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from "react-nati
15
15
  import { buoyColors, night, Clock, AlertTriangle, Zap, Hash, Info, Box, AlertCircle, FileText, parseValue, ProUpgradeModal, copyToClipboard } from "@buoy-gg/shared-ui";
16
16
  import { useIsPro } from "@buoy-gg/license";
17
17
  import { DataViewer } from "@buoy-gg/shared-ui/dataViewer";
18
- import { reduxActionStore } from "../utils/reduxActionStore";
19
- import { jumpToState, replayAction } from "../utils/buoyReduxMiddleware";
18
+ import { reduxActionStore, isEvictedState } from "../utils/reduxActionStore";
19
+ import { jumpToState, replayAction, isTimeTravelAvailable } from "../utils/buoyReduxMiddleware";
20
20
  import { ReduxActionButton } from "./ReduxActionButton";
21
+ import { REDUX_TEST_ID } from "../utils/testIDs";
21
22
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
22
23
  /**
23
24
  * Get category display info
@@ -84,10 +85,12 @@ function formatDuration(duration) {
84
85
  function SectionHeader({
85
86
  icon: Icon,
86
87
  title,
87
- badge
88
+ badge,
89
+ testID
88
90
  }) {
89
91
  return /*#__PURE__*/_jsxs(View, {
90
92
  style: styles.sectionHeader,
93
+ testID: testID,
91
94
  children: [/*#__PURE__*/_jsxs(View, {
92
95
  style: styles.sectionHeaderLeft,
93
96
  children: [/*#__PURE__*/_jsx(Icon, {
@@ -108,7 +111,8 @@ function InfoRow({
108
111
  label,
109
112
  value,
110
113
  valueColor,
111
- mono = false
114
+ mono = false,
115
+ testID
112
116
  }) {
113
117
  return /*#__PURE__*/_jsxs(View, {
114
118
  style: styles.infoRow,
@@ -118,9 +122,14 @@ function InfoRow({
118
122
  }), /*#__PURE__*/_jsx(Text, {
119
123
  style: [styles.infoValue, mono && styles.infoValueMono, valueColor && {
120
124
  color: valueColor
121
- }],
125
+ }]
126
+ // `ellipsizeMode="middle"` truncates the RENDERED text and iOS reports
127
+ // the truncated string back, so anything a test reads off one of these
128
+ // rows has to be short enough to fit on the line.
129
+ ,
122
130
  numberOfLines: 1,
123
131
  ellipsizeMode: "middle",
132
+ testID: testID,
124
133
  children: value
125
134
  })]
126
135
  });
@@ -151,6 +160,18 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
151
160
  }
152
161
  }, [showProModal, isPro]);
153
162
  const categoryInfo = getCategoryInfo(action.category);
163
+ // Time travel needs a REAL tree. The store releases raw state for older
164
+ // actions (see ReduxActionDetailContent), and `jumpToState` refuses the
165
+ // sentinel — but it refused into a console warning, so from the screen the
166
+ // button simply did nothing at all. A disabled button that says why is the
167
+ // difference between a limit and a bug.
168
+ const stateEvicted = isEvictedState(action.nextState);
169
+ // Whether this app wired time travel at all. Capture and time travel are
170
+ // separate: an app can be fully captured through `buoyReduxMiddleware` and
171
+ // still have no reducer that answers a jump, in which case the press
172
+ // dispatched, was ignored, and left the screen exactly as it was.
173
+ const canTimeTravel = isTimeTravelAvailable();
174
+ const jumpDisabled = stateEvicted || !canTimeTravel;
154
175
  const hasPayload = action.payload !== undefined;
155
176
  const hasMeta = action.meta && Object.keys(action.meta).length > 0;
156
177
  const hasError = action.error !== undefined;
@@ -191,11 +212,13 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
191
212
  }
192
213
  }, [action]);
193
214
  return /*#__PURE__*/_jsxs(ScrollView, {
215
+ testID: `${REDUX_TEST_ID}-detail-scroll`,
194
216
  style: styles.container,
195
217
  contentContainerStyle: styles.contentContainer,
196
218
  showsVerticalScrollIndicator: false,
197
219
  children: [/*#__PURE__*/_jsxs(View, {
198
220
  style: styles.card,
221
+ testID: `${REDUX_TEST_ID}-info-card`,
199
222
  children: [/*#__PURE__*/_jsx(SectionHeader, {
200
223
  icon: Info,
201
224
  title: "ACTION INFO"
@@ -204,15 +227,18 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
204
227
  children: [/*#__PURE__*/_jsx(InfoRow, {
205
228
  label: "Type",
206
229
  value: action.type,
207
- mono: true
230
+ mono: true,
231
+ testID: `${REDUX_TEST_ID}-info-type`
208
232
  }), action.sliceName && /*#__PURE__*/_jsx(InfoRow, {
209
233
  label: "Slice",
210
234
  value: action.sliceName,
211
- mono: true
235
+ mono: true,
236
+ testID: `${REDUX_TEST_ID}-info-slice`
212
237
  }), /*#__PURE__*/_jsx(InfoRow, {
213
238
  label: "Action",
214
239
  value: action.actionName,
215
- mono: true
240
+ mono: true,
241
+ testID: `${REDUX_TEST_ID}-info-action`
216
242
  }), /*#__PURE__*/_jsxs(View, {
217
243
  style: styles.badgeRow,
218
244
  children: [/*#__PURE__*/_jsx(View, {
@@ -223,6 +249,7 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
223
249
  style: [styles.categoryText, {
224
250
  color: categoryInfo.color
225
251
  }],
252
+ testID: `${REDUX_TEST_ID}-info-category`,
226
253
  children: categoryInfo.label
227
254
  })
228
255
  }), /*#__PURE__*/_jsxs(View, {
@@ -232,6 +259,7 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
232
259
  color: action.isSlowAction ? buoyColors.error : night.color.textSecondary
233
260
  }), /*#__PURE__*/_jsx(Text, {
234
261
  style: [styles.durationText, action.isSlowAction && styles.durationTextSlow],
262
+ testID: `${REDUX_TEST_ID}-info-duration`,
235
263
  children: formatDuration(action.duration)
236
264
  }), action.isSlowAction && /*#__PURE__*/_jsx(AlertTriangle, {
237
265
  size: 10,
@@ -244,12 +272,14 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
244
272
  color: buoyColors.success
245
273
  }), /*#__PURE__*/_jsx(Text, {
246
274
  style: styles.changesText,
275
+ testID: `${REDUX_TEST_ID}-info-changes`,
247
276
  children: action.diffSummary || "changed"
248
277
  })]
249
278
  }) : /*#__PURE__*/_jsx(View, {
250
279
  style: styles.noChangesBadge,
251
280
  children: /*#__PURE__*/_jsx(Text, {
252
281
  style: styles.noChangesText,
282
+ testID: `${REDUX_TEST_ID}-info-no-changes`,
253
283
  children: "no change"
254
284
  })
255
285
  })]
@@ -257,6 +287,7 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
257
287
  })]
258
288
  }), hasRelatedActions && /*#__PURE__*/_jsxs(View, {
259
289
  style: styles.card,
290
+ testID: `${REDUX_TEST_ID}-async-card`,
260
291
  children: [/*#__PURE__*/_jsx(SectionHeader, {
261
292
  icon: Hash,
262
293
  title: "ASYNC FLOW",
@@ -264,6 +295,7 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
264
295
  style: styles.asyncBadge,
265
296
  children: /*#__PURE__*/_jsxs(Text, {
266
297
  style: styles.asyncBadgeText,
298
+ testID: `${REDUX_TEST_ID}-async-count`,
267
299
  children: [relatedActions.length, " ACTIONS"]
268
300
  })
269
301
  })
@@ -271,6 +303,7 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
271
303
  style: styles.cardContent,
272
304
  children: [/*#__PURE__*/_jsx(Text, {
273
305
  style: styles.baseActionType,
306
+ testID: `${REDUX_TEST_ID}-async-base`,
274
307
  children: baseActionType
275
308
  }), totalDuration !== null && /*#__PURE__*/_jsxs(View, {
276
309
  style: styles.totalDurationRow,
@@ -279,6 +312,7 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
279
312
  children: "Total Duration"
280
313
  }), /*#__PURE__*/_jsxs(Text, {
281
314
  style: styles.totalDurationValue,
315
+ testID: `${REDUX_TEST_ID}-async-total`,
282
316
  children: [totalDuration, "ms"]
283
317
  })]
284
318
  }), /*#__PURE__*/_jsx(View, {
@@ -291,6 +325,7 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
291
325
  onPress: () => onNavigateToAction?.(relatedAction),
292
326
  disabled: isCurrent || !onNavigateToAction,
293
327
  activeOpacity: 0.7,
328
+ testID: `${REDUX_TEST_ID}-async-step-${index}`,
294
329
  children: [index < relatedActions.length - 1 && /*#__PURE__*/_jsx(View, {
295
330
  style: styles.timelineConnector
296
331
  }), /*#__PURE__*/_jsx(View, {
@@ -309,10 +344,12 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
309
344
  style: [styles.timelineStatusText, {
310
345
  color: relatedCategoryInfo.color
311
346
  }],
347
+ testID: `${REDUX_TEST_ID}-async-step-${index}-status`,
312
348
  children: relatedCategoryInfo.label
313
349
  })
314
350
  }), isCurrent && /*#__PURE__*/_jsx(View, {
315
351
  style: styles.currentBadge,
352
+ testID: `${REDUX_TEST_ID}-async-step-${index}-current`,
316
353
  children: /*#__PURE__*/_jsx(Text, {
317
354
  style: styles.currentBadgeText,
318
355
  children: "CURRENT"
@@ -329,6 +366,7 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
329
366
  })]
330
367
  }), hasPayload && /*#__PURE__*/_jsxs(View, {
331
368
  style: styles.card,
369
+ testID: `${REDUX_TEST_ID}-payload-card`,
332
370
  children: [/*#__PURE__*/_jsx(SectionHeader, {
333
371
  icon: Box,
334
372
  title: "PAYLOAD",
@@ -351,6 +389,7 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
351
389
  })]
352
390
  }), hasMeta && /*#__PURE__*/_jsxs(View, {
353
391
  style: styles.card,
392
+ testID: `${REDUX_TEST_ID}-meta-card`,
354
393
  children: [/*#__PURE__*/_jsx(SectionHeader, {
355
394
  icon: Hash,
356
395
  title: "ASYNC METADATA",
@@ -374,7 +413,8 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
374
413
  }), action.meta?.requestStatus && /*#__PURE__*/_jsx(InfoRow, {
375
414
  label: "Status",
376
415
  value: action.meta.requestStatus.toUpperCase(),
377
- valueColor: categoryInfo.color
416
+ valueColor: categoryInfo.color,
417
+ testID: `${REDUX_TEST_ID}-meta-status`
378
418
  }), action.meta?.arg !== undefined && /*#__PURE__*/_jsxs(View, {
379
419
  style: styles.argSection,
380
420
  children: [/*#__PURE__*/_jsx(Text, {
@@ -409,6 +449,7 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
409
449
  })]
410
450
  }), hasError && /*#__PURE__*/_jsxs(View, {
411
451
  style: [styles.card, styles.errorCard],
452
+ testID: `${REDUX_TEST_ID}-error-card`,
412
453
  children: [/*#__PURE__*/_jsx(SectionHeader, {
413
454
  icon: AlertCircle,
414
455
  title: "ERROR",
@@ -416,6 +457,7 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
416
457
  style: styles.errorBadge,
417
458
  children: /*#__PURE__*/_jsx(Text, {
418
459
  style: styles.errorBadgeText,
460
+ testID: `${REDUX_TEST_ID}-error-badge`,
419
461
  children: "REJECTED"
420
462
  })
421
463
  })
@@ -434,6 +476,7 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
434
476
  })]
435
477
  }), /*#__PURE__*/_jsxs(View, {
436
478
  style: styles.card,
479
+ testID: `${REDUX_TEST_ID}-full-action-card`,
437
480
  children: [/*#__PURE__*/_jsx(SectionHeader, {
438
481
  icon: FileText,
439
482
  title: "FULL ACTION OBJECT"
@@ -456,7 +499,8 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
456
499
  children: [/*#__PURE__*/_jsx(ReduxActionButton, {
457
500
  type: "replay",
458
501
  text: "REPLAY",
459
- onPress: handleReplay
502
+ onPress: handleReplay,
503
+ testID: `${REDUX_TEST_ID}-replay`
460
504
  }), /*#__PURE__*/_jsx(Text, {
461
505
  style: styles.actionDescription,
462
506
  children: "Dispatch again"
@@ -466,17 +510,21 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
466
510
  children: [/*#__PURE__*/_jsx(ReduxActionButton, {
467
511
  type: "jump",
468
512
  text: "JUMP",
469
- onPress: handleJump
513
+ onPress: handleJump,
514
+ disabled: jumpDisabled,
515
+ testID: `${REDUX_TEST_ID}-jump`
470
516
  }), /*#__PURE__*/_jsx(Text, {
471
517
  style: styles.actionDescription,
472
- children: "Restore this state"
518
+ testID: `${REDUX_TEST_ID}-jump-description`,
519
+ children: stateEvicted ? "State not retained" : canTimeTravel ? "Restore this state" : "Time travel not wired"
473
520
  })]
474
521
  }), /*#__PURE__*/_jsxs(View, {
475
522
  style: styles.actionButtonWrapper,
476
523
  children: [/*#__PURE__*/_jsx(ReduxActionButton, {
477
524
  type: "copy",
478
525
  text: "COPY ACTION",
479
- onPress: handleCopyAction
526
+ onPress: handleCopyAction,
527
+ testID: `${REDUX_TEST_ID}-copy-action`
480
528
  }), /*#__PURE__*/_jsx(Text, {
481
529
  style: styles.actionDescription,
482
530
  children: isPro ? "Copy to clipboard" : "Pro feature"
@@ -486,7 +534,8 @@ export const ReduxActionInfoView = /*#__PURE__*/memo(function ReduxActionInfoVie
486
534
  children: [/*#__PURE__*/_jsx(ReduxActionButton, {
487
535
  type: "copy",
488
536
  text: "COPY PAYLOAD",
489
- onPress: handleCopyPayload
537
+ onPress: handleCopyPayload,
538
+ testID: `${REDUX_TEST_ID}-copy-payload`
490
539
  }), /*#__PURE__*/_jsx(Text, {
491
540
  style: styles.actionDescription,
492
541
  children: isPro ? "Copy payload only" : "Pro feature"
@@ -210,7 +210,9 @@ function getPrimaryText(action) {
210
210
  // tick re-renders only that Text, not this row.
211
211
  export const ReduxActionItem = /*#__PURE__*/memo(function ReduxActionItem({
212
212
  action,
213
- onPress
213
+ onPress,
214
+ linkedCount,
215
+ testID
214
216
  }) {
215
217
  const sliceName = action.sliceName ?? undefined;
216
218
  const statusColor = getStatusColor(action.category, action.hasStateChange, sliceName);
@@ -219,10 +221,11 @@ export const ReduxActionItem = /*#__PURE__*/memo(function ReduxActionItem({
219
221
  const primaryText = getPrimaryText(action);
220
222
  const actionBadge = getActionBadge(action);
221
223
 
222
- // Check for linked async actions
223
- const linkedActions = reduxActionStore.getLinkedActions(action);
224
- const linkedCount = linkedActions.length;
225
- const hasLinkedActions = linkedCount > 1;
224
+ // Check for linked async actions. The COUNT comes from the list (see the
225
+ // prop's doc); the ordered list is only needed to say where in the flow this
226
+ // action sits, and only when there is a flow at all.
227
+ const hasLinkedActions = (linkedCount ?? 0) > 1;
228
+ const linkedActions = hasLinkedActions ? reduxActionStore.getLinkedActions(action) : [];
226
229
 
227
230
  // Get position in async sequence (1/2, 2/2, etc.)
228
231
  const asyncPosition = hasLinkedActions ? linkedActions.findIndex(a => a.id === action.id) + 1 : 0;
@@ -247,10 +250,12 @@ export const ReduxActionItem = /*#__PURE__*/memo(function ReduxActionItem({
247
250
  style: [styles.asyncSequenceText, {
248
251
  color: opColor.text
249
252
  }],
253
+ testID: testID ? `${testID}-seq` : undefined,
250
254
  children: [asyncPosition, "/", linkedCount, " #", operationIndex]
251
255
  })
252
256
  }), /*#__PURE__*/_jsx(View, {
253
257
  style: styles.slowBadge,
258
+ testID: testID ? `${testID}-slow` : undefined,
254
259
  children: /*#__PURE__*/_jsx(Clock, {
255
260
  size: 12,
256
261
  color: buoyColors.error
@@ -269,10 +274,12 @@ export const ReduxActionItem = /*#__PURE__*/memo(function ReduxActionItem({
269
274
  style: [styles.asyncSequenceText, {
270
275
  color: opColor.text
271
276
  }],
277
+ testID: testID ? `${testID}-seq` : undefined,
272
278
  children: [asyncPosition, "/", linkedCount, " #", operationIndex]
273
279
  })
274
280
  }), /*#__PURE__*/_jsx(View, {
275
281
  style: styles.errorBadge,
282
+ testID: testID ? `${testID}-error` : undefined,
276
283
  children: /*#__PURE__*/_jsx(AlertCircle, {
277
284
  size: 12,
278
285
  color: buoyColors.error
@@ -291,10 +298,12 @@ export const ReduxActionItem = /*#__PURE__*/memo(function ReduxActionItem({
291
298
  style: [styles.asyncSequenceText, {
292
299
  color: opColor.text
293
300
  }],
301
+ testID: testID ? `${testID}-seq` : undefined,
294
302
  children: [asyncPosition, "/", linkedCount, " #", operationIndex]
295
303
  })
296
304
  }), action.hasStateChange && /*#__PURE__*/_jsx(View, {
297
305
  style: styles.changeBadge,
306
+ testID: testID ? `${testID}-change` : undefined,
298
307
  children: /*#__PURE__*/_jsx(Zap, {
299
308
  size: 12,
300
309
  color: buoyColors.warning
@@ -309,9 +318,11 @@ export const ReduxActionItem = /*#__PURE__*/memo(function ReduxActionItem({
309
318
  style: [styles.actionBadgeText, {
310
319
  color: statusColor
311
320
  }],
321
+ testID: testID ? `${testID}-badge` : undefined,
312
322
  children: actionBadge
313
323
  }), /*#__PURE__*/_jsx(View, {
314
324
  style: styles.changeBadge,
325
+ testID: testID ? `${testID}-change` : undefined,
315
326
  children: /*#__PURE__*/_jsx(Zap, {
316
327
  size: 12,
317
328
  color: buoyColors.warning
@@ -320,6 +331,10 @@ export const ReduxActionItem = /*#__PURE__*/memo(function ReduxActionItem({
320
331
  });
321
332
  }
322
333
  return /*#__PURE__*/_jsx(CompactRow, {
334
+ testID: testID,
335
+ statusTestID: testID ? `${testID}-status` : undefined,
336
+ statusSublabelTestID: testID ? `${testID}-sublabel` : undefined,
337
+ accessibilityLabel: `${primaryText}, ${statusLabel}, ${sublabel}`,
323
338
  statusDotColor: statusColor,
324
339
  statusLabel: statusLabel,
325
340
  statusSublabel: sublabel,
@@ -50,6 +50,13 @@ export const ReduxDetailViewToggle = /*#__PURE__*/memo(function ReduxDetailViewT
50
50
  onPress: () => !isDisabled && onViewChange(config.key),
51
51
  activeOpacity: isDisabled ? 1 : 0.8,
52
52
  disabled: isDisabled,
53
+ testID: `buoy-redux-view-${config.key}`,
54
+ accessibilityRole: "button",
55
+ accessibilityLabel: config.label,
56
+ accessibilityState: {
57
+ selected: isActive,
58
+ disabled: isDisabled
59
+ },
53
60
  children: [/*#__PURE__*/_jsxs(View, {
54
61
  style: styles.cardContent,
55
62
  children: [/*#__PURE__*/_jsx(IconComponent, {