mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce10c42329 |
No files matched your search
@@ -1,3 +1,4 @@
|
||||
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
|
||||
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->
|
||||
|
||||
# Why
|
||||
|
||||
@@ -2,7 +2,7 @@ commentConfiguration:
|
||||
preamble: 'Subscribed to pull request'
|
||||
epilogue: 'Generated by [CodeMention](https://github.com/expo/expo/actions/workflows/codemention.yaml)'
|
||||
rules:
|
||||
- patterns: ['packages/eas-cli/**']
|
||||
- patterns: ['**/*']
|
||||
mentions: ['douglowder']
|
||||
- patterns: ['packages/eas-cli/src/update/**']
|
||||
mentions: ['wschurman', 'quinlanj']
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
name: License
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 1 1-12/3 *' # Run every three months
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/license.yml
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
name: Update BSL change date
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- name: Check last commit date
|
||||
run: |
|
||||
# The date in the license
|
||||
change_date=$(sed --quiet --regexp-extended 's/^Change Date:\s+([0-9]{4}-[0-9]{2}-[0-9]{2})/\1/p' < LICENSE-BUSL)
|
||||
[ -z "$change_date" ] && exit 1
|
||||
|
||||
new_change_date=$(date --date "+3 years" --iso-8601)
|
||||
|
||||
if [ "$(date --date "$new_change_date" +%s)" -gt "$(date --date "$change_date" +%s)" ]; then
|
||||
sed --in-place "s/$change_date/$new_change_date/" LICENSE-BUSL
|
||||
git config user.name 'Expo Bot'
|
||||
git config user.email [email protected]
|
||||
git add LICENSE-BUSL
|
||||
git commit -m "[license] Update BSL Change Date to $new_change_date"
|
||||
if [ "$(git branch --show-current)" = main ]; then
|
||||
git push
|
||||
fi
|
||||
fi
|
||||
@@ -10,74 +10,16 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Remove temporary working directories after `eas build:inspect` copies inspect output, avoiding leftover disk usage after failed builds. ([#3981](https://github.com/expo/eas-cli/pull/3981) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Bump `tmp` to `0.2.7` to resolve [Dependabot alert 423](https://github.com/expo/eas-cli/security/dependabot/423). ([#3965](https://github.com/expo/eas-cli/pull/3965) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [worker] Bump `ws` to patched releases to resolve [Dependabot alert 462](https://github.com/expo/eas-cli/security/dependabot/462). ([#3963](https://github.com/expo/eas-cli/pull/3963) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- Bump `lodash` and `lodash-es` to `4.18.1` across workspaces and upgrade GraphQL Code Generator to remove the remaining vulnerable transitive copy for Dependabot alert 354. ([#4009](https://github.com/expo/eas-cli/pull/4009) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Bump `sigstore` to `4.1.1` to resolve [Dependabot alert 475](https://github.com/expo/eas-cli/security/dependabot/475). ([#3961](https://github.com/expo/eas-cli/pull/3961) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Bump `axios` to patched releases to resolve [Dependabot alert 439](https://github.com/expo/eas-cli/security/dependabot/439). ([#3964](https://github.com/expo/eas-cli/pull/3964) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Bump `handlebars` to `4.7.9` to resolve [Dependabot alert 334](https://github.com/expo/eas-cli/security/dependabot/334). ([#3958](https://github.com/expo/eas-cli/pull/3958) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [21.0.1](https://github.com/expo/eas-cli/releases/tag/v21.0.1) - 2026-07-15
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas project:delete` command to delete a project — the current directory's project by default, or any project passed as an argument (`@account/slug` or ID). Confirmation requires the project's full name, typed at the interactive prompt or passed via `--dangerously-confirm-deletion` (required in non-interactive mode). Deletion requires sudo mode: the command prompts for your password (and OTP) to upgrade the session when the server requires it. Deletion was previously only possible from the website. ([#4012](https://github.com/expo/eas-cli/pull/4012) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `eas browse` to open the project page on expo.dev in a web browser, with an optional page argument (e.g. `eas browse build`, `eas browse hosting`) for project subpages and a `--no-browser` flag to print the URL. `eas open` is now an alias of `eas browse`. ([#4003](https://github.com/expo/eas-cli/pull/4003) by [@jonsamp](https://github.com/jonsamp))
|
||||
- [eas-cli] Add `--dev-domain` flag to `eas deploy` to choose the project's preview URL (`<name>.expo.app`) without prompting, allowing first-time deploys with `--non-interactive`. ([#4004](https://github.com/expo/eas-cli/pull/4004) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `-p` as an alias for `--platform` in `eas simulator:start`. ([#3902](https://github.com/expo/eas-cli/pull/3902) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli][build-tools][local-build-plugin][worker] Bump `tar` to `7.5.19`. ([#3971](https://github.com/expo/eas-cli/pull/3971) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [21.0.0](https://github.com/expo/eas-cli/releases/tag/v21.0.0) - 2026-07-13
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Remove `eas onboarding` command (aliases `init:onboarding`, `project:onboarding`). ([#3993](https://github.com/expo/eas-cli/pull/3993) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Improve `eas workflow:create`: add a `--template` flag, generate a placeholder workflow when a file name is passed, use shorter default file names (`build.yml`, `update.yml`, `deploy.yml`), configure EAS Build and EAS Update automatically when the chosen template requires them, set default app identifiers without prompting for the development build and deploy templates, install `expo-dev-client` during development build setup, and tighten the generated comments and next steps. ([#3943](https://github.com/expo/eas-cli/pull/3943) by [@jonsamp](https://github.com/jonsamp))
|
||||
- [eas-cli] `eas integrations:posthog:dashboard` now opens PostHog via a signed-in link, skipping the login prompt. ([#3975](https://github.com/expo/eas-cli/pull/3975) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/posthog_capture_event` workflow function to send PostHog events from workflow runs. ([#3934](https://github.com/expo/eas-cli/pull/3934) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/posthog_flag_rollout` workflow function to update PostHog feature flags from workflow runs. ([#3944](https://github.com/expo/eas-cli/pull/3944) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/posthog_wait_for_metric` workflow function to gate workflow runs on a PostHog metric. ([#3945](https://github.com/expo/eas-cli/pull/3945) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/posthog_upload_sourcemaps` workflow function to upload source maps to PostHog. ([#3947](https://github.com/expo/eas-cli/pull/3947) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/posthog_annotation` workflow function to create PostHog annotations from workflow runs. ([#3948](https://github.com/expo/eas-cli/pull/3948) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/posthog_wait_for_query` workflow function to gate workflow runs on a PostHog query. ([#3949](https://github.com/expo/eas-cli/pull/3949) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Rethrow Apple errors during capability sync instead of silently succeeding. ([#3989](https://github.com/expo/eas-cli/pull/3989) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Skip embedded bundle upload for development client builds instead of warning that the bundle is missing. ([#3940](https://github.com/expo/eas-cli/pull/3940) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Accept a navigation metric name in observe:metrics when passed in on the command line. ([#3973](https://github.com/expo/eas-cli/pull/3973) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Retry uploading assets that don't finish processing during `eas update`, instead of failing the update. ([#3918](https://github.com/expo/eas-cli/pull/3918) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Upgrade Expo plist dependencies to use `@xmldom/xmldom` `0.8.13` without a root resolution, addressing Dependabot alert 372. ([#4010](https://github.com/expo/eas-cli/pull/4010) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] add agent detection to analytics events. ([#3983](https://github.com/expo/eas-cli/pull/3983) by [@davidmokos](https://github.com/davidmokos))
|
||||
- Bump `glob` 10.x to a patched version in the root lockfile. ([#3979](https://github.com/expo/eas-cli/pull/3979) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Bump `jws` to a patched version in the root lockfile. ([#3978](https://github.com/expo/eas-cli/pull/3978) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [worker] Bump `koa` to `3.1.2`. ([#3974](https://github.com/expo/eas-cli/pull/3974) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [steps] Bump `cross-spawn` to a patched version in the TypeScript custom function fixture. ([#3977](https://github.com/expo/eas-cli/pull/3977) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Bump `node-forge` to `1.4.0`. ([#3970](https://github.com/expo/eas-cli/pull/3970) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Bump `fast-xml-builder` to `1.2.1` to resolve [Dependabot alert 400](https://github.com/expo/eas-cli/security/dependabot/400). ([#3966](https://github.com/expo/eas-cli/pull/3966) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Bump `form-data` to patched releases to resolve [Dependabot alert 465](https://github.com/expo/eas-cli/security/dependabot/465). ([#3962](https://github.com/expo/eas-cli/pull/3962) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Bump `fast-xml-parser` to `4.5.7` to resolve [Dependabot alert 286](https://github.com/expo/eas-cli/security/dependabot/286). ([#3960](https://github.com/expo/eas-cli/pull/3960) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Bump `shell-quote` to `1.9.0` to resolve [Dependabot alert 440](https://github.com/expo/eas-cli/security/dependabot/440). ([#3959](https://github.com/expo/eas-cli/pull/3959) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [20.5.1](https://github.com/expo/eas-cli/releases/tag/v20.5.1) - 2026-07-01
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix Free plan users being warned they had reached their build limit while still well below it. Overage warnings are now shown only for paid plans with an actual billable overage cost. ([#3882](https://github.com/expo/eas-cli/pull/3882) by [@sarahlane8](https://github.com/sarahlane8))
|
||||
- [eas-cli] Count only genuine per-worker build overages in the pre-build usage warning, so the reported number of builds beyond included credits is accurate. ([#3932](https://github.com/expo/eas-cli/pull/3932) by [@sarahlane8](https://github.com/sarahlane8))
|
||||
- [eas-cli] Select correct tvOS build target for non-interactive Apple builds when EXPO_TV env variable is set. ([#3907](https://github.com/expo/eas-cli/pull/3907) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that you d
|
||||
to access the functionality of and directly benefit from the
|
||||
functionality of the Licensed Work.
|
||||
|
||||
Change Date: 2029-07-15
|
||||
Change Date: 2028-04-01
|
||||
|
||||
Change License: MIT
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "21.0.2",
|
||||
"version": "20.5.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "21.0.2",
|
||||
"version": "20.5.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -38,18 +38,19 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "21.0.0",
|
||||
"@expo/eas-build-job": "21.0.2",
|
||||
"@expo/downloader": "20.0.0",
|
||||
"@expo/eas-build-job": "20.5.1",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/logger": "20.0.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.0.2",
|
||||
"@expo/template-file": "21.0.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@expo/steps": "20.5.1",
|
||||
"@expo/template-file": "20.0.0",
|
||||
"@expo/turtle-spawn": "20.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@google-cloud/storage": "^7.11.2",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "^6.0.1",
|
||||
@@ -60,7 +61,7 @@
|
||||
"gql.tada": "^1.8.13",
|
||||
"joi": "^17.13.1",
|
||||
"jose": "5.10.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-stream-zip": "1.15.0",
|
||||
@@ -71,7 +72,7 @@
|
||||
"resolve-from": "^5.0.0",
|
||||
"retry": "^0.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"tar": "7.5.19",
|
||||
"tar": "7.5.7",
|
||||
"uuid": "9.0.1",
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -59,15 +59,8 @@ public struct FirstFrameWallClock: Codable, Sendable {
|
||||
public let iso8601: String
|
||||
}
|
||||
|
||||
public enum RecordingFinalizationStage: Sendable {
|
||||
case captureStopped
|
||||
case videoSaved
|
||||
}
|
||||
|
||||
public struct RecordingManifest: Codable, Sendable {
|
||||
public let firstFrameWallClock: FirstFrameWallClock
|
||||
public let width: Int
|
||||
public let height: Int
|
||||
public let hlsVersion: Int?
|
||||
public let hlsTargetDurationSeconds: Int?
|
||||
public let hlsMediaSequence: Int?
|
||||
|
||||
+1
-5
@@ -100,9 +100,7 @@ final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
|
||||
@discardableResult
|
||||
func writeManifest(
|
||||
configuration: SimulatorRecordingConfiguration,
|
||||
firstFrameWallClock: Date,
|
||||
width: Int,
|
||||
height: Int
|
||||
firstFrameWallClock: Date
|
||||
) throws -> RecordingManifest {
|
||||
try stateQueue.sync {
|
||||
if let firstError {
|
||||
@@ -123,8 +121,6 @@ final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
|
||||
unixMs: unixMs(firstFrameWallClock),
|
||||
iso8601: iso8601(firstFrameWallClock)
|
||||
),
|
||||
width: width,
|
||||
height: height,
|
||||
hlsVersion: segmented ? 7 : nil,
|
||||
hlsTargetDurationSeconds: targetDuration,
|
||||
hlsMediaSequence: segmented ? 0 : nil,
|
||||
|
||||
+1
-13
@@ -7,7 +7,6 @@ import UniformTypeIdentifiers
|
||||
public final class SimulatorRecorder {
|
||||
public var onSegment: ((SegmentOutput) -> Void)?
|
||||
public var onSimulatorStopped: ((String) -> Void)?
|
||||
public var onFinalizationStage: ((RecordingFinalizationStage) -> Void)?
|
||||
|
||||
private static let callbackStalenessTimeout: TimeInterval = 5
|
||||
private static let firstFrameRewireInterval: TimeInterval = 1
|
||||
@@ -27,8 +26,6 @@ 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 lastPTS: CMTime?
|
||||
private var lastSeed: UInt32?
|
||||
private var lastFrameCallbackElapsed: TimeInterval?
|
||||
@@ -116,7 +113,6 @@ public final class SimulatorRecorder {
|
||||
displaySource?.stop()
|
||||
displaySource = nil
|
||||
}
|
||||
onFinalizationStage?(.captureStopped)
|
||||
|
||||
return try writerQueue.sync {
|
||||
if let firstError {
|
||||
@@ -143,18 +139,12 @@ public final class SimulatorRecorder {
|
||||
if let error = outputWriter.error {
|
||||
throw error
|
||||
}
|
||||
onFinalizationStage?(.videoSaved)
|
||||
guard let firstAcceptedWallClock else {
|
||||
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
|
||||
}
|
||||
guard let recordingWidth, let recordingHeight else {
|
||||
throw RecorderError.make(27, "Missing recording dimensions")
|
||||
}
|
||||
return try outputWriter.writeManifest(
|
||||
configuration: configuration,
|
||||
firstFrameWallClock: firstAcceptedWallClock,
|
||||
width: recordingWidth,
|
||||
height: recordingHeight
|
||||
firstFrameWallClock: firstAcceptedWallClock
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -600,8 +590,6 @@ public final class SimulatorRecorder {
|
||||
self.writer = writer
|
||||
self.input = input
|
||||
self.adaptor = adaptor
|
||||
recordingWidth = width
|
||||
recordingHeight = height
|
||||
}
|
||||
|
||||
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
|
||||
|
||||
@@ -10,15 +10,15 @@ struct CLIOptions {
|
||||
var codec: RecorderCodec = .h264
|
||||
}
|
||||
|
||||
private var requestedStopSignal: CInt = 0
|
||||
private var stopRequested: CInt = 0
|
||||
private var simulatorStopped: CInt = 0
|
||||
|
||||
private func handleStopSignal(_ signal: CInt) {
|
||||
requestedStopSignal = signal
|
||||
private func handleStopSignal(_: CInt) {
|
||||
stopRequested = 1
|
||||
}
|
||||
|
||||
private func isStopRequested() -> Bool {
|
||||
requestedStopSignal != 0 || simulatorStopped != 0
|
||||
stopRequested != 0 || simulatorStopped != 0
|
||||
}
|
||||
|
||||
func usage() -> String {
|
||||
@@ -112,14 +112,6 @@ do {
|
||||
simulatorStopped = 1
|
||||
fputs("[record-sim] simulator stopped state=\(state); finalizing recording\n", stderr)
|
||||
}
|
||||
recorder.onFinalizationStage = { stage in
|
||||
switch stage {
|
||||
case .captureStopped:
|
||||
fputs("[record-sim] screen capture stopped; saving final video\n", stderr)
|
||||
case .videoSaved:
|
||||
fputs("[record-sim] final video saved; writing recording details\n", stderr)
|
||||
}
|
||||
}
|
||||
recorder.onSegment = { segment in
|
||||
switch segment.kind {
|
||||
case .initialization:
|
||||
@@ -138,14 +130,6 @@ do {
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
}
|
||||
|
||||
if requestedStopSignal != 0 {
|
||||
let signalName = switch requestedStopSignal {
|
||||
case SIGINT: "SIGINT"
|
||||
case SIGTERM: "SIGTERM"
|
||||
default: "signal \(requestedStopSignal)"
|
||||
}
|
||||
fputs("[record-sim] received \(signalName); finishing recording\n", stderr)
|
||||
}
|
||||
let manifest = try recorder.stop()
|
||||
if let recording = manifest.recording {
|
||||
fputs("[record-sim] done recording=\(recording)\n", stderr)
|
||||
|
||||
@@ -19,10 +19,12 @@ jest.mock('fs-extra');
|
||||
jest.mock('../datadog', () => ({
|
||||
Datadog: {
|
||||
distribution: jest.fn(),
|
||||
log: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const datadogDistributionMock = jest.mocked(Datadog.distribution);
|
||||
const datadogLogMock = jest.mocked(Datadog.log);
|
||||
|
||||
describe('BuildContext', () => {
|
||||
beforeEach(() => {
|
||||
@@ -169,6 +171,45 @@ describe('BuildContext', () => {
|
||||
expect(ctx.env.EXISTING_ENV).toBe('new-value');
|
||||
expect(ctx.env.REMOVED_ENV).toBeUndefined();
|
||||
expect(ctx.env.NEW_ENV).toBe('new-env-value');
|
||||
const logMessage = datadogLogMock.mock.calls[0][0];
|
||||
expect(logMessage).toContain('BuildContext.updateEnv merge order produced different env');
|
||||
expect(logMessage).toContain('different_env_names=');
|
||||
expect(logMessage).toContain('EXISTING_ENV');
|
||||
expect(logMessage).toContain('REMOVED_ENV');
|
||||
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('old-value');
|
||||
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('new-value');
|
||||
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('new-env-value');
|
||||
});
|
||||
|
||||
it('logs when updateEnv merge order produces the same env without values', async () => {
|
||||
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
|
||||
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
secrets: {},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
EXISTING_ENV: 'same-value',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
|
||||
ctx.updateEnv({
|
||||
EXISTING_ENV: 'same-value',
|
||||
UNSET_ENV: undefined,
|
||||
});
|
||||
|
||||
expect(datadogLogMock).toHaveBeenCalledWith(
|
||||
'BuildContext.updateEnv merge order produced same env'
|
||||
);
|
||||
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('same-value');
|
||||
});
|
||||
|
||||
it('emits build phase duration metrics for successful build phases', async () => {
|
||||
|
||||
@@ -111,37 +111,6 @@ describe(runGenericJobAsync, () => {
|
||||
expect(rejected).toBe(true);
|
||||
});
|
||||
|
||||
it('passes the job hooks into the steps parser', async () => {
|
||||
// @expo/steps is fully mocked here — assert the plumbing only (real
|
||||
// insertion behavior is covered by the parser's own tests).
|
||||
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
|
||||
}));
|
||||
|
||||
mockCtx.job.hooks = { before_install_node_modules: [{ run: 'echo hi' }] };
|
||||
await runGenericJobAsync(mockCtx);
|
||||
|
||||
expect(StepsConfigParser).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ hooks: mockCtx.job.hooks })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes hooks: undefined into the steps parser for a hook-less job (dormancy)', async () => {
|
||||
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
|
||||
}));
|
||||
|
||||
await runGenericJobAsync(mockCtx);
|
||||
|
||||
expect(StepsConfigParser).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ hooks: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws before workflow execution when expoApiV2BaseUrl is missing', async () => {
|
||||
mockCtx.expoApiV2BaseUrl = undefined;
|
||||
|
||||
|
||||
@@ -10,10 +10,7 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { createTestIosJob } from './job';
|
||||
import { createMockLogger } from './logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
|
||||
export class MockContextProvider implements ExternalBuildContextProvider {
|
||||
private _env: BuildStepEnv = {};
|
||||
@@ -75,19 +72,6 @@ export function createStepContextMock({
|
||||
});
|
||||
}
|
||||
|
||||
export function createCustomBuildContextMock(): CustomBuildContext {
|
||||
const ctx = new BuildContext(createTestIosJob({}), {
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
});
|
||||
return new CustomBuildContext(ctx);
|
||||
}
|
||||
|
||||
export function createGlobalContextMock({
|
||||
logger,
|
||||
skipCleanup,
|
||||
|
||||
@@ -63,7 +63,6 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
|
||||
externalFunctions: easFunctions,
|
||||
externalFunctionGroups: easFunctionGroups,
|
||||
steps: ctx.job.steps,
|
||||
hooks: ctx.job.hooks,
|
||||
})
|
||||
: new BuildConfigParser(globalContext, {
|
||||
externalFunctions: easFunctions,
|
||||
|
||||
@@ -72,6 +72,41 @@ export interface BuildContextOptions {
|
||||
|
||||
export class SkipNativeBuildError extends Error {}
|
||||
|
||||
function logEnvComparison({
|
||||
oldMergeOrderEnv,
|
||||
newMergeOrderEnv,
|
||||
}: {
|
||||
oldMergeOrderEnv: Env;
|
||||
newMergeOrderEnv: Env;
|
||||
}): void {
|
||||
const envNames = new Set([...Object.keys(oldMergeOrderEnv), ...Object.keys(newMergeOrderEnv)]);
|
||||
const sameEnvNames: string[] = [];
|
||||
const differentEnvNames: string[] = [];
|
||||
|
||||
for (const key of envNames) {
|
||||
const hasOldValue = key in oldMergeOrderEnv;
|
||||
const hasNewValue = key in newMergeOrderEnv;
|
||||
if (hasOldValue === hasNewValue && oldMergeOrderEnv[key] === newMergeOrderEnv[key]) {
|
||||
sameEnvNames.push(key);
|
||||
} else {
|
||||
differentEnvNames.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (differentEnvNames.length === 0) {
|
||||
Datadog.log('BuildContext.updateEnv merge order produced same env');
|
||||
return;
|
||||
}
|
||||
|
||||
Datadog.log(
|
||||
`BuildContext.updateEnv merge order produced different env: compared_env_count=${
|
||||
envNames.size
|
||||
} different_env_count=${differentEnvNames.length} different_env_names=${differentEnvNames.join(
|
||||
','
|
||||
)} same_env_count=${sameEnvNames.length} same_env_names=${sameEnvNames.join(',')}`
|
||||
);
|
||||
}
|
||||
|
||||
export class BuildContext<TJob extends Job = Job> {
|
||||
public readonly workingdir: string;
|
||||
public logger: bunyan;
|
||||
@@ -248,11 +283,23 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
);
|
||||
}
|
||||
|
||||
this._env = {
|
||||
const oldMergeOrderEnv: Env = {
|
||||
...env,
|
||||
...this._env,
|
||||
__EAS_BUILD_ENVS_DIR: this.buildEnvsDirectory,
|
||||
};
|
||||
const newMergeOrderEnv: Env = {
|
||||
...this._env,
|
||||
...env,
|
||||
__EAS_BUILD_ENVS_DIR: this.buildEnvsDirectory,
|
||||
};
|
||||
|
||||
logEnvComparison({
|
||||
oldMergeOrderEnv,
|
||||
newMergeOrderEnv,
|
||||
});
|
||||
|
||||
this._env = newMergeOrderEnv;
|
||||
this._env.PATH = this._env.PATH
|
||||
? [this.buildExecutablesDirectory, this._env.PATH].join(':')
|
||||
: this.buildExecutablesDirectory;
|
||||
|
||||
@@ -9,18 +9,12 @@ import {
|
||||
StaticJobInterpolationContext,
|
||||
} from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildRuntimePlatform,
|
||||
ExternalBuildContextProvider,
|
||||
StepMetric,
|
||||
WorkflowHookMetric,
|
||||
} from '@expo/steps';
|
||||
import { BuildRuntimePlatform, ExternalBuildContextProvider, StepMetric } from '@expo/steps';
|
||||
import { Client } from '@urql/core';
|
||||
import assert from 'assert';
|
||||
import path from 'path';
|
||||
|
||||
import { ArtifactToUpload, BuildContext } from './context';
|
||||
import { reportWorkflowHookMetricToDatadog } from './utils/hookMetrics';
|
||||
import { uploadStepMetricsToWwwAsync } from './utils/stepMetrics';
|
||||
|
||||
const platformToBuildRuntimePlatform: Record<Platform, BuildRuntimePlatform> = {
|
||||
@@ -171,10 +165,6 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.pendingMetricUploads.push(p);
|
||||
}
|
||||
|
||||
public reportWorkflowHookMetric(metric: WorkflowHookMetric): void {
|
||||
reportWorkflowHookMetricToDatadog(metric);
|
||||
}
|
||||
|
||||
public async drainPendingMetricUploads(): Promise<void> {
|
||||
await Promise.allSettled(this.pendingMetricUploads);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Readable, Writable, pipeline } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import zlib from 'zlib';
|
||||
|
||||
import { GCS } from './client';
|
||||
import GCS from './client';
|
||||
|
||||
type PromiseResolveFn = (value?: void | PromiseLike<void> | undefined) => void;
|
||||
|
||||
@@ -41,7 +41,7 @@ class GCSLoggerStream extends Writable {
|
||||
}
|
||||
|
||||
private findNormalizedHeader(name: string): string | null {
|
||||
if (!this.uploadMethod) {
|
||||
if (!this.uploadMethod || 'client' in this.uploadMethod) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -165,10 +165,25 @@ class GCSLoggerStream extends Writable {
|
||||
);
|
||||
};
|
||||
|
||||
return await GCS.uploadWithSignedUrl({
|
||||
signedUrl: this.uploadMethod.signedUrl,
|
||||
srcGeneratorAsync,
|
||||
if ('signedUrl' in this.uploadMethod) {
|
||||
return await GCS.uploadWithSignedUrl({
|
||||
signedUrl: this.uploadMethod.signedUrl,
|
||||
srcGeneratorAsync,
|
||||
});
|
||||
}
|
||||
|
||||
const { Location } = await this.uploadMethod.client.uploadFile({
|
||||
key: this.uploadMethod.key,
|
||||
src: await srcGeneratorAsync(),
|
||||
streamOptions: {
|
||||
metadata: {
|
||||
contentType: 'text/plain;charset=utf-8',
|
||||
...(this.compress !== null ? { contentEncoding: this.compress } : {}),
|
||||
customTime: this.uploadMethod.customTime,
|
||||
},
|
||||
},
|
||||
});
|
||||
return Location;
|
||||
}
|
||||
|
||||
private async createCompressedStream(src: Readable): Promise<Readable> {
|
||||
@@ -208,9 +223,9 @@ namespace GCSLoggerStream {
|
||||
compress?: CompressionMethod;
|
||||
}
|
||||
|
||||
export type UploadMethod = {
|
||||
signedUrl: GCS.SignedUrl;
|
||||
};
|
||||
export type UploadMethod =
|
||||
| { client: GCS; key: string; customTime: Date | null }
|
||||
| { signedUrl: GCS.SignedUrl };
|
||||
|
||||
export interface Config {
|
||||
logger: bunyan;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
+92
-34
@@ -1,8 +1,10 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import fs from 'fs-extra';
|
||||
import fetch, { RequestInit, Response } from 'node-fetch';
|
||||
import path from 'path';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { GCS } from '../client';
|
||||
import GCS from '../client';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
@@ -27,15 +29,18 @@ class DNSError extends ErrorWithCode {
|
||||
|
||||
const TEST_BUCKET = 'turtle-v2-test';
|
||||
|
||||
function createSignedUrl(key: string, contentType: string): GCS.SignedUrl {
|
||||
return {
|
||||
url: `https://storage.googleapis.com/${TEST_BUCKET}/${key}?signature=test`,
|
||||
headers: { 'content-type': contentType },
|
||||
};
|
||||
}
|
||||
let googleApplicationCredentials: string | undefined;
|
||||
beforeAll(() => {
|
||||
googleApplicationCredentials = process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS = path.join(__dirname, 'mock-credentials.json');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS = googleApplicationCredentials;
|
||||
});
|
||||
|
||||
describe('GCS client', () => {
|
||||
describe('uploadWithSignedUrl', () => {
|
||||
describe('uploadWithPresignedURL function', () => {
|
||||
it('should throw an error if upload fails', async () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const res = {
|
||||
@@ -43,16 +48,22 @@ describe('GCS client', () => {
|
||||
status: 500,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
|
||||
fetchMock.mockImplementation(async () => res);
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
@@ -64,15 +75,21 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
|
||||
fetchMock.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
});
|
||||
@@ -84,9 +101,15 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
@@ -98,8 +121,8 @@ describe('GCS client', () => {
|
||||
.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -112,9 +135,15 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
@@ -132,8 +161,8 @@ describe('GCS client', () => {
|
||||
.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -146,9 +175,15 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
@@ -163,8 +198,8 @@ describe('GCS client', () => {
|
||||
.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -177,9 +212,15 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
|
||||
const lastDNSError = new DNSError('failed thrice');
|
||||
fetchMock
|
||||
@@ -196,8 +237,8 @@ describe('GCS client', () => {
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
})
|
||||
).rejects.toThrow(lastDNSError);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -210,9 +251,15 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
@@ -237,8 +284,8 @@ describe('GCS client', () => {
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -251,9 +298,15 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
|
||||
const nonDNSError = new ErrorWithCode('failed once', 'A_DIFFERENT_CODE');
|
||||
fetchMock
|
||||
@@ -264,8 +317,8 @@ describe('GCS client', () => {
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
})
|
||||
).rejects.toThrow(nonDNSError);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
@@ -301,14 +354,19 @@ describe('GCS client', () => {
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const signedUrl = createSignedUrl('text.txt', 'text/plain');
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key: 'text.txt',
|
||||
expirationTime: 30000,
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
|
||||
const bufferToUpload = randomBytes(16);
|
||||
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from(bufferToUpload),
|
||||
retryIntervalMs: 0,
|
||||
retryIntervalMs: 500,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/text.txt');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": "mock-project",
|
||||
"private_key_id": "mock-private-key-id",
|
||||
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIICXQIBAAKBgQCMk5Qbd5E8RQeP+2vObJVJVnXWXfW/OStxkAYe7r+KVXzHyLYU\nD6Ejt94OiC3X2+tqrMIZgNAUR3VF5tSiP57Il0iWHSspmNIHz7cW6/FHiQsvN2oJ\n8Fzz03vK/FfaU50QWO+BwRnixJ0xG1kSX3MxNyRZ5omGJgcwx0l6Jt878QIDAQAB\nAoGAcGZbqO6ceNU068H6/A1D/GSeSa3NHX2np+ChlEAPdJtP7yojs7yfekC801+9\nT/gurpe9hsjBF0XflemwIJ6/5KucWHrcp2cCkdBHtkK6ScHeIVl3DcOYYhxqJrAA\nYiyGNh1+pf3FMPFBcPJ04pQT4PqOif61/W3p8eJFUke5x7UCQQDoyJ427wrUCZb+\nj2GD5uVyxQa4OJxDbRFriK5LTdwLeFeOmQhoo6kaGywILn6UtlPTUJ38ROA4Jv58\nEJf/jssDAkEAmpi/JkWlOzSM9mnb9blt/fH/DRKu9z0JPvl47V5YAzBhUtUMK15g\nSbEl+AJZyaklpJcgv6ShiGY0PLdoY7oQ+wJBAMF6VX4lKqPYIKcN9ygRlm3Q8ufV\nLZQhKBRvsyYl3Zmu+V8tNL78IEXxhaR7OHxUGtINNHKDsLUbO/NUO3GOdo8CQQCV\nV0sIIK0+JUSq4ZYvqKI9d6Fnso28noSpBfuwabvh0MGjb9Viq7eeWHeSPksYSMLp\nXWiwWMwGZJy/rnk0JVEzAkADiiARp70BZgRB8ogBx+DGTravfhPP64eFMHJnUgAE\nmDKlj3v0x3OZfdUz6Us5hfy3WqFyYbcCGLf7joVVZAEm\n-----END RSA PRIVATE KEY-----",
|
||||
"client_email": "mock-client-email",
|
||||
"client_id": "mock-client-id",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/mock-client-email",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
@@ -1,23 +1,37 @@
|
||||
import { CreateWriteStreamOptions, File, Storage } from '@google-cloud/storage';
|
||||
import fs from 'fs-extra';
|
||||
import fetch, { FetchError } from 'node-fetch';
|
||||
import stream from 'node:stream';
|
||||
import { promisify } from 'node:util';
|
||||
import { Readable } from 'stream';
|
||||
import { URL } from 'url';
|
||||
|
||||
import { retryOnGCSUploadFailure } from './retry';
|
||||
import { RetryOptions, retryOnGCSUploadFailure } from './retry';
|
||||
|
||||
const pipeline = promisify(stream.pipeline);
|
||||
|
||||
interface SignedUrlParams {
|
||||
key: string;
|
||||
expirationTime: number;
|
||||
contentType?: string;
|
||||
extensionHeaders?: { [key: string]: number | string | string[] };
|
||||
}
|
||||
|
||||
interface UploadWithSignedUrlParams {
|
||||
signedUrl: GCS.SignedUrl;
|
||||
srcGeneratorAsync: () => Promise<Readable>;
|
||||
retryIntervalMs?: number;
|
||||
retries?: number;
|
||||
retryIntervalMs?: RetryOptions['retryIntervalMs'];
|
||||
retries?: RetryOptions['retries'];
|
||||
}
|
||||
|
||||
export namespace GCS {
|
||||
export type SignedUrl = {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
class GCS {
|
||||
private readonly client = new Storage();
|
||||
|
||||
export async function uploadWithSignedUrl({
|
||||
constructor(private readonly bucket: string) {
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
public static async uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync,
|
||||
retries = 2,
|
||||
@@ -34,15 +48,18 @@ export namespace GCS {
|
||||
body: src,
|
||||
});
|
||||
},
|
||||
{ retries, retryIntervalMs }
|
||||
{
|
||||
retries,
|
||||
retryIntervalMs,
|
||||
}
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
} catch (err: any) {
|
||||
if (err instanceof FetchError) {
|
||||
throw new Error(`Failed to upload the file, reason: ${err.code}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
let body: string | undefined;
|
||||
try {
|
||||
@@ -52,8 +69,142 @@ export namespace GCS {
|
||||
`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(signedUrl.url);
|
||||
return `${url.protocol}//${url.host}${url.pathname}`;
|
||||
return `${url.protocol}//${url.host}${url.pathname}`; // strip query string
|
||||
}
|
||||
|
||||
public formatHttpUrl(key: string): string {
|
||||
return this.client.bucket(this.bucket).file(key).publicUrl();
|
||||
}
|
||||
|
||||
public async uploadFile({
|
||||
key,
|
||||
src,
|
||||
streamOptions,
|
||||
}: {
|
||||
key: string;
|
||||
src: Readable;
|
||||
streamOptions?: CreateWriteStreamOptions;
|
||||
}): Promise<{ Location: string }> {
|
||||
const file = this.client.bucket(this.bucket).file(key);
|
||||
await new Promise<void>((res, rej) => {
|
||||
src.pipe(
|
||||
file
|
||||
.createWriteStream(streamOptions)
|
||||
.on('error', err => {
|
||||
rej(err);
|
||||
})
|
||||
.on('finish', () => {
|
||||
res();
|
||||
})
|
||||
);
|
||||
});
|
||||
return { Location: file.publicUrl() };
|
||||
}
|
||||
|
||||
public async deleteFile(key: string): Promise<void> {
|
||||
try {
|
||||
await this.client.bucket(this.bucket).file(key).delete();
|
||||
} catch (err: any) {
|
||||
if (err.response?.statusCode === 404) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime,
|
||||
contentType,
|
||||
extensionHeaders = {},
|
||||
}: SignedUrlParams): Promise<GCS.SignedUrl> {
|
||||
const config = {
|
||||
version: 'v4' as const,
|
||||
action: 'write' as const,
|
||||
expires: Date.now() + expirationTime,
|
||||
contentType,
|
||||
extensionHeaders,
|
||||
};
|
||||
|
||||
const [url] = await this.client.bucket(this.bucket).file(key).getSignedUrl(config);
|
||||
|
||||
return {
|
||||
url,
|
||||
headers: {
|
||||
...(contentType ? { 'content-type': contentType } : {}),
|
||||
...extensionHeaders,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async createSignedDownloadUrl({
|
||||
key,
|
||||
expirationTime,
|
||||
}: {
|
||||
key: string;
|
||||
expirationTime: number;
|
||||
}): Promise<string> {
|
||||
const options = {
|
||||
version: 'v4' as const,
|
||||
action: 'read' as const,
|
||||
expires: Date.now() + expirationTime,
|
||||
};
|
||||
|
||||
const [url] = await this.client.bucket(this.bucket).file(key).getSignedUrl(options);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
public async checkIfFileExists(key: string, fileHash?: string): Promise<boolean> {
|
||||
let metadata;
|
||||
try {
|
||||
[metadata] = await this.client.bucket(this.bucket).file(key).getMetadata();
|
||||
} catch (error: any) {
|
||||
if (error.code === 404) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return fileHash ? metadata.etag === fileHash : true;
|
||||
}
|
||||
|
||||
public async listDirectory(prefix: string): Promise<string[]> {
|
||||
const [files] = await this.client.bucket(this.bucket).getFiles({ prefix });
|
||||
return files.map(x => this.formatHttpUrl(x.name));
|
||||
}
|
||||
|
||||
public async moveFile(src: string, dest: string): Promise<void> {
|
||||
await this.client.bucket(this.bucket).file(src).move(dest);
|
||||
}
|
||||
|
||||
public async deleteFiles(keys: string[]): Promise<void> {
|
||||
await Promise.all(keys.map(key => this.deleteFile(key)));
|
||||
}
|
||||
|
||||
public async downloadFile(key: string, destinationPath: string): Promise<void> {
|
||||
const stream = this.client.bucket(this.bucket).file(key).createReadStream();
|
||||
await pipeline(stream, fs.createWriteStream(destinationPath));
|
||||
}
|
||||
|
||||
public getFile(key: string): File {
|
||||
return this.client.bucket(this.bucket).file(key);
|
||||
}
|
||||
}
|
||||
|
||||
namespace GCS {
|
||||
export interface SignedUrl {
|
||||
url: string;
|
||||
headers: { [key: string]: string };
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
}
|
||||
}
|
||||
|
||||
export default GCS;
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
type RetryOptions = {
|
||||
export interface RetryOptions {
|
||||
retries: number;
|
||||
retryIntervalMs: number;
|
||||
shouldRetryOnError: (error: unknown) => boolean;
|
||||
shouldRetryOnError: (error: any) => boolean;
|
||||
shouldRetryOnResponse: (response: Response) => boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// based on https://github.com/googleapis/nodejs-storage/blob/8ab50804fc7bae3bbd159bbb4adf65c02215b11b/src/storage.ts#L284-L320
|
||||
export async function retryOnGCSUploadFailure(
|
||||
@@ -18,14 +18,13 @@ export async function retryOnGCSUploadFailure(
|
||||
return await retry(fn, {
|
||||
retries,
|
||||
retryIntervalMs,
|
||||
shouldRetryOnError: err => {
|
||||
shouldRetryOnError: e => {
|
||||
return (
|
||||
isErrorWithCode(err) &&
|
||||
(err.code === 'ENOTFOUND' ||
|
||||
err.code === 'EAI_AGAIN' ||
|
||||
err.code === 'ECONNRESET' ||
|
||||
err.code === 'ETIMEDOUT' ||
|
||||
err.code === 'EPIPE')
|
||||
e.code === 'ENOTFOUND' ||
|
||||
e.code === 'EAI_AGAIN' ||
|
||||
e.code === 'ECONNRESET' ||
|
||||
e.code === 'ETIMEDOUT' ||
|
||||
e.code === 'EPIPE'
|
||||
);
|
||||
},
|
||||
shouldRetryOnResponse: resp => {
|
||||
@@ -34,12 +33,8 @@ export async function retryOnGCSUploadFailure(
|
||||
});
|
||||
}
|
||||
|
||||
function isErrorWithCode(error: unknown): error is { code: string } {
|
||||
return typeof error === 'object' && error !== null && 'code' in error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper used to execute an inner function and possibly retry it if it throws an error
|
||||
* Wrapper used to execute an inner function and possibly retry it if it throws and error
|
||||
* @param fn Function to be executed and retried in case of error
|
||||
* @param retries How many times at most should the function be retried
|
||||
* @param retryIntervalMs Time interval between the retries
|
||||
@@ -60,7 +55,7 @@ async function retry(
|
||||
} else {
|
||||
return resp;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
} catch (err: any) {
|
||||
if (attemptCount === retries || !shouldRetryOnError(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ export async function runGenericJobAsync(
|
||||
externalFunctions: getEasFunctions(customBuildCtx),
|
||||
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
|
||||
steps: ctx.job.steps,
|
||||
hooks: ctx.job.hooks,
|
||||
});
|
||||
|
||||
const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Builders from './builders';
|
||||
import GCSLoggerStream from './gcs/LoggerStream';
|
||||
import { GCS } from './gcs/client';
|
||||
import GCS from './gcs/client';
|
||||
|
||||
export { Builders, GCS, GCSLoggerStream };
|
||||
|
||||
|
||||
@@ -16,11 +16,12 @@ import ProvisioningProfile, {
|
||||
import { BuildContext } from '../../context';
|
||||
|
||||
export interface Credentials {
|
||||
applicationTargetProvisioningProfile: ProvisioningProfile<Ios.Job>;
|
||||
/** null if not signing */
|
||||
applicationTargetProvisioningProfile: ProvisioningProfile<Ios.Job> | null;
|
||||
keychainPath: string;
|
||||
targetProvisioningProfiles: TargetProvisioningProfiles;
|
||||
distributionType: DistributionType;
|
||||
teamId: string;
|
||||
distributionType: DistributionType | null;
|
||||
teamId: string | null;
|
||||
}
|
||||
|
||||
export type TargetProvisioningProfiles = Record<string, ProvisioningProfileData>;
|
||||
@@ -62,17 +63,15 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
targetProvisioningProfiles[target] = provisioningProfile.data;
|
||||
}
|
||||
|
||||
const applicationTargetProvisioningProfile = this.getApplicationTargetProvisioningProfile();
|
||||
|
||||
// TODO: ensure that all dist types and team ids in the array are the same
|
||||
const { distributionType, teamId } = applicationTargetProvisioningProfile.data;
|
||||
const applicationTargetProvisioningProfile = this.getApplicationTargetProvisioningProfile();
|
||||
|
||||
return {
|
||||
applicationTargetProvisioningProfile,
|
||||
keychainPath: this.keychain.data.path,
|
||||
targetProvisioningProfiles,
|
||||
distributionType,
|
||||
teamId,
|
||||
distributionType: applicationTargetProvisioningProfile?.data.distributionType ?? null,
|
||||
teamId: applicationTargetProvisioningProfile?.data.teamId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,9 +154,9 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
}
|
||||
}
|
||||
|
||||
private getApplicationTargetProvisioningProfile(): ProvisioningProfile<TJob> {
|
||||
private getApplicationTargetProvisioningProfile(): ProvisioningProfile<TJob> | null {
|
||||
// sorting works because bundle ids share common prefix
|
||||
const sorted = orderBy(this.provisioningProfiles, 'data.bundleIdentifier', 'asc');
|
||||
return sorted[0];
|
||||
return sorted[0] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Env, Ios } from '@expo/eas-build-job';
|
||||
import { Env, Ios, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
@@ -65,7 +65,14 @@ export async function runFastlaneResign<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>,
|
||||
{ credentials, ipaPath }: { credentials: Credentials; ipaPath: string }
|
||||
): Promise<void> {
|
||||
const { certificateCommonName } = credentials.applicationTargetProvisioningProfile.data;
|
||||
const applicationTargetProvisioningProfile = credentials.applicationTargetProvisioningProfile;
|
||||
if (!applicationTargetProvisioningProfile) {
|
||||
throw new UserError(
|
||||
'MISSING_PROVISIONING_PROFILE',
|
||||
'Provisioning profile must exist for resigning. Configure credentials with `eas credentials` and try again.'
|
||||
);
|
||||
}
|
||||
const { certificateCommonName } = applicationTargetProvisioningProfile.data;
|
||||
|
||||
const fastlaneDirPath = path.join(ctx.buildDirectory, 'fastlane');
|
||||
await fs.ensureDir(fastlaneDirPath);
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { HookAnchorId } from '@expo/eas-build-job';
|
||||
import { StepsConfigParser } from '@expo/steps';
|
||||
|
||||
import {
|
||||
createCustomBuildContextMock,
|
||||
createGlobalContextMock,
|
||||
} from '../../__tests__/utils/context';
|
||||
import { getEasFunctionGroups } from '../easFunctionGroups';
|
||||
import { getEasFunctions } from '../easFunctions';
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
describe('hook anchor self-declarations', () => {
|
||||
it('exactly the four v0 functions declare anchors, with these bindings', () => {
|
||||
const declarations: Record<string, HookAnchorId> = {};
|
||||
for (const buildFunction of getEasFunctions(createCustomBuildContextMock())) {
|
||||
if (buildFunction.__hookId !== undefined) {
|
||||
declarations[buildFunction.getFullId()] = buildFunction.__hookId;
|
||||
}
|
||||
}
|
||||
// An empty declaration set must fail: without these, every user-authored
|
||||
// unstamped `uses:` step and every `eas/build` expansion loses hooks.
|
||||
expect(declarations).toEqual({
|
||||
'eas/checkout': 'checkout',
|
||||
'eas/install_node_modules': 'install_node_modules',
|
||||
'eas/upload_to_asc': 'submit',
|
||||
'eas/maestro_tests': 'maestro_tests',
|
||||
});
|
||||
});
|
||||
|
||||
it('no function group id collides with a declaring function id', () => {
|
||||
// Group lookup shadows function lookup in StepsConfigParser, so a
|
||||
// collision would silently unbind the anchor.
|
||||
const declaringFunctionIds = getEasFunctions(createCustomBuildContextMock())
|
||||
.filter(buildFunction => buildFunction.__hookId !== undefined)
|
||||
.map(buildFunction => buildFunction.getFullId());
|
||||
const functionGroupIds = getEasFunctionGroups(createCustomBuildContextMock()).map(
|
||||
functionGroup => functionGroup.getFullId()
|
||||
);
|
||||
for (const functionId of declaringFunctionIds) {
|
||||
expect(functionGroupIds).not.toContain(functionId);
|
||||
}
|
||||
});
|
||||
|
||||
it('attaches hooks inside a uses: eas/build expansion (declaration-driven anchor discovery)', async () => {
|
||||
// This is the guard against a semantically stale declaration — the
|
||||
// declaration existence test alone cannot catch a binding that no longer
|
||||
// corresponds to what the group actually expands to.
|
||||
const customBuildCtx = createCustomBuildContextMock();
|
||||
const globalContext = createGlobalContextMock({});
|
||||
const parser = new StepsConfigParser(globalContext, {
|
||||
steps: [{ uses: 'eas/build' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }],
|
||||
after_install_node_modules: [{ run: 'echo after', id: 'after-hook' }],
|
||||
},
|
||||
externalFunctions: getEasFunctions(customBuildCtx),
|
||||
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
|
||||
});
|
||||
const workflow = await parser.parseAsync();
|
||||
|
||||
const installStep = workflow.buildSteps.find(step => step.__hookId === 'install_node_modules');
|
||||
expect(installStep).toBeDefined();
|
||||
const anchorHooks = workflow.hooksByAnchorStep.get(installStep!);
|
||||
expect(anchorHooks?.anchor).toBe('install_node_modules');
|
||||
expect(anchorHooks?.before[0].steps[0].id).toBe('before-hook');
|
||||
expect(anchorHooks?.after[0].steps[0].id).toBe('after-hook');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['eas/checkout', 'before_checkout', 'after_checkout', undefined],
|
||||
[
|
||||
'eas/install_node_modules',
|
||||
'before_install_node_modules',
|
||||
'after_install_node_modules',
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
'eas/upload_to_asc',
|
||||
'before_submit',
|
||||
'after_submit',
|
||||
{
|
||||
ipa_path: 'app.ipa',
|
||||
asc_api_key_path: 'key.p8',
|
||||
apple_app_identifier: '1234567890',
|
||||
bundle_version: '1',
|
||||
bundle_short_version: '1.0.0',
|
||||
},
|
||||
],
|
||||
[
|
||||
'eas/maestro_tests',
|
||||
'before_maestro_tests',
|
||||
'after_maestro_tests',
|
||||
{ flow_path: ['flow.yaml'] },
|
||||
],
|
||||
] as const)(
|
||||
'an unstamped user-authored %s step fires its hook keys end-to-end',
|
||||
async (functionId, beforeKey, afterKey, callInputs) => {
|
||||
const customBuildCtx = createCustomBuildContextMock();
|
||||
const globalContext = createGlobalContextMock({});
|
||||
const parser = new StepsConfigParser(globalContext, {
|
||||
steps: [{ uses: functionId, ...(callInputs !== undefined ? { with: callInputs } : null) }],
|
||||
hooks: {
|
||||
[beforeKey]: [{ run: 'echo before', id: 'before-hook' }],
|
||||
[afterKey]: [{ run: 'echo after', id: 'after-hook' }],
|
||||
},
|
||||
externalFunctions: getEasFunctions(customBuildCtx),
|
||||
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
|
||||
});
|
||||
const workflow = await parser.parseAsync();
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(1);
|
||||
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
|
||||
expect(anchorHooks.before[0].steps[0].id).toBe('before-hook');
|
||||
expect(anchorHooks.after[0].steps[0].id).toBe('after-hook');
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BuildFunction } from '@expo/steps';
|
||||
|
||||
import { calculateEASUpdateRuntimeVersionFunction } from './functions/calculateEASUpdateRuntimeVersion';
|
||||
import { createCapturePosthogEventFunction } from './functions/capturePosthogEvent';
|
||||
import { createCheckoutBuildFunction } from './functions/checkout';
|
||||
import { configureAndroidVersionFunction } from './functions/configureAndroidVersion';
|
||||
import { configureEASUpdateIfInstalledFunction } from './functions/configureEASUpdateIfInstalled';
|
||||
@@ -14,7 +13,6 @@ import { createEasDeployBuildFunction } from './functions/deploy';
|
||||
import { createEasExportBuildFunction } from './functions/export';
|
||||
import { eagerBundleBuildFunction } from './functions/eagerBundle';
|
||||
import { createFindAndUploadBuildArtifactsBuildFunction } from './functions/findAndUploadBuildArtifacts';
|
||||
import { createFinishIosSimulatorRecordingsBuildFunction } from './functions/finishIosSimulatorRecordings';
|
||||
import { generateGymfileFromTemplateFunction } from './functions/generateGymfileFromTemplate';
|
||||
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
|
||||
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
|
||||
@@ -39,7 +37,6 @@ import { runFastlaneFunction } from './functions/runFastlane';
|
||||
import { runGradleFunction } from './functions/runGradle';
|
||||
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
|
||||
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
|
||||
import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag';
|
||||
import { createSaveCacheFunction } from './functions/saveCache';
|
||||
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
@@ -47,16 +44,10 @@ import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroi
|
||||
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
|
||||
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
|
||||
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
|
||||
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
|
||||
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
|
||||
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
|
||||
import { createPosthogAnnotationFunction } from './functions/createPosthogAnnotation';
|
||||
import { createWaitForPosthogQueryFunction } from './functions/waitForPosthogQuery';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
|
||||
export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
@@ -95,20 +86,11 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
createStartIosSimulatorRecordingsBuildFunction(),
|
||||
createFinishIosSimulatorRecordingsBuildFunction(),
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartServeSimRemoteSessionBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
|
||||
createInstallPodsBuildFunction(),
|
||||
createSendSlackMessageFunction(),
|
||||
createCapturePosthogEventFunction(),
|
||||
createRolloutPosthogFlagFunction(),
|
||||
createWaitForPosthogMetricFunction(),
|
||||
createUploadPosthogSourcemapsFunction(),
|
||||
createPosthogAnnotationFunction(),
|
||||
createWaitForPosthogQueryFunction(),
|
||||
|
||||
calculateEASUpdateRuntimeVersionFunction(),
|
||||
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createCapturePosthogEventFunction } from '../capturePosthogEvent';
|
||||
|
||||
jest.mock('@expo/logger');
|
||||
jest.mock('node-fetch');
|
||||
|
||||
describe(createCapturePosthogEventFunction, () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const captureEvent = createCapturePosthogEventFunction();
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
function createStep(
|
||||
callInputs: Record<string, unknown>,
|
||||
env: Record<string, string>
|
||||
): ReturnType<typeof captureEvent.createBuildStepFromFunctionCall> {
|
||||
return captureEvent.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs,
|
||||
env,
|
||||
id: captureEvent.id,
|
||||
});
|
||||
}
|
||||
|
||||
it('sends the event to the ingestion endpoint using the env key', async () => {
|
||||
fetchMock.mockResolvedValue({ status: 200, ok: true } as Response);
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished', distinct_id: 'account-1' },
|
||||
{
|
||||
EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test',
|
||||
EXPO_PUBLIC_POSTHOG_HOST: 'https://us.posthog.com',
|
||||
}
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://us.posthog.com/i/v0/e/');
|
||||
const body = JSON.parse(init?.body as string);
|
||||
expect(body).toMatchObject({
|
||||
api_key: 'phc_test',
|
||||
event: 'workflow_finished',
|
||||
distinct_id: 'account-1',
|
||||
});
|
||||
expect(body.properties?.$process_person_profile).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends an anonymous system event when no distinct_id is given', async () => {
|
||||
fetchMock.mockResolvedValue({ status: 200, ok: true } as Response);
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished' },
|
||||
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
|
||||
expect(body.distinct_id).toBe('eas-workflow');
|
||||
expect(body.properties.$process_person_profile).toBe(false);
|
||||
});
|
||||
|
||||
it('skips when no api key is available and ignore_error is set', async () => {
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished', distinct_id: 'account-1', ignore_error: true },
|
||||
{}
|
||||
);
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringMatching(/Ignoring error\.$/)
|
||||
);
|
||||
});
|
||||
|
||||
it('logs and does not throw on a non-2xx response when ignore_error is set', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
status: 400,
|
||||
ok: false,
|
||||
text: async () => '',
|
||||
} as unknown as Response);
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished', distinct_id: 'account-1', ignore_error: true },
|
||||
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
|
||||
);
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Sending PostHog event "workflow_finished" failed with status 400. Ignoring error.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let properties override the anonymous marker', async () => {
|
||||
fetchMock.mockResolvedValue({ status: 200, ok: true } as Response);
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished', properties: { $process_person_profile: true, foo: 'bar' } },
|
||||
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
|
||||
expect(body.properties.$process_person_profile).toBe(false);
|
||||
expect(body.properties.foo).toBe('bar');
|
||||
});
|
||||
|
||||
it('throws on a network error by default', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished' },
|
||||
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'Sending PostHog event "workflow_finished" failed.'
|
||||
);
|
||||
});
|
||||
|
||||
it('logs and does not throw on a network error when ignore_error is set', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished', ignore_error: true },
|
||||
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
|
||||
);
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Sending PostHog event "workflow_finished" failed. Ignoring error.'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws with the PostHog error detail on failure by default', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
status: 400,
|
||||
ok: false,
|
||||
text: async () => JSON.stringify({ detail: 'Invalid event name' }),
|
||||
} as unknown as Response);
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished' },
|
||||
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/failed with status 400: Invalid event name/);
|
||||
});
|
||||
|
||||
it('falls back to the raw error body when PostHog omits a detail', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
status: 400,
|
||||
ok: false,
|
||||
text: async () => JSON.stringify({ error: 'nope' }),
|
||||
} as unknown as Response);
|
||||
const step = createStep(
|
||||
{ event: 'workflow_finished' },
|
||||
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/failed with status 400: {"error":"nope"}/);
|
||||
});
|
||||
|
||||
it('throws on missing key by default', async () => {
|
||||
const step = createStep({ event: 'workflow_finished' }, {});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/PostHog API key not provided/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createPosthogAnnotationFunction } from '../createPosthogAnnotation';
|
||||
|
||||
jest.mock('@expo/logger');
|
||||
jest.mock('node-fetch');
|
||||
|
||||
function jsonResponse(data: unknown, { ok = true, status = 200 } = {}): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => data,
|
||||
text: async () => JSON.stringify(data),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
const ENV = {
|
||||
POSTHOG_CLI_API_KEY: 'phx_test',
|
||||
POSTHOG_CLI_PROJECT_ID: '123',
|
||||
POSTHOG_CLI_HOST: 'https://us.posthog.com',
|
||||
};
|
||||
|
||||
describe(createPosthogAnnotationFunction, () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const annotation = createPosthogAnnotationFunction();
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
function createStep(
|
||||
callInputs: Record<string, unknown>,
|
||||
env: Record<string, string> = ENV
|
||||
): ReturnType<typeof annotation.createBuildStepFromFunctionCall> {
|
||||
return annotation.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs,
|
||||
env,
|
||||
id: annotation.id,
|
||||
});
|
||||
}
|
||||
|
||||
it('posts the annotation with an explicit date_marker', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ id: 1 }, { status: 201 }));
|
||||
const step = createStep({ content: 'Released abc123', date_marker: '2026-07-03T00:00:00Z' });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://us.posthog.com/api/projects/123/annotations/');
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
content: 'Released abc123',
|
||||
date_marker: '2026-07-03T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults date_marker to now when not given', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ id: 1 }, { status: 201 }));
|
||||
const step = createStep({ content: 'Deploy' });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
|
||||
expect(body.content).toBe('Deploy');
|
||||
expect(typeof body.date_marker).toBe('string');
|
||||
});
|
||||
|
||||
it('throws when credentials are missing by default', async () => {
|
||||
const step = createStep({ content: 'Deploy' }, {});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
/Missing PostHog credentials: personal API key, project id/
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and skips when credentials are missing and ignore_error is set', async () => {
|
||||
const step = createStep({ content: 'Deploy', ignore_error: true }, {});
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringMatching(/Ignoring error/)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a network error by default', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
|
||||
const step = createStep({ content: 'Deploy' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/Creating the PostHog annotation failed/);
|
||||
});
|
||||
|
||||
it('warns and continues on a network error when ignore_error is set', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
|
||||
const step = createStep({ content: 'Deploy', ignore_error: true });
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringMatching(/Ignoring error/)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a 403 without swallowing it', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 403 }));
|
||||
const step = createStep({ content: 'Deploy' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/annotation:write/);
|
||||
});
|
||||
|
||||
it('throws with the PostHog error detail on a non-2xx response', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ detail: 'Invalid date_marker' }, { ok: false, status: 400 })
|
||||
);
|
||||
const step = createStep({ content: 'Deploy' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
/failed with status 400: Invalid date_marker/
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and continues on a non-2xx response when ignore_error is set', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 500 }));
|
||||
const step = createStep({ content: 'Deploy', ignore_error: true });
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringMatching(/Ignoring error/)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,443 +0,0 @@
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createRolloutPosthogFlagFunction } from '../rolloutPosthogFlag';
|
||||
|
||||
jest.mock('@expo/logger');
|
||||
jest.mock('node-fetch');
|
||||
|
||||
function jsonResponse(data: unknown, { ok = true, status = 200 } = {}): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => data,
|
||||
text: async () => JSON.stringify(data),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
const ENV = {
|
||||
POSTHOG_CLI_API_KEY: 'phx_test',
|
||||
POSTHOG_CLI_PROJECT_ID: '123',
|
||||
POSTHOG_CLI_HOST: 'https://us.posthog.com',
|
||||
};
|
||||
|
||||
describe(createRolloutPosthogFlagFunction, () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const rolloutFlag = createRolloutPosthogFlagFunction();
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
function createStep(
|
||||
callInputs: Record<string, unknown>,
|
||||
env: Record<string, string> = ENV
|
||||
): ReturnType<typeof rolloutFlag.createBuildStepFromFunctionCall> {
|
||||
return rolloutFlag.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs,
|
||||
env,
|
||||
id: rolloutFlag.id,
|
||||
});
|
||||
}
|
||||
|
||||
it('enables a flag by key', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 42 }));
|
||||
const step = createStep({ flag: 'my-flag', active: 'true' });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const [patchUrl, patchInit] = fetchMock.mock.calls[1];
|
||||
expect(patchUrl).toBe('https://us.posthog.com/api/projects/123/feature_flags/42/');
|
||||
expect(patchInit?.method).toBe('PATCH');
|
||||
expect(JSON.parse(patchInit?.body as string)).toEqual({ active: true });
|
||||
});
|
||||
|
||||
it('sets rollout_percentage only on the catch-all group, preserving others', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
id: 7,
|
||||
key: 'gradual',
|
||||
filters: {
|
||||
groups: [
|
||||
{ properties: [{ key: 'is_internal' }], rollout_percentage: 100 },
|
||||
{ properties: [], rollout_percentage: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 7 }));
|
||||
const step = createStep({ flag: 'gradual', rollout_percentage: '50' });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
|
||||
expect(patchBody.filters.groups).toEqual([
|
||||
{ properties: [{ key: 'is_internal' }], rollout_percentage: 100 },
|
||||
{ properties: [], rollout_percentage: 50 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a catch-all group when the flag has none', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 8, key: 'bare', filters: {} }] }))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 8 }));
|
||||
const step = createStep({ flag: 'bare', rollout_percentage: '25' });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
|
||||
expect(patchBody.filters.groups).toEqual([{ properties: [], rollout_percentage: 25 }]);
|
||||
});
|
||||
|
||||
it('falls back to the first group with a warning when no catch-all exists', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
id: 9,
|
||||
key: 'scoped',
|
||||
filters: { groups: [{ properties: [{ key: 'is_internal' }] }] },
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 9 }));
|
||||
const step = createStep({ flag: 'scoped', rollout_percentage: '10' });
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
|
||||
expect(patchBody.filters.groups).toEqual([
|
||||
{ properties: [{ key: 'is_internal' }], rollout_percentage: 10 },
|
||||
]);
|
||||
expect(warnMock).toHaveBeenCalledWith(expect.stringMatching(/no catch-all/));
|
||||
});
|
||||
|
||||
it('throws when the flag key does not exist', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [{ id: 1, key: 'other-flag' }] }));
|
||||
const step = createStep({ flag: 'missing-flag', active: 'true' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
/No PostHog feature flag with key "missing-flag"/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a 403 even when ignore_error is set', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 403 }));
|
||||
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/forbidden \(403\)/);
|
||||
});
|
||||
|
||||
it('throws when none of active, rollout_percentage, or payload is provided', async () => {
|
||||
const step = createStep({ flag: 'my-flag' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
/"active", "rollout_percentage", or "payload"/
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets a boolean-flag payload under the "true" key as a JSON string', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 42 }));
|
||||
const step = createStep({ flag: 'my-flag', payload: { color: 'blue', count: 3 } });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
|
||||
expect(patchBody.filters.payloads).toEqual({ true: '{"color":"blue","count":3}' });
|
||||
});
|
||||
|
||||
it('keys the payload under a given variant', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 42 }));
|
||||
const step = createStep({ flag: 'my-flag', payload: { v: 1 }, variant: 'control' });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
|
||||
expect(patchBody.filters.payloads).toEqual({ control: '{"v":1}' });
|
||||
});
|
||||
|
||||
it('sets rollout_percentage and payload together in one filters object', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
results: [{ id: 7, key: 'my-flag', filters: { groups: [{ properties: [] }] } }],
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 7 }));
|
||||
const step = createStep({ flag: 'my-flag', rollout_percentage: 25, payload: { a: 1 } });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
|
||||
expect(patchBody.filters.groups[0].rollout_percentage).toBe(25);
|
||||
expect(patchBody.filters.payloads).toEqual({ true: '{"a":1}' });
|
||||
});
|
||||
|
||||
it('surfaces the PostHog error detail on a failed update', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ type: 'validation_error', detail: 'Variant control does not exist' },
|
||||
{ ok: false, status: 400 }
|
||||
)
|
||||
);
|
||||
const step = createStep({ flag: 'my-flag', active: 'true' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
/failed with status 400: Variant control does not exist/
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the raw body when the error body is not JSON', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 502,
|
||||
text: async () => 'Bad Gateway',
|
||||
} as unknown as Response);
|
||||
const step = createStep({ flag: 'my-flag', active: 'true' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/failed with status 502: Bad Gateway/);
|
||||
});
|
||||
|
||||
it('warns and skips when the flag is not found and ignore_error is set', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [] }));
|
||||
const step = createStep({ flag: 'missing-flag', active: 'true', ignore_error: true });
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringMatching(/No PostHog feature flag with key "missing-flag"/)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on an out-of-range rollout_percentage', async () => {
|
||||
const step = createStep({ flag: 'my-flag', rollout_percentage: '150' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/between 0 and 100/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when credentials are missing by default', async () => {
|
||||
const step = createStep({ flag: 'my-flag', active: 'true' }, {});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
/Missing PostHog credentials: personal API key, project id/
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and skips on missing credentials when ignore_error is set', async () => {
|
||||
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true }, {});
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringMatching(/Ignoring error\.$/)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a failed update by default', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
|
||||
.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 }));
|
||||
const step = createStep({ flag: 'my-flag', active: 'true' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/failed with status 500/);
|
||||
});
|
||||
|
||||
it('warns and skips on a failed update when ignore_error is set', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
|
||||
.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 }));
|
||||
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Updating PostHog flag "my-flag" failed with status 500: {}. Ignoring error.'
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and skips on a lookup network error when ignore_error is set', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('ECONNRESET'));
|
||||
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Looking up PostHog flag "my-flag" failed. Ignoring error.'
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and skips on a non-object lookup response when ignore_error is set', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('not-json-object'));
|
||||
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.anything() },
|
||||
expect.stringMatching(/unexpected response/)
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and skips on an unexpected lookup response when ignore_error is set', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ nope: true }));
|
||||
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.anything() },
|
||||
expect.stringMatching(/unexpected response/)
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores malformed entries in the lookup results', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ results: ['junk', null, { id: 42, key: 'my-flag', filters: {} }] })
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 42 }));
|
||||
const step = createStep({ flag: 'my-flag', active: 'true' });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws on a failed lookup by default', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 }));
|
||||
const step = createStep({ flag: 'my-flag', active: 'true' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
/Looking up PostHog flag "my-flag" failed with status 500/
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and skips on a failed lookup when ignore_error is set', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 }));
|
||||
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Looking up PostHog flag "my-flag" failed with status 500: {}. Ignoring error.'
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('warns and skips on an update network error when ignore_error is set', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
|
||||
.mockRejectedValueOnce(new Error('ECONNRESET'));
|
||||
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Updating PostHog flag "my-flag" failed. Ignoring error.'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a 403 update even when ignore_error is set', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
|
||||
.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 403 }));
|
||||
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/feature_flag:write/);
|
||||
});
|
||||
|
||||
it('debugs the underlying error before throwing on a network failure by default', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('ECONNRESET'));
|
||||
const step = createStep({ flag: 'my-flag', active: 'true' });
|
||||
const debugMock = jest.spyOn(step.ctx.logger, 'debug');
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/Looking up PostHog flag "my-flag" failed/);
|
||||
expect(debugMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on an invalid JSON lookup response by default', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new Error('unexpected token');
|
||||
},
|
||||
} as unknown as Response);
|
||||
const step = createStep({ flag: 'my-flag', active: 'true' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/returned an unexpected response/);
|
||||
});
|
||||
|
||||
it('warns and skips on an invalid JSON lookup response when ignore_error is set', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new Error('unexpected token');
|
||||
},
|
||||
} as unknown as Response);
|
||||
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'PostHog flag lookup for "my-flag" returned an unexpected response. Ignoring error.'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a non-integer rollout_percentage', async () => {
|
||||
const step = createStep({ flag: 'my-flag', rollout_percentage: 25.5 });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/must be an integer between 0 and 100/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the default host when POSTHOG_CLI_HOST is unset', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 42 }));
|
||||
const step = createStep(
|
||||
{ flag: 'my-flag', active: 'true' },
|
||||
{ POSTHOG_CLI_API_KEY: 'phx_test', POSTHOG_CLI_PROJECT_ID: '123' }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
const [searchUrl] = fetchMock.mock.calls[0];
|
||||
expect(searchUrl).toBe(
|
||||
'https://us.posthog.com/api/projects/123/feature_flags/?limit=200&search=my-flag'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,8 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
waitForArgentToolServerStateAsync,
|
||||
warnIfArgentPackageVersionCannotBeVerified,
|
||||
} from '../startArgentRemoteSession';
|
||||
|
||||
@@ -74,72 +70,3 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(waitForArgentToolServerStateAsync, () => {
|
||||
let stateDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'argent-state-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function stateJson({ port, pid, token }: { port: number; pid: number; token?: string }): string {
|
||||
return JSON.stringify({ port, pid, token });
|
||||
}
|
||||
|
||||
it.each(['tool-server.json', 'tool-server-012345abcdef.json'])(
|
||||
'reads a matching process from %s',
|
||||
async fileName => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(stateDir, fileName),
|
||||
stateJson({ port: 4321, pid: process.pid, token: 'secret' })
|
||||
);
|
||||
|
||||
await expect(
|
||||
waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid: process.ppid,
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 1,
|
||||
})
|
||||
).resolves.toEqual({ port: 4321, pid: process.pid, token: 'secret' });
|
||||
}
|
||||
);
|
||||
|
||||
it('selects the state whose pid belongs to the launched process tree', async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(stateDir, 'tool-server-111111111111.json'),
|
||||
stateJson({ port: 1111, pid: Number.MAX_SAFE_INTEGER })
|
||||
);
|
||||
await fs.promises.writeFile(
|
||||
path.join(stateDir, 'tool-server-222222222222.json'),
|
||||
stateJson({ port: 2222, pid: process.pid })
|
||||
);
|
||||
|
||||
await expect(
|
||||
waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid: process.ppid,
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 1,
|
||||
})
|
||||
).resolves.toEqual({ port: 2222, pid: process.pid });
|
||||
});
|
||||
|
||||
it('does not return a state file outside the launched process tree', async () => {
|
||||
const filePath = path.join(stateDir, 'tool-server-012345abcdef.json');
|
||||
await fs.promises.writeFile(filePath, stateJson({ port: 1111, pid: Number.MAX_SAFE_INTEGER }));
|
||||
|
||||
await expect(
|
||||
waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid: process.pid,
|
||||
timeoutMs: 10,
|
||||
pollIntervalMs: 1,
|
||||
})
|
||||
).rejects.toThrow(`state file belonging to process ${process.pid}`);
|
||||
});
|
||||
});
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../../utils/deviceRunSessionArtifacts';
|
||||
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from '../uploadDeviceRunSessionScreenRecordings';
|
||||
|
||||
jest.mock('../../utils/deviceRunSessionArtifacts', () => ({
|
||||
uploadDeviceRunSessionArtifactAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const SIMULATOR_UDID = '01234567-89AB-CDEF-0123-456789ABCDEF';
|
||||
const SECOND_SIMULATOR_UDID = 'FEDCBA98-7654-3210-FEDC-BA9876543210';
|
||||
|
||||
describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockReset()
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.once('end', resolve);
|
||||
stream.once('error', reject);
|
||||
stream.resume();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads simulator device metadata', async () => {
|
||||
const recordingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'ios-recording-test-'));
|
||||
const recordingPath = path.join(recordingDirectory, 'recording.mp4');
|
||||
await fs.writeFile(recordingPath, 'recording');
|
||||
await fs.writeFile(
|
||||
path.join(recordingDirectory, 'session.json'),
|
||||
JSON.stringify({
|
||||
recording: path.basename(recordingPath),
|
||||
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
const globalContext = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
{} as CustomBuildContext
|
||||
).createBuildStepFromFunctionCall(globalContext, {
|
||||
env: { DEVICE_RUN_SESSION_ID: 'device-run-session-id' },
|
||||
callInputs: {
|
||||
recordings_json: [
|
||||
{
|
||||
udid: SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
directory: recordingDirectory,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
name: 'iPhone 16 screen recording (01234567-…, 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',
|
||||
firstFrameAt: '2026-07-10T10:00:00.000Z',
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
},
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(recordingDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses unique names for recordings from rebooted and same-model simulators', async () => {
|
||||
const recordingDirectories = await Promise.all(
|
||||
['2026-07-10T10:00:00.000Z', '2026-07-10T10:05:00.000Z', '2026-07-10T10:00:00.000Z'].map(
|
||||
async firstFrameAt => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ios-recording-test-'));
|
||||
await fs.writeFile(path.join(directory, 'recording.mp4'), 'recording');
|
||||
await fs.writeFile(
|
||||
path.join(directory, 'session.json'),
|
||||
JSON.stringify({
|
||||
recording: 'recording.mp4',
|
||||
firstFrameWallClock: { iso8601: firstFrameAt },
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
})
|
||||
);
|
||||
return directory;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const globalContext = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
{} as CustomBuildContext
|
||||
).createBuildStepFromFunctionCall(globalContext, {
|
||||
env: { DEVICE_RUN_SESSION_ID: 'device-run-session-id' },
|
||||
callInputs: {
|
||||
recordings_json: recordingDirectories.map((directory, index) => ({
|
||||
udid: index === 2 ? SECOND_SIMULATOR_UDID : SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
directory,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
const names = jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, options]) => options.name);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
|
||||
'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:05:00.000 UTC)',
|
||||
'iPhone 16 screen recording (FEDCBA98-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
|
||||
])
|
||||
);
|
||||
expect(new Set(names).size).toBe(3);
|
||||
} finally {
|
||||
await Promise.all(
|
||||
recordingDirectories.map(directory => fs.rm(directory, { recursive: true, force: true }))
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { PipeMode } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createUploadPosthogSourcemapsFunction } from '../uploadPosthogSourcemaps';
|
||||
|
||||
jest.mock('@expo/logger');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
|
||||
const ENV = {
|
||||
POSTHOG_CLI_API_KEY: 'phx_test',
|
||||
POSTHOG_CLI_PROJECT_ID: '123',
|
||||
POSTHOG_CLI_HOST: 'https://us.posthog.com',
|
||||
};
|
||||
|
||||
describe(createUploadPosthogSourcemapsFunction, () => {
|
||||
const uploadSourcemaps = createUploadPosthogSourcemapsFunction();
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
function createStep(
|
||||
callInputs: Record<string, unknown> = {},
|
||||
env: Record<string, string> = ENV
|
||||
): ReturnType<typeof uploadSourcemaps.createBuildStepFromFunctionCall> {
|
||||
return uploadSourcemaps.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs,
|
||||
env,
|
||||
id: uploadSourcemaps.id,
|
||||
});
|
||||
}
|
||||
|
||||
it('runs @posthog/cli hermes upload with the resolved credentials and host', async () => {
|
||||
spawnMock.mockResolvedValue({ stdout: '', stderr: '' } as unknown as Awaited<
|
||||
ReturnType<typeof spawn>
|
||||
>);
|
||||
const step = createStep();
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
const [command, args, options] = spawnMock.mock.calls[0];
|
||||
expect(command).toBe('npx');
|
||||
expect(args).toEqual([
|
||||
'-y',
|
||||
'@posthog/cli',
|
||||
'--host',
|
||||
'https://us.posthog.com',
|
||||
'hermes',
|
||||
'upload',
|
||||
'--directory',
|
||||
'dist',
|
||||
]);
|
||||
expect(options?.env).toMatchObject({
|
||||
POSTHOG_CLI_API_KEY: 'phx_test',
|
||||
POSTHOG_CLI_PROJECT_ID: '123',
|
||||
});
|
||||
expect(options?.mode).toBe(PipeMode.COMBINED_AS_STDOUT);
|
||||
});
|
||||
|
||||
it('passes a custom host through to --host', async () => {
|
||||
spawnMock.mockResolvedValue({ stdout: '', stderr: '' } as unknown as Awaited<
|
||||
ReturnType<typeof spawn>
|
||||
>);
|
||||
const step = createStep({}, { ...ENV, POSTHOG_CLI_HOST: 'https://eu.i.posthog.com' });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual([
|
||||
'-y',
|
||||
'@posthog/cli',
|
||||
'--host',
|
||||
'https://eu.i.posthog.com',
|
||||
'hermes',
|
||||
'upload',
|
||||
'--directory',
|
||||
'dist',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uploads the given directory', async () => {
|
||||
spawnMock.mockResolvedValue({ stdout: '', stderr: '' } as unknown as Awaited<
|
||||
ReturnType<typeof spawn>
|
||||
>);
|
||||
const step = createStep({ directory: 'build-output' });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual([
|
||||
'-y',
|
||||
'@posthog/cli',
|
||||
'--host',
|
||||
'https://us.posthog.com',
|
||||
'hermes',
|
||||
'upload',
|
||||
'--directory',
|
||||
'build-output',
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws when credentials are missing by default', async () => {
|
||||
const step = createStep({}, {});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
/Missing PostHog credentials: personal API key, project id/
|
||||
);
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and skips when credentials are missing and ignore_error is set', async () => {
|
||||
const step = createStep({ ignore_error: true }, {});
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringMatching(/Ignoring error/)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the upload fails by default', async () => {
|
||||
spawnMock.mockRejectedValue(new Error('upload failed'));
|
||||
const step = createStep();
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/Uploading source maps to PostHog failed/);
|
||||
});
|
||||
|
||||
it('warns and continues when the upload fails and ignore_error is set', async () => {
|
||||
spawnMock.mockRejectedValue(new Error('upload failed'));
|
||||
const step = createStep({ ignore_error: true });
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringMatching(/Ignoring error/)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createWaitForPosthogMetricFunction } from '../waitForPosthogMetric';
|
||||
|
||||
jest.mock('@expo/logger');
|
||||
jest.mock('node-fetch');
|
||||
|
||||
function jsonResponse(data: unknown, { ok = true, status = 200 } = {}): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => data,
|
||||
text: async () => JSON.stringify(data),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
const ENV = {
|
||||
POSTHOG_CLI_API_KEY: 'phx_test',
|
||||
POSTHOG_CLI_PROJECT_ID: '123',
|
||||
POSTHOG_CLI_HOST: 'https://us.posthog.com',
|
||||
};
|
||||
|
||||
describe(createWaitForPosthogMetricFunction, () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const waitForMetric = createWaitForPosthogMetricFunction();
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
function createStep(
|
||||
callInputs: Record<string, unknown>,
|
||||
env: Record<string, string> = ENV
|
||||
): ReturnType<typeof waitForMetric.createBuildStepFromFunctionCall> {
|
||||
return waitForMetric.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs,
|
||||
env,
|
||||
id: waitForMetric.id,
|
||||
});
|
||||
}
|
||||
|
||||
it('clears when the metric already meets the condition (blocking query)', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [[3]] }));
|
||||
const step = createStep({
|
||||
query: 'select count() from events',
|
||||
operator: 'lt',
|
||||
threshold: '5',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://us.posthog.com/api/projects/123/query/');
|
||||
expect(JSON.parse(init?.body as string)).toMatchObject({
|
||||
query: { kind: 'HogQLQuery' },
|
||||
refresh: 'blocking',
|
||||
});
|
||||
expect(step.outputById.value.value).toBe('3');
|
||||
});
|
||||
|
||||
it('throws when credentials are missing', async () => {
|
||||
const step = createStep({ query: 'q', operator: 'lt', threshold: '5' }, {});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
/Missing PostHog credentials: personal API key, project id/
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on an invalid operator', async () => {
|
||||
const step = createStep({ query: 'q', operator: 'between', threshold: '5' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/Invalid "operator"/);
|
||||
});
|
||||
|
||||
it('rejects an Object.prototype key as an operator', async () => {
|
||||
const step = createStep({ query: 'q', operator: 'toString', threshold: '5' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/Invalid "operator"/);
|
||||
});
|
||||
|
||||
it('throws with the PostHog error detail when the query fails to run', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ detail: 'Malformed HogQL query' }, { ok: false, status: 400 })
|
||||
);
|
||||
const step = createStep({ query: 'bad', operator: 'lt', threshold: '5' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
/failed with status 400: Malformed HogQL query/
|
||||
);
|
||||
});
|
||||
|
||||
it('retries after a transient 500 and clears when the metric recovers', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 }))
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
|
||||
const step = createStep({
|
||||
query: 'q',
|
||||
operator: 'lt',
|
||||
threshold: '5',
|
||||
interval_seconds: 0.05,
|
||||
timeout_seconds: 5,
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps polling while the query returns no numeric value', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [['not-a-number']] }))
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [[2]] }));
|
||||
const step = createStep({
|
||||
query: 'q',
|
||||
operator: 'lt',
|
||||
threshold: '5',
|
||||
interval_seconds: 0.05,
|
||||
timeout_seconds: 5,
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reports a query-shape problem when no numeric value ever arrives', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ results: [['not-a-number']] }));
|
||||
const step = createStep({
|
||||
query: 'q',
|
||||
operator: 'lt',
|
||||
threshold: '5',
|
||||
interval_seconds: 0.05,
|
||||
timeout_seconds: 0.15,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/never returned a numeric value/);
|
||||
});
|
||||
|
||||
it('includes the last observed value in the timeout error', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ results: [[10]] }));
|
||||
const step = createStep({
|
||||
query: 'q',
|
||||
operator: 'lt',
|
||||
threshold: '5',
|
||||
interval_seconds: 0.05,
|
||||
timeout_seconds: 0.15,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/last value: 10/);
|
||||
});
|
||||
|
||||
it('throws on a non-positive interval', async () => {
|
||||
const step = createStep({ query: 'q', operator: 'lt', threshold: '5', interval_seconds: 0 });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/greater than 0/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['lte', 3, 3],
|
||||
['gt', 10, 5],
|
||||
['gte', 5, 5],
|
||||
['eq', 3, 3],
|
||||
] as const)('clears with the %s operator', async (operator, value, threshold) => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [[value]] }));
|
||||
const step = createStep({ query: 'q', operator, threshold });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats a non-numeric non-string cell as no value and keeps polling', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [[true]] }))
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
|
||||
const step = createStep({
|
||||
query: 'q',
|
||||
operator: 'lt',
|
||||
threshold: '5',
|
||||
interval_seconds: 0.05,
|
||||
timeout_seconds: 5,
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retries after a network error and clears when the query recovers', async () => {
|
||||
fetchMock
|
||||
.mockRejectedValueOnce(new Error('ECONNRESET'))
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
|
||||
const step = createStep({
|
||||
query: 'q',
|
||||
operator: 'lt',
|
||||
threshold: '5',
|
||||
interval_seconds: 0.05,
|
||||
timeout_seconds: 5,
|
||||
});
|
||||
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
'Running the PostHog query failed with a network error; will retry on the next poll.'
|
||||
);
|
||||
});
|
||||
|
||||
it('retries after an invalid JSON response and clears when the query recovers', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new Error('unexpected token');
|
||||
},
|
||||
} as unknown as Response)
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
|
||||
const step = createStep({
|
||||
query: 'q',
|
||||
operator: 'lt',
|
||||
threshold: '5',
|
||||
interval_seconds: 0.05,
|
||||
timeout_seconds: 5,
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws on a 403 without retrying', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 403 }));
|
||||
const step = createStep({ query: 'q', operator: 'lt', threshold: '5' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/forbidden \(403\)/);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps polling through malformed response shapes until a numeric value arrives', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [[Infinity]] }))
|
||||
.mockResolvedValueOnce(jsonResponse('not an object'))
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [5] }))
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
|
||||
const step = createStep({
|
||||
query: 'q',
|
||||
operator: 'lt',
|
||||
threshold: '5',
|
||||
interval_seconds: 0.05,
|
||||
timeout_seconds: 5,
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createWaitForPosthogQueryFunction } from '../waitForPosthogQuery';
|
||||
|
||||
jest.mock('@expo/logger');
|
||||
jest.mock('node-fetch');
|
||||
|
||||
function jsonResponse(data: unknown, { ok = true, status = 200 } = {}): Response {
|
||||
return { ok, status, json: async () => data } as unknown as Response;
|
||||
}
|
||||
|
||||
const ENV = {
|
||||
POSTHOG_CLI_API_KEY: 'phx_test',
|
||||
POSTHOG_CLI_PROJECT_ID: '123',
|
||||
POSTHOG_CLI_HOST: 'https://us.posthog.com',
|
||||
};
|
||||
|
||||
describe(createWaitForPosthogQueryFunction, () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const waitForQuery = createWaitForPosthogQueryFunction();
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
function createStep(
|
||||
callInputs: Record<string, unknown>,
|
||||
env: Record<string, string> = ENV
|
||||
): ReturnType<typeof waitForQuery.createBuildStepFromFunctionCall> {
|
||||
return waitForQuery.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs,
|
||||
env,
|
||||
id: waitForQuery.id,
|
||||
});
|
||||
}
|
||||
|
||||
it('resolves when the query returns true', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [[true]] }));
|
||||
const step = createStep({ query: 'SELECT count() > 0 FROM events' });
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(init?.body as string).query).toEqual({
|
||||
kind: 'HogQLQuery',
|
||||
query: 'SELECT count() > 0 FROM events',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves when the query returns a nonzero number', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
|
||||
const step = createStep({ query: 'q' });
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps polling until the query turns true', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [[0]] }))
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [[true]] }));
|
||||
const step = createStep({ query: 'q', interval_seconds: 0.05, timeout_seconds: 5 });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps polling after a transient failure and clears when the query recovers', async () => {
|
||||
fetchMock
|
||||
.mockRejectedValueOnce(new Error('ECONNRESET'))
|
||||
.mockResolvedValueOnce(jsonResponse({ results: [[true]] }));
|
||||
const step = createStep({ query: 'q', interval_seconds: 0.05, timeout_seconds: 5 });
|
||||
|
||||
await expect(step.executeAsync()).resolves.not.toThrow();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws when the query never returns true within the timeout', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ results: [[false]] }));
|
||||
const step = createStep({ query: 'q', interval_seconds: 0.05, timeout_seconds: 0.1 });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/did not return true within/);
|
||||
});
|
||||
|
||||
it('throws when credentials are missing', async () => {
|
||||
const step = createStep({ query: 'q' }, {});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
/Missing PostHog credentials: personal API key, project id/
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on a non-positive interval', async () => {
|
||||
const step = createStep({ query: 'q', interval_seconds: 0 });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/greater than 0/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on a 403 without retrying', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 403 }));
|
||||
const step = createStep({ query: 'q' });
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(/forbidden \(403\)/);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
|
||||
import { PosthogClient } from '../utils/PosthogClient';
|
||||
import { PosthogUtils } from '../utils/PosthogUtils';
|
||||
|
||||
export function createCapturePosthogEventFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'posthog_capture_event',
|
||||
name: 'Capture PostHog event',
|
||||
__metricsId: 'eas/posthog_capture_event',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'event',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: true,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'distinct_id',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'properties',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'api_key',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'host',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ignore_error',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, env, signal }) => {
|
||||
const { logger } = stepCtx;
|
||||
const ignoreError = Boolean(inputs.ignore_error.value);
|
||||
const event = inputs.event.value as string;
|
||||
|
||||
const client = PosthogClient.forIngestion({
|
||||
apiKeyOverride: inputs.api_key.value as string | undefined,
|
||||
hostOverride: inputs.host.value as string | undefined,
|
||||
env,
|
||||
});
|
||||
if (!client) {
|
||||
PosthogUtils.failOrLogError({
|
||||
logger,
|
||||
ignoreError,
|
||||
error: new UserError(
|
||||
'EAS_POSTHOG_MISSING_API_KEY',
|
||||
'PostHog API key not provided. Set the "api_key" input or the EXPO_PUBLIC_POSTHOG_API_KEY environment variable.'
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Sending PostHog event "${event}"`);
|
||||
try {
|
||||
await client.captureEventAsync({
|
||||
event,
|
||||
distinctId: inputs.distinct_id.value as string | undefined,
|
||||
properties: inputs.properties.value as Record<string, unknown> | undefined,
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
PosthogUtils.failOrLogError({ logger, ignoreError, error });
|
||||
return;
|
||||
}
|
||||
logger.info(`Sent PostHog event "${event}"`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,6 @@ export function createCheckoutBuildFunction(): BuildFunction {
|
||||
id: 'checkout',
|
||||
name: 'Checkout',
|
||||
__metricsId: 'eas/checkout',
|
||||
__hookId: 'checkout',
|
||||
fn: async stepsCtx => {
|
||||
if (stepsCtx.global.wasCheckedOut()) {
|
||||
stepsCtx.logger.info('Project directory is already checked out');
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
|
||||
import { PosthogClient } from '../utils/PosthogClient';
|
||||
import { PosthogUtils } from '../utils/PosthogUtils';
|
||||
|
||||
export function createPosthogAnnotationFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'posthog_annotation',
|
||||
name: 'Create a PostHog annotation',
|
||||
__metricsId: 'eas/posthog_annotation',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'content',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: true,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'date_marker',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'api_key',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'project_id',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ignore_error',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, env, signal }) => {
|
||||
const { logger } = stepCtx;
|
||||
const ignoreError = Boolean(inputs.ignore_error.value);
|
||||
|
||||
const result = PosthogClient.fromEnv({
|
||||
apiKeyOverride: inputs.api_key.value as string | undefined,
|
||||
projectIdOverride: inputs.project_id.value as string | undefined,
|
||||
env,
|
||||
});
|
||||
if (!result.client) {
|
||||
PosthogUtils.failOrLogError({
|
||||
logger,
|
||||
ignoreError,
|
||||
error: PosthogUtils.missingCredentialsError(result.missing),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const client = result.client;
|
||||
|
||||
const content = inputs.content.value as string;
|
||||
const dateMarker =
|
||||
(inputs.date_marker.value as string | undefined) ?? new Date().toISOString();
|
||||
|
||||
logger.info(`Creating PostHog annotation "${content}"`);
|
||||
try {
|
||||
await client.requestAsync('POST', '/annotations/', {
|
||||
action: 'Creating the PostHog annotation',
|
||||
forbiddenScope: 'annotation:write',
|
||||
body: { content, date_marker: dateMarker },
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
PosthogUtils.failOrLogError({ logger, ignoreError, error });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Created PostHog annotation');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { BuildFunction, BuildRuntimePlatform, BuildStepOutput } from '@expo/steps';
|
||||
|
||||
import { IosSimulatorRecordingUtils } from '../utils/IosSimulatorRecordingUtils';
|
||||
|
||||
export function createFinishIosSimulatorRecordingsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'finish_ios_simulator_recordings',
|
||||
name: 'Finish iOS Simulator recordings',
|
||||
__metricsId: 'eas/finish_ios_simulator_recordings',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'recordings_json',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { outputs }) => {
|
||||
const recordings = await IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
outputs.recordings_json.set(JSON.stringify(recordings));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -16,7 +16,6 @@ export function createInstallNodeModulesBuildFunction(): BuildFunction {
|
||||
id: 'install_node_modules',
|
||||
name: 'Install node modules',
|
||||
__metricsId: 'eas/install_node_modules',
|
||||
__hookId: 'install_node_modules',
|
||||
fn: async (stepCtx, { env }) => {
|
||||
await installNodeModules(stepCtx, env);
|
||||
},
|
||||
|
||||
@@ -97,7 +97,6 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
id: 'maestro_tests',
|
||||
name: 'Run Maestro Tests',
|
||||
__metricsId: 'eas/maestro_tests',
|
||||
__hookId: 'maestro_tests',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'flow_path',
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PosthogClient } from '../utils/PosthogClient';
|
||||
import { PosthogUtils } from '../utils/PosthogUtils';
|
||||
|
||||
const SEARCH_LIMIT = 200;
|
||||
|
||||
const FeatureFlagGroupSchema = z.object({ properties: z.unknown().optional() }).passthrough();
|
||||
const FeatureFlagSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
key: z.string(),
|
||||
filters: z
|
||||
.object({
|
||||
groups: z.array(FeatureFlagGroupSchema).optional(),
|
||||
payloads: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const FlagSearchResponseSchema = z.object({ results: z.array(z.unknown()) });
|
||||
|
||||
type FeatureFlagGroup = z.infer<typeof FeatureFlagGroupSchema>;
|
||||
type FeatureFlagFilters = NonNullable<z.infer<typeof FeatureFlagSchema>['filters']>;
|
||||
|
||||
function isCatchAllGroup(group: FeatureFlagGroup): boolean {
|
||||
const { properties } = group;
|
||||
return (
|
||||
properties === undefined ||
|
||||
properties === null ||
|
||||
(Array.isArray(properties) && properties.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function createRolloutPosthogFlagFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'posthog_flag_rollout',
|
||||
name: 'Roll out a PostHog feature flag',
|
||||
__metricsId: 'eas/posthog_flag_rollout',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'flag',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: true,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'active',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'rollout_percentage',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'payload',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'variant',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'api_key',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'project_id',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ignore_error',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, env, signal }) => {
|
||||
const { logger } = stepCtx;
|
||||
const ignoreError = Boolean(inputs.ignore_error.value);
|
||||
|
||||
const flagKey = inputs.flag.value as string;
|
||||
const active = inputs.active.value as boolean | undefined;
|
||||
const rolloutPercentage = inputs.rollout_percentage.value as number | undefined;
|
||||
const payload = inputs.payload.value as Record<string, unknown> | undefined;
|
||||
const variant = inputs.variant.value as string | undefined;
|
||||
if (active === undefined && rolloutPercentage === undefined && payload === undefined) {
|
||||
throw new UserError(
|
||||
'EAS_POSTHOG_FLAG_NO_CHANGE',
|
||||
'You need to provide "active", "rollout_percentage", or "payload" to change the feature flag.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
rolloutPercentage !== undefined &&
|
||||
(!Number.isInteger(rolloutPercentage) || rolloutPercentage < 0 || rolloutPercentage > 100)
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_POSTHOG_FLAG_INVALID_ROLLOUT',
|
||||
`"rollout_percentage" must be an integer between 0 and 100, got ${rolloutPercentage}.`
|
||||
);
|
||||
}
|
||||
|
||||
const result = PosthogClient.fromEnv({
|
||||
apiKeyOverride: inputs.api_key.value as string | undefined,
|
||||
projectIdOverride: inputs.project_id.value as string | undefined,
|
||||
env,
|
||||
});
|
||||
if (!result.client) {
|
||||
PosthogUtils.failOrLogError({
|
||||
logger,
|
||||
ignoreError,
|
||||
error: PosthogUtils.missingCredentialsError(result.missing),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const client = result.client;
|
||||
|
||||
await rolloutPosthogFlagAsync({
|
||||
logger,
|
||||
client,
|
||||
flagKey,
|
||||
active,
|
||||
rolloutPercentage,
|
||||
payload,
|
||||
variant,
|
||||
ignoreError,
|
||||
signal,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function rolloutPosthogFlagAsync({
|
||||
logger,
|
||||
client,
|
||||
flagKey,
|
||||
active,
|
||||
rolloutPercentage,
|
||||
payload,
|
||||
variant,
|
||||
ignoreError,
|
||||
signal,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
client: PosthogClient;
|
||||
flagKey: string;
|
||||
active: boolean | undefined;
|
||||
rolloutPercentage: number | undefined;
|
||||
payload: Record<string, unknown> | undefined;
|
||||
variant: string | undefined;
|
||||
ignoreError: boolean;
|
||||
signal: AbortSignal | undefined;
|
||||
}): Promise<void> {
|
||||
let searchResponse;
|
||||
try {
|
||||
searchResponse = await client.requestAsync(
|
||||
'GET',
|
||||
`/feature_flags/?limit=${SEARCH_LIMIT}&search=${encodeURIComponent(flagKey)}`,
|
||||
{
|
||||
action: `Looking up PostHog flag "${flagKey}"`,
|
||||
forbiddenScope: 'feature_flag:read',
|
||||
signal,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
PosthogUtils.failOrLogError({ logger, ignoreError, error });
|
||||
return;
|
||||
}
|
||||
|
||||
let results: unknown[];
|
||||
try {
|
||||
results = FlagSearchResponseSchema.parse(await searchResponse.json()).results;
|
||||
} catch (error) {
|
||||
PosthogUtils.failOrLogError({
|
||||
logger,
|
||||
ignoreError,
|
||||
error: new UserError(
|
||||
'EAS_POSTHOG_FLAG_UNEXPECTED_RESPONSE',
|
||||
`PostHog flag lookup for "${flagKey}" returned an unexpected response.`,
|
||||
{ cause: error }
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const flag = results
|
||||
.flatMap(result => {
|
||||
const parsed = FeatureFlagSchema.safeParse(result);
|
||||
return parsed.success ? [parsed.data] : [];
|
||||
})
|
||||
.find(candidate => candidate.key === flagKey);
|
||||
if (!flag) {
|
||||
PosthogUtils.failOrLogError({
|
||||
logger,
|
||||
ignoreError,
|
||||
error: new UserError(
|
||||
'EAS_POSTHOG_FLAG_NOT_FOUND',
|
||||
`No PostHog feature flag with key "${flagKey}" was found. Check the "flag" input.`
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (active !== undefined) {
|
||||
patch.active = active;
|
||||
}
|
||||
let filters = flag.filters;
|
||||
if (rolloutPercentage !== undefined) {
|
||||
filters = buildRolloutFilters({ logger, flagKey, filters, rolloutPercentage });
|
||||
}
|
||||
if (payload !== undefined) {
|
||||
filters = {
|
||||
...filters,
|
||||
payloads: { ...filters?.payloads, [variant ?? 'true']: JSON.stringify(payload) },
|
||||
};
|
||||
}
|
||||
if (rolloutPercentage !== undefined || payload !== undefined) {
|
||||
patch.filters = filters;
|
||||
}
|
||||
|
||||
try {
|
||||
await client.requestAsync('PATCH', `/feature_flags/${flag.id}/`, {
|
||||
action: `Updating PostHog flag "${flagKey}"`,
|
||||
forbiddenScope: 'feature_flag:write',
|
||||
body: patch,
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
PosthogUtils.failOrLogError({ logger, ignoreError, error });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Updated PostHog feature flag "${flagKey}".`);
|
||||
}
|
||||
|
||||
function buildRolloutFilters({
|
||||
logger,
|
||||
flagKey,
|
||||
filters,
|
||||
rolloutPercentage,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
flagKey: string;
|
||||
filters: FeatureFlagFilters | undefined;
|
||||
rolloutPercentage: number;
|
||||
}): FeatureFlagFilters {
|
||||
const groups = filters?.groups ?? [];
|
||||
if (groups.length === 0) {
|
||||
return { ...filters, groups: [{ properties: [], rollout_percentage: rolloutPercentage }] };
|
||||
}
|
||||
|
||||
const catchAllIndex = groups.findIndex(isCatchAllGroup);
|
||||
const hasCatchAll = catchAllIndex >= 0;
|
||||
const targetIndex = hasCatchAll ? catchAllIndex : 0;
|
||||
if (!hasCatchAll) {
|
||||
logger.warn(
|
||||
`Flag "${flagKey}" has no catch-all release-condition group; setting rollout_percentage on the first group, which is condition-scoped.`
|
||||
);
|
||||
} else if (groups.length > 1) {
|
||||
logger.warn(
|
||||
`Flag "${flagKey}" has ${groups.length} release-condition groups; setting rollout_percentage only on the catch-all group to preserve the others.`
|
||||
);
|
||||
}
|
||||
|
||||
const groupsWithRollout = groups.map((group, index) =>
|
||||
index === targetIndex ? { ...group, rollout_percentage: rolloutPercentage } : group
|
||||
);
|
||||
return { ...filters, groups: groupsWithRollout };
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -54,7 +53,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: async ({ logger, global }, { inputs, env }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
@@ -122,12 +121,10 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
|
||||
// Keep the turtle job alive so the daemon and tunnel stay reachable
|
||||
// until stopDeviceRunSession cancels the run.
|
||||
await new Promise<never>(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { isProcessDescendantOfAsync } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
@@ -27,23 +24,20 @@ import {
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
|
||||
export const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.12.0';
|
||||
const ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG = 'artifacts-list-endpoint';
|
||||
const ARGENT_STATE_DIR = path.join(os.homedir(), '.argent');
|
||||
const ARGENT_STATE_FILE = path.join(os.homedir(), '.argent', 'tool-server.json');
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
const ArgentToolServerStateSchema = z.object({
|
||||
port: z.number(),
|
||||
pid: z.number(),
|
||||
token: z.string().optional(),
|
||||
});
|
||||
|
||||
type ArgentToolServerState = z.infer<typeof ArgentToolServerStateSchema>;
|
||||
|
||||
export function createStartArgentRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
@@ -59,7 +53,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: async ({ logger, global }, { inputs, env }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
@@ -80,6 +74,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
// Stale state from a previous run would mask the new server's port.
|
||||
await fs.promises.rm(ARGENT_STATE_FILE, { force: true });
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
await spawn(
|
||||
'bunx',
|
||||
@@ -88,8 +84,6 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
);
|
||||
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
|
||||
// Keep Argent itself in foreground mode under the detached bunx process. This preserves
|
||||
// the bunx -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const argentServer = spawnDetached({
|
||||
command: 'bunx',
|
||||
args: [
|
||||
@@ -100,24 +94,20 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
'--detach',
|
||||
],
|
||||
env,
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
'Failed to start Argent: could not determine the PID of the launched process.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
|
||||
logger.info(`Waiting for argent tool-server state at ${ARGENT_STATE_FILE}.`);
|
||||
let toolServerPort: number;
|
||||
let toolServerToken: string | undefined;
|
||||
try {
|
||||
const toolServerState = await waitForArgentToolServerStateAsync({
|
||||
stateDir: ARGENT_STATE_DIR,
|
||||
ancestorPid: argentServer.pid,
|
||||
const toolServerState = await waitForFileAsync({
|
||||
filePath: ARGENT_STATE_FILE,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'argent tool-server state',
|
||||
parse: parseArgentToolServerState,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
@@ -130,69 +120,51 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
);
|
||||
}
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
const artifactPollAbortController = new AbortController();
|
||||
const artifactPollSignal = signal
|
||||
? AbortSignal.any([signal, artifactPollAbortController.signal])
|
||||
: artifactPollAbortController.signal;
|
||||
const artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
|
||||
void pollArgentArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
|
||||
toolsAuthToken: toolServerToken,
|
||||
logger,
|
||||
signal: artifactPollSignal,
|
||||
});
|
||||
|
||||
try {
|
||||
const publicToolsUrl = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
subdomainPrefix: 'argent',
|
||||
const publicToolsUrl = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
subdomainPrefix: 'argent',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
rewriteHostHeader: true,
|
||||
logger,
|
||||
});
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
|
||||
// serve-sim is iOS-only — Android sessions go without a preview URL.
|
||||
let webPreviewUrl: string | undefined;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
const serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
rewriteHostHeader: true,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
|
||||
// serve-sim is iOS-only — Android sessions go without a preview URL.
|
||||
let webPreviewUrl: string | undefined;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
const serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
webPreviewUrl = serveSim.previewUrl;
|
||||
logger.info(`Web preview URL: ${webPreviewUrl}`);
|
||||
}
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
...(webPreviewUrl ? { webPreviewUrl } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
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.');
|
||||
}
|
||||
webPreviewUrl = serveSim.previewUrl;
|
||||
logger.info(`Web preview URL: ${webPreviewUrl}`);
|
||||
}
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
...(webPreviewUrl ? { webPreviewUrl } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
|
||||
// Keep the turtle job alive so the tool-server and tunnel stay reachable
|
||||
// until stopDeviceRunSession cancels the run.
|
||||
await new Promise<never>(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -227,64 +199,12 @@ export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgentToolServerState(raw: string): ArgentToolServerState {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new SystemError('Expected tool-server state to contain valid JSON.', { cause: err });
|
||||
}
|
||||
const result = ArgentToolServerStateSchema.safeParse(json);
|
||||
function parseArgentToolServerState(raw: string): { port: number; token?: string } {
|
||||
const result = ArgentToolServerStateSchema.safeParse(JSON.parse(raw));
|
||||
if (!result.success) {
|
||||
throw new SystemError(
|
||||
`Expected tool-server state to contain { "port": <number>, "pid": <number>, ... }: ${result.error.message}`
|
||||
`Expected tool-server state to contain { "port": <number>, ... }: ${result.error.message}`
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid,
|
||||
timeoutMs,
|
||||
pollIntervalMs = 1_000,
|
||||
}: {
|
||||
stateDir: string;
|
||||
ancestorPid: number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<ArgentToolServerState> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const stateFileNames = (await fs.promises.readdir(stateDir)).filter(
|
||||
name => name.startsWith('tool-server') && name.endsWith('.json')
|
||||
);
|
||||
for (const stateFileName of stateFileNames) {
|
||||
try {
|
||||
const state = parseArgentToolServerState(
|
||||
await fs.promises.readFile(path.join(stateDir, stateFileName), 'utf8')
|
||||
);
|
||||
if (await isProcessDescendantOfAsync(state.pid, ancestorPid)) {
|
||||
return state;
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && !(err instanceof SystemError)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
await sleepAsync(pollIntervalMs);
|
||||
}
|
||||
|
||||
throw new SystemError(
|
||||
`Timed out waiting for an argent tool-server state file belonging to process ${ancestorPid} in ${stateDir}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}.`
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { IosSimulatorRecordingUtils } from '../utils/IosSimulatorRecordingUtils';
|
||||
|
||||
export function createStartIosSimulatorRecordingsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_ios_simulator_recordings',
|
||||
name: 'Start iOS Simulator recordings',
|
||||
__metricsId: 'eas/start_ios_simulator_recordings',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env }) => {
|
||||
await IosSimulatorRecordingUtils.startAsync({
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
@@ -21,7 +20,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
name: 'Start serve-sim remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env, signal }) => {
|
||||
fn: async ({ logger }, { env }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
|
||||
@@ -29,27 +28,26 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
|
||||
const { previewUrl } = await startServeSimWithTunnelAsync(ctx, {
|
||||
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Preview URL: ${previewUrl}`);
|
||||
logger.info(`Stream URL: ${streamUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: { previewUrl },
|
||||
remoteConfig: { previewUrl, streamUrl },
|
||||
logger,
|
||||
});
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
|
||||
// Keep the turtle job alive so the serve-sim tunnel stays reachable
|
||||
// until stopDeviceRunSession cancels the run.
|
||||
await new Promise<never>(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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 { 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 {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'upload_device_run_session_screen_recordings',
|
||||
name: 'Upload device run session screen recordings',
|
||||
__metricsId: 'eas/upload_device_run_session_screen_recordings',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'recordings_json',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
],
|
||||
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;
|
||||
if (recordings.length === 0) {
|
||||
logger.info('No iOS Simulator 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}.`
|
||||
);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { PipeMode } from '@expo/logger';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { PosthogClient } from '../utils/PosthogClient';
|
||||
import { PosthogUtils } from '../utils/PosthogUtils';
|
||||
|
||||
export function createUploadPosthogSourcemapsFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'posthog_upload_sourcemaps',
|
||||
name: 'Upload source maps to PostHog',
|
||||
__metricsId: 'eas/posthog_upload_sourcemaps',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'directory',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
defaultValue: 'dist',
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'api_key',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'project_id',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ignore_error',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, env, signal }) => {
|
||||
const { logger } = stepCtx;
|
||||
const ignoreError = Boolean(inputs.ignore_error.value);
|
||||
|
||||
const result = PosthogClient.fromEnv({
|
||||
apiKeyOverride: inputs.api_key.value as string | undefined,
|
||||
projectIdOverride: inputs.project_id.value as string | undefined,
|
||||
env,
|
||||
});
|
||||
if (!result.client) {
|
||||
PosthogUtils.failOrLogError({
|
||||
logger,
|
||||
ignoreError,
|
||||
error: PosthogUtils.missingCredentialsError(result.missing),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const client = result.client;
|
||||
|
||||
const directory = inputs.directory.value as string;
|
||||
const { host, env: cliEnv } = client.cliConfig();
|
||||
logger.info(`Uploading source maps from "${directory}" to PostHog`);
|
||||
try {
|
||||
await spawn(
|
||||
'npx',
|
||||
['-y', '@posthog/cli', '--host', host, 'hermes', 'upload', '--directory', directory],
|
||||
{
|
||||
logger,
|
||||
// @posthog/cli writes all its logs, progress and success included, to stderr; tag them as stdout so they don't surface as build errors.
|
||||
mode: PipeMode.COMBINED_AS_STDOUT,
|
||||
cwd: stepCtx.workingDirectory,
|
||||
env: { ...env, ...cliEnv },
|
||||
signal,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
PosthogUtils.failOrLogError({
|
||||
logger,
|
||||
ignoreError,
|
||||
error: new UserError(
|
||||
'EAS_POSTHOG_SOURCEMAPS_FAILED',
|
||||
'Uploading source maps to PostHog failed.',
|
||||
{ cause: error }
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Uploaded source maps to PostHog');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -23,7 +23,6 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
id: 'upload_to_asc',
|
||||
name: 'Upload to App Store Connect',
|
||||
__metricsId: 'eas/upload_to_asc',
|
||||
__hookId: 'submit',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ipa_path',
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
|
||||
import { PosthogClient, PosthogRetryableError } from '../utils/PosthogClient';
|
||||
import { PosthogUtils } from '../utils/PosthogUtils';
|
||||
|
||||
const OPERATORS = {
|
||||
lt: { symbol: '<', test: (value: number, threshold: number) => value < threshold },
|
||||
lte: { symbol: '<=', test: (value: number, threshold: number) => value <= threshold },
|
||||
gt: { symbol: '>', test: (value: number, threshold: number) => value > threshold },
|
||||
gte: { symbol: '>=', test: (value: number, threshold: number) => value >= threshold },
|
||||
eq: { symbol: '=', test: (value: number, threshold: number) => value === threshold },
|
||||
};
|
||||
|
||||
type Operator = keyof typeof OPERATORS;
|
||||
|
||||
export function createWaitForPosthogMetricFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'posthog_wait_for_metric',
|
||||
name: 'Wait for a PostHog metric',
|
||||
__metricsId: 'eas/posthog_wait_for_metric',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'query',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: true,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'operator',
|
||||
allowedValues: Object.keys(OPERATORS),
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: true,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'threshold',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
required: true,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'timeout_seconds',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
required: false,
|
||||
defaultValue: PosthogUtils.DEFAULT_POLL_TIMEOUT_SECONDS,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'interval_seconds',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
required: false,
|
||||
defaultValue: PosthogUtils.DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'api_key',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'project_id',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputProviders: [BuildStepOutput.createProvider({ id: 'value', required: false })],
|
||||
fn: async (stepCtx, { inputs, outputs, env, signal }) => {
|
||||
const { logger } = stepCtx;
|
||||
|
||||
const operator = inputs.operator.value as string;
|
||||
if (!Object.keys(OPERATORS).includes(operator)) {
|
||||
throw new UserError(
|
||||
'EAS_POSTHOG_METRIC_INVALID_OPERATOR',
|
||||
`Invalid "operator" "${operator}". Must be one of: ${Object.keys(OPERATORS).join(', ')}.`
|
||||
);
|
||||
}
|
||||
const timeoutSeconds = inputs.timeout_seconds.value as number;
|
||||
const intervalSeconds = inputs.interval_seconds.value as number;
|
||||
PosthogUtils.assertPollBoundsPositive({
|
||||
timeoutSeconds,
|
||||
intervalSeconds,
|
||||
errorCode: 'EAS_POSTHOG_METRIC_INVALID_INTERVAL',
|
||||
});
|
||||
|
||||
const result = PosthogClient.fromEnv({
|
||||
apiKeyOverride: inputs.api_key.value as string | undefined,
|
||||
projectIdOverride: inputs.project_id.value as string | undefined,
|
||||
env,
|
||||
});
|
||||
if (!result.client) {
|
||||
throw PosthogUtils.missingCredentialsError(result.missing);
|
||||
}
|
||||
const client = result.client;
|
||||
|
||||
const value = await waitForPosthogMetricAsync({
|
||||
logger,
|
||||
client,
|
||||
query: inputs.query.value as string,
|
||||
operator: operator as Operator,
|
||||
threshold: inputs.threshold.value as number,
|
||||
timeoutSeconds,
|
||||
intervalSeconds,
|
||||
signal,
|
||||
});
|
||||
outputs.value.set(String(value));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPosthogMetricAsync({
|
||||
logger,
|
||||
client,
|
||||
query,
|
||||
operator,
|
||||
threshold,
|
||||
timeoutSeconds,
|
||||
intervalSeconds,
|
||||
signal,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
client: PosthogClient;
|
||||
query: string;
|
||||
operator: Operator;
|
||||
threshold: number;
|
||||
timeoutSeconds: number;
|
||||
intervalSeconds: number;
|
||||
signal: AbortSignal | undefined;
|
||||
}): Promise<number> {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
const { symbol, test } = OPERATORS[operator];
|
||||
const target = `${symbol} ${threshold}`;
|
||||
let lastValue: number | undefined;
|
||||
|
||||
logger.info(
|
||||
`Waiting for the PostHog metric to reach ${target}. Checking every ${intervalSeconds}s for up to ${timeoutSeconds}s.`
|
||||
);
|
||||
|
||||
for (;;) {
|
||||
let value: number | undefined = undefined;
|
||||
try {
|
||||
value = await queryMetricAsync({ logger, client, query, signal });
|
||||
} catch (error) {
|
||||
if (!(error instanceof PosthogRetryableError)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (value !== undefined) {
|
||||
lastValue = value;
|
||||
if (test(value, threshold)) {
|
||||
logger.info(`Metric is ${value}. Target ${target} met.`);
|
||||
return value;
|
||||
}
|
||||
logger.info(`Metric is ${value}. Still waiting for ${target}.`);
|
||||
} else {
|
||||
logger.info('The query has not returned a numeric value yet. Still waiting.');
|
||||
}
|
||||
|
||||
if (!(await PosthogUtils.waitForNextPollAsync({ intervalSeconds, deadline, signal }))) {
|
||||
throw new UserError(
|
||||
'EAS_POSTHOG_METRIC_TIMEOUT',
|
||||
lastValue !== undefined
|
||||
? `The PostHog metric did not reach ${target} within ${timeoutSeconds}s (last value: ${lastValue}).`
|
||||
: `The PostHog query never returned a numeric value within ${timeoutSeconds}s. Check that the query returns a single number and that PostHog is reachable.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function queryMetricAsync({
|
||||
logger,
|
||||
client,
|
||||
query,
|
||||
signal,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
client: PosthogClient;
|
||||
query: string;
|
||||
signal: AbortSignal | undefined;
|
||||
}): Promise<number | undefined> {
|
||||
const cell = await client.runQueryAsync(query, logger, signal);
|
||||
return typeof cell === 'number' && Number.isFinite(cell) ? cell : undefined;
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
|
||||
import { PosthogClient, PosthogRetryableError } from '../utils/PosthogClient';
|
||||
import { PosthogUtils } from '../utils/PosthogUtils';
|
||||
|
||||
export function createWaitForPosthogQueryFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'posthog_wait_for_query',
|
||||
name: 'Wait for a PostHog query',
|
||||
__metricsId: 'eas/posthog_wait_for_query',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'query',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: true,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'timeout_seconds',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
required: false,
|
||||
defaultValue: PosthogUtils.DEFAULT_POLL_TIMEOUT_SECONDS,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'interval_seconds',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
required: false,
|
||||
defaultValue: PosthogUtils.DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'api_key',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'project_id',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, env, signal }) => {
|
||||
const { logger } = stepCtx;
|
||||
|
||||
const timeoutSeconds = inputs.timeout_seconds.value as number;
|
||||
const intervalSeconds = inputs.interval_seconds.value as number;
|
||||
PosthogUtils.assertPollBoundsPositive({
|
||||
timeoutSeconds,
|
||||
intervalSeconds,
|
||||
errorCode: 'EAS_POSTHOG_QUERY_INVALID_INTERVAL',
|
||||
});
|
||||
|
||||
const result = PosthogClient.fromEnv({
|
||||
apiKeyOverride: inputs.api_key.value as string | undefined,
|
||||
projectIdOverride: inputs.project_id.value as string | undefined,
|
||||
env,
|
||||
});
|
||||
if (!result.client) {
|
||||
throw PosthogUtils.missingCredentialsError(result.missing);
|
||||
}
|
||||
const client = result.client;
|
||||
|
||||
await waitForPosthogQueryAsync({
|
||||
logger,
|
||||
client,
|
||||
query: inputs.query.value as string,
|
||||
timeoutSeconds,
|
||||
intervalSeconds,
|
||||
signal,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPosthogQueryAsync({
|
||||
logger,
|
||||
client,
|
||||
query,
|
||||
timeoutSeconds,
|
||||
intervalSeconds,
|
||||
signal,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
client: PosthogClient;
|
||||
query: string;
|
||||
timeoutSeconds: number;
|
||||
intervalSeconds: number;
|
||||
signal: AbortSignal | undefined;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
|
||||
logger.info(
|
||||
`Waiting for the PostHog query to return true. Checking every ${intervalSeconds}s for up to ${timeoutSeconds}s.`
|
||||
);
|
||||
|
||||
for (;;) {
|
||||
let cell: unknown;
|
||||
try {
|
||||
cell = await client.runQueryAsync(query, logger, signal);
|
||||
} catch (error) {
|
||||
if (!(error instanceof PosthogRetryableError)) {
|
||||
throw error;
|
||||
}
|
||||
cell = undefined;
|
||||
}
|
||||
if (cell === true || (typeof cell === 'number' && cell !== 0)) {
|
||||
logger.info('Query returned true.');
|
||||
return;
|
||||
}
|
||||
logger.info('Query is not true yet. Still waiting.');
|
||||
|
||||
if (!(await PosthogUtils.waitForNextPollAsync({ intervalSeconds, deadline, signal }))) {
|
||||
throw new UserError(
|
||||
'EAS_POSTHOG_QUERY_TIMEOUT',
|
||||
`The PostHog query did not return true within ${timeoutSeconds}s. It must select a single boolean, e.g. "SELECT count() > 100 FROM events".`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
import { type Env } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { access, mkdir, mkdtemp } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
|
||||
const IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS = 2_000;
|
||||
const RECORD_SIM_FINISH_TIMEOUT_MS = 70_000;
|
||||
const RECORD_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
|
||||
const RECORD_SIM_MAX_ATTEMPTS_PER_BOOT = 3;
|
||||
const RECORD_SIM_COMMAND = 'record-sim';
|
||||
|
||||
type IosSimulatorRecording = {
|
||||
id: string;
|
||||
udid: IosSimulatorUuid;
|
||||
deviceName: string;
|
||||
runtimeDisplayName: string;
|
||||
outputDirectory: string;
|
||||
startedAt: Date;
|
||||
getOutput: () => string;
|
||||
};
|
||||
|
||||
type ActiveIosSimulatorRecording = IosSimulatorRecording & {
|
||||
recordingProcess: ChildProcess;
|
||||
completionPromise: Promise<void>;
|
||||
};
|
||||
|
||||
type IosSimulatorRecordingSession = {
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
recordSimCommand: string;
|
||||
recordingsRootDirectory: string;
|
||||
activeRecordings: Map<IosSimulatorUuid, ActiveIosSimulatorRecording>;
|
||||
completedRecordings: IosSimulatorRecording[];
|
||||
recordingFailureCounts: Map<IosSimulatorUuid, number>;
|
||||
pollingPromise: Promise<void>;
|
||||
abortController: AbortController;
|
||||
};
|
||||
|
||||
let activeIosSimulatorRecordingSession: IosSimulatorRecordingSession | null = null;
|
||||
|
||||
export namespace IosSimulatorRecordingUtils {
|
||||
export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise<void> {
|
||||
if (activeIosSimulatorRecordingSession) {
|
||||
logger.info('iOS Simulator screen recording polling is already running.');
|
||||
return;
|
||||
}
|
||||
|
||||
const recordSimCommand = await resolveRecordSimCommandAsync({ env });
|
||||
if (!recordSimCommand) {
|
||||
logger.warn(
|
||||
'record-sim binary is not available; iOS Simulator screen recordings are disabled.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const recordingsRootDirectory = await mkdtemp(
|
||||
path.join(os.tmpdir(), 'ios-simulator-recordings-')
|
||||
);
|
||||
const session: IosSimulatorRecordingSession = {
|
||||
env,
|
||||
logger,
|
||||
recordSimCommand,
|
||||
recordingsRootDirectory,
|
||||
activeRecordings: new Map(),
|
||||
completedRecordings: [],
|
||||
recordingFailureCounts: new Map(),
|
||||
pollingPromise: Promise.resolve(),
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
activeIosSimulatorRecordingSession = session;
|
||||
|
||||
logger.info('Started polling iOS Simulators for screen recordings.');
|
||||
session.pollingPromise = pollIosSimulatorRecordingsAsync(session).catch(err => {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('iOS Simulator screen recording poller failed', error);
|
||||
logger.warn({ err: error }, 'iOS Simulator screen recording poller failed.');
|
||||
});
|
||||
}
|
||||
|
||||
export async function finishAsync({ logger }: { logger: bunyan }): Promise<
|
||||
{
|
||||
udid: IosSimulatorUuid;
|
||||
deviceName: string;
|
||||
runtimeDisplayName: string;
|
||||
directory: string;
|
||||
}[]
|
||||
> {
|
||||
const session = activeIosSimulatorRecordingSession;
|
||||
if (!session) {
|
||||
logger.info('No iOS Simulator screen recordings are running.');
|
||||
return [];
|
||||
}
|
||||
activeIosSimulatorRecordingSession = null;
|
||||
|
||||
session.abortController.abort();
|
||||
await session.pollingPromise;
|
||||
await Promise.all(
|
||||
[...session.activeRecordings.values()].map(async recording => {
|
||||
logger.info(`Stopping screen recording for ${recording.deviceName}.`);
|
||||
recording.recordingProcess.kill('SIGINT');
|
||||
const finished = await Promise.race([
|
||||
recording.completionPromise.then(() => true),
|
||||
setTimeout(RECORD_SIM_FINISH_TIMEOUT_MS, false, { ref: false }),
|
||||
]);
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recordSimOutput = recording.getOutput().trim();
|
||||
const finishTimeoutSeconds = Math.round(RECORD_SIM_FINISH_TIMEOUT_MS / 1_000);
|
||||
logger.warn(
|
||||
{ recordSimOutput },
|
||||
`Screen recording for ${recording.deviceName} did not finish within ${finishTimeoutSeconds} seconds and will be stopped.${
|
||||
recordSimOutput
|
||||
? `\nRecent recorder messages:\n${recordSimOutput}`
|
||||
: '\nNo recorder messages were captured.'
|
||||
}`
|
||||
);
|
||||
recording.recordingProcess.kill('SIGKILL');
|
||||
const killed = await Promise.race([
|
||||
recording.completionPromise.then(() => true),
|
||||
setTimeout(RECORD_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
|
||||
]);
|
||||
if (!killed) {
|
||||
logger.warn(
|
||||
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
|
||||
);
|
||||
Sentry.capture(
|
||||
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`,
|
||||
{
|
||||
extras: {
|
||||
output: recording.getOutput(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const completedRecordings = [...session.completedRecordings].sort(
|
||||
(a, b) => a.startedAt.getTime() - b.startedAt.getTime()
|
||||
);
|
||||
return completedRecordings.map(recording => ({
|
||||
udid: recording.udid,
|
||||
deviceName: recording.deviceName,
|
||||
runtimeDisplayName: recording.runtimeDisplayName,
|
||||
directory: recording.outputDirectory,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function pollIosSimulatorRecordingsAsync(
|
||||
session: IosSimulatorRecordingSession
|
||||
): Promise<void> {
|
||||
let listDevicesErrorCount = 0;
|
||||
|
||||
const signal = session.abortController.signal;
|
||||
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const bootedDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env: session.env,
|
||||
filter: 'booted',
|
||||
});
|
||||
if (signal.aborted) {
|
||||
break;
|
||||
}
|
||||
listDevicesErrorCount = 0;
|
||||
|
||||
const bootedUdids = new Set(bootedDevices.map(device => device.udid));
|
||||
for (const udid of session.recordingFailureCounts.keys()) {
|
||||
if (!bootedUdids.has(udid)) {
|
||||
session.recordingFailureCounts.delete(udid);
|
||||
}
|
||||
}
|
||||
|
||||
for (const device of bootedDevices) {
|
||||
if (
|
||||
session.activeRecordings.has(device.udid) ||
|
||||
(session.recordingFailureCounts.get(device.udid) ?? 0) >= RECORD_SIM_MAX_ATTEMPTS_PER_BOOT
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await startIosSimulatorRecordingAsync(session, {
|
||||
udid: device.udid,
|
||||
deviceName: device.name,
|
||||
runtimeDisplayName: device.runtimeDisplayName,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
listDevicesErrorCount += 1;
|
||||
if (listDevicesErrorCount === 1 || listDevicesErrorCount % 5 === 0) {
|
||||
Sentry.capture('Could not poll iOS Simulators for screen recordings', error);
|
||||
session.logger.warn(
|
||||
{ err: error, failedSimulatorListCount: listDevicesErrorCount },
|
||||
'Could not poll iOS Simulators for screen recordings.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!signal.aborted) {
|
||||
try {
|
||||
await setTimeout(IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS, undefined, {
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!signal.aborted) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startIosSimulatorRecordingAsync(
|
||||
session: IosSimulatorRecordingSession,
|
||||
{
|
||||
udid,
|
||||
deviceName,
|
||||
runtimeDisplayName,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
deviceName: string;
|
||||
runtimeDisplayName: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const startedAt = new Date();
|
||||
const recordingId = randomUUID();
|
||||
const outputDirectory = path.join(session.recordingsRootDirectory, recordingId);
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
session.logger.info(`Starting screen recording for ${deviceName}.`);
|
||||
const recordingSpawn = spawn(
|
||||
session.recordSimCommand,
|
||||
['--udid', udid, '--output', outputDirectory, '--segment-duration', '0'],
|
||||
{
|
||||
env: session.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}
|
||||
);
|
||||
const getOutput = captureProcessOutput(recordingSpawn.child);
|
||||
const completionPromise = recordingSpawn
|
||||
.then(() => undefined)
|
||||
.catch((err: unknown) => {
|
||||
session.recordingFailureCounts.set(udid, (session.recordingFailureCounts.get(udid) ?? 0) + 1);
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('iOS Simulator screen recording process failed', error);
|
||||
session.logger.warn(
|
||||
{ err: error, recordSimOutput: getOutput() },
|
||||
`Screen recording process failed for ${deviceName}.`
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
session.activeRecordings.delete(udid);
|
||||
session.completedRecordings.push({
|
||||
id: recordingId,
|
||||
udid,
|
||||
deviceName,
|
||||
runtimeDisplayName,
|
||||
outputDirectory,
|
||||
startedAt,
|
||||
getOutput,
|
||||
});
|
||||
});
|
||||
|
||||
session.activeRecordings.set(udid, {
|
||||
id: recordingId,
|
||||
udid,
|
||||
deviceName,
|
||||
runtimeDisplayName,
|
||||
outputDirectory,
|
||||
recordingProcess: recordingSpawn.child,
|
||||
completionPromise,
|
||||
startedAt,
|
||||
getOutput,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveRecordSimCommandAsync({ env }: { env: Env }): Promise<string | null> {
|
||||
try {
|
||||
await spawn('which', [RECORD_SIM_COMMAND], { env });
|
||||
return RECORD_SIM_COMMAND;
|
||||
} catch {}
|
||||
|
||||
const packagedRecordSimPath = path.join(__dirname, '..', '..', '..', 'bin', RECORD_SIM_COMMAND);
|
||||
try {
|
||||
await access(packagedRecordSimPath);
|
||||
return packagedRecordSimPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function captureProcessOutput(recordingProcess: ChildProcess): () => string {
|
||||
let output = '';
|
||||
const appendChunk = (chunk: Buffer | string): void => {
|
||||
// Keep enough recorder stderr/stdout for diagnostics without retaining unbounded output.
|
||||
output = `${output}${chunk.toString()}`.slice(-16_384);
|
||||
};
|
||||
recordingProcess.stdout?.on('data', appendChunk);
|
||||
recordingProcess.stderr?.on('data', appendChunk);
|
||||
return () => output;
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PosthogUtils } from './PosthogUtils';
|
||||
|
||||
const POSTHOG_DEFAULT_HOST = 'https://us.posthog.com';
|
||||
const SYSTEM_DISTINCT_ID = 'eas-workflow';
|
||||
|
||||
const QueryResponseSchema = z.object({ results: z.array(z.array(z.unknown())) });
|
||||
|
||||
export class PosthogRetryableError extends Error {}
|
||||
|
||||
export class PosthogClient {
|
||||
private constructor(
|
||||
private readonly host: string,
|
||||
private readonly apiKey: string,
|
||||
private readonly projectId: string
|
||||
) {}
|
||||
|
||||
static fromEnv({
|
||||
apiKeyOverride,
|
||||
projectIdOverride,
|
||||
env,
|
||||
}: {
|
||||
apiKeyOverride: string | undefined;
|
||||
projectIdOverride: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
}): { client: PosthogClient } | { client: undefined; missing: PosthogUtils.Credential[] } {
|
||||
const apiKey = apiKeyOverride || env.POSTHOG_CLI_API_KEY;
|
||||
const projectId = projectIdOverride || env.POSTHOG_CLI_PROJECT_ID;
|
||||
if (!apiKey || !projectId) {
|
||||
return {
|
||||
client: undefined,
|
||||
missing: [
|
||||
...(apiKey ? [] : [PosthogUtils.API_CREDENTIALS.apiKey]),
|
||||
...(projectId ? [] : [PosthogUtils.API_CREDENTIALS.projectId]),
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
client: new PosthogClient(
|
||||
(env.POSTHOG_CLI_HOST || POSTHOG_DEFAULT_HOST).replace(/\/+$/, ''),
|
||||
apiKey,
|
||||
projectId
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static forIngestion({
|
||||
apiKeyOverride,
|
||||
hostOverride,
|
||||
env,
|
||||
}: {
|
||||
apiKeyOverride: string | undefined;
|
||||
hostOverride: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
}): PosthogClient | undefined {
|
||||
const apiKey = apiKeyOverride || env.EXPO_PUBLIC_POSTHOG_API_KEY || env.POSTHOG_API_KEY;
|
||||
if (!apiKey) {
|
||||
return undefined;
|
||||
}
|
||||
return new PosthogClient(
|
||||
(hostOverride || env.EXPO_PUBLIC_POSTHOG_HOST || POSTHOG_DEFAULT_HOST).replace(/\/+$/, ''),
|
||||
apiKey,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
async captureEventAsync({
|
||||
event,
|
||||
distinctId,
|
||||
properties,
|
||||
signal,
|
||||
}: {
|
||||
event: string;
|
||||
distinctId: string | undefined;
|
||||
properties: Record<string, unknown> | undefined;
|
||||
signal: AbortSignal | undefined;
|
||||
}): Promise<void> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${this.host}/i/v0/e/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
api_key: this.apiKey,
|
||||
event,
|
||||
distinct_id: distinctId ?? SYSTEM_DISTINCT_ID,
|
||||
properties:
|
||||
distinctId !== undefined
|
||||
? properties
|
||||
: { ...properties, $process_person_profile: false },
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
throw new UserError(
|
||||
'EAS_POSTHOG_CAPTURE_FAILED',
|
||||
`Sending PostHog event "${event}" failed.`,
|
||||
{
|
||||
cause: error,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new UserError(
|
||||
'EAS_POSTHOG_CAPTURE_FAILED',
|
||||
`Sending PostHog event "${event}" failed with ${await PosthogUtils.readErrorAsync(response)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async requestAsync(
|
||||
method: 'GET' | 'POST' | 'PATCH',
|
||||
path: string,
|
||||
{
|
||||
action,
|
||||
forbiddenScope,
|
||||
body,
|
||||
signal,
|
||||
}: { action: string; forbiddenScope: string; body?: unknown; signal: AbortSignal | undefined }
|
||||
): Promise<Response> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(this.apiUrl(path), {
|
||||
method,
|
||||
headers: this.authHeaders(),
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
throw new UserError('EAS_POSTHOG_REQUEST_FAILED', `${action} failed.`, { cause: error });
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new UserError(
|
||||
'EAS_POSTHOG_FORBIDDEN',
|
||||
`${action} was forbidden (403). The personal API key likely lacks the "${forbiddenScope}" scope.`
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new UserError(
|
||||
'EAS_POSTHOG_REQUEST_FAILED',
|
||||
`${action} failed with ${await PosthogUtils.readErrorAsync(response)}.`
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async runQueryAsync(
|
||||
query: string,
|
||||
logger: bunyan,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<unknown> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(this.apiUrl('/query/'), {
|
||||
method: 'POST',
|
||||
headers: this.authHeaders(),
|
||||
body: JSON.stringify({ query: { kind: 'HogQLQuery', query }, refresh: 'blocking' }),
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
logger.debug(error);
|
||||
logger.warn(
|
||||
'Running the PostHog query failed with a network error; will retry on the next poll.'
|
||||
);
|
||||
throw new PosthogRetryableError();
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new UserError(
|
||||
'EAS_POSTHOG_FORBIDDEN',
|
||||
'Running the PostHog query was forbidden (403). The personal API key likely lacks the "query:read" scope.'
|
||||
);
|
||||
}
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
logger.warn(
|
||||
`Running the PostHog query failed with status ${response.status}; will retry on the next poll.`
|
||||
);
|
||||
throw new PosthogRetryableError();
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new UserError(
|
||||
'EAS_POSTHOG_REQUEST_FAILED',
|
||||
`Running the PostHog query failed with ${await PosthogUtils.readErrorAsync(response)}.`
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch (error) {
|
||||
logger.debug(error);
|
||||
logger.warn('The PostHog query response was not valid JSON; will retry on the next poll.');
|
||||
throw new PosthogRetryableError();
|
||||
}
|
||||
const parsed = QueryResponseSchema.safeParse(body);
|
||||
return parsed.success ? parsed.data.results[0]?.[0] : undefined;
|
||||
}
|
||||
|
||||
cliConfig(): { host: string; env: Record<string, string> } {
|
||||
return {
|
||||
host: this.host,
|
||||
env: { POSTHOG_CLI_API_KEY: this.apiKey, POSTHOG_CLI_PROJECT_ID: this.projectId },
|
||||
};
|
||||
}
|
||||
|
||||
private apiUrl(path: string): string {
|
||||
return `${this.host}/api/projects/${encodeURIComponent(this.projectId)}${path}`;
|
||||
}
|
||||
|
||||
private authHeaders(): Record<string, string> {
|
||||
return { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
export namespace PosthogUtils {
|
||||
export interface Credential {
|
||||
label: string;
|
||||
envVar: string;
|
||||
input: string;
|
||||
}
|
||||
|
||||
export const API_CREDENTIALS = {
|
||||
apiKey: { label: 'personal API key', envVar: 'POSTHOG_CLI_API_KEY', input: 'api_key' },
|
||||
projectId: { label: 'project id', envVar: 'POSTHOG_CLI_PROJECT_ID', input: 'project_id' },
|
||||
} satisfies Record<string, Credential>;
|
||||
|
||||
export function missingCredentialsError(missing: Credential[]): UserError {
|
||||
const labels = missing.map(credential => credential.label).join(', ');
|
||||
const envVars = missing.map(credential => credential.envVar).join(', ');
|
||||
const inputs = missing.map(credential => credential.input).join(', ');
|
||||
return new UserError(
|
||||
'EAS_POSTHOG_MISSING_CREDENTIALS',
|
||||
`Missing PostHog credentials: ${labels}. Set the environment variables (${envVars}) or step inputs (${inputs}) on EAS, or re-run "eas integrations:posthog:connect" with error tracking enabled.`
|
||||
);
|
||||
}
|
||||
|
||||
export const DEFAULT_POLL_TIMEOUT_SECONDS = 600;
|
||||
export const DEFAULT_POLL_INTERVAL_SECONDS = 30;
|
||||
|
||||
export function assertPollBoundsPositive({
|
||||
timeoutSeconds,
|
||||
intervalSeconds,
|
||||
errorCode,
|
||||
}: {
|
||||
timeoutSeconds: number;
|
||||
intervalSeconds: number;
|
||||
errorCode: string;
|
||||
}): void {
|
||||
if (timeoutSeconds <= 0 || intervalSeconds <= 0) {
|
||||
throw new UserError(
|
||||
errorCode,
|
||||
'"timeout_seconds" and "interval_seconds" must be greater than 0.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForNextPollAsync({
|
||||
intervalSeconds,
|
||||
deadline,
|
||||
signal,
|
||||
}: {
|
||||
intervalSeconds: number;
|
||||
deadline: number;
|
||||
signal: AbortSignal | undefined;
|
||||
}): Promise<boolean> {
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
return false;
|
||||
}
|
||||
await setTimeoutAsync(Math.min(intervalSeconds * 1000, remainingMs), undefined, { signal });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function failOrLogError({
|
||||
logger,
|
||||
ignoreError,
|
||||
error,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
ignoreError: boolean;
|
||||
error: unknown;
|
||||
}): void {
|
||||
if (
|
||||
!ignoreError ||
|
||||
(error instanceof UserError && error.errorCode === 'EAS_POSTHOG_FORBIDDEN')
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn({ err: error }, `${message} Ignoring error.`);
|
||||
}
|
||||
|
||||
export async function readErrorAsync(response: Response): Promise<string> {
|
||||
const text = await response.text().catch(() => '');
|
||||
if (text) {
|
||||
try {
|
||||
const body = JSON.parse(text) as unknown;
|
||||
if (typeof body === 'object' && body !== null) {
|
||||
const { detail } = body as Record<string, unknown>;
|
||||
if (typeof detail === 'string' && detail !== '') {
|
||||
return `status ${response.status}: ${detail} (${text})`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return `status ${response.status}: ${text}`;
|
||||
}
|
||||
return `status ${response.status}: ${text}`;
|
||||
}
|
||||
return `status ${response.status}`;
|
||||
}
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
|
||||
import { PosthogClient, PosthogRetryableError } from '../PosthogClient';
|
||||
|
||||
jest.mock('@expo/logger');
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const logger = createLogger({ name: 'test' });
|
||||
|
||||
function res({
|
||||
ok = true,
|
||||
status = 200,
|
||||
json,
|
||||
text,
|
||||
}: {
|
||||
ok?: boolean;
|
||||
status?: number;
|
||||
json?: unknown;
|
||||
text?: string;
|
||||
}): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => json,
|
||||
text: async () => text ?? (json !== undefined ? JSON.stringify(json) : ''),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
const API_ENV = {
|
||||
POSTHOG_CLI_API_KEY: 'phx_test',
|
||||
POSTHOG_CLI_PROJECT_ID: '123',
|
||||
POSTHOG_CLI_HOST: 'https://us.posthog.com',
|
||||
};
|
||||
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
describe('PosthogClient.fromEnv', () => {
|
||||
it('builds a client from env vars', () => {
|
||||
expect(
|
||||
PosthogClient.fromEnv({
|
||||
apiKeyOverride: undefined,
|
||||
projectIdOverride: undefined,
|
||||
env: API_ENV,
|
||||
}).client
|
||||
).toBeInstanceOf(PosthogClient);
|
||||
});
|
||||
|
||||
it('prefers overrides over env', async () => {
|
||||
fetchMock.mockResolvedValue(res({ json: { results: [[1]] } }));
|
||||
const { client } = PosthogClient.fromEnv({
|
||||
apiKeyOverride: 'phx_override',
|
||||
projectIdOverride: '999',
|
||||
env: {},
|
||||
});
|
||||
await client?.runQueryAsync('select 1', logger, undefined);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://us.posthog.com/api/projects/999/query/');
|
||||
expect((init?.headers as Record<string, string>).Authorization).toBe('Bearer phx_override');
|
||||
});
|
||||
|
||||
it('exposes host and CLI env for subprocess uploads', () => {
|
||||
const { client } = PosthogClient.fromEnv({
|
||||
apiKeyOverride: 'phx_k',
|
||||
projectIdOverride: '55',
|
||||
env: { POSTHOG_CLI_HOST: 'https://eu.posthog.com' },
|
||||
});
|
||||
expect(client?.cliConfig()).toEqual({
|
||||
host: 'https://eu.posthog.com',
|
||||
env: { POSTHOG_CLI_API_KEY: 'phx_k', POSTHOG_CLI_PROJECT_ID: '55' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports both missing credentials when neither is provided', () => {
|
||||
const result = PosthogClient.fromEnv({
|
||||
apiKeyOverride: undefined,
|
||||
projectIdOverride: undefined,
|
||||
env: {},
|
||||
});
|
||||
expect(result.client).toBeUndefined();
|
||||
expect(result).toMatchObject({
|
||||
missing: [{ label: 'personal API key' }, { label: 'project id' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('reports only the missing project id when the key is present', () => {
|
||||
const result = PosthogClient.fromEnv({
|
||||
apiKeyOverride: 'phx',
|
||||
projectIdOverride: undefined,
|
||||
env: {},
|
||||
});
|
||||
expect(result.client).toBeUndefined();
|
||||
expect(result).toMatchObject({ missing: [{ label: 'project id' }] });
|
||||
});
|
||||
|
||||
it('reports only the missing api key when the project id is present', () => {
|
||||
const result = PosthogClient.fromEnv({
|
||||
apiKeyOverride: undefined,
|
||||
projectIdOverride: '1',
|
||||
env: {},
|
||||
});
|
||||
expect(result.client).toBeUndefined();
|
||||
expect(result).toMatchObject({ missing: [{ label: 'personal API key' }] });
|
||||
});
|
||||
|
||||
it('falls back to the default host', async () => {
|
||||
fetchMock.mockResolvedValue(res({ json: { results: [[1]] } }));
|
||||
const { client } = PosthogClient.fromEnv({
|
||||
apiKeyOverride: 'phx',
|
||||
projectIdOverride: '1',
|
||||
env: {},
|
||||
});
|
||||
await client?.runQueryAsync('select 1', logger, undefined);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://us.posthog.com/api/projects/1/query/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PosthogClient.forIngestion', () => {
|
||||
it('builds a client and normalizes a trailing-slash host', async () => {
|
||||
fetchMock.mockResolvedValue(res({ ok: true, status: 200 }));
|
||||
const client = PosthogClient.forIngestion({
|
||||
apiKeyOverride: 'phc_x',
|
||||
hostOverride: 'https://eu.posthog.com//',
|
||||
env: {},
|
||||
});
|
||||
await client?.captureEventAsync({
|
||||
event: 'e',
|
||||
distinctId: 'u',
|
||||
properties: undefined,
|
||||
signal: undefined,
|
||||
});
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://eu.posthog.com/i/v0/e/');
|
||||
});
|
||||
|
||||
it('falls back to a non-public POSTHOG_API_KEY', () => {
|
||||
expect(
|
||||
PosthogClient.forIngestion({
|
||||
apiKeyOverride: undefined,
|
||||
hostOverride: undefined,
|
||||
env: { POSTHOG_API_KEY: 'phc_secret' },
|
||||
})
|
||||
).toBeInstanceOf(PosthogClient);
|
||||
});
|
||||
|
||||
it('returns undefined when no key is available', () => {
|
||||
expect(
|
||||
PosthogClient.forIngestion({ apiKeyOverride: undefined, hostOverride: undefined, env: {} })
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('captureEventAsync', () => {
|
||||
const client = PosthogClient.forIngestion({
|
||||
apiKeyOverride: 'phc_x',
|
||||
hostOverride: undefined,
|
||||
env: {},
|
||||
});
|
||||
|
||||
it('sends an anonymous system event when no distinct_id is given', async () => {
|
||||
fetchMock.mockResolvedValue(res({ ok: true, status: 200 }));
|
||||
await client?.captureEventAsync({
|
||||
event: 'e',
|
||||
distinctId: undefined,
|
||||
properties: { a: 1 },
|
||||
signal: undefined,
|
||||
});
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
|
||||
expect(body.distinct_id).toBe('eas-workflow');
|
||||
expect(body.properties).toEqual({ a: 1, $process_person_profile: false });
|
||||
});
|
||||
|
||||
it('throws a UserError on a non-2xx response', async () => {
|
||||
fetchMock.mockResolvedValue(res({ ok: false, status: 400, text: 'bad' }));
|
||||
await expect(
|
||||
client?.captureEventAsync({
|
||||
event: 'e',
|
||||
distinctId: 'u',
|
||||
properties: undefined,
|
||||
signal: undefined,
|
||||
})
|
||||
).rejects.toThrow(/failed with status 400: bad/);
|
||||
});
|
||||
|
||||
it('throws a UserError on a network error', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
|
||||
await expect(
|
||||
client?.captureEventAsync({
|
||||
event: 'e',
|
||||
distinctId: 'u',
|
||||
properties: undefined,
|
||||
signal: undefined,
|
||||
})
|
||||
).rejects.toThrow(/Sending PostHog event "e" failed\.$/);
|
||||
});
|
||||
|
||||
it('passes the signal and propagates without wrapping when aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
fetchMock.mockRejectedValue(new Error('The operation was aborted'));
|
||||
await expect(
|
||||
client?.captureEventAsync({
|
||||
event: 'e',
|
||||
distinctId: 'u',
|
||||
properties: undefined,
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow('The operation was aborted');
|
||||
expect(fetchMock.mock.calls[0][1]?.signal).toBe(controller.signal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestAsync', () => {
|
||||
const { client } = PosthogClient.fromEnv({
|
||||
apiKeyOverride: undefined,
|
||||
projectIdOverride: undefined,
|
||||
env: API_ENV,
|
||||
});
|
||||
|
||||
it('sends an authenticated request and returns the response', async () => {
|
||||
fetchMock.mockResolvedValue(res({ ok: true, status: 200, json: { id: 1 } }));
|
||||
const response = await client?.requestAsync('PATCH', '/feature_flags/1/', {
|
||||
action: 'Updating flag',
|
||||
forbiddenScope: 'feature_flag:write',
|
||||
body: { active: true },
|
||||
signal: undefined,
|
||||
});
|
||||
expect(response?.ok).toBe(true);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://us.posthog.com/api/projects/123/feature_flags/1/');
|
||||
expect(init?.method).toBe('PATCH');
|
||||
expect(JSON.parse(init?.body as string)).toEqual({ active: true });
|
||||
});
|
||||
|
||||
it('throws a forbidden UserError with the named scope on 403', async () => {
|
||||
fetchMock.mockResolvedValue(res({ ok: false, status: 403 }));
|
||||
await expect(
|
||||
client?.requestAsync('GET', '/feature_flags/', {
|
||||
action: 'Looking up flag',
|
||||
forbiddenScope: 'feature_flag:read',
|
||||
signal: undefined,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_POSTHOG_FORBIDDEN',
|
||||
message: expect.stringContaining('"feature_flag:read"'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws with the error detail on a non-2xx response', async () => {
|
||||
fetchMock.mockResolvedValue(res({ ok: false, status: 400, json: { detail: 'nope' } }));
|
||||
await expect(
|
||||
client?.requestAsync('GET', '/feature_flags/', {
|
||||
action: 'Looking up flag',
|
||||
forbiddenScope: 'feature_flag:read',
|
||||
signal: undefined,
|
||||
})
|
||||
).rejects.toThrow(/Looking up flag failed with status 400: nope/);
|
||||
});
|
||||
|
||||
it('throws on a network error', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
|
||||
await expect(
|
||||
client?.requestAsync('GET', '/feature_flags/', {
|
||||
action: 'Looking up flag',
|
||||
forbiddenScope: 'feature_flag:read',
|
||||
signal: undefined,
|
||||
})
|
||||
).rejects.toThrow(/Looking up flag failed\.$/);
|
||||
});
|
||||
|
||||
it('passes the signal and propagates without wrapping when aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
fetchMock.mockRejectedValue(new Error('The operation was aborted'));
|
||||
await expect(
|
||||
client?.requestAsync('GET', '/feature_flags/', {
|
||||
action: 'Looking up flag',
|
||||
forbiddenScope: 'feature_flag:read',
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow('The operation was aborted');
|
||||
expect(fetchMock.mock.calls[0][1]?.signal).toBe(controller.signal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runQueryAsync', () => {
|
||||
const { client } = PosthogClient.fromEnv({
|
||||
apiKeyOverride: undefined,
|
||||
projectIdOverride: undefined,
|
||||
env: API_ENV,
|
||||
});
|
||||
|
||||
it('returns the top-left cell', async () => {
|
||||
fetchMock.mockResolvedValue(res({ json: { results: [[42]] } }));
|
||||
await expect(client?.runQueryAsync('select 1', logger, undefined)).resolves.toBe(42);
|
||||
});
|
||||
|
||||
it('throws a retryable error on a network error', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
|
||||
await expect(client?.runQueryAsync('q', logger, undefined)).rejects.toBeInstanceOf(
|
||||
PosthogRetryableError
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the signal to fetch and propagates without retrying when aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
fetchMock.mockRejectedValue(new Error('The operation was aborted'));
|
||||
await expect(client?.runQueryAsync('q', logger, controller.signal)).rejects.toThrow(
|
||||
'The operation was aborted'
|
||||
);
|
||||
expect(fetchMock.mock.calls[0][1]?.signal).toBe(controller.signal);
|
||||
});
|
||||
|
||||
it('throws a retryable error on 5xx and 429', async () => {
|
||||
fetchMock.mockResolvedValueOnce(res({ ok: false, status: 500 }));
|
||||
await expect(client?.runQueryAsync('q', logger, undefined)).rejects.toBeInstanceOf(
|
||||
PosthogRetryableError
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(res({ ok: false, status: 429 }));
|
||||
await expect(client?.runQueryAsync('q', logger, undefined)).rejects.toBeInstanceOf(
|
||||
PosthogRetryableError
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a retryable error on an invalid JSON body', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new Error('bad json');
|
||||
},
|
||||
} as unknown as Response);
|
||||
await expect(client?.runQueryAsync('q', logger, undefined)).rejects.toBeInstanceOf(
|
||||
PosthogRetryableError
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for a malformed result shape', async () => {
|
||||
fetchMock.mockResolvedValue(res({ json: { results: 'nope' } }));
|
||||
await expect(client?.runQueryAsync('q', logger, undefined)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws a forbidden UserError on 403', async () => {
|
||||
fetchMock.mockResolvedValue(res({ ok: false, status: 403 }));
|
||||
await expect(client?.runQueryAsync('q', logger, undefined)).rejects.toMatchObject({
|
||||
errorCode: 'EAS_POSTHOG_FORBIDDEN',
|
||||
message: expect.stringContaining('"query:read"'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws with the error detail on a non-2xx response', async () => {
|
||||
fetchMock.mockResolvedValue(res({ ok: false, status: 400, text: 'Malformed HogQL' }));
|
||||
await expect(client?.runQueryAsync('q', logger, undefined)).rejects.toThrow(
|
||||
/failed with status 400: Malformed HogQL/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('is a UserError subclass so the step surfaces it to the user', async () => {
|
||||
const { client } = PosthogClient.fromEnv({
|
||||
apiKeyOverride: undefined,
|
||||
projectIdOverride: undefined,
|
||||
env: API_ENV,
|
||||
});
|
||||
fetchMock.mockResolvedValue(res({ ok: false, status: 403 }));
|
||||
await expect(client?.runQueryAsync('q', logger, undefined)).rejects.toBeInstanceOf(UserError);
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan, createLogger } from '@expo/logger';
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
import { PosthogUtils } from '../PosthogUtils';
|
||||
|
||||
jest.mock('@expo/logger');
|
||||
|
||||
const logger = createLogger({ name: 'test' }) as bunyan;
|
||||
|
||||
function errorResponse({ status, text }: { status: number; text?: string }): Response {
|
||||
return { status, text: async () => text ?? '' } as unknown as Response;
|
||||
}
|
||||
|
||||
describe(PosthogUtils.readErrorAsync, () => {
|
||||
it('surfaces a JSON detail followed by the raw body', async () => {
|
||||
await expect(
|
||||
PosthogUtils.readErrorAsync(errorResponse({ status: 400, text: '{"detail":"boom"}' }))
|
||||
).resolves.toBe('status 400: boom ({"detail":"boom"})');
|
||||
});
|
||||
|
||||
it('falls back to the raw body for detail-less JSON', async () => {
|
||||
await expect(
|
||||
PosthogUtils.readErrorAsync(errorResponse({ status: 400, text: '{"other":1}' }))
|
||||
).resolves.toBe('status 400: {"other":1}');
|
||||
});
|
||||
|
||||
it('ignores an empty-string detail', async () => {
|
||||
await expect(
|
||||
PosthogUtils.readErrorAsync(errorResponse({ status: 400, text: '{"detail":""}' }))
|
||||
).resolves.toBe('status 400: {"detail":""}');
|
||||
});
|
||||
|
||||
it('falls back to the raw text for a non-JSON body', async () => {
|
||||
await expect(
|
||||
PosthogUtils.readErrorAsync(errorResponse({ status: 502, text: 'Bad Gateway' }))
|
||||
).resolves.toBe('status 502: Bad Gateway');
|
||||
});
|
||||
|
||||
it('uses just the status when the body is empty', async () => {
|
||||
await expect(
|
||||
PosthogUtils.readErrorAsync(errorResponse({ status: 500, text: '' }))
|
||||
).resolves.toBe('status 500');
|
||||
});
|
||||
|
||||
it('uses just the status when reading the body throws', async () => {
|
||||
const response = {
|
||||
status: 500,
|
||||
text: async () => {
|
||||
throw new Error('read fail');
|
||||
},
|
||||
} as unknown as Response;
|
||||
await expect(PosthogUtils.readErrorAsync(response)).resolves.toBe('status 500');
|
||||
});
|
||||
});
|
||||
|
||||
describe(PosthogUtils.missingCredentialsError, () => {
|
||||
it('names both missing credentials with their env vars and inputs', () => {
|
||||
expect(
|
||||
PosthogUtils.missingCredentialsError([
|
||||
PosthogUtils.API_CREDENTIALS.apiKey,
|
||||
PosthogUtils.API_CREDENTIALS.projectId,
|
||||
])
|
||||
).toMatchObject({
|
||||
errorCode: 'EAS_POSTHOG_MISSING_CREDENTIALS',
|
||||
message:
|
||||
'Missing PostHog credentials: personal API key, project id. Set the environment variables (POSTHOG_CLI_API_KEY, POSTHOG_CLI_PROJECT_ID) or step inputs (api_key, project_id) on EAS, or re-run "eas integrations:posthog:connect" with error tracking enabled.',
|
||||
});
|
||||
});
|
||||
|
||||
it('names only the missing credential', () => {
|
||||
expect(
|
||||
PosthogUtils.missingCredentialsError([PosthogUtils.API_CREDENTIALS.projectId]).message
|
||||
).toBe(
|
||||
'Missing PostHog credentials: project id. Set the environment variables (POSTHOG_CLI_PROJECT_ID) or step inputs (project_id) on EAS, or re-run "eas integrations:posthog:connect" with error tracking enabled.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(PosthogUtils.assertPollBoundsPositive, () => {
|
||||
it('throws with the given error code when a bound is not positive', () => {
|
||||
expect(() =>
|
||||
PosthogUtils.assertPollBoundsPositive({
|
||||
timeoutSeconds: 0,
|
||||
intervalSeconds: 30,
|
||||
errorCode: 'EAS_POSTHOG_TEST_INTERVAL',
|
||||
})
|
||||
).toThrow(/greater than 0/);
|
||||
expect(() =>
|
||||
PosthogUtils.assertPollBoundsPositive({
|
||||
timeoutSeconds: 600,
|
||||
intervalSeconds: -1,
|
||||
errorCode: 'EAS_POSTHOG_TEST_INTERVAL',
|
||||
})
|
||||
).toThrow(/greater than 0/);
|
||||
});
|
||||
|
||||
it('does nothing when both bounds are positive', () => {
|
||||
expect(() =>
|
||||
PosthogUtils.assertPollBoundsPositive({
|
||||
timeoutSeconds: 600,
|
||||
intervalSeconds: 30,
|
||||
errorCode: 'EAS_POSTHOG_TEST_INTERVAL',
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe(PosthogUtils.waitForNextPollAsync, () => {
|
||||
it('returns false without sleeping when the deadline has passed', async () => {
|
||||
await expect(
|
||||
PosthogUtils.waitForNextPollAsync({
|
||||
intervalSeconds: 30,
|
||||
deadline: Date.now() - 1,
|
||||
signal: undefined,
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('sleeps for the clamped interval and returns true when time remains', async () => {
|
||||
await expect(
|
||||
PosthogUtils.waitForNextPollAsync({
|
||||
intervalSeconds: 0.01,
|
||||
deadline: Date.now() + 1000,
|
||||
signal: undefined,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when the signal aborts during the sleep', async () => {
|
||||
const controller = new AbortController();
|
||||
const promise = PosthogUtils.waitForNextPollAsync({
|
||||
intervalSeconds: 30,
|
||||
deadline: Date.now() + 60_000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
await expect(promise).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe(PosthogUtils.failOrLogError, () => {
|
||||
it('throws the error when ignoreError is false', () => {
|
||||
const error = new Error('boom');
|
||||
expect(() => PosthogUtils.failOrLogError({ logger, ignoreError: false, error })).toThrow(
|
||||
'boom'
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and swallows the error when ignoreError is true', () => {
|
||||
const warnMock = jest.spyOn(logger, 'warn');
|
||||
const error = new Error('boom');
|
||||
|
||||
PosthogUtils.failOrLogError({ logger, ignoreError: true, error });
|
||||
|
||||
expect(warnMock).toHaveBeenCalledWith({ err: error }, 'boom Ignoring error.');
|
||||
});
|
||||
|
||||
it('rethrows a forbidden error even when ignoreError is true', () => {
|
||||
const error = new UserError('EAS_POSTHOG_FORBIDDEN', 'no scope');
|
||||
expect(() => PosthogUtils.failOrLogError({ logger, ignoreError: true, error })).toThrow(
|
||||
'no scope'
|
||||
);
|
||||
});
|
||||
|
||||
it('stringifies a non-Error value when swallowing', () => {
|
||||
const warnMock = jest.spyOn(logger, 'warn');
|
||||
|
||||
PosthogUtils.failOrLogError({ logger, ignoreError: true, error: 'plain string' });
|
||||
|
||||
expect(warnMock).toHaveBeenCalledWith({ err: 'plain string' }, 'plain string Ignoring error.');
|
||||
});
|
||||
});
|
||||
@@ -49,22 +49,12 @@ describe(listAgentDeviceArtifactsAsync, () => {
|
||||
artifacts: [
|
||||
{
|
||||
id: 'artifact-id',
|
||||
artifactType: 'agent-device-test-report',
|
||||
filename: 'report.json',
|
||||
mimeType: 'application/json',
|
||||
sizeBytes: 123,
|
||||
createdAt: '2026-07-02T12:00:00.000Z',
|
||||
expiresAt: '2026-07-02T12:15:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'untyped-artifact-id',
|
||||
artifactType: null,
|
||||
filename: 'untyped-report.json',
|
||||
mimeType: 'application/json',
|
||||
sizeBytes: 456,
|
||||
createdAt: '2026-07-02T12:00:00.000Z',
|
||||
expiresAt: '2026-07-02T12:15:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
@@ -78,14 +68,8 @@ describe(listAgentDeviceArtifactsAsync, () => {
|
||||
expect(artifacts).toEqual([
|
||||
{
|
||||
id: 'artifact-id',
|
||||
artifactType: 'agent-device-test-report',
|
||||
filename: 'report.json',
|
||||
},
|
||||
{
|
||||
id: 'untyped-artifact-id',
|
||||
artifactType: null,
|
||||
filename: 'untyped-report.json',
|
||||
},
|
||||
]);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts', {
|
||||
headers: { Authorization: 'Bearer daemon-token' },
|
||||
@@ -129,7 +113,6 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
logger,
|
||||
artifact: {
|
||||
id: 'artifact-id',
|
||||
artifactType: 'agent-device-test-report',
|
||||
filename: 'report.json',
|
||||
},
|
||||
});
|
||||
@@ -142,43 +125,10 @@ describe(uploadAgentDeviceArtifactAsync, () => {
|
||||
artifactId: 'artifact-id',
|
||||
name: 'report.json (artifact-id)',
|
||||
filename: 'report.json',
|
||||
kind: 'agent-device-test-report',
|
||||
size: data.length,
|
||||
stream: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads null artifact types as an explicit undefined kind', async () => {
|
||||
const data = Buffer.from('artifact-data');
|
||||
const logger = createLoggerMock();
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
});
|
||||
|
||||
await uploadAgentDeviceArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
daemonUrl: 'http://127.0.0.1:1234',
|
||||
daemonToken: 'daemon-token',
|
||||
logger,
|
||||
artifact: {
|
||||
id: 'artifact-id',
|
||||
artifactType: null,
|
||||
filename: 'report.json',
|
||||
},
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
kind: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(pollAgentDeviceArtifactsForUploadAsync, () => {
|
||||
|
||||
@@ -4,14 +4,9 @@ import { Readable } from 'node:stream';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import {
|
||||
listArgentArtifactsAsync,
|
||||
pollArgentArtifactsForUploadAsync,
|
||||
uploadArgentArtifactAsync,
|
||||
} from '../argentArtifacts';
|
||||
import { listArgentArtifactsAsync, uploadArgentArtifactAsync } from '../argentArtifacts';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
@@ -22,21 +17,6 @@ async function readStreamAsync(stream: NodeJS.ReadableStream): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAssertionAsync(assertion: () => void): Promise<void> {
|
||||
const deadline = Date.now() + 1_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return {
|
||||
info: jest.fn(),
|
||||
@@ -82,7 +62,6 @@ describe(listArgentArtifactsAsync, () => {
|
||||
]);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts', {
|
||||
headers: { Authorization: 'Bearer tools-token' },
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -119,7 +98,6 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts/artifact-id', {
|
||||
headers: { Authorization: 'Bearer tools-token' },
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
@@ -131,150 +109,3 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(pollArgentArtifactsForUploadAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset();
|
||||
});
|
||||
|
||||
it('stops polling when aborted, performs a final drain, and waits for uploads', async () => {
|
||||
const logger = createLoggerMock();
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const abortController = new AbortController();
|
||||
let listCallCount = 0;
|
||||
|
||||
jest.mocked(fetch).mockImplementation(async url => {
|
||||
const urlString = String(url);
|
||||
if (urlString.endsWith('/artifacts')) {
|
||||
listCallCount += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
artifacts:
|
||||
listCallCount === 1
|
||||
? [
|
||||
{
|
||||
id: 'artifact-a',
|
||||
filename: 'a.json',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: 'artifact-a',
|
||||
filename: 'a.json',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
id: 'artifact-b',
|
||||
filename: 'b.json',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
if (urlString.endsWith('/artifacts/artifact-a')) {
|
||||
return new Response(Readable.from([Buffer.from('artifact-a-data')]));
|
||||
}
|
||||
if (urlString.endsWith('/artifacts/artifact-b')) {
|
||||
return new Response(Readable.from([Buffer.from('artifact-b-data')]));
|
||||
}
|
||||
throw new Error(`Unexpected URL ${urlString}`);
|
||||
});
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
});
|
||||
|
||||
const pollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
toolsUrl: 'http://127.0.0.1:1234',
|
||||
toolsAuthToken: 'tools-token',
|
||||
logger,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
await waitForAssertionAsync(() => {
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
abortController.abort();
|
||||
await pollingPromise;
|
||||
|
||||
expect(listCallCount).toBe(2);
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenLastCalledWith(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'artifact-b',
|
||||
name: 'b.json (artifact-b)',
|
||||
filename: 'b.json',
|
||||
size: 'artifact-b-data'.length,
|
||||
stream: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when pending uploads do not finish before the cleanup timeout', async () => {
|
||||
const logger = createLoggerMock();
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const abortController = new AbortController();
|
||||
const originalSetTimeout = global.setTimeout;
|
||||
const setTimeoutSpy = jest
|
||||
.spyOn(global, 'setTimeout')
|
||||
.mockImplementation(((
|
||||
callback: Parameters<typeof setTimeout>[0],
|
||||
timeout?: number,
|
||||
...args: unknown[]
|
||||
) =>
|
||||
originalSetTimeout(
|
||||
callback,
|
||||
timeout === 30_000 ? 0 : timeout,
|
||||
...args
|
||||
)) as typeof setTimeout);
|
||||
|
||||
try {
|
||||
jest.mocked(fetch).mockImplementation(async url => {
|
||||
const urlString = String(url);
|
||||
if (urlString.endsWith('/artifacts')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
artifacts: [
|
||||
{
|
||||
id: 'artifact-a',
|
||||
filename: 'a.json',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
if (urlString.endsWith('/artifacts/artifact-a')) {
|
||||
return new Response(Readable.from([Buffer.from('artifact-a-data')]));
|
||||
}
|
||||
throw new Error(`Unexpected URL ${urlString}`);
|
||||
});
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(() => new Promise<void>(() => {}));
|
||||
|
||||
const pollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
toolsUrl: 'http://127.0.0.1:1234',
|
||||
toolsAuthToken: 'tools-token',
|
||||
logger,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
await waitForAssertionAsync(() => {
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
abortController.abort();
|
||||
|
||||
await expect(pollingPromise).rejects.toThrow(
|
||||
'Timed out after 30s waiting for Argent artifact uploads.'
|
||||
);
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -46,8 +46,6 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
artifactId: 'artifact-id',
|
||||
name: 'Artifact report.json (artifact-id)',
|
||||
filename: 'report.json',
|
||||
kind: 'agent-device-test-report',
|
||||
metadata: { firstFrameRecordAt: 'test-time' },
|
||||
size: reportedSize,
|
||||
stream,
|
||||
});
|
||||
@@ -59,8 +57,6 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
input: {
|
||||
name: 'Artifact report.json (artifact-id)',
|
||||
filename: 'report.json',
|
||||
kind: 'agent-device-test-report',
|
||||
metadata: { firstFrameRecordAt: 'test-time' },
|
||||
size: reportedSize,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
@@ -8,10 +7,8 @@ import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
fetchServeSimTurnArgsAsync,
|
||||
turnIceServersToServeSimArgs,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('node:timers/promises');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
@@ -35,47 +32,6 @@ function createCtxMock(): CustomBuildContext {
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function createStatusCtxMock(
|
||||
results: (
|
||||
| { status: 'NEW' | 'IN_PROGRESS' | 'STOPPED' | 'ERRORED' }
|
||||
| { error: Error }
|
||||
| { data: unknown }
|
||||
)[]
|
||||
): CustomBuildContext {
|
||||
const query = jest.fn(() => {
|
||||
const result = results.shift();
|
||||
if (!result) {
|
||||
throw new Error('No mocked status result available');
|
||||
}
|
||||
return {
|
||||
toPromise: async () => {
|
||||
if ('error' in result) {
|
||||
throw result.error;
|
||||
}
|
||||
if ('data' in result) {
|
||||
return { data: result.data };
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
deviceRunSessions: {
|
||||
byId: {
|
||||
id: 'drs-id',
|
||||
status: result.status,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
graphqlClient: {
|
||||
query,
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function createEnvMock(): BuildStepEnv {
|
||||
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
|
||||
}
|
||||
@@ -196,86 +152,3 @@ describe(fetchServeSimTurnArgsAsync, () => {
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(Sentry).capture.mockReset();
|
||||
jest.mocked(setTimeoutAsync).mockReset();
|
||||
jest.mocked(setTimeoutAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('continues polling until the device run session is stopped', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }, { status: 'STOPPED' }]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
|
||||
});
|
||||
|
||||
it('throws when the device run session errors', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'ERRORED' }]);
|
||||
|
||||
await expect(
|
||||
waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
})
|
||||
).rejects.toThrow('Device run session drs-id errored.');
|
||||
});
|
||||
|
||||
it('logs and retries transient polling errors', async () => {
|
||||
const ctx = createStatusCtxMock([{ error: new Error('network down') }, { status: 'STOPPED' }]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ failedStatusPollCount: 1 }),
|
||||
'Could not poll device run session status; will retry.'
|
||||
);
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalledWith(
|
||||
'Could not poll device run session status',
|
||||
expect.any(Error),
|
||||
{ level: 'warning' }
|
||||
);
|
||||
});
|
||||
|
||||
it('logs and retries when the status response is missing', async () => {
|
||||
const ctx = createStatusCtxMock([
|
||||
{ data: { deviceRunSessions: { byId: null } } },
|
||||
{ status: 'STOPPED' },
|
||||
]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ failedStatusPollCount: 1 }),
|
||||
'Could not poll device run session status; will retry.'
|
||||
);
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalledWith(
|
||||
'Could not poll device run session status',
|
||||
expect.objectContaining({
|
||||
message: 'Device run session drs-id status response was missing.',
|
||||
}),
|
||||
{ level: 'warning' }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,6 @@ const AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
const AgentDeviceArtifactSchema = z.object({
|
||||
id: z.string(),
|
||||
artifactType: z.string().nullish(),
|
||||
filename: z.string(),
|
||||
});
|
||||
const AgentDeviceArtifactsListResponseSchema = z.object({
|
||||
@@ -153,7 +152,6 @@ export async function uploadAgentDeviceArtifactAsync(
|
||||
artifactId: artifact.id,
|
||||
name: `${artifact.filename} (${artifact.id})`,
|
||||
filename: artifact.filename,
|
||||
kind: artifact.artifactType ?? undefined,
|
||||
size,
|
||||
stream: createReadStream(temporaryArtifactPath),
|
||||
});
|
||||
|
||||
@@ -6,17 +6,15 @@ import fetch from 'node-fetch';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
|
||||
const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
|
||||
const ARGENT_ARTIFACT_UPLOAD_CLEANUP_TIMEOUT_MS = 30_000;
|
||||
const ARGENT_ARTIFACT_FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
const ArgentArtifactSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -37,56 +35,39 @@ export async function pollArgentArtifactsForUploadAsync(
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
toolsUrl: string;
|
||||
toolsAuthToken?: string;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
): Promise<void> {
|
||||
): Promise<never> {
|
||||
logger.info('Started polling Argent tool-server for artifacts.');
|
||||
const seenArtifactIds = new Set<string>();
|
||||
const pendingUploads = new Set<Promise<void>>();
|
||||
let listArtifactsErrorCount = 0;
|
||||
|
||||
const queueArtifactUpload = (artifact: ArgentArtifact): void => {
|
||||
if (seenArtifactIds.has(artifact.id)) {
|
||||
return;
|
||||
}
|
||||
seenArtifactIds.add(artifact.id);
|
||||
const uploadPromise = uploadArgentArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
artifact,
|
||||
logger,
|
||||
}).catch(err => {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload Argent remote session artifact', error);
|
||||
logger.warn({ err: error }, 'Could not upload Argent remote session artifact.');
|
||||
});
|
||||
pendingUploads.add(uploadPromise);
|
||||
void uploadPromise.finally(() => {
|
||||
pendingUploads.delete(uploadPromise);
|
||||
});
|
||||
};
|
||||
|
||||
const listArtifactsForUploadAsync = async ({
|
||||
signal,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<ArgentArtifact[]> => {
|
||||
for (;;) {
|
||||
try {
|
||||
const artifacts = await listArgentArtifactsAsync({ toolsUrl, toolsAuthToken, signal });
|
||||
const artifacts = await listArgentArtifactsAsync({ toolsUrl, toolsAuthToken });
|
||||
listArtifactsErrorCount = 0;
|
||||
return artifacts;
|
||||
} catch (err) {
|
||||
if (signal?.aborted) {
|
||||
return [];
|
||||
for (const artifact of artifacts) {
|
||||
if (seenArtifactIds.has(artifact.id)) {
|
||||
continue;
|
||||
}
|
||||
seenArtifactIds.add(artifact.id);
|
||||
void uploadArgentArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
artifact,
|
||||
logger,
|
||||
}).catch(err => {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload Argent remote session artifact', error);
|
||||
logger.warn({ err: error }, 'Could not upload Argent remote session artifact.');
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
listArtifactsErrorCount += 1;
|
||||
if (listArtifactsErrorCount === 1 || listArtifactsErrorCount % 5 === 0) {
|
||||
@@ -96,47 +77,20 @@ export async function pollArgentArtifactsForUploadAsync(
|
||||
'Could not list Argent remote session artifacts.'
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const listAndQueueArtifactUploadsAsync = async ({
|
||||
signal,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<void> => {
|
||||
const artifacts = await listArtifactsForUploadAsync({ signal });
|
||||
for (const artifact of artifacts) {
|
||||
queueArtifactUpload(artifact);
|
||||
}
|
||||
};
|
||||
|
||||
while (!signal.aborted) {
|
||||
await listAndQueueArtifactUploadsAsync({ signal });
|
||||
await sleepUntilAbortedAsync(ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS, signal);
|
||||
await sleepAsync(ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
logger.info('Argent artifact polling stopped; draining remaining artifacts.');
|
||||
await listAndQueueArtifactUploadsAsync();
|
||||
await waitForPendingUploadsAsync({
|
||||
pendingUploads,
|
||||
timeoutMs: ARGENT_ARTIFACT_UPLOAD_CLEANUP_TIMEOUT_MS,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listArgentArtifactsAsync({
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
signal,
|
||||
}: {
|
||||
toolsUrl: string;
|
||||
toolsAuthToken?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ArgentArtifact[]> {
|
||||
const response = await fetch(new URL('/artifacts', toolsUrl).toString(), {
|
||||
headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
|
||||
signal: createTimeoutSignal({ signal, timeoutMs: ARGENT_ARTIFACT_FETCH_TIMEOUT_MS }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
@@ -184,7 +138,6 @@ export async function uploadArgentArtifactAsync(
|
||||
artifactId: artifact.id,
|
||||
name: `${filename} (${artifact.id})`,
|
||||
filename,
|
||||
kind: undefined,
|
||||
size,
|
||||
stream: createReadStream(temporaryArtifactPath),
|
||||
});
|
||||
@@ -206,10 +159,6 @@ async function downloadArgentArtifactToFileAsync({
|
||||
}): Promise<void> {
|
||||
const response = await fetch(new URL(`/artifacts/${artifact.id}`, toolsUrl).toString(), {
|
||||
headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
|
||||
signal: createTimeoutSignal({
|
||||
signal: undefined,
|
||||
timeoutMs: ARGENT_ARTIFACT_FETCH_TIMEOUT_MS,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
@@ -223,62 +172,3 @@ async function downloadArgentArtifactToFileAsync({
|
||||
}
|
||||
await pipeline(response.body, createWriteStream(destinationPath));
|
||||
}
|
||||
|
||||
async function sleepUntilAbortedAsync(timeoutMs: number, signal: AbortSignal): Promise<void> {
|
||||
try {
|
||||
await setTimeoutAsync(timeoutMs, undefined, { signal });
|
||||
} catch (err) {
|
||||
if (!signal.aborted) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createTimeoutSignal({
|
||||
signal,
|
||||
timeoutMs,
|
||||
}: {
|
||||
signal: AbortSignal | undefined;
|
||||
timeoutMs: number;
|
||||
}): AbortSignal {
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
}
|
||||
|
||||
async function waitForPendingUploadsAsync({
|
||||
pendingUploads,
|
||||
timeoutMs,
|
||||
logger,
|
||||
}: {
|
||||
pendingUploads: Set<Promise<void>>;
|
||||
timeoutMs: number;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
if (pendingUploads.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Waiting for ${pendingUploads.size} Argent artifact upload(s) to finish.`);
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
Promise.allSettled([...pendingUploads]),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(
|
||||
new SystemError(
|
||||
`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for Argent artifact uploads.`,
|
||||
{
|
||||
trackingCode: 'SIMULATOR_ARTIFACT_SLOW_UPLOAD',
|
||||
}
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,6 @@ export async function uploadDeviceRunSessionArtifactAsync(
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
kind,
|
||||
metadata,
|
||||
size,
|
||||
stream,
|
||||
}: {
|
||||
@@ -36,8 +34,6 @@ export async function uploadDeviceRunSessionArtifactAsync(
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
kind: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
size: number;
|
||||
stream: NodeJS.ReadableStream;
|
||||
}
|
||||
@@ -47,8 +43,6 @@ export async function uploadDeviceRunSessionArtifactAsync(
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
kind,
|
||||
metadata,
|
||||
size,
|
||||
});
|
||||
const response = await fetch(uploadSession.url, {
|
||||
@@ -71,16 +65,12 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
kind,
|
||||
metadata,
|
||||
size,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
kind: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
size: number;
|
||||
}
|
||||
) {
|
||||
@@ -90,8 +80,6 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
|
||||
input: {
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ import nullthrows from 'nullthrows';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
@@ -31,19 +30,6 @@ const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
}
|
||||
`);
|
||||
|
||||
const DEVICE_RUN_SESSION_STATUS_QUERY = graphql(`
|
||||
query DeviceRunSessionStatus($deviceRunSessionId: ID!) {
|
||||
deviceRunSessions {
|
||||
byId(deviceRunSessionId: $deviceRunSessionId) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
export function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string {
|
||||
const deviceRunSessionId = env.DEVICE_RUN_SESSION_ID;
|
||||
if (!deviceRunSessionId) {
|
||||
@@ -110,75 +96,6 @@ export async function selectXcodeDeveloperDirectoryAsync({
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
logger.info(
|
||||
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
|
||||
);
|
||||
let pollErrorCount = 0;
|
||||
|
||||
while (!signal?.aborted) {
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const status = result.data?.deviceRunSessions?.byId?.status;
|
||||
if (!status) {
|
||||
throw new Error(`Device run session ${deviceRunSessionId} status response was missing.`);
|
||||
}
|
||||
pollErrorCount = 0;
|
||||
if (status === 'STOPPED') {
|
||||
logger.info(`Device run session ${deviceRunSessionId} was stopped.`);
|
||||
return;
|
||||
}
|
||||
if (status === 'ERRORED') {
|
||||
throw new SystemError(`Device run session ${deviceRunSessionId} errored.`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SystemError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
pollErrorCount += 1;
|
||||
if (pollErrorCount === 1 || pollErrorCount % 5 === 0) {
|
||||
Sentry.capture('Could not poll device run session status', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error, failedStatusPollCount: pollErrorCount },
|
||||
'Could not poll device run session status; will retry.'
|
||||
);
|
||||
}
|
||||
}
|
||||
await sleepUntilAbortedAsync(DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS, signal);
|
||||
}
|
||||
}
|
||||
|
||||
async function sleepUntilAbortedAsync(
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<void> {
|
||||
try {
|
||||
await setTimeoutAsync(timeoutMs, undefined, signal ? { signal } : undefined);
|
||||
} catch (err) {
|
||||
if (!signal?.aborted) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TurnIceServersResponseSchema = z.object({
|
||||
data: z.object({
|
||||
iceServers: TurnIceServersSchema,
|
||||
@@ -293,8 +210,6 @@ export async function uploadRemoteSessionConfigAsync({
|
||||
}
|
||||
|
||||
export type DetachedProcessHandle = {
|
||||
/** PID of the directly spawned process, if the OS assigned one. */
|
||||
pid: number | undefined;
|
||||
getOutput: () => string;
|
||||
};
|
||||
|
||||
@@ -327,7 +242,7 @@ export function spawnDetached({
|
||||
promise.child.stdout?.on('data', appendChunk);
|
||||
promise.child.stderr?.on('data', appendChunk);
|
||||
|
||||
return { pid: promise.child.pid, getOutput: () => output };
|
||||
return { getOutput: () => output };
|
||||
}
|
||||
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
@@ -343,7 +258,7 @@ export async function startServeSimWithTunnelAsync(
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
}
|
||||
): Promise<{ previewUrl: string }> {
|
||||
): Promise<{ previewUrl: string; streamUrl: string }> {
|
||||
logger.info('Launching serve-sim with tunnel.');
|
||||
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
|
||||
const serveSim = spawnDetached({
|
||||
@@ -366,30 +281,33 @@ export async function startServeSimWithTunnelAsync(
|
||||
env,
|
||||
});
|
||||
|
||||
logger.info('Waiting for serve-sim to report tunnel URL.');
|
||||
logger.info('Waiting for serve-sim to report tunnel and stream URLs.');
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const output = serveSim.getOutput();
|
||||
const previewUrl = matchTunnelUrl({ output, baseDomain });
|
||||
if (previewUrl) {
|
||||
return { previewUrl };
|
||||
const previewUrl = matchLabeledUrl({ output, label: 'Tunnel', baseDomain });
|
||||
const streamUrl = matchLabeledUrl({ output, label: 'Stream', baseDomain });
|
||||
if (previewUrl && streamUrl) {
|
||||
return { previewUrl, streamUrl };
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for serve-sim to report Tunnel URL. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
`Timed out waiting for serve-sim to report Tunnel and Stream URLs. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
function matchTunnelUrl({
|
||||
function matchLabeledUrl({
|
||||
output,
|
||||
label,
|
||||
baseDomain,
|
||||
}: {
|
||||
output: string;
|
||||
label: string;
|
||||
baseDomain: string;
|
||||
}): string | null {
|
||||
const labelPattern = new RegExp(
|
||||
`Tunnel:\\s*(https:\\/\\/[a-z0-9-]+\\.${escapeRegExp(baseDomain)})`
|
||||
`${label}:\\s*(https:\\/\\/[a-z0-9-]+\\.${escapeRegExp(baseDomain)})`
|
||||
);
|
||||
const match = labelPattern.exec(output);
|
||||
return match ? match[1] : null;
|
||||
|
||||
@@ -329,9 +329,6 @@ export namespace AndroidEmulatorUtils {
|
||||
await emulatorPromise;
|
||||
}
|
||||
emulatorPromise.child.unref();
|
||||
// The emulator is detached and managed through adb. Observe its process promise
|
||||
// immediately so expected exits during retry cleanup do not become unhandled rejections.
|
||||
void asyncResult(emulatorPromise);
|
||||
|
||||
const serialId = await retryAsync(
|
||||
async () => {
|
||||
|
||||
@@ -32,11 +32,7 @@ export namespace IosSimulatorUtils {
|
||||
lastBootedAt?: string;
|
||||
};
|
||||
|
||||
type SimulatorDevice = XcrunSimctlDevice & {
|
||||
runtime: string;
|
||||
runtimeDisplayName: string;
|
||||
displayName: string;
|
||||
};
|
||||
type SimulatorDevice = XcrunSimctlDevice & { runtime: string; displayName: string };
|
||||
|
||||
type XcrunSimctlListDevicesJsonOutput = {
|
||||
devices: {
|
||||
@@ -64,7 +60,6 @@ export namespace IosSimulatorUtils {
|
||||
...devices.map(device => ({
|
||||
...device,
|
||||
runtime,
|
||||
runtimeDisplayName: formatRuntimeDisplayName(runtime),
|
||||
displayName: `${device.name} (${device.udid}) on ${runtime}`,
|
||||
}))
|
||||
);
|
||||
@@ -231,32 +226,16 @@ export namespace IosSimulatorUtils {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
const launchctlDomains = ['user/foreground', 'system'];
|
||||
let lastError: unknown;
|
||||
|
||||
for (const domain of launchctlDomains) {
|
||||
const service = `${domain}/com.apple.apsd`;
|
||||
try {
|
||||
await spawn('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'disable', service], { env });
|
||||
|
||||
try {
|
||||
await spawn('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'bootout', service], {
|
||||
env,
|
||||
});
|
||||
} catch (err) {
|
||||
// bootout can fail when apsd is already gone; verify the service state below.
|
||||
lastError = err;
|
||||
}
|
||||
|
||||
if (!(await isLaunchctlServiceLoadedAsync({ udid, env, serviceLabel: 'com.apple.apsd' }))) {
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new SystemError('Unable to disable apsd in the Simulator.');
|
||||
await spawn(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', udid, 'launchctl', 'disable', 'system/com.apple.apsd'],
|
||||
{ env }
|
||||
);
|
||||
await spawn(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', udid, 'launchctl', 'bootout', 'system/com.apple.apsd'],
|
||||
{ env }
|
||||
);
|
||||
}
|
||||
|
||||
export async function collectLogsAsync({
|
||||
@@ -379,31 +358,6 @@ export namespace IosSimulatorUtils {
|
||||
}
|
||||
}
|
||||
|
||||
function formatRuntimeDisplayName(runtimeIdentifier: string): string {
|
||||
const match = /^com\.apple\.CoreSimulator\.SimRuntime\.([^-]+)-(.+)$/.exec(runtimeIdentifier);
|
||||
return match ? `${match[1]} ${match[2].replaceAll('-', '.')}` : runtimeIdentifier;
|
||||
}
|
||||
|
||||
async function isLaunchctlServiceLoadedAsync({
|
||||
udid,
|
||||
env,
|
||||
serviceLabel,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
serviceLabel: string;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
await spawn('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'list', serviceLabel], { env });
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && 'status' in err && (err as { status: unknown }).status === 113) {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function parseUdidFromBootstatusStdout(stdout: string): IosSimulatorUuid | null {
|
||||
const matches = stdout.match(/^Monitoring boot status for .+ \((.+)\)\.$/m);
|
||||
if (!matches) {
|
||||
|
||||
@@ -23,39 +23,6 @@ describe('IosSimulatorUtils', () => {
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.getAvailableDevicesAsync, () => {
|
||||
it('adds a human-readable runtime display name', async () => {
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
devices: {
|
||||
'com.apple.CoreSimulator.SimRuntime.iOS-18-6': [
|
||||
{
|
||||
dataPath: '/tmp/eas-test-simulator-data',
|
||||
dataPathSize: 1,
|
||||
logPath: '/tmp/eas-test-simulator-logs',
|
||||
udid: 'test-udid',
|
||||
isAvailable: true,
|
||||
deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-16',
|
||||
state: 'Booted',
|
||||
name: 'iPhone 16',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.getAvailableDevicesAsync({ env: process.env, filter: 'booted' })
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.startAsync, () => {
|
||||
it('waits for boot completion without writing accessibility prefs', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
@@ -211,75 +178,7 @@ describe('IosSimulatorUtils', () => {
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.disableApsdAsync, () => {
|
||||
it('disables and boots out apsd in the simulator foreground user domain', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args.join(' ') === 'simctl spawn test-udid launchctl list com.apple.apsd') {
|
||||
throw Object.assign(new Error('apsd not loaded'), { status: 113 });
|
||||
}
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}) as any);
|
||||
|
||||
await IosSimulatorUtils.disableApsdAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', 'test-udid', 'launchctl', 'disable', 'user/foreground/com.apple.apsd'],
|
||||
{ env: process.env }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', 'test-udid', 'launchctl', 'bootout', 'user/foreground/com.apple.apsd'],
|
||||
{ env: process.env }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', 'test-udid', 'launchctl', 'list', 'com.apple.apsd'],
|
||||
{ env: process.env }
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', 'test-udid', 'launchctl', 'disable', 'system/com.apple.apsd'],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
|
||||
it('succeeds when bootout fails after apsd is already stopped', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (
|
||||
args.join(' ') ===
|
||||
'simctl spawn test-udid launchctl bootout user/foreground/com.apple.apsd'
|
||||
) {
|
||||
throw Object.assign(new Error('bootout failed'), { status: 3 });
|
||||
}
|
||||
if (args.join(' ') === 'simctl spawn test-udid launchctl list com.apple.apsd') {
|
||||
throw Object.assign(new Error('apsd not loaded'), { status: 113 });
|
||||
}
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}) as any);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.disableApsdAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to the system domain when the foreground user domain keeps apsd running', async () => {
|
||||
let listCount = 0;
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args.join(' ') === 'simctl spawn test-udid launchctl list com.apple.apsd') {
|
||||
listCount += 1;
|
||||
if (listCount === 2) {
|
||||
throw Object.assign(new Error('apsd not loaded'), { status: 113 });
|
||||
}
|
||||
}
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}) as any);
|
||||
|
||||
it('disables and boots out apsd in the simulator', async () => {
|
||||
await IosSimulatorUtils.disableApsdAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
@@ -290,19 +189,11 @@ describe('IosSimulatorUtils', () => {
|
||||
['simctl', 'spawn', 'test-udid', 'launchctl', 'disable', 'system/com.apple.apsd'],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a SystemError when apsd is still running after all disable attempts', async () => {
|
||||
mockedSpawn.mockImplementation((async () => {
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}) as any);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.disableApsdAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).rejects.toThrow(SystemError);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', 'test-udid', 'launchctl', 'bootout', 'system/com.apple.apsd'],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Datadog } from '../../datadog';
|
||||
import { reportWorkflowHookMetricToDatadog } from '../hookMetrics';
|
||||
|
||||
describe(reportWorkflowHookMetricToDatadog, () => {
|
||||
const distributionSpy = jest.spyOn(Datadog, 'distribution').mockImplementation(() => {});
|
||||
|
||||
beforeEach(() => {
|
||||
distributionSpy.mockClear();
|
||||
});
|
||||
|
||||
it('emits one eas.workflow.hook distribution tagged by anchor/timing/result', () => {
|
||||
reportWorkflowHookMetricToDatadog({
|
||||
anchor: 'install_node_modules',
|
||||
timing: 'before',
|
||||
result: 'success',
|
||||
});
|
||||
expect(distributionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(distributionSpy).toHaveBeenCalledWith('eas.workflow.hook', 1, {
|
||||
anchor: 'install_node_modules',
|
||||
timing: 'before',
|
||||
result: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('adds the anchor_result tag on after events', () => {
|
||||
reportWorkflowHookMetricToDatadog({
|
||||
anchor: 'submit',
|
||||
timing: 'after',
|
||||
result: 'failed',
|
||||
anchorResult: 'failed',
|
||||
});
|
||||
expect(distributionSpy).toHaveBeenCalledWith('eas.workflow.hook', 1, {
|
||||
anchor: 'submit',
|
||||
timing: 'after',
|
||||
result: 'failed',
|
||||
anchor_result: 'failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { WorkflowHookMetric } from '@expo/steps';
|
||||
|
||||
import { Datadog } from '../datadog';
|
||||
|
||||
/**
|
||||
* The engine's provider callback is the ONLY `eas.workflow.hook` emitter —
|
||||
* never emit directly on top. The turtle-job-runs proxy forwards these tags
|
||||
* as-is; the turtle-builds proxy (custom builds) also adds the standard build
|
||||
* tags (image, sdk_version, build_mode, …), the same as every build metric.
|
||||
*/
|
||||
export function reportWorkflowHookMetricToDatadog(metric: WorkflowHookMetric): void {
|
||||
Datadog.distribution('eas.workflow.hook', 1, {
|
||||
anchor: metric.anchor,
|
||||
timing: metric.timing,
|
||||
result: metric.result,
|
||||
...(metric.anchorResult !== undefined ? { anchor_result: metric.anchorResult } : null),
|
||||
});
|
||||
}
|
||||
@@ -30,29 +30,3 @@ export async function getParentAndDescendantProcessPidsAsync(ppid: number): Prom
|
||||
}
|
||||
return [...children];
|
||||
}
|
||||
|
||||
export async function isProcessDescendantOfAsync(
|
||||
pid: number,
|
||||
ancestorPid: number
|
||||
): Promise<boolean> {
|
||||
let currentPid = pid;
|
||||
while (currentPid > 0) {
|
||||
if (currentPid === ancestorPid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await spawn('ps', ['-p', String(currentPid), '-o', 'ppid='], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const parentPid = Number(result.stdout.toString().trim());
|
||||
if (!Number.isInteger(parentPid) || parentPid <= 0 || parentPid === currentPid) {
|
||||
return false;
|
||||
}
|
||||
currentPid = parentPid;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "21.0.2",
|
||||
"version": "20.5.1",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "21.0.2"
|
||||
"@expo/steps": "20.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "21.0.0",
|
||||
"version": "20.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "21.0.0",
|
||||
"version": "20.0.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "21.0.2",
|
||||
"version": "20.5.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/logger": "20.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@expo/turtle-spawn": "20.0.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { HOOK_ANCHORS, parseHookKey } from '../hooks';
|
||||
|
||||
describe('HOOK_ANCHORS', () => {
|
||||
it('contains the v1 anchors', () => {
|
||||
expect([...HOOK_ANCHORS].sort()).toEqual([
|
||||
'checkout',
|
||||
'install_node_modules',
|
||||
'maestro_cloud',
|
||||
'maestro_tests',
|
||||
'submit',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseHookKey, () => {
|
||||
it('parses valid keys', () => {
|
||||
expect(parseHookKey('before_install_node_modules')).toEqual({
|
||||
side: 'before',
|
||||
anchorId: 'install_node_modules',
|
||||
});
|
||||
expect(parseHookKey('after_maestro_cloud')).toEqual({
|
||||
side: 'after',
|
||||
anchorId: 'maestro_cloud',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unknown anchors and malformed keys', () => {
|
||||
expect(parseHookKey('before_nonexistent')).toBeNull();
|
||||
expect(parseHookKey('install_node_modules')).toBeNull();
|
||||
expect(parseHookKey('')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not treat Object prototype property names as registered anchors', () => {
|
||||
expect(parseHookKey('before___proto__')).toBeNull();
|
||||
expect(parseHookKey('after_toString')).toBeNull();
|
||||
expect(parseHookKey('before_constructor')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -71,30 +71,6 @@ describe('StepZ', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook anchor stamps', () => {
|
||||
it('accepts the internal __hook_id stamp field on shell steps', () => {
|
||||
const steps = [{ run: 'echo submit', __hook_id: 'submit' }];
|
||||
expect(validateSteps(steps)).toEqual(steps);
|
||||
});
|
||||
|
||||
it('accepts and retains the __hook_id stamp on uses: steps', () => {
|
||||
const steps = [{ uses: 'eas/upload_to_asc', __hook_id: 'submit' }];
|
||||
expect(validateSteps(steps)).toEqual(steps);
|
||||
});
|
||||
|
||||
it('strips the removed split-pair stamp fields (legacy __hook_before_id/__hook_after_id)', () => {
|
||||
expect(
|
||||
validateSteps([
|
||||
{ run: 'echo x', __hook_before_id: 'maestro_cloud', __hook_after_id: 'maestro_cloud' },
|
||||
])
|
||||
).toEqual([{ run: 'echo x' }]);
|
||||
});
|
||||
|
||||
it('treats stamp values as loose strings so old workers never hard-fail on new anchors', () => {
|
||||
expect(() => validateSteps([{ run: 'echo x', __hook_id: 'some_future_anchor' }])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe(isStepShellStep, () => {
|
||||
it('returns true for shell step', () => {
|
||||
expect(isStepShellStep({ run: 'echo Hello, world!', shell: 'sh' })).toBe(true);
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Single source of hook anchor names.
|
||||
*
|
||||
* An anchor is a named moment in a job that `before_<anchor>` / `after_<anchor>`
|
||||
* hook keys attach to. Which job types expose which keys is declared on the EAS
|
||||
* servers (per-job key lists), not here: anchor existence (this registry)
|
||||
* ≠ binding (self-declared by the carrier) ≠ availability (per-job key lists).
|
||||
*
|
||||
* Binding is self-declared by the anchor's carrier: EAS-generated steps carry
|
||||
* an internal `__hook_id` stamp, and EAS build functions declare `__hookId` on
|
||||
* their definition (any step invoking a declaring function — directly, or
|
||||
* inside a function-group expansion — is an occurrence of the anchor). Several
|
||||
* steps and several functions may declare the same anchor.
|
||||
*/
|
||||
export const HOOK_ANCHORS = [
|
||||
'install_node_modules',
|
||||
'submit',
|
||||
'maestro_tests',
|
||||
'maestro_cloud',
|
||||
'checkout',
|
||||
] as const;
|
||||
|
||||
export type HookAnchorId = (typeof HOOK_ANCHORS)[number];
|
||||
export type HookKey = `before_${HookAnchorId}` | `after_${HookAnchorId}`;
|
||||
|
||||
export const HOOK_KEYS: readonly HookKey[] = HOOK_ANCHORS.flatMap((anchor): HookKey[] => [
|
||||
`before_${anchor}`,
|
||||
`after_${anchor}`,
|
||||
]);
|
||||
|
||||
export function isHookAnchorId(value: string): value is HookAnchorId {
|
||||
return HOOK_ANCHORS.some(anchor => anchor === value);
|
||||
}
|
||||
|
||||
export function parseHookKey(
|
||||
key: string
|
||||
): { side: 'before' | 'after'; anchorId: HookAnchorId } | null {
|
||||
const match = key.match(/^(before|after)_(.+)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const [, side, anchorId] = match;
|
||||
if (!isHookAnchorId(anchorId)) {
|
||||
return null;
|
||||
}
|
||||
return { side: side as 'before' | 'after', anchorId };
|
||||
}
|
||||
@@ -24,7 +24,6 @@ export * as errors from './errors';
|
||||
export * from './artifacts';
|
||||
export * from './context';
|
||||
export * from './generic';
|
||||
export * from './hooks';
|
||||
export * from './step';
|
||||
export * from './submission-config';
|
||||
export * from './projectPackage';
|
||||
|
||||
@@ -54,12 +54,6 @@ const CommonStepZ = z.object({
|
||||
// Internal field for metrics collection. Not documented publicly.
|
||||
// YAML uses snake_case, but we transform to camelCase for internal use.
|
||||
__metrics_id: z.string().optional(),
|
||||
// Internal hook-anchor stamp set by EAS servers on generated anchor steps,
|
||||
// `uses:` steps included (unstamped function steps bind through the invoked
|
||||
// function's own anchor declaration instead). Loose strings on purpose
|
||||
// (never enums): an older worker must treat unknown anchor names as inert
|
||||
// metadata instead of failing validation.
|
||||
__hook_id: z.string().optional(),
|
||||
});
|
||||
|
||||
export const FunctionStepZ = CommonStepZ.extend({
|
||||
|
||||
+180
-173
File diff suppressed because it is too large.
Load diff
Generated
+1130
-1014
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "21.0.2",
|
||||
"version": "20.5.1",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,23 +48,23 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "21.0.2",
|
||||
"@expo/eas-json": "21.0.2",
|
||||
"@expo/eas-build-job": "20.5.1",
|
||||
"@expo/eas-json": "20.5.1",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/logger": "20.0.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/pkcs12": "0.1.3",
|
||||
"@expo/plist": "0.3.5",
|
||||
"@expo/plist": "0.2.0",
|
||||
"@expo/plugin-help": "5.1.23",
|
||||
"@expo/plugin-warn-if-update-available": "2.5.1",
|
||||
"@expo/prebuild-config": "9.0.12",
|
||||
"@expo/prebuild-config": "8.0.17",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.0.2",
|
||||
"@expo/steps": "20.5.1",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -72,7 +72,6 @@
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "4.0.11",
|
||||
"@urql/exchange-retry": "1.2.0",
|
||||
"agent-cli-detector": "0.1.2",
|
||||
"ajv": "8.11.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"better-opn": "3.0.2",
|
||||
@@ -107,7 +106,7 @@
|
||||
"minizlib": "3.0.1",
|
||||
"nanoid": "3.3.8",
|
||||
"node-fetch": "2.6.7",
|
||||
"node-forge": "1.4.0",
|
||||
"node-forge": "1.3.1",
|
||||
"node-stream-zip": "1.15.0",
|
||||
"nullthrows": "1.1.1",
|
||||
"ora": "5.1.0",
|
||||
@@ -121,7 +120,7 @@
|
||||
"semver": "7.5.4",
|
||||
"set-interval-async": "3.0.3",
|
||||
"slash": "3.0.0",
|
||||
"tar": "7.5.19",
|
||||
"tar": "7.5.7",
|
||||
"tar-stream": "3.1.7",
|
||||
"terminal-link": "2.1.1",
|
||||
"ts-deepmerge": "6.2.0",
|
||||
@@ -134,16 +133,17 @@
|
||||
"zod": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "6.3.1",
|
||||
"@graphql-codegen/introspection": "5.0.2",
|
||||
"@graphql-codegen/typescript": "5.0.10",
|
||||
"@graphql-codegen/typescript-operations": "5.1.0",
|
||||
"@graphql-codegen/cli": "5.0.0",
|
||||
"@graphql-codegen/introspection": "4.0.0",
|
||||
"@graphql-codegen/typescript": "4.0.1",
|
||||
"@graphql-codegen/typescript-operations": "4.0.1",
|
||||
"@tsconfig/node20": "20.1.8",
|
||||
"@types/cli-progress": "3.11.5",
|
||||
"@types/dateformat": "3.0.1",
|
||||
"@types/debug": "4.1.13",
|
||||
"@types/diff": "6.0.0",
|
||||
"@types/envinfo": "7.8.3",
|
||||
"@types/express": "4.17.15",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/getenv": "^1.0.0",
|
||||
"@types/invariant": "^2.2.37",
|
||||
@@ -159,8 +159,9 @@
|
||||
"@types/tough-cookie": "4.0.2",
|
||||
"@types/uuid": "9.0.7",
|
||||
"@types/wrap-ansi": "3.0.0",
|
||||
"axios": "1.18.1",
|
||||
"axios": "1.11.0",
|
||||
"eslint-plugin-graphql": "4.0.0",
|
||||
"express": "4.20.0",
|
||||
"jest": "29.7.0",
|
||||
"memfs": "3.4.13",
|
||||
"mockdate": "3.0.5",
|
||||
|
||||
@@ -6,7 +6,6 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { Actor, getActorDisplayName } from '../user/User';
|
||||
import UserSettings from '../user/UserSettings';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
import { getAgentTelemetryContext } from './agent';
|
||||
|
||||
const PLATFORM_TO_ANALYTICS_PLATFORM: Partial<Record<NodeJS.Platform, string>> = {
|
||||
darwin: 'Mac',
|
||||
@@ -226,12 +225,10 @@ class RudderstackAnalytics implements AnalyticsWithOrchestration {
|
||||
|
||||
private getRudderStackContext(): Record<string, any> {
|
||||
const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform();
|
||||
const agent = getAgentTelemetryContext();
|
||||
return {
|
||||
os: { name: platform, version: os.release() },
|
||||
device: { type: platform, model: platform },
|
||||
app: { name: 'eas cli', version: easCliVersion ?? undefined },
|
||||
...(agent ? { agent } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import RudderAnalytics from '@expo/rudder-sdk-node';
|
||||
|
||||
import UserSettings from '../../user/UserSettings';
|
||||
import { CommandEvent, createAnalyticsAsync } from '../AnalyticsManager';
|
||||
import { getAgentTelemetryContext } from '../agent';
|
||||
|
||||
const mockIdentify = jest.fn();
|
||||
const mockTrack = jest.fn();
|
||||
const mockFlush = jest.fn();
|
||||
|
||||
jest.mock('@expo/rudder-sdk-node', () =>
|
||||
jest.fn().mockImplementation(() => ({
|
||||
identify: mockIdentify,
|
||||
track: mockTrack,
|
||||
flush: mockFlush,
|
||||
}))
|
||||
);
|
||||
|
||||
jest.mock('../../user/UserSettings', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
deleteKeyAsync: jest.fn(),
|
||||
getAsync: jest.fn(),
|
||||
setAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../agent', () => ({
|
||||
getAgentTelemetryContext: jest.fn(),
|
||||
}));
|
||||
|
||||
const getAgentTelemetryContextMock = jest.mocked(getAgentTelemetryContext);
|
||||
const userSettingsMock = jest.mocked(UserSettings);
|
||||
|
||||
const originalHttpsProxy = process.env.https_proxy;
|
||||
const originalDisableEasAnalytics = process.env.DISABLE_EAS_ANALYTICS;
|
||||
|
||||
beforeEach(() => {
|
||||
mockIdentify.mockClear();
|
||||
mockTrack.mockClear();
|
||||
mockFlush.mockClear();
|
||||
jest.mocked(RudderAnalytics).mockClear();
|
||||
getAgentTelemetryContextMock.mockReset();
|
||||
getAgentTelemetryContextMock.mockReturnValue(null);
|
||||
userSettingsMock.getAsync.mockImplementation(async (key, defaultValue) => {
|
||||
if (key === 'analyticsDeviceId') {
|
||||
return 'persistent-device-id';
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
});
|
||||
userSettingsMock.setAsync.mockResolvedValue({});
|
||||
userSettingsMock.deleteKeyAsync.mockResolvedValue({});
|
||||
delete process.env.https_proxy;
|
||||
delete process.env.DISABLE_EAS_ANALYTICS;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalHttpsProxy === undefined) {
|
||||
delete process.env.https_proxy;
|
||||
} else {
|
||||
process.env.https_proxy = originalHttpsProxy;
|
||||
}
|
||||
if (originalDisableEasAnalytics === undefined) {
|
||||
delete process.env.DISABLE_EAS_ANALYTICS;
|
||||
} else {
|
||||
process.env.DISABLE_EAS_ANALYTICS = originalDisableEasAnalytics;
|
||||
}
|
||||
});
|
||||
|
||||
it('omits agent context when no agent is detected', async () => {
|
||||
const analytics = await createAnalyticsAsync();
|
||||
|
||||
analytics.logEvent(CommandEvent.ACTION, { action: 'eas build' });
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.not.objectContaining({ agent: expect.anything() }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('adds detected agent context to analytics events', async () => {
|
||||
getAgentTelemetryContextMock.mockReturnValue({ id: 'codex', sessionId: 'zzz' });
|
||||
const analytics = await createAnalyticsAsync();
|
||||
|
||||
analytics.logEvent(CommandEvent.ACTION, { action: 'eas build' });
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
agent: {
|
||||
id: 'codex',
|
||||
sessionId: 'zzz',
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
const detectAgentMock = jest.fn();
|
||||
const logDebugMock = jest.fn();
|
||||
|
||||
jest.mock('agent-cli-detector', () => ({
|
||||
detectAgent: detectAgentMock,
|
||||
}));
|
||||
|
||||
jest.mock('../../log', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
debug: logDebugMock,
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
detectAgentMock.mockReset();
|
||||
logDebugMock.mockReset();
|
||||
});
|
||||
|
||||
it('returns detected agent telemetry context', async () => {
|
||||
detectAgentMock.mockReturnValue({
|
||||
detected: true,
|
||||
agent: { id: 'codex', name: 'Codex', sessionId: 'session-id' },
|
||||
});
|
||||
const { getAgentTelemetryContext } = await import('../agent');
|
||||
|
||||
expect(getAgentTelemetryContext()).toEqual({ id: 'codex', sessionId: 'session-id' });
|
||||
});
|
||||
|
||||
it('returns null when no known agent is detected', async () => {
|
||||
detectAgentMock.mockReturnValue({ detected: false, agent: null });
|
||||
const { getAgentTelemetryContext } = await import('../agent');
|
||||
|
||||
expect(getAgentTelemetryContext()).toBeNull();
|
||||
});
|
||||
|
||||
it('caches the detected agent telemetry context', async () => {
|
||||
detectAgentMock.mockReturnValue({
|
||||
detected: true,
|
||||
agent: { id: 'codex', name: 'Codex', sessionId: undefined },
|
||||
});
|
||||
const { getAgentTelemetryContext } = await import('../agent');
|
||||
|
||||
expect(getAgentTelemetryContext()).toEqual({ id: 'codex', sessionId: undefined });
|
||||
expect(getAgentTelemetryContext()).toEqual({ id: 'codex', sessionId: undefined });
|
||||
expect(detectAgentMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns null and logs debug details when detection throws', async () => {
|
||||
detectAgentMock.mockImplementation(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const { getAgentTelemetryContext } = await import('../agent');
|
||||
|
||||
expect(getAgentTelemetryContext()).toBeNull();
|
||||
expect(logDebugMock).toHaveBeenCalledWith('Failed to detect coding agent:', 'boom');
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { detectAgent } from 'agent-cli-detector';
|
||||
|
||||
import Log from '../log';
|
||||
|
||||
export type AgentTelemetryContext = {
|
||||
id: string;
|
||||
sessionId: string | undefined;
|
||||
};
|
||||
|
||||
let agentTelemetryContext: AgentTelemetryContext | null | undefined;
|
||||
|
||||
export function getAgentTelemetryContext(): AgentTelemetryContext | null {
|
||||
if (agentTelemetryContext === undefined) {
|
||||
agentTelemetryContext = resolveAgentTelemetryContext();
|
||||
}
|
||||
|
||||
return agentTelemetryContext;
|
||||
}
|
||||
|
||||
function resolveAgentTelemetryContext(): AgentTelemetryContext | null {
|
||||
try {
|
||||
const { agent, detected } = detectAgent();
|
||||
if (!detected || agent == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { id: agent.id, sessionId: agent.sessionId };
|
||||
} catch (error: any) {
|
||||
Log.debug('Failed to detect coding agent:', error?.message ?? error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { getProjectDashboardUrl, getProjectPageUrl } from '../url';
|
||||
|
||||
describe(getProjectPageUrl, () => {
|
||||
it('builds the project dashboard URL when no page is provided', () => {
|
||||
expect(getProjectPageUrl('testuser', 'testapp')).toBe(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends the page path when provided', () => {
|
||||
expect(getProjectPageUrl('testuser', 'testapp', 'builds')).toBe(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp/builds'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves nested page paths without encoding the separator', () => {
|
||||
expect(getProjectPageUrl('testuser', 'testapp', 'hosting/deployments')).toBe(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp/hosting/deployments'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes account and project names', () => {
|
||||
expect(getProjectPageUrl('my org', 'my app', 'updates')).toBe(
|
||||
'https://expo.dev/accounts/my%20org/projects/my%20app/updates'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getProjectDashboardUrl, () => {
|
||||
it('matches the page URL with no page', () => {
|
||||
expect(getProjectDashboardUrl('testuser', 'testapp')).toBe(
|
||||
getProjectPageUrl('testuser', 'testapp')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,18 +4,10 @@ import { getExpoApiBaseUrl, getExpoWebsiteBaseUrl } from '../../api';
|
||||
import { AppPlatform, BuildFragment } from '../../graphql/generated';
|
||||
|
||||
export function getProjectDashboardUrl(accountName: string, projectName: string): string {
|
||||
return getProjectPageUrl(accountName, projectName);
|
||||
}
|
||||
|
||||
export function getProjectPageUrl(
|
||||
accountName: string,
|
||||
projectName: string,
|
||||
page?: string | null
|
||||
): string {
|
||||
const projectPath = `/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
|
||||
projectName
|
||||
)}`;
|
||||
return new URL(page ? `${projectPath}/${page}` : projectPath, getExpoWebsiteBaseUrl()).toString();
|
||||
return new URL(
|
||||
`/accounts/${accountName}/projects/${projectName}`,
|
||||
getExpoWebsiteBaseUrl()
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function getBuildLogsUrl(build: BuildFragment, hash?: string): string {
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ describe(getProjectIdAsync, () => {
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
featureGates: {},
|
||||
preferences: {},
|
||||
},
|
||||
authenticationInfo: { accessToken: 'fake', sessionSecret: null },
|
||||
});
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import {
|
||||
PLACEHOLDER_WORKFLOW_CONTENTS,
|
||||
WorkflowStarterName,
|
||||
howToRunWorkflow,
|
||||
workflowStarters,
|
||||
} from '../creation';
|
||||
|
||||
function starterFor(name: WorkflowStarterName): (typeof workflowStarters)[number] {
|
||||
const starter = workflowStarters.find(s => s.name === name);
|
||||
if (!starter) {
|
||||
throw new Error(`No starter for ${name}`);
|
||||
}
|
||||
return starter;
|
||||
}
|
||||
|
||||
describe('workflow creation templates', () => {
|
||||
test('uses short default file names', () => {
|
||||
expect(starterFor(WorkflowStarterName.CUSTOM).defaultFileName).toBe('custom.yml');
|
||||
expect(starterFor(WorkflowStarterName.BUILD).defaultFileName).toBe('build.yml');
|
||||
expect(starterFor(WorkflowStarterName.UPDATE).defaultFileName).toBe('update.yml');
|
||||
expect(starterFor(WorkflowStarterName.DEPLOY).defaultFileName).toBe('deploy.yml');
|
||||
});
|
||||
|
||||
test('development builds template has no trigger and uses the expected build profiles', () => {
|
||||
const { template } = starterFor(WorkflowStarterName.BUILD);
|
||||
expect(template.on).toBeUndefined();
|
||||
expect(template.name).toBe('Create development builds');
|
||||
expect(template.jobs).toEqual({
|
||||
android_development_build: {
|
||||
name: 'Build Android',
|
||||
type: 'build',
|
||||
params: { platform: 'android', profile: 'development' },
|
||||
},
|
||||
ios_device_development_build: {
|
||||
name: 'Build iOS device',
|
||||
type: 'build',
|
||||
params: { platform: 'ios', profile: 'development' },
|
||||
},
|
||||
ios_simulator_development_build: {
|
||||
name: 'Build iOS simulator',
|
||||
type: 'build',
|
||||
params: { platform: 'ios', profile: 'development-ios-simulator' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('publish update template has no trigger and is named "Publish update"', () => {
|
||||
const { template } = starterFor(WorkflowStarterName.UPDATE);
|
||||
expect(template.on).toBeUndefined();
|
||||
expect(template.name).toBe('Publish update');
|
||||
expect(template.jobs.publish_update).toMatchObject({
|
||||
name: 'Publish update',
|
||||
type: 'update',
|
||||
});
|
||||
});
|
||||
|
||||
test('placeholder workflow contains name, on, and jobs fields', () => {
|
||||
expect(PLACEHOLDER_WORKFLOW_CONTENTS).toContain('name:');
|
||||
expect(PLACEHOLDER_WORKFLOW_CONTENTS).toContain('on:');
|
||||
expect(PLACEHOLDER_WORKFLOW_CONTENTS).toContain('jobs:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('howToRunWorkflow', () => {
|
||||
test('references the workflow file name without the .eas/workflows prefix', () => {
|
||||
const message = howToRunWorkflow('build.yml', starterFor(WorkflowStarterName.BUILD));
|
||||
expect(message).toContain('eas workflow:run build.yml');
|
||||
expect(message).not.toContain('.eas/workflows/');
|
||||
});
|
||||
|
||||
test('mentions automatic triggers when the template has push branches', () => {
|
||||
const message = howToRunWorkflow('custom.yml', starterFor(WorkflowStarterName.CUSTOM));
|
||||
expect(message).toContain('eas workflow:run custom.yml');
|
||||
expect(message).toContain('automatically');
|
||||
expect(message).toContain('main');
|
||||
});
|
||||
|
||||
test('does not mention automatic triggers for manually-run templates', () => {
|
||||
const message = howToRunWorkflow('update.yml', starterFor(WorkflowStarterName.UPDATE));
|
||||
expect(message).toContain('eas workflow:run update.yml');
|
||||
expect(message).not.toContain('automatically');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,11 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
|
||||
import { BuildProfile, EasJsonAccessor, EasJsonUtils, Platform } from '@expo/eas-json';
|
||||
import { AndroidBuildProfile, IosBuildProfile } from '@expo/eas-json/build/build/types';
|
||||
|
||||
import { ensureProjectConfiguredAsync } from '../../build/configure';
|
||||
import BuildConfigure from '../../commands/build/configure';
|
||||
import UpdateConfigure from '../../commands/update/configure';
|
||||
import Log from '../../log';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import {
|
||||
ensureEASUpdateIsConfiguredAsync,
|
||||
ensureEASUpdateIsConfiguredInEasJsonAsync,
|
||||
} from '../../update/configure';
|
||||
import { Client } from '../../vcs/vcs';
|
||||
|
||||
export const DEVELOPMENT_BUILD_PROFILE_NAME = 'development';
|
||||
export const DEVELOPMENT_IOS_SIMULATOR_BUILD_PROFILE_NAME = 'development-ios-simulator';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
export async function buildProfileNamesFromProjectAsync(projectDir: string): Promise<Set<string>> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
@@ -21,35 +15,101 @@ export async function buildProfileNamesFromProjectAsync(projectDir: string): Pro
|
||||
);
|
||||
return buildProfileNames;
|
||||
}
|
||||
|
||||
export async function ensureDevelopmentBuildProfilesExistAsync(projectDir: string): Promise<void> {
|
||||
export async function getBuildProfileAsync(
|
||||
projectDir: string,
|
||||
platform: Platform,
|
||||
profileName: string
|
||||
): Promise<BuildProfile<Platform>> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const buildProfile = await EasJsonUtils.getBuildProfileAsync(
|
||||
easJsonAccessor,
|
||||
platform,
|
||||
profileName
|
||||
);
|
||||
return buildProfile;
|
||||
}
|
||||
export async function buildProfilesFromProjectAsync(
|
||||
projectDir: string
|
||||
): Promise<Map<string, { android: AndroidBuildProfile; ios: IosBuildProfile }>> {
|
||||
const buildProfileNames = await buildProfileNamesFromProjectAsync(projectDir);
|
||||
const buildProfiles: Map<string, { android: AndroidBuildProfile; ios: IosBuildProfile }> =
|
||||
new Map();
|
||||
for (const profileName of buildProfileNames) {
|
||||
const iosBuildProfile = (await getBuildProfileAsync(
|
||||
projectDir,
|
||||
Platform.IOS,
|
||||
profileName
|
||||
)) as IosBuildProfile;
|
||||
const androidBuildProfile = (await getBuildProfileAsync(
|
||||
projectDir,
|
||||
Platform.ANDROID,
|
||||
profileName
|
||||
)) as AndroidBuildProfile;
|
||||
buildProfiles.set(profileName, { android: androidBuildProfile, ios: iosBuildProfile });
|
||||
}
|
||||
return buildProfiles;
|
||||
}
|
||||
export function isBuildProfileForDevelopment(
|
||||
buildProfile: BuildProfile<Platform>,
|
||||
platform: Platform
|
||||
): boolean {
|
||||
if (buildProfile.developmentClient) {
|
||||
return true;
|
||||
}
|
||||
if (platform === Platform.ANDROID) {
|
||||
return (buildProfile as BuildProfile<Platform.ANDROID>).gradleCommand === ':app:assembleDebug';
|
||||
}
|
||||
if (platform === Platform.IOS) {
|
||||
return (buildProfile as BuildProfile<Platform.IOS>).buildConfiguration === 'Debug';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export function isIosBuildProfileForSimulator(buildProfile: BuildProfile<Platform.IOS>): boolean {
|
||||
return buildProfile.simulator ?? false;
|
||||
}
|
||||
export async function addAndroidDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir: string,
|
||||
buildProfileName: string
|
||||
): Promise<void> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await easJsonAccessor.readRawJsonAsync();
|
||||
const addedProfiles: string[] = [];
|
||||
easJsonAccessor.patch(easJsonRawObject => {
|
||||
easJsonRawObject.build = easJsonRawObject.build ?? {};
|
||||
if (!easJsonRawObject.build[DEVELOPMENT_BUILD_PROFILE_NAME]) {
|
||||
easJsonRawObject.build[DEVELOPMENT_BUILD_PROFILE_NAME] = {
|
||||
easJsonRawObject.build = {
|
||||
...easJsonRawObject.build,
|
||||
[buildProfileName]: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
};
|
||||
addedProfiles.push(DEVELOPMENT_BUILD_PROFILE_NAME);
|
||||
}
|
||||
if (!easJsonRawObject.build[DEVELOPMENT_IOS_SIMULATOR_BUILD_PROFILE_NAME]) {
|
||||
easJsonRawObject.build[DEVELOPMENT_IOS_SIMULATOR_BUILD_PROFILE_NAME] = {
|
||||
developmentClient: true,
|
||||
ios: {
|
||||
simulator: true,
|
||||
android: {
|
||||
gradleCommand: ':app:assembleDebug',
|
||||
},
|
||||
};
|
||||
addedProfiles.push(DEVELOPMENT_IOS_SIMULATOR_BUILD_PROFILE_NAME);
|
||||
}
|
||||
},
|
||||
};
|
||||
return easJsonRawObject;
|
||||
});
|
||||
await easJsonAccessor.writeAsync();
|
||||
}
|
||||
export async function addIosDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir: string,
|
||||
buildProfileName: string,
|
||||
simulator: boolean
|
||||
): Promise<void> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await easJsonAccessor.readRawJsonAsync();
|
||||
easJsonAccessor.patch(easJsonRawObject => {
|
||||
easJsonRawObject.build = {
|
||||
...easJsonRawObject.build,
|
||||
[buildProfileName]: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
ios: {
|
||||
buildConfiguration: 'Debug',
|
||||
simulator,
|
||||
},
|
||||
},
|
||||
};
|
||||
return easJsonRawObject;
|
||||
});
|
||||
await easJsonAccessor.writeAsync();
|
||||
if (addedProfiles.length > 0) {
|
||||
Log.log(`Added the following build profiles to eas.json: ${addedProfiles.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addProductionBuildProfileToEasJsonIfNeededAsync(
|
||||
@@ -80,7 +140,7 @@ export async function addProductionBuildProfileToEasJsonIfNeededAsync(
|
||||
return profileAdded;
|
||||
}
|
||||
|
||||
async function hasBuildConfigureBeenRunAsync({
|
||||
export async function hasBuildConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
}: {
|
||||
@@ -101,7 +161,7 @@ async function hasBuildConfigureBeenRunAsync({
|
||||
return true;
|
||||
}
|
||||
|
||||
async function hasUpdateConfigureBeenRunAsync({
|
||||
export async function hasUpdateConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
}: {
|
||||
@@ -122,48 +182,88 @@ async function hasUpdateConfigureBeenRunAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export async function configureEasUpdateIfNeededAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
projectId,
|
||||
vcsClient,
|
||||
}: {
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
projectId: string;
|
||||
vcsClient: Client;
|
||||
}): Promise<void> {
|
||||
if (await hasUpdateConfigureBeenRunAsync({ projectDir, expoConfig })) {
|
||||
return;
|
||||
}
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const easJsonCliConfig: EasJson['cli'] =
|
||||
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
|
||||
await ensureEASUpdateIsConfiguredAsync({
|
||||
exp: expoConfig,
|
||||
projectId,
|
||||
projectDir,
|
||||
vcsClient,
|
||||
platform: RequestedPlatform.All,
|
||||
env: undefined,
|
||||
forceNativeConfigSync: true,
|
||||
manifestHostOverride: easJsonCliConfig?.updateManifestHostOverride ?? null,
|
||||
});
|
||||
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
|
||||
Log.withTick('Configured EAS Update');
|
||||
}
|
||||
/**
|
||||
* Runs update:configure if needed. Returns a boolean (proceed with workflow creation, or not)
|
||||
*/
|
||||
|
||||
export async function configureEasBuildIfNeededAsync({
|
||||
export async function runUpdateConfigureIfNeededAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
vcsClient,
|
||||
}: {
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
vcsClient: Client;
|
||||
}): Promise<void> {
|
||||
if (await hasBuildConfigureBeenRunAsync({ projectDir, expoConfig })) {
|
||||
return;
|
||||
}): Promise<boolean> {
|
||||
if (
|
||||
await hasUpdateConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const nextStep = (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'nextStep',
|
||||
message:
|
||||
'You have chosen to create a workflow that requires EAS Update configuration. What would you like to do?',
|
||||
choices: [
|
||||
{ title: 'Configure EAS Update and then proceed', value: 'configure' },
|
||||
{ title: 'EAS Update is already configured, proceed', value: 'proceed' },
|
||||
{ title: 'Choose a different workflow template', value: 'repeat' },
|
||||
],
|
||||
})
|
||||
).nextStep;
|
||||
switch (nextStep) {
|
||||
case 'configure':
|
||||
Log.newLine();
|
||||
await UpdateConfigure.run([]);
|
||||
return true;
|
||||
case 'proceed':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Runs build:configure if needed. Returns a boolean (proceed with workflow creation, or not)
|
||||
*/
|
||||
export async function runBuildConfigureIfNeededAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
}: {
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
}): Promise<boolean> {
|
||||
if (
|
||||
await hasBuildConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const nextStep = (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'nextStep',
|
||||
message:
|
||||
'You have chosen to create a workflow that requires EAS Build configuration. What would you like to do?',
|
||||
choices: [
|
||||
{ title: 'Configure EAS Build and then proceed', value: 'configure' },
|
||||
{ title: 'EAS Build is already configured, proceed', value: 'proceed' },
|
||||
{ title: 'Choose a different workflow template', value: 'repeat' },
|
||||
],
|
||||
})
|
||||
).nextStep;
|
||||
switch (nextStep) {
|
||||
case 'configure':
|
||||
Log.newLine();
|
||||
await BuildConfigure.run(['-p', 'all']);
|
||||
return true;
|
||||
case 'proceed':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
await ensureProjectConfiguredAsync({ projectDir, nonInteractive: false, vcsClient });
|
||||
}
|
||||
@@ -1,21 +1,19 @@
|
||||
import { ExpoConfig, getPackageJson } from '@expo/config';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import chalk from 'chalk';
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import {
|
||||
DEVELOPMENT_BUILD_PROFILE_NAME,
|
||||
DEVELOPMENT_IOS_SIMULATOR_BUILD_PROFILE_NAME,
|
||||
addAndroidDevelopmentBuildProfileToEasJsonAsync,
|
||||
addIosDevelopmentBuildProfileToEasJsonAsync,
|
||||
addProductionBuildProfileToEasJsonIfNeededAsync,
|
||||
ensureDevelopmentBuildProfilesExistAsync,
|
||||
buildProfilesFromProjectAsync,
|
||||
hasBuildConfigureBeenRunAsync,
|
||||
hasUpdateConfigureBeenRunAsync,
|
||||
isBuildProfileForDevelopment,
|
||||
isIosBuildProfileForSimulator,
|
||||
} from './buildProfileUtils';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
import Log, { link } from '../../log';
|
||||
import { ensureApplicationIdIsDefinedForManagedProjectAsync } from '../../project/android/applicationId';
|
||||
import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { easCliVersion } from '../../utils/easCli';
|
||||
import { expoCommandAsync } from '../../utils/expoCli';
|
||||
import { Client } from '../../vcs/vcs';
|
||||
|
||||
export enum WorkflowStarterName {
|
||||
BUILD = 'build',
|
||||
@@ -35,13 +33,6 @@ export type WorkflowStarter = {
|
||||
|
||||
const createdByEASCLI = `# Created by EAS CLI v${easCliVersion}`;
|
||||
|
||||
export const PLACEHOLDER_WORKFLOW_CONTENTS = `name: # Workflow name
|
||||
|
||||
on: # Add triggers https://docs.expo.dev/eas/workflows/syntax/#on
|
||||
|
||||
jobs: # Add pre-packaged jobs https://docs.expo.dev/eas/workflows/pre-packaged-jobs/. See all syntax https://docs.expo.dev/eas/workflows/syntax/#jobs.
|
||||
`;
|
||||
|
||||
const CUSTOM_TEMPLATE = {
|
||||
name: 'Custom workflow',
|
||||
on: {
|
||||
@@ -67,77 +58,95 @@ const CUSTOM_TEMPLATE = {
|
||||
};
|
||||
|
||||
const CUSTOM_TEMPLATE_HEADER = `
|
||||
# Custom workflow
|
||||
# Custom job
|
||||
#
|
||||
# Runs eas/checkout, then a custom shell command. Triggered on pushes to "main".
|
||||
# Learn more: https://docs.expo.dev/eas/workflows/syntax/
|
||||
# This workflow shows how to write custom jobs.
|
||||
# It contains a predefined workflow step and a shell command to print "Hello, World!".
|
||||
#
|
||||
# Key features:
|
||||
# - Triggers on pushes to the main branch.
|
||||
# (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
|
||||
# - Can be triggered manually with eas workflow:run custom.yml
|
||||
# - Runs eas/checkout then a custom "echo" command
|
||||
#
|
||||
# For detailed documentation on workflow syntax and available step types, visit:
|
||||
# https://docs.expo.dev/eas/workflows/syntax/#jobsjob_idstepsstepuses
|
||||
#
|
||||
${createdByEASCLI}
|
||||
#
|
||||
`;
|
||||
|
||||
const BUILD_TEMPLATE = {
|
||||
name: 'Create development builds',
|
||||
jobs: {
|
||||
android_development_build: {
|
||||
name: 'Build Android',
|
||||
type: 'build',
|
||||
params: {
|
||||
platform: 'android',
|
||||
profile: DEVELOPMENT_BUILD_PROFILE_NAME,
|
||||
},
|
||||
},
|
||||
ios_device_development_build: {
|
||||
name: 'Build iOS device',
|
||||
type: 'build',
|
||||
params: {
|
||||
platform: 'ios',
|
||||
profile: DEVELOPMENT_BUILD_PROFILE_NAME,
|
||||
},
|
||||
},
|
||||
ios_simulator_development_build: {
|
||||
name: 'Build iOS simulator',
|
||||
type: 'build',
|
||||
params: {
|
||||
platform: 'ios',
|
||||
profile: DEVELOPMENT_IOS_SIMULATOR_BUILD_PROFILE_NAME,
|
||||
},
|
||||
on: {
|
||||
push: {
|
||||
branches: ['main'],
|
||||
},
|
||||
},
|
||||
jobs: {},
|
||||
};
|
||||
|
||||
const BUILD_TEMPLATE_HEADER = `
|
||||
# Create development builds
|
||||
#
|
||||
# Builds Android and iOS development builds for devices and simulators.
|
||||
# Learn more: https://docs.expo.dev/develop/development-builds/introduction/
|
||||
# This workflow shows how to create development builds.
|
||||
#
|
||||
# Key features:
|
||||
# - Can be triggered manually with eas workflow:run create-development-builds.yml
|
||||
# - Runs the pre-packaged build job to create Android and iOS development builds
|
||||
# for Android emulators, Android and iOS devices, and iOS simulators
|
||||
#
|
||||
# For a detailed guide on using this workflow, visit:
|
||||
# https://docs.expo.dev/develop/development-builds/introduction/
|
||||
#
|
||||
${createdByEASCLI}
|
||||
#
|
||||
`;
|
||||
|
||||
const PUBLISH_UPDATE_TEMPLATE = {
|
||||
name: 'Publish update',
|
||||
name: 'Publish preview update',
|
||||
on: {
|
||||
push: {
|
||||
branches: ['*'],
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
publish_update: {
|
||||
name: 'Publish update',
|
||||
publish_preview_update: {
|
||||
name: 'Publish preview update',
|
||||
type: 'update',
|
||||
params: {
|
||||
branch: '${{ github.ref_name || "main" }}',
|
||||
branch: '${{ github.ref_name || "test" }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PUBLISH_UPDATE_TEMPLATE_HEADER = `
|
||||
# Publish update
|
||||
# Publish preview update
|
||||
#
|
||||
# Publishes an EAS Update to the current branch.
|
||||
# Learn more: https://docs.expo.dev/eas/workflows/examples/publish-preview-update/
|
||||
# This workflow shows how to publish preview updates.
|
||||
# Learn more: https://docs.expo.dev/review/share-previews-with-your-team/
|
||||
#
|
||||
# Key features:
|
||||
# - Triggers on pushes to all branches
|
||||
# (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
|
||||
# - Can be triggered manually with eas workflow:run publish-preview-update.yml
|
||||
# - Runs the pre-packaged update job
|
||||
#
|
||||
# For a detailed guide on using this workflow, visit:
|
||||
# https://docs.expo.dev/eas/workflows/examples/publish-preview-update/
|
||||
#
|
||||
${createdByEASCLI}
|
||||
#
|
||||
`;
|
||||
|
||||
const DEPLOY_TEMPLATE = {
|
||||
name: 'Deploy to production',
|
||||
on: {
|
||||
push: {
|
||||
branches: ['main'],
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
fingerprint: {
|
||||
name: 'Fingerprint',
|
||||
@@ -223,226 +232,304 @@ const DEPLOY_TEMPLATE = {
|
||||
const DEPLOY_TEMPLATE_HEADER = `
|
||||
# Deploy to production
|
||||
#
|
||||
# Builds and submits to the app stores, or sends an over-the-air update when there are no native changes.
|
||||
# Learn more: https://docs.expo.dev/eas/workflows/examples/deploy-to-production/
|
||||
# This workflow shows how to build and submit to app stores.
|
||||
#
|
||||
${createdByEASCLI}
|
||||
# Key features:
|
||||
# - Triggers on pushes to "main" (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
|
||||
# - Can be triggered manually with eas workflow:run deploy-to-production.yml
|
||||
# - Creates builds and submits them to app stores when native changes are detected, otherwise sends an over-the-air update.
|
||||
#
|
||||
# For a detailed guide on using this workflow, visit:
|
||||
# https://docs.expo.dev/eas/workflows/examples/deploy-to-production/
|
||||
`;
|
||||
|
||||
function nextStepsForProductionCredentials(): string[] {
|
||||
return [
|
||||
`Set up iOS build credentials for the "production" profile, required by the build jobs. Run ${chalk.bold(
|
||||
'eas credentials:configure-build -p ios -e production'
|
||||
)}`,
|
||||
`Set up Android build credentials for the "production" profile, required by the build jobs. Run ${chalk.bold(
|
||||
'eas credentials:configure-build -p android -e production'
|
||||
)}`,
|
||||
`Set up an App Store Connect API Key, required by the iOS submit job. In the menu, choose "App Store Connect: Manage your API Key" then "Set up your project to use an API Key for EAS Submit". Run ${chalk.bold(
|
||||
'eas credentials -p ios'
|
||||
)}`,
|
||||
`Set up a Google Service Account Key, required by the Android submit job. In the menu, choose "Google Service Account" then "Set up a Google Service Account Key for Play Store Submissions". Run ${chalk.bold(
|
||||
'eas credentials -p android'
|
||||
)}`,
|
||||
];
|
||||
function nextStepForDeviceBuildProfile(buildProfileName: string): string {
|
||||
return `A build job in this workflow uses the build profile "${buildProfileName}"
|
||||
to build your app for real devices, and may require credentials.
|
||||
You can configure your credentials with the command "eas credentials".
|
||||
For more information, please see ${link('https://docs.expo.dev/app-signing/app-credentials/')}.
|
||||
`
|
||||
.trim()
|
||||
.trimStart();
|
||||
}
|
||||
|
||||
function nextStepForDeviceDevelopmentBuild(buildProfileName: string): string {
|
||||
return `Set up iOS credentials so the development build can run on a physical device (you'll register your device when prompted). Learn more: ${link(
|
||||
'https://docs.expo.dev/app-signing/app-credentials/'
|
||||
)} Run ${chalk.bold(`eas credentials:configure-build -p ios -e ${buildProfileName}`)}`;
|
||||
function nextStepForAppSubmission(): string {
|
||||
return `This workflow includes a job to submit your app to app stores.
|
||||
You will need to configure your App Store/Play Store credentials
|
||||
before you can run this workflow.
|
||||
Please see ${link('https://docs.expo.dev/deploy/submit-to-app-stores/')}.
|
||||
`
|
||||
.trim()
|
||||
.trimStart();
|
||||
}
|
||||
|
||||
export function howToRunWorkflow(
|
||||
workflowFileName: string,
|
||||
workflowStarter: WorkflowStarter
|
||||
): string {
|
||||
let autoRunNote = '';
|
||||
const branches = workflowStarter.template?.on?.push?.branches;
|
||||
if (Array.isArray(branches) && branches.length > 0) {
|
||||
const lines = [
|
||||
`To run this workflow manually, use the command "eas workflow:run .eas/workflows/${workflowFileName}".`,
|
||||
];
|
||||
if (workflowStarter.template?.on?.push?.branches?.length > 0) {
|
||||
const branches = workflowStarter.template.on.push.branches;
|
||||
if (branches.length === 1 && branches[0] === '*') {
|
||||
autoRunNote = 'This workflow also runs automatically when code is pushed to any branch. ';
|
||||
lines.push(
|
||||
'This workflow is also configured to run automatically when code is pushed to any branch.'
|
||||
);
|
||||
} else if (branches.length === 1) {
|
||||
autoRunNote = `This workflow also runs automatically when code is pushed to the "${branches[0]}" branch. `;
|
||||
lines.push(
|
||||
`This workflow is also configured to run automatically when code is pushed to the "${branches[0]}" branch.`
|
||||
);
|
||||
} else {
|
||||
autoRunNote = `This workflow also runs automatically when code is pushed to: ${branches.join(
|
||||
', '
|
||||
)}. `;
|
||||
lines.push(
|
||||
`This workflow is also configured to run automatically when code is pushed to the following branches: ${branches.join(
|
||||
', '
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
return `${autoRunNote}Run this workflow with ${chalk.bold(`eas workflow:run ${workflowFileName}`)}`;
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export const workflowStarters: WorkflowStarter[] = [
|
||||
{
|
||||
displayName: `${chalk.bold('Build')} development builds`,
|
||||
name: WorkflowStarterName.BUILD,
|
||||
defaultFileName: 'build.yml',
|
||||
template: BUILD_TEMPLATE,
|
||||
header: BUILD_TEMPLATE_HEADER,
|
||||
},
|
||||
{
|
||||
displayName: `${chalk.bold('Publish')} updates`,
|
||||
name: WorkflowStarterName.UPDATE,
|
||||
defaultFileName: 'update.yml',
|
||||
template: PUBLISH_UPDATE_TEMPLATE,
|
||||
header: PUBLISH_UPDATE_TEMPLATE_HEADER,
|
||||
},
|
||||
{
|
||||
displayName: `${chalk.bold('Deploy')} to production`,
|
||||
name: WorkflowStarterName.DEPLOY,
|
||||
defaultFileName: 'deploy.yml',
|
||||
template: DEPLOY_TEMPLATE,
|
||||
header: DEPLOY_TEMPLATE_HEADER,
|
||||
},
|
||||
{
|
||||
displayName: chalk.bold('Custom'),
|
||||
displayName: 'Custom',
|
||||
name: WorkflowStarterName.CUSTOM,
|
||||
defaultFileName: 'custom.yml',
|
||||
template: CUSTOM_TEMPLATE,
|
||||
header: CUSTOM_TEMPLATE_HEADER,
|
||||
},
|
||||
{
|
||||
displayName: 'Create development builds',
|
||||
name: WorkflowStarterName.BUILD,
|
||||
defaultFileName: 'create-development-builds.yml',
|
||||
template: BUILD_TEMPLATE,
|
||||
header: BUILD_TEMPLATE_HEADER,
|
||||
},
|
||||
{
|
||||
displayName: 'Publish updates',
|
||||
name: WorkflowStarterName.UPDATE,
|
||||
defaultFileName: 'publish-updates.yml',
|
||||
template: PUBLISH_UPDATE_TEMPLATE,
|
||||
header: PUBLISH_UPDATE_TEMPLATE_HEADER,
|
||||
},
|
||||
{
|
||||
displayName: 'Deploy to production',
|
||||
name: WorkflowStarterName.DEPLOY,
|
||||
defaultFileName: 'deploy-to-production.yml',
|
||||
template: DEPLOY_TEMPLATE,
|
||||
header: DEPLOY_TEMPLATE_HEADER,
|
||||
},
|
||||
];
|
||||
|
||||
async function setUpDevelopmentBuildTemplateAsync({
|
||||
workflowStarter,
|
||||
projectDir,
|
||||
expoConfig,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
vcsClient,
|
||||
}: {
|
||||
workflowStarter: WorkflowStarter;
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
vcsClient: Client;
|
||||
}): Promise<WorkflowStarter> {
|
||||
await ensureAppIdentifiersAreDefinedAsync({
|
||||
graphqlClient,
|
||||
projectDir,
|
||||
projectId,
|
||||
exp: expoConfig,
|
||||
vcsClient,
|
||||
});
|
||||
await ensureDevelopmentBuildProfilesExistAsync(projectDir);
|
||||
await ensureExpoDevClientInstalledAsync(projectDir);
|
||||
workflowStarter.nextSteps = [nextStepForDeviceDevelopmentBuild(DEVELOPMENT_BUILD_PROFILE_NAME)];
|
||||
export async function addBuildJobsToDevelopmentBuildTemplateAsync(
|
||||
projectDir: string,
|
||||
workflowStarter: WorkflowStarter
|
||||
): Promise<WorkflowStarter> {
|
||||
const buildProfiles = await buildProfilesFromProjectAsync(projectDir);
|
||||
// android_development_build
|
||||
|
||||
const nextSteps: Set<string> = new Set();
|
||||
|
||||
let androidDevelopmentBuildProfileName: string | null = null;
|
||||
for (const profileName of buildProfiles.keys()) {
|
||||
const profile = buildProfiles.get(profileName)?.android;
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
if (isBuildProfileForDevelopment(profile, Platform.ANDROID)) {
|
||||
androidDevelopmentBuildProfileName = profileName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!androidDevelopmentBuildProfileName) {
|
||||
Log.warn(
|
||||
'This workflow requires an Android development build profile in your eas.json, but none were found.'
|
||||
);
|
||||
const add = await confirmAsync({
|
||||
message: 'Do you want to add an Android development build profile?',
|
||||
initial: false,
|
||||
});
|
||||
if (add) {
|
||||
let androidDevelopmentBuildProfileName = 'android_development';
|
||||
while (buildProfiles.has(androidDevelopmentBuildProfileName)) {
|
||||
androidDevelopmentBuildProfileName = `${androidDevelopmentBuildProfileName}_1`;
|
||||
}
|
||||
await addAndroidDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir,
|
||||
androidDevelopmentBuildProfileName
|
||||
);
|
||||
} else {
|
||||
Log.log('Skipping Android development build job...');
|
||||
}
|
||||
}
|
||||
if (androidDevelopmentBuildProfileName) {
|
||||
Log.log(`Using Android development build profile: ${androidDevelopmentBuildProfileName}`);
|
||||
workflowStarter.template.jobs.android_development_build = {
|
||||
name: `Build ${androidDevelopmentBuildProfileName} for android`,
|
||||
type: 'build',
|
||||
params: {
|
||||
profile: androidDevelopmentBuildProfileName,
|
||||
platform: 'android',
|
||||
},
|
||||
};
|
||||
nextSteps.add(nextStepForDeviceBuildProfile(androidDevelopmentBuildProfileName));
|
||||
}
|
||||
|
||||
// ios_simulator_development_build
|
||||
let iosSimulatorDevelopmentBuildProfileName: string | null = null;
|
||||
for (const profileName of buildProfiles.keys()) {
|
||||
const profile = buildProfiles.get(profileName)?.ios;
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isBuildProfileForDevelopment(profile, Platform.IOS) &&
|
||||
isIosBuildProfileForSimulator(profile)
|
||||
) {
|
||||
iosSimulatorDevelopmentBuildProfileName = profileName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!iosSimulatorDevelopmentBuildProfileName) {
|
||||
Log.warn(
|
||||
'This workflow requires an iOS simulator development build profile in your eas.json, but none were found.'
|
||||
);
|
||||
const add = await confirmAsync({
|
||||
message: 'Do you want to add an iOS simulator development build profile?',
|
||||
initial: false,
|
||||
});
|
||||
if (add) {
|
||||
let iosSimulatorDevelopmentBuildProfileName = 'ios_simulator_development';
|
||||
while (buildProfiles.has(iosSimulatorDevelopmentBuildProfileName)) {
|
||||
iosSimulatorDevelopmentBuildProfileName = `${iosSimulatorDevelopmentBuildProfileName}_1`;
|
||||
}
|
||||
await addIosDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir,
|
||||
iosSimulatorDevelopmentBuildProfileName,
|
||||
true
|
||||
);
|
||||
} else {
|
||||
Log.log('Skipping iOS simulator development build job...');
|
||||
}
|
||||
}
|
||||
if (iosSimulatorDevelopmentBuildProfileName) {
|
||||
Log.log(
|
||||
`Using iOS simulator development build profile: ${iosSimulatorDevelopmentBuildProfileName}`
|
||||
);
|
||||
workflowStarter.template.jobs.ios_simulator_development_build = {
|
||||
name: `Build ${iosSimulatorDevelopmentBuildProfileName} for iOS simulator`,
|
||||
type: 'build',
|
||||
params: {
|
||||
profile: iosSimulatorDevelopmentBuildProfileName,
|
||||
platform: 'ios',
|
||||
},
|
||||
};
|
||||
}
|
||||
// ios_device_development_build
|
||||
let iosDeviceDevelopmentBuildProfileName: string | null = null;
|
||||
for (const profileName of buildProfiles.keys()) {
|
||||
const profile = buildProfiles.get(profileName)?.ios;
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isBuildProfileForDevelopment(profile, Platform.IOS) &&
|
||||
!isIosBuildProfileForSimulator(profile)
|
||||
) {
|
||||
iosDeviceDevelopmentBuildProfileName = profileName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!iosDeviceDevelopmentBuildProfileName) {
|
||||
Log.warn(
|
||||
'This workflow requires an iOS device development build profile in your eas.json, but none were found.'
|
||||
);
|
||||
const add = await confirmAsync({
|
||||
message: 'Do you want to add an iOS device development build profile?',
|
||||
initial: false,
|
||||
});
|
||||
if (add) {
|
||||
let iosDeviceDevelopmentBuildProfileName = 'ios_device_development';
|
||||
while (buildProfiles.has(iosDeviceDevelopmentBuildProfileName)) {
|
||||
iosDeviceDevelopmentBuildProfileName = `${iosDeviceDevelopmentBuildProfileName}_1`;
|
||||
}
|
||||
await addIosDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir,
|
||||
iosDeviceDevelopmentBuildProfileName,
|
||||
false
|
||||
);
|
||||
} else {
|
||||
Log.log('Skipping iOS device development build job...');
|
||||
}
|
||||
}
|
||||
if (iosDeviceDevelopmentBuildProfileName) {
|
||||
Log.log(`Using iOS device development build profile: ${iosDeviceDevelopmentBuildProfileName}`);
|
||||
workflowStarter.template.jobs.ios_development_build = {
|
||||
name: `Build ${iosDeviceDevelopmentBuildProfileName} for iOS simulator`,
|
||||
type: 'build',
|
||||
params: {
|
||||
profile: iosDeviceDevelopmentBuildProfileName,
|
||||
platform: 'ios',
|
||||
},
|
||||
};
|
||||
nextSteps.add(nextStepForDeviceBuildProfile(iosDeviceDevelopmentBuildProfileName));
|
||||
}
|
||||
workflowStarter.nextSteps = [...nextSteps];
|
||||
return workflowStarter;
|
||||
}
|
||||
|
||||
async function ensureAppIdentifiersAreDefinedAsync({
|
||||
graphqlClient,
|
||||
projectDir,
|
||||
projectId,
|
||||
exp,
|
||||
vcsClient,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
exp: ExpoConfig;
|
||||
vcsClient: Client;
|
||||
}): Promise<void> {
|
||||
const androidWorkflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID, vcsClient);
|
||||
if (androidWorkflow === Workflow.MANAGED) {
|
||||
await ensureApplicationIdIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir,
|
||||
projectId,
|
||||
exp,
|
||||
vcsClient,
|
||||
nonInteractive: false,
|
||||
autoSelectDefault: true,
|
||||
});
|
||||
}
|
||||
const iosWorkflow = await resolveWorkflowAsync(projectDir, Platform.IOS, vcsClient);
|
||||
if (iosWorkflow === Workflow.MANAGED) {
|
||||
await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir,
|
||||
projectId,
|
||||
exp,
|
||||
vcsClient,
|
||||
nonInteractive: false,
|
||||
autoSelectDefault: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureExpoDevClientInstalledAsync(projectDir: string): Promise<void> {
|
||||
const packageJson = getPackageJson(projectDir);
|
||||
const isInstalled = !!(packageJson.dependencies && 'expo-dev-client' in packageJson.dependencies);
|
||||
if (isInstalled) {
|
||||
return;
|
||||
}
|
||||
Log.log('Installing expo-dev-client...');
|
||||
await expoCommandAsync(projectDir, ['install', 'expo-dev-client']);
|
||||
}
|
||||
|
||||
async function setUpDeployTemplateAsync({
|
||||
workflowStarter,
|
||||
projectDir,
|
||||
expoConfig,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
vcsClient,
|
||||
}: {
|
||||
workflowStarter: WorkflowStarter;
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
vcsClient: Client;
|
||||
}): Promise<WorkflowStarter> {
|
||||
await ensureAppIdentifiersAreDefinedAsync({
|
||||
graphqlClient,
|
||||
projectDir,
|
||||
projectId,
|
||||
exp: expoConfig,
|
||||
vcsClient,
|
||||
});
|
||||
export async function ensureProductionBuildProfileExistsAsync(
|
||||
projectDir: string,
|
||||
workflowStarter: WorkflowStarter
|
||||
): Promise<WorkflowStarter> {
|
||||
await addProductionBuildProfileToEasJsonIfNeededAsync(projectDir);
|
||||
workflowStarter.nextSteps = nextStepsForProductionCredentials();
|
||||
workflowStarter.nextSteps = [
|
||||
nextStepForDeviceBuildProfile('production'),
|
||||
nextStepForAppSubmission(),
|
||||
];
|
||||
return workflowStarter;
|
||||
}
|
||||
|
||||
export async function customizeTemplateIfNeededAsync({
|
||||
workflowStarter,
|
||||
projectDir,
|
||||
expoConfig,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
vcsClient,
|
||||
}: {
|
||||
workflowStarter: WorkflowStarter;
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
vcsClient: Client;
|
||||
}): Promise<WorkflowStarter> {
|
||||
export async function customizeTemplateIfNeededAsync(
|
||||
workflowStarter: WorkflowStarter,
|
||||
projectDir: string,
|
||||
expoConfig: ExpoConfig
|
||||
): Promise<any> {
|
||||
// Ensure EAS Build is configured
|
||||
switch (workflowStarter.name) {
|
||||
case WorkflowStarterName.BUILD:
|
||||
Log.debug('Setting up development builds workflow...');
|
||||
return await setUpDevelopmentBuildTemplateAsync({
|
||||
workflowStarter,
|
||||
projectDir,
|
||||
expoConfig,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
vcsClient,
|
||||
});
|
||||
case WorkflowStarterName.DEPLOY:
|
||||
Log.debug('Setting up deploy workflow...');
|
||||
return await setUpDeployTemplateAsync({
|
||||
workflowStarter,
|
||||
projectDir,
|
||||
expoConfig,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
vcsClient,
|
||||
});
|
||||
case WorkflowStarterName.UPDATE:
|
||||
if (!(await hasBuildConfigureBeenRunAsync({ projectDir, expoConfig }))) {
|
||||
throw new Error(
|
||||
'EAS Build is not configured for this project. Please run "eas build:configure" to configure it.'
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Ensure EAS Update is configured
|
||||
switch (workflowStarter.name) {
|
||||
case WorkflowStarterName.DEPLOY:
|
||||
case WorkflowStarterName.UPDATE:
|
||||
if (!(await hasUpdateConfigureBeenRunAsync({ projectDir, expoConfig }))) {
|
||||
throw new Error(
|
||||
'EAS Update is not configured for this project. Please run "eas update:configure" to configure it.'
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Customize template
|
||||
switch (workflowStarter.name) {
|
||||
case WorkflowStarterName.BUILD:
|
||||
Log.debug('Adding build jobs to template...');
|
||||
return await addBuildJobsToDevelopmentBuildTemplateAsync(projectDir, workflowStarter);
|
||||
case WorkflowStarterName.DEPLOY:
|
||||
return await ensureProductionBuildProfileExistsAsync(projectDir, workflowStarter);
|
||||
default:
|
||||
return workflowStarter;
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import openBrowserAsync from 'better-opn';
|
||||
|
||||
import { getMockOclifConfig } from '../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../credentials/__tests__/fixtures-constants';
|
||||
import { AccountFragment } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import Browse from '../browse';
|
||||
|
||||
jest.mock('better-opn');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../project/projectUtils');
|
||||
jest.mock('../../utils/json');
|
||||
jest.mock('../../ora', () => ({
|
||||
ora: jest.fn(() => ({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe(Browse, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
function createCommand(argv: string[]): Browse {
|
||||
const command = new Browse(argv, mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
exp: { slug: 'testapp' },
|
||||
},
|
||||
loggedIn: { graphqlClient },
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest
|
||||
.mocked(getOwnerAccountForProjectIdAsync)
|
||||
.mockResolvedValue({ name: 'testuser' } as AccountFragment);
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue({} as never);
|
||||
jest.mocked(ora).mockReturnValue({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('opens the project dashboard when no page is provided', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(openBrowserAsync).toHaveBeenCalledWith(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp'
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a subpage by its EAS command name', async () => {
|
||||
await createCommand(['build']).runAsync();
|
||||
|
||||
expect(openBrowserAsync).toHaveBeenCalledWith(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp/builds'
|
||||
);
|
||||
});
|
||||
|
||||
it('maps the website wording to the same subpage', async () => {
|
||||
await createCommand(['submissions']).runAsync();
|
||||
|
||||
expect(openBrowserAsync).toHaveBeenCalledWith(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp/submissions'
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the observe page', async () => {
|
||||
await createCommand(['observe']).runAsync();
|
||||
|
||||
expect(openBrowserAsync).toHaveBeenCalledWith(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp/observe'
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the URL without opening a browser when --no-browser is passed', async () => {
|
||||
await createCommand(['hosting', '--no-browser']).runAsync();
|
||||
|
||||
expect(openBrowserAsync).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'https://expo.dev/accounts/testuser/projects/testapp/hosting'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown page', async () => {
|
||||
await expect(createCommand(['nope']).runAsync()).rejects.toThrow();
|
||||
expect(openBrowserAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards non-interactive mode to the context so it can run without prompts', async () => {
|
||||
const command = createCommand(['--non-interactive']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect((command as any).getContextAsync).toHaveBeenCalledWith(
|
||||
Browse,
|
||||
expect.objectContaining({ nonInteractive: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the URL as JSON without opening a browser when --json is passed', async () => {
|
||||
const command = createCommand(['build', '--json']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(enableJsonOutput).toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
url: 'https://expo.dev/accounts/testuser/projects/testapp/builds',
|
||||
});
|
||||
expect(openBrowserAsync).not.toHaveBeenCalled();
|
||||
expect((command as any).getContextAsync).toHaveBeenCalledWith(
|
||||
Browse,
|
||||
expect.objectContaining({ nonInteractive: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the spinner when the browser cannot be opened', async () => {
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue(false);
|
||||
const spinner = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
};
|
||||
jest.mocked(ora).mockReturnValue(spinner as any);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(spinner.fail).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unable to open a web browser')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import openBrowserAsync from 'better-opn';
|
||||
|
||||
import { getProjectPageUrl } from '../build/utils/url';
|
||||
import EasCommand from '../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../commandUtils/flags';
|
||||
import Log from '../log';
|
||||
import { ora } from '../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json';
|
||||
|
||||
const PROJECT_PAGES: Record<string, string> = {
|
||||
build: 'builds',
|
||||
builds: 'builds',
|
||||
submit: 'submissions',
|
||||
submissions: 'submissions',
|
||||
update: 'updates',
|
||||
updates: 'updates',
|
||||
workflow: 'workflows',
|
||||
workflows: 'workflows',
|
||||
cicd: 'workflows',
|
||||
hosting: 'hosting',
|
||||
deployments: 'hosting/deployments',
|
||||
credentials: 'credentials',
|
||||
env: 'environment-variables',
|
||||
insights: 'insights',
|
||||
observe: 'observe',
|
||||
settings: 'settings',
|
||||
};
|
||||
|
||||
export default class Browse extends EasCommand {
|
||||
static override description =
|
||||
'Transition from the terminal to the web browser to view and interact with your project on https://expo.dev';
|
||||
|
||||
static override args = {
|
||||
page: Args.string({
|
||||
description: 'Project subpage to open. Defaults to the project dashboard.',
|
||||
required: false,
|
||||
options: Object.keys(PROJECT_PAGES),
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
'no-browser': Flags.boolean({
|
||||
char: 'n',
|
||||
description: 'Print the URL instead of opening it in a web browser',
|
||||
default: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(Browse);
|
||||
const page = args.page ? PROJECT_PAGES[args.page] : null;
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
privateProjectConfig: { projectId, exp },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(Browse, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
const url = getProjectPageUrl(account.name, exp.slug, page);
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ url });
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags['no-browser']) {
|
||||
Log.log(url);
|
||||
return;
|
||||
}
|
||||
|
||||
const failedMessage = `Unable to open a web browser. Project page is available at: ${url}`;
|
||||
const spinner = ora(`Opening ${url}`).start();
|
||||
try {
|
||||
const opened = await openBrowserAsync(url);
|
||||
|
||||
if (opened) {
|
||||
spinner.succeed(`Opened ${url}`);
|
||||
} else {
|
||||
spinner.fail(failedMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(failedMessage);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { runBuildAndSubmitAsync } from '../../../build/runBuildAndSubmit';
|
||||
import BuildInspect from '../inspect';
|
||||
|
||||
jest.mock('fs-extra', () => ({
|
||||
pathExists: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
mkdirp: jest.fn(),
|
||||
copy: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../build/runBuildAndSubmit', () => ({
|
||||
runBuildAndSubmitAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => ({
|
||||
start: jest.fn(() => ({
|
||||
succeed: jest.fn(),
|
||||
fail: jest.fn(),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
jest.mock('../../../utils/paths', () => ({
|
||||
...jest.requireActual('../../../utils/paths'),
|
||||
getTmpDirectory: jest.fn(() => '/tmp/eas-cli'),
|
||||
}));
|
||||
jest.mock('uuid', () => ({
|
||||
v4: jest.fn(() => 'tmp-id'),
|
||||
}));
|
||||
|
||||
describe(BuildInspect, () => {
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const tmpWorkingdir = '/tmp/eas-cli/tmp-id';
|
||||
const tmpBuildDir = path.join(tmpWorkingdir, 'build');
|
||||
const outputDirectory = path.join(process.cwd(), 'inspect-output');
|
||||
const mockPathExists = fs.pathExists as jest.Mock;
|
||||
const mockMkdirp = fs.mkdirp as jest.Mock;
|
||||
const mockCopy = fs.copy as jest.Mock;
|
||||
const mockRemove = fs.remove as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPathExists.mockResolvedValue(false);
|
||||
mockMkdirp.mockResolvedValue(undefined);
|
||||
mockCopy.mockResolvedValue(undefined);
|
||||
mockRemove.mockResolvedValue(undefined);
|
||||
jest.mocked(runBuildAndSubmitAsync).mockResolvedValue({ buildIds: [] });
|
||||
});
|
||||
|
||||
function createCommand(): BuildInspect {
|
||||
const command = new BuildInspect(
|
||||
['--platform', 'ios', '--stage', 'pre-build', '--output', 'inspect-output'],
|
||||
mockConfig
|
||||
);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: {
|
||||
actor: {},
|
||||
graphqlClient: {},
|
||||
},
|
||||
getDynamicPrivateProjectConfigAsync: jest.fn(),
|
||||
projectDir: '/project',
|
||||
analytics: {},
|
||||
vcsClient: {},
|
||||
} as any);
|
||||
return command;
|
||||
}
|
||||
|
||||
it('removes the temporary working directory after copying output when the build fails', async () => {
|
||||
jest.mocked(runBuildAndSubmitAsync).mockRejectedValue(new Error('build failed'));
|
||||
mockPathExists.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
||||
|
||||
await createCommand().runAsync();
|
||||
|
||||
expect(fs.copy).toHaveBeenCalledWith(tmpBuildDir, outputDirectory);
|
||||
expect(fs.remove).toHaveBeenCalledWith(tmpBuildDir);
|
||||
expect(fs.remove).toHaveBeenCalledWith(tmpWorkingdir);
|
||||
});
|
||||
|
||||
it('keeps the temporary working directory when copying output fails', async () => {
|
||||
jest.mocked(runBuildAndSubmitAsync).mockRejectedValue(new Error('build failed'));
|
||||
mockPathExists.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
||||
mockCopy.mockRejectedValue(new Error('copy failed'));
|
||||
|
||||
await expect(createCommand().runAsync()).rejects.toThrow('copy failed');
|
||||
|
||||
expect(fs.remove).not.toHaveBeenCalledWith(tmpBuildDir);
|
||||
expect(fs.remove).not.toHaveBeenCalledWith(tmpWorkingdir);
|
||||
});
|
||||
});
|
||||
@@ -135,7 +135,6 @@ export default class BuildInspect extends EasCommand {
|
||||
}
|
||||
} finally {
|
||||
await this.copyToOutputDirAsync(path.join(tmpWorkingdir, 'build'), outputDirectory);
|
||||
await this.cleanUpTmpWorkingdirAsync(tmpWorkingdir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,13 +163,4 @@ export default class BuildInspect extends EasCommand {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanUpTmpWorkingdirAsync(tmpWorkingdir: string): Promise<void> {
|
||||
try {
|
||||
await fs.remove(tmpWorkingdir);
|
||||
} catch (err) {
|
||||
Log.warn(`Failed to remove temporary directory ${tmpWorkingdir}`);
|
||||
Log.debug(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import * as WorkerAssets from '../../worker/assets';
|
||||
import {
|
||||
assertValidDevDomainName,
|
||||
assignWorkerDeploymentAliasAsync,
|
||||
assignWorkerDeploymentProductionAsync,
|
||||
getSignedDeploymentUrlAsync,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
uploadAsync,
|
||||
} from '../../worker/upload';
|
||||
import {
|
||||
EXPO_BASE_DOMAIN,
|
||||
formatWorkerDeploymentJson,
|
||||
formatWorkerDeploymentTable,
|
||||
getDeploymentUrlFromFullName,
|
||||
@@ -52,7 +50,6 @@ interface DeployFlags {
|
||||
aliasName?: string;
|
||||
environment?: string;
|
||||
deploymentIdentifier?: string;
|
||||
devDomainName?: string;
|
||||
exportDir: string;
|
||||
dryRun: boolean;
|
||||
sourceMaps: boolean;
|
||||
@@ -65,7 +62,6 @@ interface RawDeployFlags {
|
||||
prod: boolean;
|
||||
alias?: string;
|
||||
id?: string;
|
||||
'dev-domain'?: string;
|
||||
'export-dir': string;
|
||||
'dry-run': boolean;
|
||||
'source-maps': boolean;
|
||||
@@ -86,11 +82,7 @@ interface DeployInProgressParams {
|
||||
export default class WorkerDeploy extends EasCommand {
|
||||
static override description = 'deploy your Expo Router web build and API Routes';
|
||||
static override aliases = ['worker:deploy'];
|
||||
static override usage = [
|
||||
chalk`deploy {dim [options]}`,
|
||||
`deploy --prod`,
|
||||
`deploy --non-interactive --dev-domain my-app`,
|
||||
];
|
||||
static override usage = [chalk`deploy {dim [options]}`, `deploy --prod`];
|
||||
static override state = 'preview';
|
||||
|
||||
static override flags = {
|
||||
@@ -107,10 +99,6 @@ export default class WorkerDeploy extends EasCommand {
|
||||
description: 'Custom unique identifier for the new deployment.',
|
||||
helpValue: 'xyz123',
|
||||
}),
|
||||
'dev-domain': Flags.string({
|
||||
description: `Custom preview URL subdomain to assign to the project on its first deployment, e.g. "my-app" for my-app.${EXPO_BASE_DOMAIN}.app. Required with --non-interactive if you want to customize the preview URL.`,
|
||||
helpValue: 'name',
|
||||
}),
|
||||
'export-dir': Flags.string({
|
||||
description: 'Directory where the Expo project was exported.',
|
||||
helpValue: 'dir',
|
||||
@@ -334,7 +322,6 @@ export default class WorkerDeploy extends EasCommand {
|
||||
const uploadUrl = await getSignedDeploymentUrlAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
deploymentIdentifier: flags.deploymentIdentifier,
|
||||
devDomainName: flags.devDomainName,
|
||||
// NOTE(cedric): this function might ask the user for a dev-domain name,
|
||||
// when that happens, no ora spinner should be running.
|
||||
onSetupDevDomain: () => progress.stop(),
|
||||
@@ -480,19 +467,12 @@ export default class WorkerDeploy extends EasCommand {
|
||||
|
||||
private sanitizeFlags(flags: RawDeployFlags): DeployFlags {
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const devDomainName = flags['dev-domain']?.trim().toLowerCase();
|
||||
if (devDomainName !== undefined) {
|
||||
assertValidDevDomainName(devDomainName);
|
||||
}
|
||||
|
||||
return {
|
||||
nonInteractive,
|
||||
json,
|
||||
isProduction: !!flags.prod,
|
||||
aliasName: flags.alias?.trim().toLowerCase(),
|
||||
deploymentIdentifier: flags.id?.trim(),
|
||||
devDomainName,
|
||||
exportDir: flags['export-dir'],
|
||||
environment: flags['environment'],
|
||||
dryRun: flags['dry-run'],
|
||||
|
||||
@@ -61,6 +61,7 @@ describe(IntegrationsConvexConnect, () => {
|
||||
ownerUserActor: null,
|
||||
users: [],
|
||||
},
|
||||
preferences: { onboarding: null },
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
|
||||
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user