Skip to content

Commit 784cf20

Browse files
authored
refactor(apple): declare journal retention in command traits (#3094)
1 parent c973d38 commit 784cf20

3 files changed

Lines changed: 95 additions & 74 deletions

File tree

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift‎

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -125,22 +125,9 @@ final class RunnerCommandJournal {
125125
}
126126

127127
private func encodeResponseJson(command: Command, response: Response) -> String? {
128-
guard shouldRetainResponseJson(command: command) else { return nil }
128+
guard command.traits.retainsJournalResponseJson else { return nil }
129129
guard let data = try? JSONEncoder().encode(response) else { return nil }
130130
guard data.count <= maxResponseJsonBytes else { return nil }
131131
return String(data: data, encoding: .utf8)
132132
}
133-
134-
private func shouldRetainResponseJson(command: Command) -> Bool {
135-
switch command.command {
136-
case .snapshot, .screenshot:
137-
return false
138-
case .tap, .mouseClick, .longPress, .drag,
139-
.remotePress, .type, .swipe, .scroll, .desktopScroll, .findText, .querySelector, .readText,
140-
.backInApp, .backSystem, .home, .rotate, .appSwitcher, .actionButton, .keyboardDismiss, .keyboardReturn,
141-
.alert, .sequence, .gesture, .gestureViewport, .recordStart, .recordStop,
142-
.status, .uptime, .appState, .pasteboardWrite, .activate, .terminate, .targetReset, .shutdown:
143-
return true
144-
}
145-
}
146133
}

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift‎

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -93,25 +93,29 @@ struct CommandTraits {
9393
let isInteraction: Bool
9494
/// Whether the command is eligible for the session-invalidating retry.
9595
let retryOnSessionLoss: Bool
96-
/// What the runner may do when the command's app is not running. The one fact with no default: no
97-
/// command inherits a launch answer from how it was classified for anything else.
96+
/// What the runner may do when the command's app is not running. No command inherits a launch
97+
/// answer from how it was classified for anything else.
9898
let launchPolicy: CommandLaunchPolicy
9999
/// Whether an XCTest-recorded failure during this command turns its own healthy response into a
100100
/// failure and invalidates the session. That conversion is the only evidence a mutation with no
101101
/// settle and no post-action observation ever landed, while a command that reports the runner's own
102102
/// state or drives its lifecycle has no user-visible mutation to prove.
103103
let convertsRecordedFailure: Bool
104+
/// Whether the journal may store this command's response JSON, subject to its byte limit.
105+
let retainsJournalResponseJson: Bool
104106

105107
init(
106108
isInteraction: Bool = false,
107109
retryOnSessionLoss: Bool = false,
108110
launchPolicy: CommandLaunchPolicy,
109-
convertsRecordedFailure: Bool = false
111+
convertsRecordedFailure: Bool = false,
112+
retainsJournalResponseJson: Bool
110113
) {
111114
self.isInteraction = isInteraction
112115
self.retryOnSessionLoss = retryOnSessionLoss
113116
self.launchPolicy = launchPolicy
114117
self.convertsRecordedFailure = convertsRecordedFailure
118+
self.retainsJournalResponseJson = retainsJournalResponseJson
115119
}
116120
}
117121

