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
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,33 @@ extension RunnerTests {
}

func execute(command: Command) throws -> Response {
if command.command == .status {
return executeStatus(command: command)
}
commandJournal.accept(command: command)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't let status probes evict the command they query

Because every request is journaled before dispatch, status probes are stored and pruned like normal commands. When the journal is already at its 64-entry limit, a status request appends its own generated commandId and pruneIfNeeded() removes the oldest entry before commandJournal.status(statusCommandId) runs, so querying that oldest retained command returns notAccepted solely because of the probe. Avoid journaling status commands or defer pruning until after the lookup so lifecycle recovery does not consume its own history slot.

Useful? React with 👍 / 👎.

commandJournal.start(command: command)
do {
let response = try executeDispatched(command: command)
commandJournal.finish(command: command, response: response)
return response
} catch {
commandJournal.fail(command: command, error: error)
throw error
}
}

private func executeStatus(command: Command) -> Response {
guard
let statusCommandId = command.statusCommandId?
.trimmingCharacters(in: .whitespacesAndNewlines),
!statusCommandId.isEmpty
else {
return Response(ok: false, error: ErrorPayload(message: "status requires statusCommandId"))
}
return Response(ok: true, data: commandJournal.status(commandId: statusCommandId))
}

private func executeDispatched(command: Command) throws -> Response {
if Thread.isMainThread {
return try executeOnMainSafely(command: command)
}
Expand Down Expand Up @@ -183,6 +210,8 @@ extension RunnerTests {
}

switch command.command {
case .status:
return executeStatus(command: command)
case .shutdown:
stopRecordingIfNeeded()
return Response(ok: true, data: DataPayload(message: "shutdown"))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import Foundation

enum RunnerCommandLifecycleState: String {
case notAccepted
case accepted
case started
case completed
case failed
}

struct RunnerCommandJournalEntry {
let commandId: String
let command: String
var state: RunnerCommandLifecycleState
var responseOk: Bool?
var responseJson: String?
var error: ErrorPayload?
}

final class RunnerCommandJournal {
private let lock = NSLock()
private let maxEntries = 64
private let maxResponseJsonBytes = 16 * 1024
private var entries: [String: RunnerCommandJournalEntry] = [:]
private var order: [String] = []

func accept(command: Command) {
guard let commandId = normalizedCommandId(command.commandId) else { return }
lock.lock()
defer { lock.unlock() }
entries[commandId] = RunnerCommandJournalEntry(
commandId: commandId,
command: command.command.rawValue,
state: .accepted,
responseOk: nil,
responseJson: nil,
error: nil
)
order.removeAll { $0 == commandId }
order.append(commandId)
pruneIfNeeded()
}

func start(command: Command) {
update(command: command, state: .started, responseOk: nil, responseJson: nil, error: nil)
}

func finish(command: Command, response: Response) {
update(
command: command,
state: response.ok ? .completed : .failed,
responseOk: response.ok,
responseJson: encodeResponseJson(response),
error: response.error
)
}

func fail(command: Command, error: Error) {
update(
command: command,
state: .failed,
responseOk: nil,
responseJson: nil,
error: ErrorPayload(message: error.localizedDescription)
)
}

func status(commandId: String) -> DataPayload {
guard let normalized = normalizedCommandId(commandId) else {
return DataPayload(lifecycleState: RunnerCommandLifecycleState.notAccepted.rawValue)
}
lock.lock()
let entry = entries[normalized]
lock.unlock()
guard let entry else {
return DataPayload(
commandId: normalized,
lifecycleState: RunnerCommandLifecycleState.notAccepted.rawValue
)
}
return DataPayload(
commandId: entry.commandId,
lifecycleState: entry.state.rawValue,
lifecycleCommand: entry.command,
lifecycleResponseOk: entry.responseOk,
lifecycleResponseJson: entry.responseJson,
lifecycleErrorCode: entry.error?.code,
lifecycleErrorMessage: entry.error?.message,
lifecycleErrorHint: entry.error?.hint
)
}

private func update(
command: Command,
state: RunnerCommandLifecycleState,
responseOk: Bool?,
responseJson: String?,
error: ErrorPayload?
) {
guard let commandId = normalizedCommandId(command.commandId) else { return }
lock.lock()
defer { lock.unlock() }
var entry = entries[commandId] ?? RunnerCommandJournalEntry(
commandId: commandId,
command: command.command.rawValue,
state: .accepted,
responseOk: nil,
responseJson: nil,
error: nil
)
entry.state = state
entry.responseOk = responseOk
entry.responseJson = responseJson
entry.error = error
entries[commandId] = entry
order.removeAll { $0 == commandId }
order.append(commandId)
pruneIfNeeded()
}

private func pruneIfNeeded() {
while order.count > maxEntries {
let removed = order.removeFirst()
entries.removeValue(forKey: removed)
}
}

private func normalizedCommandId(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}

private func encodeResponseJson(_ response: Response) -> String? {
guard response.data?.nodes == nil else { return nil }
guard let data = try? JSONEncoder().encode(response) else { return nil }
guard data.count <= maxResponseJsonBytes else { return nil }
return String(data: data, encoding: .utf8)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ enum CommandType: String, Codable {
case transformGesture
case recordStart
case recordStop
case status
case uptime
case shutdown
}
Expand Down Expand Up @@ -91,6 +92,9 @@ extension CommandType {
case .recordStop, .uptime, .shutdown:
return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: true)

case .status:
return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: true)

// Normal preflight, not retried.
// NOTE: mouseClick stays non-interaction for now — it is macOS-only and the foreground
// guard interacts with bespoke macOS activation, so classifying it needs a macOS smoke
Expand All @@ -104,6 +108,8 @@ extension CommandType {

struct Command: Codable {
let command: CommandType
let commandId: String?
let statusCommandId: String?
let appBundleId: String?
let text: String?
let selectorKey: String?
Expand Down Expand Up @@ -171,6 +177,14 @@ struct DataPayload: Codable {
let referenceWidth: Double?
let referenceHeight: Double?
let currentUptimeMs: Double?
let commandId: String?
let lifecycleState: String?
let lifecycleCommand: String?
let lifecycleResponseOk: Bool?
let lifecycleResponseJson: String?
let lifecycleErrorCode: String?
let lifecycleErrorMessage: String?
let lifecycleErrorHint: String?
let visible: Bool?
let wasVisible: Bool?
let dismissed: Bool?
Expand All @@ -192,6 +206,14 @@ struct DataPayload: Codable {
referenceWidth: Double? = nil,
referenceHeight: Double? = nil,
currentUptimeMs: Double? = nil,
commandId: String? = nil,
lifecycleState: String? = nil,
lifecycleCommand: String? = nil,
lifecycleResponseOk: Bool? = nil,
lifecycleResponseJson: String? = nil,
lifecycleErrorCode: String? = nil,
lifecycleErrorMessage: String? = nil,
lifecycleErrorHint: String? = nil,
visible: Bool? = nil,
wasVisible: Bool? = nil,
dismissed: Bool? = nil,
Expand All @@ -212,6 +234,14 @@ struct DataPayload: Codable {
self.referenceWidth = referenceWidth
self.referenceHeight = referenceHeight
self.currentUptimeMs = currentUptimeMs
self.commandId = commandId
self.lifecycleState = lifecycleState
self.lifecycleCommand = lifecycleCommand
self.lifecycleResponseOk = lifecycleResponseOk
self.lifecycleResponseJson = lifecycleResponseJson
self.lifecycleErrorCode = lifecycleErrorCode
self.lifecycleErrorMessage = lifecycleErrorMessage
self.lifecycleErrorHint = lifecycleErrorHint
self.visible = visible
self.wasVisible = wasVisible
self.dismissed = dismissed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ final class RunnerTests: XCTestCase {
var needsPostSnapshotInteractionDelay = false
var needsFirstInteractionDelay = false
var activeRecording: ScreenRecorder?
let commandJournal = RunnerCommandJournal()
let interactiveTypes: Set<XCUIElement.ElementType> = [
.button,
.cell,
Expand Down
24 changes: 24 additions & 0 deletions src/platforms/ios/__tests__/runner-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ vi.mock('../runner-macos-products.ts', async () => {
import type { DeviceInfo } from '../../../utils/device.ts';
import { AppError } from '../../../utils/errors.ts';
import type { RunnerCommand } from '../runner-contract.ts';
import { withRunnerCommandId } from '../runner-contract.ts';
import {
assertSafeDerivedCleanup,
isRetryableRunnerError,
Expand Down Expand Up @@ -159,6 +160,7 @@ const runnerProtocolCommandFixtures: Record<RunnerCommand['command'], RunnerComm
quality: 7,
},
recordStop: { command: 'recordStop' },
status: { command: 'status', statusCommandId: 'runner-command-1' },
uptime: { command: 'uptime' },
shutdown: { command: 'shutdown' },
};
Expand Down Expand Up @@ -359,6 +361,7 @@ test('runner protocol fixtures cover every runner command with JSON-safe samples
'screenshot',
'shutdown',
'snapshot',
'status',
'swipe',
'tap',
'tapSeries',
Expand All @@ -380,6 +383,27 @@ test('runner protocol fixtures cover every runner command with JSON-safe samples
assert.equal(roundTrip.recordStart!.quality, 7);
});

test('withRunnerCommandId replaces blank command ids', () => {
const command = withRunnerCommandId({ command: 'uptime', commandId: ' ' });

assert.match(command.commandId ?? '', /^runner-/);
});

test('withRunnerCommandId preserves existing command ids', () => {
const command = withRunnerCommandId({ command: 'uptime', commandId: 'runner-existing' });

assert.deepEqual(command, { command: 'uptime', commandId: 'runner-existing' });
});

test('withRunnerCommandId does not add command ids to status probes', () => {
const command = withRunnerCommandId({
command: 'status',
statusCommandId: 'runner-command-1',
});

assert.deepEqual(command, { command: 'status', statusCommandId: 'runner-command-1' });
});

test('resolveRunnerDestination uses device destination for physical devices', () => {
assert.equal(resolveRunnerDestination(iosDevice), 'platform=iOS,id=00008110-000E12341234002E');
});
Expand Down
Loading
Loading