agent-device 0.12.4 → 0.12.6
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.
- package/dist/src/152.js +1 -1
- package/dist/src/57.js +1 -1
- package/dist/src/641.js +38 -0
- package/dist/src/818.js +1 -1
- package/dist/src/backend.d.ts +527 -0
- package/dist/src/backend.js +1 -0
- package/dist/src/bin.js +62 -62
- package/dist/src/commands/index.d.ts +1880 -0
- package/dist/src/commands/index.js +1 -0
- package/dist/src/contracts.d.ts +1 -1
- package/dist/src/daemon.js +15 -15
- package/dist/src/index.d.ts +2029 -167
- package/dist/src/index.js +3 -3
- package/dist/src/io.d.ts +85 -0
- package/dist/src/io.js +1 -0
- package/dist/src/selectors.js +1 -1
- package/dist/src/testing/conformance.d.ts +753 -0
- package/dist/src/testing/conformance.js +1 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +12 -3
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +1 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift +24 -5
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +2 -0
- package/ios-runner/AgentDeviceRunner/RecordingScripts/recording-resize.swift +182 -0
- package/ios-runner/RUNNER_PROTOCOL.md +1 -1
- package/package.json +17 -1
- package/skills/agent-device/references/bootstrap-install.md +13 -0
- package/skills/agent-device/references/macos-desktop.md +1 -1
- package/skills/agent-device/references/remote-tenancy.md +28 -5
- package/skills/agent-device/references/verification.md +1 -0
- package/dist/src/155.js +0 -38
- package/dist/src/940.js +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import e from"node:assert/strict";import{commands as a,selector as s}from"../commands/index.js";let t={session:"default",app:"com.example.app",installSourcePath:"/tmp/example.app",appEventName:"example.ready",appPushPayload:{aps:{alert:"hello"}},visibleSelector:"label=Continue",visibleText:"Continue",editableTarget:s("label=Email"),fillText:"hello@example.com",point:{x:4,y:8},swipeTo:{x:24,y:28}},n=w({name:"capture",cases:[{name:"captures screenshots through the backend primitive",command:"capture.screenshot",run:async(s,t)=>{let n=await a.capture.screenshot(s,{session:t.session});e.equal(typeof n.path,"string"),e.ok(n.path.length>0)}},{name:"captures snapshots with nodes",command:"capture.snapshot",run:async(s,t)=>{let n=await a.capture.snapshot(s,{session:t.session});e.ok(Array.isArray(n.nodes))}}]}),i=w({name:"selectors",cases:[{name:"finds visible text",command:"selectors.find",run:async(s,t)=>{let n=await a.selectors.find(s,{session:t.session,query:t.visibleText,action:"exists"});e.equal(n.kind,"found"),e.equal(n.found,!0)}},{name:"reads text from a selector",command:"selectors.getText",run:async(t,n)=>{let i=await a.selectors.getText(t,{session:n.session,target:s(n.visibleSelector)});e.equal(i.kind,"text"),e.equal(i.text,n.visibleText)}},{name:"checks selector visibility",command:"selectors.isVisible",run:async(t,n)=>{let i=await a.selectors.isVisible(t,{session:n.session,target:s(n.visibleSelector)});e.equal(i.pass,!0)}},{name:"waits for visible text",command:"selectors.waitForText",run:async(s,t)=>{let n=await a.selectors.waitForText(s,{session:t.session,text:t.visibleText,timeoutMs:1});e.equal(n.kind,"text"),e.equal(n.text,t.visibleText)}}]}),o=w({name:"interactions",cases:[{name:"clicks selector targets",command:"interactions.click",run:async(t,n)=>{let i=await a.interactions.click(t,{session:n.session,target:s(n.visibleSelector)});e.equal(i.kind,"selector")}},{name:"presses explicit points",command:"interactions.press",run:async(s,t)=>{let n=await a.interactions.press(s,{session:t.session,target:{kind:"point",...t.point}});e.deepEqual(n.point,t.point)}},{name:"fills editable targets",command:"interactions.fill",run:async(s,t)=>{let n=await a.interactions.fill(s,{session:t.session,target:t.editableTarget,text:t.fillText});e.equal(n.text,t.fillText)}},{name:"types text without a target",command:"interactions.typeText",run:async(s,t)=>{let n=await a.interactions.typeText(s,{session:t.session,text:t.fillText});e.equal(n.text,t.fillText)}},{name:"focuses selector targets",command:"interactions.focus",run:async(t,n)=>{let i=await a.interactions.focus(t,{session:n.session,target:s(n.visibleSelector)});e.equal(i.kind,"selector")}},{name:"long presses selector targets",command:"interactions.longPress",run:async(t,n)=>{let i=await a.interactions.longPress(t,{session:n.session,target:s(n.visibleSelector),durationMs:500});e.equal(i.kind,"selector")}},{name:"swipes explicit points",command:"interactions.swipe",run:async(s,t)=>{let n=await a.interactions.swipe(s,{session:t.session,from:t.point,to:t.swipeTo});e.deepEqual(n.from,t.point)}},{name:"scrolls viewport targets",command:"interactions.scroll",run:async(s,t)=>{let n=await a.interactions.scroll(s,{session:t.session,target:{kind:"viewport"},direction:"down"});e.equal(n.kind,"viewport")}},{name:"pinches through the backend primitive",command:"interactions.pinch",run:async s=>{let t=await a.interactions.pinch(s,{scale:1.1});e.equal(t.kind,"pinch")}}]}),r=w({name:"system",cases:[{name:"presses back",command:"system.back",run:async(s,t)=>{let n=await a.system.back(s,{session:t.session,mode:"in-app"});e.equal(n.kind,"systemBack")}},{name:"presses home",command:"system.home",run:async(s,t)=>{let n=await a.system.home(s,{session:t.session});e.equal(n.kind,"systemHome")}},{name:"rotates devices",command:"system.rotate",run:async(s,t)=>{let n=await a.system.rotate(s,{session:t.session,orientation:"portrait"});e.equal(n.orientation,"portrait")}},{name:"reads keyboard state",command:"system.keyboard",run:async(s,t)=>{let n=await a.system.keyboard(s,{session:t.session,action:"status"});e.equal(n.kind,"keyboardState")}},{name:"reads clipboard text",command:"system.clipboard",run:async(s,t)=>{let n=await a.system.clipboard(s,{session:t.session,action:"read"});e.equal(n.kind,"clipboardText")}},{name:"opens settings",command:"system.settings",run:async(s,t)=>{let n=await a.system.settings(s,{session:t.session});e.equal(n.kind,"settingsOpened")}},{name:"reads alert state",command:"system.alert",run:async(s,t)=>{let n=await a.system.alert(s,{session:t.session,action:"get"});e.equal(n.kind,"alertStatus")}},{name:"opens app switcher",command:"system.appSwitcher",run:async(s,t)=>{let n=await a.system.appSwitcher(s,{session:t.session});e.equal(n.kind,"appSwitcherOpened")}}]}),c=w({name:"apps",cases:[{name:"opens apps by id",command:"apps.open",run:async(s,t)=>{let n=await a.apps.open(s,{session:t.session,app:t.app});e.equal(n.kind,"appOpened"),e.equal(n.target.app,t.app)}},{name:"closes apps by id",command:"apps.close",run:async(s,t)=>{let n=await a.apps.close(s,{session:t.session,app:t.app});e.equal(n.kind,"appClosed"),e.equal(n.app,t.app)}},{name:"lists apps",command:"apps.list",run:async s=>{let t=await a.apps.list(s,{filter:"all"});e.equal(t.kind,"appsList"),e.ok(Array.isArray(t.apps))}},{name:"reads app state",command:"apps.state",run:async(s,t)=>{let n=await a.apps.state(s,{session:t.session,app:t.app});e.equal(n.kind,"appState"),e.equal(n.app,t.app)}},{name:"pushes app payloads",command:"apps.push",run:async(s,t)=>{let n=await a.apps.push(s,{session:t.session,app:t.app,input:{kind:"json",payload:t.appPushPayload}});e.equal(n.kind,"appPushed"),e.equal(n.inputKind,"json")}},{name:"triggers app events",command:"apps.triggerEvent",run:async(s,t)=>{let n=await a.apps.triggerEvent(s,{session:t.session,name:t.appEventName});e.equal(n.kind,"appEventTriggered"),e.equal(n.name,t.appEventName)}}]}),l=w({name:"admin",cases:[{name:"lists devices",command:"admin.devices",run:async s=>{let t=await a.admin.devices(s,{});e.equal(t.kind,"adminDevices"),e.ok(Array.isArray(t.devices))}},{name:"boots devices",command:"admin.boot",run:async s=>{let t=await a.admin.boot(s,{});e.equal(t.kind,"deviceBooted")}},{name:"ensures simulators",command:"admin.ensureSimulator",run:async s=>{let t=await a.admin.ensureSimulator(s,{device:"iPhone 16",runtime:"iOS 18"});e.equal(t.kind,"simulatorEnsured")}},{name:"installs apps from structured sources",command:"admin.install",run:async(s,t)=>{let n=await a.admin.install(s,{app:t.app,source:{kind:"path",path:t.installSourcePath}});e.equal(n.kind,"appInstalled")}},{name:"reinstalls apps from structured sources",command:"admin.reinstall",run:async(s,t)=>{let n=await a.admin.reinstall(s,{app:t.app,source:{kind:"path",path:t.installSourcePath}});e.equal(n.kind,"appReinstalled")}},{name:"installs apps from source resolver",command:"admin.installFromSource",run:async(s,t)=>{let n=await a.admin.installFromSource(s,{source:{kind:"path",path:t.installSourcePath}});e.equal(n.kind,"appInstalledFromSource")}}]}),m=w({name:"recording",cases:[{name:"starts recording",command:"record",run:async s=>{let t=await a.recording.record(s,{action:"start"});e.equal(t.kind,"recordingStarted")}},{name:"stops traces",command:"trace",run:async s=>{let t=await a.recording.trace(s,{action:"stop"});e.equal(t.kind,"traceStopped")}}]}),p=w({name:"diagnostics",cases:[{name:"reads paginated logs",command:"diagnostics.logs",run:async s=>{let t=await a.diagnostics.logs(s,{limit:10});e.equal(t.kind,"diagnosticsLogs"),e.ok(Array.isArray(t.entries))}},{name:"dumps structured network entries",command:"diagnostics.network",run:async s=>{let t=await a.diagnostics.network(s,{limit:10,include:"summary"});e.equal(t.kind,"diagnosticsNetwork"),e.ok(Array.isArray(t.entries))}},{name:"measures perf metrics",command:"diagnostics.perf",run:async s=>{let t=await a.diagnostics.perf(s,{sampleMs:100});e.equal(t.kind,"diagnosticsPerf"),e.ok(Array.isArray(t.metrics))}}]}),d=[n,i,o,r,c,l,m,p];async function u(e,a={}){let s=a.suites??d,t=[];for(let a of s)t.push(await a.run(e));let n=t.flatMap(e=>e.failures);return{target:e.name,passed:t.reduce((e,a)=>e+a.passed,0),failed:t.reduce((e,a)=>e+a.failed,0),failures:n,suites:t}}async function y(e,a={}){let s=await u(e,a);if(s.failed>0)throw AggregateError(s.failures.map(e=>e.error),`${e.name} failed ${s.failed} agent-device conformance case${1===s.failed?"":"s"}`);return s}function w(e){return{name:e.name,cases:e.cases,run:async a=>{let s={...t,...a.fixtures},n=[],i=0;for(let t of e.cases){let o={suite:e.name,caseName:t.name,fixtures:s};try{await a.beforeEach?.(o);let e=await a.createRuntime();await t.run(e,s),i+=1}catch(a){n.push({suite:e.name,caseName:t.name,command:t.command,error:a})}finally{await a.afterEach?.(o)}}return{suite:e.name,passed:i,failed:n.length,failures:n}}}}export{l as adminConformanceSuite,c as appsConformanceSuite,y as assertCommandConformance,n as captureConformanceSuite,d as commandConformanceSuites,t as defaultCommandConformanceFixtures,p as diagnosticsConformanceSuite,o as interactionConformanceSuite,m as recordingConformanceSuite,u as runCommandConformance,i as selectorConformanceSuite,r as systemConformanceSuite};
|
package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift
CHANGED
|
@@ -183,16 +183,25 @@ extension RunnerTests {
|
|
|
183
183
|
if let requestedFps = command.fps, (requestedFps < minRecordingFps || requestedFps > maxRecordingFps) {
|
|
184
184
|
return Response(ok: false, error: ErrorPayload(message: "recordStart fps must be between \(minRecordingFps) and \(maxRecordingFps)"))
|
|
185
185
|
}
|
|
186
|
+
if let requestedQuality = command.quality, (requestedQuality < minRecordingQuality || requestedQuality > maxRecordingQuality) {
|
|
187
|
+
return Response(ok: false, error: ErrorPayload(message: "recordStart quality must be between \(minRecordingQuality) and \(maxRecordingQuality)"))
|
|
188
|
+
}
|
|
186
189
|
do {
|
|
187
190
|
let resolvedOutPath = resolveRecordingOutPath(requestedOutPath)
|
|
188
191
|
let fpsLabel = command.fps.map(String.init) ?? String(RunnerTests.defaultRecordingFps)
|
|
192
|
+
let qualityLabel = command.quality.map(String.init) ?? "native"
|
|
189
193
|
NSLog(
|
|
190
|
-
"AGENT_DEVICE_RUNNER_RECORD_START requestedOutPath=%@ resolvedOutPath=%@ fps=%@",
|
|
194
|
+
"AGENT_DEVICE_RUNNER_RECORD_START requestedOutPath=%@ resolvedOutPath=%@ fps=%@ quality=%@",
|
|
191
195
|
requestedOutPath,
|
|
192
196
|
resolvedOutPath,
|
|
193
|
-
fpsLabel
|
|
197
|
+
fpsLabel,
|
|
198
|
+
qualityLabel
|
|
199
|
+
)
|
|
200
|
+
let recorder = ScreenRecorder(
|
|
201
|
+
outputPath: resolvedOutPath,
|
|
202
|
+
fps: command.fps.map { Int32($0) },
|
|
203
|
+
quality: command.quality
|
|
194
204
|
)
|
|
195
|
-
let recorder = ScreenRecorder(outputPath: resolvedOutPath, fps: command.fps.map { Int32($0) })
|
|
196
205
|
try recorder.start { [weak self] in
|
|
197
206
|
return self?.captureRunnerFrame()
|
|
198
207
|
}
|
package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift
CHANGED
|
@@ -7,6 +7,7 @@ extension RunnerTests {
|
|
|
7
7
|
final class ScreenRecorder {
|
|
8
8
|
private let outputPath: String
|
|
9
9
|
private let fps: Int32?
|
|
10
|
+
private let quality: Int?
|
|
10
11
|
private var effectiveFps: Int32 {
|
|
11
12
|
max(1, fps ?? RunnerTests.defaultRecordingFps)
|
|
12
13
|
}
|
|
@@ -25,9 +26,10 @@ extension RunnerTests {
|
|
|
25
26
|
private var startedSession = false
|
|
26
27
|
private var startError: Error?
|
|
27
28
|
|
|
28
|
-
init(outputPath: String, fps: Int32?) {
|
|
29
|
+
init(outputPath: String, fps: Int32?, quality: Int?) {
|
|
29
30
|
self.outputPath = outputPath
|
|
30
31
|
self.fps = fps
|
|
32
|
+
self.quality = quality
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
func start(captureFrame: @escaping () -> RunnerImage?) throws {
|
|
@@ -48,7 +50,7 @@ extension RunnerTests {
|
|
|
48
50
|
while Date() < bootstrapDeadline {
|
|
49
51
|
if let image = captureFrame(), let cgImage = runnerCGImage(from: image) {
|
|
50
52
|
bootstrapImage = image
|
|
51
|
-
dimensions =
|
|
53
|
+
dimensions = scaledDimensions(width: cgImage.width, height: cgImage.height)
|
|
52
54
|
break
|
|
53
55
|
}
|
|
54
56
|
Thread.sleep(forTimeInterval: 0.05)
|
|
@@ -240,11 +242,13 @@ extension RunnerTests {
|
|
|
240
242
|
|
|
241
243
|
CVPixelBufferLockBaseAddress(pixelBuffer, [])
|
|
242
244
|
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
|
|
245
|
+
let width = CVPixelBufferGetWidth(pixelBuffer)
|
|
246
|
+
let height = CVPixelBufferGetHeight(pixelBuffer)
|
|
243
247
|
guard
|
|
244
248
|
let context = CGContext(
|
|
245
249
|
data: CVPixelBufferGetBaseAddress(pixelBuffer),
|
|
246
|
-
width:
|
|
247
|
-
height:
|
|
250
|
+
width: width,
|
|
251
|
+
height: height,
|
|
248
252
|
bitsPerComponent: 8,
|
|
249
253
|
bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer),
|
|
250
254
|
space: CGColorSpaceCreateDeviceRGB(),
|
|
@@ -253,8 +257,23 @@ extension RunnerTests {
|
|
|
253
257
|
else {
|
|
254
258
|
return nil
|
|
255
259
|
}
|
|
256
|
-
context.draw(image, in: CGRect(x: 0, y: 0, width:
|
|
260
|
+
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
257
261
|
return pixelBuffer
|
|
258
262
|
}
|
|
263
|
+
|
|
264
|
+
private func scaledDimensions(width: Int, height: Int) -> CGSize {
|
|
265
|
+
guard let quality, quality < 10 else {
|
|
266
|
+
return CGSize(width: width, height: height)
|
|
267
|
+
}
|
|
268
|
+
let scale = Double(quality) / 10.0
|
|
269
|
+
return CGSize(
|
|
270
|
+
width: scaledEvenDimension(width, scale: scale),
|
|
271
|
+
height: scaledEvenDimension(height, scale: scale)
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private func scaledEvenDimension(_ value: Int, scale: Double) -> Int {
|
|
276
|
+
max(2, Int((Double(value) * scale / 2.0).rounded()) * 2)
|
|
277
|
+
}
|
|
259
278
|
}
|
|
260
279
|
}
|
|
@@ -48,6 +48,8 @@ final class RunnerTests: XCTestCase {
|
|
|
48
48
|
let tvRemoteDoublePressDelayDefault: TimeInterval = 0.0
|
|
49
49
|
let minRecordingFps = 1
|
|
50
50
|
let maxRecordingFps = 120
|
|
51
|
+
let minRecordingQuality = 5
|
|
52
|
+
let maxRecordingQuality = 10
|
|
51
53
|
var needsPostSnapshotInteractionDelay = false
|
|
52
54
|
var needsFirstInteractionDelay = false
|
|
53
55
|
var activeRecording: ScreenRecorder?
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
enum ResizeError: Error, CustomStringConvertible {
|
|
5
|
+
case invalidArgs(String)
|
|
6
|
+
case missingVideoTrack
|
|
7
|
+
case exportFailed(String)
|
|
8
|
+
|
|
9
|
+
var description: String {
|
|
10
|
+
switch self {
|
|
11
|
+
case .invalidArgs(let message):
|
|
12
|
+
return message
|
|
13
|
+
case .missingVideoTrack:
|
|
14
|
+
return "Input video does not contain a video track."
|
|
15
|
+
case .exportFailed(let message):
|
|
16
|
+
return message
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
do {
|
|
22
|
+
try run()
|
|
23
|
+
} catch {
|
|
24
|
+
fputs("recording-resize: \(error)\n", stderr)
|
|
25
|
+
exit(1)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func run() throws {
|
|
29
|
+
let arguments = Array(CommandLine.arguments.dropFirst())
|
|
30
|
+
let parsedArgs = try parseArguments(arguments)
|
|
31
|
+
let inputURL = URL(fileURLWithPath: parsedArgs.inputPath)
|
|
32
|
+
let outputURL = URL(fileURLWithPath: parsedArgs.outputPath)
|
|
33
|
+
|
|
34
|
+
if FileManager.default.fileExists(atPath: outputURL.path) {
|
|
35
|
+
try FileManager.default.removeItem(at: outputURL)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let asset = AVURLAsset(url: inputURL)
|
|
39
|
+
guard let sourceVideoTrack = asset.tracks(withMediaType: .video).first else {
|
|
40
|
+
throw ResizeError.missingVideoTrack
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let renderSize = scaledRenderSize(for: sourceVideoTrack, quality: parsedArgs.quality)
|
|
44
|
+
let composition = AVMutableComposition()
|
|
45
|
+
let fullRange = CMTimeRange(start: .zero, duration: asset.duration)
|
|
46
|
+
|
|
47
|
+
guard let compositionVideoTrack = composition.addMutableTrack(
|
|
48
|
+
withMediaType: .video,
|
|
49
|
+
preferredTrackID: kCMPersistentTrackID_Invalid
|
|
50
|
+
) else {
|
|
51
|
+
throw ResizeError.exportFailed("Failed to create composition video track.")
|
|
52
|
+
}
|
|
53
|
+
try compositionVideoTrack.insertTimeRange(fullRange, of: sourceVideoTrack, at: .zero)
|
|
54
|
+
|
|
55
|
+
if let sourceAudioTrack = asset.tracks(withMediaType: .audio).first,
|
|
56
|
+
let compositionAudioTrack = composition.addMutableTrack(
|
|
57
|
+
withMediaType: .audio,
|
|
58
|
+
preferredTrackID: kCMPersistentTrackID_Invalid
|
|
59
|
+
) {
|
|
60
|
+
try? compositionAudioTrack.insertTimeRange(fullRange, of: sourceAudioTrack, at: .zero)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let scale = CGFloat(parsedArgs.quality) / 10.0
|
|
64
|
+
let videoComposition = AVMutableVideoComposition()
|
|
65
|
+
videoComposition.renderSize = renderSize
|
|
66
|
+
videoComposition.frameDuration = resolvedFrameDuration(for: sourceVideoTrack)
|
|
67
|
+
|
|
68
|
+
let instruction = AVMutableVideoCompositionInstruction()
|
|
69
|
+
instruction.timeRange = fullRange
|
|
70
|
+
let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack)
|
|
71
|
+
// Scale the full preferred transform (including translation) to match the smaller render canvas.
|
|
72
|
+
let scaledTransform = scaledPreferredTransform(sourceVideoTrack.preferredTransform, scale: scale)
|
|
73
|
+
layerInstruction.setTransform(scaledTransform, at: .zero)
|
|
74
|
+
instruction.layerInstructions = [layerInstruction]
|
|
75
|
+
videoComposition.instructions = [instruction]
|
|
76
|
+
|
|
77
|
+
guard let exporter = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
|
|
78
|
+
throw ResizeError.exportFailed("Failed to create export session.")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
exporter.outputURL = outputURL
|
|
82
|
+
exporter.outputFileType = .mp4
|
|
83
|
+
exporter.videoComposition = videoComposition
|
|
84
|
+
exporter.shouldOptimizeForNetworkUse = true
|
|
85
|
+
|
|
86
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
87
|
+
exporter.exportAsynchronously {
|
|
88
|
+
semaphore.signal()
|
|
89
|
+
}
|
|
90
|
+
if semaphore.wait(timeout: .now() + 120) == .timedOut {
|
|
91
|
+
exporter.cancelExport()
|
|
92
|
+
throw ResizeError.exportFailed("Resize export timed out.")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if exporter.status != .completed {
|
|
96
|
+
throw ResizeError.exportFailed(exporter.error?.localizedDescription ?? "Resize export failed.")
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func parseArguments(_ arguments: [String]) throws -> (inputPath: String, outputPath: String, quality: Int) {
|
|
101
|
+
var inputPath: String?
|
|
102
|
+
var outputPath: String?
|
|
103
|
+
var quality: Int?
|
|
104
|
+
var index = 0
|
|
105
|
+
|
|
106
|
+
while index < arguments.count {
|
|
107
|
+
let argument = arguments[index]
|
|
108
|
+
let nextIndex = index + 1
|
|
109
|
+
switch argument {
|
|
110
|
+
case "--input":
|
|
111
|
+
guard nextIndex < arguments.count else { throw ResizeError.invalidArgs("--input requires a value") }
|
|
112
|
+
inputPath = arguments[nextIndex]
|
|
113
|
+
index += 2
|
|
114
|
+
case "--output":
|
|
115
|
+
guard nextIndex < arguments.count else { throw ResizeError.invalidArgs("--output requires a value") }
|
|
116
|
+
outputPath = arguments[nextIndex]
|
|
117
|
+
index += 2
|
|
118
|
+
case "--quality":
|
|
119
|
+
guard nextIndex < arguments.count else { throw ResizeError.invalidArgs("--quality requires a value") }
|
|
120
|
+
guard let parsed = Int(arguments[nextIndex]), parsed >= 5, parsed <= 10 else {
|
|
121
|
+
throw ResizeError.invalidArgs("--quality must be an integer between 5 and 10")
|
|
122
|
+
}
|
|
123
|
+
quality = parsed
|
|
124
|
+
index += 2
|
|
125
|
+
default:
|
|
126
|
+
throw ResizeError.invalidArgs("Unknown argument: \(argument)")
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
guard let inputPath, let outputPath, let quality else {
|
|
131
|
+
throw ResizeError.invalidArgs(
|
|
132
|
+
"Usage: recording-resize.swift --input <video> --output <video> --quality <5-10>"
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
return (inputPath, outputPath, quality)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func resolvedRenderSize(for track: AVAssetTrack) -> CGSize {
|
|
139
|
+
let transformed = track.naturalSize.applying(track.preferredTransform)
|
|
140
|
+
return CGSize(width: abs(transformed.width), height: abs(transformed.height))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func scaledRenderSize(for track: AVAssetTrack, quality: Int) -> CGSize {
|
|
144
|
+
let renderSize = resolvedRenderSize(for: track)
|
|
145
|
+
guard quality < 10 else { return renderSize }
|
|
146
|
+
let scale = CGFloat(quality) / 10.0
|
|
147
|
+
return CGSize(
|
|
148
|
+
width: scaledDimension(renderSize.width, scale: scale),
|
|
149
|
+
height: scaledDimension(renderSize.height, scale: scale)
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
func scaledDimension(_ value: CGFloat, scale: CGFloat) -> CGFloat {
|
|
154
|
+
let evenValue = Int((Double(value * scale) / 2.0).rounded()) * 2
|
|
155
|
+
return CGFloat(max(2, evenValue))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func resolvedFrameDuration(for track: AVAssetTrack) -> CMTime {
|
|
159
|
+
let minFrameDuration = track.minFrameDuration
|
|
160
|
+
if minFrameDuration.isValid && !minFrameDuration.isIndefinite && minFrameDuration.seconds > 0 {
|
|
161
|
+
return minFrameDuration
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let nominalFrameRate = track.nominalFrameRate
|
|
165
|
+
if nominalFrameRate > 0 {
|
|
166
|
+
let timescale = Int32(max(1, round(nominalFrameRate)))
|
|
167
|
+
return CMTime(value: 1, timescale: timescale)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return CMTime(value: 1, timescale: 60)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
func scaledPreferredTransform(_ transform: CGAffineTransform, scale: CGFloat) -> CGAffineTransform {
|
|
174
|
+
CGAffineTransform(
|
|
175
|
+
a: transform.a * scale,
|
|
176
|
+
b: transform.b * scale,
|
|
177
|
+
c: transform.c * scale,
|
|
178
|
+
d: transform.d * scale,
|
|
179
|
+
tx: transform.tx * scale,
|
|
180
|
+
ty: transform.ty * scale
|
|
181
|
+
)
|
|
182
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-device",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.6",
|
|
4
4
|
"description": "Unified control plane for physical and virtual devices via an agent-driven CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Callstack",
|
|
@@ -12,6 +12,22 @@
|
|
|
12
12
|
"import": "./dist/src/index.js",
|
|
13
13
|
"types": "./dist/src/index.d.ts"
|
|
14
14
|
},
|
|
15
|
+
"./commands": {
|
|
16
|
+
"import": "./dist/src/commands/index.js",
|
|
17
|
+
"types": "./dist/src/commands/index.d.ts"
|
|
18
|
+
},
|
|
19
|
+
"./backend": {
|
|
20
|
+
"import": "./dist/src/backend.js",
|
|
21
|
+
"types": "./dist/src/backend.d.ts"
|
|
22
|
+
},
|
|
23
|
+
"./io": {
|
|
24
|
+
"import": "./dist/src/io.js",
|
|
25
|
+
"types": "./dist/src/io.d.ts"
|
|
26
|
+
},
|
|
27
|
+
"./testing/conformance": {
|
|
28
|
+
"import": "./dist/src/testing/conformance.js",
|
|
29
|
+
"types": "./dist/src/testing/conformance.d.ts"
|
|
30
|
+
},
|
|
15
31
|
"./artifacts": {
|
|
16
32
|
"import": "./dist/src/artifacts.js",
|
|
17
33
|
"types": "./dist/src/artifacts.d.ts"
|
|
@@ -17,6 +17,7 @@ Use this exact order when you are not sure about the installed app identifier. O
|
|
|
17
17
|
## Install path
|
|
18
18
|
|
|
19
19
|
- `install` or `reinstall`
|
|
20
|
+
- `install-from-source` when the artifact already exists at a URL the daemon can reach
|
|
20
21
|
|
|
21
22
|
## Most common mistake to avoid
|
|
22
23
|
|
|
@@ -60,14 +61,26 @@ agent-device install com.example.app ./build/app.apk --platform android --serial
|
|
|
60
61
|
agent-device install com.example.app ./build/MyApp.app --platform ios --device "iPhone 17 Pro"
|
|
61
62
|
```
|
|
62
63
|
|
|
64
|
+
```bash
|
|
65
|
+
agent-device install-from-source https://example.com/builds/app.aab --platform android
|
|
66
|
+
agent-device install-from-source https://api.github.com/repos/acme/app/actions/artifacts/123/zip --platform ios --header "authorization: Bearer TOKEN"
|
|
67
|
+
```
|
|
68
|
+
|
|
63
69
|
## Install guidance
|
|
64
70
|
|
|
65
71
|
- Use `install <app> <path>` when the app may already be installed and you do not need a fresh-state reset.
|
|
66
72
|
- Use `reinstall <app> <path>` when you explicitly need uninstall plus install as one deterministic step.
|
|
73
|
+
- Use `install-from-source <url>` when an existing artifact URL is already reachable by the daemon.
|
|
74
|
+
- Local `.apk`, `.aab`, `.app`, and `.ipa` paths go through `install` or `reinstall`; existing reachable URLs go through `install-from-source`.
|
|
75
|
+
- Do not download, re-zip, publish temporary GitHub releases, or move CI artifacts elsewhere just to make an install command work.
|
|
67
76
|
- Keep install and open as separate phases. Do not turn them into one default command flow.
|
|
68
77
|
- Supported binary formats:
|
|
69
78
|
- Android: `.apk` and `.aab`
|
|
70
79
|
- iOS: `.app` and `.ipa`
|
|
80
|
+
- Android URL sources can be direct `.apk` or `.aab` files.
|
|
81
|
+
- Trusted artifact service URLs, currently GitHub Actions and EAS, may point at archive-backed downloads that contain one installable artifact. This includes GitHub Actions artifact ZIPs whose URL path does not end in `.zip` and ZIPs containing one nested `.apk`, `.aab`, `.ipa`, or iOS `.app` tar archive.
|
|
82
|
+
- If a trusted artifact archive contains multiple installables, stop and ask for the intended artifact instead of guessing.
|
|
83
|
+
- `.aab` still requires `bundletool` in `PATH`, or `AGENT_DEVICE_BUNDLETOOL_JAR=<absolute-path-to-bundletool-all.jar>` with `java` in `PATH`, when the daemon installs the materialized artifact.
|
|
71
84
|
- For iOS `.ipa` files, `<app>` is used as the bundle id or bundle name hint when the archive contains multiple app bundles.
|
|
72
85
|
- After install or reinstall, later use `open <app>` with the exact discovered or known package/bundle identifier, not the artifact path.
|
|
73
86
|
|
|
@@ -75,7 +75,7 @@ Use `snapshot --raw --platform macos` only when debugging AX structure or collec
|
|
|
75
75
|
Things not to rely on:
|
|
76
76
|
|
|
77
77
|
- Mobile-only helpers such as `install`, `reinstall`, or `push`.
|
|
78
|
-
- Desktop-global click or
|
|
78
|
+
- Desktop-global click, fill, or gesture parity from `desktop` or `menubar` sessions.
|
|
79
79
|
- Raw coordinate assumptions across runs.
|
|
80
80
|
|
|
81
81
|
Troubleshooting:
|
|
@@ -13,7 +13,7 @@ Open this file for remote daemon HTTP flows that let an agent running in a Linux
|
|
|
13
13
|
|
|
14
14
|
## Most common mistake to avoid
|
|
15
15
|
|
|
16
|
-
Do not run remote tenant work by repeating `--remote-config` on every command. `--remote-config` is a `connect` input. After connecting, use normal `agent-device` commands; the active connection supplies daemon URL, tenant, run,
|
|
16
|
+
Do not run remote tenant work by repeating `--remote-config` on every command. `--remote-config` is a `connect` input. After connecting, use normal `agent-device` commands; the active connection supplies daemon URL, tenant, run, and session context, then resolves lease and Metro details only when a later command actually needs them.
|
|
17
17
|
|
|
18
18
|
## Preferred remote flow
|
|
19
19
|
|
|
@@ -27,13 +27,28 @@ agent-device connect \
|
|
|
27
27
|
--remote-config ./remote-config.json
|
|
28
28
|
|
|
29
29
|
agent-device install com.example.app ./app.apk
|
|
30
|
+
agent-device install-from-source https://example.com/builds/app.apk --platform android
|
|
30
31
|
agent-device open com.example.app --relaunch
|
|
31
32
|
agent-device snapshot -i
|
|
32
33
|
agent-device fill @e3 "test@example.com"
|
|
33
34
|
agent-device disconnect
|
|
34
35
|
```
|
|
35
36
|
|
|
36
|
-
`connect` resolves the remote profile,
|
|
37
|
+
`connect` resolves the remote profile, generates a local session name when the profile omits one, stores local non-secret connection state, and defers tenant lease allocation plus Metro preparation until a later command needs them. When a command such as `open`, `install`, `apps`, or `snapshot` needs a lease, the client allocates or refreshes it from the connected scope. When a command needs Metro runtime hints, the client prepares Metro locally at that point and starts the local Metro companion when the bridge needs it, including `batch` runs whose steps open an app. `disconnect` closes the session when possible, stops the Metro companion owned by that connection, releases the lease when one was allocated, and removes local connection state.
|
|
38
|
+
|
|
39
|
+
After `connect`, normal `agent-device` commands use the active remote connection. Do not repeat `--remote-config` on every command.
|
|
40
|
+
|
|
41
|
+
Remote install examples:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
agent-device install com.example.app ./app.apk
|
|
45
|
+
agent-device install-from-source https://example.com/builds/app.aab --platform android
|
|
46
|
+
agent-device install-from-source https://api.github.com/repos/acme/app/actions/artifacts/123/zip --platform ios --header "authorization: Bearer TOKEN"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- Use `install` or `reinstall` for local paths; remote daemons upload local artifacts automatically.
|
|
50
|
+
- Use `install-from-source` for artifact URLs the remote daemon can reach.
|
|
51
|
+
- For local-path versus URL artifact rules, follow [bootstrap-install.md](bootstrap-install.md).
|
|
37
52
|
|
|
38
53
|
Use `agent-device connection status --session adc-android` to inspect the active connection without reading JSON state manually. Status output must not include auth tokens.
|
|
39
54
|
|
|
@@ -48,18 +63,26 @@ Example `remote-config.json` shape:
|
|
|
48
63
|
"tenant": "acme",
|
|
49
64
|
"runId": "run-123",
|
|
50
65
|
"sessionIsolation": "tenant",
|
|
51
|
-
"session": "adc-android",
|
|
52
66
|
"platform": "android",
|
|
67
|
+
"metroPublicBaseUrl": "http://127.0.0.1:8081"
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Optional overrides stay available for advanced cases:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"session": "adc-android",
|
|
53
76
|
"leaseBackend": "android-instance",
|
|
54
77
|
"metroProjectRoot": ".",
|
|
55
|
-
"
|
|
78
|
+
"metroKind": "expo",
|
|
56
79
|
"metroProxyBaseUrl": "https://bridge.example.com/metro/acme/run-123"
|
|
57
80
|
}
|
|
58
81
|
```
|
|
59
82
|
|
|
60
83
|
- Keep secrets in env/config managed by the operator boundary. Do not persist auth tokens in connection state.
|
|
61
84
|
- Omit Metro fields for non-React Native flows.
|
|
62
|
-
- Put `tenant`, `runId`,
|
|
85
|
+
- Put `tenant`, `runId`, and `sessionIsolation` in the remote profile so agents can run `agent-device connect --remote-config ./remote-config.json` without extra scope flags. Add `platform`, `leaseBackend`, `session`, or Metro overrides only when the default inference is not enough for that flow.
|
|
63
86
|
- Explicit command-line flags override connected defaults. Use them intentionally when switching session, platform, target, tenant, run, or lease scope.
|
|
64
87
|
- For React Native Metro runs with `metroProxyBaseUrl`, `agent-device >= 0.11.12` can manage the local companion tunnel, but Metro itself still needs to be running locally.
|
|
65
88
|
- Use a lease backend that matches the bridge target platform, for example `android-instance`, `ios-instance`, or an explicit `--lease-backend` override.
|
|
@@ -82,6 +82,7 @@ agent-device record stop
|
|
|
82
82
|
- On iOS, recording is a wrapper around `simctl` for simulators and the corresponding device capture path for physical devices.
|
|
83
83
|
- On Android, recording is a wrapper around `adb`.
|
|
84
84
|
- Recording writes a video artifact and a gesture-telemetry sidecar JSON.
|
|
85
|
+
- Use `record start <path> --quality 5` when a smaller video is easier to inspect or share. The scale is 5-10, where 10 is native resolution; omit it to preserve native/current resolution.
|
|
85
86
|
- On macOS hosts, touch overlay burn-in is available for supported recordings.
|
|
86
87
|
- On non-macOS hosts, recording still succeeds but the video stays raw and `record stop` can return an `overlayWarning`.
|
|
87
88
|
- If the agent already knows the interaction sequence and wants a more lifelike, uninterrupted recording, drive the flow with `batch` while recording instead of replanning between each step.
|