@@ -121,52 +125,68 @@ fileprivate extension CommandTraits {
121125
static let interaction = CommandTraits(
122126
isInteraction: true,
123127
launchPolicy: .mayLaunch,
124-
convertsRecordedFailure: true
128+
convertsRecordedFailure: true,
129+
retainsJournalResponseJson: true
125130
)
126131

127132
/// Mutations the runner performs without the element-interaction preflight. NOTE: `mouseClick`
128133
/// stays non-interaction for now — it is macOS-only and the foreground guard interacts with
129134
/// bespoke macOS activation, so classifying it needs a macOS smoke check first (tracked as a
130135
/// follow-up).
131-
static let appMutation = CommandTraits(launchPolicy: .mayLaunch, convertsRecordedFailure: true)
136+
static let appMutation = CommandTraits(
137+
launchPolicy: .mayLaunch, convertsRecordedFailure: true, retainsJournalResponseJson: true
138+
)
132139

133140
/// Reads of the session app: replayable after session invalidation, and refused rather than
134141
/// answered by starting the app.
135-
static let appRead = CommandTraits(retryOnSessionLoss: true, launchPolicy: .existingApp)
142+
static func appRead(retainsJournalResponseJson: Bool) -> CommandTraits {
143+
CommandTraits(
144+
retryOnSessionLoss: true, launchPolicy: .existingApp,
145+
retainsJournalResponseJson: retainsJournalResponseJson
146+
)
147+
}
136148

137149
/// Selector resolution is an observation: it refuses a stopped app instead of bare-launching it,
138150
/// and the runner still must not replay it after session invalidation. Those are two facts about
139151
/// one command, which is why they are two declarations (#2890). The refusal is the iOS-enforced
140152
/// half: off iOS a selector read of a stopped app still activates it, as it did before this axis.
141153
static let selectorResolution = CommandTraits(
142154
launchPolicy: .existingApp,
143-
convertsRecordedFailure: true
155+
convertsRecordedFailure: true,
156+
retainsJournalResponseJson: true
144157
)
145158

146159
/// Reads the runner answers from its own capture and state, so preparation never brings an app
147160
/// forward; a capture aimed at an app still observes that app while it executes.
148-
static let runnerCaptureRead = CommandTraits(retryOnSessionLoss: true, launchPolicy: .noApp)
161+
static func runnerCaptureRead(retainsJournalResponseJson: Bool) -> CommandTraits {
162+
CommandTraits(
163+
retryOnSessionLoss: true, launchPolicy: .noApp,
164+
retainsJournalResponseJson: retainsJournalResponseJson
165+
)
166+
}
149167

150168
/// The runner's own lifecycle: no session app is brought forward, and no mutation is proven.
151-
static let runnerLifecycle = CommandTraits(launchPolicy: .noApp)
169+
static let runnerLifecycle = CommandTraits(launchPolicy: .noApp, retainsJournalResponseJson: true)
152170

153171
/// Device state the runner sets from its own process, such as the pasteboard: no app is brought
154172
/// forward, since the state belongs to the device rather than to the session app, and no UI
155173
/// mutation is proven.
156-
static let deviceState = CommandTraits(launchPolicy: .noApp)
174+
static let deviceState = CommandTraits(launchPolicy: .noApp, retainsJournalResponseJson: true)
157175

158176
/// Commands hosted by the surface that already has focus, which no activation may cancel. A
159177
/// hardware press belongs to the system rather than to the session app, and an alert answers from
160178
/// the modal where it sits; both mutate.
161179
static let presentedSurfaceMutation = CommandTraits(
162180
launchPolicy: .presentedSurface,
163-
convertsRecordedFailure: true
181+
convertsRecordedFailure: true,
182+
retainsJournalResponseJson: true
164183
)
165184

166185
/// `alert get` changes nothing, so it is the one alert action that may be replayed.
167186
static let presentedSurfaceQuery = CommandTraits(
168187
retryOnSessionLoss: true,
169-
launchPolicy: .presentedSurface
188+
launchPolicy: .presentedSurface,
189+
retainsJournalResponseJson: true
170190
)
171191
}
172192

