mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62d46fece5 | ||
|
|
d66dc2920f | ||
|
|
d725a6c7f4 | ||
|
|
f93b75b9a6 | ||
|
|
a323655f7c | ||
|
|
4bb500ae01 | ||
|
|
a207e5d62e | ||
|
|
f81efbacde | ||
|
|
06c6a8416d | ||
|
|
e9ebc41413 | ||
|
|
9313ef11ca | ||
|
|
fd80532a7e | ||
|
|
f20ccebcad | ||
|
|
e8602f7062 | ||
|
|
d14c0b196b | ||
|
|
c24b9a037a | ||
|
|
c3c3f087ac | ||
|
|
ac761edb5c | ||
|
|
78c30acbc9 | ||
|
|
a716925ed7 | ||
|
|
e579441d5b | ||
|
|
4f5831305b | ||
|
|
98db228cc8 | ||
|
|
5c762d300e | ||
|
|
2a6bee7f5a | ||
|
|
4d365e0355 | ||
|
|
48aa2ccde2 | ||
|
|
bdc04e3e6b | ||
|
|
935491f967 | ||
|
|
031198d1aa | ||
|
|
ac60d159f2 | ||
|
|
fc09fe4a34 | ||
|
|
a62289b988 | ||
|
|
78c0584331 | ||
|
|
c4a7b7c9f1 | ||
|
|
f448bc75b5 | ||
|
|
0dda7ba880 | ||
|
|
9679af9670 | ||
|
|
3e0e823e56 | ||
|
|
fbb8af6fca |
No files matched your search
@@ -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: ['**/*']
|
||||
- patterns: ['packages/eas-cli/**']
|
||||
mentions: ['douglowder']
|
||||
- patterns: ['packages/eas-cli/src/update/**']
|
||||
mentions: ['wschurman', 'quinlanj']
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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
|
||||
@@ -6,10 +6,51 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Support drill-down from event lists in observe:session. ([#3987](https://github.com/expo/eas-cli/pull/3987) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [21.0.2](https://github.com/expo/eas-cli/releases/tag/v21.0.2) - 2026-07-17
|
||||
|
||||
### 🐛 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] Add `eas env:set`, which creates or updates an environment variable in one command. `eas env:create` and `eas env:update` are now deprecated. ([#4002](https://github.com/expo/eas-cli/pull/4002) by [@jonsamp](https://github.com/jonsamp))
|
||||
- [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))
|
||||
@@ -28,6 +69,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🧹 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))
|
||||
@@ -44,6 +86,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
### 🐛 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: 2028-04-01
|
||||
Change Date: 2029-07-15
|
||||
|
||||
Change License: MIT
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.3",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.3",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -39,18 +39,17 @@
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "21.0.0",
|
||||
"@expo/eas-build-job": "21.0.0",
|
||||
"@expo/eas-build-job": "21.0.3",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.0.0",
|
||||
"@expo/template-file": "21.0.0",
|
||||
"@expo/steps": "21.0.3",
|
||||
"@expo/template-file": "21.0.2",
|
||||
"@expo/turtle-spawn": "21.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",
|
||||
@@ -61,7 +60,7 @@
|
||||
"gql.tada": "^1.8.13",
|
||||
"joi": "^17.13.1",
|
||||
"jose": "5.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "^4.18.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-stream-zip": "1.15.0",
|
||||
@@ -72,7 +71,7 @@
|
||||
"resolve-from": "^5.0.0",
|
||||
"retry": "^0.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"tar": "7.5.7",
|
||||
"tar": "7.5.19",
|
||||
"uuid": "9.0.1",
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -59,8 +59,15 @@ 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?
|
||||
|
||||
+5
-1
@@ -100,7 +100,9 @@ final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
|
||||
@discardableResult
|
||||
func writeManifest(
|
||||
configuration: SimulatorRecordingConfiguration,
|
||||
firstFrameWallClock: Date
|
||||
firstFrameWallClock: Date,
|
||||
width: Int,
|
||||
height: Int
|
||||
) throws -> RecordingManifest {
|
||||
try stateQueue.sync {
|
||||
if let firstError {
|
||||
@@ -121,6 +123,8 @@ 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,
|
||||
|
||||
+13
-1
@@ -7,6 +7,7 @@ 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
|
||||
@@ -26,6 +27,8 @@ 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?
|
||||
@@ -113,6 +116,7 @@ public final class SimulatorRecorder {
|
||||
displaySource?.stop()
|
||||
displaySource = nil
|
||||
}
|
||||
onFinalizationStage?(.captureStopped)
|
||||
|
||||
return try writerQueue.sync {
|
||||
if let firstError {
|
||||
@@ -139,12 +143,18 @@ 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
|
||||
firstFrameWallClock: firstAcceptedWallClock,
|
||||
width: recordingWidth,
|
||||
height: recordingHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -590,6 +600,8 @@ 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 stopRequested: CInt = 0
|
||||
private var requestedStopSignal: CInt = 0
|
||||
private var simulatorStopped: CInt = 0
|
||||
|
||||
private func handleStopSignal(_: CInt) {
|
||||
stopRequested = 1
|
||||
private func handleStopSignal(_ signal: CInt) {
|
||||
requestedStopSignal = signal
|
||||
}
|
||||
|
||||
private func isStopRequested() -> Bool {
|
||||
stopRequested != 0 || simulatorStopped != 0
|
||||
requestedStopSignal != 0 || simulatorStopped != 0
|
||||
}
|
||||
|
||||
func usage() -> String {
|
||||
@@ -112,6 +112,14 @@ 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:
|
||||
@@ -130,6 +138,14 @@ 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,12 +19,10 @@ 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(() => {
|
||||
@@ -171,45 +169,6 @@ 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,6 +111,37 @@ 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,7 +10,10 @@ 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 = {};
|
||||
@@ -72,6 +75,19 @@ 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,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { restoreCredentials } from '../../android/credentials';
|
||||
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
|
||||
import androidBuilder from '../android';
|
||||
import { runBuilderWithHooksAsync } from '../common';
|
||||
import { setupAsync } from '../../common/setup';
|
||||
import {
|
||||
injectConfigureVersionGradleConfig,
|
||||
injectCredentialsGradleConfig,
|
||||
@@ -89,6 +90,48 @@ describe(androidBuilder, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the install_node_modules anchor and a hooks ref to setupAsync', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(setupAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
wrappedAnchors: ['install_node_modules'],
|
||||
jobHooksRef: expect.objectContaining({ current: null }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('drains queued hook metric uploads even when setup fails after assigning the ref', async () => {
|
||||
const drainPendingMetricUploads = jest.fn();
|
||||
jest.mocked(setupAsync).mockImplementationOnce(async (_ctx, { jobHooksRef }) => {
|
||||
// A before-hook can queue a metric upload and then fail setup; the drain
|
||||
// must still run.
|
||||
jobHooksRef.current = {
|
||||
customBuildContext: { drainPendingMetricUploads },
|
||||
} as never;
|
||||
throw new Error('setup boom');
|
||||
});
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
|
||||
await expect(androidBuilder(ctx)).rejects.toThrow('setup boom');
|
||||
expect(drainPendingMetricUploads).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('injects Android version config without build credentials', async () => {
|
||||
const job: Android.Job = {
|
||||
...createTestAndroidJob(),
|
||||
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
resolveGradleCommand,
|
||||
runGradleCommand,
|
||||
} from '../android/gradle';
|
||||
import { parseGradleProfile, formatGradleProfileReport } from '../android/gradleProfile';
|
||||
import { formatGradleProfileReport, parseGradleProfile } from '../android/gradleProfile';
|
||||
import { Sentry } from '../sentry';
|
||||
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
|
||||
import { prebuildAsync } from '../common/prebuild';
|
||||
import { setupAsync } from '../common/setup';
|
||||
import { JobHooksRef, setupAsync } from '../common/setup';
|
||||
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
@@ -50,7 +50,21 @@ export default async function androidBuilder(ctx: BuildContext<Android.Job>): Pr
|
||||
}
|
||||
|
||||
async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
await setupAsync(ctx);
|
||||
const jobHooksRef: JobHooksRef = { current: null };
|
||||
try {
|
||||
await buildInnerAsync(ctx, jobHooksRef);
|
||||
} finally {
|
||||
// Native builds have no other drain path for the hook context's queued
|
||||
// step-metric uploads.
|
||||
await jobHooksRef.current?.customBuildContext.drainPendingMetricUploads();
|
||||
}
|
||||
}
|
||||
|
||||
async function buildInnerAsync(
|
||||
ctx: BuildContext<Android.Job>,
|
||||
jobHooksRef: JobHooksRef
|
||||
): Promise<void> {
|
||||
await setupAsync(ctx, { wrappedAnchors: ['install_node_modules'], jobHooksRef });
|
||||
const evictUsedBefore = new Date();
|
||||
const workingDirectory = ctx.getReactNativeProjectDirectory();
|
||||
const hasNativeCode = ctx.job.type === Workflow.GENERIC;
|
||||
|
||||
@@ -63,6 +63,7 @@ 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,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { runBuilderWithHooksAsync } from './common';
|
||||
import { runCustomBuildAsync } from './custom';
|
||||
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
|
||||
import { prebuildAsync } from '../common/prebuild';
|
||||
import { setupAsync } from '../common/setup';
|
||||
import { JobHooksRef, setupAsync } from '../common/setup';
|
||||
import { Artifacts, BuildContext } from '../context';
|
||||
import { configureXcodeProject } from '../ios/configure';
|
||||
import CredentialsManager from '../ios/credentials/manager';
|
||||
@@ -49,7 +49,22 @@ export default async function iosBuilder(ctx: BuildContext<Ios.Job>): Promise<Ar
|
||||
}
|
||||
|
||||
async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
await setupAsync(ctx);
|
||||
const jobHooksRef: JobHooksRef = { current: null };
|
||||
try {
|
||||
await buildInnerAsync(ctx, jobHooksRef);
|
||||
} finally {
|
||||
// Native builds have no other drain path for the hook context's queued
|
||||
// step-metric uploads. Must wrap the WHOLE body — iOS's existing inner
|
||||
// try/finally only starts after setupAsync returns.
|
||||
await jobHooksRef.current?.customBuildContext.drainPendingMetricUploads();
|
||||
}
|
||||
}
|
||||
|
||||
async function buildInnerAsync(
|
||||
ctx: BuildContext<Ios.Job>,
|
||||
jobHooksRef: JobHooksRef
|
||||
): Promise<void> {
|
||||
await setupAsync(ctx, { wrappedAnchors: ['install_node_modules'], jobHooksRef });
|
||||
const hasNativeCode = ctx.job.type === Workflow.GENERIC;
|
||||
const evictUsedBefore = new Date();
|
||||
const credentialsManager = new CredentialsManager(ctx);
|
||||
|
||||
@@ -196,4 +196,67 @@ describe('easBuildInternal', () => {
|
||||
|
||||
expect((newJob as any).refreshAdHocProvisioningProfile).toBe(true);
|
||||
});
|
||||
|
||||
describe('hooks retention', () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), child: jest.fn() } as any;
|
||||
const originalHooks = { before_install_node_modules: [{ run: 'echo original' }] };
|
||||
|
||||
function mockRegeneratedJob(hooks?: object): void {
|
||||
const internalJob = {
|
||||
platform: Platform.IOS,
|
||||
type: Workflow.GENERIC,
|
||||
triggeredBy: 'EAS_CLI',
|
||||
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
|
||||
projectRootDirectory: '.',
|
||||
secrets: {
|
||||
buildCredentials: {
|
||||
testapp: {
|
||||
distributionCertificate: {
|
||||
dataBase64: 'YmluYXJ5Y29udGVudDE=',
|
||||
password: 'distCertPassword',
|
||||
},
|
||||
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
|
||||
},
|
||||
},
|
||||
},
|
||||
initiatingUserId: 'user-id',
|
||||
appId: 'app-id',
|
||||
...(hooks ? { hooks } : null),
|
||||
};
|
||||
jest.mocked(spawn).mockResolvedValue({
|
||||
stdout: Buffer.from(JSON.stringify({ job: internalJob, metadata: {} })),
|
||||
stderr: Buffer.from(''),
|
||||
} as any);
|
||||
}
|
||||
|
||||
function runWith(jobHooks?: object): Promise<{ newJob: BuildJob }> {
|
||||
const job = {
|
||||
platform: Platform.IOS,
|
||||
buildProfile: 'preview',
|
||||
appId: 'app-id',
|
||||
initiatingUserId: 'user-id',
|
||||
secrets: { robotAccessToken: 'token' },
|
||||
...(jobHooks ? { hooks: jobHooks } : null),
|
||||
} as unknown as BuildJob;
|
||||
return runEasBuildInternalAsync({ job, logger, env: {}, cwd: '/tmp/project' });
|
||||
}
|
||||
|
||||
it('keeps the original hooks when the regenerated job has none', async () => {
|
||||
mockRegeneratedJob();
|
||||
const { newJob } = await runWith(originalHooks);
|
||||
expect((newJob as any).hooks).toEqual(originalHooks);
|
||||
});
|
||||
|
||||
it('lets the original hooks win over hooks in the regenerated job', async () => {
|
||||
mockRegeneratedJob({ after_install_node_modules: [{ run: 'echo from-eas-json' }] });
|
||||
const { newJob } = await runWith(originalHooks);
|
||||
expect((newJob as any).hooks).toEqual(originalHooks);
|
||||
});
|
||||
|
||||
it('carries no hooks when the original had none, even if the regenerated job adds some', async () => {
|
||||
mockRegeneratedJob({ before_install_node_modules: [{ run: 'echo from-eas-json' }] });
|
||||
const { newJob } = await runWith(undefined);
|
||||
expect((newJob as any).hooks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { BuildPhase, BuildTrigger, Hooks, Ios } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { executeHookStepsAsync } from '@expo/steps';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { BuildContext } from '../../context';
|
||||
import { runHookableBuildPhaseAsync } from '../hookableBuildPhase';
|
||||
import { ParsedJobHooks, parseJobHooksAsync } from '../jobHooks';
|
||||
|
||||
// The primitive's step-execution rules (failure()/success(), no-if continuation,
|
||||
// metric emission) are covered in @expo/steps. Here we mock it to unit-test the
|
||||
// wrapper's own orchestration: ordering, error precedence, and the env bridge.
|
||||
jest.mock('@expo/steps', () => ({
|
||||
...jest.requireActual('@expo/steps'),
|
||||
executeHookStepsAsync: jest.fn(),
|
||||
}));
|
||||
const executeHookStepsMock = jest.mocked(executeHookStepsAsync);
|
||||
|
||||
async function setup(hooks: Hooks | undefined): Promise<{
|
||||
ctx: BuildContext<Ios.Job>;
|
||||
hooks: ParsedJobHooks | null;
|
||||
}> {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn(() => logger),
|
||||
} as unknown as bunyan;
|
||||
const ctx = new BuildContext<Ios.Job>(
|
||||
{ ...createTestIosJob({ triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION }), hooks },
|
||||
{
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger,
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '/tmp/wd',
|
||||
}
|
||||
);
|
||||
jest
|
||||
.spyOn(ctx, 'runBuildPhase')
|
||||
.mockImplementation((_phase: BuildPhase, fn: () => Promise<unknown>) => fn() as never);
|
||||
return { ctx, hooks: await parseJobHooksAsync(ctx, ['install_node_modules']) };
|
||||
}
|
||||
|
||||
const runArgs = <TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>,
|
||||
hooks: ParsedJobHooks | null,
|
||||
fn: () => Promise<unknown>
|
||||
): Parameters<typeof runHookableBuildPhaseAsync>[0] => ({
|
||||
ctx,
|
||||
hooks,
|
||||
buildPhase: BuildPhase.INSTALL_DEPENDENCIES,
|
||||
anchor: 'install_node_modules',
|
||||
fn,
|
||||
});
|
||||
|
||||
describe(runHookableBuildPhaseAsync, () => {
|
||||
beforeEach(() => {
|
||||
executeHookStepsMock.mockReset();
|
||||
executeHookStepsMock.mockResolvedValue({ failedLocally: false, firstError: undefined });
|
||||
});
|
||||
|
||||
it('runs the phase once with no hook execution when there are no hooks', async () => {
|
||||
const { ctx, hooks } = await setup(undefined);
|
||||
const fn = jest.fn().mockResolvedValue('ok');
|
||||
expect(await runHookableBuildPhaseAsync(runArgs(ctx, hooks, fn))).toBe('ok');
|
||||
expect(ctx.runBuildPhase).toHaveBeenCalledTimes(1);
|
||||
expect(executeHookStepsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs before -> phase -> after in order and returns the phase result', async () => {
|
||||
const { ctx, hooks } = await setup({
|
||||
before_install_node_modules: [{ run: 'echo before' }],
|
||||
after_install_node_modules: [{ run: 'echo after' }],
|
||||
});
|
||||
const order: string[] = [];
|
||||
executeHookStepsMock.mockImplementation(async (_ctx, _entries, opts) => {
|
||||
order.push(opts.timing);
|
||||
return { failedLocally: false, firstError: undefined };
|
||||
});
|
||||
const fn = jest.fn().mockImplementation(async () => {
|
||||
order.push('phase');
|
||||
return 'result';
|
||||
});
|
||||
expect(await runHookableBuildPhaseAsync(runArgs(ctx, hooks, fn))).toBe('result');
|
||||
expect(order).toEqual(['before', 'phase', 'after']);
|
||||
expect(executeHookStepsMock).toHaveBeenLastCalledWith(hooks!.globalContext, expect.anything(), {
|
||||
anchor: 'install_node_modules',
|
||||
timing: 'after',
|
||||
anchorResult: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the phase and after-hooks when a before-hook fails', async () => {
|
||||
const { ctx, hooks } = await setup({
|
||||
before_install_node_modules: [{ run: 'echo before' }],
|
||||
after_install_node_modules: [{ run: 'echo after' }],
|
||||
});
|
||||
executeHookStepsMock.mockResolvedValue({
|
||||
failedLocally: true,
|
||||
firstError: new Error('before boom'),
|
||||
});
|
||||
const fn = jest.fn().mockResolvedValue('ok');
|
||||
await expect(runHookableBuildPhaseAsync(runArgs(ctx, hooks, fn))).rejects.toThrow(
|
||||
'Hook "before_install_node_modules" failed'
|
||||
);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(executeHookStepsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws the after-hook error when the phase succeeded but an after-hook fails', async () => {
|
||||
const { ctx, hooks } = await setup({
|
||||
after_install_node_modules: [{ run: 'echo after' }],
|
||||
});
|
||||
executeHookStepsMock.mockResolvedValue({
|
||||
failedLocally: true,
|
||||
firstError: new Error('after boom'),
|
||||
});
|
||||
await expect(
|
||||
runHookableBuildPhaseAsync(runArgs(ctx, hooks, jest.fn().mockResolvedValue('ok')))
|
||||
).rejects.toThrow('Hook "after_install_node_modules" failed');
|
||||
});
|
||||
|
||||
it('lets the phase error win over a failing after-hook, marking the context failed', async () => {
|
||||
const { ctx, hooks } = await setup({
|
||||
after_install_node_modules: [{ run: 'echo after' }],
|
||||
});
|
||||
const markAsFailed = jest.spyOn(hooks!.globalContext, 'markAsFailed');
|
||||
executeHookStepsMock.mockResolvedValue({
|
||||
failedLocally: true,
|
||||
firstError: new Error('after boom'),
|
||||
});
|
||||
await expect(
|
||||
runHookableBuildPhaseAsync(
|
||||
runArgs(ctx, hooks, jest.fn().mockRejectedValue(new Error('phase boom')))
|
||||
)
|
||||
).rejects.toThrow('phase boom');
|
||||
// after ran against the failed anchor
|
||||
expect(markAsFailed).toHaveBeenCalled();
|
||||
expect(executeHookStepsMock).toHaveBeenLastCalledWith(hooks!.globalContext, expect.anything(), {
|
||||
anchor: 'install_node_modules',
|
||||
timing: 'after',
|
||||
anchorResult: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes the hook context before and writes env back after each hook', async () => {
|
||||
const { ctx, hooks } = await setup({
|
||||
before_install_node_modules: [{ run: 'echo before' }],
|
||||
});
|
||||
const forwardRefresh = jest.spyOn(hooks!.customBuildContext, 'updateEnv');
|
||||
const writeBack = jest.spyOn(ctx, 'updateEnv');
|
||||
// Captured before the run: the reverse write-back replaces ctx.env, so
|
||||
// comparing the forward-refresh arg against the post-run ctx.env would drift.
|
||||
const envBeforeRun = ctx.env;
|
||||
await runHookableBuildPhaseAsync(runArgs(ctx, hooks, jest.fn().mockResolvedValue('ok')));
|
||||
expect(forwardRefresh).toHaveBeenCalledWith(envBeforeRun);
|
||||
// The job/metadata snapshot is synced onto the hook context before the hook.
|
||||
expect(hooks!.customBuildContext.job).toBe(ctx.job);
|
||||
expect(hooks!.customBuildContext.metadata).toBe(ctx.metadata);
|
||||
expect(writeBack).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { BuildTrigger, ErrorCode, Hooks, Ios, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { BuildContext } from '../../context';
|
||||
import { parseJobHooksAsync } from '../jobHooks';
|
||||
|
||||
const INSTALL: ['install_node_modules'] = ['install_node_modules'];
|
||||
|
||||
function createCtx(hooks: Hooks | undefined): {
|
||||
ctx: BuildContext<Ios.Job>;
|
||||
warn: jest.Mock;
|
||||
} {
|
||||
const warn = jest.fn();
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn,
|
||||
child: jest.fn(() => logger),
|
||||
} as unknown as bunyan;
|
||||
const ctx = new BuildContext<Ios.Job>(
|
||||
{ ...createTestIosJob({ triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION }), hooks },
|
||||
{
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger,
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '/tmp/wd',
|
||||
}
|
||||
);
|
||||
return { ctx, warn };
|
||||
}
|
||||
|
||||
describe(parseJobHooksAsync, () => {
|
||||
it('returns null when the job has no hooks', async () => {
|
||||
const { ctx } = createCtx(undefined);
|
||||
expect(await parseJobHooksAsync(ctx, INSTALL)).toBeNull();
|
||||
});
|
||||
|
||||
it('parses a wrapped-anchor hook into one entry in a checked-out context', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_install_node_modules: [{ run: 'echo hi' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.hookEntriesByKey.before_install_node_modules).toHaveLength(1);
|
||||
expect(parsed!.globalContext.wasCheckedOut()).toBe(true);
|
||||
});
|
||||
|
||||
it('throws a shape error for a malformed step, tagged with the hooks error code', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_install_node_modules: [{} as never],
|
||||
});
|
||||
await expect(parseJobHooksAsync(ctx, INSTALL)).rejects.toThrow(
|
||||
'Invalid steps in "hooks.before_install_node_modules"'
|
||||
);
|
||||
await expect(parseJobHooksAsync(ctx, INSTALL)).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.HOOKS_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an unknown hook key as inert and warns', async () => {
|
||||
const { ctx, warn } = createCtx({
|
||||
before_bogus: [{ run: 'echo hi' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey).toEqual({});
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown hook key "before_bogus"'));
|
||||
});
|
||||
|
||||
it('shape-checks a registered-but-unwrapped key, then warns and skips it', async () => {
|
||||
const { ctx, warn } = createCtx({
|
||||
before_submit: [{ run: 'echo hi' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey.before_submit).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
|
||||
});
|
||||
|
||||
it('shape-validates a registered-but-unwrapped key even though it is skipped', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_submit: [{} as never],
|
||||
});
|
||||
await expect(parseJobHooksAsync(ctx, INSTALL)).rejects.toThrow(
|
||||
'Invalid steps in "hooks.before_submit"'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an empty-array opt-out without validating or constructing it', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_install_node_modules: [],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey.before_install_node_modules).toBeUndefined();
|
||||
});
|
||||
|
||||
it('errors on an unknown function under a wrapped key (construction, not shape)', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_install_node_modules: [{ uses: 'eas/nonexistent' }],
|
||||
});
|
||||
await expect(parseJobHooksAsync(ctx, INSTALL)).rejects.toThrow(
|
||||
'Failed to parse hooks.before_install_node_modules'
|
||||
);
|
||||
});
|
||||
|
||||
it('parses a function group in an after-hook (no after-hook fence)', async () => {
|
||||
const { ctx } = createCtx({
|
||||
after_install_node_modules: [{ uses: 'eas/checkout' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey.after_install_node_modules).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shares one global context across the before and after entries of an anchor', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_install_node_modules: [{ run: 'echo before' }],
|
||||
after_install_node_modules: [{ run: 'echo after' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
const before = parsed!.hookEntriesByKey.before_install_node_modules![0];
|
||||
const after = parsed!.hookEntriesByKey.after_install_node_modules![0];
|
||||
expect(before.steps[0].ctx.global).toBe(parsed!.globalContext);
|
||||
expect(after.steps[0].ctx.global).toBe(parsed!.globalContext);
|
||||
});
|
||||
|
||||
it('wraps a cross-key duplicate step id in the aggregate error', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_install_node_modules: [{ id: 'dup', run: 'echo a' }],
|
||||
after_install_node_modules: [{ id: 'dup', run: 'echo b' }],
|
||||
});
|
||||
const result = parseJobHooksAsync(ctx, INSTALL);
|
||||
await expect(result).rejects.toThrow(UserError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.HOOKS_ERROR,
|
||||
message: expect.stringContaining("The job's hooks are invalid"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Android, BuildTrigger } from '@expo/eas-build-job';
|
||||
|
||||
import { createTestAndroidJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
|
||||
import { runHookableBuildPhaseAsync } from '../hookableBuildPhase';
|
||||
import { JobHooksRef, setupAsync } from '../setup';
|
||||
import { ParsedJobHooks, parseJobHooksAsync } from '../jobHooks';
|
||||
import { prepareProjectSourcesAsync } from '../projectSources';
|
||||
import { runHookIfPresent } from '../../utils/hooks';
|
||||
|
||||
jest.mock('../projectSources', () => ({ prepareProjectSourcesAsync: jest.fn() }));
|
||||
jest.mock('../easBuildInternal', () => ({
|
||||
resolveEnvFromBuildProfileAsync: jest.fn(),
|
||||
runEasBuildInternalAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../hookableBuildPhase', () => ({ runHookableBuildPhaseAsync: jest.fn() }));
|
||||
jest.mock('../jobHooks', () => ({ parseJobHooksAsync: jest.fn() }));
|
||||
jest.mock('../../utils/npmrc', () => ({ setUpNpmrcAsync: jest.fn() }));
|
||||
jest.mock('../../utils/hooks', () => ({
|
||||
Hook: { PRE_INSTALL: 'PRE_INSTALL' },
|
||||
runHookIfPresent: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/project', () => ({
|
||||
readAndLogPackageJson: jest.fn(() => ({})),
|
||||
readEasJsonContents: jest.fn(() => '{}'),
|
||||
readPackageJson: jest.fn(() => ({})),
|
||||
}));
|
||||
jest.mock('../../ios/xcodeEnv', () => ({ deleteXcodeEnvLocalIfExistsAsync: jest.fn() }));
|
||||
|
||||
// A sentinel the parse mock returns; the install wrapper must receive exactly it.
|
||||
const PARSED = { sentinel: true } as unknown as ParsedJobHooks;
|
||||
|
||||
describe(setupAsync, () => {
|
||||
let order: string[];
|
||||
|
||||
function createCtx(triggeredBy: BuildTrigger): BuildContext<Android.Job> {
|
||||
const ctx = new BuildContext<Android.Job>(
|
||||
{ ...createTestAndroidJob(), triggeredBy },
|
||||
{
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '/workingdir',
|
||||
}
|
||||
);
|
||||
// Run each phase closure inline; skip the phase markers.
|
||||
jest
|
||||
.spyOn(ctx, 'runBuildPhase')
|
||||
.mockImplementation((_phase, fn: () => Promise<unknown>) => fn() as never);
|
||||
// READ_APP_CONFIG reads this; a minimal config passes validateAppConfigAsync
|
||||
// and the EAS_BUILD_INTERNAL bundle-id/package checks.
|
||||
jest.spyOn(ctx, 'appConfig', 'get').mockReturnValue(
|
||||
Promise.resolve({
|
||||
android: { package: 'com.test' },
|
||||
ios: { bundleIdentifier: 'com.test' },
|
||||
} as never)
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
order = [];
|
||||
jest.mocked(prepareProjectSourcesAsync).mockImplementation(async () => {
|
||||
order.push('prepare-project');
|
||||
return { handled: false };
|
||||
});
|
||||
jest.mocked(resolveEnvFromBuildProfileAsync).mockImplementation(async () => {
|
||||
order.push('profile-env');
|
||||
return {};
|
||||
});
|
||||
jest.mocked(runHookIfPresent).mockImplementation(async () => {
|
||||
order.push('pre-install-hook');
|
||||
});
|
||||
jest.mocked(parseJobHooksAsync).mockImplementation(async () => {
|
||||
order.push('parse-hooks');
|
||||
return PARSED;
|
||||
});
|
||||
jest.mocked(runHookableBuildPhaseAsync).mockImplementation(async () => {
|
||||
// Record the wrapper call; deliberately do NOT run its `fn` so the install
|
||||
// internals stay out of the ordering test.
|
||||
order.push('install-wrapper');
|
||||
return undefined as never;
|
||||
});
|
||||
jest.mocked(runEasBuildInternalAsync).mockImplementation(async ({ job }) => ({
|
||||
newJob: job,
|
||||
newMetadata: {} as never,
|
||||
}));
|
||||
});
|
||||
|
||||
it('parses hooks after project prep + profile-env + pre-install hook, and before the install wrapper', async () => {
|
||||
const jobHooksRef: JobHooksRef = { current: null };
|
||||
await setupAsync(createCtx(BuildTrigger.GIT_BASED_INTEGRATION), {
|
||||
wrappedAnchors: ['install_node_modules'],
|
||||
jobHooksRef,
|
||||
});
|
||||
const parseAt = order.indexOf('parse-hooks');
|
||||
expect(order.indexOf('prepare-project')).toBeLessThan(parseAt);
|
||||
expect(order.indexOf('profile-env')).toBeLessThan(parseAt);
|
||||
expect(order.indexOf('pre-install-hook')).toBeLessThan(parseAt);
|
||||
expect(parseAt).toBeLessThan(order.indexOf('install-wrapper'));
|
||||
});
|
||||
|
||||
it('parses hooks unconditionally on a non-git-based build (never parked in the git-only branch)', async () => {
|
||||
const jobHooksRef: JobHooksRef = { current: null };
|
||||
await setupAsync(createCtx(BuildTrigger.EAS_CLI), {
|
||||
wrappedAnchors: ['install_node_modules'],
|
||||
jobHooksRef,
|
||||
});
|
||||
expect(parseJobHooksAsync).toHaveBeenCalledTimes(1);
|
||||
// The profile-env block is git-only; the parse must not be coupled to it.
|
||||
expect(resolveEnvFromBuildProfileAsync).not.toHaveBeenCalled();
|
||||
expect(order.indexOf('parse-hooks')).toBeLessThan(order.indexOf('install-wrapper'));
|
||||
});
|
||||
|
||||
it('publishes the parsed hooks to the ref before the install wrapper runs', async () => {
|
||||
const jobHooksRef: JobHooksRef = { current: null };
|
||||
await setupAsync(createCtx(BuildTrigger.GIT_BASED_INTEGRATION), {
|
||||
wrappedAnchors: ['install_node_modules'],
|
||||
jobHooksRef,
|
||||
});
|
||||
expect(jobHooksRef.current).toBe(PARSED);
|
||||
// The wrapper received the same parsed hooks the ref holds.
|
||||
expect(runHookableBuildPhaseAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ hooks: PARSED, anchor: 'install_node_modules' })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -152,6 +152,9 @@ function validateEasBuildInternalResult<TJob extends BuildJob>({
|
||||
// We want to retain values that we have set on the job.
|
||||
appId: oldJob.appId,
|
||||
initiatingUserId: oldJob.initiatingUserId,
|
||||
// Hooks are resolved server-side at submit; the mid-build eas.json
|
||||
// regeneration must not add, drop, or change them (the original wins).
|
||||
hooks: oldJob.hooks,
|
||||
...(oldJob.platform === Platform.IOS && oldJob.refreshAdHocProvisioningProfile === true
|
||||
? { refreshAdHocProvisioningProfile: true }
|
||||
: null),
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
BuildJob,
|
||||
BuildPhase,
|
||||
ErrorCode,
|
||||
HookAnchorId,
|
||||
HookKey,
|
||||
UserError,
|
||||
} from '@expo/eas-build-job';
|
||||
import { executeHookStepsAsync } from '@expo/steps';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { ParsedJobHooks } from './jobHooks';
|
||||
|
||||
/**
|
||||
* Runs a native build phase with its `before_<anchor>` / `after_<anchor>` hooks
|
||||
* wrapped around it: before-entries -> phase (in a try) -> after-entries, with
|
||||
* after-on-failure guaranteed. A failing before sequence skips the phase AND the
|
||||
* after-hooks (steps-engine parity). The phase error always wins over an
|
||||
* after-hook error. Passthrough (no hooks or no keys for this anchor) runs the
|
||||
* phase exactly once with zero overhead.
|
||||
*/
|
||||
export async function runHookableBuildPhaseAsync<TJob extends BuildJob, T>({
|
||||
ctx,
|
||||
hooks,
|
||||
buildPhase,
|
||||
anchor,
|
||||
fn,
|
||||
}: {
|
||||
ctx: BuildContext<TJob>;
|
||||
hooks: ParsedJobHooks | null;
|
||||
buildPhase: BuildPhase;
|
||||
anchor: HookAnchorId;
|
||||
fn: () => Promise<T>;
|
||||
}): Promise<T> {
|
||||
// runHookEntriesAsync no-ops when the key has no entries.
|
||||
if (hooks) {
|
||||
await runHookEntriesAsync({ ctx, hooks, timing: 'before', anchor });
|
||||
}
|
||||
|
||||
let result: T;
|
||||
try {
|
||||
result = await ctx.runBuildPhase(buildPhase, fn);
|
||||
} catch (phaseError) {
|
||||
if (hooks) {
|
||||
try {
|
||||
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: true });
|
||||
} catch (hookError) {
|
||||
// The phase error wins; still surface the after-hook failure in the log.
|
||||
ctx.logger.error({ err: hookError }, `after_${anchor} hook failed`);
|
||||
}
|
||||
}
|
||||
throw phaseError;
|
||||
}
|
||||
|
||||
if (hooks) {
|
||||
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: false });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function runHookEntriesAsync<TJob extends BuildJob>({
|
||||
ctx,
|
||||
hooks,
|
||||
timing,
|
||||
anchor,
|
||||
anchorFailed = false,
|
||||
}: {
|
||||
ctx: BuildContext<TJob>;
|
||||
hooks: ParsedJobHooks;
|
||||
timing: 'before' | 'after';
|
||||
anchor: HookAnchorId;
|
||||
anchorFailed?: boolean;
|
||||
}): Promise<void> {
|
||||
const hookKey: HookKey = `${timing}_${anchor}`;
|
||||
const entries = hooks.hookEntriesByKey[hookKey];
|
||||
if (!entries?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the hook context from the live BuildContext before running: env
|
||||
// moves as the build runs (set-env written back below; profile env), and
|
||||
// job/metadata are replaced by eas build:internal.
|
||||
hooks.customBuildContext.updateEnv(ctx.env);
|
||||
// Overwrite the job/metadata snapshot; NOT updateJobInformation, whose
|
||||
// delta-merge would duplicate environmentSecrets on each refresh.
|
||||
hooks.customBuildContext.job = ctx.job;
|
||||
hooks.customBuildContext.metadata = ctx.metadata;
|
||||
|
||||
if (timing === 'after' && anchorFailed) {
|
||||
// Mirror the engine: the anchor's failure is marked on the shared context so
|
||||
// `failure()` / `success()` on after-hook steps read the phase outcome.
|
||||
hooks.globalContext.markAsFailed();
|
||||
}
|
||||
|
||||
const { failedLocally, firstError } = await executeHookStepsAsync(hooks.globalContext, entries, {
|
||||
anchor,
|
||||
timing,
|
||||
...(timing === 'after' ? { anchorResult: anchorFailed ? 'failed' : 'success' } : null),
|
||||
});
|
||||
|
||||
// Reverse write-back BEFORE propagating a failure: env set by the entries that
|
||||
// did run must reach the build context even when a later entry failed, so the
|
||||
// native phases (and the outer lifecycle hooks) see it. Hook-carrying builds
|
||||
// are git-based, so updateEnv's gate does not fire here.
|
||||
ctx.updateEnv(hooks.globalContext.env);
|
||||
|
||||
if (failedLocally) {
|
||||
throw new UserError(
|
||||
ErrorCode.HOOKS_ERROR,
|
||||
`Hook "${hookKey}" failed: ${
|
||||
firstError instanceof Error ? firstError.message : String(firstError)
|
||||
}. Check the failing hook step's logs above.`,
|
||||
{ cause: firstError }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
BuildJob,
|
||||
ErrorCode,
|
||||
HookAnchorId,
|
||||
HookKey,
|
||||
UserError,
|
||||
parseHookKey,
|
||||
validateSteps,
|
||||
} from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildStep,
|
||||
BuildStepGlobalContext,
|
||||
HookEntry,
|
||||
constructHookEntriesAsync,
|
||||
validateHookStepsAsync,
|
||||
} from '@expo/steps';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
|
||||
import { getEasFunctions } from '../steps/easFunctions';
|
||||
|
||||
export interface ParsedJobHooks {
|
||||
globalContext: BuildStepGlobalContext;
|
||||
// The hook runner re-syncs this from the live BuildContext and runs the
|
||||
// hooks against it.
|
||||
customBuildContext: CustomBuildContext;
|
||||
// One HookEntry per authored hook step (a `uses:` group's whole expansion
|
||||
// stays inside one entry). The engine evaluates each entry's `if:` once and
|
||||
// isolates within-entry failures, so the boundary must survive to execution.
|
||||
hookEntriesByKey: Partial<Record<HookKey, HookEntry[]>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the native job's `hooks` into engine-ready entries, once, at setup
|
||||
* time. Shape-validates every registered key (reachable or not); constructs
|
||||
* entries only for the anchors this build actually wraps. Returns null when the
|
||||
* job declares no hooks.
|
||||
*/
|
||||
export async function parseJobHooksAsync<TJob extends BuildJob>(
|
||||
ctx: BuildContext<TJob>,
|
||||
wrappedAnchors: readonly HookAnchorId[]
|
||||
): Promise<ParsedJobHooks | null> {
|
||||
const hooks = ctx.job.hooks;
|
||||
if (!hooks || Object.keys(hooks).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const customBuildContext = new CustomBuildContext(ctx);
|
||||
const globalContext = new BuildStepGlobalContext(customBuildContext, false);
|
||||
// Hook steps run in the checked-out project directory, not the pre-checkout
|
||||
// target directory the global context defaults to.
|
||||
globalContext.markAsCheckedOut(ctx.logger);
|
||||
|
||||
const externalFunctions = getEasFunctions(customBuildContext);
|
||||
const externalFunctionGroups = getEasFunctionGroups(customBuildContext);
|
||||
|
||||
// Shape-validate every REGISTERED key, reachable or not (steps-world parity:
|
||||
// a malformed `before_submit` fails a steps build, so it fails here too), and
|
||||
// warn on keys this build won't run. An unknown key is inert — a key newer
|
||||
// than this worker must be ignored, never fail the job.
|
||||
for (const [key, steps] of Object.entries(hooks)) {
|
||||
const parsed = parseHookKey(key);
|
||||
if (parsed === null) {
|
||||
ctx.logger.warn(`Ignoring unknown hook key "${key}".`);
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(steps) && steps.length === 0) {
|
||||
// `[]` is an explicit opt-out; validateSteps requires >= 1 step.
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
validateSteps(steps);
|
||||
} catch (err) {
|
||||
throw new UserError(
|
||||
ErrorCode.HOOKS_ERROR,
|
||||
`Invalid steps in "hooks.${key}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
// Registered but not wrapped by this build: shape-checked above, then
|
||||
// skipped. Function existence is deliberately NOT checked (that happens only
|
||||
// during construction) — an unknown `uses:` under an unwrapped key warns
|
||||
// rather than failing the build.
|
||||
if (!wrappedAnchors.includes(parsed.anchorId)) {
|
||||
ctx.logger.warn(
|
||||
`Ignoring "hooks.${key}": this build does not run the "${parsed.anchorId}" step.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct entries for the wrapped anchors in EXECUTION order (per anchor,
|
||||
// before_ then after_) so generated step ids and output references follow the
|
||||
// order steps actually run. All entries share one globalContext, so env and
|
||||
// outputs accumulate across keys.
|
||||
const hookEntriesByKey: Partial<Record<HookKey, HookEntry[]>> = {};
|
||||
const orderedSteps: BuildStep[] = [];
|
||||
for (const anchor of wrappedAnchors) {
|
||||
for (const side of ['before', 'after'] as const) {
|
||||
const key: HookKey = `${side}_${anchor}`;
|
||||
const steps = hooks[key];
|
||||
if (!Array.isArray(steps) || steps.length === 0) {
|
||||
continue;
|
||||
}
|
||||
let entries: HookEntry[];
|
||||
try {
|
||||
entries = await constructHookEntriesAsync(globalContext, steps, {
|
||||
externalFunctions,
|
||||
externalFunctionGroups,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new UserError(
|
||||
ErrorCode.HOOKS_ERROR,
|
||||
`Failed to parse hooks.${key}: ${err instanceof Error ? err.message : String(err)}. Hooks use the same syntax as workflow steps.`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
hookEntriesByKey[key] = entries;
|
||||
for (const entry of entries) {
|
||||
orderedSteps.push(...entry.steps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One aggregate pass over every constructed step in execution order: unique
|
||||
// ids, output references, and platform allowance. Per-key validation misses
|
||||
// cross-key id collisions and would reject a legal after -> before output
|
||||
// reference as a future reference.
|
||||
try {
|
||||
await validateHookStepsAsync(globalContext, orderedSteps);
|
||||
} catch (err) {
|
||||
throw new UserError(
|
||||
ErrorCode.HOOKS_ERROR,
|
||||
`The job's hooks are invalid: ${err instanceof Error ? err.message : String(err)}. Fix the reported step ids or output references.`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
|
||||
return { globalContext, customBuildContext, hookEntriesByKey };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BuildJob,
|
||||
BuildPhase,
|
||||
BuildTrigger,
|
||||
HookAnchorId,
|
||||
Ios,
|
||||
Job,
|
||||
Platform,
|
||||
@@ -14,7 +15,9 @@ import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
|
||||
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from './easBuildInternal';
|
||||
import { runHookableBuildPhaseAsync } from './hookableBuildPhase';
|
||||
import { installDependenciesAsync, resolvePackagerDir } from './installDependencies';
|
||||
import { ParsedJobHooks, parseJobHooksAsync } from './jobHooks';
|
||||
import { prepareProjectSourcesAsync } from './projectSources';
|
||||
import { BuildContext } from '../context';
|
||||
import { deleteXcodeEnvLocalIfExistsAsync } from '../ios/xcodeEnv';
|
||||
@@ -26,7 +29,7 @@ import {
|
||||
shouldUseFrozenLockfile,
|
||||
} from '../utils/packageManager';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
|
||||
import { readAndLogPackageJson, readEasJsonContents, readPackageJson } from '../utils/project';
|
||||
import { retryAsync } from '../utils/retry';
|
||||
|
||||
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
|
||||
@@ -36,7 +39,22 @@ const INSTALL_DEPENDENCIES_KILL_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
class DoctorTimeoutError extends Error {}
|
||||
class InstallDependenciesTimeoutError extends Error {}
|
||||
|
||||
export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>): Promise<void> {
|
||||
/**
|
||||
* Mutable holder the builder passes into setupAsync. setupAsync assigns
|
||||
* `current` the moment hooks are parsed (before any hook runs), so the builder's
|
||||
* finally can drain queued metric uploads even if setup fails partway through.
|
||||
*/
|
||||
export interface JobHooksRef {
|
||||
current: ParsedJobHooks | null;
|
||||
}
|
||||
|
||||
export async function setupAsync<TJob extends BuildJob>(
|
||||
ctx: BuildContext<TJob>,
|
||||
{
|
||||
wrappedAnchors,
|
||||
jobHooksRef,
|
||||
}: { wrappedAnchors: readonly HookAnchorId[]; jobHooksRef: JobHooksRef }
|
||||
): Promise<void> {
|
||||
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {
|
||||
await retryAsync(
|
||||
async () => {
|
||||
@@ -81,32 +99,48 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
}
|
||||
});
|
||||
|
||||
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
|
||||
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
|
||||
readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
|
||||
const expoVersion =
|
||||
ctx.metadata?.sdkVersion ??
|
||||
getPackageVersionFromPackageJson({
|
||||
packageJson,
|
||||
packageName: 'expo',
|
||||
});
|
||||
// Parse hooks unconditionally, after every pre-install env mutation
|
||||
// (PREPARE_PROJECT, the git-based profile-env block, PRE_INSTALL_HOOK) and
|
||||
// before the first wrapped phase.
|
||||
jobHooksRef.current = await parseJobHooksAsync(ctx, wrappedAnchors);
|
||||
|
||||
const reactNativeVersion =
|
||||
ctx.metadata?.reactNativeVersion ??
|
||||
getPackageVersionFromPackageJson({
|
||||
packageJson,
|
||||
packageName: 'react-native',
|
||||
});
|
||||
await runHookableBuildPhaseAsync({
|
||||
ctx,
|
||||
hooks: jobHooksRef.current,
|
||||
buildPhase: BuildPhase.INSTALL_DEPENDENCIES,
|
||||
anchor: 'install_node_modules',
|
||||
fn: async () => {
|
||||
// Read package.json fresh here rather than reusing the READ_PACKAGE_JSON
|
||||
// capture: a before_install_node_modules hook may have patched it, and the
|
||||
// steps-world install already reads it fresh. Silent read — READ_PACKAGE_JSON
|
||||
// stays the only logging site.
|
||||
const freshPackageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
|
||||
const expoVersion =
|
||||
ctx.metadata?.sdkVersion ??
|
||||
getPackageVersionFromPackageJson({
|
||||
packageJson: freshPackageJson,
|
||||
packageName: 'expo',
|
||||
});
|
||||
|
||||
await runInstallDependenciesAsync(ctx, {
|
||||
useFrozenLockfile: shouldUseFrozenLockfile({
|
||||
env: ctx.env,
|
||||
sdkVersion: expoVersion,
|
||||
reactNativeVersion,
|
||||
}),
|
||||
});
|
||||
const reactNativeVersion =
|
||||
ctx.metadata?.reactNativeVersion ??
|
||||
getPackageVersionFromPackageJson({
|
||||
packageJson: freshPackageJson,
|
||||
packageName: 'react-native',
|
||||
});
|
||||
|
||||
await runInstallDependenciesAsync(ctx, {
|
||||
useFrozenLockfile: shouldUseFrozenLockfile({
|
||||
env: ctx.env,
|
||||
sdkVersion: expoVersion,
|
||||
reactNativeVersion,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
|
||||
@@ -140,7 +174,9 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
});
|
||||
}
|
||||
|
||||
const hasExpoPackage = !!packageJson.dependencies?.expo;
|
||||
// Read fresh: a before_install_node_modules hook may have added or removed
|
||||
// the expo dependency.
|
||||
const hasExpoPackage = !!readPackageJson(ctx.getReactNativeProjectDirectory()).dependencies?.expo;
|
||||
if (!ctx.env.EAS_BUILD_DISABLE_EXPO_DOCTOR_STEP && hasExpoPackage) {
|
||||
await ctx.runBuildPhase(BuildPhase.RUN_EXPO_DOCTOR, async () => {
|
||||
try {
|
||||
|
||||
@@ -72,41 +72,6 @@ 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;
|
||||
@@ -283,23 +248,11 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
);
|
||||
}
|
||||
|
||||
const oldMergeOrderEnv: Env = {
|
||||
...env,
|
||||
...this._env,
|
||||
__EAS_BUILD_ENVS_DIR: this.buildEnvsDirectory,
|
||||
};
|
||||
const newMergeOrderEnv: Env = {
|
||||
this._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,12 +9,18 @@ import {
|
||||
StaticJobInterpolationContext,
|
||||
} from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, ExternalBuildContextProvider, StepMetric } from '@expo/steps';
|
||||
import {
|
||||
BuildRuntimePlatform,
|
||||
ExternalBuildContextProvider,
|
||||
StepMetric,
|
||||
WorkflowHookMetric,
|
||||
} 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> = {
|
||||
@@ -165,6 +171,10 @@ 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);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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,210 +0,0 @@
|
||||
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 { 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?: RetryOptions['retryIntervalMs'];
|
||||
retries?: RetryOptions['retries'];
|
||||
}
|
||||
|
||||
class GCS {
|
||||
private readonly client = new Storage();
|
||||
|
||||
constructor(private readonly bucket: string) {
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
public static async uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync,
|
||||
retries = 2,
|
||||
retryIntervalMs = 30_000,
|
||||
}: UploadWithSignedUrlParams): Promise<string> {
|
||||
let resp;
|
||||
try {
|
||||
resp = await retryOnGCSUploadFailure(
|
||||
async () => {
|
||||
const src = await srcGeneratorAsync();
|
||||
return await fetch(signedUrl.url, {
|
||||
method: 'PUT',
|
||||
headers: signedUrl.headers,
|
||||
body: src,
|
||||
});
|
||||
},
|
||||
{
|
||||
retries,
|
||||
retryIntervalMs,
|
||||
}
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err instanceof FetchError) {
|
||||
throw new Error(`Failed to upload the file, reason: ${err.code}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (!resp.ok) {
|
||||
let body: string | undefined;
|
||||
try {
|
||||
body = await resp.text();
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`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}`; // 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;
|
||||
@@ -47,6 +47,7 @@ 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,8 +1,9 @@
|
||||
import * as Builders from './builders';
|
||||
import GCSLoggerStream from './gcs/LoggerStream';
|
||||
import GCS from './gcs/client';
|
||||
import RemoteLoggerStream from './logging/RemoteLoggerStream';
|
||||
|
||||
export { Builders, GCS, GCSLoggerStream };
|
||||
export { Builders, RemoteLoggerStream };
|
||||
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
|
||||
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
|
||||
|
||||
export {
|
||||
ArtifactToUpload,
|
||||
|
||||
+17
-32
@@ -8,17 +8,17 @@ import { Readable, Writable, pipeline } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import zlib from 'zlib';
|
||||
|
||||
import GCS from './client';
|
||||
import { type SignedUrl, uploadWithSignedUrl } from '../storage/uploadWithSignedUrl';
|
||||
|
||||
type PromiseResolveFn = (value?: void | PromiseLike<void> | undefined) => void;
|
||||
|
||||
const pipe = promisify(pipeline);
|
||||
|
||||
class GCSLoggerStream extends Writable {
|
||||
class RemoteLoggerStream extends Writable {
|
||||
public writable = true;
|
||||
private readonly logger: bunyan;
|
||||
private readonly uploadMethod?: GCSLoggerStream.UploadMethod;
|
||||
private readonly options: GCSLoggerStream.Options;
|
||||
private readonly uploadMethod?: RemoteLoggerStream.UploadMethod;
|
||||
private readonly options: RemoteLoggerStream.Options;
|
||||
private readonly temporaryLogsPath: string;
|
||||
private readonly temporaryCompressedLogsPath: string;
|
||||
private readonly compress: string | null;
|
||||
@@ -30,7 +30,7 @@ class GCSLoggerStream extends Writable {
|
||||
private writePromise?: Promise<any>;
|
||||
private cleanUpCalled: boolean = false;
|
||||
|
||||
constructor({ logger, uploadMethod, options }: GCSLoggerStream.Config) {
|
||||
constructor({ logger, uploadMethod, options }: RemoteLoggerStream.Config) {
|
||||
super();
|
||||
this.logger = logger;
|
||||
this.uploadMethod = uploadMethod;
|
||||
@@ -41,7 +41,7 @@ class GCSLoggerStream extends Writable {
|
||||
}
|
||||
|
||||
private findNormalizedHeader(name: string): string | null {
|
||||
if (!this.uploadMethod || 'client' in this.uploadMethod) {
|
||||
if (!this.uploadMethod) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ class GCSLoggerStream extends Writable {
|
||||
|
||||
await fs.remove(this.temporaryLogsPath);
|
||||
await fs.remove(this.temporaryCompressedLogsPath);
|
||||
this.logger.info('Cleaning up GCS log stream');
|
||||
this.logger.info('Cleaning up log stream');
|
||||
}
|
||||
|
||||
public write(rec: any): boolean {
|
||||
@@ -115,7 +115,7 @@ class GCSLoggerStream extends Writable {
|
||||
this.buffer = this.buffer.slice(buffer.length);
|
||||
this.hasChangesToUpload = true;
|
||||
} catch (err) {
|
||||
this.logger.error({ err, origin: 'gcs-logger' }, 'Failed to write logs to file');
|
||||
this.logger.error({ err, origin: 'remote-logger' }, 'Failed to write logs to file');
|
||||
} finally {
|
||||
this.writePromise = undefined;
|
||||
}
|
||||
@@ -144,7 +144,7 @@ class GCSLoggerStream extends Writable {
|
||||
return result;
|
||||
})
|
||||
.catch(err => {
|
||||
this.logger.error({ err }, 'Failed to upload logs file to GCS');
|
||||
this.logger.error({ err }, 'Failed to upload logs file');
|
||||
})
|
||||
.then(result => {
|
||||
this.uploadingPromise = undefined;
|
||||
@@ -165,25 +165,10 @@ class GCSLoggerStream extends Writable {
|
||||
);
|
||||
};
|
||||
|
||||
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 await uploadWithSignedUrl({
|
||||
signedUrl: this.uploadMethod.signedUrl,
|
||||
srcGeneratorAsync,
|
||||
});
|
||||
return Location;
|
||||
}
|
||||
|
||||
private async createCompressedStream(src: Readable): Promise<Readable> {
|
||||
@@ -212,7 +197,7 @@ class GCSLoggerStream extends Writable {
|
||||
}
|
||||
}
|
||||
|
||||
namespace GCSLoggerStream {
|
||||
namespace RemoteLoggerStream {
|
||||
export enum CompressionMethod {
|
||||
GZIP = 'gzip',
|
||||
BR = 'br',
|
||||
@@ -223,9 +208,9 @@ namespace GCSLoggerStream {
|
||||
compress?: CompressionMethod;
|
||||
}
|
||||
|
||||
export type UploadMethod =
|
||||
| { client: GCS; key: string; customTime: Date | null }
|
||||
| { signedUrl: GCS.SignedUrl };
|
||||
export type UploadMethod = {
|
||||
signedUrl: SignedUrl;
|
||||
};
|
||||
|
||||
export interface Config {
|
||||
logger: bunyan;
|
||||
@@ -234,4 +219,4 @@ namespace GCSLoggerStream {
|
||||
}
|
||||
}
|
||||
|
||||
export default GCSLoggerStream;
|
||||
export default RemoteLoggerStream;
|
||||
@@ -0,0 +1,117 @@
|
||||
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');
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isFileAttrRun,
|
||||
junitFileHasFileAttrs,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowNamesFromJUnitFile,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseJUnitTestCases,
|
||||
@@ -14,6 +15,33 @@ import {
|
||||
parseMaestroResultsFromFileAttrs,
|
||||
} from '../maestroResultParser';
|
||||
|
||||
describe(parseFailedFlowNamesFromJUnitFile, () => {
|
||||
it('returns the names of failed testcases, keeping slashes', async () => {
|
||||
vol.fromJSON({
|
||||
'/attempt/android-maestro-junit-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="3" failures="2">',
|
||||
' <testcase name="home" status="SUCCESS"/>',
|
||||
' <testcase name="sub/login" status="ERROR"><failure>boom</failure></testcase>',
|
||||
' <testcase name="checkout" status="ERROR"><failure>boom</failure></testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const failed = await parseFailedFlowNamesFromJUnitFile(
|
||||
'/attempt/android-maestro-junit-attempt-0.xml'
|
||||
);
|
||||
|
||||
expect([...failed].sort()).toEqual(['checkout', 'sub/login']);
|
||||
});
|
||||
|
||||
it('returns an empty set when the file cannot be read', async () => {
|
||||
expect((await parseFailedFlowNamesFromJUnitFile('/missing.xml')).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMaestroResults, () => {
|
||||
it('parses JUnit results and enriches with name→path map', async () => {
|
||||
vol.fromJSON({
|
||||
|
||||
@@ -80,6 +80,7 @@ describe(harvestFailureScreenshotsAsync, () => {
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 1,
|
||||
failedFlowNames: new Set(),
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -113,6 +114,7 @@ describe(harvestFailureScreenshotsAsync, () => {
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(),
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -136,6 +138,7 @@ describe(harvestFailureScreenshotsAsync, () => {
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(),
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -154,6 +157,7 @@ describe(harvestFailureScreenshotsAsync, () => {
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(),
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -175,6 +179,7 @@ describe(harvestFailureScreenshotsAsync, () => {
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 1,
|
||||
failedFlowNames: new Set(),
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -186,11 +191,304 @@ describe(harvestFailureScreenshotsAsync, () => {
|
||||
testsDirectory: path.join(testsDirectory, 'does-not-exist'),
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([]);
|
||||
});
|
||||
|
||||
// --- Maestro >= 2.7.0 bundle layout: <session>/<flow>[-shard-N]/screenshots/step-<NNN>-<slug>.png ---
|
||||
|
||||
async function makeBundle(args: {
|
||||
sessionDir: string;
|
||||
flowDir: string;
|
||||
stepFiles: { name: string; mtimeMs: number }[];
|
||||
sessionDirMtimeMs: number;
|
||||
}): Promise<void> {
|
||||
const sessionDir = path.join(testsDirectory, args.sessionDir);
|
||||
const screenshotsDir = path.join(sessionDir, args.flowDir, 'screenshots');
|
||||
await fs.mkdir(screenshotsDir, { recursive: true });
|
||||
for (const { name, mtimeMs } of args.stepFiles) {
|
||||
const filePath = path.join(screenshotsDir, name);
|
||||
await fs.writeFile(filePath, '');
|
||||
const when = new Date(mtimeMs);
|
||||
await fs.utimes(filePath, when, when);
|
||||
}
|
||||
const dirWhen = new Date(args.sessionDirMtimeMs);
|
||||
await fs.utimes(sessionDir, dirWhen, dirWhen);
|
||||
}
|
||||
|
||||
it('picks the highest step-NNN screenshot in a failed flow bundle (mtime as capturedAtMs)', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: '2026-01-01_100000',
|
||||
flowDir: 'Login Flow',
|
||||
stepFiles: [
|
||||
{ name: 'step-001-launchApp.png', mtimeMs: since + 1_000 },
|
||||
{ name: 'step-003-assertVisible-Welcome.png', mtimeMs: since + 3_000 },
|
||||
],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Login Flow']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([
|
||||
{
|
||||
fileAbsPath: path.join(
|
||||
testsDirectory,
|
||||
'2026-01-01_100000',
|
||||
'Login Flow',
|
||||
'screenshots',
|
||||
'step-003-assertVisible-Welcome.png'
|
||||
),
|
||||
displayName: 'Failure Screenshot: Login Flow (attempt 1)',
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName: 'Login Flow',
|
||||
attemptIndex: 0,
|
||||
capturedAtMs: since + 3_000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores final.png and non-step pngs in the bundle', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Flow',
|
||||
stepFiles: [
|
||||
{ name: 'step-002-tapOn.png', mtimeMs: since + 2_000 },
|
||||
{ name: 'final.png', mtimeMs: since + 9_000 },
|
||||
],
|
||||
sessionDirMtimeMs: since + 9_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Flow']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toHaveLength(1);
|
||||
expect(shots[0].fileAbsPath.endsWith('step-002-tapOn.png')).toBe(true);
|
||||
});
|
||||
|
||||
it('harvests only flows that JUnit reports as failed', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Failed Flow',
|
||||
stepFiles: [{ name: 'step-001-tapOn.png', mtimeMs: since + 1_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
// A passing flow can still leave a warned-step screenshot; it must not be harvested.
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Passed Flow',
|
||||
stepFiles: [{ name: 'step-001-assertVisible.png', mtimeMs: since + 1_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Failed Flow']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots.map(shot => shot.metadata.flowName)).toEqual(['Failed Flow']);
|
||||
});
|
||||
|
||||
it('strips the -shard-N suffix to match the JUnit flow name', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Checkout-shard-2',
|
||||
stepFiles: [{ name: 'step-004-tapOn-Pay.png', mtimeMs: since + 1_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Checkout']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toHaveLength(1);
|
||||
expect(shots[0].metadata.flowName).toBe('Checkout');
|
||||
});
|
||||
|
||||
it('resolves a combined shard + collision dir name to the flow', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Checkout-shard-2-3',
|
||||
stepFiles: [{ name: 'step-004-tapOn-Pay.png', mtimeMs: since + 1_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Checkout']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toHaveLength(1);
|
||||
expect(shots[0].metadata.flowName).toBe('Checkout');
|
||||
});
|
||||
|
||||
it('normalizes / to _ when matching the bundle dir to the JUnit flow name', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'sub_Login',
|
||||
stepFiles: [{ name: 'step-002-tapOn.png', mtimeMs: since + 1_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['sub/Login']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toHaveLength(1);
|
||||
expect(shots[0].metadata.flowName).toBe('sub_Login');
|
||||
});
|
||||
|
||||
it('skips a bundle whose dir name is ambiguous against the failed set', async () => {
|
||||
const since = 1_000_000;
|
||||
// 'foo-2' could be the literal flow 'foo-2' or a collision-suffixed 'foo' — unresolvable.
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'foo-2',
|
||||
stepFiles: [{ name: 'step-001-tapOn.png', mtimeMs: since + 1_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['foo', 'foo-2']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a step screenshot captured before capturedSinceMs (dir touched later)', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Flow',
|
||||
stepFiles: [{ name: 'step-001-tapOn.png', mtimeMs: since - 5_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Flow']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([]);
|
||||
});
|
||||
|
||||
it('yields nothing for a failed flow whose screenshots dir is empty', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Flow',
|
||||
stepFiles: [],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Flow']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([]);
|
||||
});
|
||||
|
||||
it('reduces two bundle dirs that resolve to the same flow to a single shot', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Dup',
|
||||
stepFiles: [{ name: 'step-001-tapOn.png', mtimeMs: since + 1_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Dup-2',
|
||||
stepFiles: [{ name: 'step-001-tapOn.png', mtimeMs: since + 2_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Dup']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toHaveLength(1);
|
||||
expect(shots[0].metadata.flowName).toBe('Dup');
|
||||
});
|
||||
|
||||
it('collects both a legacy flat screenshot and a new-layout bundle in the same session dir', async () => {
|
||||
const since = 1_000_000;
|
||||
const sessionDir = path.join(testsDirectory, 'session');
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sessionDir, `screenshot-❌-${since + 1_000}-(Legacy Flow).png`),
|
||||
''
|
||||
);
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Bundle Flow',
|
||||
stepFiles: [{ name: 'step-002-tapOn.png', mtimeMs: since + 2_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Bundle Flow']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots.map(shot => shot.metadata.flowName).sort()).toEqual([
|
||||
'Bundle Flow',
|
||||
'Legacy Flow',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(computePureFailureFlowNames, () => {
|
||||
|
||||
+82
-3
@@ -12,7 +12,23 @@ 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');
|
||||
@@ -22,6 +38,8 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
JSON.stringify({
|
||||
recording: path.basename(recordingPath),
|
||||
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -36,7 +54,7 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
callInputs: {
|
||||
recordings_json: [
|
||||
{
|
||||
udid: 'simulator-udid',
|
||||
udid: SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
directory: recordingDirectory,
|
||||
@@ -50,13 +68,16 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
name: 'iPhone 16 screen recording',
|
||||
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',
|
||||
udid: SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
firstFrameAt: '2026-07-10T10:00:00.000Z',
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -64,4 +85,62 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
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 }))
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ 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');
|
||||
|
||||
@@ -16,6 +16,7 @@ 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);
|
||||
},
|
||||
|
||||
@@ -122,6 +122,18 @@ async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]>
|
||||
}
|
||||
}
|
||||
|
||||
// Raw names (slashes kept) of the testcases a single JUnit file marks failed. Best-effort: an
|
||||
// unreadable or malformed file yields an empty set rather than throwing.
|
||||
export async function parseFailedFlowNamesFromJUnitFile(junitFile: string): Promise<Set<string>> {
|
||||
const failed = new Set<string>();
|
||||
for (const testcase of await parseJUnitFile(junitFile)) {
|
||||
if (testcase.status === 'failed') {
|
||||
failed.add(testcase.name);
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
|
||||
@@ -8,11 +8,20 @@ export interface ParsedFailureScreenshot {
|
||||
capturedAtMs: number;
|
||||
}
|
||||
|
||||
// Maestro failure screenshot: `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png`.
|
||||
// pre-v2.7.0 (Maestro <= 2.6.x): failure screenshots are flat files named
|
||||
// `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png` directly in the session dir.
|
||||
// The flow name may contain parentheses, so anchor on the `-(` after the epoch and the
|
||||
// `).png` suffix rather than scanning for parens.
|
||||
// `).png` suffix rather than scanning for parens. Remove this path once the build fleet is
|
||||
// entirely on Maestro >= 2.7.0.
|
||||
const FAILURE_SCREENSHOT_PATTERN = /^screenshot-(?:shard-\d+-)?❌-(\d+)-\((.+)\)\.png$/u;
|
||||
|
||||
// Maestro >= 2.7.0 bundle layout. Each flow gets its own dir under the session dir, with step
|
||||
// screenshots under `screenshots/` named `step-<NNN>-<slug>.png` (NNN is a 1-based, zero-padded
|
||||
// monotonic step sequence). Under the CLI (no `--analyze`) only failed/warned steps produce one.
|
||||
const STEP_SCREENSHOTS_DIRNAME = 'screenshots';
|
||||
const STEP_SCREENSHOT_PATTERN = /^step-(\d+)(?:-.*)?\.png$/;
|
||||
|
||||
// pre-v2.7.0: parses the legacy flat failure-screenshot filename.
|
||||
export function parseFailureScreenshotFilename(filename: string): ParsedFailureScreenshot | null {
|
||||
const match = filename.match(FAILURE_SCREENSHOT_PATTERN);
|
||||
if (!match) {
|
||||
@@ -41,65 +50,212 @@ export interface HarvestedScreenshot {
|
||||
};
|
||||
}
|
||||
|
||||
// Scans every debug dir under testsDirectory whose mtime is recent enough (mtime-based to cover
|
||||
// the maestro <2.5.0 bug that splits one invocation across two dirs), then keeps only screenshots
|
||||
// whose own capturedAtMs is at/after capturedSinceMs. Never throws: an unreadable dir is logged
|
||||
// and skipped so a screenshot harvest can't affect the maestro step outcome.
|
||||
// Harvests one attempt's failure screenshots from the maestro debug output. Walks each session
|
||||
// dir under testsDirectory whose mtime is recent enough (mtime-based to also cover the maestro
|
||||
// <2.5.0 bug that splits one invocation across two dirs). Within a session dir, a child is either
|
||||
// a flat pre-v2.7.0 screenshot file or a Maestro >= 2.7.0 per-flow bundle dir — the single
|
||||
// structural seam below. Never throws: an unreadable dir/file is logged and skipped so a
|
||||
// screenshot harvest can't affect the maestro step outcome.
|
||||
export async function harvestFailureScreenshotsAsync(args: {
|
||||
testsDirectory: string;
|
||||
capturedSinceMs: number;
|
||||
attemptIndex: number;
|
||||
// Flow names the JUnit report marks failed for THIS attempt (raw; normalized here). A v2.7.0
|
||||
// bundle dir carries no failure marker, so this is what tells a failed flow's bundle apart from
|
||||
// a passing flow that merely left a warned-step screenshot. Unused by the legacy path.
|
||||
failedFlowNames: ReadonlySet<string>;
|
||||
logger: bunyan;
|
||||
}): Promise<HarvestedScreenshot[]> {
|
||||
// Gate dirs by mtime floored to the second, so a dir created within the same second as the
|
||||
// attempt start isn't stat'd below the baseline. The exact capturedAtMs gate below is what
|
||||
// attempt start isn't stat'd below the baseline. The exact capturedAtMs gate per file is what
|
||||
// prevents mis-attributing an earlier attempt's screenshot.
|
||||
const dirSinceMtimeMs = Math.floor(args.capturedSinceMs / 1000) * 1000;
|
||||
let entries: Dirent[];
|
||||
// Bundle dir names already have '/' -> '_'; normalize the JUnit names the same way to match.
|
||||
const normalizedFailedFlowNames = new Set([...args.failedFlowNames].map(normalizeFlowName));
|
||||
|
||||
let sessionEntries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(args.testsDirectory, { withFileTypes: true });
|
||||
sessionEntries = await fs.readdir(args.testsDirectory, { withFileTypes: true });
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping screenshot harvest: cannot read ${args.testsDirectory}.`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: HarvestedScreenshot[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
const legacyShots: HarvestedScreenshot[] = [];
|
||||
const bundleShots: HarvestedScreenshot[] = [];
|
||||
for (const sessionEntry of sessionEntries) {
|
||||
if (!sessionEntry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const dirPath = path.join(args.testsDirectory, entry.name);
|
||||
let filenames: string[];
|
||||
const sessionDir = path.join(args.testsDirectory, sessionEntry.name);
|
||||
let children: Dirent[];
|
||||
try {
|
||||
if ((await fs.stat(dirPath)).mtimeMs < dirSinceMtimeMs) {
|
||||
if ((await fs.stat(sessionDir)).mtimeMs < dirSinceMtimeMs) {
|
||||
continue;
|
||||
}
|
||||
filenames = await fs.readdir(dirPath);
|
||||
children = await fs.readdir(sessionDir, { withFileTypes: true });
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping unreadable debug dir ${dirPath}.`);
|
||||
args.logger.info({ err }, `Skipping unreadable debug dir ${sessionDir}.`);
|
||||
continue;
|
||||
}
|
||||
for (const filename of filenames) {
|
||||
const parsed = parseFailureScreenshotFilename(filename);
|
||||
// Re-check capture time per file: a dir's mtime can advance after this attempt began (or
|
||||
// fall within the floored-second dir baseline), so the dir gate alone would re-attribute an
|
||||
// earlier attempt's screenshot to this one.
|
||||
if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
fileAbsPath: path.join(dirPath, filename),
|
||||
displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName: parsed.flowName,
|
||||
for (const child of children) {
|
||||
if (child.isDirectory()) {
|
||||
// Maestro >= 2.7.0: a per-flow bundle dir.
|
||||
const shot = await harvestFromBundleAsync({
|
||||
bundleDir: path.join(sessionDir, child.name),
|
||||
bundleName: child.name,
|
||||
normalizedFailedFlowNames,
|
||||
capturedSinceMs: args.capturedSinceMs,
|
||||
attemptIndex: args.attemptIndex,
|
||||
capturedAtMs: parsed.capturedAtMs,
|
||||
},
|
||||
});
|
||||
logger: args.logger,
|
||||
});
|
||||
if (shot !== null) {
|
||||
bundleShots.push(shot);
|
||||
}
|
||||
} else {
|
||||
// pre-v2.7.0: a flat failure screenshot file in the session dir.
|
||||
const shot = harvestLegacyFlatShot({
|
||||
fileName: child.name,
|
||||
sessionDir,
|
||||
capturedSinceMs: args.capturedSinceMs,
|
||||
attemptIndex: args.attemptIndex,
|
||||
});
|
||||
if (shot !== null) {
|
||||
legacyShots.push(shot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
// The website shows one screenshot per (flow, attempt). Two v2.7.0 bundle dirs can resolve to
|
||||
// the same flow — a collision dir (`<flow>-2`) or a duplicate flow name — so keep the latest.
|
||||
// Legacy shots need no dedupe: a flat filename is emitted once per failed flow, so there is no
|
||||
// `<flow>-2` collision to collapse (and deduping them would change historical behavior).
|
||||
return [...legacyShots, ...dedupeBundleShotsByFlowName(bundleShots)];
|
||||
}
|
||||
|
||||
// Maestro >= 2.7.0: resolve a bundle dir to its owning failed flow and return that flow's failure
|
||||
// screenshot — the highest-numbered step in `screenshots/`. A required failure halts the flow, so
|
||||
// the failed step is normally the last (highest-numbered) captured step; earlier warned steps have
|
||||
// lower numbers. Best-effort limitation: without reading Maestro's per-command JSON we can't tell a
|
||||
// failed-step frame from a warned-step frame, so if the failing command captured nothing (e.g. a
|
||||
// non-visible command like runScript) but an earlier optional step warned, we return that warned
|
||||
// frame instead. Returns null when the bundle isn't a failed flow, its name is ambiguous, or it has
|
||||
// no usable step screenshot. Never throws.
|
||||
async function harvestFromBundleAsync(args: {
|
||||
bundleDir: string;
|
||||
bundleName: string;
|
||||
normalizedFailedFlowNames: ReadonlySet<string>;
|
||||
capturedSinceMs: number;
|
||||
attemptIndex: number;
|
||||
logger: bunyan;
|
||||
}): Promise<HarvestedScreenshot | null> {
|
||||
const flowName = resolveBundleFlowName(args.bundleName, args.normalizedFailedFlowNames);
|
||||
if (flowName === null) {
|
||||
return null;
|
||||
}
|
||||
const screenshotsDir = path.join(args.bundleDir, STEP_SCREENSHOTS_DIRNAME);
|
||||
let filenames: string[];
|
||||
try {
|
||||
filenames = await fs.readdir(screenshotsDir);
|
||||
} catch {
|
||||
// No screenshots/ dir — e.g. the flow failed on a non-visible command, which captures none.
|
||||
return null;
|
||||
}
|
||||
|
||||
let best: { fileAbsPath: string; stepIndex: number; capturedAtMs: number } | null = null;
|
||||
for (const filename of filenames) {
|
||||
const match = filename.match(STEP_SCREENSHOT_PATTERN);
|
||||
if (match === null) {
|
||||
continue; // final.png and any non-step file
|
||||
}
|
||||
const stepIndex = Number(match[1]);
|
||||
const fileAbsPath = path.join(screenshotsDir, filename);
|
||||
let capturedAtMs: number;
|
||||
try {
|
||||
capturedAtMs = Math.round((await fs.stat(fileAbsPath)).mtimeMs);
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping unreadable screenshot ${fileAbsPath}.`);
|
||||
continue;
|
||||
}
|
||||
if (capturedAtMs < args.capturedSinceMs) {
|
||||
continue; // a prior attempt's frame (bundle dir touched later than this attempt started)
|
||||
}
|
||||
if (best === null || stepIndex > best.stepIndex) {
|
||||
best = { fileAbsPath, stepIndex, capturedAtMs };
|
||||
}
|
||||
}
|
||||
if (best === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
fileAbsPath: best.fileAbsPath,
|
||||
displayName: `Failure Screenshot: ${flowName} (attempt ${args.attemptIndex + 1})`,
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName,
|
||||
attemptIndex: args.attemptIndex,
|
||||
capturedAtMs: best.capturedAtMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Maestro names a bundle dir `<cleanFlow>` optionally + `-shard-<n>` (sharded) + `-<n>` (name
|
||||
// collision), where cleanFlow is the flow name with '/' -> '_'. Those suffixes share a namespace
|
||||
// with legitimate flow names, so we can't strip blindly (a flow may be named `x-shard-1` or `x-2`).
|
||||
// Enumerate the ways the dir name could peel back to a flow name, keep those that match a failed
|
||||
// flow, and use the result only if exactly one fits — otherwise skip rather than guess.
|
||||
function resolveBundleFlowName(
|
||||
bundleName: string,
|
||||
normalizedFailedFlowNames: ReadonlySet<string>
|
||||
): string | null {
|
||||
const withoutCollision = bundleName.replace(/-\d+$/, '');
|
||||
const preimages = [
|
||||
bundleName,
|
||||
withoutCollision,
|
||||
withoutCollision.replace(/-shard-\d+$/, ''),
|
||||
bundleName.replace(/-shard-\d+$/, ''),
|
||||
];
|
||||
const matches = new Set(preimages.filter(name => normalizedFailedFlowNames.has(name)));
|
||||
return matches.size === 1 ? [...matches][0] : null;
|
||||
}
|
||||
|
||||
// Keep one screenshot per flow name (attemptIndex is constant within a harvest call), preferring
|
||||
// the latest capture when two bundles resolve to the same flow.
|
||||
function dedupeBundleShotsByFlowName(shots: HarvestedScreenshot[]): HarvestedScreenshot[] {
|
||||
const byFlowName = new Map<string, HarvestedScreenshot>();
|
||||
for (const shot of shots) {
|
||||
const existing = byFlowName.get(shot.metadata.flowName);
|
||||
if (existing === undefined || shot.metadata.capturedAtMs > existing.metadata.capturedAtMs) {
|
||||
byFlowName.set(shot.metadata.flowName, shot);
|
||||
}
|
||||
}
|
||||
return [...byFlowName.values()];
|
||||
}
|
||||
|
||||
// pre-v2.7.0 (Maestro <= 2.6.x) reader: a flat `screenshot-...❌...png` file whose name carries the
|
||||
// flow name and capture epoch.
|
||||
function harvestLegacyFlatShot(args: {
|
||||
fileName: string;
|
||||
sessionDir: string;
|
||||
capturedSinceMs: number;
|
||||
attemptIndex: number;
|
||||
}): HarvestedScreenshot | null {
|
||||
const parsed = parseFailureScreenshotFilename(args.fileName);
|
||||
// Re-check capture time per file — a dir's mtime can advance after this attempt began, so the dir
|
||||
// gate alone would re-attribute an earlier attempt's screenshot to this one.
|
||||
if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
fileAbsPath: path.join(args.sessionDir, args.fileName),
|
||||
displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName: parsed.flowName,
|
||||
attemptIndex: args.attemptIndex,
|
||||
capturedAtMs: parsed.capturedAtMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Maestro substitutes '/' -> '_' in screenshot filenames (so HarvestedScreenshot.flowName
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
copyLatestAttemptXml,
|
||||
junitFileHasFileAttrs,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowNamesFromJUnitFile,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseJUnitTestCases,
|
||||
@@ -97,6 +98,7 @@ 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',
|
||||
@@ -275,11 +277,15 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
|
||||
// runs, so harvesting other formats would just create orphan artifacts the website hides.
|
||||
if (outputFormat === 'junit') {
|
||||
const failedFlowNames = outputPath
|
||||
? await parseFailedFlowNamesFromJUnitFile(outputPath)
|
||||
: new Set<string>();
|
||||
harvested.push(
|
||||
...(await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: attemptStartedAtMs,
|
||||
attemptIndex: attempt,
|
||||
failedFlowNames,
|
||||
logger,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -29,19 +29,18 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
|
||||
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync(ctx, {
|
||||
const { previewUrl } = 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, streamUrl },
|
||||
remoteConfig: { previewUrl },
|
||||
logger,
|
||||
});
|
||||
|
||||
|
||||
@@ -30,9 +30,24 @@ 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 {
|
||||
@@ -68,11 +83,15 @@ export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
await Promise.all(
|
||||
recordings.map(recording =>
|
||||
limit(async () => {
|
||||
const displayName = `${recording.deviceName} screen recording`;
|
||||
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);
|
||||
@@ -86,11 +105,14 @@ export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
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),
|
||||
|
||||
@@ -23,6 +23,7 @@ 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',
|
||||
|
||||
@@ -12,7 +12,7 @@ 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 = 30_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';
|
||||
@@ -114,7 +114,16 @@ export namespace IosSimulatorRecordingUtils {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`Forcing iOS Simulator recording process for ${recording.deviceName} to stop.`);
|
||||
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),
|
||||
|
||||
@@ -343,7 +343,7 @@ export async function startServeSimWithTunnelAsync(
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
}
|
||||
): Promise<{ previewUrl: string; streamUrl: string }> {
|
||||
): Promise<{ previewUrl: string }> {
|
||||
logger.info('Launching serve-sim with tunnel.');
|
||||
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
|
||||
const serveSim = spawnDetached({
|
||||
@@ -366,33 +366,30 @@ export async function startServeSimWithTunnelAsync(
|
||||
env,
|
||||
});
|
||||
|
||||
logger.info('Waiting for serve-sim to report tunnel and stream URLs.');
|
||||
logger.info('Waiting for serve-sim to report tunnel URL.');
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const output = serveSim.getOutput();
|
||||
const previewUrl = matchLabeledUrl({ output, label: 'Tunnel', baseDomain });
|
||||
const streamUrl = matchLabeledUrl({ output, label: 'Stream', baseDomain });
|
||||
if (previewUrl && streamUrl) {
|
||||
return { previewUrl, streamUrl };
|
||||
const previewUrl = matchTunnelUrl({ output, baseDomain });
|
||||
if (previewUrl) {
|
||||
return { previewUrl };
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for serve-sim to report Tunnel and Stream URLs. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
`Timed out waiting for serve-sim to report Tunnel URL. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
function matchLabeledUrl({
|
||||
function matchTunnelUrl({
|
||||
output,
|
||||
label,
|
||||
baseDomain,
|
||||
}: {
|
||||
output: string;
|
||||
label: string;
|
||||
baseDomain: string;
|
||||
}): string | null {
|
||||
const labelPattern = new RegExp(
|
||||
`${label}:\\s*(https:\\/\\/[a-z0-9-]+\\.${escapeRegExp(baseDomain)})`
|
||||
`Tunnel:\\s*(https:\\/\\/[a-z0-9-]+\\.${escapeRegExp(baseDomain)})`
|
||||
);
|
||||
const match = labelPattern.exec(output);
|
||||
return match ? match[1] : null;
|
||||
|
||||
+44
-102
@@ -1,10 +1,8 @@
|
||||
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 { type SignedUrl, uploadWithSignedUrl } from '../uploadWithSignedUrl';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
@@ -29,18 +27,15 @@ class DNSError extends ErrorWithCode {
|
||||
|
||||
const TEST_BUCKET = 'turtle-v2-test';
|
||||
|
||||
let googleApplicationCredentials: string | undefined;
|
||||
beforeAll(() => {
|
||||
googleApplicationCredentials = process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS = path.join(__dirname, 'mock-credentials.json');
|
||||
});
|
||||
function createSignedUrl(key: string, contentType: string): SignedUrl {
|
||||
return {
|
||||
url: `https://storage.googleapis.com/${TEST_BUCKET}/${key}?signature=test`,
|
||||
headers: { 'content-type': contentType },
|
||||
};
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS = googleApplicationCredentials;
|
||||
});
|
||||
|
||||
describe('GCS client', () => {
|
||||
describe('uploadWithPresignedURL function', () => {
|
||||
describe('signed URL uploader', () => {
|
||||
describe('uploadWithSignedUrl', () => {
|
||||
it('should throw an error if upload fails', async () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const res = {
|
||||
@@ -48,22 +43,16 @@ describe('GCS client', () => {
|
||||
status: 500,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock.mockImplementation(async () => res);
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
@@ -75,21 +64,15 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
const result = await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
});
|
||||
@@ -101,15 +84,9 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
@@ -119,10 +96,10 @@ describe('GCS client', () => {
|
||||
throw new DNSError('failed twice', 'EAI_AGAIN');
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
const result = await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -135,15 +112,9 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
@@ -159,10 +130,10 @@ describe('GCS client', () => {
|
||||
} as Response;
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
const result = await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -175,15 +146,9 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
@@ -196,10 +161,10 @@ describe('GCS client', () => {
|
||||
throw new DNSError('failed once');
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
const result = await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -212,15 +177,9 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
const lastDNSError = new DNSError('failed thrice');
|
||||
fetchMock
|
||||
@@ -235,10 +194,10 @@ describe('GCS client', () => {
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
})
|
||||
).rejects.toThrow(lastDNSError);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -251,15 +210,9 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => {
|
||||
@@ -282,10 +235,10 @@ describe('GCS client', () => {
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -298,15 +251,9 @@ describe('GCS client', () => {
|
||||
status: 200,
|
||||
} as Response;
|
||||
|
||||
const localImagePath = path.join(__dirname, 'cat.jpg');
|
||||
const key = 'cat.jpg';
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key,
|
||||
expirationTime: 30000,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
const nonDNSError = new ErrorWithCode('failed once', 'A_DIFFERENT_CODE');
|
||||
fetchMock
|
||||
@@ -315,10 +262,10 @@ describe('GCS client', () => {
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
|
||||
retryIntervalMs: 500,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
})
|
||||
).rejects.toThrow(nonDNSError);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
@@ -354,19 +301,14 @@ describe('GCS client', () => {
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const gcs = new GCS(TEST_BUCKET);
|
||||
const signedUrl = await gcs.createSignedUploadUrl({
|
||||
key: 'text.txt',
|
||||
expirationTime: 30000,
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
const signedUrl = createSignedUrl('text.txt', 'text/plain');
|
||||
|
||||
const bufferToUpload = randomBytes(16);
|
||||
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
const result = await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from(bufferToUpload),
|
||||
retryIntervalMs: 500,
|
||||
retryIntervalMs: 0,
|
||||
});
|
||||
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/text.txt');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
export interface RetryOptions {
|
||||
type RetryOptions = {
|
||||
retries: number;
|
||||
retryIntervalMs: number;
|
||||
shouldRetryOnError: (error: any) => boolean;
|
||||
shouldRetryOnError: (error: unknown) => boolean;
|
||||
shouldRetryOnResponse: (response: Response) => boolean;
|
||||
}
|
||||
};
|
||||
|
||||
// based on https://github.com/googleapis/nodejs-storage/blob/8ab50804fc7bae3bbd159bbb4adf65c02215b11b/src/storage.ts#L284-L320
|
||||
export async function retryOnGCSUploadFailure(
|
||||
export async function retryOnUploadFailure(
|
||||
fn: (attemptCount: number) => Promise<Response>,
|
||||
{
|
||||
retries,
|
||||
@@ -18,13 +18,14 @@ export async function retryOnGCSUploadFailure(
|
||||
return await retry(fn, {
|
||||
retries,
|
||||
retryIntervalMs,
|
||||
shouldRetryOnError: e => {
|
||||
shouldRetryOnError: err => {
|
||||
return (
|
||||
e.code === 'ENOTFOUND' ||
|
||||
e.code === 'EAI_AGAIN' ||
|
||||
e.code === 'ECONNRESET' ||
|
||||
e.code === 'ETIMEDOUT' ||
|
||||
e.code === 'EPIPE'
|
||||
isErrorWithCode(err) &&
|
||||
(err.code === 'ENOTFOUND' ||
|
||||
err.code === 'EAI_AGAIN' ||
|
||||
err.code === 'ECONNRESET' ||
|
||||
err.code === 'ETIMEDOUT' ||
|
||||
err.code === 'EPIPE')
|
||||
);
|
||||
},
|
||||
shouldRetryOnResponse: resp => {
|
||||
@@ -33,8 +34,12 @@ 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 and error
|
||||
* Wrapper used to execute an inner function and possibly retry it if it throws an 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
|
||||
@@ -55,7 +60,7 @@ async function retry(
|
||||
} else {
|
||||
return resp;
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
if (attemptCount === retries || !shouldRetryOnError(err)) {
|
||||
throw err;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import fetch, { FetchError } from 'node-fetch';
|
||||
import { Readable } from 'stream';
|
||||
import { URL } from 'url';
|
||||
|
||||
import { retryOnUploadFailure } from './retry';
|
||||
|
||||
export type SignedUrl = {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
export type UploadWithSignedUrlParams = {
|
||||
signedUrl: SignedUrl;
|
||||
srcGeneratorAsync: () => Promise<Readable>;
|
||||
retryIntervalMs?: number;
|
||||
retries?: number;
|
||||
};
|
||||
|
||||
export async function uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync,
|
||||
retries = 2,
|
||||
retryIntervalMs = 30_000,
|
||||
}: UploadWithSignedUrlParams): Promise<string> {
|
||||
let resp;
|
||||
try {
|
||||
resp = await retryOnUploadFailure(
|
||||
async () => {
|
||||
const src = await srcGeneratorAsync();
|
||||
return await fetch(signedUrl.url, {
|
||||
method: 'PUT',
|
||||
headers: signedUrl.headers,
|
||||
body: src,
|
||||
});
|
||||
},
|
||||
{ retries, retryIntervalMs }
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof FetchError) {
|
||||
throw new Error(`Failed to upload the file, reason: ${err.code}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
let body: string | undefined;
|
||||
try {
|
||||
body = await resp.text();
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`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}`;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.3",
|
||||
"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.0"
|
||||
"@expo/steps": "21.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.3",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { HOOK_ANCHORS, parseHookKey } from '../hooks';
|
||||
|
||||
describe('HOOK_ANCHORS', () => {
|
||||
it('contains the registered anchors', () => {
|
||||
expect([...HOOK_ANCHORS].sort()).toEqual([
|
||||
'checkout',
|
||||
'install_node_modules',
|
||||
'maestro_cloud',
|
||||
'maestro_tests',
|
||||
'submit',
|
||||
'update',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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,6 +71,30 @@ 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);
|
||||
|
||||
@@ -8,6 +8,7 @@ export enum ErrorCode {
|
||||
UNKNOWN_FASTLANE_RESIGN_ERROR = 'EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR',
|
||||
UNKNOWN_GRADLE_ERROR = 'EAS_BUILD_UNKNOWN_GRADLE_ERROR',
|
||||
BUILD_TIMEOUT_ERROR = 'EAS_BUILD_TIMEOUT_ERROR',
|
||||
HOOKS_ERROR = 'EAS_BUILD_HOOKS_ERROR',
|
||||
}
|
||||
|
||||
export interface ExternalBuildError {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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',
|
||||
'update',
|
||||
] 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,6 +24,7 @@ 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,6 +54,12 @@ 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({
|
||||
|
||||
+188
-174
File diff suppressed because it is too large.
Load diff
Generated
+536
-39
@@ -12174,6 +12174,87 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "workflowCachesPaginated",
|
||||
"description": "Caches associated with this app, ordered by last access time (most recent first).",
|
||||
"args": [
|
||||
{
|
||||
"name": "after",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "before",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "first",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "last",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "AppWorkflowCachesConnection",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "workflowCachingConfig",
|
||||
"description": "Per-type caching configuration for this app.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowCachingConfig",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "workflowDeviceTestCaseHistory",
|
||||
"description": null,
|
||||
@@ -13802,6 +13883,55 @@
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "setWorkflowCachingConfig",
|
||||
"description": "Set per-type caching configuration for the app.",
|
||||
"args": [
|
||||
{
|
||||
"name": "appId",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "config",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "WorkflowCachingConfigInput",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "App",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
@@ -23454,6 +23584,100 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "AppWorkflowCacheEdge",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "cursor",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "node",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowCache",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "AppWorkflowCachesConnection",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "edges",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "AppWorkflowCacheEdge",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "pageInfo",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "PageInfo",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "AppWorkflowFilterInput",
|
||||
@@ -58471,7 +58695,7 @@
|
||||
},
|
||||
{
|
||||
"name": "setPreferences",
|
||||
"description": "Sets user preferences. This is a key-value store for user-specific settings. Provided values are\nkey-level merged with existing values.",
|
||||
"description": "Legacy user preferences are no longer stored; this mutation accepts and discards\nits input. Use userPreference.set instead.",
|
||||
"args": [
|
||||
{
|
||||
"name": "preferences",
|
||||
@@ -58499,8 +58723,8 @@
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "No longer stored; this mutation has no effect. Use userPreference.set instead."
|
||||
},
|
||||
{
|
||||
"name": "setPrimarySecondFactorDevice",
|
||||
@@ -64215,6 +64439,22 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "workflowCache",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowCacheMutation",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "workflowDeviceTestCaseResult",
|
||||
"description": null,
|
||||
@@ -66775,8 +67015,8 @@
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "No longer stored. Use userPreference instead."
|
||||
},
|
||||
{
|
||||
"name": "primaryAccount",
|
||||
@@ -67765,16 +68005,12 @@
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "Use previewUrl instead."
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
@@ -75247,6 +75483,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "searchTerm",
|
||||
"description": "Case-insensitive substring match on update group message and branch name.\nEmbedded updates are matched on channel and runtime version.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "types",
|
||||
"description": null,
|
||||
@@ -76602,8 +76850,8 @@
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "No longer stored. Use userPreference instead."
|
||||
},
|
||||
{
|
||||
"name": "primaryAccount",
|
||||
@@ -77305,8 +77553,8 @@
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "No longer stored. Use userPreference instead."
|
||||
},
|
||||
{
|
||||
"name": "primaryAccount",
|
||||
@@ -80045,20 +80293,8 @@
|
||||
"name": "UserPreferencesOnboarding",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "selectedAccountName",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "Use userPreference.get(key: \"selected-account-id\") instead."
|
||||
"deprecationReason": "No longer stored; always null."
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
@@ -80112,7 +80348,7 @@
|
||||
},
|
||||
{
|
||||
"name": "deviceType",
|
||||
"description": "Can be null if the user has not selected one yet.",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
@@ -80124,7 +80360,7 @@
|
||||
},
|
||||
{
|
||||
"name": "environment",
|
||||
"description": "Can be null if the user has not selected one yet.",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
@@ -80136,7 +80372,7 @@
|
||||
},
|
||||
{
|
||||
"name": "isCLIDone",
|
||||
"description": "Set by CLI when the user has completed that phase. Used by the website to determine when\nthe next step can be shown.",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
@@ -80148,7 +80384,7 @@
|
||||
},
|
||||
{
|
||||
"name": "lastUsed",
|
||||
"description": "The last time when this object was updated.",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -80164,7 +80400,7 @@
|
||||
},
|
||||
{
|
||||
"name": "platform",
|
||||
"description": "User selects a platform for which they want to build the app. CLI uses this information to start the build.",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
@@ -86296,6 +86532,255 @@
|
||||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowCache",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "createdAt",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "DateTime",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "creatingActor",
|
||||
"description": "Actor who created this cache. Null if the creating user no longer exists.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "INTERFACE",
|
||||
"name": "Actor",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "gitBranchName",
|
||||
"description": "Git branch name the cache was created from. Null for user-scoped caches.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "hydratingBuild",
|
||||
"description": "Build that created this cache. Null if the cache was created by a job run\nor the build no longer exists.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "OBJECT",
|
||||
"name": "Build",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "hydratingJobRun",
|
||||
"description": "Job run that created this cache. Null if the cache was created by a build\nor the job run no longer exists.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "OBJECT",
|
||||
"name": "JobRun",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"description": "Cache key used to identify the cache entry.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "lastAccessedAt",
|
||||
"description": "Time the cache was last used to restore, or the creation time if it has\nnever been restored.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "DateTime",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "sizeBytes",
|
||||
"description": "Size of the cache archive in bytes. Null if the cache has not been hydrated yet.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Float",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowCacheMutation",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "deleteWorkflowCache",
|
||||
"description": null,
|
||||
"args": [
|
||||
{
|
||||
"name": "cacheId",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowCache",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowCachingConfig",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "ccacheEnabled",
|
||||
"description": "Whether the ccache compiler cache is enabled. Null if not explicitly\nconfigured, in which case the account-level default applies.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "gradleCacheEnabled",
|
||||
"description": "Whether the Gradle build cache is enabled. Null if not explicitly\nconfigured, in which case it is disabled.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "WorkflowCachingConfigInput",
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "ccacheEnabled",
|
||||
"description": "Pass true or false to explicitly enable or disable, null to reset to the account-level\ndefault, or omit the field to leave it unchanged.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "gradleCacheEnabled",
|
||||
"description": "Pass true or false to explicitly enable or disable, null to reset to the default\n(disabled), or omit the field to leave it unchanged.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"interfaces": null,
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowDeviceTestCaseErrorPattern",
|
||||
@@ -88995,6 +89480,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "reviewingActor",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "INTERFACE",
|
||||
"name": "Actor",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "updatedAt",
|
||||
"description": null,
|
||||
@@ -89020,8 +89517,8 @@
|
||||
"name": "UserActor",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "Use reviewingActor instead"
|
||||
},
|
||||
{
|
||||
"name": "workflowJob",
|
||||
@@ -93115,4 +93612,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "21.0.0",
|
||||
"version": "21.0.3",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,8 +48,8 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "21.0.0",
|
||||
"@expo/eas-json": "21.0.0",
|
||||
"@expo/eas-build-job": "21.0.3",
|
||||
"@expo/eas-json": "21.0.3",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "21.0.0",
|
||||
@@ -57,14 +57,14 @@
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/pkcs12": "0.1.3",
|
||||
"@expo/plist": "0.2.0",
|
||||
"@expo/plist": "0.3.5",
|
||||
"@expo/plugin-help": "5.1.23",
|
||||
"@expo/plugin-warn-if-update-available": "2.5.1",
|
||||
"@expo/prebuild-config": "8.0.17",
|
||||
"@expo/prebuild-config": "9.0.12",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.0.0",
|
||||
"@expo/steps": "21.0.3",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -121,7 +121,7 @@
|
||||
"semver": "7.5.4",
|
||||
"set-interval-async": "3.0.3",
|
||||
"slash": "3.0.0",
|
||||
"tar": "7.5.7",
|
||||
"tar": "7.5.19",
|
||||
"tar-stream": "3.1.7",
|
||||
"terminal-link": "2.1.1",
|
||||
"ts-deepmerge": "6.2.0",
|
||||
@@ -134,17 +134,16 @@
|
||||
"zod": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"@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",
|
||||
@@ -160,9 +159,8 @@
|
||||
"@types/tough-cookie": "4.0.2",
|
||||
"@types/uuid": "9.0.7",
|
||||
"@types/wrap-ansi": "3.0.0",
|
||||
"axios": "1.11.0",
|
||||
"axios": "1.18.1",
|
||||
"eslint-plugin-graphql": "4.0.0",
|
||||
"express": "4.20.0",
|
||||
"jest": "29.7.0",
|
||||
"memfs": "3.4.13",
|
||||
"mockdate": "3.0.5",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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,10 +4,18 @@ import { getExpoApiBaseUrl, getExpoWebsiteBaseUrl } from '../../api';
|
||||
import { AppPlatform, BuildFragment } from '../../graphql/generated';
|
||||
|
||||
export function getProjectDashboardUrl(accountName: string, projectName: string): string {
|
||||
return new URL(
|
||||
`/accounts/${accountName}/projects/${projectName}`,
|
||||
getExpoWebsiteBaseUrl()
|
||||
).toString();
|
||||
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();
|
||||
}
|
||||
|
||||
export function getBuildLogsUrl(build: BuildFragment, hash?: string): string {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import { makeUserMessage, streamChatResponseAsync } from '../chatClient';
|
||||
|
||||
const mockSpinners: { isSpinning: boolean; text: string }[] = [];
|
||||
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../ora', () => ({
|
||||
ora: () => {
|
||||
const spinner = {
|
||||
isSpinning: true,
|
||||
text: '',
|
||||
start: () => spinner,
|
||||
stop: () => {
|
||||
spinner.isSpinning = false;
|
||||
return spinner;
|
||||
},
|
||||
};
|
||||
mockSpinners.push(spinner);
|
||||
return spinner;
|
||||
},
|
||||
}));
|
||||
|
||||
const WEBSITE_ORIGIN = 'https://expo.dev';
|
||||
|
||||
function sseBody(frames: object[]): string {
|
||||
return [...frames.map(frame => `data: ${JSON.stringify(frame)}`), 'data: [DONE]', ''].join(
|
||||
'\n\n'
|
||||
);
|
||||
}
|
||||
|
||||
describe(streamChatResponseAsync, () => {
|
||||
let writeSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
nock.cleanAll();
|
||||
mockSpinners.length = 0;
|
||||
writeSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('streams text deltas to stdout and returns the full text', async () => {
|
||||
nock(WEBSITE_ORIGIN)
|
||||
.post('/api/chat')
|
||||
.reply(
|
||||
200,
|
||||
sseBody([
|
||||
{ type: 'start' },
|
||||
{ type: 'tool-input-available', toolCallId: 't1', toolName: 'get_latest_builds' },
|
||||
{ type: 'text-start', id: '0' },
|
||||
{ type: 'text-delta', id: '0', delta: 'Your latest ' },
|
||||
{ type: 'text-delta', id: '0', delta: 'build passed.' },
|
||||
{ type: 'finish' },
|
||||
]),
|
||||
{ 'content-type': 'text/event-stream' }
|
||||
);
|
||||
|
||||
const result = await streamChatResponseAsync({
|
||||
messages: [makeUserMessage('how are my builds?')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"session-id","version":"1"}',
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const written = writeSpy.mock.calls.map(call => call[0]).join('');
|
||||
expect(written).toContain('Your latest ');
|
||||
expect(written).toContain('build passed.');
|
||||
expect(result.text).toBe('Your latest build passed.');
|
||||
});
|
||||
|
||||
it('neutralizes terminal control characters in streamed text deltas', async () => {
|
||||
const delta = 'Safe \u001b[2Jtext\u009b31m\u0007.';
|
||||
nock(WEBSITE_ORIGIN)
|
||||
.post('/api/chat')
|
||||
.reply(200, sseBody([{ type: 'text-delta', id: '0', delta }]), {
|
||||
'content-type': 'text/event-stream',
|
||||
});
|
||||
|
||||
const result = await streamChatResponseAsync({
|
||||
messages: [makeUserMessage('show the build log')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const written = writeSpy.mock.calls.map(call => call[0]).join('');
|
||||
expect(written).toContain('Safe [2Jtext31m.');
|
||||
expect(written).not.toContain('\u001b[2J');
|
||||
expect(written).not.toContain('\u009b31m');
|
||||
expect(written).not.toContain('\u0007');
|
||||
expect(result.text).toBe(delta);
|
||||
});
|
||||
|
||||
it('keeps the spinner active until a full line is ready instead of clearing it on the first delta', async () => {
|
||||
nock(WEBSITE_ORIGIN)
|
||||
.post('/api/chat')
|
||||
.reply(
|
||||
200,
|
||||
sseBody([
|
||||
{ type: 'text-start', id: '0' },
|
||||
{ type: 'text-delta', id: '0', delta: 'Looking good' },
|
||||
{ type: 'tool-input-available', toolCallId: 't1', toolName: 'get_latest_builds' },
|
||||
{ type: 'text-delta', id: '0', delta: ' so far.' },
|
||||
{ type: 'finish' },
|
||||
]),
|
||||
{ 'content-type': 'text/event-stream' }
|
||||
);
|
||||
|
||||
const result = await streamChatResponseAsync({
|
||||
messages: [makeUserMessage('how are my builds?')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const written = writeSpy.mock.calls.map(call => call[0]).join('');
|
||||
// No newline arrives until the end, so nothing is flushed mid-stream: the tool activity
|
||||
// is shown on the still-running spinner rather than being printed inline.
|
||||
expect(mockSpinners[0].text).toContain('Looking up builds');
|
||||
expect(written).not.toContain('Looking up builds');
|
||||
// The buffered text is rendered in one go once the response completes.
|
||||
expect(written).toContain('Looking good so far.');
|
||||
expect(result.text).toBe('Looking good so far.');
|
||||
});
|
||||
|
||||
it('captures tool calls with inputs and outputs without writing to stdout when not streaming', async () => {
|
||||
nock(WEBSITE_ORIGIN)
|
||||
.post('/api/chat')
|
||||
.reply(
|
||||
200,
|
||||
sseBody([
|
||||
{ type: 'start-step' },
|
||||
{
|
||||
type: 'tool-input-available',
|
||||
toolCallId: 't1',
|
||||
toolName: 'get_latest_builds',
|
||||
input: { limit: 1 },
|
||||
},
|
||||
{ type: 'tool-output-available', toolCallId: 't1', output: { builds: ['b1'] } },
|
||||
{ type: 'finish-step' },
|
||||
{ type: 'start-step' },
|
||||
{ type: 'text-delta', delta: 'Done.' },
|
||||
{ type: 'finish-step' },
|
||||
{ type: 'finish' },
|
||||
])
|
||||
);
|
||||
|
||||
const result = await streamChatResponseAsync({
|
||||
messages: [makeUserMessage('builds?')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: false,
|
||||
});
|
||||
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
expect(result.text).toBe('Done.');
|
||||
expect(result.toolCalls).toEqual([
|
||||
{
|
||||
toolName: 'get_latest_builds',
|
||||
input: { limit: 1 },
|
||||
output: { builds: ['b1'] },
|
||||
errorText: undefined,
|
||||
},
|
||||
]);
|
||||
expect(result.assistantMessage).toEqual(
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{ type: 'step-start' },
|
||||
{
|
||||
type: 'tool-get_latest_builds',
|
||||
toolCallId: 't1',
|
||||
state: 'output-available',
|
||||
input: { limit: 1 },
|
||||
output: { builds: ['b1'] },
|
||||
},
|
||||
{ type: 'step-start' },
|
||||
{ type: 'text', text: 'Done.' },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the session secret as a cookie and the account as a header', async () => {
|
||||
let capturedHeaders: Record<string, string | string[]> = {};
|
||||
let capturedBody: any;
|
||||
nock(WEBSITE_ORIGIN)
|
||||
.post('/api/chat')
|
||||
.reply(function (_uri, body) {
|
||||
capturedHeaders = this.req.headers;
|
||||
capturedBody = body;
|
||||
return [200, sseBody([{ type: 'text-delta', delta: 'hi' }])];
|
||||
});
|
||||
|
||||
await streamChatResponseAsync({
|
||||
messages: [makeUserMessage('hello')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const cookie = [capturedHeaders.cookie].flat().join('; ');
|
||||
expect(cookie).toContain('io.expo.auth.sessionSecret=');
|
||||
expect(cookie).toContain(encodeURIComponent('{"id":"abc","version":"1"}'));
|
||||
expect([capturedHeaders['x-account-name']].flat()[0]).toBe('my-account');
|
||||
expect(capturedBody.messages[0].role).toBe('system');
|
||||
expect(capturedBody.messages[0].parts[0].text).toContain('https://expo.dev');
|
||||
expect(capturedBody.messages[1]).toEqual(
|
||||
expect.objectContaining({ role: 'user', parts: [{ type: 'text', text: 'hello' }] })
|
||||
);
|
||||
});
|
||||
|
||||
it('maps a 429 response to a usage-limit error', async () => {
|
||||
nock(WEBSITE_ORIGIN).post('/api/chat').reply(429, 'Chat usage limit reached');
|
||||
|
||||
await expect(
|
||||
streamChatResponseAsync({
|
||||
messages: [makeUserMessage('hello')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: false,
|
||||
})
|
||||
).rejects.toThrow(/usage limit/);
|
||||
});
|
||||
|
||||
it('maps a 403 response to a not-enabled error', async () => {
|
||||
nock(WEBSITE_ORIGIN).post('/api/chat').reply(403, 'Forbidden');
|
||||
|
||||
await expect(
|
||||
streamChatResponseAsync({
|
||||
messages: [makeUserMessage('hello')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: false,
|
||||
})
|
||||
).rejects.toThrow(/not enabled/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
createMarkdownRenderState,
|
||||
renderInlineMarkdown,
|
||||
renderMarkdownLine,
|
||||
wrapToWidth,
|
||||
} from '../renderMarkdown';
|
||||
|
||||
describe(renderInlineMarkdown, () => {
|
||||
it('renders bold with chalk.bold', () => {
|
||||
expect(renderInlineMarkdown('a **bold** word')).toBe(`a ${chalk.bold('bold')} word`);
|
||||
expect(renderInlineMarkdown('a __bold__ word')).toBe(`a ${chalk.bold('bold')} word`);
|
||||
});
|
||||
|
||||
it('renders inline code with chalk.cyan', () => {
|
||||
expect(renderInlineMarkdown('run `eas build` now')).toBe(`run ${chalk.cyan('eas build')} now`);
|
||||
});
|
||||
|
||||
it('leaves underscores inside a word untouched', () => {
|
||||
expect(renderInlineMarkdown('my_project_name')).toBe('my_project_name');
|
||||
});
|
||||
|
||||
it('renders links as underlined label with dimmed url', () => {
|
||||
expect(renderInlineMarkdown('see [build](https://expo.dev/b/1)')).toBe(
|
||||
`see ${chalk.underline('build')} ${chalk.dim('(https://expo.dev/b/1)')}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(renderMarkdownLine, () => {
|
||||
it('renders bullet lines with a bullet glyph', () => {
|
||||
const state = createMarkdownRenderState();
|
||||
expect(renderMarkdownLine('- first', state)).toBe(`${chalk.dim('•')} first`);
|
||||
});
|
||||
|
||||
it('drops code fences and dims code block contents', () => {
|
||||
const state = createMarkdownRenderState();
|
||||
expect(renderMarkdownLine('```ts', state)).toBeNull();
|
||||
expect(state.inCodeBlock).toBe(true);
|
||||
expect(renderMarkdownLine('const x = 1;', state)).toBe(chalk.gray('const x = 1;'));
|
||||
expect(renderMarkdownLine('```', state)).toBeNull();
|
||||
expect(state.inCodeBlock).toBe(false);
|
||||
});
|
||||
|
||||
it('applies inline styling to plain lines', () => {
|
||||
const state = createMarkdownRenderState();
|
||||
expect(renderMarkdownLine('the **latest** build', state)).toBe(
|
||||
`the ${chalk.bold('latest')} build`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(wrapToWidth, () => {
|
||||
it('word-wraps to the given width', () => {
|
||||
const segments = wrapToWidth('one two three four five', 12);
|
||||
expect(segments.length).toBeGreaterThan(1);
|
||||
for (const segment of segments) {
|
||||
expect(segment.length).toBeLessThanOrEqual(12);
|
||||
}
|
||||
expect(segments.join(' ').replace(/\s+/g, ' ').trim()).toBe('one two three four five');
|
||||
});
|
||||
|
||||
it('hard-breaks long unbreakable tokens like URLs', () => {
|
||||
const url = 'https://expo.dev/accounts/acme/projects/mobile/builds/cb7c9bdd-56e6-42b8';
|
||||
const segments = wrapToWidth(url, 20);
|
||||
expect(segments.length).toBeGreaterThan(1);
|
||||
for (const segment of segments) {
|
||||
expect(segment.length).toBeLessThanOrEqual(20);
|
||||
}
|
||||
expect(segments.join('')).toBe(url);
|
||||
});
|
||||
|
||||
it('returns the text unchanged when the width is too small to wrap into', () => {
|
||||
expect(wrapToWidth('anything at all here', 4)).toEqual(['anything at all here']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { createChatReplInput } from '../replInput';
|
||||
|
||||
describe(createChatReplInput, () => {
|
||||
it('resolves the typed line', async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const replInput = createChatReplInput({ input, output, terminal: false });
|
||||
|
||||
const pending = replInput.askAsync('> ');
|
||||
input.write('how are my builds?\n');
|
||||
|
||||
expect(await pending).toBe('how are my builds?');
|
||||
replInput.close();
|
||||
});
|
||||
|
||||
it('reads input even when the stream was left paused (e.g. by a spinner)', async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const replInput = createChatReplInput({ input, output, terminal: false });
|
||||
input.pause();
|
||||
|
||||
const pending = replInput.askAsync('> ');
|
||||
input.write('hello\n');
|
||||
|
||||
expect(await pending).toBe('hello');
|
||||
replInput.close();
|
||||
});
|
||||
|
||||
it('resolves null when the input stream ends', async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const replInput = createChatReplInput({ input, output, terminal: false });
|
||||
|
||||
const pending = replInput.askAsync('> ');
|
||||
input.end();
|
||||
|
||||
expect(await pending).toBeNull();
|
||||
replInput.close();
|
||||
});
|
||||
|
||||
it('resolves null immediately once closed', async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const replInput = createChatReplInput({ input, output, terminal: false });
|
||||
replInput.close();
|
||||
|
||||
expect(await replInput.askAsync('> ')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getExpoWebsiteBaseUrl } from '../api';
|
||||
import fetch, { RequestError } from '../fetch';
|
||||
import Log from '../log';
|
||||
import { ora } from '../ora';
|
||||
import {
|
||||
MarkdownRenderState,
|
||||
createMarkdownRenderState,
|
||||
renderMarkdownLine,
|
||||
wrapToWidth,
|
||||
} from './renderMarkdown';
|
||||
|
||||
const CHAT_SYSTEM_GUIDANCE = [
|
||||
'You are being used through the EAS CLI in a terminal, not the web dashboard.',
|
||||
'The user cannot see the interactive tables the dashboard renders from tool results, so include the important details directly in your text answer instead of assuming a table is shown.',
|
||||
'Whenever you reference a specific artifact (a build, update, submission, deployment, workflow run, channel, or branch), include its direct https://expo.dev link so the user can open it.',
|
||||
'Use only simple markdown: bold, inline code, and bullet or numbered lists.',
|
||||
].join(' ');
|
||||
|
||||
function getWebsiteAuthCookieName(): string {
|
||||
if (process.env.EXPO_STAGING) {
|
||||
return 'staging.expo.auth.sessionSecret';
|
||||
} else if (process.env.EXPO_LOCAL) {
|
||||
return 'local.expo.auth.sessionSecret';
|
||||
} else {
|
||||
return 'io.expo.auth.sessionSecret';
|
||||
}
|
||||
}
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
get_projects: 'Looking up your projects',
|
||||
get_latest_builds: 'Looking up builds',
|
||||
get_build: 'Looking up a build',
|
||||
get_build_logs: 'Reading build logs',
|
||||
get_activity: 'Looking up recent activity',
|
||||
get_updates: 'Looking up updates',
|
||||
get_workflow_runs: 'Looking up workflow runs',
|
||||
get_workflow_run: 'Looking up a workflow run',
|
||||
get_workflow_run_logs: 'Reading workflow logs',
|
||||
get_submissions: 'Looking up submissions',
|
||||
get_channels: 'Looking up channels',
|
||||
get_branches: 'Looking up branches',
|
||||
get_worker_deployments: 'Looking up deployments',
|
||||
get_environment_variables: 'Looking up environment variables',
|
||||
get_account_members: 'Looking up account members',
|
||||
get_usage: 'Looking up usage',
|
||||
get_usage_history: 'Looking up usage history',
|
||||
get_invoices: 'Looking up invoices',
|
||||
search_expo_docs: 'Searching Expo docs',
|
||||
navigate: 'Finding the right page',
|
||||
};
|
||||
|
||||
function describeTool(toolName: string): string {
|
||||
return TOOL_LABELS[toolName] ?? `Using ${toolName.replace(/_/g, ' ')}`;
|
||||
}
|
||||
|
||||
const ASSISTANT_LABEL_TEXT = 'Expo';
|
||||
const ASSISTANT_LABEL = `${chalk.bold.magenta(ASSISTANT_LABEL_TEXT)}${chalk.dim(' > ')}`;
|
||||
const ASSISTANT_INDENT = ' '.repeat(`${ASSISTANT_LABEL_TEXT} > `.length);
|
||||
const ASSISTANT_SPINNER_PREFIX = `${chalk.bold.magenta(ASSISTANT_LABEL_TEXT)}${chalk.dim(' >')}`;
|
||||
|
||||
type UIMessageStreamFrame = {
|
||||
type: string;
|
||||
id?: string;
|
||||
delta?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
type ChatMessagePart =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'step-start' }
|
||||
| {
|
||||
type: `tool-${string}`;
|
||||
toolCallId: string;
|
||||
state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error';
|
||||
input: unknown;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
parts: ChatMessagePart[];
|
||||
};
|
||||
|
||||
function makeSystemMessage(text: string): ChatMessage {
|
||||
return { id: uuidv4(), role: 'system', parts: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
export function makeUserMessage(text: string): ChatMessage {
|
||||
return { id: uuidv4(), role: 'user', parts: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
function makeAssistantMessage(parts: ChatMessagePart[]): ChatMessage {
|
||||
return { id: uuidv4(), role: 'assistant', parts };
|
||||
}
|
||||
|
||||
export type ChatToolCall = {
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
export type ChatResult = {
|
||||
text: string;
|
||||
toolCalls: ChatToolCall[];
|
||||
assistantMessage: ChatMessage;
|
||||
};
|
||||
|
||||
export async function streamChatResponseAsync({
|
||||
messages,
|
||||
accountName,
|
||||
sessionSecret,
|
||||
stream,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
accountName: string | undefined;
|
||||
sessionSecret: string;
|
||||
stream: boolean;
|
||||
}): Promise<ChatResult> {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${getExpoWebsiteBaseUrl()}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'text/event-stream',
|
||||
cookie: `${getWebsiteAuthCookieName()}=${encodeURIComponent(sessionSecret)}`,
|
||||
...(accountName ? { 'x-account-name': accountName } : {}),
|
||||
},
|
||||
body: JSON.stringify({ messages: [makeSystemMessage(CHAT_SYSTEM_GUIDANCE), ...messages] }),
|
||||
});
|
||||
} catch (error) {
|
||||
throw toFriendlyChatError(error);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('The chat service returned an empty response.');
|
||||
}
|
||||
|
||||
const spinner = stream
|
||||
? ora({ text: 'Thinking…', discardStdin: false, prefixText: ASSISTANT_SPINNER_PREFIX }).start()
|
||||
: undefined;
|
||||
|
||||
let spinnerStopped = false;
|
||||
const stopSpinner = (): void => {
|
||||
if (!spinner || spinnerStopped) {
|
||||
return;
|
||||
}
|
||||
spinnerStopped = true;
|
||||
spinner.stop();
|
||||
if (process.stdout.isTTY) {
|
||||
process.stdout.write('\r');
|
||||
}
|
||||
};
|
||||
const toolMessagePartsById = new Map<string, Extract<ChatMessagePart, { toolCallId: string }>>();
|
||||
const textMessagePartsById = new Map<string, Extract<ChatMessagePart, { type: 'text' }>>();
|
||||
const assistantMessageParts: ChatMessagePart[] = [];
|
||||
const announcedTools = new Set<string>();
|
||||
const markdownState: MarkdownRenderState = createMarkdownRenderState();
|
||||
let fullText = '';
|
||||
let streamingText = false;
|
||||
let errorText: string | undefined;
|
||||
let buffer = '';
|
||||
let displayBuffer = '';
|
||||
let wroteAssistantLine = false;
|
||||
let currentTextMessagePart: Extract<ChatMessagePart, { type: 'text' }> | undefined;
|
||||
|
||||
const getOrCreateToolMessagePart = (
|
||||
toolCallId: string,
|
||||
toolName: string
|
||||
): Extract<ChatMessagePart, { toolCallId: string }> => {
|
||||
const existing = toolMessagePartsById.get(toolCallId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const part: Extract<ChatMessagePart, { toolCallId: string }> = {
|
||||
type: `tool-${toolName}`,
|
||||
toolCallId,
|
||||
state: 'input-streaming',
|
||||
input: undefined,
|
||||
};
|
||||
toolMessagePartsById.set(toolCallId, part);
|
||||
assistantMessageParts.push(part);
|
||||
return part;
|
||||
};
|
||||
|
||||
const getOrCreateTextMessagePart = (
|
||||
id: string | undefined
|
||||
): Extract<ChatMessagePart, { type: 'text' }> => {
|
||||
const existing = id ? textMessagePartsById.get(id) : currentTextMessagePart;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const part: Extract<ChatMessagePart, { type: 'text' }> = { type: 'text', text: '' };
|
||||
if (id) {
|
||||
textMessagePartsById.set(id, part);
|
||||
}
|
||||
currentTextMessagePart = part;
|
||||
assistantMessageParts.push(part);
|
||||
return part;
|
||||
};
|
||||
|
||||
const writeAssistantLine = (rendered: string, withNewline: boolean): void => {
|
||||
if (!streamingText) {
|
||||
stopSpinner();
|
||||
streamingText = true;
|
||||
}
|
||||
const columns = process.stdout.columns ?? 0;
|
||||
const width = columns > 0 ? columns - ASSISTANT_INDENT.length : 0;
|
||||
const segments = wrapToWidth(rendered, width);
|
||||
segments.forEach((segment, index) => {
|
||||
const prefix = wroteAssistantLine ? ASSISTANT_INDENT : ASSISTANT_LABEL;
|
||||
wroteAssistantLine = true;
|
||||
const isLastSegment = index === segments.length - 1;
|
||||
const needsNewline = !isLastSegment || withNewline;
|
||||
process.stdout.write(`${prefix}${segment}${needsNewline ? '\n' : ''}`);
|
||||
});
|
||||
};
|
||||
|
||||
const flushDisplayLines = (flushRemainder: boolean): void => {
|
||||
let newlineIndex: number;
|
||||
while ((newlineIndex = displayBuffer.indexOf('\n')) !== -1) {
|
||||
const rawLine = displayBuffer.slice(0, newlineIndex);
|
||||
displayBuffer = displayBuffer.slice(newlineIndex + 1);
|
||||
const rendered = renderMarkdownLine(rawLine, markdownState);
|
||||
if (rendered !== null) {
|
||||
writeAssistantLine(rendered, true);
|
||||
}
|
||||
}
|
||||
if (flushRemainder && displayBuffer.length > 0) {
|
||||
const rendered = renderMarkdownLine(displayBuffer, markdownState);
|
||||
displayBuffer = '';
|
||||
if (rendered !== null) {
|
||||
writeAssistantLine(rendered, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const announceTool = (toolName: string): void => {
|
||||
if (!stream || announcedTools.has(toolName)) {
|
||||
return;
|
||||
}
|
||||
announcedTools.add(toolName);
|
||||
const label = describeTool(toolName);
|
||||
if (streamingText) {
|
||||
flushDisplayLines(true);
|
||||
process.stdout.write(chalk.dim(`\n${ASSISTANT_INDENT}(${label})\n`));
|
||||
} else if (spinner) {
|
||||
spinner.text = `${label}…`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFrame = (frame: UIMessageStreamFrame): void => {
|
||||
switch (frame.type) {
|
||||
case 'start-step': {
|
||||
assistantMessageParts.push({ type: 'step-start' });
|
||||
currentTextMessagePart = undefined;
|
||||
break;
|
||||
}
|
||||
case 'text-start': {
|
||||
currentTextMessagePart = getOrCreateTextMessagePart(frame.id);
|
||||
break;
|
||||
}
|
||||
case 'text-delta': {
|
||||
const delta = typeof frame.delta === 'string' ? frame.delta : '';
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
fullText += delta;
|
||||
getOrCreateTextMessagePart(frame.id).text += delta;
|
||||
if (stream) {
|
||||
displayBuffer += delta;
|
||||
flushDisplayLines(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text-end': {
|
||||
currentTextMessagePart = undefined;
|
||||
break;
|
||||
}
|
||||
case 'tool-input-start':
|
||||
case 'tool-input-available': {
|
||||
const { toolCallId, toolName } = frame;
|
||||
if (!toolCallId || !toolName) {
|
||||
return;
|
||||
}
|
||||
const toolPart = getOrCreateToolMessagePart(toolCallId, toolName);
|
||||
if (frame.type === 'tool-input-available') {
|
||||
toolPart.state = 'input-available';
|
||||
toolPart.input = frame.input;
|
||||
delete toolPart.output;
|
||||
delete toolPart.errorText;
|
||||
} else if (frame.input !== undefined) {
|
||||
toolPart.input = frame.input;
|
||||
}
|
||||
announceTool(toolName);
|
||||
break;
|
||||
}
|
||||
case 'tool-input-error': {
|
||||
const { toolCallId, toolName } = frame;
|
||||
if (!toolCallId || !toolName || typeof frame.errorText !== 'string') {
|
||||
return;
|
||||
}
|
||||
const toolPart = getOrCreateToolMessagePart(toolCallId, toolName);
|
||||
toolPart.state = 'output-error';
|
||||
toolPart.input = frame.input;
|
||||
delete toolPart.output;
|
||||
toolPart.errorText = frame.errorText;
|
||||
announceTool(toolName);
|
||||
break;
|
||||
}
|
||||
case 'tool-output-available': {
|
||||
const { toolCallId } = frame;
|
||||
if (!toolCallId) {
|
||||
return;
|
||||
}
|
||||
const toolPart = toolMessagePartsById.get(toolCallId);
|
||||
if (toolPart) {
|
||||
toolPart.state = 'output-available';
|
||||
toolPart.output = frame.output;
|
||||
delete toolPart.errorText;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool-output-error': {
|
||||
const { toolCallId } = frame;
|
||||
if (!toolCallId || typeof frame.errorText !== 'string') {
|
||||
return;
|
||||
}
|
||||
const toolPart = toolMessagePartsById.get(toolCallId);
|
||||
if (toolPart) {
|
||||
toolPart.state = 'output-error';
|
||||
delete toolPart.output;
|
||||
toolPart.errorText = frame.errorText;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
if (typeof frame.errorText === 'string') {
|
||||
errorText = frame.errorText;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const decoder = new StringDecoder('utf8');
|
||||
try {
|
||||
for await (const chunk of response.body) {
|
||||
buffer += decoder.write(chunk as Buffer);
|
||||
let newlineIndex: number;
|
||||
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (!line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
const data = line.slice('data:'.length).trim();
|
||||
if (!data || data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
let frame: UIMessageStreamFrame;
|
||||
try {
|
||||
frame = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
handleFrame(frame);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (stream) {
|
||||
flushDisplayLines(true);
|
||||
}
|
||||
stopSpinner();
|
||||
}
|
||||
|
||||
if (errorText) {
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
const toolCalls: ChatToolCall[] = assistantMessageParts
|
||||
.filter((part): part is Extract<ChatMessagePart, { toolCallId: string }> =>
|
||||
part.type.startsWith('tool-')
|
||||
)
|
||||
.map(part => ({
|
||||
toolName: part.type.slice('tool-'.length),
|
||||
input: part.input,
|
||||
output: part.output,
|
||||
errorText: part.errorText,
|
||||
}));
|
||||
const assistantMessage = makeAssistantMessage(
|
||||
assistantMessageParts.filter(
|
||||
part =>
|
||||
part.type === 'text' ||
|
||||
part.type === 'step-start' ||
|
||||
part.state === 'output-available' ||
|
||||
part.state === 'output-error'
|
||||
)
|
||||
);
|
||||
|
||||
if (stream) {
|
||||
if (fullText.length > 0) {
|
||||
Log.newLine();
|
||||
} else {
|
||||
Log.log(
|
||||
chalk.dim(
|
||||
toolCalls.length > 0
|
||||
? 'The assistant looked up your data but did not return a text response. Try asking a more specific question.'
|
||||
: 'The assistant did not return a response.'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { text: fullText, toolCalls, assistantMessage };
|
||||
}
|
||||
|
||||
function toFriendlyChatError(error: unknown): Error {
|
||||
if (error instanceof RequestError) {
|
||||
const status = error.response.status;
|
||||
if (status === 401) {
|
||||
return new Error('Your Expo session is not valid for chat. Run `eas login` and try again.');
|
||||
}
|
||||
if (status === 403) {
|
||||
return new Error(
|
||||
'Chat is not enabled for this account yet. It is still rolling out. If you think you should have access, reach out to Expo.'
|
||||
);
|
||||
}
|
||||
if (status === 429) {
|
||||
return new Error(
|
||||
'You have reached your chat usage limit. Try again later or upgrade your plan for a larger allowance.'
|
||||
);
|
||||
}
|
||||
return new Error(`The chat request failed (HTTP ${status}).`);
|
||||
}
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getConfigFilePaths } from '@expo/config';
|
||||
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { findProjectDirAndVerifyProjectSetupAsync } from '../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { AppQuery } from '../graphql/queries/AppQuery';
|
||||
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
|
||||
|
||||
export async function detectCurrentProjectAsync(
|
||||
graphqlClient: ExpoGraphqlClient
|
||||
): Promise<{ accountName: string; label: string } | null> {
|
||||
let projectDir: string;
|
||||
try {
|
||||
projectDir = await findProjectDirAndVerifyProjectSetupAsync();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!projectDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configPaths = getConfigFilePaths(projectDir);
|
||||
if (!configPaths.staticConfigPath && !configPaths.dynamicConfigPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let projectId: unknown;
|
||||
try {
|
||||
const expoConfig = await getPrivateExpoConfigAsync(projectDir);
|
||||
projectId = expoConfig.extra?.eas?.projectId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof projectId !== 'string' || !projectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
return { accountName: app.ownerAccount.name, label: app.fullName };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import chalk from 'chalk';
|
||||
import wrapAnsi from 'wrap-ansi';
|
||||
|
||||
export function wrapToWidth(text: string, width: number): string[] {
|
||||
if (width < 10) {
|
||||
return [text];
|
||||
}
|
||||
return wrapAnsi(text, width, { hard: true, trim: false }).split('\n');
|
||||
}
|
||||
|
||||
export type MarkdownRenderState = { inCodeBlock: boolean };
|
||||
|
||||
export function createMarkdownRenderState(): MarkdownRenderState {
|
||||
return { inCodeBlock: false };
|
||||
}
|
||||
|
||||
const FENCE_REGEX = /^\s*```/;
|
||||
const BULLET_REGEX = /^(\s*)[-*+]\s+(.*)$/;
|
||||
const ORDERED_REGEX = /^(\s*)(\d+)\.\s+(.*)$/;
|
||||
const TERMINAL_CONTROL_CHARACTERS_REGEX = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
||||
|
||||
export function renderMarkdownLine(line: string, state: MarkdownRenderState): string | null {
|
||||
const sanitizedLine = line.replace(TERMINAL_CONTROL_CHARACTERS_REGEX, '');
|
||||
if (FENCE_REGEX.test(sanitizedLine)) {
|
||||
state.inCodeBlock = !state.inCodeBlock;
|
||||
return null;
|
||||
}
|
||||
if (state.inCodeBlock) {
|
||||
return chalk.gray(sanitizedLine);
|
||||
}
|
||||
|
||||
const bullet = sanitizedLine.match(BULLET_REGEX);
|
||||
if (bullet) {
|
||||
return `${bullet[1]}${chalk.dim('•')} ${renderInlineMarkdown(bullet[2])}`;
|
||||
}
|
||||
|
||||
const ordered = sanitizedLine.match(ORDERED_REGEX);
|
||||
if (ordered) {
|
||||
return `${ordered[1]}${chalk.dim(`${ordered[2]}.`)} ${renderInlineMarkdown(ordered[3])}`;
|
||||
}
|
||||
|
||||
return renderInlineMarkdown(sanitizedLine);
|
||||
}
|
||||
|
||||
export function renderInlineMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/`([^`]+)`/g, (_match, code) => chalk.cyan(code))
|
||||
.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
(_match, label, url) => `${chalk.underline(label)} ${chalk.dim(`(${url})`)}`
|
||||
)
|
||||
.replace(/(\*\*|__)(.+?)\1/g, (_match, _marker, content) => chalk.bold(content));
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import readline from 'node:readline';
|
||||
|
||||
export type ChatReplInput = {
|
||||
askAsync(prompt: string): Promise<string | null>;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
export function createChatReplInput(options?: {
|
||||
input?: NodeJS.ReadableStream;
|
||||
output?: NodeJS.WritableStream;
|
||||
terminal?: boolean;
|
||||
history?: string[];
|
||||
}): ChatReplInput {
|
||||
const inputStream = options?.input ?? process.stdin;
|
||||
const readlineInterface = readline.createInterface({
|
||||
input: inputStream,
|
||||
output: options?.output ?? process.stdout,
|
||||
terminal: options?.terminal ?? true,
|
||||
historySize: 100,
|
||||
history: options?.history,
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
readlineInterface.on('close', () => {
|
||||
closed = true;
|
||||
});
|
||||
readlineInterface.on('SIGINT', () => {
|
||||
readlineInterface.close();
|
||||
});
|
||||
|
||||
return {
|
||||
async askAsync(prompt: string): Promise<string | null> {
|
||||
if (closed) {
|
||||
return null;
|
||||
}
|
||||
inputStream.resume();
|
||||
return await new Promise<string | null>(resolve => {
|
||||
let settled = false;
|
||||
const onClose = (): void => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
readlineInterface.once('close', onClose);
|
||||
readlineInterface.question(prompt, answer => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
readlineInterface.removeListener('close', onClose);
|
||||
resolve(answer);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
close(): void {
|
||||
if (!closed) {
|
||||
readlineInterface.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
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')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
import { getMockOclifConfig } from '../../__tests__/commands/utils';
|
||||
import { ChatResult, streamChatResponseAsync } from '../../chat/chatClient';
|
||||
import { detectCurrentProjectAsync } from '../../chat/detectProject';
|
||||
import { ChatReplInput, createChatReplInput } from '../../chat/replInput';
|
||||
import * as flagsModule from '../../commandUtils/flags';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import Chat from '../chat';
|
||||
|
||||
jest.mock('../../chat/chatClient', () => ({
|
||||
...jest.requireActual('../../chat/chatClient'),
|
||||
streamChatResponseAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../chat/replInput');
|
||||
jest.mock('../../chat/detectProject');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../utils/json');
|
||||
jest.mock('../../graphql/queries/AppQuery', () => ({
|
||||
AppQuery: { byFullNameAsync: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockStreamChatResponseAsync = jest.mocked(streamChatResponseAsync);
|
||||
const mockCreateChatReplInput = jest.mocked(createChatReplInput);
|
||||
const mockDetectCurrentProjectAsync = jest.mocked(detectCurrentProjectAsync);
|
||||
const mockAppByFullNameAsync = jest.mocked(AppQuery.byFullNameAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeChatResult(text: string, toolCalls: ChatResult['toolCalls'] = []): ChatResult {
|
||||
return {
|
||||
text,
|
||||
toolCalls,
|
||||
assistantMessage: {
|
||||
id: 'assistant-message',
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const emptyResult = makeChatResult('ok');
|
||||
|
||||
function mockReplInput(lines: (string | null)[]): { askAsync: jest.Mock; close: jest.Mock } {
|
||||
const askAsync = jest.fn();
|
||||
for (const line of lines) {
|
||||
askAsync.mockResolvedValueOnce(line);
|
||||
}
|
||||
askAsync.mockResolvedValue(null);
|
||||
const input = { askAsync, close: jest.fn() };
|
||||
mockCreateChatReplInput.mockReturnValue(input as ChatReplInput);
|
||||
return input;
|
||||
}
|
||||
|
||||
function forceInteractive(): void {
|
||||
jest
|
||||
.spyOn(flagsModule, 'resolveNonInteractiveAndJsonFlags')
|
||||
.mockReturnValue({ json: false, nonInteractive: false });
|
||||
}
|
||||
|
||||
describe(Chat, () => {
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
mockStreamChatResponseAsync.mockResolvedValue(emptyResult);
|
||||
mockDetectCurrentProjectAsync.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
function createCommand(
|
||||
argv: string[],
|
||||
{
|
||||
sessionSecret = '{"id":"session-id","version":"1"}',
|
||||
primaryAccountName = 'my-account',
|
||||
}: { sessionSecret?: string | null; primaryAccountName?: string } = {}
|
||||
): Chat {
|
||||
const command = new Chat(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: {
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: 'user-id',
|
||||
primaryAccount: { id: 'account-id', name: primaryAccountName },
|
||||
accounts: [{ id: 'account-id', name: primaryAccountName }],
|
||||
},
|
||||
graphqlClient: {},
|
||||
authenticationInfo: sessionSecret
|
||||
? { accessToken: null, sessionSecret }
|
||||
: { accessToken: 'token', sessionSecret: null },
|
||||
},
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('sends the message for the primary account and does not prompt in non-interactive mode', async () => {
|
||||
const command = createCommand(['how are my builds?', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
|
||||
const call = mockStreamChatResponseAsync.mock.calls[0][0];
|
||||
expect(call.accountName).toBe('my-account');
|
||||
expect(call.sessionSecret).toBe('{"id":"session-id","version":"1"}');
|
||||
expect(call.stream).toBe(true);
|
||||
expect(call.messages).toHaveLength(1);
|
||||
expect(call.messages[0].parts[0]).toEqual({ type: 'text', text: 'how are my builds?' });
|
||||
expect(mockCreateChatReplInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefers the --account flag over the primary account and skips project auto-detection', async () => {
|
||||
const command = createCommand(['hi', '--account', 'other-account', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync.mock.calls[0][0].accountName).toBe('other-account');
|
||||
expect(mockDetectCurrentProjectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('auto-scopes to the current directory project when no scope flags are given', async () => {
|
||||
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
|
||||
|
||||
const command = createCommand(['is my build ok?', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
const call = mockStreamChatResponseAsync.mock.calls[0][0];
|
||||
expect(call.accountName).toBe('acme');
|
||||
expect(call.messages[0].parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not auto-detect when --project is given', async () => {
|
||||
mockAppByFullNameAsync.mockResolvedValue({
|
||||
id: 'app1',
|
||||
fullName: '@acme/mobile',
|
||||
slug: 'mobile',
|
||||
ownerAccount: { id: 'acc', name: 'acme' },
|
||||
} as any);
|
||||
|
||||
const command = createCommand(['hi', '--project', 'acme/mobile', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockDetectCurrentProjectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves --project to its owner account and frames the message', async () => {
|
||||
mockAppByFullNameAsync.mockResolvedValue({
|
||||
id: 'app1',
|
||||
fullName: '@acme/mobile',
|
||||
slug: 'mobile',
|
||||
ownerAccount: { id: 'acc', name: 'acme' },
|
||||
} as any);
|
||||
|
||||
const command = createCommand([
|
||||
'is my build ok?',
|
||||
'--project',
|
||||
'acme/mobile',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockAppByFullNameAsync).toHaveBeenCalledWith(expect.anything(), '@acme/mobile');
|
||||
const call = mockStreamChatResponseAsync.mock.calls[0][0];
|
||||
expect(call.accountName).toBe('acme');
|
||||
expect(call.messages[0].parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws a friendly error when --project cannot be found', async () => {
|
||||
mockAppByFullNameAsync.mockRejectedValue(new Error('not found'));
|
||||
const command = createCommand(['hi', '--project', 'acme/ghost', '--non-interactive']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/@acme\/ghost was not found/);
|
||||
});
|
||||
|
||||
it('continues the conversation with follow-up replies until the user types /exit', async () => {
|
||||
forceInteractive();
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
const input = mockReplInput(['and my updates?', '/exit']);
|
||||
|
||||
const command = createCommand(['how are my builds?']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
|
||||
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
|
||||
expect(secondCall.messages).toHaveLength(3);
|
||||
expect(secondCall.messages[1].role).toBe('assistant');
|
||||
expect(secondCall.messages[2].role).toBe('user');
|
||||
expect(secondCall.messages[2].parts[0]).toEqual({ type: 'text', text: 'and my updates?' });
|
||||
expect(input.close).toHaveBeenCalled();
|
||||
expect(mockCreateChatReplInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ history: ['how are my builds?'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('retains tool results in the conversation history for follow-up questions', async () => {
|
||||
forceInteractive();
|
||||
const result = makeChatResult('The latest build passed.', [
|
||||
{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: ['b1'] } },
|
||||
]);
|
||||
result.assistantMessage.parts = [
|
||||
{ type: 'step-start' },
|
||||
{
|
||||
type: 'tool-get_latest_builds',
|
||||
toolCallId: 't1',
|
||||
state: 'output-available',
|
||||
input: { limit: 1 },
|
||||
output: { builds: ['b1'] },
|
||||
},
|
||||
{ type: 'step-start' },
|
||||
{ type: 'text', text: result.text },
|
||||
];
|
||||
mockStreamChatResponseAsync.mockResolvedValue(result);
|
||||
mockReplInput(['what SDK did it use?', '/exit']);
|
||||
|
||||
const command = createCommand(['how are my builds?']);
|
||||
await command.runAsync();
|
||||
|
||||
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
|
||||
expect(secondCall.messages[1]).toEqual(result.assistantMessage);
|
||||
});
|
||||
|
||||
it('exits when the input stream closes (Ctrl-D)', async () => {
|
||||
forceInteractive();
|
||||
mockReplInput([null]);
|
||||
|
||||
const command = createCommand(['how are my builds?']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the interactive session alive when a request fails and re-prompts', async () => {
|
||||
forceInteractive();
|
||||
mockStreamChatResponseAsync
|
||||
.mockRejectedValueOnce(new Error('You have reached your chat usage limit.'))
|
||||
.mockResolvedValue(makeChatResult('answer'));
|
||||
const input = mockReplInput(['try again', '/exit']);
|
||||
|
||||
const command = createCommand(['how are my builds?']);
|
||||
await expect(command.runAsync()).resolves.toBeUndefined();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
|
||||
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
|
||||
expect(secondCall.messages).toHaveLength(1);
|
||||
expect(secondCall.messages[0].parts[0]).toEqual({ type: 'text', text: 'try again' });
|
||||
expect(input.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates a request failure in non-interactive mode without retrying', async () => {
|
||||
mockStreamChatResponseAsync.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const command = createCommand(['hi', '--non-interactive']);
|
||||
await expect(command.runAsync()).rejects.toThrow('boom');
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('starts a new conversation with /clear', async () => {
|
||||
forceInteractive();
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
mockReplInput(['/clear', 'fresh question', '/exit']);
|
||||
|
||||
const command = createCommand(['first question']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
|
||||
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
|
||||
expect(secondCall.messages).toHaveLength(1);
|
||||
expect(secondCall.messages[0].parts[0]).toEqual({ type: 'text', text: 'fresh question' });
|
||||
});
|
||||
|
||||
it('restores project scope after /clear', async () => {
|
||||
forceInteractive();
|
||||
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
mockReplInput(['/clear', 'fresh question', '/exit']);
|
||||
|
||||
const command = createCommand(['first question']);
|
||||
await command.runAsync();
|
||||
|
||||
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
|
||||
expect(secondCall.messages).toHaveLength(1);
|
||||
expect(secondCall.messages[0].parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Regarding the EAS project @acme/mobile: fresh question',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores unknown slash commands and keeps prompting', async () => {
|
||||
forceInteractive();
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
mockReplInput(['/bogus', 'a real question', '/exit']);
|
||||
|
||||
const command = createCommand(['first question']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockStreamChatResponseAsync.mock.calls[1][0].messages[2].parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'a real question',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits structured JSON and does not stream or prompt when --json is passed', async () => {
|
||||
mockStreamChatResponseAsync.mockResolvedValue(
|
||||
makeChatResult('Your latest build passed.', [
|
||||
{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: [] } },
|
||||
])
|
||||
);
|
||||
|
||||
const command = createCommand(['how are my builds?', '--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockStreamChatResponseAsync.mock.calls[0][0].stream).toBe(false);
|
||||
expect(mockCreateChatReplInput).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
message: 'how are my builds?',
|
||||
account: 'my-account',
|
||||
project: null,
|
||||
response: 'Your latest build passed.',
|
||||
toolCalls: [{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: [] } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when authenticated with an access token instead of a session secret', async () => {
|
||||
const command = createCommand(['hi', '--non-interactive'], { sessionSecret: null });
|
||||
await expect(command.runAsync()).rejects.toThrow(/requires an interactive login/);
|
||||
expect(mockStreamChatResponseAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires a message argument', async () => {
|
||||
const command = createCommand(['--non-interactive']);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('opens an interactive prompt when no message is given', async () => {
|
||||
forceInteractive();
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
const input = mockReplInput(['how are my builds?', '/exit']);
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateChatReplInput).toHaveBeenCalledWith(expect.objectContaining({ history: [] }));
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
|
||||
const call = mockStreamChatResponseAsync.mock.calls[0][0];
|
||||
expect(call.messages).toHaveLength(1);
|
||||
expect(call.messages[0].parts[0]).toEqual({ type: 'text', text: 'how are my builds?' });
|
||||
expect(input.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('frames the first typed message with the auto-detected project when no message is given', async () => {
|
||||
forceInteractive();
|
||||
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
mockReplInput(['is my build ok?', '/exit']);
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
const call = mockStreamChatResponseAsync.mock.calls[0][0];
|
||||
expect(call.accountName).toBe('acme');
|
||||
expect(call.messages[0].parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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,6 +135,7 @@ export default class BuildInspect extends EasCommand {
|
||||
}
|
||||
} finally {
|
||||
await this.copyToOutputDirAsync(path.join(tmpWorkingdir, 'build'), outputDirectory);
|
||||
await this.cleanUpTmpWorkingdirAsync(tmpWorkingdir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,4 +164,13 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
ChatMessage,
|
||||
ChatResult,
|
||||
makeUserMessage,
|
||||
streamChatResponseAsync,
|
||||
} from '../chat/chatClient';
|
||||
import { detectCurrentProjectAsync } from '../chat/detectProject';
|
||||
import { ChatReplInput, createChatReplInput } from '../chat/replInput';
|
||||
import EasCommand from '../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../commandUtils/flags';
|
||||
import { AppQuery } from '../graphql/queries/AppQuery';
|
||||
import Log from '../log';
|
||||
import { Actor } from '../user/User';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json';
|
||||
|
||||
const EXIT_WORDS = new Set(['exit', 'quit', 'q']);
|
||||
const USER_LABEL = `${chalk.bold.cyan('Chat')}${chalk.dim(' > ')}`;
|
||||
const CHAT_HELP = [
|
||||
'Commands:',
|
||||
' /help Show this help',
|
||||
' /clear Start a new conversation',
|
||||
' /exit Quit',
|
||||
].join('\n');
|
||||
|
||||
export default class Chat extends EasCommand {
|
||||
static override description = 'ask an AI assistant about your Expo account and EAS projects';
|
||||
|
||||
static override hidden = true;
|
||||
|
||||
static override args = {
|
||||
message: Args.string({
|
||||
required: false,
|
||||
description: 'Message to send to the assistant (omit to open an interactive prompt)',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
account: Flags.string({
|
||||
char: 'a',
|
||||
description: 'Account to scope the conversation to (defaults to your primary account)',
|
||||
}),
|
||||
project: Flags.string({
|
||||
char: 'p',
|
||||
description:
|
||||
'Project to focus the conversation on, as @account-name/project-slug or a project slug',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(Chat);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (!args.message && (json || nonInteractive)) {
|
||||
throw new Error('Provide a message, e.g. `eas chat "how are my builds?"`.');
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { actor, authenticationInfo, graphqlClient },
|
||||
} = await this.getContextAsync(Chat, { nonInteractive });
|
||||
|
||||
if (!authenticationInfo.sessionSecret) {
|
||||
throw new Error(
|
||||
'`eas chat` requires an interactive login. Run `eas login` first. Chat does not support EXPO_TOKEN access tokens yet.'
|
||||
);
|
||||
}
|
||||
const { sessionSecret } = authenticationInfo;
|
||||
|
||||
let accountName = flags.account ?? getDefaultAccountName(actor);
|
||||
let projectLabel: string | undefined;
|
||||
let projectDetectedFromDirectory = false;
|
||||
if (flags.project) {
|
||||
const resolved = await resolveProjectAsync(graphqlClient, flags.project, accountName);
|
||||
accountName = resolved.accountName;
|
||||
projectLabel = resolved.label;
|
||||
} else if (!flags.account) {
|
||||
const detected = await detectCurrentProjectAsync(graphqlClient);
|
||||
if (detected) {
|
||||
accountName = detected.accountName;
|
||||
projectLabel = detected.label;
|
||||
projectDetectedFromDirectory = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (json) {
|
||||
const messages: ChatMessage[] = [
|
||||
makeUserMessage(scopeMessageToProject(args.message as string, projectLabel)),
|
||||
];
|
||||
const result = await streamChatResponseAsync({
|
||||
messages: [...messages],
|
||||
accountName,
|
||||
sessionSecret,
|
||||
stream: false,
|
||||
});
|
||||
printJsonOnlyOutput({
|
||||
message: args.message,
|
||||
account: accountName ?? null,
|
||||
project: projectLabel ?? null,
|
||||
response: result.text,
|
||||
toolCalls: result.toolCalls,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectLabel) {
|
||||
Log.log(
|
||||
chalk.dim(
|
||||
projectDetectedFromDirectory
|
||||
? `Project: ${projectLabel} (from current directory; use --account to widen)`
|
||||
: `Project: ${projectLabel}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!nonInteractive) {
|
||||
Log.log(chalk.dim('Use /help for commands, or /exit to quit.'));
|
||||
}
|
||||
|
||||
const input = nonInteractive
|
||||
? undefined
|
||||
: createChatReplInput({ history: args.message ? [args.message] : [] });
|
||||
|
||||
const messages: ChatMessage[] = [];
|
||||
if (args.message) {
|
||||
Log.log(`${USER_LABEL}${args.message}`);
|
||||
Log.newLine();
|
||||
messages.push(makeUserMessage(scopeMessageToProject(args.message, projectLabel)));
|
||||
}
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (!lastMessage || lastMessage.role === 'assistant') {
|
||||
if (!input) {
|
||||
break;
|
||||
}
|
||||
const scopeNextMessage = messages.length === 0;
|
||||
const nextMessage = await readNextUserMessageAsync(
|
||||
input,
|
||||
messages,
|
||||
projectLabel,
|
||||
scopeNextMessage
|
||||
);
|
||||
if (nextMessage === null) {
|
||||
Log.log(chalk.dim('Ending chat.'));
|
||||
break;
|
||||
}
|
||||
messages.push(makeUserMessage(nextMessage));
|
||||
}
|
||||
|
||||
let result: ChatResult;
|
||||
try {
|
||||
result = await streamChatResponseAsync({
|
||||
messages: [...messages],
|
||||
accountName,
|
||||
sessionSecret,
|
||||
stream: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!input) {
|
||||
throw error;
|
||||
}
|
||||
Log.error(error instanceof Error ? error.message : String(error));
|
||||
messages.pop();
|
||||
continue;
|
||||
}
|
||||
messages.push(result.assistantMessage);
|
||||
}
|
||||
} finally {
|
||||
input?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readNextUserMessageAsync(
|
||||
input: ChatReplInput,
|
||||
messages: ChatMessage[],
|
||||
projectLabel: string | undefined,
|
||||
scopeFirstMessage = false
|
||||
): Promise<string | null> {
|
||||
let shouldRestoreProjectScope = scopeFirstMessage;
|
||||
for (;;) {
|
||||
Log.newLine();
|
||||
const line = await input.askAsync(USER_LABEL);
|
||||
if (line === null) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (EXIT_WORDS.has(trimmed.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
const command = trimmed.slice(1).split(/\s+/)[0].toLowerCase();
|
||||
if (['exit', 'quit', 'q'].includes(command)) {
|
||||
return null;
|
||||
}
|
||||
if (['clear', 'new', 'reset'].includes(command)) {
|
||||
messages.length = 0;
|
||||
shouldRestoreProjectScope = true;
|
||||
Log.log(chalk.dim('Started a new conversation.'));
|
||||
continue;
|
||||
}
|
||||
if (['help', 'h', '?'].includes(command)) {
|
||||
Log.log(chalk.dim(CHAT_HELP));
|
||||
continue;
|
||||
}
|
||||
Log.warn(`Unknown command "/${command}". Type /help for commands.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
return shouldRestoreProjectScope ? scopeMessageToProject(trimmed, projectLabel) : trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function scopeMessageToProject(message: string, projectLabel: string | undefined): string {
|
||||
return projectLabel ? `Regarding the EAS project ${projectLabel}: ${message}` : message;
|
||||
}
|
||||
|
||||
function getDefaultAccountName(actor: Actor): string | undefined {
|
||||
if ('primaryAccount' in actor && actor.primaryAccount) {
|
||||
return actor.primaryAccount.name;
|
||||
}
|
||||
return actor.accounts[0]?.name ?? undefined;
|
||||
}
|
||||
|
||||
async function resolveProjectAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectReference: string,
|
||||
fallbackAccountName: string | undefined
|
||||
): Promise<{ accountName: string; label: string }> {
|
||||
const parsed = parseProjectReference(projectReference);
|
||||
const accountForLookup = parsed.account ?? fallbackAccountName;
|
||||
if (!accountForLookup) {
|
||||
throw new Error(
|
||||
`Could not determine which account owns project "${projectReference}". Pass it as @account-name/project-slug.`
|
||||
);
|
||||
}
|
||||
|
||||
const fullName = `@${accountForLookup}/${parsed.slug}`;
|
||||
let app;
|
||||
try {
|
||||
app = await AppQuery.byFullNameAsync(graphqlClient, fullName);
|
||||
} catch {
|
||||
throw new Error(`Project ${fullName} was not found or you do not have access to it.`);
|
||||
}
|
||||
return { accountName: app.ownerAccount.name, label: app.fullName };
|
||||
}
|
||||
|
||||
function parseProjectReference(reference: string): { account?: string; slug: string } {
|
||||
const cleaned = reference.startsWith('@') ? reference.slice(1) : reference;
|
||||
const parts = cleaned.split('/');
|
||||
if (parts.length === 2 && parts[0] && parts[1]) {
|
||||
return { account: parts[0], slug: parts[1] };
|
||||
}
|
||||
return { slug: cleaned };
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import * as WorkerAssets from '../../worker/assets';
|
||||
import {
|
||||
assertValidDevDomainName,
|
||||
assignWorkerDeploymentAliasAsync,
|
||||
assignWorkerDeploymentProductionAsync,
|
||||
getSignedDeploymentUrlAsync,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
uploadAsync,
|
||||
} from '../../worker/upload';
|
||||
import {
|
||||
EXPO_BASE_DOMAIN,
|
||||
formatWorkerDeploymentJson,
|
||||
formatWorkerDeploymentTable,
|
||||
getDeploymentUrlFromFullName,
|
||||
@@ -50,6 +52,7 @@ interface DeployFlags {
|
||||
aliasName?: string;
|
||||
environment?: string;
|
||||
deploymentIdentifier?: string;
|
||||
devDomainName?: string;
|
||||
exportDir: string;
|
||||
dryRun: boolean;
|
||||
sourceMaps: boolean;
|
||||
@@ -62,6 +65,7 @@ interface RawDeployFlags {
|
||||
prod: boolean;
|
||||
alias?: string;
|
||||
id?: string;
|
||||
'dev-domain'?: string;
|
||||
'export-dir': string;
|
||||
'dry-run': boolean;
|
||||
'source-maps': boolean;
|
||||
@@ -82,7 +86,11 @@ 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`];
|
||||
static override usage = [
|
||||
chalk`deploy {dim [options]}`,
|
||||
`deploy --prod`,
|
||||
`deploy --non-interactive --dev-domain my-app`,
|
||||
];
|
||||
static override state = 'preview';
|
||||
|
||||
static override flags = {
|
||||
@@ -99,6 +107,10 @@ 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',
|
||||
@@ -322,6 +334,7 @@ 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(),
|
||||
@@ -467,12 +480,19 @@ 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'],
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { getMockAppFragment, getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../../graphql/generated';
|
||||
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import {
|
||||
parseVisibility,
|
||||
promptVariableEnvironmentAsync,
|
||||
promptVariableNameAsync,
|
||||
promptVariableTypeAsync,
|
||||
promptVariableValueAsync,
|
||||
promptVariableVisibilityAsync,
|
||||
} from '../../../utils/prompts';
|
||||
import EnvSet from '../set';
|
||||
|
||||
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../utils/prompts');
|
||||
jest.mock('../../../utils/json');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
describe(EnvSet, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const variableId = 'testId';
|
||||
const testAccountId = 'test-account-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest
|
||||
.mocked(parseVisibility)
|
||||
.mockImplementation(jest.requireActual('../../../utils/prompts').parseVisibility);
|
||||
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest
|
||||
.mocked(EnvironmentVariableMutation.createForAppAsync)
|
||||
.mockImplementation(async (_client, input, _appId) => ({
|
||||
...input,
|
||||
id: variableId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
type: EnvironmentSecretType.String,
|
||||
}));
|
||||
jest
|
||||
.mocked(EnvironmentVariableMutation.createSharedVariableAsync)
|
||||
.mockImplementation(async (_client, input, _appId) => ({
|
||||
...input,
|
||||
id: variableId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
type: EnvironmentSecretType.String,
|
||||
}));
|
||||
jest
|
||||
.mocked(EnvironmentVariableMutation.updateAsync)
|
||||
.mockImplementation(async (_client, input) => ({
|
||||
...input,
|
||||
id: variableId,
|
||||
name: 'VarName',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
type: EnvironmentSecretType.String,
|
||||
}));
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockImplementation(async () => []);
|
||||
jest.mocked(EnvironmentVariablesQuery.sharedAsync).mockImplementation(async () => []);
|
||||
});
|
||||
|
||||
it('creates a project variable when none exists', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'secret',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Secret,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
testProjectId
|
||||
);
|
||||
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts --variable-name and --variable-value aliases', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--variable-name',
|
||||
'VarName',
|
||||
'--variable-value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'secret',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Secret,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
testProjectId
|
||||
);
|
||||
});
|
||||
|
||||
it('updates an existing project variable in the same environment without --force', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'secret',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
const otherVariableId = 'otherId';
|
||||
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
},
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
filterNames: ['VarName'],
|
||||
});
|
||||
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Secret,
|
||||
type: undefined,
|
||||
fileName: undefined,
|
||||
});
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves visibility when updating non-interactively without --visibility', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--non-interactive',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
const otherVariableId = 'otherId';
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
type: EnvironmentSecretType.String,
|
||||
visibility: EnvironmentVariableVisibility.Sensitive,
|
||||
},
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(promptVariableVisibilityAsync).not.toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Sensitive,
|
||||
type: EnvironmentSecretType.String,
|
||||
fileName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves other environments when updating an existing project variable', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'secret',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
const otherVariableId = 'otherId';
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
environments: [DefaultEnvironment.Production, DefaultEnvironment.Preview],
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
},
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production, DefaultEnvironment.Preview],
|
||||
visibility: EnvironmentVariableVisibility.Secret,
|
||||
type: undefined,
|
||||
fileName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates an account-wide variable when none exists', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--scope',
|
||||
'account',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.createSharedVariableAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
testAccountId
|
||||
);
|
||||
});
|
||||
|
||||
it('updates an existing account-wide variable without --force', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--scope',
|
||||
'account',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
const otherVariableId = 'otherId';
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.sharedAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
},
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: undefined,
|
||||
});
|
||||
expect(EnvironmentVariableMutation.createSharedVariableAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves other environments when updating an existing account-wide variable', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--scope',
|
||||
'account',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
const otherVariableId = 'otherId';
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.sharedAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
environments: [DefaultEnvironment.Production, DefaultEnvironment.Preview],
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
},
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production, DefaultEnvironment.Preview],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts the environment as a positional argument', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'development',
|
||||
'--name',
|
||||
'TEST_VAR',
|
||||
'--value',
|
||||
'test-value',
|
||||
'--visibility',
|
||||
'plaintext',
|
||||
'--scope',
|
||||
'project',
|
||||
'--non-interactive',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
name: 'TEST_VAR',
|
||||
value: 'test-value',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
testProjectId
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts for missing arguments', async () => {
|
||||
const command = new EnvSet([], mockConfig);
|
||||
|
||||
jest.mocked(promptVariableNameAsync).mockImplementation(async () => 'VarName');
|
||||
jest.mocked(promptVariableValueAsync).mockImplementation(async () => 'VarValue');
|
||||
jest
|
||||
.mocked(promptVariableTypeAsync)
|
||||
.mockImplementation(async () => EnvironmentSecretType.String);
|
||||
|
||||
jest
|
||||
.mocked(promptVariableEnvironmentAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [DefaultEnvironment.Production]);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(promptVariableNameAsync).toHaveBeenCalled();
|
||||
expect(promptVariableValueAsync).toHaveBeenCalled();
|
||||
expect(promptVariableEnvironmentAsync).toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
testProjectId
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the file type when updating a file variable with --value but no --type', async () => {
|
||||
const testFilePath = '/path/to/creds.json';
|
||||
const testFileBase64 = 'dGVzdCBmaWxlIGNvbnRlbnQ=';
|
||||
const testFileName = 'creds.json';
|
||||
const otherVariableId = 'otherId';
|
||||
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
},
|
||||
]);
|
||||
|
||||
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(true));
|
||||
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(testFileBase64));
|
||||
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
testFilePath,
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'secret',
|
||||
'--non-interactive',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(fs.pathExists).toHaveBeenCalledWith(testFilePath);
|
||||
expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'base64');
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: testFileBase64,
|
||||
visibility: EnvironmentVariableVisibility.Secret,
|
||||
environments: [DefaultEnvironment.Production],
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
fileName: testFileName,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when updating a file variable with a value that is not a valid file path and no --type', async () => {
|
||||
const plainStringValue = 'some-new-value';
|
||||
const otherVariableId = 'otherId';
|
||||
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
},
|
||||
]);
|
||||
|
||||
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(false));
|
||||
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
plainStringValue,
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'secret',
|
||||
'--non-interactive',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
`Variable "VarName" is a file type, but "${plainStringValue}" does not exist as a file. If you want to convert it to a string, pass --type string.`
|
||||
);
|
||||
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('outputs the created variable as JSON with --json and does not print a tick', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'plaintext',
|
||||
'--json',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(enableJsonOutput).toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
testProjectId
|
||||
);
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: variableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+6
-1
@@ -57,7 +57,9 @@ interface CreateFlags {
|
||||
}
|
||||
|
||||
export default class EnvCreate extends EasCommand {
|
||||
static override description = 'create an environment variable for the current project or account';
|
||||
static override description =
|
||||
'create an environment variable for the current project or account (deprecated, use eas env:set)';
|
||||
static override hidden = true;
|
||||
|
||||
static override args = {
|
||||
environment: Args.string({
|
||||
@@ -95,6 +97,9 @@ export default class EnvCreate extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
Log.warn('This command is deprecated. Use eas env:set instead.');
|
||||
Log.newLine();
|
||||
|
||||
const { args, flags } = await this.parse(EnvCreate);
|
||||
|
||||
const validatedFlags = this.sanitizeFlags(flags);
|
||||
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EASEnvironmentVariableScopeFlag,
|
||||
EASEnvironmentVariableScopeFlagValue,
|
||||
EASMultiEnvironmentFlag,
|
||||
EASVariableVisibilityFlag,
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableFragment,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../graphql/generated';
|
||||
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
getDisplayNameForProjectIdAsync,
|
||||
getOwnerAccountForProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import {
|
||||
parseVisibility,
|
||||
promptVariableEnvironmentAsync,
|
||||
promptVariableNameAsync,
|
||||
promptVariableTypeAsync,
|
||||
promptVariableValueAsync,
|
||||
promptVariableVisibilityAsync,
|
||||
} from '../../utils/prompts';
|
||||
|
||||
interface RawSetFlags {
|
||||
name?: string;
|
||||
value?: string;
|
||||
type?: 'string' | 'file';
|
||||
visibility?: 'plaintext' | 'sensitive' | 'secret';
|
||||
scope: EASEnvironmentVariableScopeFlagValue;
|
||||
environment?: string[];
|
||||
'non-interactive': boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface SetFlags {
|
||||
name?: string;
|
||||
value?: string;
|
||||
type?: 'string' | 'file';
|
||||
visibility?: 'plaintext' | 'sensitive' | 'secret';
|
||||
scope: EnvironmentVariableScope;
|
||||
environment?: string[];
|
||||
'non-interactive': boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface ResolvedVariableDetails {
|
||||
value: string;
|
||||
visibility: EnvironmentVariableVisibility;
|
||||
type: EnvironmentSecretType | undefined;
|
||||
fileName: string | undefined;
|
||||
}
|
||||
|
||||
export default class EnvSet extends EasCommand {
|
||||
static override description =
|
||||
'set (create or update) an environment variable on the current project or account';
|
||||
|
||||
static override args = {
|
||||
environment: Args.string({
|
||||
description:
|
||||
"Environment to set the variable in. Default environments are 'production', 'preview', and 'development'.",
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
name: Flags.string({
|
||||
description: 'Name of the variable',
|
||||
aliases: ['variable-name'],
|
||||
}),
|
||||
value: Flags.string({
|
||||
description: 'Text value of the variable',
|
||||
aliases: ['variable-value'],
|
||||
}),
|
||||
type: Flags.option({
|
||||
description: 'The type of variable',
|
||||
options: ['string', 'file'] as const,
|
||||
})(),
|
||||
...EASVariableVisibilityFlag,
|
||||
...EASEnvironmentVariableScopeFlag,
|
||||
...EASMultiEnvironmentFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.Analytics,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(EnvSet);
|
||||
|
||||
const {
|
||||
name: nameFlag,
|
||||
value,
|
||||
type,
|
||||
visibility,
|
||||
scope,
|
||||
environment,
|
||||
} = this.sanitizeFlags(flags);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(EnvSet, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const name = nameFlag ?? (await promptVariableNameAsync(nonInteractive));
|
||||
|
||||
const environments = await this.resolveEnvironmentsAsync(environment, args.environment, {
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
|
||||
const existingVariable = await this.findExistingVariableAsync(graphqlClient, {
|
||||
scope,
|
||||
projectId,
|
||||
name,
|
||||
environments,
|
||||
});
|
||||
const environmentsToSet = existingVariable
|
||||
? [...new Set([...(existingVariable.environments ?? []), ...environments])]
|
||||
: environments;
|
||||
|
||||
const {
|
||||
value: resolvedValue,
|
||||
visibility: resolvedVisibility,
|
||||
type: resolvedType,
|
||||
fileName,
|
||||
} = await this.resolveVariableDetailsAsync(
|
||||
{ name, value, type, visibility, nonInteractive },
|
||||
existingVariable
|
||||
);
|
||||
|
||||
const [projectDisplayName, ownerAccount] = await Promise.all([
|
||||
getDisplayNameForProjectIdAsync(graphqlClient, projectId),
|
||||
getOwnerAccountForProjectIdAsync(graphqlClient, projectId),
|
||||
]);
|
||||
|
||||
if (scope === EnvironmentVariableScope.Project) {
|
||||
const variable = existingVariable
|
||||
? await EnvironmentVariableMutation.updateAsync(graphqlClient, {
|
||||
id: existingVariable.id,
|
||||
name,
|
||||
value: resolvedValue,
|
||||
visibility: resolvedVisibility,
|
||||
environments: environmentsToSet,
|
||||
type: resolvedType,
|
||||
fileName,
|
||||
})
|
||||
: await EnvironmentVariableMutation.createForAppAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
name,
|
||||
value: resolvedValue,
|
||||
environments,
|
||||
visibility: resolvedVisibility,
|
||||
type: resolvedType ?? EnvironmentSecretType.String,
|
||||
fileName,
|
||||
},
|
||||
projectId
|
||||
);
|
||||
|
||||
if (!variable) {
|
||||
throw new Error(
|
||||
`Could not set variable with name ${name} on project ${projectDisplayName}`
|
||||
);
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(variable);
|
||||
} else {
|
||||
Log.withTick(
|
||||
`${existingVariable ? 'Updated' : 'Created'} variable ${chalk.bold(name)} on project ${chalk.bold(
|
||||
projectDisplayName
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
} else if (scope === EnvironmentVariableScope.Shared) {
|
||||
const variable = existingVariable
|
||||
? await EnvironmentVariableMutation.updateAsync(graphqlClient, {
|
||||
id: existingVariable.id,
|
||||
name,
|
||||
value: resolvedValue,
|
||||
visibility: resolvedVisibility,
|
||||
environments: environmentsToSet,
|
||||
type: resolvedType,
|
||||
})
|
||||
: await EnvironmentVariableMutation.createSharedVariableAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
name,
|
||||
value: resolvedValue,
|
||||
visibility: resolvedVisibility,
|
||||
environments,
|
||||
type: resolvedType ?? EnvironmentSecretType.String,
|
||||
},
|
||||
ownerAccount.id
|
||||
);
|
||||
|
||||
if (!variable) {
|
||||
throw new Error(`Could not set variable with name ${name} on account ${ownerAccount.name}`);
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(variable);
|
||||
} else {
|
||||
Log.withTick(
|
||||
`${existingVariable ? 'Updated' : 'Created'} variable ${chalk.bold(name)} on account ${chalk.bold(
|
||||
ownerAccount.name
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveEnvironmentsAsync(
|
||||
environmentFlag: string[] | undefined,
|
||||
environmentArg: string | undefined,
|
||||
{
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
}: { nonInteractive: boolean; graphqlClient: ExpoGraphqlClient; projectId: string }
|
||||
): Promise<string[]> {
|
||||
const environments = environmentFlag ?? (environmentArg ? [environmentArg] : undefined);
|
||||
if (environments && environments.length > 0) {
|
||||
return environments;
|
||||
}
|
||||
|
||||
const promptedEnvironments = await promptVariableEnvironmentAsync({
|
||||
nonInteractive,
|
||||
multiple: true,
|
||||
canEnterCustomEnvironment: true,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
|
||||
if (!promptedEnvironments || promptedEnvironments.length === 0) {
|
||||
throw new Error('No environments selected');
|
||||
}
|
||||
|
||||
return promptedEnvironments;
|
||||
}
|
||||
|
||||
private async findExistingVariableAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
scope,
|
||||
projectId,
|
||||
name,
|
||||
environments,
|
||||
}: {
|
||||
scope: EnvironmentVariableScope;
|
||||
projectId: string;
|
||||
name: string;
|
||||
environments: string[];
|
||||
}
|
||||
): Promise<EnvironmentVariableFragment | undefined> {
|
||||
if (scope === EnvironmentVariableScope.Project) {
|
||||
const existingVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
filterNames: [name],
|
||||
});
|
||||
|
||||
return existingVariables.find(
|
||||
variable =>
|
||||
variable.scope === EnvironmentVariableScope.Project &&
|
||||
variable.environments?.some(env => environments.includes(env))
|
||||
);
|
||||
}
|
||||
|
||||
const existingVariables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
filterNames: [name],
|
||||
});
|
||||
|
||||
return existingVariables.find(variable =>
|
||||
variable.environments?.some(env => environments.includes(env))
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveVariableDetailsAsync(
|
||||
{
|
||||
name,
|
||||
value,
|
||||
type,
|
||||
visibility,
|
||||
nonInteractive,
|
||||
}: {
|
||||
name: string;
|
||||
value?: string;
|
||||
type?: 'string' | 'file';
|
||||
visibility?: 'plaintext' | 'sensitive' | 'secret';
|
||||
nonInteractive: boolean;
|
||||
},
|
||||
existingVariable: EnvironmentVariableFragment | undefined
|
||||
): Promise<ResolvedVariableDetails> {
|
||||
let newType: EnvironmentSecretType | undefined;
|
||||
if (type === 'file') {
|
||||
newType = EnvironmentSecretType.FileBase64;
|
||||
} else if (type === 'string') {
|
||||
newType = EnvironmentSecretType.String;
|
||||
}
|
||||
|
||||
let newVisibility = visibility ? parseVisibility(visibility) : undefined;
|
||||
|
||||
if (!type && !value && !nonInteractive) {
|
||||
newType = await promptVariableTypeAsync(nonInteractive, existingVariable?.type);
|
||||
}
|
||||
|
||||
if (!newVisibility && !nonInteractive) {
|
||||
newVisibility = await promptVariableVisibilityAsync(
|
||||
nonInteractive,
|
||||
existingVariable?.visibility
|
||||
);
|
||||
}
|
||||
|
||||
if (value && !newType) {
|
||||
newType = existingVariable?.type;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
value = await promptVariableValueAsync({
|
||||
nonInteractive,
|
||||
hidden: newVisibility !== EnvironmentVariableVisibility.Public,
|
||||
filePath: (newType ?? existingVariable?.type) === EnvironmentSecretType.FileBase64,
|
||||
});
|
||||
}
|
||||
|
||||
newVisibility =
|
||||
newVisibility ?? existingVariable?.visibility ?? EnvironmentVariableVisibility.Public;
|
||||
|
||||
let fileName: string | undefined;
|
||||
if (newType === EnvironmentSecretType.FileBase64) {
|
||||
const environmentFilePath = path.resolve(value);
|
||||
if (!(await fs.pathExists(environmentFilePath))) {
|
||||
if (type === 'file') {
|
||||
throw new Error(`File "${value}" does not exist`);
|
||||
}
|
||||
throw new Error(
|
||||
`Variable "${name}" is a file type, but "${value}" does not exist as a file. If you want to convert it to a string, pass --type string.`
|
||||
);
|
||||
}
|
||||
fileName = path.basename(environmentFilePath);
|
||||
value = await fs.readFile(environmentFilePath, 'base64');
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
visibility: newVisibility,
|
||||
type: newType,
|
||||
fileName,
|
||||
};
|
||||
}
|
||||
|
||||
private sanitizeFlags(flags: RawSetFlags): SetFlags {
|
||||
return {
|
||||
...flags,
|
||||
scope:
|
||||
flags.scope === 'account'
|
||||
? EnvironmentVariableScope.Shared
|
||||
: EnvironmentVariableScope.Project,
|
||||
};
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -63,7 +63,9 @@ interface UpdateFlags {
|
||||
}
|
||||
|
||||
export default class EnvUpdate extends EasCommand {
|
||||
static override description = 'update an environment variable on the current project or account';
|
||||
static override description =
|
||||
'update an environment variable on the current project or account (deprecated, use eas env:set)';
|
||||
static override hidden = true;
|
||||
|
||||
static override flags = {
|
||||
'variable-name': Flags.string({
|
||||
@@ -104,6 +106,9 @@ export default class EnvUpdate extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
Log.warn('This command is deprecated. Use eas env:set instead.');
|
||||
Log.newLine();
|
||||
|
||||
const { args, flags } = await this.parse(EnvUpdate);
|
||||
const {
|
||||
name,
|
||||
|
||||
@@ -264,6 +264,14 @@ describe(ObserveMetrics, () => {
|
||||
'metric argument is required in non-interactive mode'
|
||||
);
|
||||
});
|
||||
|
||||
it('treats --json as non-interactive when no metric is provided', async () => {
|
||||
const command = createCommand(['--json']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'metric argument is required in non-interactive mode'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveOrderBy, () => {
|
||||
|
||||
@@ -1,27 +1,99 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { fetchObserveSessionEventsAsync } from '../../../observe/fetchSessions';
|
||||
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
|
||||
import {
|
||||
fetchObserveSessionEventsAsync,
|
||||
fetchSessionLogCandidatesAsync,
|
||||
fetchSessionMetricCandidatesAsync,
|
||||
} from '../../../observe/fetchSessions';
|
||||
import {
|
||||
buildObserveSessionEventsJson,
|
||||
buildObserveSessionEventsTable,
|
||||
} from '../../../observe/formatSessions';
|
||||
import { selectAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveSession from '../session';
|
||||
|
||||
jest.mock('../../../observe/fetchSessions');
|
||||
jest.mock('../../../observe/formatSessions', () => ({
|
||||
buildObserveSessionEventsTable: jest.fn().mockReturnValue('events-table'),
|
||||
buildObserveSessionEventsJson: jest.fn().mockReturnValue({}),
|
||||
jest.mock('../../../observe/formatSessions', () => {
|
||||
const actual = jest.requireActual('../../../observe/formatSessions');
|
||||
return {
|
||||
...actual,
|
||||
buildObserveSessionEventsTable: jest.fn().mockReturnValue('events-table'),
|
||||
buildObserveSessionEventsJson: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
|
||||
ObserveQuery: {
|
||||
customEventNamesAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../prompts', () => {
|
||||
const actual = jest.requireActual('../../../prompts');
|
||||
return {
|
||||
...actual,
|
||||
selectAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveSessionEventsAsync = jest.mocked(fetchObserveSessionEventsAsync);
|
||||
const mockFetchSessionMetricCandidatesAsync = jest.mocked(fetchSessionMetricCandidatesAsync);
|
||||
const mockFetchSessionLogCandidatesAsync = jest.mocked(fetchSessionLogCandidatesAsync);
|
||||
const mockCustomEventNamesAsync = jest.mocked(ObserveQuery.customEventNamesAsync);
|
||||
const mockSelectAsync = jest.mocked(selectAsync);
|
||||
const mockBuildObserveSessionEventsTable = jest.mocked(buildObserveSessionEventsTable);
|
||||
const mockBuildObserveSessionEventsJson = jest.mocked(buildObserveSessionEventsJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeMetricEvent(overrides: any = {}): any {
|
||||
return {
|
||||
__typename: 'AppObserveEvent',
|
||||
id: 'evt-m-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-metric-1',
|
||||
easClientId: 'client-1',
|
||||
customParams: null,
|
||||
routeName: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCustomEvent(overrides: any = {}): any {
|
||||
return {
|
||||
__typename: 'AppObserveCustomEvent',
|
||||
id: 'evt-c-1',
|
||||
eventName: 'login_pressed',
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
sessionId: 'session-log-1',
|
||||
severityNumber: null,
|
||||
severityText: null,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
appEasBuildId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
environment: 'production',
|
||||
easClientId: 'client-1',
|
||||
countryCode: 'US',
|
||||
properties: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(ObserveSession, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
@@ -35,6 +107,9 @@ describe(ObserveSession, () => {
|
||||
hasMoreMetricEvents: false,
|
||||
hasMoreLogEvents: false,
|
||||
});
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([]);
|
||||
mockFetchSessionLogCandidatesAsync.mockResolvedValue([]);
|
||||
mockCustomEventNamesAsync.mockResolvedValue({ names: [], isTruncated: false });
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveSession {
|
||||
@@ -47,11 +122,7 @@ describe(ObserveSession, () => {
|
||||
return command;
|
||||
}
|
||||
|
||||
it('requires a session ID argument', async () => {
|
||||
const command = createCommand([]);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
// ---- existing single-session behavior ----
|
||||
|
||||
it('passes the session ID argument to the fetcher', async () => {
|
||||
const command = createCommand(['session-abc']);
|
||||
@@ -76,7 +147,7 @@ describe(ObserveSession, () => {
|
||||
expect(mockBuildObserveSessionEventsJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits JSON output when --json is set', async () => {
|
||||
it('emits JSON output when --json is set with a session ID', async () => {
|
||||
const command = createCommand(['session-abc', '--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
@@ -90,4 +161,212 @@ describe(ObserveSession, () => {
|
||||
const command = createCommand(['session-abc', '--platform', 'ios']);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ---- new picker-mode behavior ----
|
||||
|
||||
it('errors when no session ID is provided in non-interactive mode', async () => {
|
||||
const command = createCommand(['--non-interactive']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/session ID argument is required/);
|
||||
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats --json as non-interactive (requires a session ID)', async () => {
|
||||
const command = createCommand(['--json']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/session ID argument is required/);
|
||||
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects picker flags when a session ID is also provided', async () => {
|
||||
const command = createCommand(['session-abc', '--event-name', 'tti']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
|
||||
});
|
||||
|
||||
it('rejects --days when a session ID is also provided', async () => {
|
||||
const command = createCommand(['session-abc', '--days', '7']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
|
||||
});
|
||||
|
||||
it('picker mode with --event-name tti fetches metric events and threads selected sessionId', async () => {
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
|
||||
makeMetricEvent({ sessionId: 'picked-session-1' }),
|
||||
]);
|
||||
// 1) sort prompt (no --sort flag), 2) event picker
|
||||
mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('picked-session-1');
|
||||
|
||||
const command = createCommand(['--event-name', 'tti']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchSessionMetricCandidatesAsync).toHaveBeenCalledTimes(1);
|
||||
const options = mockFetchSessionMetricCandidatesAsync.mock.calls[0][2];
|
||||
expect(options.metricName).toBe('expo.app_startup.tti');
|
||||
expect(options.limit).toBe(25);
|
||||
|
||||
expect(mockFetchSessionLogCandidatesAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchObserveSessionEventsAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
expect.objectContaining({ sessionId: 'picked-session-1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('picker mode with an unknown --event-name treats it as a log event and fetches custom events', async () => {
|
||||
mockFetchSessionLogCandidatesAsync.mockResolvedValue([
|
||||
makeCustomEvent({ sessionId: 'picked-log-session-1' }),
|
||||
]);
|
||||
// 1) sort prompt (no --sort flag), 2) event picker
|
||||
mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('picked-log-session-1');
|
||||
|
||||
const command = createCommand(['--event-name', 'login_pressed']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchSessionLogCandidatesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchSessionLogCandidatesAsync.mock.calls[0][2].eventName).toBe('login_pressed');
|
||||
expect(mockFetchSessionMetricCandidatesAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchObserveSessionEventsAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
expect.objectContaining({ sessionId: 'picked-log-session-1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('picker mode without --event-name or --sort prompts for event name, then sort, then event', async () => {
|
||||
mockCustomEventNamesAsync.mockResolvedValue({
|
||||
names: [
|
||||
{ __typename: 'AppObserveCustomEventName', eventName: 'login_pressed', count: 12 } as any,
|
||||
],
|
||||
isTruncated: false,
|
||||
});
|
||||
// 1) event-name picker → metric choice
|
||||
// 2) sort picker → newest
|
||||
// 3) event picker → sessionId
|
||||
mockSelectAsync
|
||||
.mockResolvedValueOnce({ name: 'expo.app_startup.tti', isMetric: true } as any)
|
||||
.mockResolvedValueOnce('newest')
|
||||
.mockResolvedValueOnce('picked-session-2');
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
|
||||
makeMetricEvent({ sessionId: 'picked-session-2' }),
|
||||
]);
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCustomEventNamesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockSelectAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockFetchSessionMetricCandidatesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchSessionMetricCandidatesAsync.mock.calls[0][2].metricName).toBe(
|
||||
'expo.app_startup.tti'
|
||||
);
|
||||
expect(mockFetchObserveSessionEventsAsync.mock.calls[0][2].sessionId).toBe('picked-session-2');
|
||||
});
|
||||
|
||||
it('prompts for sort order when --sort is not provided and offers metric-only options for metric events', async () => {
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
|
||||
makeMetricEvent({ sessionId: 'picked-session-3' }),
|
||||
]);
|
||||
mockSelectAsync.mockResolvedValueOnce('slowest').mockResolvedValueOnce('picked-session-3');
|
||||
|
||||
const command = createCommand(['--event-name', 'tti']);
|
||||
await command.runAsync();
|
||||
|
||||
// First selectAsync is the sort picker; verify choice values
|
||||
const sortChoices = mockSelectAsync.mock.calls[0][1];
|
||||
expect(sortChoices.map((c: any) => c.value)).toEqual([
|
||||
'newest',
|
||||
'oldest',
|
||||
'slowest',
|
||||
'fastest',
|
||||
]);
|
||||
});
|
||||
|
||||
it('prompts for sort order without fastest/slowest for log events', async () => {
|
||||
mockFetchSessionLogCandidatesAsync.mockResolvedValue([
|
||||
makeCustomEvent({ sessionId: 'picked-log-session-2' }),
|
||||
]);
|
||||
mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('picked-log-session-2');
|
||||
|
||||
const command = createCommand(['--event-name', 'login_pressed']);
|
||||
await command.runAsync();
|
||||
|
||||
const sortChoices = mockSelectAsync.mock.calls[0][1];
|
||||
expect(sortChoices.map((c: any) => c.value)).toEqual(['newest', 'oldest']);
|
||||
});
|
||||
|
||||
it('skips the sort prompt when --sort is provided explicitly', async () => {
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
|
||||
makeMetricEvent({ sessionId: 'picked-session-4' }),
|
||||
]);
|
||||
mockSelectAsync.mockResolvedValueOnce('picked-session-4');
|
||||
|
||||
const command = createCommand(['--event-name', 'tti', '--sort', 'oldest']);
|
||||
await command.runAsync();
|
||||
|
||||
// Only the event picker was called, not the sort picker
|
||||
expect(mockSelectAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('forwards --sort=slowest to the metric candidate fetcher', async () => {
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([makeMetricEvent()]);
|
||||
mockSelectAsync.mockResolvedValueOnce('session-metric-1');
|
||||
|
||||
const command = createCommand(['--event-name', 'tti', '--sort', 'slowest']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchSessionMetricCandidatesAsync.mock.calls[0][2].sort).toBe('slowest');
|
||||
});
|
||||
|
||||
it('throws when --sort=slowest is combined with a log event name', async () => {
|
||||
const command = createCommand(['--event-name', 'login_pressed', '--sort', 'slowest']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/only supported for metric events/);
|
||||
expect(mockFetchSessionLogCandidatesAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes orderAscending=true to the log candidate fetcher for --sort=oldest', async () => {
|
||||
mockFetchSessionLogCandidatesAsync.mockResolvedValue([makeCustomEvent()]);
|
||||
mockSelectAsync.mockResolvedValueOnce('session-log-1');
|
||||
|
||||
const command = createCommand(['--event-name', 'login_pressed', '--sort', 'oldest']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchSessionLogCandidatesAsync.mock.calls[0][2].orderAscending).toBe(true);
|
||||
});
|
||||
|
||||
it('passes orderAscending=false to the log candidate fetcher for --sort=newest', async () => {
|
||||
mockFetchSessionLogCandidatesAsync.mockResolvedValue([makeCustomEvent()]);
|
||||
mockSelectAsync.mockResolvedValueOnce('session-log-1');
|
||||
|
||||
const command = createCommand(['--event-name', 'login_pressed', '--sort', 'newest']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchSessionLogCandidatesAsync.mock.calls[0][2].orderAscending).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when the metric picker returns no candidate events', async () => {
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([]);
|
||||
mockSelectAsync.mockResolvedValueOnce('newest');
|
||||
const command = createCommand(['--event-name', 'tti']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/No events found/);
|
||||
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers each returned candidate as a choice in the event picker', async () => {
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
|
||||
makeMetricEvent({ sessionId: 'session-a' }),
|
||||
makeMetricEvent({ id: 'evt-m-2', sessionId: 'session-b' }),
|
||||
]);
|
||||
// 1) sort prompt, 2) event picker
|
||||
mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('session-a');
|
||||
|
||||
const command = createCommand(['--event-name', 'tti']);
|
||||
await command.runAsync();
|
||||
|
||||
// Second selectAsync call is the event picker
|
||||
const eventChoices = mockSelectAsync.mock.calls[1][1];
|
||||
expect(eventChoices).toHaveLength(2);
|
||||
expect(eventChoices.map((c: any) => c.value)).toEqual(['session-a', 'session-b']);
|
||||
});
|
||||
|
||||
it('rejects --sort when a session ID is also provided', async () => {
|
||||
const command = createCommand(['session-abc', '--sort', 'slowest']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import Log from '../../log';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
@@ -73,6 +76,7 @@ export default class ObserveEvents extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveEvents);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (args.eventName && flags['all-events']) {
|
||||
throw new Error(
|
||||
@@ -85,10 +89,10 @@ export default class ObserveEvents extends EasCommand {
|
||||
commandClass: ObserveEvents,
|
||||
loggedInOnlyContextDefinition: ObserveEvents.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
@@ -104,7 +108,7 @@ export default class ObserveEvents extends EasCommand {
|
||||
platform,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveCustomEventNamesJson(names, isTruncated));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
@@ -140,7 +144,7 @@ export default class ObserveEvents extends EasCommand {
|
||||
platform,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(
|
||||
buildObserveCustomEventsEmptyWithSuggestionsJson(args.eventName, names, isTruncated)
|
||||
);
|
||||
@@ -158,7 +162,7 @@ export default class ObserveEvents extends EasCommand {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveCustomEventsJson(events, pageInfo));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
|
||||
import {
|
||||
@@ -73,16 +76,17 @@ export default class ObserveMetricsSummary extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveMetricsSummary);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveMetricsSummary,
|
||||
loggedInOnlyContextDefinition: ObserveMetricsSummary.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
@@ -108,7 +112,7 @@ export default class ObserveMetricsSummary extends EasCommand {
|
||||
? Array.from(new Set(flags.stat.map(resolveStatKey)))
|
||||
: undefined;
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON;
|
||||
printJsonOnlyOutput(
|
||||
buildObserveMetricsJson(
|
||||
|
||||
@@ -2,7 +2,10 @@ import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
@@ -71,23 +74,24 @@ export default class ObserveMetrics extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveMetrics);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveMetrics,
|
||||
loggedInOnlyContextDefinition: ObserveMetrics.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
let metricName: string;
|
||||
if (args.metric) {
|
||||
metricName = resolveMetricName(args.metric);
|
||||
} else if (flags['non-interactive']) {
|
||||
} else if (nonInteractive) {
|
||||
throw new EasCommandError(
|
||||
'A metric argument is required in non-interactive mode. Available metrics: ' +
|
||||
Object.keys(METRIC_ALIASES).join(', ')
|
||||
@@ -128,7 +132,7 @@ export default class ObserveMetrics extends EasCommand {
|
||||
),
|
||||
]);
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveNavigationRoutesAsync } from '../../observe/fetchNavigationRoutes';
|
||||
@@ -81,16 +84,17 @@ export default class ObserveRoutes extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveRoutes);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveRoutes,
|
||||
loggedInOnlyContextDefinition: ObserveRoutes.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
@@ -125,7 +129,7 @@ export default class ObserveRoutes extends EasCommand {
|
||||
}
|
||||
);
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
const stats = argumentsStat ?? DEFAULT_STATS_JSON;
|
||||
printJsonOnlyOutput(
|
||||
buildObserveNavigationRoutesJson(routes, metricNames, stats, pageInfoByPlatform)
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
import { Args } from '@oclif/core';
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveSessionEventsAsync } from '../../observe/fetchSessions';
|
||||
import { ObserveProjectIdFlag } from '../../observe/flags';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { EventsOrderPreset } from '../../observe/fetchEvents';
|
||||
import {
|
||||
fetchObserveSessionEventsAsync,
|
||||
fetchSessionLogCandidatesAsync,
|
||||
fetchSessionMetricCandidatesAsync,
|
||||
} from '../../observe/fetchSessions';
|
||||
import { ObserveProjectIdFlag, ObserveTimeRangeFlags } from '../../observe/flags';
|
||||
import {
|
||||
buildObserveSessionEventsJson,
|
||||
buildObserveSessionEventsTable,
|
||||
formatLogCandidateTitle,
|
||||
formatMetricCandidateTitle,
|
||||
} from '../../observe/formatSessions';
|
||||
import {
|
||||
METRIC_SHORT_NAMES,
|
||||
isKnownMetricName,
|
||||
resolveMetricName,
|
||||
} from '../../observe/metricNames';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { ExpoChoice, selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
// Fixed at 100 — the maximum page size accepted by the underlying events and
|
||||
@@ -17,18 +37,33 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
// command pulls one page of each and merges client-side.
|
||||
const SESSION_PAGE_SIZE = 100;
|
||||
|
||||
// How many candidate events to present in the picker. Small enough to browse;
|
||||
// users narrow further with --days / --sort.
|
||||
const PICKER_CANDIDATE_LIMIT = 25;
|
||||
|
||||
export default class ObserveSession extends EasCommand {
|
||||
static override description =
|
||||
'display the timeline of metric and log events for a specific session';
|
||||
|
||||
static override args = {
|
||||
sessionId: Args.string({
|
||||
description: 'Session ID to inspect',
|
||||
required: true,
|
||||
description: 'Session ID to inspect (omit in interactive mode to pick one from a list)',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
sort: Flags.option({
|
||||
description:
|
||||
'Sort order for candidate events when picking a session (if omitted in interactive mode, you will be prompted)',
|
||||
options: Object.values(EventsOrderPreset).map(s => s.toLowerCase()),
|
||||
required: false,
|
||||
})(),
|
||||
'event-name': Flags.string({
|
||||
description:
|
||||
'Metric or log event name to pick candidate sessions by (e.g. tti, cold_launch, login_pressed). If omitted in interactive mode, you will be prompted.',
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -44,30 +79,59 @@ export default class ObserveSession extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveSession);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveSession,
|
||||
loggedInOnlyContextDefinition: ObserveSession.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
let sessionId: string;
|
||||
if (args.sessionId) {
|
||||
const pickerFlagsProvided =
|
||||
flags['event-name'] !== undefined ||
|
||||
flags.sort !== undefined ||
|
||||
flags.days !== undefined ||
|
||||
flags.start !== undefined ||
|
||||
flags.end !== undefined;
|
||||
if (pickerFlagsProvided) {
|
||||
throw new EasCommandError(
|
||||
'The picker flags (--event-name, --sort, --days, --start, --end) describe how to find a session and cannot be combined with a session ID argument.'
|
||||
);
|
||||
}
|
||||
sessionId = args.sessionId;
|
||||
} else if (nonInteractive) {
|
||||
throw new EasCommandError(
|
||||
'A session ID argument is required in non-interactive mode. In interactive mode, you can omit the session ID to pick one from a list of events.'
|
||||
);
|
||||
} else {
|
||||
sessionId = await pickSessionIdInteractivelyAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
eventNameFlag: flags['event-name'],
|
||||
sort: flags.sort,
|
||||
timeRangeFlags: { days: flags.days, start: flags.start, end: flags.end },
|
||||
});
|
||||
}
|
||||
|
||||
const { entries, metadata, hasMoreMetricEvents, hasMoreLogEvents } =
|
||||
await fetchObserveSessionEventsAsync(graphqlClient, projectId, {
|
||||
sessionId: args.sessionId,
|
||||
sessionId,
|
||||
limit: SESSION_PAGE_SIZE,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(
|
||||
buildObserveSessionEventsJson(
|
||||
entries,
|
||||
args.sessionId,
|
||||
sessionId,
|
||||
metadata,
|
||||
hasMoreMetricEvents,
|
||||
hasMoreLogEvents
|
||||
@@ -85,3 +149,145 @@ export default class ObserveSession extends EasCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface EventNameChoice {
|
||||
name: string;
|
||||
isMetric: boolean;
|
||||
}
|
||||
|
||||
async function pickSessionIdInteractivelyAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
eventNameFlag,
|
||||
sort,
|
||||
timeRangeFlags,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
eventNameFlag: string | undefined;
|
||||
sort: string | undefined;
|
||||
timeRangeFlags: { days: number | undefined; start: string | undefined; end: string | undefined };
|
||||
}): Promise<string> {
|
||||
const { startTime, endTime } = resolveTimeRange(timeRangeFlags);
|
||||
|
||||
const eventNameChoice: EventNameChoice = eventNameFlag
|
||||
? { name: eventNameFlag, isMetric: isKnownMetricName(eventNameFlag) }
|
||||
: await promptForEventNameAsync({ graphqlClient, projectId, startTime, endTime });
|
||||
|
||||
let sortValue: string;
|
||||
if (sort) {
|
||||
const preset = sort.toUpperCase() as EventsOrderPreset;
|
||||
const sortIsValueBased =
|
||||
preset === EventsOrderPreset.Slowest || preset === EventsOrderPreset.Fastest;
|
||||
if (!eventNameChoice.isMetric && sortIsValueBased) {
|
||||
throw new EasCommandError(
|
||||
`--sort=${sort} is only supported for metric events. Use newest or oldest for log events.`
|
||||
);
|
||||
}
|
||||
sortValue = sort;
|
||||
} else {
|
||||
sortValue = await promptForSortOrderAsync(eventNameChoice.isMetric);
|
||||
}
|
||||
|
||||
const candidates: CandidateEvent[] = eventNameChoice.isMetric
|
||||
? (
|
||||
await fetchSessionMetricCandidatesAsync(graphqlClient, projectId, {
|
||||
metricName: resolveMetricName(eventNameChoice.name),
|
||||
sort: sortValue,
|
||||
startTime,
|
||||
endTime,
|
||||
limit: PICKER_CANDIDATE_LIMIT,
|
||||
})
|
||||
).map(event => ({ sessionId: event.sessionId, title: formatMetricCandidateTitle(event) }))
|
||||
: (
|
||||
await fetchSessionLogCandidatesAsync(graphqlClient, projectId, {
|
||||
eventName: eventNameChoice.name,
|
||||
orderAscending: sortValue.toUpperCase() === EventsOrderPreset.Oldest,
|
||||
startTime,
|
||||
endTime,
|
||||
limit: PICKER_CANDIDATE_LIMIT,
|
||||
})
|
||||
).map(event => ({ sessionId: event.sessionId, title: formatLogCandidateTitle(event) }));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new EasCommandError(
|
||||
`No events found for "${eventNameChoice.name}" in the selected time range. Try widening the window with --days or picking a different --event-name.`
|
||||
);
|
||||
}
|
||||
|
||||
return await promptForSessionIdAsync(candidates);
|
||||
}
|
||||
|
||||
async function promptForSortOrderAsync(isMetric: boolean): Promise<string> {
|
||||
const choices: ExpoChoice<string>[] = [
|
||||
{ title: 'Newest first', value: EventsOrderPreset.Newest.valueOf().toLowerCase() },
|
||||
{ title: 'Oldest first', value: EventsOrderPreset.Oldest.valueOf().toLowerCase() },
|
||||
...(isMetric
|
||||
? [
|
||||
{
|
||||
title: 'Slowest first (highest metric value)',
|
||||
value: EventsOrderPreset.Slowest.valueOf().toLowerCase(),
|
||||
},
|
||||
{
|
||||
title: 'Fastest first (lowest metric value)',
|
||||
value: EventsOrderPreset.Fastest.valueOf().toLowerCase(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return await selectAsync('Sort candidate events by', choices);
|
||||
}
|
||||
|
||||
async function promptForEventNameAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}): Promise<EventNameChoice> {
|
||||
const { names: customEventNames } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
|
||||
const metricChoices: ExpoChoice<EventNameChoice>[] = Object.entries(METRIC_SHORT_NAMES).map(
|
||||
([fullName, displayName]) => ({
|
||||
title: `${displayName} (metric)`,
|
||||
value: { name: fullName, isMetric: true },
|
||||
})
|
||||
);
|
||||
|
||||
const logChoices: ExpoChoice<EventNameChoice>[] = customEventNames.map(
|
||||
({ eventName, count }) => ({
|
||||
title: `${eventName} (${count} log event${count === 1 ? '' : 's'})`,
|
||||
value: { name: eventName, isMetric: false },
|
||||
})
|
||||
);
|
||||
|
||||
const choices = [...metricChoices, ...logChoices];
|
||||
if (choices.length === 0) {
|
||||
throw new EasCommandError(
|
||||
'No metric or log events found for the selected time range. Widen the window with --days or wait for data to arrive.'
|
||||
);
|
||||
}
|
||||
|
||||
return await selectAsync('Select an event name', choices);
|
||||
}
|
||||
|
||||
interface CandidateEvent {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
async function promptForSessionIdAsync(candidates: CandidateEvent[]): Promise<string> {
|
||||
const choices: ExpoChoice<string>[] = candidates.map(c => ({
|
||||
title: c.title,
|
||||
value: c.sessionId,
|
||||
}));
|
||||
return await selectAsync('Select an event (its session will be shown)', choices);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
|
||||
import {
|
||||
@@ -34,16 +37,17 @@ export default class ObserveVersions extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveVersions);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveVersions,
|
||||
loggedInOnlyContextDefinition: ObserveVersions.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
@@ -59,7 +63,7 @@ export default class ObserveVersions extends EasCommand {
|
||||
endTime
|
||||
);
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveVersionsJson(results));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
@@ -1,46 +1,12 @@
|
||||
import openBrowserAsync from 'better-opn';
|
||||
|
||||
import { getProjectDashboardUrl } from '../build/utils/url';
|
||||
import EasCommand from '../commandUtils/EasCommand';
|
||||
import { ora } from '../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import Browse from './browse';
|
||||
|
||||
export default class Open extends EasCommand {
|
||||
static override description = 'open the project page in a web browser';
|
||||
static override description = 'open the project page in a web browser (alias of `eas browse`)';
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
static override hidden = true;
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
// this command is interactive by nature (only really run by humans in a terminal)
|
||||
const {
|
||||
privateProjectConfig: { projectId, exp },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(Open, {
|
||||
nonInteractive: false,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
|
||||
const projectName = exp.slug;
|
||||
|
||||
const projectDashboardUrl = getProjectDashboardUrl(account.name, projectName);
|
||||
const failedMessage = `Unable to open a web browser. Project page is available at: ${projectDashboardUrl}`;
|
||||
const spinner = ora(`Opening ${projectDashboardUrl}`).start();
|
||||
try {
|
||||
const opened = await openBrowserAsync(projectDashboardUrl);
|
||||
|
||||
if (opened) {
|
||||
spinner.succeed(`Opened ${projectDashboardUrl}`);
|
||||
} else {
|
||||
spinner.fail(failedMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(failedMessage);
|
||||
throw error;
|
||||
}
|
||||
await Browse.run([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { getConfigFilePaths } from '@expo/config';
|
||||
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { findProjectRootAsync } from '../../../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import {
|
||||
testAppQueryByIdResponse,
|
||||
testExperienceName,
|
||||
testProjectId,
|
||||
} from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
BackgroundJobReceiptDataFragment,
|
||||
BackgroundJobResultType,
|
||||
BackgroundJobState,
|
||||
} from '../../../graphql/generated';
|
||||
import { AppMutation } from '../../../graphql/mutations/AppMutation';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import Log from '../../../log';
|
||||
import { getPrivateExpoConfigAsync } from '../../../project/expoConfig';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { isSudoModeRequiredError, promptForSudoModeUpgradeAsync } from '../../../user/sudo';
|
||||
import { pollForBackgroundJobReceiptAsync } from '../../../utils/pollForBackgroundJobReceiptAsync';
|
||||
import ProjectDelete from '../delete';
|
||||
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../graphql/mutations/AppMutation');
|
||||
jest.mock('../../../utils/pollForBackgroundJobReceiptAsync');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../user/sudo');
|
||||
jest.mock('../../../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync');
|
||||
jest.mock('../../../project/expoConfig');
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: () => ({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
stop: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe(ProjectDelete, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
const mockReceipt: BackgroundJobReceiptDataFragment = {
|
||||
id: 'receipt-1',
|
||||
state: BackgroundJobState.Success,
|
||||
tries: 1,
|
||||
willRetry: false,
|
||||
resultId: null,
|
||||
resultType: BackgroundJobResultType.Void,
|
||||
resultData: null,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
function createCommand(argv: string[], { insideProjectDir = true } = {}): ProjectDelete {
|
||||
if (insideProjectDir) {
|
||||
jest.mocked(findProjectRootAsync).mockResolvedValue('/app');
|
||||
jest
|
||||
.mocked(getConfigFilePaths)
|
||||
.mockReturnValue({ staticConfigPath: '/app/app.json', dynamicConfigPath: null });
|
||||
jest
|
||||
.mocked(getPrivateExpoConfigAsync)
|
||||
.mockResolvedValue({ extra: { eas: { projectId: testProjectId } } } as never);
|
||||
} else {
|
||||
jest
|
||||
.mocked(findProjectRootAsync)
|
||||
.mockRejectedValue(new Error('Run this command inside a project directory.'));
|
||||
}
|
||||
const command = new ProjectDelete(argv, mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: {
|
||||
graphqlClient,
|
||||
authenticationInfo: { accessToken: null, sessionSecret: 'session-secret' },
|
||||
},
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'error').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
|
||||
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
|
||||
jest.mocked(AppQuery.byFullNameAsync).mockResolvedValue(testAppQueryByIdResponse);
|
||||
jest.mocked(AppMutation.scheduleAppDeletionAsync).mockResolvedValue(mockReceipt);
|
||||
jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue(mockReceipt);
|
||||
jest.mocked(promptAsync).mockResolvedValue({ confirmedName: testExperienceName });
|
||||
jest.mocked(isSudoModeRequiredError).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('deletes the project when the typed name matches', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining(testExperienceName),
|
||||
})
|
||||
);
|
||||
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
|
||||
expect(pollForBackgroundJobReceiptAsync).toHaveBeenCalledWith(graphqlClient, mockReceipt);
|
||||
});
|
||||
|
||||
it('cancels deletion when the typed name does not match', async () => {
|
||||
jest.mocked(promptAsync).mockResolvedValue({ confirmedName: 'wrong-name' });
|
||||
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit');
|
||||
});
|
||||
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow('process.exit');
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('deletes without prompting when --dangerously-confirm-deletion matches the full name', async () => {
|
||||
await createCommand(['--dangerously-confirm-deletion', testExperienceName]).runAsync();
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
|
||||
});
|
||||
|
||||
it('throws when --dangerously-confirm-deletion does not match the full name', async () => {
|
||||
await expect(
|
||||
createCommand(['--dangerously-confirm-deletion', 'wrong-name']).runAsync()
|
||||
).rejects.toThrow(/did not match the project's full name/);
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes in non-interactive mode when --dangerously-confirm-deletion matches the full name', async () => {
|
||||
await createCommand([
|
||||
'--non-interactive',
|
||||
'--dangerously-confirm-deletion',
|
||||
testExperienceName,
|
||||
]).runAsync();
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
|
||||
});
|
||||
|
||||
it('throws in non-interactive mode without --dangerously-confirm-deletion', async () => {
|
||||
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
|
||||
/requires passing --dangerously-confirm-deletion/
|
||||
);
|
||||
|
||||
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes a project by full name argument outside a project directory', async () => {
|
||||
await createCommand(
|
||||
[
|
||||
testExperienceName,
|
||||
'--non-interactive',
|
||||
'--dangerously-confirm-deletion',
|
||||
testExperienceName,
|
||||
],
|
||||
{ insideProjectDir: false }
|
||||
).runAsync();
|
||||
|
||||
expect(AppQuery.byFullNameAsync).toHaveBeenCalledWith(graphqlClient, testExperienceName);
|
||||
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
|
||||
});
|
||||
|
||||
it('deletes a project by ID argument', async () => {
|
||||
await createCommand([
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'--non-interactive',
|
||||
'--dangerously-confirm-deletion',
|
||||
testExperienceName,
|
||||
]).runAsync();
|
||||
|
||||
expect(AppQuery.byIdAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'00000000-0000-0000-0000-000000000001'
|
||||
);
|
||||
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledWith(graphqlClient, testProjectId);
|
||||
});
|
||||
|
||||
it('throws outside a project directory when no argument is passed', async () => {
|
||||
await expect(createCommand([], { insideProjectDir: false }).runAsync()).rejects.toThrow(
|
||||
/No EAS project found in the current directory/
|
||||
);
|
||||
|
||||
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the current project has no EAS projectId configured, without offering to create one', async () => {
|
||||
const command = createCommand([]);
|
||||
jest.mocked(getPrivateExpoConfigAsync).mockResolvedValue({} as never);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
/No EAS project found in the current directory/
|
||||
);
|
||||
|
||||
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not read or create an app config when the directory has none', async () => {
|
||||
const command = createCommand([]);
|
||||
jest
|
||||
.mocked(getConfigFilePaths)
|
||||
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
/No EAS project found in the current directory/
|
||||
);
|
||||
|
||||
expect(getPrivateExpoConfigAsync).not.toHaveBeenCalled();
|
||||
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates app config errors instead of reporting no project found', async () => {
|
||||
const command = createCommand([]);
|
||||
jest
|
||||
.mocked(getPrivateExpoConfigAsync)
|
||||
.mockRejectedValue(new Error('Invalid app config.\n"name" is required'));
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(/Invalid app config/);
|
||||
|
||||
expect(AppMutation.scheduleAppDeletionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upgrades the session to sudo mode and retries when the server requires it', async () => {
|
||||
const sudoError = new Error('sudo required');
|
||||
jest
|
||||
.mocked(AppMutation.scheduleAppDeletionAsync)
|
||||
.mockRejectedValueOnce(sudoError)
|
||||
.mockResolvedValueOnce(mockReceipt);
|
||||
jest.mocked(isSudoModeRequiredError).mockImplementation(e => e === sudoError);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(promptForSudoModeUpgradeAsync).toHaveBeenCalledWith({
|
||||
accessToken: null,
|
||||
sessionSecret: 'session-secret',
|
||||
});
|
||||
expect(AppMutation.scheduleAppDeletionAsync).toHaveBeenCalledTimes(2);
|
||||
expect(pollForBackgroundJobReceiptAsync).toHaveBeenCalledWith(graphqlClient, mockReceipt);
|
||||
});
|
||||
|
||||
it('throws in non-interactive mode when sudo mode is required', async () => {
|
||||
const sudoError = new Error('sudo required');
|
||||
jest.mocked(AppMutation.scheduleAppDeletionAsync).mockRejectedValue(sudoError);
|
||||
jest.mocked(isSudoModeRequiredError).mockImplementation(e => e === sudoError);
|
||||
|
||||
await expect(
|
||||
createCommand([
|
||||
'--non-interactive',
|
||||
'--dangerously-confirm-deletion',
|
||||
testExperienceName,
|
||||
]).runAsync()
|
||||
).rejects.toThrow(/requires a session in sudo mode/);
|
||||
|
||||
expect(promptForSudoModeUpgradeAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { getConfigFilePaths } from '@expo/config';
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { findProjectRootAsync } from '../../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { AppFragment } from '../../graphql/generated';
|
||||
import { AppMutation } from '../../graphql/mutations/AppMutation';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { isSudoModeRequiredError, promptForSudoModeUpgradeAsync } from '../../user/sudo';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export default class ProjectDelete extends EasCommand {
|
||||
static override description = 'delete a project';
|
||||
|
||||
static override args = {
|
||||
name: Args.string({
|
||||
required: false,
|
||||
description:
|
||||
'Full name (@account/slug) or ID of the project to delete. Defaults to the project in the current directory.',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
'dangerously-confirm-deletion': Flags.string({
|
||||
description:
|
||||
"The project's full name (@account/slug), to confirm deletion. Required in non-interactive mode.",
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { name },
|
||||
flags,
|
||||
} = await this.parse(ProjectDelete);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient, authenticationInfo },
|
||||
} = await this.getContextAsync(ProjectDelete, { nonInteractive });
|
||||
|
||||
let app: AppFragment;
|
||||
if (name) {
|
||||
app = UUID_REGEX.test(name)
|
||||
? await AppQuery.byIdAsync(graphqlClient, name)
|
||||
: await AppQuery.byFullNameAsync(graphqlClient, name);
|
||||
} else {
|
||||
let projectDir: string | null = null;
|
||||
try {
|
||||
projectDir = await findProjectRootAsync();
|
||||
} catch {
|
||||
// Not inside a project directory — handled by the error below.
|
||||
}
|
||||
|
||||
let projectId: string | undefined;
|
||||
if (projectDir) {
|
||||
// Only read the config when one already exists: getPrivateExpoConfigAsync creates a
|
||||
// fresh app.json as a side effect when the directory has none. Config errors (broken
|
||||
// app.config.js, invalid app.json) propagate so they are not masked by the error below.
|
||||
const configPaths = getConfigFilePaths(projectDir);
|
||||
if (configPaths.staticConfigPath ?? configPaths.dynamicConfigPath) {
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir);
|
||||
projectId = exp.extra?.eas?.projectId;
|
||||
}
|
||||
}
|
||||
if (!projectId) {
|
||||
throw new Error(
|
||||
"No EAS project found in the current directory. Pass the project's full name (@account/slug) or ID as an argument."
|
||||
);
|
||||
}
|
||||
app = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
}
|
||||
|
||||
const confirmedFullName = flags['dangerously-confirm-deletion'];
|
||||
if (confirmedFullName) {
|
||||
if (confirmedFullName !== app.fullName) {
|
||||
throw new Error(
|
||||
`The value of --dangerously-confirm-deletion ("${confirmedFullName}") did not match the project's full name ("${app.fullName}"). Cancelled deletion.`
|
||||
);
|
||||
}
|
||||
} else if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Deleting a project in non-interactive mode requires passing --dangerously-confirm-deletion with the project's full name ("${app.fullName}").`
|
||||
);
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`You are about to permanently delete project ${chalk.bold(app.fullName)}.` +
|
||||
`\nThis will delete everything associated with it, including builds, submissions, update branches, published updates, and environment variables.` +
|
||||
`\nThis action is irreversible.`
|
||||
);
|
||||
Log.newLine();
|
||||
const { confirmedName } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'confirmedName',
|
||||
message: `Type the project's full name (${app.fullName}) to confirm deletion:`,
|
||||
});
|
||||
if (confirmedName !== app.fullName) {
|
||||
Log.error(`The input did not match the project's full name. Cancelled deletion.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = ora(`Deleting project ${app.fullName}`).start();
|
||||
try {
|
||||
let receipt;
|
||||
try {
|
||||
receipt = await AppMutation.scheduleAppDeletionAsync(graphqlClient, app.id);
|
||||
} catch (error) {
|
||||
if (!isSudoModeRequiredError(error)) {
|
||||
throw error;
|
||||
}
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'Deleting a project requires a session in sudo mode. Run this command interactively to confirm your password, then retry in non-interactive mode while sudo mode is active.'
|
||||
);
|
||||
}
|
||||
spinner.stop();
|
||||
await promptForSudoModeUpgradeAsync(authenticationInfo);
|
||||
spinner.start();
|
||||
receipt = await AppMutation.scheduleAppDeletionAsync(graphqlClient, app.id);
|
||||
}
|
||||
await pollForBackgroundJobReceiptAsync(graphqlClient, receipt);
|
||||
spinner.succeed(`Deleted project ${chalk.bold(app.fullName)}`);
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to delete project ${chalk.bold(app.fullName)}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ id: app.id, fullName: app.fullName });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export default class SimulatorStart extends EasCommand {
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
char: 'p',
|
||||
description: 'Device platform',
|
||||
options: ['android', 'ios'] as const,
|
||||
required: true,
|
||||
|
||||
+847
-83
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,14 @@ import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import { CreateAppMutation, CreateAppMutationVariables } from '../generated';
|
||||
import {
|
||||
BackgroundJobReceiptDataFragment,
|
||||
CreateAppMutation,
|
||||
CreateAppMutationVariables,
|
||||
ScheduleAppDeletionMutation,
|
||||
ScheduleAppDeletionMutationVariables,
|
||||
} from '../generated';
|
||||
import { BackgroundJobReceiptNode } from '../types/BackgroundJobReceipt';
|
||||
|
||||
export const AppMutation = {
|
||||
async createAppAsync(
|
||||
@@ -35,4 +42,30 @@ export const AppMutation = {
|
||||
assert(appId, 'App ID must be defined');
|
||||
return appId;
|
||||
},
|
||||
async scheduleAppDeletionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string
|
||||
): Promise<BackgroundJobReceiptDataFragment> {
|
||||
const result = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<ScheduleAppDeletionMutation, ScheduleAppDeletionMutationVariables>(
|
||||
gql`
|
||||
mutation ScheduleAppDeletion($appId: ID!) {
|
||||
app {
|
||||
scheduleAppDeletion(appId: $appId) {
|
||||
id
|
||||
...BackgroundJobReceiptData
|
||||
}
|
||||
}
|
||||
}
|
||||
${BackgroundJobReceiptNode}
|
||||
`,
|
||||
{ appId }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const receipt = result.app?.scheduleAppDeletion;
|
||||
assert(receipt, 'Background job receipt must be defined');
|
||||
return receipt;
|
||||
},
|
||||
};
|
||||
@@ -201,6 +201,12 @@ export const AccountQuery = {
|
||||
overageMetrics {
|
||||
id
|
||||
value
|
||||
metadata {
|
||||
... on AccountUsageEASBuildMetadata {
|
||||
billingResourceClass
|
||||
platform
|
||||
}
|
||||
}
|
||||
}
|
||||
totalCost
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ export const DeviceRunSessionQuery = {
|
||||
}
|
||||
... on ServeSimRunSessionRemoteConfig {
|
||||
previewUrl
|
||||
streamUrl
|
||||
}
|
||||
}
|
||||
turtleJobRun {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AppObserveAppVersion,
|
||||
AppObserveCustomEvent,
|
||||
AppObserveCustomEventListFilter,
|
||||
AppObserveCustomEventListOrderBy,
|
||||
AppObserveCustomEventName,
|
||||
AppObserveEvent,
|
||||
AppObserveEventsFilter,
|
||||
@@ -112,6 +113,7 @@ type AppObserveCustomEventListQueryVariables = {
|
||||
filter?: AppObserveCustomEventListFilter;
|
||||
first?: number;
|
||||
after?: string;
|
||||
orderBy?: AppObserveCustomEventListOrderBy;
|
||||
};
|
||||
|
||||
type AppObserveCustomEventNamesQuery = {
|
||||
@@ -328,12 +330,18 @@ export const ObserveQuery = {
|
||||
$filter: AppObserveCustomEventListFilter
|
||||
$first: Int
|
||||
$after: String
|
||||
$orderBy: AppObserveCustomEventListOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
customEventList(filter: $filter, first: $first, after: $after) {
|
||||
customEventList(
|
||||
filter: $filter
|
||||
first: $first
|
||||
after: $after
|
||||
orderBy: $orderBy
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
|
||||
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user