mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3aee2d0ca | ||
|
|
4fb6de564c | ||
|
|
975693e96e | ||
|
|
b84233bf07 | ||
|
|
6dec2f9a13 | ||
|
|
c15e184bb8 | ||
|
|
89740dbbfd | ||
|
|
390a165c57 | ||
|
|
f7fb43b974 | ||
|
|
a32080fadf | ||
|
|
55017fbf70 | ||
|
|
7fd8a3a75b | ||
|
|
0f38c2ad82 |
No files matched your search
@@ -26,6 +26,9 @@ jobs:
|
||||
uses: ./.github/actions/setup-mise
|
||||
env:
|
||||
MISE_NODE_VERSION: ${{ matrix.node }}
|
||||
- name: Install ffmpeg
|
||||
# build-tools recording-recovery tests cut and decode real MP4 files with ffmpeg and ffprobe.
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
env:
|
||||
|
||||
@@ -8,8 +8,14 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Allow workflow submissions to skip loading app config when project and application identifiers are supplied. ([#4471](https://github.com/expo/eas-cli/pull/4471) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Record Android device sessions and upload the screen recording, including the partial recording a killed Device Hub leaves behind. ([#4418](https://github.com/expo/eas-cli/pull/4418) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Disable agent-device's internal idle timeouts in remote sessions, so only the session's own idle and duration limits end it. ([#4485](https://github.com/expo/eas-cli/pull/4485) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Record both iPhone Duo panels side by side and upright, so iOS Simulator screen recordings are no longer black while the device is folded.
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
|
||||
@@ -27,10 +33,13 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Report early Argent server exits during simulator startup instead of waiting for the full readiness timeout. ([#4454](https://github.com/expo/eas-cli/pull/4454) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [eas-cli] Stop spinners from printing a new line on every frame after the terminal gets narrower. ([#4470](https://github.com/expo/eas-cli/pull/4470) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Exclude time waiting for simulator concurrency from the session startup timeout and show queue progress. ([#4411](https://github.com/expo/eas-cli/pull/4411) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] List `eas simulator` only once in `eas --help`. The `sim` shorthand is now a hidden alias, so it still runs but no longer duplicates the entry. ([#4457](https://github.com/expo/eas-cli/pull/4457) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Start the Argent tool-server with `ARGENT_EMULATOR_NO_WINDOW=1`, so Argent can boot the Android emulator again after it stops. Without it, the windowed emulator fails on the Linux image because `libpulse.so.0` is missing. ([#4465](https://github.com/expo/eas-cli/pull/4465) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Fix `eas account:view` printing `Role: undefined` for the new Release Manager role. ([#4300](https://github.com/expo/eas-cli/pull/4300) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- [eas-cli] Include legacy serve-sim sessions in `eas simulator:list --type web-preview-only`, and list `web-preview-only` only once in the `--type` options. ([#4466](https://github.com/expo/eas-cli/pull/4466) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ let package = Package(
|
||||
.target(
|
||||
name: "RecordSim",
|
||||
linkerSettings: [
|
||||
.linkedFramework("Accelerate"),
|
||||
.linkedFramework("AVFoundation"),
|
||||
.linkedFramework("CoreGraphics"),
|
||||
.linkedFramework("CoreMedia"),
|
||||
|
||||
@@ -9,6 +9,13 @@ It implements one recording path:
|
||||
3. Feed one continuous `AVAssetWriter`.
|
||||
4. Emit Apple HLS/CMAF-style fragmented MP4 segments.
|
||||
|
||||
Foldable simulators such as iPhone Duo keep a framebuffer for every integrated
|
||||
panel. The recorder puts all integrated panels side by side in one video, in
|
||||
screen ID order, so the video follows the device when it folds. The panel that
|
||||
is not in use is black. Each panel is rotated upright by the `nativeRotation`
|
||||
in the device profile, which turns the iPhone Duo inner panel 90 degrees
|
||||
clockwise. Other simulators record their largest framebuffer.
|
||||
|
||||
The output is suitable for uploading while recording. `session.json` contains
|
||||
the ordered segment metadata needed to build a playlist later.
|
||||
|
||||
|
||||
+121
-22
@@ -8,6 +8,18 @@ final class FramebufferDisplaySource {
|
||||
let width: Int
|
||||
let height: Int
|
||||
let seed: UInt32
|
||||
/// Clockwise rotation in degrees (0, 90, 180, or 270) that shows the panel upright.
|
||||
let rotation: Int
|
||||
|
||||
var uprightWidth: Int { rotation % 180 == 0 ? width : height }
|
||||
var uprightHeight: Int { rotation % 180 == 0 ? height : width }
|
||||
}
|
||||
|
||||
/// The panels to record, in a stable order. A nil entry is a panel that has no surface yet.
|
||||
struct FrameSnapshot {
|
||||
let panels: [SurfaceSnapshot?]
|
||||
|
||||
var seeds: [UInt32?] { panels.map { $0?.seed } }
|
||||
}
|
||||
|
||||
private let deviceUDID: String
|
||||
@@ -15,6 +27,10 @@ final class FramebufferDisplaySource {
|
||||
private let onFrame: () -> Void
|
||||
private let onSurfaceChange: () -> Void
|
||||
private var descriptors: [NSObject] = []
|
||||
/// Integrated panels in screen ID order, which puts the iPhone Duo front panel first.
|
||||
private var integratedPanels: [(descriptor: NSObject, rotation: Int)] = []
|
||||
/// Mounting rotation of each integrated panel from the device profile, by screen ID.
|
||||
private var nativeRotations: [UInt32: Int] = [:]
|
||||
private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
|
||||
private var retainedBlocks: [AnyObject] = []
|
||||
private var ioClient: NSObject?
|
||||
@@ -46,40 +62,54 @@ final class FramebufferDisplaySource {
|
||||
throw RecorderError.make(3, "Failed to get simulator IO client")
|
||||
}
|
||||
ioClient = io
|
||||
nativeRotations = Self.nativeRotations(device: device)
|
||||
try wireUpFramebuffer()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
unregisterCallbacks()
|
||||
descriptors.removeAll()
|
||||
integratedPanels.removeAll()
|
||||
retainedBlocks.removeAll()
|
||||
ioClient = nil
|
||||
}
|
||||
|
||||
func surfaceSnapshot() -> SurfaceSnapshot? {
|
||||
let surfaceSelector = NSSelectorFromString("framebufferSurface")
|
||||
var bestSnapshot: SurfaceSnapshot?
|
||||
var bestArea = 0
|
||||
for descriptor in descriptors {
|
||||
guard let surfaceObject = descriptor.perform(surfaceSelector)?.takeUnretainedValue()
|
||||
else {
|
||||
continue
|
||||
}
|
||||
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
|
||||
let width = IOSurfaceGetWidth(surface)
|
||||
let height = IOSurfaceGetHeight(surface)
|
||||
let area = width * height
|
||||
if area > bestArea {
|
||||
bestSnapshot = SurfaceSnapshot(
|
||||
surface: surface,
|
||||
width: width,
|
||||
height: height,
|
||||
seed: IOSurfaceGetSeed(surface)
|
||||
)
|
||||
bestArea = area
|
||||
/// Foldable simulators such as iPhone Duo keep an IOSurface alive for every integrated panel,
|
||||
/// and the panel that is not in use stays black. Record all integrated panels so the video
|
||||
/// follows a fold; other simulators record their largest surface.
|
||||
func frameSnapshot() -> FrameSnapshot? {
|
||||
if integratedPanels.count >= 2 {
|
||||
let panels = integratedPanels.map {
|
||||
surfaceSnapshot(for: $0.descriptor, rotation: $0.rotation)
|
||||
}
|
||||
return panels.contains { $0 != nil } ? FrameSnapshot(panels: panels) : nil
|
||||
}
|
||||
return bestSnapshot
|
||||
let largest = descriptors.compactMap { surfaceSnapshot(for: $0, rotation: 0) }.max {
|
||||
$0.width * $0.height < $1.width * $1.height
|
||||
}
|
||||
return largest.map { FrameSnapshot(panels: [$0]) }
|
||||
}
|
||||
|
||||
private func surfaceSnapshot(for descriptor: NSObject, rotation: Int) -> SurfaceSnapshot? {
|
||||
guard
|
||||
let surfaceObject = descriptor.perform(NSSelectorFromString("framebufferSurface"))?
|
||||
.takeUnretainedValue()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
|
||||
let width = IOSurfaceGetWidth(surface)
|
||||
let height = IOSurfaceGetHeight(surface)
|
||||
guard width > 0, height > 0 else {
|
||||
return nil
|
||||
}
|
||||
return SurfaceSnapshot(
|
||||
surface: surface,
|
||||
width: width,
|
||||
height: height,
|
||||
seed: IOSurfaceGetSeed(surface),
|
||||
rotation: rotation
|
||||
)
|
||||
}
|
||||
|
||||
func rewireFramebuffer() throws {
|
||||
@@ -94,6 +124,12 @@ final class FramebufferDisplaySource {
|
||||
let nextDescriptors = try findFramebufferDescriptors(io: io)
|
||||
unregisterCallbacks()
|
||||
descriptors = nextDescriptors
|
||||
integratedPanels = Self.integratedPanels(in: nextDescriptors).map { panel in
|
||||
// A panel mounted at 270 degrees, such as the iPhone Duo inner panel, turns 90
|
||||
// degrees clockwise to be upright.
|
||||
let nativeRotation = nativeRotations[panel.screenID] ?? 0
|
||||
return (panel.descriptor, (360 - nativeRotation) % 360)
|
||||
}
|
||||
retainedBlocks.removeAll()
|
||||
do {
|
||||
for descriptor in descriptors {
|
||||
@@ -102,6 +138,7 @@ final class FramebufferDisplaySource {
|
||||
} catch {
|
||||
unregisterCallbacks()
|
||||
descriptors.removeAll()
|
||||
integratedPanels.removeAll()
|
||||
retainedBlocks.removeAll()
|
||||
throw error
|
||||
}
|
||||
@@ -135,6 +172,61 @@ final class FramebufferDisplaySource {
|
||||
return candidates
|
||||
}
|
||||
|
||||
/// Xcode releases without the screen properties API report no integrated panels, which keeps
|
||||
/// them on the largest-surface path.
|
||||
private static func integratedPanels(
|
||||
in descriptors: [NSObject]
|
||||
) -> [(screenID: UInt32, descriptor: NSObject)] {
|
||||
typealias Panel = (screenID: UInt32, descriptor: NSObject)
|
||||
let panels = descriptors.compactMap { descriptor -> Panel? in
|
||||
let selector = NSSelectorFromString("screenProperties")
|
||||
guard descriptor.responds(to: selector),
|
||||
let properties = descriptor.perform(selector)?.takeUnretainedValue()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let object: AnyObject = properties
|
||||
guard object.recordSimScreenType?() == integratedScreenType,
|
||||
let screenID = object.recordSimScreenID?()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return (screenID, descriptor)
|
||||
}
|
||||
return panels.sorted { $0.screenID < $1.screenID }
|
||||
}
|
||||
|
||||
/// The device profile that serve-sim also reads. A missing profile means no rotation.
|
||||
private static func nativeRotations(device: NSObject) -> [UInt32: Int] {
|
||||
let typeSelector = NSSelectorFromString("deviceType")
|
||||
let capabilitiesSelector = NSSelectorFromString("capabilities")
|
||||
guard device.responds(to: typeSelector),
|
||||
let type = device.perform(typeSelector)?.takeUnretainedValue() as? NSObject,
|
||||
type.responds(to: capabilitiesSelector),
|
||||
let profile = type.perform(capabilitiesSelector)?.takeUnretainedValue()
|
||||
as? [String: Any],
|
||||
let capabilities = profile["capabilities"] as? [String: Any],
|
||||
let displays = capabilities["displays"] as? [[String: Any]]
|
||||
else {
|
||||
return [:]
|
||||
}
|
||||
var rotations: [UInt32: Int] = [:]
|
||||
for display in displays {
|
||||
guard display["displayType"] as? String == "integrated",
|
||||
let id = display["screenID"] as? NSNumber,
|
||||
let screenID = UInt32(exactly: id.int64Value),
|
||||
let rotation = display["nativeRotation"] as? NSNumber,
|
||||
[0, 90, 180, 270].contains(rotation.intValue)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
rotations[screenID] = rotation.intValue
|
||||
}
|
||||
return rotations
|
||||
}
|
||||
|
||||
private static let integratedScreenType: UInt64 = 0
|
||||
|
||||
private func registerCallbacks(descriptor: NSObject) throws {
|
||||
let selector = NSSelectorFromString(
|
||||
"registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:"
|
||||
@@ -190,3 +282,10 @@ final class FramebufferDisplaySource {
|
||||
callbackUUIDs.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Getters from the CoreSimDeviceIO SimScreenProperties protocol. Optional dispatch keeps Xcode
|
||||
/// releases that lack them working.
|
||||
@objc private protocol SimScreenPropertiesAccess {
|
||||
@objc(screenID) func recordSimScreenID() -> UInt32
|
||||
@objc(screenType) func recordSimScreenType() -> UInt64
|
||||
}
|
||||
+167
-65
@@ -1,4 +1,5 @@
|
||||
import AVFoundation
|
||||
import Accelerate
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import IOSurface
|
||||
@@ -27,10 +28,9 @@ public final class SimulatorRecorder {
|
||||
private var monotonicClock = MonotonicClock()
|
||||
private var firstAcceptedCaptureTime: CMTime?
|
||||
private var firstAcceptedWallClock: Date?
|
||||
private var recordingWidth: Int?
|
||||
private var recordingHeight: Int?
|
||||
private var canvasLayout: PanelCanvasLayout?
|
||||
private var lastPTS: CMTime?
|
||||
private var lastSeed: UInt32?
|
||||
private var lastSeeds: [UInt32?]?
|
||||
private var lastFrameCallbackElapsed: TimeInterval?
|
||||
private var lastFirstFrameRewireElapsed: TimeInterval = 0
|
||||
private var lastAppendedPixelBuffer: CVPixelBuffer?
|
||||
@@ -147,14 +147,14 @@ public final class SimulatorRecorder {
|
||||
guard let firstAcceptedWallClock else {
|
||||
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
|
||||
}
|
||||
guard let recordingWidth, let recordingHeight else {
|
||||
guard let canvasLayout else {
|
||||
throw RecorderError.make(27, "Missing recording dimensions")
|
||||
}
|
||||
return try outputWriter.writeManifest(
|
||||
configuration: configuration,
|
||||
firstFrameWallClock: firstAcceptedWallClock,
|
||||
width: recordingWidth,
|
||||
height: recordingHeight
|
||||
width: canvasLayout.width,
|
||||
height: canvasLayout.height
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -239,19 +239,13 @@ public final class SimulatorRecorder {
|
||||
if reason == .callback {
|
||||
lastFrameCallbackElapsed = monotonicClock.elapsedSeconds()
|
||||
}
|
||||
guard let snapshot = displaySource?.surfaceSnapshot() else {
|
||||
return
|
||||
}
|
||||
let surface = snapshot.surface
|
||||
let surfaceWidth = snapshot.width
|
||||
let surfaceHeight = snapshot.height
|
||||
guard surfaceWidth > 0, surfaceHeight > 0 else {
|
||||
guard let snapshot = displaySource?.frameSnapshot() else {
|
||||
return
|
||||
}
|
||||
|
||||
let seed = snapshot.seed
|
||||
let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed }
|
||||
if !force, matchesLastAppendedSeed {
|
||||
let seeds = snapshot.seeds
|
||||
let matchesLastAppendedSeeds = writerQueue.sync { lastSeeds == seeds }
|
||||
if !force, matchesLastAppendedSeeds {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -262,14 +256,10 @@ public final class SimulatorRecorder {
|
||||
let capturedAt = monotonicClock.elapsedTime()
|
||||
let capturedAtWallClock = Date()
|
||||
do {
|
||||
let pixelBuffer = try copySurfaceToOwnedPixelBuffer(
|
||||
surface,
|
||||
width: surfaceWidth,
|
||||
height: surfaceHeight
|
||||
)
|
||||
let pixelBuffer = try copyFrameToOwnedPixelBuffer(snapshot)
|
||||
appendOwned(
|
||||
pixelBuffer: pixelBuffer,
|
||||
seed: seed,
|
||||
seeds: seeds,
|
||||
capturedAt: capturedAt,
|
||||
capturedAtWallClock: capturedAtWallClock
|
||||
)
|
||||
@@ -296,17 +286,14 @@ public final class SimulatorRecorder {
|
||||
return
|
||||
}
|
||||
|
||||
guard let snapshot = displaySource.surfaceSnapshot(),
|
||||
snapshot.width > 0,
|
||||
snapshot.height > 0
|
||||
else {
|
||||
guard let snapshot = displaySource.frameSnapshot() else {
|
||||
rewireFramebuffer()
|
||||
return
|
||||
}
|
||||
|
||||
let observedSeed = snapshot.seed
|
||||
let lastAppendedSeed = writerQueue.sync { lastSeed }
|
||||
guard let lastAppendedSeed, observedSeed != lastAppendedSeed else {
|
||||
let observedSeeds = snapshot.seeds
|
||||
let lastAppendedSeeds = writerQueue.sync { lastSeeds }
|
||||
guard let lastAppendedSeeds, observedSeeds != lastAppendedSeeds else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -369,22 +356,20 @@ public final class SimulatorRecorder {
|
||||
pendingLock.unlock()
|
||||
}
|
||||
|
||||
private func copySurfaceToOwnedPixelBuffer(
|
||||
_ surface: IOSurface,
|
||||
width: Int,
|
||||
height: Int
|
||||
private func copyFrameToOwnedPixelBuffer(
|
||||
_ snapshot: FramebufferDisplaySource.FrameSnapshot
|
||||
) throws -> CVPixelBuffer {
|
||||
let pool = try writerQueue.sync {
|
||||
let (pool, layout) = try writerQueue.sync {
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
if writer == nil {
|
||||
try startWriter(width: width, height: height)
|
||||
try startWriter(layout: PanelCanvasLayout(panels: snapshot.panels))
|
||||
}
|
||||
guard let pool = adaptor?.pixelBufferPool else {
|
||||
guard let pool = adaptor?.pixelBufferPool, let canvasLayout else {
|
||||
throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable")
|
||||
}
|
||||
return pool
|
||||
return (pool, canvasLayout)
|
||||
}
|
||||
|
||||
var output: CVPixelBuffer?
|
||||
@@ -397,39 +382,129 @@ public final class SimulatorRecorder {
|
||||
guard destinationLockStatus == kCVReturnSuccess else {
|
||||
throw RecorderError.make(50, "Failed to lock pixel buffer: \(destinationLockStatus)")
|
||||
}
|
||||
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
|
||||
guard surfaceLockStatus == KERN_SUCCESS else {
|
||||
CVPixelBufferUnlockBaseAddress(destination, [])
|
||||
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
|
||||
}
|
||||
defer {
|
||||
IOSurfaceUnlock(surface, .readOnly, nil)
|
||||
CVPixelBufferUnlockBaseAddress(destination, [])
|
||||
}
|
||||
|
||||
defer { CVPixelBufferUnlockBaseAddress(destination, []) }
|
||||
guard let destinationAddress = CVPixelBufferGetBaseAddress(destination) else {
|
||||
throw RecorderError.make(42, "Pixel buffer has no base address")
|
||||
}
|
||||
let sourceAddress = IOSurfaceGetBaseAddress(surface)
|
||||
let sourceStride = IOSurfaceGetBytesPerRow(surface)
|
||||
|
||||
let destinationStride = CVPixelBufferGetBytesPerRow(destination)
|
||||
let copyWidth = min(width, IOSurfaceGetWidth(surface), CVPixelBufferGetWidth(destination))
|
||||
let copyHeight = min(
|
||||
height, IOSurfaceGetHeight(surface), CVPixelBufferGetHeight(destination))
|
||||
let copyBytes = min(copyWidth * 4, sourceStride, destinationStride)
|
||||
for row in 0..<copyHeight {
|
||||
memcpy(
|
||||
destinationAddress.advanced(by: row * destinationStride),
|
||||
sourceAddress.advanced(by: row * sourceStride),
|
||||
copyBytes
|
||||
let destinationWidth = CVPixelBufferGetWidth(destination)
|
||||
let destinationHeight = CVPixelBufferGetHeight(destination)
|
||||
for (index, slot) in layout.slots.enumerated() {
|
||||
let slotWidth = max(0, min(slot.width, destinationWidth - slot.x))
|
||||
guard slotWidth > 0 else {
|
||||
continue
|
||||
}
|
||||
let panel = index < snapshot.panels.count ? snapshot.panels[index] : nil
|
||||
try copyPanel(
|
||||
panel?.surface,
|
||||
rotation: panel?.rotation ?? 0,
|
||||
into: destinationAddress.advanced(by: slot.x * 4),
|
||||
stride: destinationStride,
|
||||
width: slotWidth,
|
||||
height: destinationHeight
|
||||
)
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
/// Pool buffers are reused, so every pixel of the region that the surface does not cover,
|
||||
/// including the whole region of a panel without a surface, is cleared to black.
|
||||
private func copyPanel(
|
||||
_ surface: IOSurface?,
|
||||
rotation: Int,
|
||||
into destinationAddress: UnsafeMutableRawPointer,
|
||||
stride destinationStride: Int,
|
||||
width: Int,
|
||||
height: Int
|
||||
) throws {
|
||||
let regionBytes = width * 4
|
||||
guard let surface else {
|
||||
for row in 0..<height {
|
||||
memset(destinationAddress.advanced(by: row * destinationStride), 0, regionBytes)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
|
||||
guard surfaceLockStatus == KERN_SUCCESS else {
|
||||
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
|
||||
}
|
||||
defer { IOSurfaceUnlock(surface, .readOnly, nil) }
|
||||
|
||||
let sourceAddress = IOSurfaceGetBaseAddress(surface)
|
||||
let sourceStride = IOSurfaceGetBytesPerRow(surface)
|
||||
if rotation != 0 {
|
||||
try rotatePanel(
|
||||
surface,
|
||||
rotation: rotation,
|
||||
into: destinationAddress,
|
||||
stride: destinationStride,
|
||||
width: width,
|
||||
height: height
|
||||
)
|
||||
return
|
||||
}
|
||||
let copyHeight = min(height, IOSurfaceGetHeight(surface))
|
||||
let copyBytes = min(min(width, IOSurfaceGetWidth(surface)) * 4, sourceStride)
|
||||
for row in 0..<height {
|
||||
let destinationRow = destinationAddress.advanced(by: row * destinationStride)
|
||||
let copiedBytes = row < copyHeight ? copyBytes : 0
|
||||
if copiedBytes > 0 {
|
||||
memcpy(destinationRow, sourceAddress.advanced(by: row * sourceStride), copiedBytes)
|
||||
}
|
||||
if copiedBytes < regionBytes {
|
||||
memset(destinationRow.advanced(by: copiedBytes), 0, regionBytes - copiedBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expects the surface to be locked. vImage centers a surface whose upright size differs from
|
||||
/// the region and fills the rest with black.
|
||||
private func rotatePanel(
|
||||
_ surface: IOSurface,
|
||||
rotation: Int,
|
||||
into destinationAddress: UnsafeMutableRawPointer,
|
||||
stride destinationStride: Int,
|
||||
width: Int,
|
||||
height: Int
|
||||
) throws {
|
||||
let rotationConstant: Int
|
||||
switch rotation {
|
||||
case 90: rotationConstant = kRotate90DegreesClockwise
|
||||
case 180: rotationConstant = kRotate180DegreesClockwise
|
||||
case 270: rotationConstant = kRotate270DegreesClockwise
|
||||
default: throw RecorderError.make(51, "Unsupported panel rotation: \(rotation)")
|
||||
}
|
||||
let surfaceWidth = IOSurfaceGetWidth(surface)
|
||||
let surfaceHeight = IOSurfaceGetHeight(surface)
|
||||
let rotatedHeight = min(height, rotation == 180 ? surfaceHeight : surfaceWidth)
|
||||
var source = vImage_Buffer(
|
||||
data: IOSurfaceGetBaseAddress(surface),
|
||||
height: vImagePixelCount(surfaceHeight),
|
||||
width: vImagePixelCount(surfaceWidth),
|
||||
rowBytes: IOSurfaceGetBytesPerRow(surface)
|
||||
)
|
||||
var destination = vImage_Buffer(
|
||||
data: destinationAddress,
|
||||
height: vImagePixelCount(rotatedHeight),
|
||||
width: vImagePixelCount(width),
|
||||
rowBytes: destinationStride
|
||||
)
|
||||
var black: [UInt8] = [0, 0, 0, 0]
|
||||
let error = vImageRotate90_ARGB8888(
|
||||
&source, &destination, UInt8(rotationConstant), &black, vImage_Flags(kvImageNoFlags))
|
||||
guard error == kvImageNoError else {
|
||||
throw RecorderError.make(52, "Failed to rotate panel: \(error)")
|
||||
}
|
||||
for row in rotatedHeight..<height {
|
||||
memset(destinationAddress.advanced(by: row * destinationStride), 0, width * 4)
|
||||
}
|
||||
}
|
||||
|
||||
private func appendOwned(
|
||||
pixelBuffer: CVPixelBuffer,
|
||||
seed: UInt32,
|
||||
seeds: [UInt32?],
|
||||
capturedAt: CMTime,
|
||||
capturedAtWallClock: Date
|
||||
) {
|
||||
@@ -441,7 +516,7 @@ public final class SimulatorRecorder {
|
||||
do {
|
||||
try self.appendOnWriterQueue(
|
||||
pixelBuffer: pixelBuffer,
|
||||
seed: seed,
|
||||
seeds: seeds,
|
||||
capturedAt: capturedAt,
|
||||
capturedAtWallClock: capturedAtWallClock
|
||||
)
|
||||
@@ -454,7 +529,7 @@ public final class SimulatorRecorder {
|
||||
|
||||
private func appendOnWriterQueue(
|
||||
pixelBuffer: CVPixelBuffer,
|
||||
seed: UInt32,
|
||||
seeds: [UInt32?],
|
||||
capturedAt: CMTime,
|
||||
capturedAtWallClock: Date
|
||||
) throws {
|
||||
@@ -480,7 +555,7 @@ public final class SimulatorRecorder {
|
||||
firstAcceptedWallClock = capturedAtWallClock
|
||||
}
|
||||
lastPTS = pts
|
||||
lastSeed = seed
|
||||
lastSeeds = seeds
|
||||
lastAppendedPixelBuffer = pixelBuffer
|
||||
signalFirstFrameReadyIfNeeded()
|
||||
}
|
||||
@@ -552,7 +627,9 @@ public final class SimulatorRecorder {
|
||||
return CMTimeSubtract(capturedAt, start)
|
||||
}
|
||||
|
||||
private func startWriter(width: Int, height: Int) throws {
|
||||
private func startWriter(layout: PanelCanvasLayout) throws {
|
||||
let width = layout.width
|
||||
let height = layout.height
|
||||
guard let outputWriter else {
|
||||
throw RecorderError.make(46, "Missing output writer")
|
||||
}
|
||||
@@ -600,8 +677,7 @@ public final class SimulatorRecorder {
|
||||
self.writer = writer
|
||||
self.input = input
|
||||
self.adaptor = adaptor
|
||||
recordingWidth = width
|
||||
recordingHeight = height
|
||||
canvasLayout = layout
|
||||
}
|
||||
|
||||
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
|
||||
@@ -642,3 +718,29 @@ public final class SimulatorRecorder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upright panels side by side, top aligned, sized from the first frame. A panel without a
|
||||
/// surface in that frame gets no region.
|
||||
private struct PanelCanvasLayout {
|
||||
struct Slot {
|
||||
let x: Int
|
||||
let width: Int
|
||||
}
|
||||
|
||||
let slots: [Slot]
|
||||
let width: Int
|
||||
let height: Int
|
||||
|
||||
init(panels: [FramebufferDisplaySource.SurfaceSnapshot?]) {
|
||||
var x = 0
|
||||
var slots: [Slot] = []
|
||||
for panel in panels {
|
||||
let width = panel?.uprightWidth ?? 0
|
||||
slots.append(Slot(x: x, width: width))
|
||||
x += width
|
||||
}
|
||||
self.slots = slots
|
||||
width = x
|
||||
height = panels.compactMap { $0?.uprightHeight }.max() ?? 0
|
||||
}
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils');
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/agentDeviceEvents');
|
||||
jest.mock('../../utils/appiumEvents');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
spawnDetached: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
waitForFileAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const finishHost = jest.fn();
|
||||
const closePreview = jest.fn();
|
||||
const stopTool = jest.fn();
|
||||
const stopTunnel = jest.fn();
|
||||
const stopEvents = jest.fn();
|
||||
const openPreview = jest.fn();
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// The daemon package is external. No installation or daemon process runs in these tests.
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 4567, token: 'daemon-token' });
|
||||
for (const stop of [finishHost, closePreview, stopTool, stopTunnel, stopEvents]) {
|
||||
stop.mockResolvedValue(undefined);
|
||||
}
|
||||
openPreview.mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://preview.example.test',
|
||||
closeAsync: closePreview,
|
||||
});
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopTool,
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
url: 'https://tool.example.test',
|
||||
subdomainId: 'tool-id',
|
||||
stopAsync: stopTunnel,
|
||||
});
|
||||
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
|
||||
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction],
|
||||
['Agent Device', createStartAgentDeviceRemoteSessionBuildFunction],
|
||||
] as const)('%s host ownership', (name, createFunction) => {
|
||||
async function runAsync() {
|
||||
const fn = createFunction({} as CustomBuildContext);
|
||||
await fn.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session-id',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
it('holds the host until session completion', async () => {
|
||||
await runAsync();
|
||||
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test' });
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://preview.example.test',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(closePreview).not.toHaveBeenCalled();
|
||||
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopEvents).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops automation while recording finalization or upload is pending', async () => {
|
||||
let release!: () => void;
|
||||
let stopped!: () => void;
|
||||
const pendingFinish = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const toolStopped = new Promise<void>(resolve => {
|
||||
stopped = resolve;
|
||||
});
|
||||
finishHost.mockReturnValueOnce(pendingFinish);
|
||||
stopTool.mockImplementationOnce(async () => {
|
||||
stopped();
|
||||
});
|
||||
let completed = false;
|
||||
const running = runAsync().then(() => {
|
||||
completed = true;
|
||||
});
|
||||
try {
|
||||
await toolStopped;
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(completed).toBe(false);
|
||||
} finally {
|
||||
release();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
|
||||
it('finalizes recording and stops automation while the tunnel close is pending', async () => {
|
||||
let release!: () => void;
|
||||
let finished!: () => void;
|
||||
const pendingClose = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const hostFinished = new Promise<void>(resolve => {
|
||||
finished = resolve;
|
||||
});
|
||||
const toolStopped = new Promise<void>(resolve => {
|
||||
stopTool.mockImplementationOnce(async () => resolve());
|
||||
});
|
||||
stopTunnel.mockReturnValueOnce(pendingClose);
|
||||
finishHost.mockImplementationOnce(async () => {
|
||||
finished();
|
||||
});
|
||||
const running = runAsync();
|
||||
try {
|
||||
// Both settle while the tunnel close is still held open.
|
||||
await Promise.all([hostFinished, toolStopped]);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
release();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
|
||||
it('still stops the automation process when event collection stop rejects', async () => {
|
||||
stopEvents.mockRejectedValueOnce(new Error('collector failed'));
|
||||
// Agent Device wraps its collector stop in a catch-all helper; Appium does not.
|
||||
const outcome = await runAsync().then(
|
||||
() => 'resolved',
|
||||
(err: Error) => err.message
|
||||
);
|
||||
expect(outcome).toBe(name === 'Appium' ? 'collector failed' : 'resolved');
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still stops automation when recording finalization rejects', async () => {
|
||||
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
|
||||
await expect(runAsync()).rejects.toThrow('recording cleanup failed');
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])('finishes the host after %s fails', async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
// A teardown failure must not replace the error that ended the session.
|
||||
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
|
||||
if (phase === 'preview') {
|
||||
openPreview.mockRejectedValueOnce(error);
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
}
|
||||
await expect(runAsync()).rejects.toBe(error);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: 'recording cleanup failed' }) },
|
||||
'Could not stop the session host during remote session teardown.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not stop the session host after the remote session failed',
|
||||
expect.objectContaining({ message: 'recording cleanup failed' }),
|
||||
{ level: 'warning' }
|
||||
);
|
||||
});
|
||||
});
|
||||
+14
-7
@@ -7,6 +7,7 @@ import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
monitorLocalEgressAsync,
|
||||
readLocalEgressHandoffAsync,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import {
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
@@ -27,6 +27,7 @@ jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../../utils/processes');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
jest.mock('../../utils/argentArtifacts');
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
@@ -58,7 +59,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
subdomainId: 'controller-id',
|
||||
stopAsync: jest.fn(),
|
||||
}),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
@@ -82,6 +82,7 @@ const controllers = [
|
||||
|
||||
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
|
||||
const stopPreview = jest.fn();
|
||||
const openPreview = jest.fn();
|
||||
const stopEgress = jest.fn();
|
||||
let lifetime: AbortSignal;
|
||||
async function run(signal?: AbortSignal): Promise<void> {
|
||||
@@ -119,11 +120,15 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
|
||||
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
openPreview.mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://preview.test',
|
||||
previewToken: 'preview-secret',
|
||||
stopAsync: stopPreview,
|
||||
closeAsync: jest.fn(),
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
finishAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
@@ -169,12 +174,14 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(['startup', 'report', 'wait', 'teardown'])(
|
||||
it.each(['startup', 'preview', 'report', 'wait', 'teardown'])(
|
||||
'releases egress after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'startup') {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'preview') {
|
||||
openPreview.mockRejectedValue(error);
|
||||
} else if (phase === 'report') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'wait') {
|
||||
@@ -186,7 +193,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
|
||||
phase === 'startup' || phase === 'report' ? 0 : 1
|
||||
phase === 'startup' || phase === 'preview' || phase === 'report' ? 0 : 1
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
+12
-8
@@ -8,13 +8,13 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -40,6 +40,7 @@ jest.mock('../../utils/agentDeviceArtifacts', () => ({
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
@@ -47,7 +48,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
@@ -104,6 +104,7 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: mockDaemonStopAsync,
|
||||
});
|
||||
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token: 'daemon-token' });
|
||||
@@ -112,10 +113,13 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
subdomainId: 'agent-device-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
stopAsync: mockPreviewStopAsync,
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
@@ -134,7 +138,7 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
);
|
||||
@@ -163,7 +167,7 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
});
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
|
||||
+13
-2
@@ -75,6 +75,7 @@ describe(startAgentDeviceDaemonAsync, () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 1234,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync,
|
||||
});
|
||||
jest.mocked(spawn).mockImplementation((async (
|
||||
@@ -108,7 +109,12 @@ describe(startAgentDeviceDaemonAsync, () => {
|
||||
expect(spawnDetached).toHaveBeenCalledWith({
|
||||
command: 'node',
|
||||
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
|
||||
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
|
||||
env: expect.objectContaining({
|
||||
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
|
||||
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
|
||||
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
|
||||
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
|
||||
}),
|
||||
});
|
||||
|
||||
await handle.stopAsync();
|
||||
@@ -165,7 +171,12 @@ describe(startAgentDeviceDaemonAsync, () => {
|
||||
command: 'bun',
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: '/tmp/agent-device-src',
|
||||
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
|
||||
env: expect.objectContaining({
|
||||
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
|
||||
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
|
||||
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
|
||||
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
|
||||
}),
|
||||
});
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
|
||||
|
||||
+128
-8
@@ -6,6 +6,7 @@ import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -42,15 +42,17 @@ jest.mock('../../utils/argentEvents', () => ({
|
||||
...jest.requireActual('../../utils/argentEvents'),
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
finishRemoteSessionAsync: jest.requireActual('../../utils/remoteDeviceRunSession')
|
||||
.finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
@@ -86,6 +88,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: jest.fn(),
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
@@ -93,10 +96,13 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
subdomainId: 'argent-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
stopAsync: mockPreviewStopAsync,
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
@@ -113,6 +119,75 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports an early exit with output before opening tunnels or publishing readiness', async () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => 'could not bind server port',
|
||||
getExitError: () => new Error('process exited with code 1'),
|
||||
stopAsync: jest.fn(),
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toThrow(
|
||||
'Argent exited before becoming ready: process exited with code 1\nArgent tool-server output:\ncould not bind server port'
|
||||
);
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])(
|
||||
'finishes the host and tools after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
}
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(error);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(spawnDetached).mock.results[0].value.stopAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('enables the event log flag, shares one path, and starts/stops the collector', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
@@ -177,7 +252,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
);
|
||||
@@ -216,7 +291,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
} as never
|
||||
);
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
@@ -266,4 +341,49 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
});
|
||||
|
||||
it('stops automation without waiting for recording upload', async () => {
|
||||
let release!: () => void;
|
||||
let stopped!: () => void;
|
||||
const pendingFinish = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const toolStopped = new Promise<void>(resolve => {
|
||||
stopped = resolve;
|
||||
});
|
||||
mockPreviewStopAsync.mockReturnValueOnce(pendingFinish);
|
||||
const stopServer = jest.fn(async () => {
|
||||
stopped();
|
||||
});
|
||||
jest.mocked(spawnDetached).mockReturnValueOnce({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
try {
|
||||
await toolStopped;
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
release();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,59 @@ describe(waitForArgentToolServerStateAsync, () => {
|
||||
return JSON.stringify({ port, pid, token });
|
||||
}
|
||||
|
||||
it('reports process exit instead of waiting for the startup timeout', async () => {
|
||||
const getExitError = jest.fn(() => new Error('server exited with code 1'));
|
||||
await expect(
|
||||
waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid: process.pid,
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 1,
|
||||
getExitError,
|
||||
})
|
||||
).rejects.toThrow('server exited with code 1');
|
||||
expect(getExitError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('detects an exit after polling has begun', async () => {
|
||||
const getExitError = jest
|
||||
.fn<Error | undefined, []>()
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValue(new Error('server stopped'));
|
||||
await expect(
|
||||
waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid: process.pid,
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 1,
|
||||
getExitError,
|
||||
})
|
||||
).rejects.toThrow('server stopped');
|
||||
expect(getExitError).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rejects an exit observed while reading a matching state file', async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(stateDir, 'tool-server.json'),
|
||||
stateJson({ port: 4321, pid: process.pid })
|
||||
);
|
||||
const getExitError = jest
|
||||
.fn<Error | undefined, []>()
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValue(new Error('server exited during startup'));
|
||||
await expect(
|
||||
waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid: process.ppid,
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 1,
|
||||
getExitError,
|
||||
})
|
||||
).rejects.toThrow('server exited during startup');
|
||||
expect(getExitError).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(['tool-server.json', 'tool-server-012345abcdef.json'])(
|
||||
'reads a matching process from %s',
|
||||
async fileName => {
|
||||
|
||||
@@ -31,7 +31,12 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
|
||||
const server = {
|
||||
pid: 12345,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: jest.fn(),
|
||||
};
|
||||
const tunnel = {
|
||||
url: 'https://egress.example.com',
|
||||
subdomainId: 'egress-id',
|
||||
|
||||
+37
-16
@@ -3,22 +3,22 @@ import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
@@ -56,10 +56,13 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
stopAsync,
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
@@ -67,11 +70,14 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('reports the session token when serve-sim minted one', async () => {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
stopAsync,
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
|
||||
await runAsync(BuildRuntimePlatform.DARWIN);
|
||||
@@ -87,6 +93,22 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])('finishes the session after %s fails', async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
}
|
||||
await expect(runAsync(BuildRuntimePlatform.LINUX)).rejects.toBe(error);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
@@ -94,9 +116,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
await runAsync(runtimePlatform);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(ctx, {
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: 'tunnel.example.com',
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
@@ -139,14 +160,14 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to the web preview and announces them', async () => {
|
||||
it('hands the launch inputs to the session host and announces them', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
});
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
@@ -166,6 +187,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
})
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+22
-9
@@ -29,7 +29,20 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads simulator device metadata', async () => {
|
||||
it.each([
|
||||
{
|
||||
platform: BuildRuntimePlatform.DARWIN,
|
||||
udid: SIMULATOR_UDID,
|
||||
name: 'iPhone 16',
|
||||
runtime: 'iOS 18.6',
|
||||
},
|
||||
{
|
||||
platform: BuildRuntimePlatform.LINUX,
|
||||
udid: 'emulator-5554',
|
||||
name: 'Pixel',
|
||||
runtime: 'Android 16',
|
||||
},
|
||||
])('uploads device metadata on $platform', async ({ platform, udid, name, runtime }) => {
|
||||
const recordingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'ios-recording-test-'));
|
||||
const recordingPath = path.join(recordingDirectory, 'recording.mp4');
|
||||
await fs.writeFile(recordingPath, 'recording');
|
||||
@@ -45,7 +58,7 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
|
||||
try {
|
||||
const globalContext = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
runtimePlatform: platform,
|
||||
});
|
||||
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
{} as CustomBuildContext
|
||||
@@ -54,9 +67,9 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
callInputs: {
|
||||
recordings_json: [
|
||||
{
|
||||
udid: SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
udid,
|
||||
deviceName: name,
|
||||
runtimeDisplayName: runtime,
|
||||
directory: recordingDirectory,
|
||||
},
|
||||
],
|
||||
@@ -68,13 +81,13 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
name: 'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
|
||||
name: `${name} screen recording (${udid.slice(0, 8)}-…, started at Jul 10, 2026, 10:00:00.000 UTC)`,
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording',
|
||||
__eas_screen_recording: '1',
|
||||
udid: SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
udid,
|
||||
deviceName: name,
|
||||
runtimeDisplayName: runtime,
|
||||
firstFrameAt: '2026-07-10T10:00:00.000Z',
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
@@ -30,13 +31,13 @@ import {
|
||||
type DetachedProcessHandle,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
@@ -51,6 +52,13 @@ const STARTUP_TIMEOUT_MS = 60_000;
|
||||
const AGENT_DEVICE_DAEMON_ENV = {
|
||||
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
|
||||
AGENT_DEVICE_RETAIN_ARTIFACTS: '1',
|
||||
// The session lifetime is owned by max_idle_time_minutes / max_duration_seconds
|
||||
// and the device run session stop, so disable agent-device's own idle timers.
|
||||
// Without this the daemon exits after 5 minutes with no open session, and the
|
||||
// tunnel is left without a daemon behind it.
|
||||
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
|
||||
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
|
||||
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
|
||||
};
|
||||
|
||||
export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
@@ -128,18 +136,18 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
|
||||
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let eventCollection:
|
||||
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
|
||||
| undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
@@ -147,6 +155,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
logger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
);
|
||||
@@ -193,19 +202,34 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
await agentDeviceTunnel.stopAsync();
|
||||
if (eventCollection) {
|
||||
await stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
await daemonProcess.stopAsync();
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['agent-device tunnel', agentDeviceTunnel.stopAsync()],
|
||||
[
|
||||
'agent-device daemon',
|
||||
(async () => {
|
||||
try {
|
||||
if (eventCollection) {
|
||||
await stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await daemonProcess.stopAsync();
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
@@ -32,13 +33,13 @@ import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -138,7 +139,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
@@ -154,9 +156,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
@@ -164,6 +165,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
@@ -197,16 +199,29 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
if (appiumTunnel) {
|
||||
await appiumTunnel.stopAsync();
|
||||
}
|
||||
await eventCollection.stopAsync();
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Appium tunnel', appiumTunnel?.stopAsync()],
|
||||
[
|
||||
'Appium server',
|
||||
(async () => {
|
||||
try {
|
||||
await eventCollection.stopAsync();
|
||||
} finally {
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
@@ -32,13 +33,13 @@ import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -184,6 +185,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
stateDir: ARGENT_STATE_DIR,
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
@@ -216,7 +218,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
toolsTunnel = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
@@ -233,9 +236,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
@@ -243,6 +245,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
logger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
);
|
||||
@@ -276,23 +279,46 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
if (toolsTunnel) {
|
||||
await toolsTunnel.stopAsync();
|
||||
}
|
||||
await stopArgentEventCollectionSafelyAsync({ eventCollection, deviceRunSessionId, logger });
|
||||
artifactPollAbortController.abort();
|
||||
try {
|
||||
await artifactPollingPromise;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not finish Argent remote session artifact polling', error);
|
||||
logger.warn({ err: error }, 'Could not finish Argent remote session artifact polling.');
|
||||
}
|
||||
await argentServer.stopAsync();
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Argent tunnel', toolsTunnel?.stopAsync()],
|
||||
[
|
||||
'Argent tool-server',
|
||||
(async () => {
|
||||
try {
|
||||
await stopArgentEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
artifactPollAbortController.abort();
|
||||
try {
|
||||
await artifactPollingPromise;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture(
|
||||
'Could not finish Argent remote session artifact polling',
|
||||
error
|
||||
);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not finish Argent remote session artifact polling.'
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await argentServer.stopAsync();
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -371,16 +397,26 @@ export async function waitForArgentToolServerStateAsync({
|
||||
ancestorPid,
|
||||
timeoutMs,
|
||||
pollIntervalMs = 1_000,
|
||||
getExitError,
|
||||
}: {
|
||||
stateDir: string;
|
||||
ancestorPid: number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs?: number;
|
||||
getExitError?: () => Error | undefined;
|
||||
}): Promise<ArgentToolServerState> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
const throwIfExited = (): void => {
|
||||
const error = getExitError?.();
|
||||
if (error) {
|
||||
throw new SystemError(`Argent exited before becoming ready: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
throwIfExited();
|
||||
let matchingState: ArgentToolServerState | undefined;
|
||||
try {
|
||||
const stateFileNames = (await fs.promises.readdir(stateDir)).filter(
|
||||
name => name.startsWith('tool-server') && name.endsWith('.json')
|
||||
@@ -391,7 +427,8 @@ export async function waitForArgentToolServerStateAsync({
|
||||
await fs.promises.readFile(path.join(stateDir, stateFileName), 'utf8')
|
||||
);
|
||||
if (await isProcessDescendantOfAsync(state.pid, ancestorPid)) {
|
||||
return state;
|
||||
matchingState = state;
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && !(err instanceof SystemError)) {
|
||||
@@ -402,6 +439,11 @@ export async function waitForArgentToolServerStateAsync({
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
// Keep process failures outside the retryable state-file read errors.
|
||||
throwIfExited();
|
||||
if (matchingState) {
|
||||
return matchingState;
|
||||
}
|
||||
await sleepAsync(pollIntervalMs);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@exp
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { withLogPhaseAsync } from '../../utils/logPhase';
|
||||
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
|
||||
|
||||
@@ -76,19 +77,7 @@ export async function markSandboxReadyAsync(
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(
|
||||
MARK_SANDBOX_READY_MUTATION,
|
||||
{ sandboxId },
|
||||
signal
|
||||
? {
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
.mutation(MARK_SANDBOX_READY_MUTATION, { sandboxId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
if (result.error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -69,9 +69,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
const webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
const sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
@@ -80,9 +79,10 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
});
|
||||
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
|
||||
|
||||
try {
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
@@ -104,7 +104,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
await webPreview.stopAsync();
|
||||
await sessionHost.finishAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
+11
-100
@@ -1,53 +1,17 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import limitFactory from 'promise-limit';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../utils/deviceRunSessionArtifacts';
|
||||
import {
|
||||
parseDeviceScreenRecordings,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../utils/deviceRunSessionScreenRecordings';
|
||||
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const RecordingsSchema = z.array(
|
||||
z.object({
|
||||
udid: z.string(),
|
||||
deviceName: z.string(),
|
||||
runtimeDisplayName: z.string(),
|
||||
directory: z.string(),
|
||||
})
|
||||
);
|
||||
|
||||
const RecordingManifestSchema = z.object({
|
||||
firstFrameWallClock: z.object({
|
||||
iso8601: z.string(),
|
||||
}),
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
recording: z.string(),
|
||||
});
|
||||
|
||||
const recordingStartTimeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
hourCycle: 'h23',
|
||||
timeZone: 'UTC',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
@@ -65,69 +29,16 @@ export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { env, inputs }) => {
|
||||
const result = RecordingsSchema.safeParse(inputs.recordings_json.value ?? []);
|
||||
if (!result.success) {
|
||||
throw new SystemError('Invalid iOS Simulator recordings input.', {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
const recordings = result.data;
|
||||
const recordings = parseDeviceScreenRecordings(inputs.recordings_json.value ?? []);
|
||||
if (recordings.length === 0) {
|
||||
logger.info('No iOS Simulator recordings found; skipping uploads.');
|
||||
logger.info('No device screen recordings found; skipping uploads.');
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
|
||||
const limit = limitFactory(5);
|
||||
await Promise.all(
|
||||
recordings.map(recording =>
|
||||
limit(async () => {
|
||||
try {
|
||||
const metadata = RecordingManifestSchema.parse(
|
||||
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
|
||||
);
|
||||
const startedAt = recordingStartTimeFormatter.format(
|
||||
new Date(metadata.firstFrameWallClock.iso8601)
|
||||
);
|
||||
const shortUdid = `${recording.udid.slice(0, 8)}-…`;
|
||||
const displayName = `${recording.deviceName} screen recording (${shortUdid}, started at ${startedAt})`;
|
||||
const recordingPath = path.join(recording.directory, metadata.recording);
|
||||
const { size } = await stat(recordingPath);
|
||||
const recordingId = path.basename(recording.directory);
|
||||
logger.info(
|
||||
`Uploading screen recording for ${recording.deviceName} (${formatBytes(size)}).`
|
||||
);
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: recordingId,
|
||||
name: displayName,
|
||||
filename: `${recordingId}.mp4`,
|
||||
kind: 'screen-recording',
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording',
|
||||
__eas_screen_recording: '1',
|
||||
udid: recording.udid,
|
||||
deviceName: recording.deviceName,
|
||||
runtimeDisplayName: recording.runtimeDisplayName,
|
||||
firstFrameAt: metadata.firstFrameWallClock.iso8601,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
},
|
||||
size,
|
||||
stream: createReadStream(recordingPath),
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload iOS Simulator screen recording', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Could not upload screen recording for ${recording.deviceName}.`
|
||||
);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
await uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
recordings,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -63,7 +63,8 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
metadata: { firstFrameRecordAt: 'test-time' },
|
||||
size: reportedSize,
|
||||
},
|
||||
})
|
||||
}),
|
||||
{ fetch: expect.any(Function) }
|
||||
);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/artifact',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
DeviceRunSessionTimeoutError,
|
||||
withDeviceRunSessionTimeoutAsync,
|
||||
} from '../deviceRunSessionTimeout';
|
||||
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('aborts a stalled operation when its deadline expires', async () => {
|
||||
jest.useFakeTimers();
|
||||
let signal: AbortSignal | undefined;
|
||||
const operation = withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'test operation', timeoutMs: 100 },
|
||||
async value => {
|
||||
signal = value;
|
||||
return await new Promise<void>(() => {});
|
||||
}
|
||||
);
|
||||
const rejected = expect(operation).rejects.toThrow('test operation timed out after 100ms');
|
||||
const typed = expect(operation).rejects.toBeInstanceOf(DeviceRunSessionTimeoutError);
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
await rejected;
|
||||
await typed;
|
||||
expect(signal?.aborted).toBe(true);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not start an operation with an already aborted parent', async () => {
|
||||
const operation = jest.fn();
|
||||
await expect(
|
||||
withDeviceRunSessionTimeoutAsync(
|
||||
{
|
||||
name: 'test',
|
||||
timeoutMs: 100,
|
||||
signal: AbortSignal.abort(new Error('parent ended')),
|
||||
},
|
||||
operation
|
||||
)
|
||||
).rejects.toThrow('parent ended');
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the deadline when an operation succeeds', async () => {
|
||||
jest.useFakeTimers();
|
||||
await expect(
|
||||
withDeviceRunSessionTimeoutAsync({ name: 'test', timeoutMs: 100 }, async () => 42)
|
||||
).resolves.toBe(42);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a deadline reset that arrives after the operation settled', async () => {
|
||||
jest.useFakeTimers();
|
||||
let resetDeadline!: () => void;
|
||||
await expect(
|
||||
withDeviceRunSessionTimeoutAsync({ name: 'test', timeoutMs: 100 }, async (_signal, reset) => {
|
||||
resetDeadline = reset;
|
||||
return 42;
|
||||
})
|
||||
).resolves.toBe(42);
|
||||
resetDeadline();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps an operation alive while it reports progress', async () => {
|
||||
jest.useFakeTimers();
|
||||
const operation = withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'test operation', timeoutMs: 100 },
|
||||
async (signal, resetDeadline) => {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 80));
|
||||
resetDeadline();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 80));
|
||||
resetDeadline();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 80));
|
||||
return signal.aborted;
|
||||
}
|
||||
);
|
||||
const settled = expect(operation).resolves.toBe(false);
|
||||
await jest.advanceTimersByTimeAsync(240);
|
||||
await settled;
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import {
|
||||
findUnlistedDeviceScreenRecordingsAsync,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../deviceRunSessionScreenRecordings';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(r => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'recording-id',
|
||||
name: 'Recording',
|
||||
filename: 'recording.mp4',
|
||||
kind: 'screen-recording',
|
||||
size: 16,
|
||||
};
|
||||
let handle: (request: IncomingMessage, response: ServerResponse) => void;
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creates: number;
|
||||
let authorization: string | undefined;
|
||||
let stallCreation: boolean;
|
||||
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
|
||||
let creationBodies: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
creates = 0;
|
||||
authorization = undefined;
|
||||
stallCreation = false;
|
||||
creationBodies = [];
|
||||
creationStarted = deferred();
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url === '/graphql') {
|
||||
creates++;
|
||||
authorization = request.headers.authorization;
|
||||
creationStarted.resolve(request);
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => creationBodies.push(body));
|
||||
request.on('end', () => {
|
||||
if (stallCreation) {
|
||||
return;
|
||||
}
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact`, headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
handle(request, response);
|
||||
}
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Missing test server address');
|
||||
}
|
||||
url = `http://127.0.0.1:${address.port}`;
|
||||
ctx = {
|
||||
graphqlClient: new Client({
|
||||
url: `${url}/graphql`,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
|
||||
}),
|
||||
} as CustomBuildContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('replays the full file after a transient PUT failure without recreating the upload session', async () => {
|
||||
const bodies: string[] = [];
|
||||
handle = (request, response) => {
|
||||
const chunks: string[] = [];
|
||||
request.setEncoding('utf8');
|
||||
request.on('data', chunk => chunks.push(chunk));
|
||||
request.on('end', () => {
|
||||
bodies.push(chunks.join(''));
|
||||
response.statusCode = bodies.length === 1 ? 503 : 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
const file = path.join(directory, 'recording.mp4');
|
||||
const payload = '*'.repeat(128 * 1024);
|
||||
await writeFile(file, payload);
|
||||
const streams: Readable[] = [];
|
||||
const open = () => {
|
||||
const stream = createReadStream(file);
|
||||
streams.push(stream);
|
||||
return stream;
|
||||
};
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
size: payload.length,
|
||||
stream: open(),
|
||||
reopenStream: open,
|
||||
});
|
||||
expect(creates).toBe(1);
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(bodies).toEqual([payload, payload]);
|
||||
expect(streams).toHaveLength(2);
|
||||
expect(streams.every(stream => stream.destroyed)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a slow PUT alive while its body is still flowing', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
let received = 0;
|
||||
let onChunk = () => {};
|
||||
handle = (request, response) => {
|
||||
request.on('data', chunk => {
|
||||
received += chunk.length;
|
||||
onChunk();
|
||||
});
|
||||
request.on('end', () => {
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
const waitForBytes = (total: number) =>
|
||||
new Promise<void>(resolve => {
|
||||
onChunk = () => {
|
||||
if (received >= total) {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
onChunk();
|
||||
});
|
||||
const stream = new Readable({ read() {} });
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, { ...metadata, size: 3, stream });
|
||||
const settled = expect(upload).resolves.toBeUndefined();
|
||||
stream.push('a');
|
||||
await waitForBytes(1);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
stream.push('b');
|
||||
await waitForBytes(2);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
stream.push('c');
|
||||
stream.push(null);
|
||||
await settled;
|
||||
expect(received).toBe(3);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('retries a PUT that stalled past the idle deadline with a fresh stream', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
let puts = 0;
|
||||
const firstBodyRead = deferred<void>();
|
||||
handle = (request, response) => {
|
||||
puts++;
|
||||
request.resume();
|
||||
request.on('end', () => {
|
||||
if (puts === 1) {
|
||||
firstBodyRead.resolve();
|
||||
return;
|
||||
}
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
const streams: Readable[] = [];
|
||||
const open = () => {
|
||||
const stream = Readable.from('recording');
|
||||
streams.push(stream);
|
||||
return stream;
|
||||
};
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
size: 9,
|
||||
stream: open(),
|
||||
reopenStream: open,
|
||||
});
|
||||
const settled = expect(upload).resolves.toBeUndefined();
|
||||
await firstBodyRead.promise;
|
||||
await jest.advanceTimersByTimeAsync(90_000);
|
||||
await jest.advanceTimersByTimeAsync(500);
|
||||
await settled;
|
||||
expect(puts).toBe(2);
|
||||
expect(streams).toHaveLength(2);
|
||||
expect(streams.every(stream => stream.destroyed)).toBe(true);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('cancels an active PUT socket and destroys its source without retrying', async () => {
|
||||
const started = deferred<void>();
|
||||
const closed = deferred<void>();
|
||||
let puts = 0;
|
||||
handle = request => {
|
||||
puts++;
|
||||
request.socket.once('close', () => closed.resolve());
|
||||
request.on('data', () => started.resolve());
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const stream = new Readable({ read() {} });
|
||||
stream.push(Buffer.alloc(1024));
|
||||
const reopenStream = jest.fn(() => Readable.from('unexpected retry'));
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream,
|
||||
reopenStream,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejected = expect(upload).rejects.toThrow('test cancellation');
|
||||
await started.promise;
|
||||
controller.abort(new Error('test cancellation'));
|
||||
await rejected;
|
||||
await closed.promise;
|
||||
expect(stream.destroyed).toBe(true);
|
||||
expect(reopenStream).not.toHaveBeenCalled();
|
||||
expect(puts).toBe(1);
|
||||
});
|
||||
|
||||
it.each([200, 400, 503])(
|
||||
'closes a stalled HTTP %s response before leaving or retrying the PUT',
|
||||
async status => {
|
||||
const sockets = new Set<Socket>();
|
||||
let puts = 0;
|
||||
let maxOpenSockets = 0;
|
||||
handle = (request, response) => {
|
||||
puts++;
|
||||
sockets.add(request.socket);
|
||||
maxOpenSockets = Math.max(maxOpenSockets, sockets.size);
|
||||
request.socket.once('close', () => sockets.delete(request.socket));
|
||||
request.resume();
|
||||
request.on('end', () => {
|
||||
response.writeHead(status, { 'Content-Length': '1000' });
|
||||
response.flushHeaders();
|
||||
response.write('partial response');
|
||||
});
|
||||
};
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream: Readable.from('recording'),
|
||||
reopenStream: () => Readable.from('recording'),
|
||||
});
|
||||
if (status === 200) {
|
||||
await expect(upload).resolves.toBeUndefined();
|
||||
} else {
|
||||
await expect(upload).rejects.toThrow(`HTTP ${status}`);
|
||||
}
|
||||
for (let i = 0; i < 100 && sockets.size > 0; i++) {
|
||||
await delay(10);
|
||||
}
|
||||
expect(creates).toBe(1);
|
||||
expect(puts).toBe(status === 503 ? 3 : 1);
|
||||
expect(maxOpenSockets).toBe(1);
|
||||
expect(sockets.size).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
it('cancels urql session creation with auth intact and never starts a PUT', async () => {
|
||||
stallCreation = true;
|
||||
const puts = jest.fn();
|
||||
handle = puts;
|
||||
const controller = new AbortController();
|
||||
const stream = Readable.from('recording');
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejected = expect(upload).rejects.toThrow('test cancellation');
|
||||
const request = await creationStarted.promise;
|
||||
const closed = new Promise<void>(resolve => request.socket.once('close', resolve));
|
||||
controller.abort(new Error('test cancellation'));
|
||||
await rejected;
|
||||
await closed;
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(creates).toBe(1);
|
||||
expect(puts).not.toHaveBeenCalled();
|
||||
expect(stream.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('says ffprobe is missing instead of calling an unlisted recording undecodable', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const session = path.join(directory, 'session');
|
||||
await mkdir(session);
|
||||
await writeFile(path.join(session, 'recording.mp4.partial'), new Uint8Array(28));
|
||||
await writeFile(
|
||||
path.join(session, 'session.json'),
|
||||
JSON.stringify({
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
status: 'recording',
|
||||
recording: 'recording.mp4.partial',
|
||||
})
|
||||
);
|
||||
|
||||
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
|
||||
root: directory,
|
||||
env: { ...process.env, PATH: path.join(directory, 'no-tools') } as BuildStepEnv,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(recordings).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'ffprobe is not available; cannot recover the unlisted recording session/recording.mp4.partial.'
|
||||
);
|
||||
});
|
||||
|
||||
it('recovers unlisted recordings from a killed host and flags the unfinished ones as partial', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const env = process.env as BuildStepEnv;
|
||||
const cut = path.join(directory, 'cut');
|
||||
const empty = path.join(directory, 'empty');
|
||||
const failed = path.join(directory, 'failed');
|
||||
const done = path.join(directory, 'done');
|
||||
await mkdir(cut);
|
||||
await mkdir(empty);
|
||||
await mkdir(failed);
|
||||
await mkdir(done);
|
||||
const manifest = {
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
status: 'recording',
|
||||
recording: 'recording.mp4.partial',
|
||||
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
|
||||
width: 128,
|
||||
height: 96,
|
||||
};
|
||||
execFileSync('ffmpeg', [
|
||||
'-v',
|
||||
'error',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'color=c=red:s=128x96:r=1',
|
||||
'-t',
|
||||
'3',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-g',
|
||||
'1',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-movflags',
|
||||
'frag_keyframe+empty_moov',
|
||||
'-f',
|
||||
'mp4',
|
||||
path.join(cut, 'recording.mp4.partial'),
|
||||
]);
|
||||
await writeFile(path.join(cut, 'session.json'), JSON.stringify(manifest));
|
||||
await writeFile(path.join(empty, 'recording.mp4.partial'), new Uint8Array(28));
|
||||
await writeFile(path.join(empty, 'session.json'), JSON.stringify(manifest));
|
||||
await copyFile(
|
||||
path.join(cut, 'recording.mp4.partial'),
|
||||
path.join(failed, 'recording.mp4.partial')
|
||||
);
|
||||
await writeFile(
|
||||
path.join(failed, 'session.json'),
|
||||
JSON.stringify({
|
||||
...manifest,
|
||||
status: 'failed',
|
||||
error: 'Recording finalization exceeded 30000 ms.',
|
||||
})
|
||||
);
|
||||
|
||||
await copyFile(path.join(cut, 'recording.mp4.partial'), path.join(done, 'recording.mp4'));
|
||||
await writeFile(
|
||||
path.join(done, 'session.json'),
|
||||
JSON.stringify({ ...manifest, status: 'complete', recording: 'recording.mp4' })
|
||||
);
|
||||
|
||||
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
|
||||
root: directory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
expect(recordings).toEqual([
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: cut,
|
||||
},
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: done,
|
||||
},
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: failed,
|
||||
},
|
||||
]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
|
||||
|
||||
let putBytes = 0;
|
||||
handle = (request, response) => {
|
||||
request.on('data', chunk => (putBytes += chunk.length));
|
||||
request.on('end', () => {
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
recordings,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
|
||||
const inputs = creationBodies
|
||||
.map(body => JSON.parse(body).variables.input)
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
|
||||
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
|
||||
expect(inputs[1].metadata).not.toHaveProperty('partial');
|
||||
expect(inputs[0].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'The Device Hub stopped before it could finalize the recording.',
|
||||
width: 128,
|
||||
height: 96,
|
||||
});
|
||||
expect(inputs[2].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'Recording finalization exceeded 30000 ms.',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { access, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
findUnlistedDeviceScreenRecordingsAsync,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../deviceRunSessionScreenRecordings';
|
||||
import { startDeviceSessionHostAsync } from '../deviceSessionHost';
|
||||
import { spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../serveSimMetricsRecorder', () => ({
|
||||
readServeSimServersAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ udid: 'emulator-5554', token: 'preview-token' }]),
|
||||
}));
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../remoteDeviceRunSession'),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
fetchWebPreviewTurnArgsAsync: jest.fn().mockResolvedValue([]),
|
||||
spawnDetached: jest.fn(),
|
||||
}));
|
||||
jest.mock('../deviceRunSessionScreenRecordings', () => ({
|
||||
...jest.requireActual('../deviceRunSessionScreenRecordings'),
|
||||
findUnlistedDeviceScreenRecordingsAsync: jest.fn(),
|
||||
uploadDeviceRunSessionScreenRecordingsAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(r => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const env = {
|
||||
DEVICE_RUN_SESSION_ID: 'drs-id',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
} as BuildStepEnv;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const stopServer = jest.fn();
|
||||
const closeTunnel = jest.fn();
|
||||
const directories: string[] = [];
|
||||
const baseDomain = 'preview.example.test';
|
||||
|
||||
async function startHostAsync() {
|
||||
return await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
stopServer.mockResolvedValue(undefined);
|
||||
closeTunnel.mockResolvedValue(undefined);
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset().mockResolvedValue(false);
|
||||
jest.mocked(findUnlistedDeviceScreenRecordingsAsync).mockReset().mockResolvedValue([]);
|
||||
jest.mocked(spawnDetached).mockImplementation(options => {
|
||||
const flag = options.args.indexOf('--android-recording-directory');
|
||||
if (flag >= 0) {
|
||||
directories.push(options.args[flag + 1]);
|
||||
}
|
||||
return {
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
};
|
||||
});
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://public.example.test',
|
||||
close: closeTunnel,
|
||||
} as never);
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ready', device: 'emulator-5554' }),
|
||||
}) as Awaited<ReturnType<typeof turtleFetch>>
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.useRealTimers();
|
||||
await Promise.all(
|
||||
directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))
|
||||
);
|
||||
});
|
||||
|
||||
it('records by default with no preview and finalizes before process stop, uploading exactly once', async () => {
|
||||
const host = await startHostAsync();
|
||||
expect(ngrok.forward).not.toHaveBeenCalled();
|
||||
const directory = directories[0];
|
||||
const recordings = [
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: path.join(directory, 'session'),
|
||||
},
|
||||
];
|
||||
await writeFile(path.join(directory, 'recordings.json'), JSON.stringify(recordings));
|
||||
await mkdir(recordings[0].directory);
|
||||
const started = deferred<void>();
|
||||
const response = deferred<Awaited<ReturnType<typeof turtleFetch>>>();
|
||||
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
|
||||
started.resolve();
|
||||
return await response.promise;
|
||||
});
|
||||
const finishing = host.finishAsync();
|
||||
expect(host.finishAsync()).toBe(finishing);
|
||||
await started.promise;
|
||||
expect(stopServer).not.toHaveBeenCalled();
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
const token =
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN;
|
||||
expect(token).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(turtleFetch).toHaveBeenLastCalledWith(
|
||||
expect.stringMatching(/\/_eas\/android-recording\/stop$/),
|
||||
'POST',
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
retries: 0,
|
||||
shouldThrowOnNotOk: false,
|
||||
signal: expect.any(AbortSignal),
|
||||
}
|
||||
);
|
||||
response.resolve({ ok: true } as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
await finishing;
|
||||
await host.finishAsync();
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledWith(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
recordings,
|
||||
});
|
||||
expect(stopServer.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow(
|
||||
'after session host finalization'
|
||||
);
|
||||
});
|
||||
|
||||
it('coalesces preview opens and allows close/reopen without ending capture', async () => {
|
||||
const host = await startHostAsync();
|
||||
const opening = host.openPreviewAsync({ baseDomain });
|
||||
expect(host.openPreviewAsync({ baseDomain })).toBe(opening);
|
||||
const first = await opening;
|
||||
const closing = first.closeAsync();
|
||||
expect(first.closeAsync()).toBe(closing);
|
||||
await closing;
|
||||
const secondClose = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValueOnce({
|
||||
url: () => 'https://replacement.example.test',
|
||||
close: secondClose,
|
||||
} as never);
|
||||
const second = await host.openPreviewAsync({ baseDomain });
|
||||
await first.closeAsync();
|
||||
expect(await host.openPreviewAsync({ baseDomain })).toBe(second);
|
||||
expect(secondClose).not.toHaveBeenCalled();
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
expect(stopServer).not.toHaveBeenCalled();
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
|
||||
await host.finishAsync();
|
||||
expect(closeTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(secondClose).toHaveBeenCalledTimes(1);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a failed tunnel without restarting the host', async () => {
|
||||
const host = await startHostAsync();
|
||||
jest.mocked(ngrok.forward).mockRejectedValueOnce(new Error('tunnel unavailable'));
|
||||
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow('tunnel unavailable');
|
||||
expect(stopServer).not.toHaveBeenCalled();
|
||||
await host.openPreviewAsync({ baseDomain });
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
await host.finishAsync();
|
||||
expect(closeTunnel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drains an in-flight tunnel when finishing and rejects new opens', async () => {
|
||||
const host = await startHostAsync();
|
||||
const tunnel = deferred<Awaited<ReturnType<typeof ngrok.forward>>>();
|
||||
jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise);
|
||||
const opening = host.openPreviewAsync({ baseDomain });
|
||||
const finishing = host.finishAsync();
|
||||
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow(
|
||||
'after session host finalization'
|
||||
);
|
||||
tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never);
|
||||
await expect(opening).rejects.toThrow('finalized while the preview was opening');
|
||||
await finishing;
|
||||
expect(closeTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps cleanup and upload best-effort when tunnel close and finalization fail', async () => {
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(options => {
|
||||
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
|
||||
return {
|
||||
pid: undefined,
|
||||
getOutput: () => '[serve-emu] emulator-5554 capture error: scrcpy exited with code 255',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
};
|
||||
});
|
||||
const host = await startHostAsync();
|
||||
await writeFile(path.join(directories[0], 'recordings.json'), '[]');
|
||||
await host.openPreviewAsync({ baseDomain });
|
||||
closeTunnel.mockRejectedValueOnce(new Error('tunnel close failure'));
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Error: scrcpy exited with code 255',
|
||||
} as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
await host.finishAsync();
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.objectContaining({
|
||||
message: expect.stringContaining('HTTP 500: Error: scrcpy exited with code 255'),
|
||||
}),
|
||||
},
|
||||
'Could not finalize Android recording before shutdown.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not finalize Android recording before shutdown',
|
||||
expect.any(Error),
|
||||
{ level: 'warning' }
|
||||
);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ hostOutput: '[serve-emu] emulator-5554 capture error: scrcpy exited with code 255' },
|
||||
'Session host output around the recording failure.'
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the reason and skips upload and output dump when the Hub never recorded', async () => {
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(options => {
|
||||
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
|
||||
return {
|
||||
pid: undefined,
|
||||
getOutput: () => '[serve-emu] Android recording skipped',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
};
|
||||
});
|
||||
const host = await startHostAsync();
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 409,
|
||||
text: async () => 'Android recording requires exactly one booted emulator; found 0.',
|
||||
} as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
await host.finishAsync();
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Android recording was not captured: Android recording requires exactly one booted emulator; found 0.'
|
||||
);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Session host output around the recording failure.'
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back failed host startup without replacing the original error', async () => {
|
||||
stopServer.mockRejectedValueOnce(new Error('stop failure'));
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 0,
|
||||
})
|
||||
).rejects.toThrow('Timed out waiting');
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringMatching(/finalize|upload/)
|
||||
);
|
||||
});
|
||||
|
||||
async function writeRecordingDescriptorAsync(directory: string) {
|
||||
const child = path.join(directory, 'session');
|
||||
await mkdir(child);
|
||||
await writeFile(
|
||||
path.join(directory, 'recordings.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android',
|
||||
directory: child,
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
it.each([true, false])(
|
||||
'removes its recording root only when every upload succeeds (%s)',
|
||||
async uploaded => {
|
||||
const host = await startHostAsync();
|
||||
const directory = directories[0];
|
||||
await writeRecordingDescriptorAsync(directory);
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockResolvedValueOnce(uploaded);
|
||||
await host.finishAsync();
|
||||
if (uploaded) {
|
||||
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} else {
|
||||
await expect(access(directory)).resolves.toBeUndefined();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('finishes despite a stalled tunnel close', async () => {
|
||||
const host = await startHostAsync();
|
||||
await host.openPreviewAsync({ baseDomain });
|
||||
const closing = deferred<void>();
|
||||
closeTunnel.mockReturnValueOnce(closing.promise);
|
||||
jest.useFakeTimers();
|
||||
const finishing = host.finishAsync();
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
await finishing;
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('deadline'));
|
||||
closing.resolve();
|
||||
});
|
||||
|
||||
it('aborts a stalled finalization request before stopping the host', async () => {
|
||||
const host = await startHostAsync();
|
||||
const response = deferred<Awaited<ReturnType<typeof turtleFetch>>>();
|
||||
jest.mocked(turtleFetch).mockReturnValueOnce(response.promise);
|
||||
jest.useFakeTimers();
|
||||
const finishing = host.finishAsync();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
await finishing;
|
||||
const options = jest.mocked(turtleFetch).mock.calls.at(-1)?.[2];
|
||||
expect(options?.signal?.aborted).toBe(true);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
response.resolve({ ok: true } as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
});
|
||||
|
||||
it('uploads the partial recording a killed host left behind and then removes the root', async () => {
|
||||
const host = await startHostAsync();
|
||||
const directory = directories[0];
|
||||
const child = path.join(directory, 'session');
|
||||
await mkdir(child);
|
||||
await writeFile(path.join(directory, 'recordings.json'), '[]');
|
||||
const partial = [
|
||||
{ udid: 'emulator-5554', deviceName: 'Pixel', runtimeDisplayName: 'Android', directory: child },
|
||||
];
|
||||
jest.mocked(findUnlistedDeviceScreenRecordingsAsync).mockResolvedValueOnce(partial);
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockResolvedValueOnce(true);
|
||||
await host.finishAsync();
|
||||
expect(findUnlistedDeviceScreenRecordingsAsync).toHaveBeenCalledWith({
|
||||
root: directory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledWith(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
recordings: partial,
|
||||
});
|
||||
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('rejects descriptors outside its recording root', async () => {
|
||||
const host = await startHostAsync();
|
||||
const directory = directories[0];
|
||||
await writeFile(
|
||||
path.join(directory, 'recordings.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android',
|
||||
directory: path.dirname(directory),
|
||||
},
|
||||
])
|
||||
);
|
||||
await host.finishAsync();
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
await expect(access(directory)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('retains recordings without uploading if the host cannot be stopped', async () => {
|
||||
const host = await startHostAsync();
|
||||
const directory = directories[0];
|
||||
await writeRecordingDescriptorAsync(directory);
|
||||
stopServer.mockRejectedValueOnce(new Error('process stop failed'));
|
||||
await host.finishAsync();
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
await expect(access(directory)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('closes a listener that arrives after finish has already timed out waiting for it', async () => {
|
||||
const host = await startHostAsync();
|
||||
const tunnel = deferred<Awaited<ReturnType<typeof ngrok.forward>>>();
|
||||
jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise);
|
||||
const opening = host.openPreviewAsync({ baseDomain });
|
||||
const rejected = expect(opening).rejects.toThrow('finalized while the preview was opening');
|
||||
jest.useFakeTimers();
|
||||
const finishing = host.finishAsync();
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
await finishing;
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never);
|
||||
await rejected;
|
||||
expect(closeTunnel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves iOS recording to its existing build steps', async () => {
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const options = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(options.args).not.toContain('--android-recording-directory');
|
||||
expect(options.env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN).toBeUndefined();
|
||||
await host.finishAsync();
|
||||
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -454,6 +454,7 @@ describe(startChiselServerAsync, () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 12345,
|
||||
getOutput: () => output,
|
||||
getExitError: () => undefined,
|
||||
stopAsync,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,9 @@ import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
clearTimeout as clearTimeoutCallback,
|
||||
setTimeout as setTimeoutCallback,
|
||||
@@ -15,24 +18,28 @@ import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
parseServeSimLaunchInputs,
|
||||
simulatorPreviewPageUrl,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startExpoDeviceHubWithTunnelAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
turnIceServersToWebPreviewArgs,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
simulatorPreviewPageUrl,
|
||||
startDeviceSessionHostAsync,
|
||||
waitForWebPreviewReadyAsync,
|
||||
websiteOrigin,
|
||||
websiteOriginServeSimArgs,
|
||||
} from '../remoteDeviceRunSession';
|
||||
} from '../deviceSessionHost';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('node:timers');
|
||||
@@ -41,6 +48,10 @@ jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../deviceRunSessionScreenRecordings', () => ({
|
||||
...jest.requireActual('../deviceRunSessionScreenRecordings'),
|
||||
uploadDeviceRunSessionScreenRecordingsAsync: jest.fn(),
|
||||
}));
|
||||
// Spyable so a test can stand in for the serve-sim state directory, which a local serve-sim owns.
|
||||
jest.mock('../serveSimMetricsRecorder', () => {
|
||||
const actual = jest.requireActual('../serveSimMetricsRecorder');
|
||||
@@ -56,6 +67,69 @@ function createLoggerMock(): bunyan {
|
||||
} as unknown as bunyan;
|
||||
}
|
||||
|
||||
describe(spawnDetached, () => {
|
||||
function mockProcess(promise: Promise<unknown>): void {
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockReturnValue(
|
||||
Object.assign(promise, { child: { pid: 1234, unref: jest.fn(), once: jest.fn() } }) as never
|
||||
);
|
||||
}
|
||||
|
||||
it('observes exit without waiting for inherited output pipes to close', () => {
|
||||
const child = Object.assign(new EventEmitter(), { pid: 1234, unref: jest.fn() });
|
||||
jest.mocked(spawn).mockReturnValue(Object.assign(new Promise(() => {}), { child }) as never);
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
child.emit('exit', 1, null);
|
||||
expect(handle.getExitError()?.message).toContain('code 1');
|
||||
expect(child.listenerCount('exit')).toBe(0);
|
||||
});
|
||||
|
||||
it('does not report an exit while the process is running', () => {
|
||||
mockProcess(new Promise(() => {}));
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
expect(handle.getExitError()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports a clean exit so startup does not keep waiting', async () => {
|
||||
const completion = Promise.resolve();
|
||||
mockProcess(completion);
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
await completion;
|
||||
expect(handle.getExitError()?.message).toContain('code 0');
|
||||
});
|
||||
|
||||
it.each([0, 1])('observes a real subprocess exiting with code %s', async exitCode => {
|
||||
const actualSpawn =
|
||||
jest.requireActual<typeof import('@expo/turtle-spawn')>('@expo/turtle-spawn').default;
|
||||
let completion: ReturnType<typeof actualSpawn>;
|
||||
jest.mocked(spawn).mockImplementationOnce((...args) => {
|
||||
completion = actualSpawn(...args);
|
||||
return completion;
|
||||
});
|
||||
const handle = spawnDetached({
|
||||
command: process.execPath,
|
||||
args: ['-e', `console.error('startup-marker'); process.exit(${exitCode});`],
|
||||
env: {},
|
||||
});
|
||||
await completion!.catch(() => {});
|
||||
expect(handle.getExitError()).toBeInstanceOf(Error);
|
||||
expect(handle.getOutput()).toContain('startup-marker');
|
||||
});
|
||||
|
||||
it.each(['spawn ENOENT', 'server exited with code 1', 'server terminated by SIGTERM'])(
|
||||
'preserves the process failure: %s',
|
||||
async message => {
|
||||
const error = new Error(message);
|
||||
const completion = Promise.reject(error);
|
||||
mockProcess(completion);
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
await completion.catch(() => {});
|
||||
expect(handle.getExitError()).toBe(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
function createCtxMock(): CustomBuildContext {
|
||||
return {
|
||||
env: {
|
||||
@@ -382,6 +456,12 @@ describe(createServeSimArgs, () => {
|
||||
});
|
||||
|
||||
describe(createExpoDeviceHubArgs, () => {
|
||||
it('opts in to recording only when a directory is provided', () => {
|
||||
expect(createExpoDeviceHubArgs({ port: 4321 })).not.toContain('--android-recording-directory');
|
||||
expect(
|
||||
createExpoDeviceHubArgs({ port: 4321, recordingDirectory: '/tmp/recordings' }).slice(-2)
|
||||
).toEqual(['--android-recording-directory', '/tmp/recordings']);
|
||||
});
|
||||
it('uses the latest Expo package and applies the EAS Android streaming policy', () => {
|
||||
expect(
|
||||
createExpoDeviceHubArgs({
|
||||
@@ -531,7 +611,7 @@ describe(startNgrokTunnelAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
describe(startDeviceSessionHostAsync, () => {
|
||||
const baseDomain = 'eas-simulator.ngrok.dev';
|
||||
const turnArgs = [
|
||||
'--turn-url',
|
||||
@@ -548,6 +628,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
} as unknown as BuildStepEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset();
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(ngrok.forward).mockReset();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
@@ -559,6 +640,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
child: {
|
||||
pid: undefined,
|
||||
unref: jest.fn(),
|
||||
once: jest.fn(),
|
||||
},
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(spawnPromise as never);
|
||||
@@ -600,14 +682,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>)
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
expect(spawnCalls[0]).toEqual(['ffmpeg', ['-version'], { env }]);
|
||||
@@ -634,11 +716,17 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(command).toBe('npx');
|
||||
expect(args).toEqual(['--yes', ...createExpoDeviceHubArgs({ port, turnArgs, packageVersion })]);
|
||||
expect(args).toContain('--android-recording-directory');
|
||||
const recordingDirectory = args[args.indexOf('--android-recording-directory') + 1];
|
||||
expect(args).toEqual([
|
||||
'--yes',
|
||||
...createExpoDeviceHubArgs({ port, turnArgs, packageVersion, recordingDirectory }),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.apiUrl).toBe('https://android-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
await host.finishAsync();
|
||||
await fs.rm(recordingDirectory, { recursive: true, force: true });
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -648,13 +736,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
expect(preview.previewToken).toBe('tok-1');
|
||||
expect(preview.apiUrl).toBe('https://preview.example.test');
|
||||
@@ -677,13 +765,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env: { ...env, EXPO_STAGING: '1' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
expect(preview.previewPageUrl).toMatch(
|
||||
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
|
||||
@@ -702,9 +790,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1' }]);
|
||||
|
||||
await expect(
|
||||
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
@@ -720,13 +807,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
expect(preview.previewToken).toBeUndefined();
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
@@ -741,14 +828,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -767,33 +854,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not install ffmpeg before starting expo-device-hub outside Linux', async () => {
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startExpoDeviceHubWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(jest.mocked(spawn).mock.calls[0][0]).toBe('npx');
|
||||
expect(jest.mocked(spawn)).not.toHaveBeenCalledWith(
|
||||
'ffmpeg',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
await preview.stopAsync();
|
||||
await host.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -805,14 +866,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
} as never);
|
||||
|
||||
const bunEnv = { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env: bunEnv,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion: '4.5.6',
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -828,7 +889,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await preview.stopAsync();
|
||||
await host.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -839,13 +900,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env: { ...env, EAS_FALLBACK_PACKAGE_MANAGER: 'bun' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -860,7 +921,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await preview.stopAsync();
|
||||
await host.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -870,9 +931,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
@@ -880,6 +940,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
});
|
||||
await host.finishAsync();
|
||||
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(args.slice(-8)).toEqual([
|
||||
@@ -896,9 +957,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
|
||||
it('refuses to launch an application on Linux, where expo-device-hub cannot', async () => {
|
||||
await expect(
|
||||
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { isProcessRunning, spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
|
||||
it('resolves stopAsync only after a process that ignores SIGTERM is gone', async () => {
|
||||
const handle = spawnDetached({
|
||||
command: 'sh',
|
||||
args: ['-c', 'trap "" TERM; echo ready; sleep 30 & wait'],
|
||||
env: process.env as BuildStepEnv,
|
||||
stopGracePeriodMs: 300,
|
||||
});
|
||||
const pid = handle.pid;
|
||||
if (pid === undefined) {
|
||||
throw new Error('spawn failed');
|
||||
}
|
||||
while (!handle.getOutput().includes('ready')) {
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
await handle.stopAsync();
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(300);
|
||||
expect(isProcessRunning(pid)).toBe(false);
|
||||
});
|
||||
@@ -1,8 +1,15 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch, { Headers } from 'node-fetch';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import {
|
||||
DeviceRunSessionTimeoutError,
|
||||
withDeviceRunSessionTimeoutAsync,
|
||||
} from './deviceRunSessionTimeout';
|
||||
|
||||
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateDeviceRunSessionArtifactUploadSession(
|
||||
@@ -20,82 +27,157 @@ const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
}
|
||||
`);
|
||||
|
||||
type ArtifactMetadata = {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
kind: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type ArtifactSource = { stream: Readable; reopenStream?: () => Readable };
|
||||
type ArtifactUploadSession = Awaited<
|
||||
ReturnType<typeof createDeviceRunSessionArtifactUploadSessionAsync>
|
||||
>;
|
||||
|
||||
export async function uploadDeviceRunSessionArtifactAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
kind,
|
||||
metadata,
|
||||
size,
|
||||
stream,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
kind: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
size: number;
|
||||
stream: NodeJS.ReadableStream;
|
||||
}
|
||||
options: ArtifactMetadata & ArtifactSource & { signal?: AbortSignal }
|
||||
): Promise<void> {
|
||||
const uploadSession = await createDeviceRunSessionArtifactUploadSessionAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
kind,
|
||||
metadata,
|
||||
size,
|
||||
});
|
||||
const response = await fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
headers: new Headers(uploadSession.headers as Record<string, string>),
|
||||
body: stream,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
`Failed to upload device run session artifact ${artifactId}: ${response.status} ${response.statusText}`,
|
||||
{ cause: response }
|
||||
try {
|
||||
const uploadSession = await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Artifact upload session creation', timeoutMs: 15_000, signal: options.signal },
|
||||
async signal => await createDeviceRunSessionArtifactUploadSessionAsync(ctx, options, signal)
|
||||
);
|
||||
options.signal?.throwIfAborted();
|
||||
await putArtifactWithRetriesAsync({
|
||||
uploadSession,
|
||||
source: options,
|
||||
artifactId: options.artifactId,
|
||||
signal: options.signal,
|
||||
});
|
||||
} finally {
|
||||
// One-shot callers may have opened their stream before upload-session creation failed.
|
||||
options.stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function putArtifactWithRetriesAsync({
|
||||
uploadSession,
|
||||
source,
|
||||
artifactId,
|
||||
signal,
|
||||
}: {
|
||||
uploadSession: ArtifactUploadSession;
|
||||
source: ArtifactSource;
|
||||
artifactId: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const attempts = source.reopenStream ? 3 : 1;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
signal?.throwIfAborted();
|
||||
const stream = attempt > 0 && source.reopenStream ? source.reopenStream() : source.stream;
|
||||
try {
|
||||
// Upload time scales with file size, so the deadline bounds stalls, not the whole request.
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Artifact PUT', timeoutMs: 90_000, signal },
|
||||
async (putSignal, resetDeadline) =>
|
||||
await putArtifactAsync({
|
||||
uploadSession,
|
||||
stream,
|
||||
artifactId,
|
||||
signal: putSignal,
|
||||
onProgress: resetDeadline,
|
||||
})
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
const retryable =
|
||||
error instanceof ArtifactPutError
|
||||
? [408, 429, 500, 502, 503, 504].includes(error.status)
|
||||
: error instanceof DeviceRunSessionTimeoutError ||
|
||||
(error instanceof Error && error.name === 'FetchError');
|
||||
if (!retryable || attempt + 1 === attempts) {
|
||||
throw error;
|
||||
}
|
||||
await delay(500 * (attempt + 1), undefined, { signal });
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function putArtifactAsync({
|
||||
uploadSession,
|
||||
stream,
|
||||
artifactId,
|
||||
signal,
|
||||
onProgress,
|
||||
}: {
|
||||
uploadSession: ArtifactUploadSession;
|
||||
stream: Readable;
|
||||
artifactId: string;
|
||||
signal: AbortSignal;
|
||||
onProgress: () => void;
|
||||
}): Promise<void> {
|
||||
const requestController = new AbortController();
|
||||
try {
|
||||
const responding = fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
headers: new Headers(uploadSession.headers as Record<string, string>),
|
||||
body: stream,
|
||||
signal: AbortSignal.any([signal, requestController.signal]),
|
||||
});
|
||||
// fetch has piped the body already, so this listener observes flow without starting it.
|
||||
stream.on('data', onProgress);
|
||||
const response = await responding;
|
||||
signal.throwIfAborted();
|
||||
if (!response.ok) {
|
||||
throw new ArtifactPutError(artifactId, response);
|
||||
}
|
||||
} finally {
|
||||
// node-fetch destroys the request body on abort, and its response stream is a PassThrough
|
||||
// whose destruction leaves the socket open, so the request itself is aborted here.
|
||||
requestController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
class ArtifactPutError extends SystemError {
|
||||
readonly status: number;
|
||||
|
||||
constructor(artifactId: string, response: { status: number; statusText: string }) {
|
||||
super(
|
||||
`Failed to upload device run session artifact ${artifactId}: HTTP ${response.status} ${response.statusText}.`
|
||||
);
|
||||
this.status = response.status;
|
||||
}
|
||||
}
|
||||
|
||||
async function createDeviceRunSessionArtifactUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
kind,
|
||||
metadata,
|
||||
size,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
kind: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
size: number;
|
||||
}
|
||||
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
|
||||
signal: AbortSignal
|
||||
) {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION, {
|
||||
deviceRunSessionId,
|
||||
input: {
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
.mutation(
|
||||
CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
input: {
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
},
|
||||
},
|
||||
})
|
||||
graphqlAbortContext(signal)
|
||||
)
|
||||
.toPromise();
|
||||
signal.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw new SystemError(
|
||||
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import limitFactory from 'promise-limit';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
|
||||
const RecordingsSchema = z.array(
|
||||
z.object({
|
||||
udid: z.string(),
|
||||
deviceName: z.string(),
|
||||
runtimeDisplayName: z.string(),
|
||||
directory: z.string(),
|
||||
})
|
||||
);
|
||||
|
||||
const RecordingManifestSchema = z.object({
|
||||
firstFrameWallClock: z.object({
|
||||
iso8601: z.string(),
|
||||
}),
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
recording: z.string(),
|
||||
status: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const UnlistedRecordingManifestSchema = z.object({
|
||||
udid: z.string(),
|
||||
deviceName: z.string(),
|
||||
runtimeDisplayName: z.string(),
|
||||
status: z.enum(['recording', 'failed', 'complete']),
|
||||
recording: z.string(),
|
||||
});
|
||||
|
||||
const recordingStartTimeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
hourCycle: 'h23',
|
||||
timeZone: 'UTC',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
export function parseDeviceScreenRecordings(input: unknown): z.infer<typeof RecordingsSchema> {
|
||||
const result = RecordingsSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new SystemError('Invalid device screen recordings input.', {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Hub that was killed never lists its recording, even one it had already completed at the
|
||||
* duration limit. session.json is at "recording" or "failed" with a fragmented .partial that plays
|
||||
* up to its last keyframe, or at "complete" with the finished file. Return the ones ffprobe can read.
|
||||
*/
|
||||
export async function findUnlistedDeviceScreenRecordingsAsync({
|
||||
root,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
root: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<z.infer<typeof RecordingsSchema>> {
|
||||
const found: z.infer<typeof RecordingsSchema> = [];
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const directory = path.join(root, entry.name);
|
||||
const manifest = UnlistedRecordingManifestSchema.safeParse(
|
||||
await readFile(path.join(directory, 'session.json'), 'utf-8')
|
||||
.then(text => JSON.parse(text))
|
||||
.catch(() => null)
|
||||
);
|
||||
if (!manifest.success || path.basename(manifest.data.recording) !== manifest.data.recording) {
|
||||
continue;
|
||||
}
|
||||
const file = path.join(directory, manifest.data.recording);
|
||||
const name = path.join(entry.name, manifest.data.recording);
|
||||
let duration = 0;
|
||||
try {
|
||||
const probe = await spawn(
|
||||
'ffprobe',
|
||||
['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
duration = Number(probe.stdout.trim());
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.warn(`ffprobe is not available; cannot recover the unlisted recording ${name}.`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!(duration > 0)) {
|
||||
logger.warn(`Unlisted recording ${name} does not decode; skipping it.`);
|
||||
continue;
|
||||
}
|
||||
const { udid, deviceName, runtimeDisplayName } = manifest.data;
|
||||
found.push({ udid, deviceName, runtimeDisplayName, directory });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export async function uploadDeviceRunSessionScreenRecordingsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
logger,
|
||||
deviceRunSessionId,
|
||||
recordings,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
deviceRunSessionId: string;
|
||||
recordings: z.infer<typeof RecordingsSchema>;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
if (recordings.length === 0) {
|
||||
logger.info('No device screen recordings found; skipping uploads.');
|
||||
return true;
|
||||
}
|
||||
|
||||
const limit = limitFactory(5);
|
||||
const uploaded = await Promise.all(
|
||||
recordings.map(recording =>
|
||||
limit(async () => {
|
||||
try {
|
||||
const metadata = RecordingManifestSchema.parse(
|
||||
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
|
||||
);
|
||||
if (path.basename(metadata.recording) !== metadata.recording) {
|
||||
throw new Error('Recording filename must not contain a directory.');
|
||||
}
|
||||
const startedAt = recordingStartTimeFormatter.format(
|
||||
new Date(metadata.firstFrameWallClock.iso8601)
|
||||
);
|
||||
const partial = metadata.status !== undefined && metadata.status !== 'complete';
|
||||
const shortUdid = `${recording.udid.slice(0, 8)}-…`;
|
||||
const displayName = `${recording.deviceName} screen recording (${shortUdid}, started at ${startedAt}${partial ? ', partial' : ''})`;
|
||||
const recordingPath = path.join(recording.directory, metadata.recording);
|
||||
const { size } = await stat(recordingPath);
|
||||
const recordingId = path.basename(recording.directory);
|
||||
logger.info(
|
||||
`Uploading screen recording for ${recording.deviceName} (${formatBytes(size)}).`
|
||||
);
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: recordingId,
|
||||
name: displayName,
|
||||
filename: `${recordingId}.mp4`,
|
||||
kind: 'screen-recording',
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording',
|
||||
__eas_screen_recording: '1',
|
||||
udid: recording.udid,
|
||||
deviceName: recording.deviceName,
|
||||
runtimeDisplayName: recording.runtimeDisplayName,
|
||||
firstFrameAt: metadata.firstFrameWallClock.iso8601,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
...(partial
|
||||
? {
|
||||
partial: true,
|
||||
partialReason:
|
||||
metadata.error ??
|
||||
'The Device Hub stopped before it could finalize the recording.',
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
size,
|
||||
stream: createReadStream(recordingPath),
|
||||
reopenStream: () => createReadStream(recordingPath),
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload device screen recording', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Could not upload screen recording for ${recording.deviceName}.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
return uploaded.every(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export class DeviceRunSessionTimeoutError extends Error {
|
||||
constructor(name: string, timeoutMs: number) {
|
||||
super(`${name} timed out after ${timeoutMs}ms.`);
|
||||
this.name = 'DeviceRunSessionTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds waiting and signals cancellation. Operations must check the signal before later effects.
|
||||
* Calling resetDeadline restarts the timer, so the bound applies to stalls instead of total time.
|
||||
*/
|
||||
export async function withDeviceRunSessionTimeoutAsync<T>(
|
||||
{ name, timeoutMs, signal: parent }: { name: string; timeoutMs: number; signal?: AbortSignal },
|
||||
operation: (signal: AbortSignal, resetDeadline: () => void) => Promise<T>
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const signal = parent ? AbortSignal.any([parent, controller.signal]) : controller.signal;
|
||||
signal.throwIfAborted();
|
||||
let rejectAbort: (reason: unknown) => void = () => {};
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
rejectAbort = reject;
|
||||
});
|
||||
const onAbort = () => rejectAbort(signal.reason);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
const expire = () => controller.abort(new DeviceRunSessionTimeoutError(name, timeoutMs));
|
||||
let timer = setTimeout(expire, timeoutMs);
|
||||
let settled = false;
|
||||
const resetDeadline = () => {
|
||||
clearTimeout(timer);
|
||||
if (!settled && !signal.aborted) {
|
||||
timer = setTimeout(expire, timeoutMs);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const result = await Promise.race([operation(signal, resetDeadline), aborted]);
|
||||
signal.throwIfAborted();
|
||||
return result;
|
||||
} finally {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import {
|
||||
findUnlistedDeviceScreenRecordingsAsync,
|
||||
parseDeviceScreenRecordings,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from './deviceRunSessionScreenRecordings';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
type ServeSimLaunchOptions,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
findAvailablePortAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
isProcessRunning,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
} from './remoteDeviceRunSession';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1600';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '10000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
|
||||
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
|
||||
// On SIGTERM the Hub finalizes the recording itself, with this deadline before it force-exits,
|
||||
// so a stop request that failed here still gets one more chance to write the MP4.
|
||||
const EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS = 60_000;
|
||||
const EXPO_DEVICE_HUB_EXIT_LEEWAY_MS = 10_000;
|
||||
const RECORDING_STOP_GRACE_PERIOD_MS =
|
||||
EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS + EXPO_DEVICE_HUB_EXIT_LEEWAY_MS;
|
||||
const HOST_OUTPUT_TAIL_CHARS = 8_000;
|
||||
|
||||
export function websiteOrigin(env: BuildStepEnv): string {
|
||||
return env.EXPO_LOCAL
|
||||
? 'https://expo.test'
|
||||
: env.EXPO_STAGING
|
||||
? 'https://staging.expo.dev'
|
||||
: 'https://expo.dev';
|
||||
}
|
||||
|
||||
export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string): string {
|
||||
return new URL(`/simulator-preview/${subdomainId}`, websiteOrigin(env)).toString();
|
||||
}
|
||||
|
||||
// Website dev servers on expo.test use staging sessions, and CORS matches ports exactly.
|
||||
const WEBSITE_DEV_ORIGINS = [
|
||||
'https://expo.test',
|
||||
'https://expo.test:13001',
|
||||
...Array.from({ length: 16 }, (_, index) => `https://expo.test:${13200 + index}`),
|
||||
];
|
||||
|
||||
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
|
||||
const origins = new Set([websiteOrigin(env)]);
|
||||
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
|
||||
origins.add('https://*.expo.dev');
|
||||
}
|
||||
if (env.EXPO_LOCAL || env.EXPO_STAGING) {
|
||||
for (const origin of WEBSITE_DEV_ORIGINS) {
|
||||
origins.add(origin);
|
||||
}
|
||||
}
|
||||
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
|
||||
}
|
||||
|
||||
function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
shareUrl,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
shareUrl?: string;
|
||||
packageVersion?: string;
|
||||
} & ServeSimLaunchOptions): string[] {
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--max-dimension',
|
||||
SERVE_SIM_MAX_DIMENSION,
|
||||
'--mjpeg-quality',
|
||||
SERVE_SIM_MJPEG_QUALITY,
|
||||
'--video-bitrate',
|
||||
SERVE_SIM_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
SERVE_SIM_VIDEO_FPS,
|
||||
...turnArgs,
|
||||
...websiteArgs,
|
||||
...(shareUrl ? ['--share-url', shareUrl] : []),
|
||||
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
|
||||
...launchArgs.flatMap(argument => ['--launch-arg', argument]),
|
||||
...(openUrl ? ['--open-url', openUrl] : []),
|
||||
];
|
||||
}
|
||||
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
packageVersion,
|
||||
recordingDirectory,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
packageVersion?: string;
|
||||
recordingDirectory?: string;
|
||||
}): string[] {
|
||||
return [
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--webrtc-ice-policy',
|
||||
'all',
|
||||
'--max-dimension',
|
||||
EXPO_DEVICE_HUB_MAX_DIMENSION,
|
||||
'--video-bitrate',
|
||||
EXPO_DEVICE_HUB_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
EXPO_DEVICE_HUB_VIDEO_FPS,
|
||||
'--hide-sidebar',
|
||||
'--hide-boot-device',
|
||||
...(recordingDirectory ? ['--android-recording-directory', recordingDirectory] : []),
|
||||
...turnArgs,
|
||||
];
|
||||
}
|
||||
|
||||
const WebPreviewReadyResponseSchema = z.object({
|
||||
status: z.literal('ready'),
|
||||
device: z.string(),
|
||||
});
|
||||
|
||||
export async function waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
}: {
|
||||
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<string> {
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
|
||||
throw new SystemError(
|
||||
`${serverName} exited before becoming ready. Last output:\n${
|
||||
previewServer.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await turtleFetch(readyUrl, 'GET', {
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
});
|
||||
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return ready.device;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function readServeSimPreviewTokenAsync(
|
||||
udid: string,
|
||||
stateDir: string = SERVE_SIM_STATE_DIR
|
||||
): Promise<string | undefined> {
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
return servers.find(server => server.udid === udid)?.token;
|
||||
}
|
||||
|
||||
export type DeviceWebPreview = {
|
||||
previewPageUrl: string;
|
||||
apiUrl: string;
|
||||
previewToken?: string;
|
||||
/** Closes this tunnel only. The session host and recording remain running. */
|
||||
closeAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
export type DeviceSessionHost = {
|
||||
openPreviewAsync(options: { baseDomain: string }): Promise<DeviceWebPreview>;
|
||||
/**
|
||||
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
|
||||
* and reported, so callers need no error handling around it.
|
||||
*/
|
||||
finishAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
type AndroidSessionRecording = {
|
||||
deviceRunSessionId: string;
|
||||
directory: string;
|
||||
controlToken: string;
|
||||
env: BuildStepEnv;
|
||||
};
|
||||
|
||||
export async function startDeviceSessionHostAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
} & ServeSimLaunchOptions
|
||||
): Promise<DeviceSessionHost> {
|
||||
const isAndroid = runtimePlatform === BuildRuntimePlatform.LINUX;
|
||||
// Unreachable from the step functions, which reject a non-Darwin launch while parsing.
|
||||
// Kept because this function is exported and expo-device-hub cannot launch.
|
||||
if (isAndroid && launchAppIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
|
||||
);
|
||||
}
|
||||
if (isAndroid) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
const recording: AndroidSessionRecording | null = isAndroid
|
||||
? {
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
directory: await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-session-recordings-')),
|
||||
controlToken: randomBytes(32).toString('hex'),
|
||||
env,
|
||||
}
|
||||
: null;
|
||||
const subdomainId = randomBytes(16).toString('hex');
|
||||
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
|
||||
const port = await findAvailablePortAsync();
|
||||
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
|
||||
const packageSpec = isAndroid
|
||||
? createExpoDeviceHubPackageSpec(packageVersion)
|
||||
: createServeSimPackageSpec(packageVersion);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
isAndroid
|
||||
? createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
recordingDirectory: recording?.directory,
|
||||
})
|
||||
: createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
websiteArgs: websiteOriginServeSimArgs(env),
|
||||
shareUrl: previewPageUrl,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
})
|
||||
);
|
||||
logger.info(
|
||||
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
|
||||
);
|
||||
const previewServer = spawnDetached({
|
||||
command: previewExec.command,
|
||||
args: previewExec.args,
|
||||
env: recording
|
||||
? { ...env, EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken }
|
||||
: env,
|
||||
stopGracePeriodMs: recording ? RECORDING_STOP_GRACE_PERIOD_MS : undefined,
|
||||
});
|
||||
|
||||
let previewToken: string | undefined;
|
||||
let previewTask: Promise<DeviceWebPreview> | null = null;
|
||||
let finishTask: Promise<void> | null = null;
|
||||
let hostReady = false;
|
||||
|
||||
const host: DeviceSessionHost = {
|
||||
openPreviewAsync({ baseDomain }) {
|
||||
if (finishTask) {
|
||||
return Promise.reject(
|
||||
new SystemError('Cannot open a preview after session host finalization.')
|
||||
);
|
||||
}
|
||||
if (previewTask) {
|
||||
return previewTask;
|
||||
}
|
||||
const opening = (async (): Promise<DeviceWebPreview> => {
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId,
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
if (finishTask) {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Late preview tunnel close', timeoutMs: 5_000 },
|
||||
async () => await tunnel.stopAsync()
|
||||
).catch(err => logger.warn({ err }, 'Could not close a late preview tunnel.'));
|
||||
throw new SystemError('Session host finalized while the preview was opening.');
|
||||
}
|
||||
let closeTask: Promise<void> | null = null;
|
||||
return {
|
||||
previewPageUrl,
|
||||
apiUrl: tunnel.url,
|
||||
previewToken,
|
||||
closeAsync() {
|
||||
return (closeTask ??= (async () => {
|
||||
try {
|
||||
await tunnel.stopAsync();
|
||||
} finally {
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
}
|
||||
})());
|
||||
},
|
||||
};
|
||||
})();
|
||||
previewTask = opening;
|
||||
// A failed tunnel may be retried without restarting capture.
|
||||
void opening.catch(() => {
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
});
|
||||
return opening;
|
||||
},
|
||||
finishAsync() {
|
||||
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
|
||||
previewTask,
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
// A host that never answered /readyz has nothing to finalize or upload.
|
||||
recording: hostReady ? recording : null,
|
||||
logger,
|
||||
}));
|
||||
},
|
||||
};
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = await waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
});
|
||||
hostReady = true;
|
||||
if (!isAndroid) {
|
||||
previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
throw new SystemError(
|
||||
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
|
||||
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
|
||||
'This usually means the state file was not written as expected; retry the session, and ' +
|
||||
'report it if it repeats.'
|
||||
);
|
||||
}
|
||||
}
|
||||
return host;
|
||||
} catch (error) {
|
||||
await host.finishAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function finishDeviceSessionHostAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
previewTask,
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
recording,
|
||||
logger,
|
||||
}: {
|
||||
previewTask: Promise<DeviceWebPreview> | null;
|
||||
previewServer: DetachedProcessHandle;
|
||||
serverName: string;
|
||||
port: number;
|
||||
recording: AndroidSessionRecording | null;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<void> {
|
||||
// Native ngrok operations have no scoped cancellation. Retire a late listener too.
|
||||
const retirePreview = withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Preview tunnel retirement', timeoutMs: 5_000 },
|
||||
async () => {
|
||||
// A preview that failed to open, or was closed by its opener after finish began, has no tunnel left.
|
||||
const preview = await previewTask?.catch(() => null);
|
||||
await preview?.closeAsync();
|
||||
}
|
||||
).catch(err => {
|
||||
logger.warn({ err }, `Could not close the ${serverName} preview tunnel within its deadline.`);
|
||||
});
|
||||
let finalization: AndroidRecordingFinalization | null = null;
|
||||
if (recording) {
|
||||
// stopAsync signals the whole process group, including capture's encoder.
|
||||
// Finalize the MP4 first; the token protects this route on the preview server.
|
||||
finalization = await finalizeAndroidRecordingAsync({
|
||||
port,
|
||||
controlToken: recording.controlToken,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
let hostStopped = false;
|
||||
try {
|
||||
await previewServer.stopAsync();
|
||||
if (previewServer.pid !== undefined && isProcessRunning(previewServer.pid)) {
|
||||
throw new Error('Session host is still running after shutdown.');
|
||||
}
|
||||
hostStopped = true;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Could not stop the ${serverName} session host.`);
|
||||
}
|
||||
await retirePreview;
|
||||
// A Hub that never recorded has logged its reason and left nothing to upload.
|
||||
const captured = finalization !== 'not-recording';
|
||||
let uploaded = false;
|
||||
if (recording && captured && hostStopped) {
|
||||
uploaded = await uploadFinishedAndroidRecordingAsync(ctx, { recording, logger });
|
||||
}
|
||||
if (recording && captured && (finalization === 'failed' || !uploaded)) {
|
||||
// The Hub reports capture failures on stderr; the stop route answers with only a summary.
|
||||
logger.warn(
|
||||
{ hostOutput: previewServer.getOutput().slice(-HOST_OUTPUT_TAIL_CHARS) || '<empty>' },
|
||||
'Session host output around the recording failure.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type AndroidRecordingFinalization = 'finalized' | 'not-recording' | 'failed';
|
||||
|
||||
async function finalizeAndroidRecordingAsync({
|
||||
port,
|
||||
controlToken,
|
||||
logger,
|
||||
}: {
|
||||
port: number;
|
||||
controlToken: string;
|
||||
logger: bunyan;
|
||||
}): Promise<AndroidRecordingFinalization> {
|
||||
try {
|
||||
const response = await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Android recording finalization', timeoutMs: 60_000 },
|
||||
async signal =>
|
||||
await turtleFetch(
|
||||
`http://${WEB_PREVIEW_HOST}:${port}/_eas/android-recording/stop`,
|
||||
'POST',
|
||||
{
|
||||
headers: { Authorization: `Bearer ${controlToken}` },
|
||||
retries: 0,
|
||||
shouldThrowOnNotOk: false,
|
||||
signal,
|
||||
}
|
||||
)
|
||||
);
|
||||
if (response.status === 409) {
|
||||
// The Hub answers 409 with the reason nothing was recorded, such as the emulator count.
|
||||
logger.warn(`Android recording was not captured: ${await response.text()}`);
|
||||
return 'not-recording';
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
`Android recording finalization returned HTTP ${response.status}: ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return 'finalized';
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not finalize Android recording before shutdown', error, {
|
||||
level: 'warning',
|
||||
});
|
||||
logger.warn({ err: error }, 'Could not finalize Android recording before shutdown.');
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFinishedAndroidRecordingAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ recording, logger }: { recording: AndroidSessionRecording; logger: bunyan }
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
let recordings = parseDeviceScreenRecordings(
|
||||
JSON.parse(
|
||||
await fs.promises.readFile(path.join(recording.directory, 'recordings.json'), 'utf8')
|
||||
)
|
||||
);
|
||||
if (recordings.length === 0) {
|
||||
// recordings.json is written at finish; a killed Hub leaves what it had on disk unlisted.
|
||||
recordings = await findUnlistedDeviceScreenRecordingsAsync({
|
||||
root: recording.directory,
|
||||
env: recording.env,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
for (const item of recordings) {
|
||||
if (
|
||||
path.dirname(path.resolve(item.directory)) !== recording.directory ||
|
||||
(await fs.promises.lstat(item.directory)).isSymbolicLink()
|
||||
) {
|
||||
throw new Error('Recording directory is not an owned session child.');
|
||||
}
|
||||
}
|
||||
const uploaded = await uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: recording.deviceRunSessionId,
|
||||
recordings,
|
||||
});
|
||||
if (recordings.length > 0 && uploaded) {
|
||||
await fs.promises.rm(recording.directory, { recursive: true });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload the Android session recording', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error, recordingDirectory: recording.directory },
|
||||
'Could not upload the Android session recording.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -26,26 +26,10 @@ import {
|
||||
parseOpenUrlInput,
|
||||
} from '../functions/launchApplication';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1600';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '10000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
|
||||
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
|
||||
|
||||
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
|
||||
@@ -547,10 +531,11 @@ export type DetachedProcessHandle = {
|
||||
/** PID of the directly spawned process, if the OS assigned one. */
|
||||
pid: number | undefined;
|
||||
getOutput: () => string;
|
||||
getExitError: () => Error | undefined;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
export function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
@@ -559,7 +544,10 @@ function isProcessRunning(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDetachedProcessAsync(pid: number | undefined): Promise<void> {
|
||||
async function stopDetachedProcessAsync(
|
||||
pid: number | undefined,
|
||||
gracePeriodMs = 5_000
|
||||
): Promise<void> {
|
||||
if (pid === undefined || !isProcessRunning(pid)) {
|
||||
return;
|
||||
}
|
||||
@@ -575,7 +563,7 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 5_000;
|
||||
const deadline = Date.now() + gracePeriodMs;
|
||||
while (Date.now() < deadline && isProcessRunning(pid)) {
|
||||
await sleepAsync(100);
|
||||
}
|
||||
@@ -589,6 +577,43 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise<void>
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {}
|
||||
}
|
||||
// kill(pid, 0) succeeds on the zombie until libuv reaps it on a later loop turn.
|
||||
const killDeadline = Date.now() + 5_000;
|
||||
while (Date.now() < killDeadline && isProcessRunning(pid)) {
|
||||
await sleepAsync(100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every named teardown to completion and logs each failure. The first failure is rethrown
|
||||
* only when the session body succeeded, so a teardown error cannot replace the error that ended it.
|
||||
*/
|
||||
export async function finishRemoteSessionAsync({
|
||||
teardown,
|
||||
sessionFailed,
|
||||
logger,
|
||||
}: {
|
||||
teardown: [name: string, task: Promise<unknown> | undefined][];
|
||||
sessionFailed: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const results = await Promise.allSettled(teardown.map(([, task]) => task));
|
||||
const failures = results.flatMap((result, index) =>
|
||||
result.status === 'rejected' ? [{ name: teardown[index][0], err: result.reason }] : []
|
||||
);
|
||||
for (const { name, err } of failures) {
|
||||
logger.warn({ err }, `Could not stop the ${name} during remote session teardown.`);
|
||||
if (sessionFailed) {
|
||||
// The session error is what the step reports, so a swallowed teardown failure goes to Sentry.
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture(`Could not stop the ${name} after the remote session failed`, error, {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!sessionFailed && failures.length > 0) {
|
||||
throw failures[0].err;
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnDetached({
|
||||
@@ -596,11 +621,13 @@ export function spawnDetached({
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
stopGracePeriodMs,
|
||||
}: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env: BuildStepEnv;
|
||||
stopGracePeriodMs?: number;
|
||||
}): DetachedProcessHandle {
|
||||
const promise = spawn(command, args, {
|
||||
cwd,
|
||||
@@ -608,9 +635,24 @@ export function spawnDetached({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
// We don't await the process — it should outlive this step. Failures show
|
||||
// up in the captured output; suppress unhandled rejections here.
|
||||
promise.catch(() => {});
|
||||
// Observe completion without rejecting in the background. Startup callers can
|
||||
// distinguish a dead process from one that is still preparing its state file.
|
||||
let exitError: Error | undefined;
|
||||
// The spawn promise waits for stdio to close. Descendants may keep those
|
||||
// pipes open after the launcher exits, so observe the exit itself as well.
|
||||
promise.child.once('exit', (code, signal) => {
|
||||
exitError = new Error(
|
||||
signal ? `Process exited with signal ${signal}.` : `Process exited with code ${code}.`
|
||||
);
|
||||
});
|
||||
void promise.then(
|
||||
() => {
|
||||
exitError ??= new Error('Process exited with code 0.');
|
||||
},
|
||||
error => {
|
||||
exitError ??= error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
);
|
||||
promise.child.unref();
|
||||
|
||||
let output = '';
|
||||
@@ -624,50 +666,11 @@ export function spawnDetached({
|
||||
return {
|
||||
pid,
|
||||
getOutput: () => output,
|
||||
stopAsync: async () => await stopDetachedProcessAsync(pid),
|
||||
getExitError: () => exitError,
|
||||
stopAsync: async () => await stopDetachedProcessAsync(pid, stopGracePeriodMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function websiteOrigin(env: BuildStepEnv): string {
|
||||
return env.EXPO_LOCAL
|
||||
? 'https://expo.test'
|
||||
: env.EXPO_STAGING
|
||||
? 'https://staging.expo.dev'
|
||||
: 'https://expo.dev';
|
||||
}
|
||||
|
||||
export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string): string {
|
||||
return new URL(`/simulator-preview/${subdomainId}`, websiteOrigin(env)).toString();
|
||||
}
|
||||
|
||||
// Website dev servers on expo.test use staging sessions, and CORS matches ports exactly.
|
||||
const WEBSITE_DEV_ORIGINS = [
|
||||
'https://expo.test',
|
||||
'https://expo.test:13001',
|
||||
...Array.from({ length: 16 }, (_, index) => `https://expo.test:${13200 + index}`),
|
||||
];
|
||||
|
||||
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
|
||||
const origins = new Set([websiteOrigin(env)]);
|
||||
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
|
||||
origins.add('https://*.expo.dev');
|
||||
}
|
||||
if (env.EXPO_LOCAL || env.EXPO_STAGING) {
|
||||
for (const origin of WEBSITE_DEV_ORIGINS) {
|
||||
origins.add(origin);
|
||||
}
|
||||
}
|
||||
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
|
||||
}
|
||||
|
||||
function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
export interface ServeSimLaunchOptions {
|
||||
launchAppIdentifier?: string;
|
||||
launchArgs?: string[];
|
||||
@@ -742,91 +745,12 @@ export function describeServeSimLaunch({
|
||||
return `serve-sim will launch ${launchAppIdentifier}${withArguments}${thenOpen}.`;
|
||||
}
|
||||
|
||||
export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
shareUrl,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
shareUrl?: string;
|
||||
packageVersion?: string;
|
||||
} & ServeSimLaunchOptions): string[] {
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--max-dimension',
|
||||
SERVE_SIM_MAX_DIMENSION,
|
||||
'--mjpeg-quality',
|
||||
SERVE_SIM_MJPEG_QUALITY,
|
||||
'--video-bitrate',
|
||||
SERVE_SIM_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
SERVE_SIM_VIDEO_FPS,
|
||||
...turnArgs,
|
||||
...websiteArgs,
|
||||
...(shareUrl ? ['--share-url', shareUrl] : []),
|
||||
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
|
||||
...launchArgs.flatMap(argument => ['--launch-arg', argument]),
|
||||
...(openUrl ? ['--open-url', openUrl] : []),
|
||||
];
|
||||
}
|
||||
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
packageVersion,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
packageVersion?: string;
|
||||
}): string[] {
|
||||
return [
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--webrtc-ice-policy',
|
||||
'all',
|
||||
'--max-dimension',
|
||||
EXPO_DEVICE_HUB_MAX_DIMENSION,
|
||||
'--video-bitrate',
|
||||
EXPO_DEVICE_HUB_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
EXPO_DEVICE_HUB_VIDEO_FPS,
|
||||
'--hide-sidebar',
|
||||
'--hide-boot-device',
|
||||
...turnArgs,
|
||||
];
|
||||
}
|
||||
|
||||
export async function findAvailablePortAsync(): Promise<number> {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, WEB_PREVIEW_HOST, () => resolve());
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -838,270 +762,6 @@ export async function findAvailablePortAsync(): Promise<number> {
|
||||
return address.port;
|
||||
}
|
||||
|
||||
const WebPreviewReadyResponseSchema = z.object({
|
||||
status: z.literal('ready'),
|
||||
device: z.string(),
|
||||
});
|
||||
|
||||
export async function waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
}: {
|
||||
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<string> {
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
|
||||
throw new SystemError(
|
||||
`${serverName} exited before becoming ready. Last output:\n${
|
||||
previewServer.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await turtleFetch(readyUrl, 'GET', {
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
});
|
||||
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return ready.device;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export type DeviceWebPreviewHandle = {
|
||||
previewPageUrl: string;
|
||||
apiUrl: string;
|
||||
/** Session token gating the preview. Only serve-sim mints one. */
|
||||
previewToken?: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type ServeSimPreviewHandle = DeviceWebPreviewHandle;
|
||||
|
||||
async function startWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName,
|
||||
packageSpec,
|
||||
createArgs,
|
||||
readPreviewTokenAsync,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
serverName: string;
|
||||
packageSpec: string;
|
||||
createArgs: (port: number, turnArgs: string[], previewPageUrl: string) => string[];
|
||||
readPreviewTokenAsync?: (device: string) => Promise<string>;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
const subdomainId = randomBytes(16).toString('hex');
|
||||
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
|
||||
const port = await findAvailablePortAsync();
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
createArgs(port, turnArgs, previewPageUrl)
|
||||
);
|
||||
logger.info(
|
||||
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
|
||||
);
|
||||
const previewServer = spawnDetached({
|
||||
command: previewExec.command,
|
||||
args: previewExec.args,
|
||||
env,
|
||||
});
|
||||
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = await waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
});
|
||||
const previewToken = await readPreviewTokenAsync?.(device);
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId,
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
return {
|
||||
previewPageUrl,
|
||||
apiUrl: tunnel.url,
|
||||
previewToken,
|
||||
stopAsync: async () => {
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn({ err: result.reason }, `Could not stop a ${serverName} preview resource.`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await previewServer.stopAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readServeSimPreviewTokenAsync(
|
||||
udid: string,
|
||||
stateDir: string = SERVE_SIM_STATE_DIR
|
||||
): Promise<string | undefined> {
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
return servers.find(server => server.udid === udid)?.token;
|
||||
}
|
||||
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
} & ServeSimLaunchOptions
|
||||
): Promise<ServeSimPreviewHandle> {
|
||||
const websiteArgs = websiteOriginServeSimArgs(env);
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName: 'serve-sim',
|
||||
packageSpec: createServeSimPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs, previewPageUrl) =>
|
||||
createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
websiteArgs,
|
||||
shareUrl: previewPageUrl,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
}),
|
||||
readPreviewTokenAsync: async device => {
|
||||
const previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
// A serve-sim that does not know --require-token fails earlier, in the readiness check, so
|
||||
// reaching here means it started and left no token in its state file.
|
||||
throw new SystemError(
|
||||
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
|
||||
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
|
||||
'This usually means the state file was not written as expected; retry the session, and ' +
|
||||
'report it if it repeats.'
|
||||
);
|
||||
}
|
||||
return previewToken;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function startExpoDeviceHubWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName: 'expo-device-hub',
|
||||
packageSpec: createExpoDeviceHubPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) => createExpoDeviceHubArgs({ port, turnArgs, packageVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startDeviceWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
...options
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
} & ServeSimLaunchOptions
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
return await startServeSimWithTunnelAsync(ctx, {
|
||||
...options,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
});
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
// Unreachable from the three step functions, which reject a non-Darwin launch while
|
||||
// parsing. Kept because this function is exported and expo-device-hub cannot launch.
|
||||
if (launchAppIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
|
||||
);
|
||||
}
|
||||
return await startExpoDeviceHubWithTunnelAsync(ctx, { ...options, runtimePlatform });
|
||||
}
|
||||
}
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
subdomainId: string;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { graphqlAbortContext } from '../graphqlAbort';
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
it('returns nothing without a signal', () => {
|
||||
expect(graphqlAbortContext(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('aborts the request when either the caller or urql aborts', async () => {
|
||||
const seen: AbortSignal[] = [];
|
||||
globalThis.fetch = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seen.push(init!.signal!);
|
||||
return new Response('{}');
|
||||
}) as typeof fetch;
|
||||
const caller = new AbortController();
|
||||
const urql = new AbortController();
|
||||
const context = graphqlAbortContext(caller.signal)!;
|
||||
await context.fetch!('https://api.test/graphql', { method: 'POST', signal: urql.signal });
|
||||
await context.fetch!('https://api.test/graphql', { method: 'POST' });
|
||||
expect(seen).toHaveLength(2);
|
||||
expect(seen.every(signal => !signal.aborted)).toBe(true);
|
||||
urql.abort(new Error('urql gave up'));
|
||||
expect(seen[0].aborted).toBe(true);
|
||||
expect(seen[1].aborted).toBe(false);
|
||||
caller.abort(new Error('caller gave up'));
|
||||
expect(seen[1].aborted).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { OperationContext } from '@urql/core';
|
||||
|
||||
/**
|
||||
* urql replaces `fetchOptions.signal` with its own, so a caller's signal has to be joined into
|
||||
* fetch after urql has resolved auth and its signal. Returns nothing without a signal, so the
|
||||
* client's default fetch stays in use.
|
||||
*/
|
||||
export function graphqlAbortContext(
|
||||
signal: AbortSignal | undefined
|
||||
): Pick<OperationContext, 'fetch'> | undefined {
|
||||
if (!signal) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
signal: init?.signal ? AbortSignal.any([init.signal, signal]) : signal,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import type { ora as oraType } from '../ora';
|
||||
|
||||
jest.unmock('ora');
|
||||
|
||||
class FakeTTYStream extends EventEmitter {
|
||||
isTTY = true;
|
||||
columns = 200;
|
||||
clearLineCalls = 0;
|
||||
|
||||
write(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
clearLine(): boolean {
|
||||
this.clearLineCalls++;
|
||||
return true;
|
||||
}
|
||||
|
||||
cursorTo(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
moveCursor(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ora', () => {
|
||||
const originalEnv = process.env;
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
let ora: typeof oraType;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.CI;
|
||||
delete process.env.EXPO_DEBUG;
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true });
|
||||
jest.isolateModules(() => {
|
||||
ora = require('../ora').ora;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
process.env = originalEnv;
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, writable: true });
|
||||
});
|
||||
|
||||
it('clears every wrapped line after the terminal gets narrower', () => {
|
||||
const stream = new FakeTTYStream();
|
||||
const spinner = ora({
|
||||
text: 'x'.repeat(150),
|
||||
stream: stream as unknown as NodeJS.WritableStream,
|
||||
discardStdin: false,
|
||||
}).start();
|
||||
|
||||
stream.columns = 80;
|
||||
stream.emit('resize');
|
||||
spinner.render();
|
||||
|
||||
stream.clearLineCalls = 0;
|
||||
spinner.render();
|
||||
expect(stream.clearLineCalls).toBe(2);
|
||||
|
||||
spinner.stop();
|
||||
expect(stream.listenerCount('resize')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -187,7 +187,11 @@ describe(SimulatorList, () => {
|
||||
after: 'page-cursor',
|
||||
filter: {
|
||||
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
|
||||
types: [DeviceRunSessionType.Appium, DeviceRunSessionType.WebPreviewOnly],
|
||||
types: [
|
||||
DeviceRunSessionType.Appium,
|
||||
DeviceRunSessionType.ServeSim,
|
||||
DeviceRunSessionType.WebPreviewOnly,
|
||||
],
|
||||
platforms: [AppPlatform.Ios],
|
||||
name: 'checkout',
|
||||
tags: ['variant:pro', 'nightly'],
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_RESOURCE_CLASS_FLAG_VALUES,
|
||||
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_OPTIONS,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
DeviceRunSessionRemoteConfig,
|
||||
formatRemoteSessionInstructions,
|
||||
@@ -120,7 +121,7 @@ export default class Simulator extends EasCommand {
|
||||
type: Flags.option({
|
||||
description:
|
||||
'Type of simulator session to create. All session types include a web preview. agent-device, appium, and argent also include an automation interface; web-preview-only includes no automation interface.',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
options: DEVICE_RUN_SESSION_TYPE_FLAG_OPTIONS,
|
||||
default: DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice],
|
||||
})(),
|
||||
'package-version': Flags.string({
|
||||
|
||||
@@ -17,9 +17,9 @@ import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQue
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_OPTIONS,
|
||||
deviceRunSessionTypeToFlagValue,
|
||||
deviceRunSessionTypesForFlagValue,
|
||||
} from '../../simulator/utils';
|
||||
import { fromNow } from '../../utils/date';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
@@ -62,7 +62,7 @@ export default class SimulatorList extends EasCommand {
|
||||
type: Flags.option({
|
||||
description:
|
||||
'Filter by session type (repeatable). All session types include a web preview. agent-device, appium, and argent also include an automation interface; web-preview-only includes no automation interface.',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
options: DEVICE_RUN_SESSION_TYPE_FLAG_OPTIONS,
|
||||
multiple: true,
|
||||
})(),
|
||||
platform: Flags.option({
|
||||
@@ -110,7 +110,7 @@ export default class SimulatorList extends EasCommand {
|
||||
filter.statuses = flags.status.map(value => STATUS_BY_FLAG_VALUE[value]);
|
||||
}
|
||||
if (flags.type && flags.type.length > 0) {
|
||||
filter.types = flags.type.map(value => DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[value]);
|
||||
filter.types = flags.type.flatMap(deviceRunSessionTypesForFlagValue);
|
||||
}
|
||||
if (flags.platform && flags.platform.length > 0) {
|
||||
filter.platforms = flags.platform.map(value => PLATFORM_BY_FLAG_VALUE[value]);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SubmissionAndroidReleaseStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { GoogleServiceAccountKeyQuery } from '../../graphql/queries/GoogleServiceAccountKeyQuery';
|
||||
import AndroidSubmitCommand from '../../submit/android/AndroidSubmitCommand';
|
||||
import { SubmissionContext, createSubmissionContextAsync } from '../../submit/context';
|
||||
@@ -41,11 +42,19 @@ export default class SubmitInternal extends EasCommand {
|
||||
description: 'ID of the build to submit',
|
||||
required: true,
|
||||
}),
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID. With --application-identifier, skips loading app config.',
|
||||
dependsOn: ['application-identifier'],
|
||||
}),
|
||||
'application-identifier': Flags.string({
|
||||
description: 'Bundle identifier or Android package name read from the build artifact.',
|
||||
dependsOn: ['project-id'],
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.DynamicProjectConfig,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
...this.ContextOptions.Analytics,
|
||||
...this.ContextOptions.Vcs,
|
||||
@@ -58,7 +67,8 @@ export default class SubmitInternal extends EasCommand {
|
||||
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
privateProjectConfig: { exp, projectId, projectDir },
|
||||
projectDir,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
analytics,
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(SubmitInternal, {
|
||||
@@ -66,6 +76,15 @@ export default class SubmitInternal extends EasCommand {
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const project = flags['project-id']
|
||||
? await AppQuery.byIdAsync(graphqlClient, flags['project-id'])
|
||||
: undefined;
|
||||
// Worker submissions already know the project and artifact identifiers.
|
||||
// The existing submission code only needs the project name and slug here.
|
||||
const { exp, projectId } = project
|
||||
? { exp: { name: project.name, slug: project.slug }, projectId: project.id }
|
||||
: await getDynamicPrivateProjectConfigAsync();
|
||||
|
||||
if (vcsClient instanceof GitClient) {
|
||||
// `build:internal` is run on EAS workers and the repo may have been changed
|
||||
// by pre-install hooks or other scripts. We don't want to require committing changes
|
||||
@@ -93,6 +112,7 @@ export default class SubmitInternal extends EasCommand {
|
||||
analytics,
|
||||
exp,
|
||||
projectId,
|
||||
applicationIdentifier: flags['application-identifier'],
|
||||
vcsClient,
|
||||
specifiedProfile: flags.profile,
|
||||
groups: undefined, // use groups from submit profile
|
||||
|
||||
@@ -30,14 +30,22 @@ export function isSpinnerEnabled(): boolean {
|
||||
export function ora(options?: Options | string): Ora {
|
||||
const inputOptions = typeof options === 'string' ? { text: options } : (options ?? {});
|
||||
const disabled = !isSpinnerEnabled();
|
||||
// In non-interactive mode, send the stream to stdout so it prevents looking like an error.
|
||||
const stream: NodeJS.WritableStream =
|
||||
inputOptions.stream ?? (disabled ? process.stdout : process.stderr);
|
||||
const spinner = oraReal({
|
||||
// Ensure our non-interactive mode emulates CI mode.
|
||||
isEnabled: !disabled,
|
||||
// In non-interactive mode, send the stream to stdout so it prevents looking like an error.
|
||||
stream: disabled ? process.stdout : process.stderr,
|
||||
...inputOptions,
|
||||
stream,
|
||||
});
|
||||
|
||||
// ora only recounts the wrapped lines of its text when the text changes. After the
|
||||
// terminal gets narrower, it clears too few lines and every frame leaves a copy behind.
|
||||
const onResize = (): void => {
|
||||
spinner.text = spinner.text;
|
||||
};
|
||||
|
||||
const oraStart = spinner.start.bind(spinner);
|
||||
const oraStop = spinner.stop.bind(spinner);
|
||||
const oraStopAndPersist = spinner.stopAndPersist.bind(spinner);
|
||||
@@ -95,6 +103,8 @@ export function ora(options?: Options | string): Ora {
|
||||
// Skipping wrapping native logs removes the repeated interleaved "Exporting..." messages.
|
||||
if (!disabled) {
|
||||
wrapNativeLogs();
|
||||
stream.off('resize', onResize);
|
||||
stream.on('resize', onResize);
|
||||
}
|
||||
|
||||
return oraStart(text);
|
||||
@@ -103,12 +113,14 @@ export function ora(options?: Options | string): Ora {
|
||||
spinner.stopAndPersist = (options): Ora => {
|
||||
const result = oraStopAndPersist(options);
|
||||
resetNativeLogs();
|
||||
stream.off('resize', onResize);
|
||||
return result;
|
||||
};
|
||||
|
||||
spinner.stop = (): Ora => {
|
||||
const result = oraStop();
|
||||
resetNativeLogs();
|
||||
stream.off('resize', onResize);
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@ import { DeviceRunSessionResourceClass, DeviceRunSessionType } from '../../graph
|
||||
import {
|
||||
DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_OPTIONS,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
DeviceRunSessionRemoteConfig,
|
||||
EAS_SIMULATOR_WAITLIST_URL,
|
||||
deviceRunSessionTypeToFlagValue,
|
||||
deviceRunSessionTypesForFlagValue,
|
||||
formatLoopbackForwardNotice,
|
||||
formatPreviewUrl,
|
||||
formatRemoteSessionInstructions,
|
||||
@@ -226,6 +228,23 @@ describe('simulator session type flags', () => {
|
||||
expect(DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE['serve-sim']).toBeUndefined();
|
||||
expect(DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE['web-preview']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists each flag value once', () => {
|
||||
expect(DEVICE_RUN_SESSION_TYPE_FLAG_OPTIONS).toEqual([
|
||||
'agent-device',
|
||||
'appium',
|
||||
'argent',
|
||||
'web-preview-only',
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters web-preview-only by the new and the legacy session type', () => {
|
||||
expect(deviceRunSessionTypesForFlagValue('web-preview-only')).toEqual([
|
||||
DeviceRunSessionType.ServeSim,
|
||||
DeviceRunSessionType.WebPreviewOnly,
|
||||
]);
|
||||
expect(deviceRunSessionTypesForFlagValue('appium')).toEqual([DeviceRunSessionType.Appium]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('simulator resource class flags', () => {
|
||||
|
||||
@@ -45,6 +45,19 @@ export const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
|
||||
)
|
||||
) as Record<string, DeviceRunSessionType>;
|
||||
|
||||
// Legacy ServeSim and WebPreviewOnly share a flag value, so list each value once.
|
||||
export const DEVICE_RUN_SESSION_TYPE_FLAG_OPTIONS = [
|
||||
...new Set(Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES)),
|
||||
];
|
||||
|
||||
// Every enum value behind a flag value, so filtering by web-preview-only also
|
||||
// matches sessions created with the legacy ServeSim type.
|
||||
export function deviceRunSessionTypesForFlagValue(value: string): DeviceRunSessionType[] {
|
||||
return (Object.keys(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES) as DeviceRunSessionType[]).filter(
|
||||
type => DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[type] === value
|
||||
);
|
||||
}
|
||||
|
||||
export function deviceRunSessionTypeToFlagValue(type: DeviceRunSessionType): string {
|
||||
return DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[type];
|
||||
}
|
||||
|
||||
@@ -79,7 +79,6 @@ export async function getSessionUsingBrowserAuthFlowAsync({ sso = false }): Prom
|
||||
`code_challenge=${codeChallenge}`,
|
||||
`code_challenge_method=S256`,
|
||||
`state=${state}`,
|
||||
`confirm_account=true`,
|
||||
].join('&');
|
||||
return `${expoWebsiteUrl}${sso ? '/sso-login' : '/login'}?${params}`;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user