@@ -179,8 +199,8 @@ extension CommandTraits {
179199

180200
extension Command {
181201
/// Whether arriving at the prepared command path invalidates a remembered text-entry tap. Not a
182-
/// fifth trait: everywhere but the two owner commands, it is having a mutation to prove that makes
183-
/// the witness stale, so this reads `convertsRecordedFailure` and that set rather than declaring a
202+
/// separate trait: everywhere but the two owner commands, having a mutation to prove makes the
203+
/// witness stale, so this reads `convertsRecordedFailure` and that set rather than declaring a
184204
/// fact no command would answer for itself (#2890 review). `executeOnMainPrepared` is its only
185205
/// consumer, and the exhaustive table test pins the answer for every command.
186206
var invalidatesRememberedTextEntryTap: Bool {
@@ -243,13 +263,19 @@ extension Command {
243263
.keyboardDismiss, .keyboardReturn, .sequence, .gesture:
244264
return .interaction
245265

246-
case .findText, .readText, .snapshot, .gestureViewport:
247-
return .appRead
266+
case .findText, .readText, .gestureViewport:
267+
return .appRead(retainsJournalResponseJson: true)
268+
269+
case .snapshot:
270+
return .appRead(retainsJournalResponseJson: false)
248271

249272
// appState reads the session app's XCUIApplication.state; bringing no app forward is what makes
250273
// its answer the state the app is in, not the one a repair leaves.
251-
case .screenshot, .status, .appState:
252-
return .runnerCaptureRead
274+
case .status, .appState:
275+
return .runnerCaptureRead(retainsJournalResponseJson: true)
276+
277+
case .screenshot:
278+
return .runnerCaptureRead(retainsJournalResponseJson: false)
253279

254280
case .alert:
255281
return (action ?? "get").lowercased() == "get"

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift‎

Lines changed: 50 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -153,19 +153,22 @@ extension RunnerTests {
153153
let retryOnSessionLoss: Bool
154154
let launchPolicy: CommandLaunchPolicy
155155
let convertsRecordedFailure: Bool
156+
let retainsJournalResponseJson: Bool
156157
}
157158

158159
private func expectation(
159160
interaction: Bool,
160161
retry: Bool,
161162
launch: CommandLaunchPolicy,
162-
converts: Bool
163+
converts: Bool,
164+
retains: Bool
163165
) -> ExpectedTraits {
164166
ExpectedTraits(
165167
isInteraction: interaction,
166168
retryOnSessionLoss: retry,
167169
launchPolicy: launch,
168-
convertsRecordedFailure: converts
170+
convertsRecordedFailure: converts,
171+
retainsJournalResponseJson: retains
169172
)
170173
}
171174

@@ -186,6 +189,11 @@ extension RunnerTests {
186189
expectation.convertsRecordedFailure,
187190
"\(request) convertsRecordedFailure"
188191
)
192+
XCTAssertEqual(
193+
traits.retainsJournalResponseJson,
194+
expectation.retainsJournalResponseJson,
195+
"\(request) retainsJournalResponseJson"
196+
)
189197
}
190198

191199
/// The commands the merge-base classified read-only, copied from its `CommandType.traits`
@@ -244,54 +252,54 @@ extension RunnerTests {
244252
/// a concrete launch case, so re-pointing a command at another policy fails that row.
245253
func testEveryCommandDeclaresEveryRunnerSideDecisionTogether() throws {
246254
let table: [(CommandType, ExpectedTraits)] = [
247-
(.tap, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
248-
(.mouseClick, expectation(interaction: false, retry: false, launch: .mayLaunch, converts: true)),
249-
(.longPress, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
250-
(.drag, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
251-
(.remotePress, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
252-
(.type, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
253-
(.swipe, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
254-
(.scroll, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
255-
(.desktopScroll, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
256-
(.findText, expectation(interaction: false, retry: true, launch: .existingApp, converts: false)),
255+
(.tap, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
256+
(.mouseClick, expectation(interaction: false, retry: false, launch: .mayLaunch, converts: true, retains: true)),
257+
(.longPress, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
258+
(.drag, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
259+
(.remotePress, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
260+
(.type, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
261+
(.swipe, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
262+
(.scroll, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
263+
(.desktopScroll, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
264+
(.findText, expectation(interaction: false, retry: true, launch: .existingApp, converts: false, retains: true)),
257265
(
258266
.querySelector,
259-
expectation(interaction: false, retry: false, launch: .existingApp, converts: true)
267+
expectation(interaction: false, retry: false, launch: .existingApp, converts: true, retains: true)
260268
),
261-
(.readText, expectation(interaction: false, retry: true, launch: .existingApp, converts: false)),
262-
(.snapshot, expectation(interaction: false, retry: true, launch: .existingApp, converts: false)),
263-
(.screenshot, expectation(interaction: false, retry: true, launch: .noApp, converts: false)),
264-
(.backInApp, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
265-
(.backSystem, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
266-
(.home, expectation(interaction: false, retry: false, launch: .mayLaunch, converts: true)),
267-
(.rotate, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
268-
(.appSwitcher, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
269+
(.readText, expectation(interaction: false, retry: true, launch: .existingApp, converts: false, retains: true)),
270+
(.snapshot, expectation(interaction: false, retry: true, launch: .existingApp, converts: false, retains: false)),
271+
(.screenshot, expectation(interaction: false, retry: true, launch: .noApp, converts: false, retains: false)),
272+
(.backInApp, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
273+
(.backSystem, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
274+
(.home, expectation(interaction: false, retry: false, launch: .mayLaunch, converts: true, retains: true)),
275+
(.rotate, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
276+
(.appSwitcher, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
269277
(
270278
.actionButton,
271-
expectation(interaction: false, retry: false, launch: .presentedSurface, converts: true)
279+
expectation(interaction: false, retry: false, launch: .presentedSurface, converts: true, retains: true)
272280
),
273-
(.keyboardDismiss, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
274-
(.keyboardReturn, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
281+
(.keyboardDismiss, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
282+
(.keyboardReturn, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
275283
(
276284
.alert,
277-
expectation(interaction: false, retry: true, launch: .presentedSurface, converts: false)
285+
expectation(interaction: false, retry: true, launch: .presentedSurface, converts: false, retains: true)
278286
),
279-
(.sequence, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
280-
(.gesture, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true)),
287+
(.sequence, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
288+
(.gesture, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)),
281289
(
282290
.gestureViewport,
283-
expectation(interaction: false, retry: true, launch: .existingApp, converts: false)
291+
expectation(interaction: false, retry: true, launch: .existingApp, converts: false, retains: true)
284292
),
285-
(.recordStart, expectation(interaction: false, retry: false, launch: .mayLaunch, converts: true)),
286-
(.recordStop, expectation(interaction: false, retry: false, launch: .noApp, converts: false)),
287-
(.status, expectation(interaction: false, retry: true, launch: .noApp, converts: false)),
288-
(.uptime, expectation(interaction: false, retry: false, launch: .noApp, converts: false)),
289-
(.appState, expectation(interaction: false, retry: true, launch: .noApp, converts: false)),
290-
(.pasteboardWrite, expectation(interaction: false, retry: false, launch: .noApp, converts: false)),
291-
(.activate, expectation(interaction: false, retry: false, launch: .mayLaunch, converts: true)),
292-
(.terminate, expectation(interaction: false, retry: false, launch: .noApp, converts: false)),
293-
(.targetReset, expectation(interaction: false, retry: false, launch: .noApp, converts: false)),
294-
(.shutdown, expectation(interaction: false, retry: false, launch: .noApp, converts: false))
293+
(.recordStart, expectation(interaction: false, retry: false, launch: .mayLaunch, converts: true, retains: true)),
294+
(.recordStop, expectation(interaction: false, retry: false, launch: .noApp, converts: false, retains: true)),
295+
(.status, expectation(interaction: false, retry: true, launch: .noApp, converts: false, retains: true)),
296+
(.uptime, expectation(interaction: false, retry: false, launch: .noApp, converts: false, retains: true)),
297+
(.appState, expectation(interaction: false, retry: true, launch: .noApp, converts: false, retains: true)),
298+
(.pasteboardWrite, expectation(interaction: false, retry: false, launch: .noApp, converts: false, retains: true)),
299+
(.activate, expectation(interaction: false, retry: false, launch: .mayLaunch, converts: true, retains: true)),
300+
(.terminate, expectation(interaction: false, retry: false, launch: .noApp, converts: false, retains: true)),
301+
(.targetReset, expectation(interaction: false, retry: false, launch: .noApp, converts: false, retains: true)),
302+
(.shutdown, expectation(interaction: false, retry: false, launch: .noApp, converts: false, retains: true))
295303
]
296304
for (type, rowExpectation) in table {
297305
let request = #"{"command":"\#(type.rawValue)"}"#
@@ -314,15 +322,15 @@ extension RunnerTests {
314322
// The one payload-dependent command settles each fact per action: `get` changes nothing and may
315323
// be replayed, while `accept` and `dismiss` mutate and must not be.
316324
let alertCases: [(action: String?, expectation: ExpectedTraits)] = [
317-
(nil, expectation(interaction: false, retry: true, launch: .presentedSurface, converts: false)),
318-
("get", expectation(interaction: false, retry: true, launch: .presentedSurface, converts: false)),
325+
(nil, expectation(interaction: false, retry: true, launch: .presentedSurface, converts: false, retains: true)),
326+
("get", expectation(interaction: false, retry: true, launch: .presentedSurface, converts: false, retains: true)),
319327
(
320328
"accept",
321-
expectation(interaction: false, retry: false, launch: .presentedSurface, converts: true)
329+
expectation(interaction: false, retry: false, launch: .presentedSurface, converts: true, retains: true)
322330
),
323331
(
324332
"dismiss",
325-
expectation(interaction: false, retry: false, launch: .presentedSurface, converts: true)
333+
expectation(interaction: false, retry: false, launch: .presentedSurface, converts: true, retains: true)
326334
)
327335
]
328336
for alertCase in alertCases {

0 commit comments

Comments
 (0)