mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e2aaa4ac9 | ||
|
|
3ef0f74110 | ||
|
|
51ba7198d7 | ||
|
|
e10e6a2235 | ||
|
|
17f84cf7d6 | ||
|
|
f7d9b7b297 | ||
|
|
b287d7a4e0 | ||
|
|
d9d1b91fa5 | ||
|
|
a7436edb50 | ||
|
|
89fe2cb3e1 | ||
|
|
0e8e8cc0aa | ||
|
|
6d7cef80a3 | ||
|
|
5a744b73f5 | ||
|
|
b8b53e262a | ||
|
|
3f74ea369d | ||
|
|
28f80da648 | ||
|
|
a7260dfed3 | ||
|
|
5cd1b5eee3 | ||
|
|
9d627c6917 | ||
|
|
66dbcaf432 |
No files matched your search
@@ -81,10 +81,13 @@ jobs:
|
||||
jq --arg version "$EAS_CLI_VERSION" '.STAGING = $version' \
|
||||
cli-versions.json > cli-versions.json.tmp
|
||||
mv cli-versions.json.tmp cli-versions.json
|
||||
jq --arg version "$EAS_CLI_VERSION" '.PRODUCTION = $version' \
|
||||
cli-versions.json > cli-versions.json.tmp
|
||||
mv cli-versions.json.tmp cli-versions.json
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Expo CI"
|
||||
git add cli-versions.json CHANGELOG.md
|
||||
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING in cli-versions.json"
|
||||
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING and PRODUCTION in cli-versions.json"
|
||||
git push
|
||||
- name: Prepare changelog for Slack
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Update Appium command list
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Every Monday at 06:00 UTC
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/update-appium-commands.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
runs-on: ubuntu-latest
|
||||
name: Regenerate Appium command list
|
||||
outputs:
|
||||
changed: ${{ steps.diff.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Regenerate the Appium command list
|
||||
# The generator installs Appium, XCUITest, and UiAutomator2 on demand,
|
||||
# so it always picks up the latest supported command sets.
|
||||
run: yarn workspace @expo/build-tools generate-appium-commands
|
||||
- name: Check whether the command list changed
|
||||
id: diff
|
||||
run: |
|
||||
if git diff --quiet -- packages/build-tools/src/steps/utils/appiumCommands.generated.ts; then
|
||||
echo 'changed=false' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'changed=true' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Upload the generated command list
|
||||
if: steps.diff.outputs.changed == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: appium-command-list
|
||||
path: packages/build-tools/src/steps/utils/appiumCommands.generated.ts
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
needs: generate
|
||||
if: needs.generate.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
name: Publish Appium command list update
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download the generated command list
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: appium-command-list
|
||||
path: packages/build-tools/src/steps/utils
|
||||
- name: Open a pull request
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
|
||||
run: |
|
||||
branch=bot/update-appium-commands
|
||||
title='[build-tools] Update Appium command list'
|
||||
body=$'Automated update of the Appium, XCUITest, and UiAutomator2 command list.\n\nIf this PR adds new commands, typechecking will fail until a curated summary is added for each of them in `packages/build-tools/src/steps/utils/appiumCommandSummary.ts`.'
|
||||
|
||||
gh auth setup-git
|
||||
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
|
||||
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
fi
|
||||
|
||||
git config user.name 'Expo Bot'
|
||||
git config user.email [email protected]
|
||||
git checkout -B "$branch"
|
||||
git add packages/build-tools/src/steps/utils/appiumCommands.generated.ts
|
||||
git commit -m "$title"
|
||||
git push --force-with-lease origin "$branch"
|
||||
|
||||
if gh pr view "$branch" --json state --jq '.state' 2>/dev/null | grep -q OPEN; then
|
||||
gh pr edit "$branch" --title "$title" --body "$body" --add-reviewer sjchmiela
|
||||
else
|
||||
gh pr create --base main --head "$branch" --title "$title" --body "$body" \
|
||||
--label 'no changelog' --reviewer sjchmiela
|
||||
fi
|
||||
+44
-5
@@ -6,10 +6,53 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Rename the EAS Simulator preview-only session type from `--type serve-sim` to `--type web-preview-only`. ([#4298](https://github.com/expo/eas-cli/pull/4298) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Cache CocoaPods dependencies between iOS builds. ([#4266](https://github.com/expo/eas-cli/pull/4266) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
- [build-tools] Support `EAS_BUN_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `bun install --filter`. ([#4292](https://github.com/expo/eas-cli/pull/4292) by [@konrad-armatys](https://github.com/konrad-armatys))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Prevent user npm minimum release age settings from applying to internal EAS CLI commands. ([#4296](https://github.com/expo/eas-cli/pull/4296) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Reduce serve-sim preview resolution from 1280 px to 960 px to lower streaming bandwidth. ([#4297](https://github.com/expo/eas-cli/pull/4297) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [worker] Use turtle cache upload and download URLs for eas.json build cache restore and save. ([#3860](https://github.com/expo/eas-cli/pull/3860) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [ci] Bump both STAGING and PRODUCTION cli-versions in release workflow. ([#4290](https://github.com/expo/eas-cli/pull/4290) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [22.6.0](https://github.com/expo/eas-cli/releases/tag/v22.6.0) - 2026-08-26
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add an opt-in `--max-idle-time-minutes` flag to `eas simulator`; the session stops automatically after that many minutes without activity. ([#4156](https://github.com/expo/eas-cli/pull/4156) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add experimental `--resource-class` flag to `eas simulator`. ([#4268](https://github.com/expo/eas-cli/pull/4268) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add worker-side SSH session helpers (upterm relay + session create/report/close). ([#4030](https://github.com/expo/eas-cli/pull/4030) by [@gwdp](https://github.com/gwdp))
|
||||
- [worker] Wire an SSH session into the build lifecycle behind the workflow ssh flag. ([#4031](https://github.com/expo/eas-cli/pull/4031) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Increase the serve-sim video bitrate to 6 Mbps for higher-quality simulator previews. ([#4291](https://github.com/expo/eas-cli/pull/4291) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Preapprove custom URL schemes before opening them in iOS Simulator sessions to avoid the first-use confirmation prompt. ([#4274](https://github.com/expo/eas-cli/pull/4274) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [22.4.0](https://github.com/expo/eas-cli/releases/tag/v22.4.0) - 2026-08-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--launch-arg`, `--open-url`, and Expo Go `--sdk-version` options to `eas simulator`. ([#4264](https://github.com/expo/eas-cli/pull/4264) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Pass launch arguments and a URL to open when launching applications in simulator sessions. ([#4263](https://github.com/expo/eas-cli/pull/4263) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add `eas billing:subscribe <plan>` to start a Stripe checkout for an EAS plan, and `eas billing:manage` to open the Stripe customer portal. ([#4007](https://github.com/expo/eas-cli/pull/4007) by [@jonsamp](https://github.com/jonsamp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Support downloading iOS simulator archives whose app bundle contents are stored at the archive root. ([#4262](https://github.com/expo/eas-cli/pull/4262) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [22.3.0](https://github.com/expo/eas-cli/releases/tag/v22.3.0) - 2026-08-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `ios_signing_backend` option to the repack step. ([#4239](https://github.com/expo/eas-cli/pull/4239) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
- [build-tools] Support an optional `package_version` input on `eas/start_serve_sim_remote_session`, so a simulator session can pin the `@expo/serve-sim` version instead of always running `latest`. ([#4253](https://github.com/expo/eas-cli/pull/4253) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add composable custom build functions for downloading, installing, and launching application archives in simulator sessions. ([#4222](https://github.com/expo/eas-cli/pull/4222) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
@@ -17,9 +60,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Support downloading iOS simulator archives whose app bundle contents are stored at the archive root. ([#4262](https://github.com/expo/eas-cli/pull/4262) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
- [build-tools] Upload tvOS (and other non-iOS) IPAs to the App Store Connect platform declared in the bundle's `DTPlatformName`, instead of always uploading to the iOS train. ([#4234](https://github.com/expo/eas-cli/pull/4234) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [22.2.0](https://github.com/expo/eas-cli/releases/tag/v22.2.0) - 2026-08-20
|
||||
|
||||
@@ -27,7 +68,6 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
- [eas-cli] Add `--device` flag to `eas simulator` for selecting the virtual device to start. ([#4172](https://github.com/expo/eas-cli/pull/4172) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add support for Appium EAS Simulator sessions. ([#4190](https://github.com/expo/eas-cli/pull/4190) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Add `eas billing:subscribe <plan>` to start a Stripe checkout for an EAS plan, and `eas billing:manage` to open the Stripe customer portal. ([#4007](https://github.com/expo/eas-cli/pull/4007) by [@jonsamp](https://github.com/jonsamp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
@@ -35,7 +75,6 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [eas-cli] Send the expected `Content-MD5` checksum with GCS signed URL uploads so GCS rejects corrupted uploads. ([#4208](https://github.com/expo/eas-cli/pull/4208) by [@wschurman](https://github.com/wschurman))
|
||||
- [eas-cli] Resolve the `eas go` SDK version to its repack target, so `--sdk-version 57` no longer fails with "No Expo Go repack target is published for SDK 57". ([#4215](https://github.com/expo/eas-cli/pull/4215) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Handle missing fingerprint source in fingerprint:compare command. ([#4210](https://github.com/expo/eas-cli/pull/4210) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Upload tvOS (and other non-iOS) IPAs to the App Store Connect platform declared in the bundle's `DTPlatformName`, instead of always uploading to the iOS train. ([#4234](https://github.com/expo/eas-cli/pull/4234) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "22.2.0",
|
||||
"PRODUCTION": "21.5.0"
|
||||
"STAGING": "22.6.0",
|
||||
"PRODUCTION": "22.4.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "22.4.0",
|
||||
"version": "23.0.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "22.4.0",
|
||||
"version": "23.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,6 +25,7 @@
|
||||
"build": "tsc",
|
||||
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
|
||||
"typecheck": "tsc",
|
||||
"generate-appium-commands": "mise exec [email protected] -- node scripts/generate-appium-commands.js",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
"jest-unit": "jest --config jest/unit-config.ts",
|
||||
"jest-integration": "jest --config jest/integration-config.ts",
|
||||
@@ -38,17 +39,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "22.0.0",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/downloader": "23.0.0",
|
||||
"@expo/eas-build-job": "23.0.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/logger": "23.0.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "22.4.0",
|
||||
"@expo/template-file": "22.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/steps": "23.0.0",
|
||||
"@expo/template-file": "23.0.0",
|
||||
"@expo/turtle-spawn": "23.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Regenerates src/steps/utils/appiumCommands.generated.ts from Appium and the
|
||||
// XCUITest and UiAutomator2 drivers, so TypeScript can verify that we handle
|
||||
// every command that can appear in Appium event timings.
|
||||
//
|
||||
// Appium and its drivers are NOT dependencies of this package. Like repack, we
|
||||
// install them on demand into a temp directory just for this script, so nothing
|
||||
// heavy ships with @expo/build-tools.
|
||||
//
|
||||
// Run with: yarn workspace @expo/build-tools generate-appium-commands
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
// Track the Appium 3 line, matching the appium@^3 the worker installs for
|
||||
// simulator sessions (see startAppiumRemoteSession.ts).
|
||||
const APPIUM_VERSION = '^3';
|
||||
const DRIVERS = [
|
||||
{
|
||||
packageName: 'appium-xcuitest-driver',
|
||||
exportName: 'XCUITestDriver',
|
||||
typeName: 'XCUITestAppiumCommand',
|
||||
},
|
||||
{
|
||||
packageName: 'appium-uiautomator2-driver',
|
||||
exportName: 'AndroidUiautomator2Driver',
|
||||
typeName: 'UiAutomator2AppiumCommand',
|
||||
},
|
||||
];
|
||||
|
||||
function renderCommandType(typeName, commands) {
|
||||
return `export type ${typeName} =\n${commands.map(command => ` | ${serializeStringLiteral(command)}`).join('\n')};`;
|
||||
}
|
||||
|
||||
function serializeStringLiteral(value) {
|
||||
const jsonContents = JSON.stringify(value).slice(1, -1).replaceAll('\\"', '"');
|
||||
return `'${jsonContents.replaceAll("'", "\\'")}'`;
|
||||
}
|
||||
|
||||
function readRouteCommands(methodMap) {
|
||||
return Object.values(methodMap).flatMap(route =>
|
||||
Object.values(route).map(method => method.command)
|
||||
);
|
||||
}
|
||||
|
||||
async function readDriverCommands({ packageName, exportName }, sandbox) {
|
||||
const packageJsonPath = require.resolve(`${packageName}/package.json`, { paths: [sandbox] });
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const modulePath = path.join(path.dirname(packageJsonPath), packageJson.exports['.'].import);
|
||||
const driverModule = await import(pathToFileURL(modulePath).href);
|
||||
const Driver = driverModule[exportName];
|
||||
return [
|
||||
...readRouteCommands(Driver.newMethodMap),
|
||||
...Object.values(Driver.executeMethodMap).map(method => method.command),
|
||||
];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'appium-command-generator-'));
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(sandbox, 'package.json'),
|
||||
`${JSON.stringify({ name: 'appium-command-generator-sandbox', private: true })}\n`
|
||||
);
|
||||
execFileSync(
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
'--prefix',
|
||||
sandbox,
|
||||
`appium@${APPIUM_VERSION}`,
|
||||
...DRIVERS.map(({ packageName }) => packageName),
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { ALL_COMMANDS } = require(require.resolve('@appium/base-driver', { paths: [sandbox] }));
|
||||
const baseDriverCommands = [...new Set(ALL_COMMANDS)].sort();
|
||||
const baseDriverCommandSet = new Set(baseDriverCommands);
|
||||
const driverCommands = await Promise.all(
|
||||
DRIVERS.map(async driver => ({
|
||||
typeName: driver.typeName,
|
||||
commands: [...new Set(await readDriverCommands(driver, sandbox))]
|
||||
.filter(command => !baseDriverCommandSet.has(command))
|
||||
.sort(),
|
||||
}))
|
||||
);
|
||||
const commands = [
|
||||
...new Set([...baseDriverCommands, ...driverCommands.flatMap(({ commands }) => commands)]),
|
||||
];
|
||||
|
||||
const outputPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'src',
|
||||
'steps',
|
||||
'utils',
|
||||
'appiumCommands.generated.ts'
|
||||
);
|
||||
const contents = `// AUTO-GENERATED. Do not edit by hand.
|
||||
// Run \`yarn workspace @expo/build-tools generate-appium-commands\` to refresh this
|
||||
// list from Appium, XCUITest, and UiAutomator2 (installed on demand by the generator).
|
||||
${renderCommandType('BaseDriverAppiumCommand', baseDriverCommands)}
|
||||
|
||||
${driverCommands
|
||||
.map(({ typeName, commands: commandsForDriver }) =>
|
||||
renderCommandType(typeName, commandsForDriver)
|
||||
)
|
||||
.join('\n\n')}
|
||||
|
||||
export type AppiumCommand =
|
||||
| BaseDriverAppiumCommand
|
||||
| XCUITestAppiumCommand
|
||||
| UiAutomator2AppiumCommand;
|
||||
`;
|
||||
fs.writeFileSync(outputPath, contents);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Wrote ${commands.length} Appium commands to ${outputPath}`);
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -18,8 +18,12 @@ import { downloadApplicationArchiveAsync } from '../ios/resign';
|
||||
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
restoreCocoapodsCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveCocoapodsCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
@@ -107,6 +111,12 @@ async function buildInnerAsync(
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => {
|
||||
@@ -250,6 +260,12 @@ async function buildInnerAsync(
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
|
||||
|
||||
@@ -70,6 +70,48 @@ describe('easBuildInternal', () => {
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('overrides the minimum release age only for the internal config command', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
const ctx = {
|
||||
logger,
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
},
|
||||
job: { platform: 'ios', buildProfile: 'production' },
|
||||
} as any;
|
||||
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValueOnce({
|
||||
cmd: 'npx',
|
||||
args: ['-y', 'eas-cli@latest'],
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
jest.mocked(spawn).mockResolvedValueOnce({
|
||||
stdout: Buffer.from(JSON.stringify({ buildProfile: { env: {} } })),
|
||||
} as any);
|
||||
|
||||
await resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' });
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '0',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ctx.env).toEqual({
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes --refresh-ad-hoc-provisioning-profile to build:internal for iOS jobs with refreshAdHocProvisioningProfile', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
|
||||
@@ -3,7 +3,10 @@ import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { PackageManager } from '../../utils/packageManager';
|
||||
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
|
||||
import {
|
||||
installDependenciesAsync,
|
||||
installDependenciesWithNpmCacheFallbackAsync,
|
||||
} from '../installDependencies';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => jest.fn());
|
||||
jest.mock('../../sentry', () => ({
|
||||
@@ -236,6 +239,49 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(installDependenciesAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(spawn).mockReturnValue(createSpawnPromise(Promise.resolve(createSpawnResult())));
|
||||
});
|
||||
|
||||
it('installs only the selected workspace when EAS_BUN_FILTER_WORKSPACE is set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.BUN,
|
||||
env: { EAS_BUN_FILTER_WORKSPACE: 'my-app' },
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--filter', 'my-app', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
|
||||
it('installs all workspaces when EAS_BUN_FILTER_WORKSPACE is not set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.BUN,
|
||||
env: {},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
|
||||
return Object.assign(result, {
|
||||
child: {
|
||||
|
||||
@@ -58,7 +58,11 @@ export async function installDependenciesAsync({
|
||||
break;
|
||||
}
|
||||
case PackageManager.BUN:
|
||||
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
|
||||
args = [
|
||||
'install',
|
||||
...(env['EAS_BUN_FILTER_WORKSPACE'] ? ['--filter', env['EAS_BUN_FILTER_WORKSPACE']] : []),
|
||||
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
|
||||
];
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported package manager: ${packageManager}`);
|
||||
|
||||
@@ -5,6 +5,8 @@ import RemoteLoggerStream from './logging/RemoteLoggerStream';
|
||||
export { Builders, HttpLogStream, RemoteLoggerStream };
|
||||
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
|
||||
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
|
||||
export { downloadCacheAsync } from './steps/functions/restoreCache';
|
||||
export { uploadCacheAsync } from './steps/functions/saveCache';
|
||||
|
||||
export {
|
||||
ArtifactToUpload,
|
||||
@@ -18,6 +20,9 @@ export {
|
||||
|
||||
export { PackageManager } from './utils/packageManager';
|
||||
|
||||
export * as TurtleSshSession from './utils/turtleSshSession';
|
||||
export { formatSecondsForLog } from './utils/formatDuration';
|
||||
|
||||
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
|
||||
|
||||
export { Hook, runHookIfPresent } from './utils/hooks';
|
||||
|
||||
@@ -162,6 +162,28 @@ describe(createEasBuildBuildFunctionGroup, () => {
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
|
||||
});
|
||||
|
||||
it('restores and saves caches around an iOS simulator build', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
const stepNames = steps.map(step => step.displayName);
|
||||
const restoreCacheIndex = stepNames.indexOf('Restore Cache');
|
||||
const installPodsIndex = stepNames.indexOf('Install Pods');
|
||||
const saveCacheIndex = stepNames.indexOf('Save Cache');
|
||||
|
||||
expect(restoreCacheIndex).toBeGreaterThan(-1);
|
||||
expect(restoreCacheIndex).toBeLessThan(installPodsIndex);
|
||||
expect(saveCacheIndex).toBeGreaterThan(installPodsIndex);
|
||||
|
||||
const restoreCacheStep = steps[restoreCacheIndex];
|
||||
expect(restoreCacheStep.inputs?.find(input => input.id === 'simulator')?.rawValue).toBe(true);
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.ANDROID,
|
||||
|
||||
@@ -98,6 +98,8 @@ function createStepsForIosSimulatorBuild({
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
@@ -106,6 +108,16 @@ function createStepsForIosSimulatorBuild({
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -123,6 +135,16 @@ function createStepsForIosSimulatorBuild({
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
},
|
||||
});
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
@@ -138,6 +160,7 @@ function createStepsForIosSimulatorBuild({
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
@@ -159,6 +182,10 @@ function createStepsForIosSimulatorBuild({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { spawnAsync } from '@expo/steps';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Datadog } from '../../../datadog';
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../../../utils/cocoapodsCache';
|
||||
import { downloadCacheAsync } from '../restoreCache';
|
||||
import { restoreCocoapodsCacheAsync } from '../restoreBuildCache';
|
||||
import { uploadCacheAsync } from '../saveCache';
|
||||
import { saveCocoapodsCacheAsync } from '../saveBuildCache';
|
||||
|
||||
jest.mock('@expo/steps', () => ({
|
||||
...jest.requireActual('@expo/steps'),
|
||||
spawnAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../restoreCache', () => ({
|
||||
decompressCacheAsync: jest.fn(),
|
||||
downloadCacheAsync: jest.fn(),
|
||||
downloadPublicCacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../saveCache', () => ({
|
||||
compressCacheAsync: jest.fn(),
|
||||
uploadCacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../utils/cocoapodsCache', () => ({
|
||||
compressCocoapodsCacheAsync: jest.fn(),
|
||||
getCocoapodsCachePaths: jest.fn(),
|
||||
resolveCocoapodsCacheKeyAsync: jest.fn(),
|
||||
restoreCocoapodsCacheArchiveAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = createMockLogger();
|
||||
const env = {
|
||||
EAS_PODS_CACHE: '1',
|
||||
EAS_BUILD_ID: 'build-id',
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
};
|
||||
const secrets = { robotAccessToken: 'robot-token' };
|
||||
|
||||
describe('CocoaPods build cache', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vol.fromJSON(
|
||||
{
|
||||
'/workingdir/ios/Pods/Manifest.lock': 'manifest',
|
||||
'/workingdir/ios/Podfile.lock': 'lockfile',
|
||||
'/tmp/cocoapods-cache.tar.gz': 'archive',
|
||||
},
|
||||
'/'
|
||||
);
|
||||
jest.mocked(getCocoapodsCachePaths).mockReturnValue({
|
||||
iosDirectory: '/workingdir/ios',
|
||||
podsDirectory: '/workingdir/ios/Pods',
|
||||
podfileLockPath: '/workingdir/ios/Podfile.lock',
|
||||
});
|
||||
jest.mocked(resolveCocoapodsCacheKeyAsync).mockResolvedValue({
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
keyPrefix: 'ios-pods-1.16.2-',
|
||||
});
|
||||
jest.mocked(spawnAsync).mockResolvedValue({ stdout: '1.16.2\n' } as any);
|
||||
});
|
||||
|
||||
it('does nothing when the cache is disabled', async () => {
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env: {},
|
||||
secrets,
|
||||
});
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env: {},
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(spawnAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the newest matching CocoaPods cache', async () => {
|
||||
jest.mocked(downloadCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
matchedKey: 'ios-pods-1.16.2-older-lock-hash',
|
||||
});
|
||||
const datadogLogSpy = jest.spyOn(Datadog, 'log');
|
||||
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env,
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(downloadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
keyPrefixes: ['ios-pods-1.16.2-'],
|
||||
paths: ['/workingdir/ios/Pods'],
|
||||
})
|
||||
);
|
||||
expect(restoreCocoapodsCacheArchiveAsync).toHaveBeenCalledWith({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
workingDirectory: '/workingdir',
|
||||
});
|
||||
expect(datadogLogSpy).toHaveBeenCalledWith('CocoaPods cache restored (prefix_match)', {
|
||||
event: 'cocoapods_cache_restored',
|
||||
cache_hit_type: 'prefix_match',
|
||||
});
|
||||
});
|
||||
|
||||
it('compresses and saves the installed Pods directory', async () => {
|
||||
jest.mocked(compressCocoapodsCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
});
|
||||
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env,
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(uploadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
paths: ['/workingdir/ios/Pods'],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -56,11 +56,80 @@ describe(launchApplicationAsync, () => {
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'booted',
|
||||
'defaults',
|
||||
'write',
|
||||
'com.apple.launchservices.schemeapproval',
|
||||
'com.apple.CoreSimulator.CoreSimulatorBridge-->exp',
|
||||
'-string',
|
||||
'host.exp.Exponent',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'xcrun',
|
||||
['simctl', 'openurl', 'booted', 'exp://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a web URL on iOS without preapproving its scheme', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
openUrl: 'https://example.test',
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'xcrun',
|
||||
['simctl', 'launch', 'booted', 'com.example.app'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'xcrun',
|
||||
['simctl', 'openurl', 'booted', 'https://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('opens a custom URL on iOS when scheme preapproval fails', async () => {
|
||||
const logger = createMockLogger();
|
||||
const approvalError = new Error('Scheme approval is unavailable.');
|
||||
mockedSpawn
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
|
||||
.mockRejectedValueOnce(approvalError);
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'host.exp.Exponent',
|
||||
openUrl: 'exp://example.test',
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'xcrun',
|
||||
['simctl', 'openurl', 'booted', 'exp://example.test'],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: approvalError },
|
||||
'Could not preapprove the exp URL scheme for host.exp.Exponent. Opening the URL anyway; the Simulator might require manual confirmation.'
|
||||
);
|
||||
});
|
||||
|
||||
it('launches an Android Emulator application by package and activity', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import fs from 'fs';
|
||||
import fetch from 'node-fetch';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync } from '../saveCache';
|
||||
import { compressCacheAsync, uploadCacheAsync } from '../saveCache';
|
||||
import { decompressCacheAsync } from '../restoreCache';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
@@ -81,3 +87,71 @@ describe('cache compress/decompress round trip', () => {
|
||||
expect(Math.floor(restoredStat.mtimeMs / 1000)).toBe(Math.floor(mtime.getTime() / 1000));
|
||||
});
|
||||
});
|
||||
|
||||
describe(uploadCacheAsync, () => {
|
||||
afterEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['normal upload', undefined, false],
|
||||
['forced upload', true, true],
|
||||
] as const)('sends force for %s', async (_name, force, expectedForce) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'upload-cache-test-'));
|
||||
const archivePath = path.join(tempDir, 'cache.tar.gz');
|
||||
await fs.promises.writeFile(archivePath, 'cache archive');
|
||||
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
headers: {},
|
||||
url: 'https://storage.expo.test/cache',
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
.mockImplementationOnce(async (_url, request) => {
|
||||
const body = request!.body as NodeJS.ReadableStream & AsyncIterable<Buffer>;
|
||||
for await (const _chunk of body) {
|
||||
// Consume the archive stream as a real upload would.
|
||||
}
|
||||
return new Response(undefined, { status: 200 });
|
||||
});
|
||||
|
||||
try {
|
||||
await uploadCacheAsync({
|
||||
logger: createLoggerMock(),
|
||||
jobId: 'build-id',
|
||||
expoApiServerURL: 'https://api.expo.test',
|
||||
robotAccessToken: 'robot-token',
|
||||
paths: ['/cache/path'],
|
||||
key: 'cache-key',
|
||||
archivePath,
|
||||
size: 13,
|
||||
platform: Platform.ANDROID,
|
||||
force,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
const [uploadSessionUrl, uploadSessionRequest] = jest.mocked(fetch).mock.calls[0];
|
||||
expect(uploadSessionUrl.toString()).toBe(
|
||||
'https://api.expo.test/v2/turtle-builds/caches/upload-sessions'
|
||||
);
|
||||
expect(JSON.parse(uploadSessionRequest!.body as string)).toMatchObject({
|
||||
buildId: 'build-id',
|
||||
force: expectedForce,
|
||||
key: 'cache-key',
|
||||
size: 13,
|
||||
});
|
||||
expect(jest.mocked(fetch).mock.calls[1][0].toString()).toBe(
|
||||
'https://storage.expo.test/cache'
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
const IOS_URL_SCHEME_APPROVAL_DOMAIN = 'com.apple.launchservices.schemeapproval';
|
||||
const IOS_URL_SCHEME_APPROVAL_KEY_PREFIX = 'com.apple.CoreSimulator.CoreSimulatorBridge-->';
|
||||
|
||||
export function createLaunchApplicationFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
@@ -86,6 +89,7 @@ export async function launchApplicationAsync({
|
||||
logger,
|
||||
});
|
||||
if (openUrl) {
|
||||
await preapproveIosUrlSchemeAsync({ applicationIdentifier, openUrl, env, logger });
|
||||
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
|
||||
await spawn('xcrun', ['simctl', 'openurl', 'booted', openUrl], { env, logger });
|
||||
}
|
||||
@@ -130,6 +134,46 @@ export async function launchApplicationAsync({
|
||||
}
|
||||
}
|
||||
|
||||
async function preapproveIosUrlSchemeAsync({
|
||||
applicationIdentifier,
|
||||
openUrl,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
applicationIdentifier: string;
|
||||
openUrl: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const urlScheme = new URL(openUrl).protocol.slice(0, -1);
|
||||
if (urlScheme === 'http' || urlScheme === 'https') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'booted',
|
||||
'defaults',
|
||||
'write',
|
||||
IOS_URL_SCHEME_APPROVAL_DOMAIN,
|
||||
`${IOS_URL_SCHEME_APPROVAL_KEY_PREFIX}${urlScheme}`,
|
||||
'-string',
|
||||
applicationIdentifier,
|
||||
],
|
||||
{ env, logger }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Could not preapprove the ${urlScheme} URL scheme for ${applicationIdentifier}. Opening the URL anyway; the Simulator might require manual confirmation.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function logApplicationLaunch(
|
||||
logger: bunyan,
|
||||
applicationIdentifier: string,
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
getCcacheKeyPrefix,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import {
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../../utils/cocoapodsCache';
|
||||
import { Datadog } from '../../datadog';
|
||||
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
|
||||
@@ -79,6 +84,13 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
} else {
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -206,6 +218,67 @@ export async function restoreCcacheAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EAS_PODS_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await spawnAsync('pod', ['--version'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const { key, keyPrefix } = await resolveCocoapodsCacheKeyAsync(workingDirectory, stdout);
|
||||
logger.info(`Restoring CocoaPods cache key: ${key}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const { podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [podsDirectory],
|
||||
key,
|
||||
keyPrefixes: [keyPrefix],
|
||||
platform: Platform.IOS,
|
||||
});
|
||||
|
||||
await restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory });
|
||||
|
||||
const hitType = matchedKey === key ? 'direct_hit' : 'prefix_match';
|
||||
logger.info(
|
||||
`CocoaPods cache restored to ${podsDirectory} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
|
||||
);
|
||||
Datadog.log(`CocoaPods cache restored (${hitType})`, {
|
||||
event: 'cocoapods_cache_restored',
|
||||
cache_hit_type: hitType,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof TurtleFetchError && err.response?.status === 404) {
|
||||
logger.info('No CocoaPods cache found for this key');
|
||||
} else {
|
||||
logger.warn('Failed to restore CocoaPods cache: ', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
generateDefaultBuildCacheKeyAsync,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
} from '../../utils/cocoapodsCache';
|
||||
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
|
||||
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
|
||||
@@ -78,11 +83,77 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
} else {
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EAS_PODS_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { podsDirectory, podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
|
||||
try {
|
||||
await Promise.all([fs.promises.access(podsDirectory), fs.promises.access(podfileLockPath)]);
|
||||
} catch {
|
||||
logger.warn('No CocoaPods installation found, skipping cache save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await spawnAsync('pod', ['--version'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const { key } = await resolveCocoapodsCacheKeyAsync(workingDirectory, stdout);
|
||||
logger.info(`Saving CocoaPods cache key: ${key}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
|
||||
logger.info('Compressing CocoaPods cache...');
|
||||
const { archivePath } = await compressCocoapodsCacheAsync({ workingDirectory });
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
logger.info(`CocoaPods cache archive size: ${formatBytes(size)}`);
|
||||
|
||||
await uploadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key,
|
||||
paths: [podsDirectory],
|
||||
size,
|
||||
platform: Platform.IOS,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save CocoaPods cache');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
|
||||
@@ -103,6 +103,7 @@ export async function uploadCacheAsync({
|
||||
archivePath,
|
||||
size,
|
||||
platform,
|
||||
force = false,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
jobId: string;
|
||||
@@ -113,6 +114,7 @@ export async function uploadCacheAsync({
|
||||
archivePath: string;
|
||||
size: number;
|
||||
platform: Platform | undefined;
|
||||
force?: boolean;
|
||||
}): Promise<void> {
|
||||
const routerURL = platform
|
||||
? 'v2/turtle-builds/caches/upload-sessions'
|
||||
@@ -127,12 +129,14 @@ export async function uploadCacheAsync({
|
||||
key,
|
||||
version: getCacheVersion(paths),
|
||||
size,
|
||||
force,
|
||||
})
|
||||
: JSON.stringify({
|
||||
jobRunId: jobId,
|
||||
key,
|
||||
version: getCacheVersion(paths),
|
||||
size,
|
||||
force,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { APPIUM_COMMAND_SUMMARIES, humanizeAppiumCommand } from '../appiumCommandSummary';
|
||||
|
||||
// Coverage of the generated Appium and driver command sets is enforced at compile time by the
|
||||
// `Record<AppiumCommand, string>` type in appiumCommandSummary.ts, so it is not
|
||||
// re-checked here.
|
||||
|
||||
describe('APPIUM_COMMAND_SUMMARIES', () => {
|
||||
it('has a non-empty summary that differs from the command name for every entry', () => {
|
||||
for (const [command, summary] of Object.entries(APPIUM_COMMAND_SUMMARIES)) {
|
||||
expect(summary).toBeTruthy();
|
||||
expect(summary).not.toBe(command);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(humanizeAppiumCommand, () => {
|
||||
it.each([
|
||||
['getWindowRect', 'Read the screen size'],
|
||||
['getScreenshot', 'Took a screenshot'],
|
||||
['getPageSource', 'Read the screen contents'],
|
||||
['click', 'Tapped an element'],
|
||||
['setValue', 'Typed into an element'],
|
||||
['createSession', 'Started the session'],
|
||||
['deleteSession', 'Ended the session'],
|
||||
['postAcceptAlert', 'Accepted an alert'],
|
||||
['mobileScroll', 'Scrolled the screen'],
|
||||
['openNotifications', 'Opened notifications'],
|
||||
])('maps the known command %s to a curated summary', (command, expected) => {
|
||||
expect(humanizeAppiumCommand(command)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(['proxyReqRes', 'mobile: scroll', 'getDeviceInfo', 'toString', 'constructor', ''])(
|
||||
'returns the raw command %p when it has no curated summary',
|
||||
command => {
|
||||
expect(humanizeAppiumCommand(command)).toBe(command);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -58,7 +58,10 @@ describe(startAppiumEventCollectionAsync, () => {
|
||||
producer: 'appium',
|
||||
type: 'operation.completed',
|
||||
durationMs: 25,
|
||||
summary: 'getOrientation',
|
||||
summary: 'Read the screen orientation',
|
||||
data: {
|
||||
command: 'getOrientation',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -67,10 +67,17 @@ describe(listArgentArtifactsAsync, () => {
|
||||
artifacts: [
|
||||
{
|
||||
id: 'artifact-id',
|
||||
kind: 'native-profile-report',
|
||||
filename: 'report.json',
|
||||
mimeType: 'application/json',
|
||||
isDirectory: false,
|
||||
},
|
||||
{
|
||||
id: 'legacy-artifact-id',
|
||||
filename: 'screen.png',
|
||||
mimeType: 'image/png',
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
@@ -84,10 +91,17 @@ describe(listArgentArtifactsAsync, () => {
|
||||
expect(artifacts).toEqual([
|
||||
{
|
||||
id: 'artifact-id',
|
||||
kind: 'native-profile-report',
|
||||
filename: 'report.json',
|
||||
mimeType: 'application/json',
|
||||
isDirectory: false,
|
||||
},
|
||||
{
|
||||
id: 'legacy-artifact-id',
|
||||
filename: 'screen.png',
|
||||
mimeType: 'image/png',
|
||||
isDirectory: false,
|
||||
},
|
||||
]);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts', {
|
||||
headers: { Authorization: 'Bearer tools-token' },
|
||||
@@ -143,6 +157,7 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
|
||||
async function uploadAsync(artifact: {
|
||||
id: string;
|
||||
kind?: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
isDirectory?: boolean;
|
||||
@@ -185,7 +200,42 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves directory artifacts unclassified because they are uploaded as a tarball', async () => {
|
||||
it('uses the semantic kind reported by Argent', async () => {
|
||||
const ctx = await uploadAsync({
|
||||
id: 'artifact-id',
|
||||
kind: 'native-profile-report',
|
||||
filename: 'native-profile-report.md',
|
||||
mimeType: 'text/markdown',
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'native-profile-report.md',
|
||||
kind: 'native-profile-report',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the semantic kind when a directory is uploaded as a tarball', async () => {
|
||||
const ctx = await uploadAsync({
|
||||
id: 'artifact-id',
|
||||
kind: 'native-profile-trace',
|
||||
filename: 'native-profile.trace',
|
||||
mimeType: 'application/octet-stream',
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
filename: 'native-profile.trace.tar.gz',
|
||||
kind: 'native-profile-trace',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an older unclassified directory artifact without a kind', async () => {
|
||||
const ctx = await uploadAsync({
|
||||
id: 'artifact-id',
|
||||
filename: 'screenshots',
|
||||
|
||||
@@ -132,11 +132,11 @@ describe(createServeSimArgs, () => {
|
||||
'--webrtc-codec',
|
||||
'vp8',
|
||||
'--max-dimension',
|
||||
'1280',
|
||||
'960',
|
||||
'--mjpeg-quality',
|
||||
'0.55',
|
||||
'--video-bitrate',
|
||||
'3000000',
|
||||
'6000000',
|
||||
'--video-fps',
|
||||
'60',
|
||||
'--turn-url',
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import { type AppiumCommand } from './appiumCommands.generated';
|
||||
|
||||
export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
|
||||
// Session lifecycle
|
||||
createSession: 'Started the session',
|
||||
deleteSession: 'Ended the session',
|
||||
getSession: 'Read the session details',
|
||||
getStatus: 'Checked the server status',
|
||||
getAppiumSessions: 'Listed Appium sessions',
|
||||
getAppiumSessionCapabilities: 'Read the session capabilities',
|
||||
getTimeouts: 'Read timeouts',
|
||||
timeouts: 'Set timeouts',
|
||||
getSettings: 'Read Appium settings',
|
||||
updateSettings: 'Updated Appium settings',
|
||||
listCommands: 'Listed available commands',
|
||||
listExtensions: 'Listed available extensions',
|
||||
receiveAsyncResponse: 'Received an async response',
|
||||
|
||||
// Screen and media
|
||||
getScreenshot: 'Took a screenshot',
|
||||
getElementScreenshot: 'Took a screenshot of an element',
|
||||
getPageSource: 'Read the screen contents',
|
||||
printPage: 'Printed the page',
|
||||
|
||||
// Window and orientation
|
||||
getWindowRect: 'Read the screen size',
|
||||
setWindowRect: 'Resized the window',
|
||||
maximizeWindow: 'Maximized the window',
|
||||
minimizeWindow: 'Minimized the window',
|
||||
fullScreenWindow: 'Made the window full screen',
|
||||
getWindowHandle: 'Read the current window',
|
||||
getWindowHandles: 'Listed open windows',
|
||||
setWindow: 'Switched window',
|
||||
closeWindow: 'Closed the window',
|
||||
createNewWindow: 'Opened a new window',
|
||||
setFrame: 'Switched to a frame',
|
||||
switchToParentFrame: 'Switched to the parent frame',
|
||||
getOrientation: 'Read the screen orientation',
|
||||
setOrientation: 'Changed the screen orientation',
|
||||
getRotation: 'Read the device rotation',
|
||||
setRotation: 'Rotated the device',
|
||||
|
||||
// Device
|
||||
getDeviceTime: 'Read the device time',
|
||||
getGeoLocation: 'Read the device location',
|
||||
setGeoLocation: 'Set the device location',
|
||||
getNetworkConnection: 'Read the network connection',
|
||||
setNetworkConnection: 'Changed the network connection',
|
||||
setDevicePosture: 'Set the device posture',
|
||||
clearDevicePosture: 'Cleared the device posture',
|
||||
setPermissions: 'Set app permissions',
|
||||
|
||||
// Element discovery
|
||||
findElement: 'Found an element',
|
||||
findElements: 'Found elements',
|
||||
findElementFromElement: 'Found a nested element',
|
||||
findElementsFromElement: 'Found nested elements',
|
||||
findElementFromShadowRoot: 'Found an element in a shadow root',
|
||||
findElementsFromShadowRoot: 'Found elements in a shadow root',
|
||||
elementShadowRoot: 'Read an element shadow root',
|
||||
active: 'Read the focused element',
|
||||
|
||||
// Element interaction
|
||||
click: 'Tapped an element',
|
||||
clear: 'Cleared an element',
|
||||
setValue: 'Typed into an element',
|
||||
getText: 'Read element text',
|
||||
getName: 'Read an element tag',
|
||||
getAttribute: 'Read an element attribute',
|
||||
getProperty: 'Read an element property',
|
||||
getCssProperty: 'Read an element style',
|
||||
getComputedLabel: 'Read an element label',
|
||||
getComputedRole: 'Read an element role',
|
||||
getElementRect: 'Read an element position and size',
|
||||
elementDisplayed: 'Checked if an element was visible',
|
||||
elementEnabled: 'Checked if an element was enabled',
|
||||
elementSelected: 'Checked if an element was selected',
|
||||
|
||||
// Gestures
|
||||
performActions: 'Performed a gesture',
|
||||
releaseActions: 'Finished a gesture',
|
||||
|
||||
// App management
|
||||
installApp: 'Installed an app',
|
||||
removeApp: 'Removed an app',
|
||||
isAppInstalled: 'Checked if an app was installed',
|
||||
activateApp: 'Activated an app',
|
||||
terminateApp: 'Terminated an app',
|
||||
queryAppState: 'Read the app state',
|
||||
background: 'Sent the app to the background',
|
||||
|
||||
// Contexts
|
||||
getContexts: 'Listed available contexts',
|
||||
getCurrentContext: 'Read the current context',
|
||||
setContext: 'Switched context',
|
||||
|
||||
// Web navigation
|
||||
setUrl: 'Opened a URL',
|
||||
getUrl: 'Read the current URL',
|
||||
back: 'Navigated back',
|
||||
forward: 'Navigated forward',
|
||||
refresh: 'Refreshed the page',
|
||||
title: 'Read the page title',
|
||||
|
||||
// Cookies
|
||||
getCookie: 'Read a cookie',
|
||||
getCookies: 'Read all cookies',
|
||||
setCookie: 'Set a cookie',
|
||||
deleteCookie: 'Deleted a cookie',
|
||||
deleteCookies: 'Deleted all cookies',
|
||||
|
||||
// Alerts
|
||||
getAlertText: 'Read an alert',
|
||||
setAlertText: 'Typed into an alert',
|
||||
postAcceptAlert: 'Accepted an alert',
|
||||
postDismissAlert: 'Dismissed an alert',
|
||||
|
||||
// Keyboard and input methods
|
||||
hideKeyboard: 'Hid the keyboard',
|
||||
isKeyboardShown: 'Checked if the keyboard was shown',
|
||||
activateIMEEngine: 'Activated an input method',
|
||||
deactivateIMEEngine: 'Deactivated the input method',
|
||||
getActiveIMEEngine: 'Read the active input method',
|
||||
availableIMEEngines: 'Listed input methods',
|
||||
isIMEActivated: 'Checked if an input method was active',
|
||||
|
||||
// Clipboard (driver-level, not declared by base-driver)
|
||||
getClipboard: 'Read the clipboard',
|
||||
setClipboard: 'Wrote to the clipboard',
|
||||
|
||||
// Files
|
||||
pushFile: 'Pushed a file to the device',
|
||||
pullFile: 'Pulled a file from the device',
|
||||
pullFolder: 'Pulled a folder from the device',
|
||||
|
||||
// Logs
|
||||
getLog: 'Read device logs',
|
||||
getLogTypes: 'Listed log types',
|
||||
getLogEvents: 'Read logged events',
|
||||
logCustomEvent: 'Logged a custom event',
|
||||
generateTestReport: 'Generated a test report',
|
||||
|
||||
// Scripts
|
||||
execute: 'Ran a script command',
|
||||
executeAsync: 'Ran an async script command',
|
||||
executeCdp: 'Ran a Chrome DevTools command',
|
||||
|
||||
// Web authentication (virtual authenticators)
|
||||
addVirtualAuthenticator: 'Added a virtual authenticator',
|
||||
removeVirtualAuthenticator: 'Removed a virtual authenticator',
|
||||
addAuthCredential: 'Added an auth credential',
|
||||
getAuthCredential: 'Read auth credentials',
|
||||
removeAuthCredential: 'Removed an auth credential',
|
||||
removeAllAuthCredentials: 'Removed all auth credentials',
|
||||
setUserAuthVerified: 'Set the user verification state',
|
||||
|
||||
// Federated sign-in (FedCM)
|
||||
fedCMGetAccounts: 'Listed federated sign-in accounts',
|
||||
fedCMSelectAccount: 'Selected a federated sign-in account',
|
||||
fedCMGetDialogType: 'Read the federated sign-in dialog type',
|
||||
fedCMGetTitle: 'Read the federated sign-in dialog title',
|
||||
fedCMClickDialogButton: 'Clicked a federated sign-in dialog button',
|
||||
fedCMCancelDialog: 'Canceled the federated sign-in dialog',
|
||||
fedCMResetCooldown: 'Reset the federated sign-in cooldown',
|
||||
fedCMSetDelayEnabled: 'Toggled the federated sign-in delay',
|
||||
|
||||
// Virtual sensors and pressure sources
|
||||
createVirtualSensor: 'Created a virtual sensor',
|
||||
updateVirtualSensorReading: 'Updated a virtual sensor reading',
|
||||
getVirtualSensorInfo: 'Read virtual sensor info',
|
||||
deleteVirtualSensor: 'Removed a virtual sensor',
|
||||
createVirtualPressureSource: 'Created a virtual pressure source',
|
||||
updateVirtualPressureSource: 'Updated a virtual pressure source',
|
||||
deleteVirtualPressureSource: 'Removed a virtual pressure source',
|
||||
|
||||
// Privacy / experimental web platform
|
||||
getGlobalPrivacyControl: 'Read the Global Privacy Control setting',
|
||||
setGlobalPrivacyControl: 'Set the Global Privacy Control setting',
|
||||
setStorageAccess: 'Set storage access',
|
||||
setRPHRegistrationMode: 'Set the protocol handler registration mode',
|
||||
setSPCTransactionMode: 'Set the payment transaction mode',
|
||||
|
||||
// Driver routes and compatibility commands
|
||||
asyncScriptTimeout: 'Set the async script timeout',
|
||||
closeApp: 'Closed the app',
|
||||
disableConditionInducer: 'Disabled the condition inducer',
|
||||
enableConditionInducer: 'Enabled the condition inducer',
|
||||
fingerprint: 'Simulated a fingerprint',
|
||||
getCurrentActivity: 'Read the current activity',
|
||||
getCurrentPackage: 'Read the current package',
|
||||
getDisplayDensity: 'Read the display density',
|
||||
getLocation: 'Read an element location',
|
||||
getLocationInView: 'Read an element location in view',
|
||||
getPerformanceData: 'Read performance data',
|
||||
getPerformanceDataTypes: 'Listed performance data types',
|
||||
getScreenInfo: 'Read screen information',
|
||||
getSize: 'Read an element size',
|
||||
getStrings: 'Read app strings',
|
||||
getSystemBars: 'Read system bar information',
|
||||
getViewportRect: 'Read the viewport size',
|
||||
getViewportScreenshot: 'Took a viewport screenshot',
|
||||
getWindowSize: 'Read the window size',
|
||||
gsmCall: 'Simulated a phone call',
|
||||
gsmSignal: 'Changed the cellular signal',
|
||||
gsmVoice: 'Changed the cellular voice state',
|
||||
implicitWait: 'Set the element lookup timeout',
|
||||
isLocationServicesEnabled: 'Checked if location services were enabled',
|
||||
isLocked: 'Checked if the device was locked',
|
||||
keyevent: 'Sent a key event',
|
||||
keys: 'Sent keyboard input',
|
||||
launchApp: 'Launched the app',
|
||||
listConditionInducers: 'Listed condition inducers',
|
||||
lock: 'Locked the device',
|
||||
longPressKeyCode: 'Long-pressed a key',
|
||||
networkSpeed: 'Changed the network speed',
|
||||
openNotifications: 'Opened notifications',
|
||||
powerAC: 'Changed the charging state',
|
||||
powerCapacity: 'Changed the battery level',
|
||||
pressKeyCode: 'Pressed a key',
|
||||
replaceValue: 'Replaced an element value',
|
||||
reset: 'Reset the app',
|
||||
sendSMS: 'Simulated a text message',
|
||||
sensorSet: 'Changed a sensor value',
|
||||
setStylusHandwriting: 'Changed stylus handwriting mode',
|
||||
setValueImmediate: 'Typed into an element immediately',
|
||||
startActivity: 'Started an activity',
|
||||
startAudioRecording: 'Started audio recording',
|
||||
startRecordingScreen: 'Started screen recording',
|
||||
stopAudioRecording: 'Stopped audio recording',
|
||||
stopRecordingScreen: 'Stopped screen recording',
|
||||
submit: 'Submitted an element',
|
||||
toggleData: 'Toggled mobile data',
|
||||
toggleEnrollTouchId: 'Changed Touch ID enrollment',
|
||||
toggleFlightMode: 'Toggled airplane mode',
|
||||
toggleLocationServices: 'Toggled location services',
|
||||
toggleWiFi: 'Toggled Wi-Fi',
|
||||
touchId: 'Simulated Touch ID',
|
||||
unlock: 'Unlocked the device',
|
||||
|
||||
// Driver mobile commands
|
||||
mobileAcceptAlert: 'Accepted an alert',
|
||||
mobileActivateApp: 'Activated an app',
|
||||
mobileBackgroundApp: 'Sent the app to the background',
|
||||
mobileBluetooth: 'Changed Bluetooth state',
|
||||
mobileBroadcast: 'Sent an Android broadcast',
|
||||
mobileCalibrateWebToRealCoordinatesTranslation: 'Calibrated web coordinates',
|
||||
mobileChangePermissions: 'Changed app permissions',
|
||||
mobileClearApp: 'Cleared app data',
|
||||
mobileClearKeychains: 'Cleared keychains',
|
||||
mobileClickGesture: 'Performed a click gesture',
|
||||
mobileConfigureLocalization: 'Changed device localization',
|
||||
mobileDeepLink: 'Opened a deep link',
|
||||
mobileDeleteFile: 'Deleted a device file',
|
||||
mobileDeleteFolder: 'Deleted a device folder',
|
||||
mobileDeviceidle: 'Changed device idle state',
|
||||
mobileDisableVoiceOver: 'Disabled VoiceOver',
|
||||
mobileDismissAlert: 'Dismissed an alert',
|
||||
mobileDoubleClickGesture: 'Performed a double-click gesture',
|
||||
mobileDoubleTap: 'Double-tapped the screen',
|
||||
mobileDragFromToForDuration: 'Dragged between screen points',
|
||||
mobileDragFromToWithVelocity: 'Dragged with a set velocity',
|
||||
mobileDragGesture: 'Performed a drag gesture',
|
||||
mobileEnableVoiceOver: 'Enabled VoiceOver',
|
||||
mobileEnrollBiometric: 'Changed biometric enrollment',
|
||||
mobileExecEmuConsoleCommand: 'Ran an emulator console command',
|
||||
mobileExpectNotification: 'Waited for a notification',
|
||||
mobileFingerprint: 'Simulated a fingerprint',
|
||||
mobileFlingGesture: 'Performed a fling gesture',
|
||||
mobileForcePress: 'Force-pressed the screen',
|
||||
mobileGetActionHistory: 'Read scheduled action history',
|
||||
mobileGetActiveAppInfo: 'Read active app information',
|
||||
mobileGetAppearance: 'Read the device appearance',
|
||||
mobileGetBatteryInfo: 'Read battery information',
|
||||
mobileGetChromeCapabilities: 'Read Chrome capabilities',
|
||||
mobileGetConnectivity: 'Read device connectivity',
|
||||
mobileGetContentSize: 'Read scrollable content size',
|
||||
mobileGetContexts: 'Listed available contexts',
|
||||
mobileGetDeviceInfo: 'Read device information',
|
||||
mobileGetDeviceTime: 'Read the device time',
|
||||
mobileGetDeclaredOrientation: 'Read the declared device orientation',
|
||||
mobileGetGeolocation: 'Read the device location',
|
||||
mobileGetIncreaseContrast: 'Read the Increase Contrast setting',
|
||||
mobileGetNotifications: 'Read notifications',
|
||||
mobileGetPasteboard: 'Read the pasteboard',
|
||||
mobileGetPerformanceData: 'Read app performance data',
|
||||
mobileGetPermission: 'Read an app permission',
|
||||
mobileGetPermissions: 'Read app permissions',
|
||||
mobileGetSimulatedLocation: 'Read the simulated location',
|
||||
mobileGetSource: 'Read the screen contents',
|
||||
mobileGetUiMode: 'Read the Android UI mode',
|
||||
mobileGetXctestScreenRecordingInfo: 'Read XCTest screen recording information',
|
||||
mobileGsmCall: 'Simulated a phone call',
|
||||
mobileGsmSignal: 'Changed the cellular signal',
|
||||
mobileGsmVoice: 'Changed the cellular voice state',
|
||||
mobileHandleAlert: 'Handled an alert',
|
||||
mobileHideKeyboard: 'Hid the keyboard',
|
||||
mobileInjectEmulatorCameraImage: 'Injected an emulator camera image',
|
||||
mobileInstallApp: 'Installed an app',
|
||||
mobileInstallCertificate: 'Installed a certificate',
|
||||
mobileInstallMultipleApks: 'Installed multiple APKs',
|
||||
mobileInstallXCTestBundle: 'Installed an XCTest bundle',
|
||||
mobileIsAppInstalled: 'Checked if an app was installed',
|
||||
mobileIsBiometricEnrolled: 'Checked biometric enrollment',
|
||||
mobileIsMediaProjectionRecordingRunning: 'Checked media projection recording',
|
||||
mobileIsVoiceOverEnabled: 'Checked if VoiceOver was enabled',
|
||||
mobileKeys: 'Sent keyboard input',
|
||||
mobileKillApp: 'Stopped an app',
|
||||
mobileLaunchApp: 'Launched an app',
|
||||
mobileListApps: 'Listed installed apps',
|
||||
mobileListCertificates: 'Listed certificates',
|
||||
mobileListDisplays: 'Listed displays',
|
||||
mobileListSms: 'Listed text messages',
|
||||
mobileListWindows: 'Listed app windows',
|
||||
mobileListXCTestBundles: 'Listed XCTest bundles',
|
||||
mobileLongClickGesture: 'Performed a long-click gesture',
|
||||
mobileNetworkSpeed: 'Changed the network speed',
|
||||
mobileNfc: 'Changed NFC state',
|
||||
mobilePerformAccessibilityAudit: 'Ran an accessibility audit',
|
||||
mobilePerformEditorAction: 'Performed a keyboard editor action',
|
||||
mobilePerformHandGesture: 'Performed a hand gesture',
|
||||
mobilePerformIndigoHidEvent: 'Performed an Indigo HID event',
|
||||
mobilePerformIoHidEvent: 'Performed an IOHID event',
|
||||
mobilePerformStatusBarCommand: 'Performed a status bar command',
|
||||
mobilePinch: 'Pinched the screen',
|
||||
mobilePinchCloseGesture: 'Performed a pinch-close gesture',
|
||||
mobilePinchOpenGesture: 'Performed a pinch-open gesture',
|
||||
mobilePowerAc: 'Changed the charging state',
|
||||
mobilePowerCapacity: 'Changed the battery level',
|
||||
mobilePressButton: 'Pressed a device button',
|
||||
mobilePressKey: 'Pressed a key',
|
||||
mobilePullFile: 'Pulled a file from the device',
|
||||
mobilePullFolder: 'Pulled a folder from the device',
|
||||
mobilePushFile: 'Pushed a file to the device',
|
||||
mobilePushNotification: 'Sent a push notification',
|
||||
mobileQueryAppState: 'Read the app state',
|
||||
mobileRefreshGpsCache: 'Refreshed the GPS cache',
|
||||
mobileRemoveApp: 'Removed an app',
|
||||
mobileRemoveCertificate: 'Removed a certificate',
|
||||
mobileReplaceElementValue: 'Replaced an element value',
|
||||
mobileResetAccessibilityCache: 'Reset the accessibility cache',
|
||||
mobileResetGeolocation: 'Reset the device location',
|
||||
mobileResetLocationService: 'Reset location services',
|
||||
mobileResetPermission: 'Reset an app permission',
|
||||
mobileResetSimulatedLocation: 'Reset the simulated location',
|
||||
mobileRotateDigitalCrown: 'Rotated the Digital Crown',
|
||||
mobileRotateElement: 'Rotated an element',
|
||||
mobileRunXCTest: 'Ran an XCTest bundle',
|
||||
mobileScheduleAction: 'Scheduled a device action',
|
||||
mobileScreenshots: 'Took display screenshots',
|
||||
mobileScroll: 'Scrolled the screen',
|
||||
mobileScrollBackTo: 'Scrolled back to an element',
|
||||
mobileScrollGesture: 'Performed a scroll gesture',
|
||||
mobileScrollToElement: 'Scrolled to an element',
|
||||
mobileSelectPickerWheelValue: 'Changed a picker wheel value',
|
||||
mobileSendBiometricMatch: 'Simulated biometric authentication',
|
||||
mobileSendMemoryWarning: 'Sent a memory warning',
|
||||
mobileSendSms: 'Simulated a text message',
|
||||
mobileSendTrimMemory: 'Sent an Android memory trim event',
|
||||
mobileSetAppearance: 'Changed the device appearance',
|
||||
mobileSetConnectivity: 'Changed device connectivity',
|
||||
mobileSetContentSize: 'Changed the content size',
|
||||
mobileSetGeolocation: 'Set the device location',
|
||||
mobileSetIncreaseContrast: 'Changed the Increase Contrast setting',
|
||||
mobileSetPasteboard: 'Wrote to the pasteboard',
|
||||
mobileSetPermissions: 'Set app permissions',
|
||||
mobileSetSimulatedLocation: 'Set a simulated location',
|
||||
mobileSetUiMode: 'Changed the Android UI mode',
|
||||
mobileShake: 'Shook the device',
|
||||
mobileShell: 'Ran an Android shell command',
|
||||
mobileSimctl: 'Ran a simctl command',
|
||||
mobileSiriCommand: 'Sent a Siri command',
|
||||
mobileStartActivity: 'Started an activity',
|
||||
mobileStartLogsBroadcast: 'Started log streaming',
|
||||
mobileStartMediaProjectionRecording: 'Started media projection recording',
|
||||
mobileStartNetworkMonitor: 'Started network monitoring',
|
||||
mobileStartPerfRecord: 'Started performance recording',
|
||||
mobileStartScreenRecording: 'Started screen recording',
|
||||
mobileStartScreenStreaming: 'Started screen streaming',
|
||||
mobileStartService: 'Started a service',
|
||||
mobileStartSystemMonitor: 'Started system monitoring',
|
||||
mobileStartXctestScreenRecording: 'Started XCTest screen recording',
|
||||
mobileStopLogsBroadcast: 'Stopped log streaming',
|
||||
mobileStopMediaProjectionRecording: 'Stopped media projection recording',
|
||||
mobileStopNetworkMonitor: 'Stopped network monitoring',
|
||||
mobileStopPerfRecord: 'Stopped performance recording',
|
||||
mobileStopScreenRecording: 'Stopped screen recording',
|
||||
mobileStopScreenStreaming: 'Stopped screen streaming',
|
||||
mobileStopService: 'Stopped a service',
|
||||
mobileStopSystemMonitor: 'Stopped system monitoring',
|
||||
mobileStopXctestScreenRecording: 'Stopped XCTest screen recording',
|
||||
mobileSwipe: 'Swiped the screen',
|
||||
mobileSwipeGesture: 'Performed a swipe gesture',
|
||||
mobileTap: 'Tapped the screen',
|
||||
mobileTapWithNumberOfTaps: 'Tapped the screen multiple times',
|
||||
mobileTerminateApp: 'Terminated an app',
|
||||
mobileTouchAndHold: 'Touched and held the screen',
|
||||
mobileTwoFingerTap: 'Tapped with two fingers',
|
||||
mobileType: 'Typed text',
|
||||
mobileUnlock: 'Unlocked the device',
|
||||
mobileUnscheduleAction: 'Canceled a scheduled action',
|
||||
mobileUpdateSafariPreferences: 'Updated Safari preferences',
|
||||
mobileViewPortRect: 'Read the viewport size',
|
||||
mobileViewportScreenshot: 'Took a viewport screenshot',
|
||||
mobileVoiceOverCurrentSpeech: 'Read current VoiceOver speech',
|
||||
mobileVoiceOverMove: 'Moved the VoiceOver cursor',
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate a raw Appium command name into a short, human-readable summary.
|
||||
*
|
||||
* If a command has no curated summary, the raw command name is returned
|
||||
* unchanged — we intentionally do not guess a phrasing.
|
||||
*/
|
||||
export function humanizeAppiumCommand(command: string): string {
|
||||
return Object.hasOwn(APPIUM_COMMAND_SUMMARIES, command)
|
||||
? (APPIUM_COMMAND_SUMMARIES as Record<string, string>)[command]
|
||||
: command;
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// AUTO-GENERATED. Do not edit by hand.
|
||||
// Run `yarn workspace @expo/build-tools generate-appium-commands` to refresh this
|
||||
// list from Appium, XCUITest, and UiAutomator2 (installed on demand by the generator).
|
||||
export type BaseDriverAppiumCommand =
|
||||
| 'activateApp'
|
||||
| 'activateIMEEngine'
|
||||
| 'active'
|
||||
| 'addAuthCredential'
|
||||
| 'addVirtualAuthenticator'
|
||||
| 'availableIMEEngines'
|
||||
| 'back'
|
||||
| 'clear'
|
||||
| 'clearDevicePosture'
|
||||
| 'click'
|
||||
| 'closeWindow'
|
||||
| 'createNewWindow'
|
||||
| 'createSession'
|
||||
| 'createVirtualPressureSource'
|
||||
| 'createVirtualSensor'
|
||||
| 'deactivateIMEEngine'
|
||||
| 'deleteCookie'
|
||||
| 'deleteCookies'
|
||||
| 'deleteSession'
|
||||
| 'deleteVirtualPressureSource'
|
||||
| 'deleteVirtualSensor'
|
||||
| 'elementDisplayed'
|
||||
| 'elementEnabled'
|
||||
| 'elementSelected'
|
||||
| 'elementShadowRoot'
|
||||
| 'execute'
|
||||
| 'executeAsync'
|
||||
| 'executeCdp'
|
||||
| 'fedCMCancelDialog'
|
||||
| 'fedCMClickDialogButton'
|
||||
| 'fedCMGetAccounts'
|
||||
| 'fedCMGetDialogType'
|
||||
| 'fedCMGetTitle'
|
||||
| 'fedCMResetCooldown'
|
||||
| 'fedCMSelectAccount'
|
||||
| 'fedCMSetDelayEnabled'
|
||||
| 'findElement'
|
||||
| 'findElementFromElement'
|
||||
| 'findElementFromShadowRoot'
|
||||
| 'findElements'
|
||||
| 'findElementsFromElement'
|
||||
| 'findElementsFromShadowRoot'
|
||||
| 'forward'
|
||||
| 'fullScreenWindow'
|
||||
| 'generateTestReport'
|
||||
| 'getActiveIMEEngine'
|
||||
| 'getAlertText'
|
||||
| 'getAppiumSessionCapabilities'
|
||||
| 'getAppiumSessions'
|
||||
| 'getAttribute'
|
||||
| 'getAuthCredential'
|
||||
| 'getComputedLabel'
|
||||
| 'getComputedRole'
|
||||
| 'getContexts'
|
||||
| 'getCookie'
|
||||
| 'getCookies'
|
||||
| 'getCssProperty'
|
||||
| 'getCurrentContext'
|
||||
| 'getDeviceTime'
|
||||
| 'getElementRect'
|
||||
| 'getElementScreenshot'
|
||||
| 'getGeoLocation'
|
||||
| 'getGlobalPrivacyControl'
|
||||
| 'getLog'
|
||||
| 'getLogEvents'
|
||||
| 'getLogTypes'
|
||||
| 'getName'
|
||||
| 'getNetworkConnection'
|
||||
| 'getOrientation'
|
||||
| 'getPageSource'
|
||||
| 'getProperty'
|
||||
| 'getRotation'
|
||||
| 'getScreenshot'
|
||||
| 'getSession'
|
||||
| 'getSettings'
|
||||
| 'getStatus'
|
||||
| 'getText'
|
||||
| 'getTimeouts'
|
||||
| 'getUrl'
|
||||
| 'getVirtualSensorInfo'
|
||||
| 'getWindowHandle'
|
||||
| 'getWindowHandles'
|
||||
| 'getWindowRect'
|
||||
| 'hideKeyboard'
|
||||
| 'installApp'
|
||||
| 'isAppInstalled'
|
||||
| 'isIMEActivated'
|
||||
| 'isKeyboardShown'
|
||||
| 'listCommands'
|
||||
| 'listExtensions'
|
||||
| 'logCustomEvent'
|
||||
| 'maximizeWindow'
|
||||
| 'minimizeWindow'
|
||||
| 'performActions'
|
||||
| 'postAcceptAlert'
|
||||
| 'postDismissAlert'
|
||||
| 'printPage'
|
||||
| 'pullFile'
|
||||
| 'pullFolder'
|
||||
| 'pushFile'
|
||||
| 'queryAppState'
|
||||
| 'receiveAsyncResponse'
|
||||
| 'refresh'
|
||||
| 'releaseActions'
|
||||
| 'removeAllAuthCredentials'
|
||||
| 'removeApp'
|
||||
| 'removeAuthCredential'
|
||||
| 'removeVirtualAuthenticator'
|
||||
| 'setAlertText'
|
||||
| 'setContext'
|
||||
| 'setCookie'
|
||||
| 'setDevicePosture'
|
||||
| 'setFrame'
|
||||
| 'setGeoLocation'
|
||||
| 'setGlobalPrivacyControl'
|
||||
| 'setNetworkConnection'
|
||||
| 'setOrientation'
|
||||
| 'setPermissions'
|
||||
| 'setRPHRegistrationMode'
|
||||
| 'setRotation'
|
||||
| 'setSPCTransactionMode'
|
||||
| 'setStorageAccess'
|
||||
| 'setUrl'
|
||||
| 'setUserAuthVerified'
|
||||
| 'setValue'
|
||||
| 'setWindow'
|
||||
| 'setWindowRect'
|
||||
| 'switchToParentFrame'
|
||||
| 'terminateApp'
|
||||
| 'timeouts'
|
||||
| 'title'
|
||||
| 'updateSettings'
|
||||
| 'updateVirtualPressureSource'
|
||||
| 'updateVirtualSensorReading';
|
||||
|
||||
export type XCUITestAppiumCommand =
|
||||
| 'asyncScriptTimeout'
|
||||
| 'background'
|
||||
| 'closeApp'
|
||||
| 'disableConditionInducer'
|
||||
| 'enableConditionInducer'
|
||||
| 'getClipboard'
|
||||
| 'getLocation'
|
||||
| 'getLocationInView'
|
||||
| 'getScreenInfo'
|
||||
| 'getSize'
|
||||
| 'getStrings'
|
||||
| 'getViewportRect'
|
||||
| 'getViewportScreenshot'
|
||||
| 'getWindowSize'
|
||||
| 'implicitWait'
|
||||
| 'isLocked'
|
||||
| 'keys'
|
||||
| 'launchApp'
|
||||
| 'listConditionInducers'
|
||||
| 'lock'
|
||||
| 'mobileActivateApp'
|
||||
| 'mobileCalibrateWebToRealCoordinatesTranslation'
|
||||
| 'mobileClearApp'
|
||||
| 'mobileClearKeychains'
|
||||
| 'mobileConfigureLocalization'
|
||||
| 'mobileDeepLink'
|
||||
| 'mobileDeleteFile'
|
||||
| 'mobileDeleteFolder'
|
||||
| 'mobileDisableVoiceOver'
|
||||
| 'mobileDoubleTap'
|
||||
| 'mobileDragFromToForDuration'
|
||||
| 'mobileDragFromToWithVelocity'
|
||||
| 'mobileEnableVoiceOver'
|
||||
| 'mobileEnrollBiometric'
|
||||
| 'mobileExpectNotification'
|
||||
| 'mobileForcePress'
|
||||
| 'mobileGetActiveAppInfo'
|
||||
| 'mobileGetAppearance'
|
||||
| 'mobileGetBatteryInfo'
|
||||
| 'mobileGetContentSize'
|
||||
| 'mobileGetContexts'
|
||||
| 'mobileGetDeviceInfo'
|
||||
| 'mobileGetDeviceTime'
|
||||
| 'mobileGetIncreaseContrast'
|
||||
| 'mobileGetPasteboard'
|
||||
| 'mobileGetPermission'
|
||||
| 'mobileGetSimulatedLocation'
|
||||
| 'mobileGetSource'
|
||||
| 'mobileGetXctestScreenRecordingInfo'
|
||||
| 'mobileHandleAlert'
|
||||
| 'mobileHideKeyboard'
|
||||
| 'mobileInstallApp'
|
||||
| 'mobileInstallCertificate'
|
||||
| 'mobileInstallXCTestBundle'
|
||||
| 'mobileIsAppInstalled'
|
||||
| 'mobileIsBiometricEnrolled'
|
||||
| 'mobileIsVoiceOverEnabled'
|
||||
| 'mobileKeys'
|
||||
| 'mobileKillApp'
|
||||
| 'mobileLaunchApp'
|
||||
| 'mobileListApps'
|
||||
| 'mobileListCertificates'
|
||||
| 'mobileListXCTestBundles'
|
||||
| 'mobilePerformAccessibilityAudit'
|
||||
| 'mobilePerformHandGesture'
|
||||
| 'mobilePerformIndigoHidEvent'
|
||||
| 'mobilePerformIoHidEvent'
|
||||
| 'mobilePinch'
|
||||
| 'mobilePressButton'
|
||||
| 'mobilePullFile'
|
||||
| 'mobilePullFolder'
|
||||
| 'mobilePushFile'
|
||||
| 'mobilePushNotification'
|
||||
| 'mobileQueryAppState'
|
||||
| 'mobileRemoveApp'
|
||||
| 'mobileRemoveCertificate'
|
||||
| 'mobileResetLocationService'
|
||||
| 'mobileResetPermission'
|
||||
| 'mobileResetSimulatedLocation'
|
||||
| 'mobileRotateDigitalCrown'
|
||||
| 'mobileRotateElement'
|
||||
| 'mobileRunXCTest'
|
||||
| 'mobileScroll'
|
||||
| 'mobileScrollToElement'
|
||||
| 'mobileSelectPickerWheelValue'
|
||||
| 'mobileSendBiometricMatch'
|
||||
| 'mobileSendMemoryWarning'
|
||||
| 'mobileSetAppearance'
|
||||
| 'mobileSetContentSize'
|
||||
| 'mobileSetIncreaseContrast'
|
||||
| 'mobileSetPasteboard'
|
||||
| 'mobileSetPermissions'
|
||||
| 'mobileSetSimulatedLocation'
|
||||
| 'mobileShake'
|
||||
| 'mobileSimctl'
|
||||
| 'mobileSiriCommand'
|
||||
| 'mobileStartLogsBroadcast'
|
||||
| 'mobileStartNetworkMonitor'
|
||||
| 'mobileStartPerfRecord'
|
||||
| 'mobileStartScreenRecording'
|
||||
| 'mobileStartSystemMonitor'
|
||||
| 'mobileStartXctestScreenRecording'
|
||||
| 'mobileStopLogsBroadcast'
|
||||
| 'mobileStopNetworkMonitor'
|
||||
| 'mobileStopPerfRecord'
|
||||
| 'mobileStopScreenRecording'
|
||||
| 'mobileStopSystemMonitor'
|
||||
| 'mobileStopXctestScreenRecording'
|
||||
| 'mobileSwipe'
|
||||
| 'mobileTap'
|
||||
| 'mobileTapWithNumberOfTaps'
|
||||
| 'mobileTerminateApp'
|
||||
| 'mobileTouchAndHold'
|
||||
| 'mobileTwoFingerTap'
|
||||
| 'mobileUpdateSafariPreferences'
|
||||
| 'mobileVoiceOverCurrentSpeech'
|
||||
| 'mobileVoiceOverMove'
|
||||
| 'reset'
|
||||
| 'setClipboard'
|
||||
| 'setValueImmediate'
|
||||
| 'startAudioRecording'
|
||||
| 'startRecordingScreen'
|
||||
| 'stopAudioRecording'
|
||||
| 'stopRecordingScreen'
|
||||
| 'submit'
|
||||
| 'toggleEnrollTouchId'
|
||||
| 'touchId'
|
||||
| 'unlock';
|
||||
|
||||
export type UiAutomator2AppiumCommand =
|
||||
| 'background'
|
||||
| 'fingerprint'
|
||||
| 'getClipboard'
|
||||
| 'getCurrentActivity'
|
||||
| 'getCurrentPackage'
|
||||
| 'getDisplayDensity'
|
||||
| 'getLocation'
|
||||
| 'getLocationInView'
|
||||
| 'getPerformanceData'
|
||||
| 'getPerformanceDataTypes'
|
||||
| 'getSize'
|
||||
| 'getStrings'
|
||||
| 'getSystemBars'
|
||||
| 'getWindowSize'
|
||||
| 'gsmCall'
|
||||
| 'gsmSignal'
|
||||
| 'gsmVoice'
|
||||
| 'implicitWait'
|
||||
| 'isLocationServicesEnabled'
|
||||
| 'isLocked'
|
||||
| 'keyevent'
|
||||
| 'keys'
|
||||
| 'lock'
|
||||
| 'longPressKeyCode'
|
||||
| 'mobileAcceptAlert'
|
||||
| 'mobileBackgroundApp'
|
||||
| 'mobileBluetooth'
|
||||
| 'mobileBroadcast'
|
||||
| 'mobileChangePermissions'
|
||||
| 'mobileClearApp'
|
||||
| 'mobileClickGesture'
|
||||
| 'mobileDeepLink'
|
||||
| 'mobileDeleteFile'
|
||||
| 'mobileDeviceidle'
|
||||
| 'mobileDismissAlert'
|
||||
| 'mobileDoubleClickGesture'
|
||||
| 'mobileDragGesture'
|
||||
| 'mobileExecEmuConsoleCommand'
|
||||
| 'mobileFingerprint'
|
||||
| 'mobileFlingGesture'
|
||||
| 'mobileGetActionHistory'
|
||||
| 'mobileGetBatteryInfo'
|
||||
| 'mobileGetChromeCapabilities'
|
||||
| 'mobileGetConnectivity'
|
||||
| 'mobileGetContexts'
|
||||
| 'mobileGetDeclaredOrientation'
|
||||
| 'mobileGetDeviceInfo'
|
||||
| 'mobileGetDeviceTime'
|
||||
| 'mobileGetGeolocation'
|
||||
| 'mobileGetNotifications'
|
||||
| 'mobileGetPerformanceData'
|
||||
| 'mobileGetPermissions'
|
||||
| 'mobileGetUiMode'
|
||||
| 'mobileGsmCall'
|
||||
| 'mobileGsmSignal'
|
||||
| 'mobileGsmVoice'
|
||||
| 'mobileInjectEmulatorCameraImage'
|
||||
| 'mobileInstallApp'
|
||||
| 'mobileInstallMultipleApks'
|
||||
| 'mobileIsAppInstalled'
|
||||
| 'mobileIsMediaProjectionRecordingRunning'
|
||||
| 'mobileListApps'
|
||||
| 'mobileListDisplays'
|
||||
| 'mobileListSms'
|
||||
| 'mobileListWindows'
|
||||
| 'mobileLongClickGesture'
|
||||
| 'mobileNetworkSpeed'
|
||||
| 'mobileNfc'
|
||||
| 'mobilePerformEditorAction'
|
||||
| 'mobilePerformStatusBarCommand'
|
||||
| 'mobilePinchCloseGesture'
|
||||
| 'mobilePinchOpenGesture'
|
||||
| 'mobilePowerAc'
|
||||
| 'mobilePowerCapacity'
|
||||
| 'mobilePressKey'
|
||||
| 'mobileRefreshGpsCache'
|
||||
| 'mobileRemoveApp'
|
||||
| 'mobileReplaceElementValue'
|
||||
| 'mobileResetAccessibilityCache'
|
||||
| 'mobileResetGeolocation'
|
||||
| 'mobileScheduleAction'
|
||||
| 'mobileScreenshots'
|
||||
| 'mobileScroll'
|
||||
| 'mobileScrollBackTo'
|
||||
| 'mobileScrollGesture'
|
||||
| 'mobileSendSms'
|
||||
| 'mobileSendTrimMemory'
|
||||
| 'mobileSetConnectivity'
|
||||
| 'mobileSetGeolocation'
|
||||
| 'mobileSetUiMode'
|
||||
| 'mobileShell'
|
||||
| 'mobileStartActivity'
|
||||
| 'mobileStartLogsBroadcast'
|
||||
| 'mobileStartMediaProjectionRecording'
|
||||
| 'mobileStartScreenStreaming'
|
||||
| 'mobileStartService'
|
||||
| 'mobileStopLogsBroadcast'
|
||||
| 'mobileStopMediaProjectionRecording'
|
||||
| 'mobileStopScreenStreaming'
|
||||
| 'mobileStopService'
|
||||
| 'mobileSwipeGesture'
|
||||
| 'mobileTerminateApp'
|
||||
| 'mobileType'
|
||||
| 'mobileUnlock'
|
||||
| 'mobileUnscheduleAction'
|
||||
| 'mobileViewPortRect'
|
||||
| 'mobileViewportScreenshot'
|
||||
| 'networkSpeed'
|
||||
| 'openNotifications'
|
||||
| 'powerAC'
|
||||
| 'powerCapacity'
|
||||
| 'pressKeyCode'
|
||||
| 'replaceValue'
|
||||
| 'sendSMS'
|
||||
| 'sensorSet'
|
||||
| 'setClipboard'
|
||||
| 'setStylusHandwriting'
|
||||
| 'setValueImmediate'
|
||||
| 'startActivity'
|
||||
| 'startRecordingScreen'
|
||||
| 'stopRecordingScreen'
|
||||
| 'toggleData'
|
||||
| 'toggleFlightMode'
|
||||
| 'toggleLocationServices'
|
||||
| 'toggleWiFi'
|
||||
| 'unlock';
|
||||
|
||||
export type AppiumCommand =
|
||||
| BaseDriverAppiumCommand
|
||||
| XCUITestAppiumCommand
|
||||
| UiAutomator2AppiumCommand;
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { humanizeAppiumCommand } from './appiumCommandSummary';
|
||||
import {
|
||||
type DeviceRunSessionEventCollection,
|
||||
type DeviceRunSessionEventSource,
|
||||
@@ -146,7 +147,7 @@ function createAppiumEventSource(eventFile: string): DeviceRunSessionEventSource
|
||||
type: 'operation.completed',
|
||||
operationId,
|
||||
durationMs: Math.max(0, command.endTime - command.startTime),
|
||||
summary: command.cmd,
|
||||
summary: humanizeAppiumCommand(command.cmd),
|
||||
data: {
|
||||
command: command.cmd,
|
||||
appiumSessionId: command.appiumSessionId,
|
||||
|
||||
@@ -18,11 +18,11 @@ const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
|
||||
const ARGENT_ARTIFACT_UPLOAD_CLEANUP_TIMEOUT_MS = 30_000;
|
||||
const ARGENT_ARTIFACT_FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
// Kinds the EAS dashboard groups device run session artifacts by. Only media types the dashboard
|
||||
// renders specially are mapped; anything else stays unclassified and lands in its "Other" group.
|
||||
// Note that these kinds only affect grouping and labelling. The dashboard gates its inline video
|
||||
// player on the `__eas_screen_recording` metadata flag, which Argent artifacts deliberately do not
|
||||
// set, so a `screen-recording` kind here does not add a player to the session page.
|
||||
// Fallback kinds for Argent versions that do not include a semantic artifact kind. Only media types
|
||||
// the EAS dashboard renders specially can be inferred safely. Note that these kinds only affect
|
||||
// grouping and labelling. The dashboard gates its inline video player on the
|
||||
// `__eas_screen_recording` metadata flag, which Argent artifacts deliberately do not set, so a
|
||||
// `screen-recording` kind here does not add a player to the session page.
|
||||
const ARGENT_ARTIFACT_KIND_BY_MIME_TYPE = new Map<string, string>([
|
||||
['image/png', 'screenshot'],
|
||||
['image/jpeg', 'screenshot'],
|
||||
@@ -32,6 +32,9 @@ const ARGENT_ARTIFACT_KIND_BY_MIME_TYPE = new Map<string, string>([
|
||||
|
||||
const ArgentArtifactSchema = z.object({
|
||||
id: z.string(),
|
||||
// Optional for compatibility with older Argent tool servers. Keep this open to new kinds so a
|
||||
// newer Argent version can add a category without requiring an EAS worker release first.
|
||||
kind: z.string().optional(),
|
||||
filename: z.string(),
|
||||
mimeType: z.string(),
|
||||
isDirectory: z.boolean().optional(),
|
||||
@@ -43,8 +46,12 @@ const ArgentArtifactsListResponseSchema = z.object({
|
||||
type ArgentArtifact = z.infer<typeof ArgentArtifactSchema>;
|
||||
|
||||
function getArgentArtifactKind(artifact: ArgentArtifact): string | undefined {
|
||||
if (artifact.kind) {
|
||||
return artifact.kind;
|
||||
}
|
||||
// Directories are repackaged as a tarball before upload, so the reported media type describes the
|
||||
// contents rather than the file we actually store.
|
||||
// contents rather than the file we actually store. A semantic kind above remains valid because it
|
||||
// describes what the artifact represents, not its transport format.
|
||||
if (artifact.isDirectory) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@ import { turtleFetch } from '../../utils/turtleFetch';
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1280';
|
||||
const SERVE_SIM_MAX_DIMENSION = '960';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '3000000';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '6000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
|
||||
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../cocoapodsCache';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
describe('CocoaPods cache utilities', () => {
|
||||
let workingDirectory: string;
|
||||
const cocoapodsVersion = '1.16.2';
|
||||
|
||||
beforeEach(async () => {
|
||||
workingDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cocoapods-cache-test-'));
|
||||
await fs.promises.mkdir(path.join(workingDirectory, 'ios'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(workingDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses a CocoaPods version prefix when no Podfile.lock exists', async () => {
|
||||
await expect(
|
||||
resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion)
|
||||
).resolves.toEqual({
|
||||
key: 'ios-pods-1.16.2-',
|
||||
keyPrefix: 'ios-pods-1.16.2-',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the Podfile.lock hash when it exists', async () => {
|
||||
const { podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
|
||||
await fs.promises.writeFile(podfileLockPath, 'PODS:\n - Expo (55.0.0)\n');
|
||||
|
||||
const firstResult = await resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion);
|
||||
expect(firstResult.key).toMatch(/^ios-pods-1\.16\.2-[a-f0-9]+$/);
|
||||
expect(firstResult.keyPrefix).toBe('ios-pods-1.16.2-');
|
||||
|
||||
await fs.promises.writeFile(podfileLockPath, 'PODS:\n - Expo (56.0.0)\n');
|
||||
const secondResult = await resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion);
|
||||
expect(secondResult.key).not.toBe(firstResult.key);
|
||||
});
|
||||
|
||||
it('preserves symlinks and executable permissions through an archive round trip', async () => {
|
||||
const { podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
const frameworkVersionsDirectory = path.join(podsDirectory, 'Example.framework', 'Versions');
|
||||
const frameworkVersionDirectory = path.join(frameworkVersionsDirectory, 'A');
|
||||
await fs.promises.mkdir(frameworkVersionDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(frameworkVersionDirectory, 'Example'), 'binary');
|
||||
await fs.promises.symlink('A', path.join(frameworkVersionsDirectory, 'Current'));
|
||||
|
||||
const scriptPath = path.join(podsDirectory, 'Target Support Files', 'script.sh');
|
||||
await fs.promises.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.promises.writeFile(scriptPath, '#!/bin/sh\n');
|
||||
await fs.promises.chmod(scriptPath, 0o755);
|
||||
|
||||
const { archivePath } = await compressCocoapodsCacheAsync({ workingDirectory });
|
||||
await fs.promises.rm(podsDirectory, { recursive: true, force: true });
|
||||
await restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory });
|
||||
|
||||
const restoredSymlinkPath = path.join(frameworkVersionsDirectory, 'Current');
|
||||
expect((await fs.promises.lstat(restoredSymlinkPath)).isSymbolicLink()).toBe(true);
|
||||
await expect(fs.promises.readlink(restoredSymlinkPath)).resolves.toBe('A');
|
||||
|
||||
const restoredScriptStat = await fs.promises.stat(scriptPath);
|
||||
expect(restoredScriptStat.mode & 0o111).toBe(0o111);
|
||||
|
||||
await fs.promises.rm(path.dirname(archivePath), { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${PRODUCTION_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
@@ -133,7 +133,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(stagingResult).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
|
||||
});
|
||||
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
@@ -141,7 +141,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(productionResult).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
@@ -173,9 +173,17 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: [`eas-cli@${PRODUCTION_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the minimum release age for every npx invocation', async () => {
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
|
||||
expect(result.extraEnv).toEqual({ NPM_CONFIG_MIN_RELEASE_AGE: '0' });
|
||||
});
|
||||
});
|
||||
|
||||
describe(runEasCliCommand, () => {
|
||||
@@ -218,4 +226,25 @@ describe(runEasCliCommand, () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('overrides a user-provided minimum release age', async () => {
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
|
||||
await runEasCliCommand({
|
||||
args: ['deploy', '--json'],
|
||||
options: {
|
||||
cwd: '/tmp/project',
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const spawnEnv = jest.mocked(spawn).mock.calls[0][2]?.env;
|
||||
expect(spawnEnv).toEqual({
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '0',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { formatSecondsForLog } from '../formatDuration';
|
||||
|
||||
describe(formatSecondsForLog, () => {
|
||||
it.each([
|
||||
[0, '0 seconds'],
|
||||
[1, '1 second'],
|
||||
[45, '45 seconds'],
|
||||
[60, '1 minute'],
|
||||
[90, '1 minute 30 seconds'],
|
||||
[120, '2 minutes'],
|
||||
[3600, '1 hour'],
|
||||
[3661, '1 hour 1 minute 1 second'],
|
||||
[7200, '2 hours'],
|
||||
] as const)('formats %i as %s', (seconds, expected) => {
|
||||
expect(formatSecondsForLog(seconds)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ChildProcess } from 'node:child_process';
|
||||
|
||||
import { isChildProcessAlive } from '../processes';
|
||||
|
||||
function child(partial: {
|
||||
exitCode: number | null;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
killed: boolean;
|
||||
}): ChildProcess {
|
||||
return partial as ChildProcess;
|
||||
}
|
||||
|
||||
describe(isChildProcessAlive, () => {
|
||||
it('is alive while the process is running', () => {
|
||||
expect(isChildProcessAlive(child({ exitCode: null, signalCode: null, killed: false }))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('is dead after a normal exit', () => {
|
||||
expect(isChildProcessAlive(child({ exitCode: 0, signalCode: null, killed: false }))).toBe(
|
||||
false
|
||||
);
|
||||
expect(isChildProcessAlive(child({ exitCode: 1, signalCode: null, killed: false }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('is dead after an external signal termination (the regression case)', () => {
|
||||
expect(
|
||||
isChildProcessAlive(child({ exitCode: null, signalCode: 'SIGTERM', killed: false }))
|
||||
).toBe(false);
|
||||
expect(
|
||||
isChildProcessAlive(child({ exitCode: null, signalCode: 'SIGKILL', killed: false }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is dead once we have killed it', () => {
|
||||
expect(isChildProcessAlive(child({ exitCode: null, signalCode: null, killed: true }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
import { SshSettings, SystemError } from '@expo/eas-build-job';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { sleepAsync } from '../retry';
|
||||
import {
|
||||
SshSessionHandle,
|
||||
getSshIdleTimeoutSeconds,
|
||||
getSshRelayServerUrl,
|
||||
isSshEnabled,
|
||||
startSshSessionAsync,
|
||||
superviseSshSessionAsync,
|
||||
} from '../turtleSshSession';
|
||||
import { SshConnectionConfig, UptermHost, startUptermHostAsync } from '../upterm';
|
||||
|
||||
jest.mock('../retry', () => ({
|
||||
sleepAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../upterm', () => ({
|
||||
...jest.requireActual('../upterm'),
|
||||
startUptermHostAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedStartUptermHost = jest.mocked(startUptermHostAsync);
|
||||
|
||||
function sshJob(idleTimeoutSeconds: number): { ssh: SshSettings } {
|
||||
return { ssh: { idleTimeoutSeconds, relayServerUrl: 'wss://relay.expo.dev' } };
|
||||
}
|
||||
|
||||
function superviseHandle(
|
||||
overrides: Partial<SshSessionHandle> &
|
||||
Pick<SshSessionHandle, 'getConnectedClientCountAsync' | 'ensureConnectedAsync'>
|
||||
): SshSessionHandle {
|
||||
return {
|
||||
stopAsync: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(isSshEnabled, () => {
|
||||
it('is true when job.ssh is present', () => {
|
||||
expect(isSshEnabled(sshJob(0))).toBe(true);
|
||||
});
|
||||
|
||||
it('is false otherwise', () => {
|
||||
expect(isSshEnabled({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getSshIdleTimeoutSeconds, () => {
|
||||
it('returns idleTimeoutSeconds from job.ssh', () => {
|
||||
expect(getSshIdleTimeoutSeconds(sshJob(900))).toBe(900);
|
||||
expect(getSshIdleTimeoutSeconds(sshJob(0))).toBe(0);
|
||||
expect(getSshIdleTimeoutSeconds(sshJob(3600))).toBe(3600);
|
||||
});
|
||||
|
||||
it('defaults to 0 when job.ssh is missing', () => {
|
||||
expect(getSshIdleTimeoutSeconds({})).toBe(0);
|
||||
});
|
||||
|
||||
it('throws when the value is not an integer in range', () => {
|
||||
for (const idleTimeoutSeconds of [300.5, -1, 3601]) {
|
||||
expect(() => getSshIdleTimeoutSeconds(sshJob(idleTimeoutSeconds))).toThrow(SystemError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(getSshRelayServerUrl, () => {
|
||||
it('returns the relay url from the job ssh settings', () => {
|
||||
expect(getSshRelayServerUrl(sshJob(0))).toBe('wss://relay.expo.dev');
|
||||
});
|
||||
|
||||
it('throws when the job carries no relay url', () => {
|
||||
expect(() => getSshRelayServerUrl({})).toThrow(/no relay server URL/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(startSshSessionAsync, () => {
|
||||
const config1: SshConnectionConfig = {
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'secret-1',
|
||||
};
|
||||
const config2: SshConnectionConfig = { ...config1, secret: 'secret-2' };
|
||||
const target = { type: 'JOB_RUN', id: 'jr-1' } as const;
|
||||
|
||||
let createOrUpdateResult: { data?: unknown; error?: { message: string } };
|
||||
let mutation: jest.Mock;
|
||||
let ctx: BuildContext;
|
||||
|
||||
function makeHost(overrides: Partial<UptermHost> = {}): UptermHost {
|
||||
const host: UptermHost = {
|
||||
connectionConfig: config1,
|
||||
getConnectedClientCountAsync: jest.fn().mockResolvedValue(3),
|
||||
isAlive: jest.fn().mockReturnValue(true),
|
||||
redialAsync: jest.fn(async () => {
|
||||
host.connectionConfig = config2;
|
||||
return config2;
|
||||
}),
|
||||
stopAsync: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
return host;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createOrUpdateResult = {
|
||||
data: {
|
||||
turtleSshSession: {
|
||||
createOrUpdateTurtleSshSession: {
|
||||
id: 'ts-1',
|
||||
sessionSettings: { idleTimeoutSeconds: 900 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
mutation = jest.fn().mockReturnValue({ toPromise: async () => createOrUpdateResult });
|
||||
ctx = {
|
||||
logger: createMockLogger(),
|
||||
graphqlClient: { mutation },
|
||||
} as unknown as BuildContext;
|
||||
});
|
||||
|
||||
it('creates or updates the session and returns a handle plus idle timeout that delegates to the host', async () => {
|
||||
const host = makeHost();
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
|
||||
const { handle, idleTimeoutSeconds } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://relay.expo.dev',
|
||||
idleTimeoutSeconds: 900,
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
|
||||
sessionSettings: { idleTimeoutSeconds: 900 },
|
||||
});
|
||||
expect(idleTimeoutSeconds).toBe(900);
|
||||
await handle.getConnectedClientCountAsync();
|
||||
expect(host.getConnectedClientCountAsync).toHaveBeenCalled();
|
||||
await handle.stopAsync();
|
||||
expect(host.stopAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a build target', async () => {
|
||||
const host = makeHost();
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
|
||||
await startSshSessionAsync(ctx, {
|
||||
target: { type: 'BUILD', id: 'b-1' },
|
||||
relayServerUrl: 'wss://relay.expo.dev',
|
||||
idleTimeoutSeconds: 900,
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
target: { type: 'BUILD', id: 'b-1' },
|
||||
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
|
||||
sessionSettings: { idleTimeoutSeconds: 900 },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws and tears down the dialed host when creating or updating the session fails', async () => {
|
||||
const host = makeHost();
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
createOrUpdateResult = { error: { message: 'boom' } };
|
||||
|
||||
await expect(
|
||||
startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
})
|
||||
).rejects.toThrow('boom');
|
||||
expect(host.stopAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still rethrows when teardown after a create/update failure also fails', async () => {
|
||||
const host = makeHost({
|
||||
stopAsync: jest.fn().mockRejectedValue(new Error('already gone')),
|
||||
});
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
createOrUpdateResult = { error: { message: 'boom' } };
|
||||
|
||||
await expect(
|
||||
startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
})
|
||||
).rejects.toThrow('boom');
|
||||
expect(host.stopAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws "no data returned" when create or update succeeds without a payload', async () => {
|
||||
const host = makeHost();
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
createOrUpdateResult = {};
|
||||
|
||||
await expect(
|
||||
startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
})
|
||||
).rejects.toThrow(/no data returned/);
|
||||
expect(host.stopAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ensureConnected is a no-op while the host is alive', async () => {
|
||||
const host = makeHost({ isAlive: jest.fn().mockReturnValue(true) });
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
await handle.ensureConnectedAsync();
|
||||
|
||||
expect(host.redialAsync).not.toHaveBeenCalled();
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks reconnecting, redials, then reports the fresh config', async () => {
|
||||
const host = makeHost({ isAlive: jest.fn().mockReturnValue(false) });
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
await handle.ensureConnectedAsync();
|
||||
|
||||
expect(host.redialAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
connectionConfig: expect.objectContaining({ reconnecting: true }),
|
||||
})
|
||||
);
|
||||
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
|
||||
sessionSettings: { idleTimeoutSeconds: 300 },
|
||||
});
|
||||
});
|
||||
|
||||
it('continues redial when the reconnecting status update fails', async () => {
|
||||
const host = makeHost({ isAlive: jest.fn().mockReturnValue(false) });
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
mutation
|
||||
.mockReturnValueOnce({ toPromise: async () => createOrUpdateResult }) // initial create
|
||||
.mockReturnValueOnce({
|
||||
toPromise: async () => {
|
||||
throw new Error('www unreachable');
|
||||
},
|
||||
}) // reconnecting=true best-effort
|
||||
.mockReturnValue({ toPromise: async () => createOrUpdateResult }); // post-redial
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
await handle.ensureConnectedAsync();
|
||||
|
||||
expect(host.redialAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries only the report (no second redial) when reporting the fresh config fails', async () => {
|
||||
let alive = false;
|
||||
const host = makeHost({
|
||||
isAlive: jest.fn(() => alive),
|
||||
});
|
||||
host.redialAsync = jest.fn(async () => {
|
||||
alive = true;
|
||||
host.connectionConfig = config2;
|
||||
return config2;
|
||||
});
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
mutation = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce({ toPromise: async () => createOrUpdateResult })
|
||||
.mockReturnValueOnce({ toPromise: async () => createOrUpdateResult })
|
||||
.mockReturnValueOnce({ toPromise: async () => ({ error: { message: 'flaky' } }) })
|
||||
.mockReturnValue({ toPromise: async () => createOrUpdateResult });
|
||||
ctx = { logger: createMockLogger(), graphqlClient: { mutation } } as unknown as BuildContext;
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
await handle.ensureConnectedAsync();
|
||||
|
||||
expect(host.redialAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
|
||||
sessionSettings: { idleTimeoutSeconds: 300 },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws once the redial budget is exhausted', async () => {
|
||||
const host = makeHost({
|
||||
isAlive: jest.fn().mockReturnValue(false),
|
||||
redialAsync: jest.fn().mockRejectedValue(new Error('relay down')),
|
||||
});
|
||||
mockedStartUptermHost.mockResolvedValue(host);
|
||||
|
||||
const { handle } = await startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: 'wss://r',
|
||||
idleTimeoutSeconds: 300,
|
||||
});
|
||||
|
||||
await expect(handle.ensureConnectedAsync()).rejects.toThrow(/after 10 attempts/);
|
||||
expect(host.redialAsync).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe(superviseSshSessionAsync, () => {
|
||||
const logger = createMockLogger();
|
||||
const mockedSleep = jest.mocked(sleepAsync);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedSleep.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('keeps the session open while the job is running, even with a 0 idle timeout', async () => {
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {
|
||||
if (polls >= 5) {
|
||||
throw new SystemError('stop the loop');
|
||||
}
|
||||
},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
return 0;
|
||||
},
|
||||
}),
|
||||
idleTimeoutSeconds: 0,
|
||||
hasJobFinished: () => false,
|
||||
logger,
|
||||
});
|
||||
expect(polls).toBe(5);
|
||||
});
|
||||
|
||||
it('closes as soon as the job finishes when the idle timeout is 0', async () => {
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
return 0;
|
||||
},
|
||||
}),
|
||||
idleTimeoutSeconds: 0,
|
||||
hasJobFinished: () => true,
|
||||
logger,
|
||||
});
|
||||
expect(polls).toBe(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'The job finished and no SSH client is connected. Closing the session.'
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for a connected client to disconnect before closing with a 0 idle timeout', async () => {
|
||||
const clientCounts = [1, 1, 0];
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
return clientCounts.shift() ?? 0;
|
||||
},
|
||||
}),
|
||||
idleTimeoutSeconds: 0,
|
||||
hasJobFinished: () => true,
|
||||
logger,
|
||||
});
|
||||
expect(polls).toBe(3);
|
||||
});
|
||||
|
||||
it('starts the idle countdown when the job finishes, not when the session opens', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(0);
|
||||
try {
|
||||
let jobHasFinished = false;
|
||||
let polls = 0;
|
||||
mockedSleep.mockImplementation(async () => {
|
||||
jest.setSystemTime(Date.now() + 60_000);
|
||||
});
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
if (polls >= 5) {
|
||||
jobHasFinished = true;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
}),
|
||||
idleTimeoutSeconds: 300,
|
||||
hasJobFinished: () => jobHasFinished,
|
||||
logger,
|
||||
});
|
||||
// Nobody connects, so the 4 idle minutes spent during the job must not count towards the
|
||||
// 5 minute timeout: it closes 5 minutes after the job finished, on the 10th poll.
|
||||
expect(polls).toBe(10);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('logs the connected client count whenever it changes', async () => {
|
||||
const clientCounts = [1, 2, 1, 0];
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {},
|
||||
getConnectedClientCountAsync: async () => clientCounts.shift() ?? 0,
|
||||
}),
|
||||
idleTimeoutSeconds: 0,
|
||||
hasJobFinished: () => true,
|
||||
logger,
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 1');
|
||||
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 2');
|
||||
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 1');
|
||||
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 0');
|
||||
});
|
||||
|
||||
it('closes the session when the relay connection cannot be restored', async () => {
|
||||
let clientCountCalls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {
|
||||
throw new SystemError('relay down');
|
||||
},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
clientCountCalls += 1;
|
||||
return 1;
|
||||
},
|
||||
}),
|
||||
idleTimeoutSeconds: 300,
|
||||
hasJobFinished: () => false,
|
||||
logger,
|
||||
});
|
||||
expect(clientCountCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('does not close while a client stays connected, even past the idle timeout', async () => {
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {
|
||||
if (polls > 3) {
|
||||
throw new SystemError('stop the loop');
|
||||
}
|
||||
},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
return 1;
|
||||
},
|
||||
}),
|
||||
idleTimeoutSeconds: 0,
|
||||
hasJobFinished: () => true,
|
||||
logger,
|
||||
});
|
||||
expect(polls).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it('closes when the client count cannot be read', async () => {
|
||||
let polls = 0;
|
||||
await superviseSshSessionAsync({
|
||||
handle: superviseHandle({
|
||||
ensureConnectedAsync: async () => {},
|
||||
getConnectedClientCountAsync: async () => {
|
||||
polls += 1;
|
||||
throw new SystemError(
|
||||
'Could not read the SSH client count from the upterm admin socket.'
|
||||
);
|
||||
},
|
||||
}),
|
||||
idleTimeoutSeconds: 300,
|
||||
hasJobFinished: () => false,
|
||||
logger,
|
||||
});
|
||||
expect(polls).toBe(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Could not read the SSH client count. Closing the session.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import downloadFile from '@expo/downloader';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { sleepAsync } from '../retry';
|
||||
import {
|
||||
connectionConfigFromUptermSession,
|
||||
redactConnectionSecrets,
|
||||
redactSpawnErrorForLog,
|
||||
resolveUptermGcsObjectName,
|
||||
startUptermHostAsync,
|
||||
} from '../upterm';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('@expo/downloader', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../retry', () => ({ sleepAsync: jest.fn() }));
|
||||
jest.mock('node:fs/promises', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
access: jest.fn(),
|
||||
mkdir: jest.fn(),
|
||||
chmod: jest.fn(),
|
||||
mkdtemp: jest.fn(),
|
||||
writeFile: jest.fn(),
|
||||
rm: jest.fn(),
|
||||
readdir: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe(resolveUptermGcsObjectName, () => {
|
||||
it('maps worker platforms to GCS object names', () => {
|
||||
expect(resolveUptermGcsObjectName('darwin', 'arm64')).toBe('upterm-darwin-arm64');
|
||||
expect(resolveUptermGcsObjectName('linux', 'x64')).toBe('upterm-linux-amd64');
|
||||
});
|
||||
|
||||
it('rejects unsupported platforms', () => {
|
||||
expect(() => resolveUptermGcsObjectName('darwin', 'x64')).toThrow(/only available/);
|
||||
expect(() => resolveUptermGcsObjectName('linux', 'arm64')).toThrow(/only available/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(connectionConfigFromUptermSession, () => {
|
||||
it('parses sessionId and host from session current fields', () => {
|
||||
expect(
|
||||
connectionConfigFromUptermSession({
|
||||
sessionId: 'TOKENabc123',
|
||||
host: 'ssh://relay.expo.dev:22',
|
||||
})
|
||||
).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENabc123',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a bare hostname', () => {
|
||||
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'relay.expo.dev' })).toEqual(
|
||||
{
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'tok',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a non-default port so the CLI can pass it to ssh -p', () => {
|
||||
expect(
|
||||
connectionConfigFromUptermSession({
|
||||
sessionId: 'tok',
|
||||
host: 'ssh://relay.expo.dev:2222',
|
||||
})
|
||||
).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev:2222',
|
||||
secret: 'tok',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the host is not a parseable url', () => {
|
||||
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://' })).toBeNull();
|
||||
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://[' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(redactConnectionSecrets, () => {
|
||||
it('strips the userinfo secret from a connection string', () => {
|
||||
expect(
|
||||
redactConnectionSecrets('Connect: upterm proxy wss://[email protected] now')
|
||||
).toBe('Connect: upterm proxy wss://<redacted>@relay.expo.dev now');
|
||||
});
|
||||
|
||||
it('strips the bare token that trails the ssh ProxyCommand line', () => {
|
||||
const line =
|
||||
"ssh -o ProxyCommand='upterm proxy wss://[email protected]' [email protected]";
|
||||
const redacted = redactConnectionSecrets(line);
|
||||
expect(redacted).not.toContain('TOKENabc123');
|
||||
expect(redacted).toBe(
|
||||
"ssh -o ProxyCommand='upterm proxy wss://<redacted>@uptermd.upterm.dev' <redacted>@uptermd.upterm.dev"
|
||||
);
|
||||
});
|
||||
|
||||
it('strips the bare token for ws:// relay URLs too', () => {
|
||||
const line =
|
||||
"ssh -o ProxyCommand='upterm proxy ws://[email protected]' [email protected]";
|
||||
const redacted = redactConnectionSecrets(line);
|
||||
expect(redacted).not.toContain('TOKENabc123');
|
||||
expect(redacted).toBe(
|
||||
"ssh -o ProxyCommand='upterm proxy ws://<redacted>@relay.local' <redacted>@relay.local"
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves output without userinfo untouched', () => {
|
||||
expect(redactConnectionSecrets('dialing wss://relay.expo.dev')).toBe(
|
||||
'dialing wss://relay.expo.dev'
|
||||
);
|
||||
});
|
||||
|
||||
it('strips control characters (incl. ESC) while keeping newlines', () => {
|
||||
// ESC alone is enough to neutralize CSI; leftover printable "[31m" is harmless in logs.
|
||||
expect(redactConnectionSecrets('ok\n\u001b[31mfail\u001b[0m\r\nbad\u0007')).toBe(
|
||||
'ok\n[31mfail[0m\nbad'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(redactSpawnErrorForLog, () => {
|
||||
it('redacts secrets in message, stdout, and stderr', () => {
|
||||
expect(
|
||||
redactSpawnErrorForLog({
|
||||
message: 'failed wss://[email protected]',
|
||||
stdout: 'upterm proxy wss://[email protected]',
|
||||
stderr: 'ssh dial error: wss://[email protected]',
|
||||
code: 1,
|
||||
})
|
||||
).toEqual({
|
||||
message: 'failed wss://<redacted>@relay.expo.dev',
|
||||
stdout: 'upterm proxy wss://<redacted>@relay.expo.dev',
|
||||
stderr: 'ssh dial error: wss://<redacted>@relay.expo.dev',
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through non-objects', () => {
|
||||
expect(redactSpawnErrorForLog('boom')).toBe('boom');
|
||||
expect(redactSpawnErrorForLog(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves non-string message/stdout/stderr fields alone', () => {
|
||||
expect(
|
||||
redactSpawnErrorForLog({
|
||||
message: 42,
|
||||
stdout: Buffer.from('x'),
|
||||
code: 1,
|
||||
})
|
||||
).toEqual({
|
||||
message: 42,
|
||||
stdout: Buffer.from('x'),
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(startUptermHostAsync, () => {
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedSleep = jest.mocked(sleepAsync);
|
||||
const mockedFs = jest.mocked(fs);
|
||||
const mockedDownloadFile = jest.mocked(downloadFile);
|
||||
|
||||
let hostProcess: {
|
||||
child: {
|
||||
stdout: { on: (event: string, cb: (chunk: Buffer) => void) => void };
|
||||
stderr: { on: jest.Mock };
|
||||
pid: number;
|
||||
kill: jest.Mock;
|
||||
unref: jest.Mock;
|
||||
exitCode: number | null;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
killed: boolean;
|
||||
};
|
||||
catch: (cb: (err: unknown) => void) => Promise<void>;
|
||||
};
|
||||
let sessionStdout: string;
|
||||
let uptermOnPath = true;
|
||||
|
||||
function makeHostProcess(): typeof hostProcess {
|
||||
return {
|
||||
child: {
|
||||
stdout: {
|
||||
on: jest.fn((_event: string, cb: (chunk: Buffer) => void) => {
|
||||
cb(Buffer.from('host output'));
|
||||
}),
|
||||
},
|
||||
stderr: { on: jest.fn() },
|
||||
pid: 4242,
|
||||
kill: jest.fn(),
|
||||
unref: jest.fn(),
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
killed: false,
|
||||
},
|
||||
catch: cb => {
|
||||
cb(new Error('exited'));
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(env: Record<string, string> = {}): BuildContext {
|
||||
return {
|
||||
logger: createMockLogger(),
|
||||
env,
|
||||
} as unknown as BuildContext;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(process, 'kill').mockReturnValue(true);
|
||||
hostProcess = makeHostProcess();
|
||||
uptermOnPath = true;
|
||||
sessionStdout = JSON.stringify({
|
||||
sessionId: 'TOKENx',
|
||||
host: 'ssh://relay.expo.dev:22',
|
||||
clientCount: 0,
|
||||
});
|
||||
mockedSleep.mockResolvedValue(undefined);
|
||||
mockedFs.mkdir.mockResolvedValue(undefined as never);
|
||||
mockedFs.chmod.mockResolvedValue(undefined as never);
|
||||
mockedFs.mkdtemp.mockImplementation((async (prefix: string) =>
|
||||
String(prefix).includes('eas-upterm') ? '/tmp/eas-upterm-1' : '/tmp/eas-ssh-1') as never);
|
||||
mockedFs.writeFile.mockResolvedValue(undefined as never);
|
||||
mockedFs.rm.mockResolvedValue(undefined as never);
|
||||
mockedFs.readdir.mockResolvedValue(['default.sock'] as never);
|
||||
mockedDownloadFile.mockResolvedValue(undefined as never);
|
||||
mockedSpawn.mockImplementation(((cmd: string, args: string[]) => {
|
||||
if (cmd === 'upterm' && Array.isArray(args) && args[0] === 'version') {
|
||||
if (!uptermOnPath) {
|
||||
return Promise.reject(new Error('not found')) as never;
|
||||
}
|
||||
return Promise.resolve({ stdout: 'upterm version 0.24.0\n', stderr: '' }) as never;
|
||||
}
|
||||
if (Array.isArray(args) && args[0] === 'host') {
|
||||
return hostProcess as never;
|
||||
}
|
||||
if (Array.isArray(args) && args[0] === 'session') {
|
||||
return Promise.resolve({ stdout: sessionStdout, stderr: '' }) as never;
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' }) as never;
|
||||
}) as never);
|
||||
});
|
||||
|
||||
it('uses upterm on PATH, dials, and returns a handle with the parsed config', async () => {
|
||||
const env = { PATH: '/job/bin', CUSTOM: '1' };
|
||||
const host = await startUptermHostAsync(makeCtx(env), {
|
||||
relayServerUrl: 'wss://relay.expo.dev',
|
||||
});
|
||||
|
||||
expect(host.connectionConfig).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENx',
|
||||
});
|
||||
expect(host.isAlive()).toBe(true);
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('upterm', ['version'], {
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
});
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'upterm',
|
||||
expect.arrayContaining(['host']),
|
||||
expect.objectContaining({ env: expect.objectContaining({ CUSTOM: '1', PATH: '/job/bin' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads from GCS into a fresh temp dir when upterm is not on PATH', async () => {
|
||||
uptermOnPath = false;
|
||||
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://relay.expo.dev' });
|
||||
|
||||
expect(host.connectionConfig.secret).toBe('TOKENx');
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/storage\.googleapis\.com\/turtle-v2\/upterm\/upterm-/),
|
||||
expect.stringContaining('/tmp/eas-upterm-1'),
|
||||
expect.objectContaining({ retry: 3 })
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/tmp/eas-upterm-1'),
|
||||
expect.arrayContaining(['host']),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when upterm is missing from PATH and the GCS download fails', async () => {
|
||||
uptermOnPath = false;
|
||||
mockedDownloadFile.mockRejectedValue(new Error('403 Forbidden') as never);
|
||||
|
||||
await expect(startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' })).rejects.toThrow(
|
||||
/could not be downloaded/
|
||||
);
|
||||
});
|
||||
|
||||
it('still throws when download fails with a non-Error and temp cleanup also fails', async () => {
|
||||
uptermOnPath = false;
|
||||
mockedDownloadFile.mockRejectedValue('network down' as never);
|
||||
mockedFs.rm.mockRejectedValue(new Error('EPERM') as never);
|
||||
|
||||
await expect(startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' })).rejects.toThrow(
|
||||
/network down/
|
||||
);
|
||||
});
|
||||
|
||||
it('tears down and rethrows when the host never registers with the relay', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(0);
|
||||
try {
|
||||
mockedFs.readdir.mockResolvedValue([] as never);
|
||||
mockedSleep.mockImplementation(async () => {
|
||||
jest.setSystemTime(Date.now() + 61_000);
|
||||
});
|
||||
|
||||
await expect(startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' })).rejects.toThrow(
|
||||
/did not register/
|
||||
);
|
||||
expect(process.kill).toHaveBeenCalledWith(-4242, 'SIGTERM');
|
||||
expect(mockedFs.rm).toHaveBeenCalledWith('/tmp/eas-ssh-1', expect.anything());
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('stopAsync kills the process and removes the state dir', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
await host.stopAsync();
|
||||
|
||||
expect(process.kill).toHaveBeenCalledWith(-4242, 'SIGTERM');
|
||||
expect(mockedFs.rm).toHaveBeenCalledWith('/tmp/eas-ssh-1', expect.anything());
|
||||
});
|
||||
|
||||
it('falls back to child.kill when process.kill on the process group fails', async () => {
|
||||
jest.spyOn(process, 'kill').mockImplementation(() => {
|
||||
throw new Error('ESRCH');
|
||||
});
|
||||
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
await host.stopAsync();
|
||||
|
||||
expect(hostProcess.child.kill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips process.kill when the host process has no pid', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
hostProcess.child.pid = undefined as unknown as number;
|
||||
await host.stopAsync();
|
||||
|
||||
expect(process.kill).not.toHaveBeenCalled();
|
||||
expect(hostProcess.child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redialAsync re-establishes the connection', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
const config = await host.redialAsync();
|
||||
|
||||
expect(config).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENx',
|
||||
});
|
||||
expect(host.connectionConfig).toEqual(config);
|
||||
});
|
||||
|
||||
it('redialAsync waits for the old process to exit before clearing its socket dir', async () => {
|
||||
const order: string[] = [];
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
|
||||
// Keep the give-up timer pending so only the process exit can unblock the redial.
|
||||
mockedSleep.mockImplementation(() => new Promise<void>(() => {}));
|
||||
let resolveExit: (() => void) | undefined;
|
||||
hostProcess.catch = () =>
|
||||
new Promise<void>(resolve => {
|
||||
resolveExit = () => {
|
||||
order.push('exited');
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
mockedFs.rm.mockImplementation((() => {
|
||||
order.push('rm');
|
||||
return Promise.resolve(undefined);
|
||||
}) as never);
|
||||
jest.spyOn(process, 'kill').mockImplementation(() => {
|
||||
order.push('kill');
|
||||
// A real SIGTERM is asynchronous, so the exit lands after the kill returns.
|
||||
setImmediate(() => resolveExit?.());
|
||||
return true;
|
||||
});
|
||||
|
||||
await host.redialAsync();
|
||||
|
||||
expect(order).toEqual(['kill', 'exited', 'rm']);
|
||||
});
|
||||
|
||||
it('redialAsync gives up waiting when the old process does not exit', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
hostProcess.catch = () => new Promise<void>(() => {});
|
||||
|
||||
await expect(host.redialAsync()).resolves.toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENx',
|
||||
});
|
||||
expect(mockedSleep).toHaveBeenCalledWith(5_000);
|
||||
});
|
||||
|
||||
it('continues when the previous socket dir cannot be cleared', async () => {
|
||||
mockedFs.rm.mockImplementation(((dir: string) =>
|
||||
dir === '/tmp/eas-ssh-1/upterm'
|
||||
? Promise.reject(new Error('busy'))
|
||||
: Promise.resolve(undefined)) as never);
|
||||
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
|
||||
expect(host.connectionConfig.secret).toBe('TOKENx');
|
||||
});
|
||||
|
||||
describe('getConnectedClientCountAsync', () => {
|
||||
it('throws when no admin socket exists yet (after retries)', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
mockedFs.readdir.mockResolvedValue([] as never);
|
||||
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
|
||||
});
|
||||
|
||||
it('returns the client count reported by the admin socket', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
sessionStdout = JSON.stringify({
|
||||
sessionId: 'TOKENx',
|
||||
host: 'relay.expo.dev',
|
||||
clientCount: 4,
|
||||
});
|
||||
expect(await host.getConnectedClientCountAsync()).toBe(4);
|
||||
});
|
||||
|
||||
it('throws when the count query fails (after retries)', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
mockedSpawn.mockImplementation((() => Promise.reject(new Error('socket gone'))) as never);
|
||||
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
|
||||
});
|
||||
|
||||
it('throws when the reported count is not a number (after retries)', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
sessionStdout = JSON.stringify({
|
||||
sessionId: 'TOKENx',
|
||||
host: 'relay.expo.dev',
|
||||
clientCount: 'nope',
|
||||
});
|
||||
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
|
||||
});
|
||||
|
||||
it('throws when the socket dir cannot be read (after retries)', async () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
mockedFs.readdir.mockRejectedValue(new Error('no dir') as never);
|
||||
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { hashFiles } from '@expo/steps';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import * as tar from 'tar';
|
||||
|
||||
export const COCOAPODS_CACHE_KEY_PREFIX = 'ios-pods-';
|
||||
|
||||
const PODS_DIRECTORY_NAME = 'Pods';
|
||||
const PODFILE_LOCK_NAME = 'Podfile.lock';
|
||||
|
||||
export function getCocoapodsCachePaths(workingDirectory: string): {
|
||||
iosDirectory: string;
|
||||
podsDirectory: string;
|
||||
podfileLockPath: string;
|
||||
} {
|
||||
const iosDirectory = path.join(workingDirectory, 'ios');
|
||||
return {
|
||||
iosDirectory,
|
||||
podsDirectory: path.join(iosDirectory, PODS_DIRECTORY_NAME),
|
||||
podfileLockPath: path.join(iosDirectory, PODFILE_LOCK_NAME),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveCocoapodsCacheKeyAsync(
|
||||
workingDirectory: string,
|
||||
cocoapodsVersion: string
|
||||
): Promise<{ key: string; keyPrefix: string }> {
|
||||
const normalizedVersion = cocoapodsVersion.trim();
|
||||
if (!normalizedVersion) {
|
||||
throw new Error('Failed to determine CocoaPods version');
|
||||
}
|
||||
|
||||
const keyPrefix = `${COCOAPODS_CACHE_KEY_PREFIX}${normalizedVersion}-`;
|
||||
const { podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
|
||||
|
||||
try {
|
||||
await fs.promises.access(podfileLockPath);
|
||||
} catch {
|
||||
return { key: keyPrefix, keyPrefix };
|
||||
}
|
||||
|
||||
return {
|
||||
key: `${keyPrefix}${hashFiles([podfileLockPath])}`,
|
||||
keyPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function compressCocoapodsCacheAsync({
|
||||
workingDirectory,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
}): Promise<{ archivePath: string }> {
|
||||
const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
await fs.promises.access(podsDirectory);
|
||||
|
||||
const archiveDestinationDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'save-cocoapods-cache-')
|
||||
);
|
||||
const archivePath = path.join(archiveDestinationDirectory, 'cache.tar.gz');
|
||||
|
||||
await tar.create(
|
||||
{
|
||||
file: archivePath,
|
||||
cwd: iosDirectory,
|
||||
gzip: true,
|
||||
},
|
||||
[PODS_DIRECTORY_NAME]
|
||||
);
|
||||
|
||||
return { archivePath };
|
||||
}
|
||||
|
||||
export async function restoreCocoapodsCacheArchiveAsync({
|
||||
archivePath,
|
||||
workingDirectory,
|
||||
}: {
|
||||
archivePath: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<void> {
|
||||
const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
await fs.promises.mkdir(iosDirectory, { recursive: true });
|
||||
|
||||
const temporaryRestoreDirectory = await fs.promises.mkdtemp(
|
||||
path.join(iosDirectory, '.eas-pods-cache-')
|
||||
);
|
||||
try {
|
||||
await tar.extract({
|
||||
file: archivePath,
|
||||
cwd: temporaryRestoreDirectory,
|
||||
filter: entryPath =>
|
||||
entryPath === PODS_DIRECTORY_NAME || entryPath.startsWith(`${PODS_DIRECTORY_NAME}/`),
|
||||
});
|
||||
|
||||
const restoredPodsDirectory = path.join(temporaryRestoreDirectory, PODS_DIRECTORY_NAME);
|
||||
const restoredPodsStat = await fs.promises.stat(restoredPodsDirectory);
|
||||
if (!restoredPodsStat.isDirectory()) {
|
||||
throw new Error('CocoaPods cache archive does not contain a Pods directory');
|
||||
}
|
||||
|
||||
await fs.promises.rm(podsDirectory, { recursive: true, force: true });
|
||||
await fs.promises.rename(restoredPodsDirectory, podsDirectory);
|
||||
} finally {
|
||||
await fs.promises.rm(temporaryRestoreDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { Sentry } from '../sentry';
|
||||
|
||||
let cachedEasCliVersionsPromise: Promise<EasCliVersions> | undefined;
|
||||
|
||||
const NPX_ENV = { NPM_CONFIG_MIN_RELEASE_AGE: '0' };
|
||||
|
||||
/**
|
||||
* Resolves the `eas-cli` versions to install for staging/production. Prefers the
|
||||
* versions committed to `cli-versions.json` (fetched from expo/eas-cli); on any
|
||||
@@ -74,21 +76,21 @@ export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: {},
|
||||
extraEnv: NPX_ENV,
|
||||
};
|
||||
} else if (process.env.ENVIRONMENT === 'staging') {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
extraEnv: { ...NPX_ENV, EXPO_STAGING: '1' },
|
||||
};
|
||||
} else {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: NPX_ENV,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export function formatSecondsForLog(totalSeconds: number): string {
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const parts: string[] = [];
|
||||
if (hours > 0) {
|
||||
parts.push(hours === 1 ? '1 hour' : `${hours} hours`);
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(minutes === 1 ? '1 minute' : `${minutes} minutes`);
|
||||
}
|
||||
if (seconds > 0 || parts.length === 0) {
|
||||
parts.push(seconds === 1 ? '1 second' : `${seconds} seconds`);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
@@ -1,5 +1,26 @@
|
||||
import { ChildProcess } from 'node:child_process';
|
||||
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
export function isChildProcessAlive(child: ChildProcess): boolean {
|
||||
return child.exitCode === null && child.signalCode === null && !child.killed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a detached spawn's process group. Negated pid targets the group so bash/sleep
|
||||
* children cannot survive after the parent is gone (e.g. across upterm redial).
|
||||
*/
|
||||
export function killProcessGroup(child: ChildProcess): void {
|
||||
if (child.pid == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
async function getChildrenPidsAsync(parentPids: number[]): Promise<number[]> {
|
||||
try {
|
||||
const result = await spawn('pgrep', ['-P', parentPids.join(',')], {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { Job, SshSettings, SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { formatSecondsForLog } from './formatDuration';
|
||||
import { sleepAsync } from './retry';
|
||||
import { SshConnectionConfig, startUptermHostAsync } from './upterm';
|
||||
import { BuildContext } from '../context';
|
||||
import { Sentry } from '../sentry';
|
||||
|
||||
const MAX_SSH_REDIALS = 10;
|
||||
const REDIAL_BACKOFF_MS = 6_000;
|
||||
const CLIENT_COUNT_POLL_INTERVAL_MS = 5_000;
|
||||
const MAX_SSH_IDLE_TIMEOUT_SECONDS = 3600;
|
||||
const DEFAULT_SSH_IDLE_TIMEOUT_SECONDS = 0;
|
||||
|
||||
const CREATE_OR_UPDATE_TURTLE_SSH_SESSION_MUTATION = graphql(`
|
||||
mutation CreateOrUpdateTurtleSshSession(
|
||||
$target: TurtleSshTargetInput!
|
||||
$connectionConfig: TurtleSshConnectionConfigInput!
|
||||
$sessionSettings: TurtleSshSessionSettingsInput!
|
||||
) {
|
||||
turtleSshSession {
|
||||
createOrUpdateTurtleSshSession(
|
||||
target: $target
|
||||
connectionConfig: $connectionConfig
|
||||
sessionSettings: $sessionSettings
|
||||
) {
|
||||
id
|
||||
sessionSettings {
|
||||
idleTimeoutSeconds
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export type TurtleSshTarget = { type: 'BUILD' | 'JOB_RUN'; id: string };
|
||||
|
||||
export function isSshEnabled(job: Pick<Job, 'ssh'>): boolean {
|
||||
return job.ssh != null;
|
||||
}
|
||||
|
||||
export function getSshIdleTimeoutSeconds(job: Pick<Job, 'ssh'>): number {
|
||||
const idleTimeoutSeconds = job.ssh?.idleTimeoutSeconds ?? DEFAULT_SSH_IDLE_TIMEOUT_SECONDS;
|
||||
if (
|
||||
!Number.isInteger(idleTimeoutSeconds) ||
|
||||
idleTimeoutSeconds < 0 ||
|
||||
idleTimeoutSeconds > MAX_SSH_IDLE_TIMEOUT_SECONDS
|
||||
) {
|
||||
throw new SystemError(
|
||||
`SSH idle timeout must be an integer between 0 and ${MAX_SSH_IDLE_TIMEOUT_SECONDS} seconds, got ${idleTimeoutSeconds}.`,
|
||||
{ trackingCode: 'SSH_IDLE_TIMEOUT_INVALID' }
|
||||
);
|
||||
}
|
||||
return idleTimeoutSeconds;
|
||||
}
|
||||
|
||||
export function getSshRelayServerUrl(job: Pick<Job, 'ssh'>): string {
|
||||
const relayServerUrl = job.ssh?.relayServerUrl;
|
||||
if (!relayServerUrl) {
|
||||
throw new SystemError('SSH is enabled but no relay server URL was configured on the job.', {
|
||||
trackingCode: 'SSH_RELAY_SERVER_URL_MISSING',
|
||||
});
|
||||
}
|
||||
return relayServerUrl;
|
||||
}
|
||||
|
||||
export type SshSessionHandle = {
|
||||
getConnectedClientCountAsync: () => Promise<number>;
|
||||
ensureConnectedAsync: () => Promise<void>;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type StartedSshSession = {
|
||||
handle: SshSessionHandle;
|
||||
idleTimeoutSeconds: number;
|
||||
};
|
||||
|
||||
async function createOrUpdateSessionAsync(
|
||||
ctx: BuildContext,
|
||||
{
|
||||
target,
|
||||
connectionConfig,
|
||||
idleTimeoutSeconds,
|
||||
}: {
|
||||
target: TurtleSshTarget;
|
||||
connectionConfig: SshConnectionConfig & { reconnecting?: boolean };
|
||||
idleTimeoutSeconds: number;
|
||||
}
|
||||
): Promise<{ idleTimeoutSeconds: number }> {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_OR_UPDATE_TURTLE_SSH_SESSION_MUTATION, {
|
||||
target,
|
||||
connectionConfig: {
|
||||
...connectionConfig,
|
||||
type: 'UPTERM_V1',
|
||||
},
|
||||
sessionSettings: { idleTimeoutSeconds },
|
||||
})
|
||||
.toPromise();
|
||||
if (result.error || !result.data) {
|
||||
throw new SystemError(
|
||||
`Failed to create or update the SSH session: ${result.error?.message ?? 'no data returned'}`,
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
const session = result.data.turtleSshSession.createOrUpdateTurtleSshSession;
|
||||
return { idleTimeoutSeconds: session.sessionSettings.idleTimeoutSeconds };
|
||||
}
|
||||
|
||||
export async function startSshSessionAsync(
|
||||
ctx: BuildContext,
|
||||
{
|
||||
target,
|
||||
relayServerUrl,
|
||||
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
|
||||
}: {
|
||||
target: TurtleSshTarget;
|
||||
} & SshSettings
|
||||
): Promise<StartedSshSession> {
|
||||
const logger = ctx.logger;
|
||||
const host = await startUptermHostAsync(ctx, { relayServerUrl });
|
||||
let idleTimeoutSeconds: number;
|
||||
try {
|
||||
({ idleTimeoutSeconds } = await createOrUpdateSessionAsync(ctx, {
|
||||
target,
|
||||
connectionConfig: { ...host.connectionConfig, reconnecting: false },
|
||||
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
|
||||
}));
|
||||
} catch (err) {
|
||||
await host.stopAsync().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ensureConnectedAsync = async (): Promise<void> => {
|
||||
if (host.isAlive()) {
|
||||
return;
|
||||
}
|
||||
for (let attempt = 1; attempt <= MAX_SSH_REDIALS; attempt++) {
|
||||
try {
|
||||
if (!host.isAlive()) {
|
||||
logger.warn('The SSH relay connection dropped. Reconnecting...');
|
||||
await createOrUpdateSessionAsync(ctx, {
|
||||
target,
|
||||
connectionConfig: { ...host.connectionConfig, reconnecting: true },
|
||||
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
|
||||
}).catch(() => {});
|
||||
await host.redialAsync();
|
||||
}
|
||||
await createOrUpdateSessionAsync(ctx, {
|
||||
target,
|
||||
connectionConfig: { ...host.connectionConfig, reconnecting: false },
|
||||
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
|
||||
});
|
||||
logger.info('The SSH relay connection was restored.');
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `SSH reconnect attempt ${attempt} of ${MAX_SSH_REDIALS} failed.`);
|
||||
if (attempt < MAX_SSH_REDIALS) {
|
||||
await sleepAsync(REDIAL_BACKOFF_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new SystemError(
|
||||
`The SSH relay connection dropped and could not be restored after ${MAX_SSH_REDIALS} attempts.`
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
handle: {
|
||||
getConnectedClientCountAsync: () => host.getConnectedClientCountAsync(),
|
||||
ensureConnectedAsync,
|
||||
stopAsync: () => host.stopAsync(),
|
||||
},
|
||||
idleTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
export async function superviseSshSessionAsync({
|
||||
handle,
|
||||
idleTimeoutSeconds,
|
||||
hasJobFinished,
|
||||
logger,
|
||||
}: {
|
||||
handle: SshSessionHandle;
|
||||
idleTimeoutSeconds: number;
|
||||
hasJobFinished: () => boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const idleTimeoutMs = idleTimeoutSeconds * 1_000;
|
||||
let idleSince: number | null = null;
|
||||
let previousClientCount = 0;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
await handle.ensureConnectedAsync();
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Could not restore the SSH relay connection. Closing the session.');
|
||||
Sentry.capture(
|
||||
'Could not restore the SSH relay connection',
|
||||
err instanceof Error ? err : undefined,
|
||||
{
|
||||
tags: { trackingCode: 'SSH_RELAY_RECONNECT_FAILED' },
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let connectedClientCount: number;
|
||||
try {
|
||||
connectedClientCount = await handle.getConnectedClientCountAsync();
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Could not read the SSH client count. Closing the session.');
|
||||
Sentry.capture(
|
||||
'Could not read the SSH client count',
|
||||
err instanceof Error ? err : undefined,
|
||||
{
|
||||
tags: { trackingCode: 'SSH_CLIENT_COUNT_UNREADABLE' },
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (connectedClientCount !== previousClientCount) {
|
||||
logger.info(`SSH clients connected: ${connectedClientCount}`);
|
||||
previousClientCount = connectedClientCount;
|
||||
}
|
||||
|
||||
const jobHasFinished = hasJobFinished();
|
||||
if (connectedClientCount > 0 || !jobHasFinished) {
|
||||
idleSince = null;
|
||||
} else if (idleSince === null) {
|
||||
idleSince = Date.now();
|
||||
}
|
||||
|
||||
if (jobHasFinished && connectedClientCount === 0) {
|
||||
if (idleTimeoutSeconds === 0) {
|
||||
logger.info('The job finished and no SSH client is connected. Closing the session.');
|
||||
return;
|
||||
}
|
||||
if (idleSince !== null && Date.now() - idleSince >= idleTimeoutMs) {
|
||||
logger.info(
|
||||
`No SSH client connected for ${formatSecondsForLog(idleTimeoutSeconds)} after the job finished. Closing the session.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await sleepAsync(CLIENT_COUNT_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { Env, SystemError } from '@expo/eas-build-job';
|
||||
import downloadFile from '@expo/downloader';
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isChildProcessAlive, killProcessGroup } from './processes';
|
||||
import { sleepAsync } from './retry';
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
|
||||
|
||||
export function resolveUptermGcsObjectName(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch
|
||||
): string {
|
||||
if (platform === 'darwin' && arch === 'arm64') {
|
||||
return 'upterm-darwin-arm64';
|
||||
}
|
||||
if (platform === 'linux' && arch === 'x64') {
|
||||
return 'upterm-linux-amd64';
|
||||
}
|
||||
throw new SystemError(
|
||||
`SSH upterm is only available on darwin/arm64 and linux/x64 (got ${platform}/${arch}).`
|
||||
);
|
||||
}
|
||||
|
||||
const UPTERM_GCS_BASE_URL = 'https://storage.googleapis.com/turtle-v2/upterm';
|
||||
const UPTERM_DOWNLOAD_TIMEOUT_MS = 60_000;
|
||||
const UPTERM_KEEPALIVE_SLEEP_SECONDS = 6 * 60 * 60;
|
||||
const CONNECTION_POLL_INTERVAL_MS = 500;
|
||||
const CONNECTION_STARTUP_TIMEOUT_MS = 60_000;
|
||||
const PROCESS_EXIT_TIMEOUT_MS = 5_000;
|
||||
const CLIENT_COUNT_READ_ATTEMPTS = 4;
|
||||
const CLIENT_COUNT_READ_RETRY_MS = 500;
|
||||
const DEFAULT_SSH_PORT = '22';
|
||||
|
||||
export type SshConnectionConfig = {
|
||||
type: 'upterm-v1';
|
||||
host: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
export type UptermHost = {
|
||||
connectionConfig: SshConnectionConfig;
|
||||
getConnectedClientCountAsync: () => Promise<number>;
|
||||
isAlive: () => boolean;
|
||||
redialAsync: () => Promise<SshConnectionConfig>;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
const UptermSessionJsonZ = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
host: z.string().min(1),
|
||||
clientCount: z.number().optional(),
|
||||
});
|
||||
type UptermSessionJson = z.infer<typeof UptermSessionJsonZ>;
|
||||
|
||||
export function connectionConfigFromUptermSession(
|
||||
parsed: Pick<UptermSessionJson, 'sessionId' | 'host'>
|
||||
): SshConnectionConfig | null {
|
||||
let host = parsed.host;
|
||||
if (host.includes('://')) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(host);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
host = url.port && url.port !== DEFAULT_SSH_PORT ? `${url.hostname}:${url.port}` : url.hostname;
|
||||
}
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
return { type: 'upterm-v1', host, secret: parsed.sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip upterm session secrets out of text before it reaches a log or an error message. upterm
|
||||
* prints the session id as the userinfo of the connect line it advertises
|
||||
* (`upterm proxy ws(s)://<sessionId>@host`) and repeats it bare as the ssh destination on the
|
||||
* same line. Control characters are dropped first so they cannot split a token mid-match. The
|
||||
* loop lifts the id out of the proxy URL and removes every occurrence of it; the final replace
|
||||
* blanks userinfo in any other URL as a catch-all.
|
||||
*/
|
||||
export function redactConnectionSecrets(text: string): string {
|
||||
let redacted = text.replace(CONTROL_CHARACTERS, '');
|
||||
for (const [, token] of redacted.matchAll(/upterm proxy wss?:\/\/([^@\s]+)@/g)) {
|
||||
redacted = redacted.split(token).join('<redacted>');
|
||||
}
|
||||
return redacted.replace(/([a-z][a-z0-9+.-]*:\/\/)[^@\s/]+@/gi, '$1<redacted>@');
|
||||
}
|
||||
|
||||
export function redactSpawnErrorForLog(err: unknown): unknown {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return err;
|
||||
}
|
||||
const spawnErr = err as { message?: unknown; stdout?: unknown; stderr?: unknown };
|
||||
return {
|
||||
...spawnErr,
|
||||
...(typeof spawnErr.message === 'string'
|
||||
? { message: redactConnectionSecrets(spawnErr.message) }
|
||||
: {}),
|
||||
...(typeof spawnErr.stdout === 'string'
|
||||
? { stdout: redactConnectionSecrets(spawnErr.stdout) }
|
||||
: {}),
|
||||
...(typeof spawnErr.stderr === 'string'
|
||||
? { stderr: redactConnectionSecrets(spawnErr.stderr) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveUptermPathAsync(env: Env): Promise<string> {
|
||||
try {
|
||||
await spawn('upterm', ['version'], { stdio: 'pipe', env });
|
||||
return 'upterm';
|
||||
} catch {}
|
||||
|
||||
const objectName = resolveUptermGcsObjectName();
|
||||
const downloadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-upterm-'));
|
||||
const uptermPath = path.join(downloadDir, objectName);
|
||||
const url = `${UPTERM_GCS_BASE_URL}/${objectName}`;
|
||||
try {
|
||||
await downloadFile(url, uptermPath, { retry: 3, timeout: UPTERM_DOWNLOAD_TIMEOUT_MS });
|
||||
await fs.chmod(uptermPath, 0o755);
|
||||
} catch (err) {
|
||||
await fs.rm(downloadDir, { recursive: true, force: true }).catch(() => {});
|
||||
throw new SystemError(
|
||||
`The upterm SSH client was not on PATH and could not be downloaded from ${url}. ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
);
|
||||
}
|
||||
return uptermPath;
|
||||
}
|
||||
|
||||
async function findAdminSocketPathAsync(uptermSocketDir: string): Promise<string | null> {
|
||||
// Use this dial's own admin socket, not upterm's default, which can still point at a previous
|
||||
// dial's session after a redial and break client-count reads.
|
||||
const entries = await fs.readdir(uptermSocketDir).catch(() => [] as string[]);
|
||||
const socketName = entries.find(entry => entry.endsWith('.sock'));
|
||||
return socketName ? path.join(uptermSocketDir, socketName) : null;
|
||||
}
|
||||
|
||||
async function readCurrentSessionJsonAsync(
|
||||
uptermPath: string,
|
||||
adminSocketPath: string
|
||||
): Promise<UptermSessionJson | null> {
|
||||
try {
|
||||
const result = await spawn(
|
||||
uptermPath,
|
||||
['session', 'current', '--admin-socket', adminSocketPath, '--output', 'json'],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
const parsed = UptermSessionJsonZ.safeParse(JSON.parse(result.stdout));
|
||||
return parsed.success ? parsed.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForConnectionConfigAsync(
|
||||
uptermPath: string,
|
||||
uptermSocketDir: string,
|
||||
getHostOutput: () => string
|
||||
): Promise<SshConnectionConfig> {
|
||||
const deadline = Date.now() + CONNECTION_STARTUP_TIMEOUT_MS;
|
||||
for (;;) {
|
||||
const adminSocketPath = await findAdminSocketPathAsync(uptermSocketDir);
|
||||
if (adminSocketPath) {
|
||||
const session = await readCurrentSessionJsonAsync(uptermPath, adminSocketPath);
|
||||
if (session) {
|
||||
const connectionConfig = connectionConfigFromUptermSession(session);
|
||||
if (connectionConfig) {
|
||||
return connectionConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new SystemError(
|
||||
`The upterm client did not register with the relay within ${
|
||||
CONNECTION_STARTUP_TIMEOUT_MS / 1_000
|
||||
}s. Output:\n${redactConnectionSecrets(getHostOutput())}`
|
||||
);
|
||||
}
|
||||
await sleepAsync(CONNECTION_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startUptermHostAsync(
|
||||
ctx: BuildContext,
|
||||
{ relayServerUrl }: { relayServerUrl: string }
|
||||
): Promise<UptermHost> {
|
||||
const uptermPath = await resolveUptermPathAsync(ctx.env);
|
||||
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-ssh-'));
|
||||
const hostKeyPath = path.join(stateDir, 'id_host');
|
||||
const forceCommandPath = path.join(stateDir, 'join.sh');
|
||||
const uptermSocketDir = path.join(stateDir, 'upterm');
|
||||
|
||||
await spawn('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', hostKeyPath, '-q'], {
|
||||
logger: ctx.logger,
|
||||
});
|
||||
await fs.writeFile(forceCommandPath, '#!/usr/bin/env bash\nexec bash -l\n', { mode: 0o755 });
|
||||
|
||||
let currentProcess: SpawnPromise<SpawnResult> | null = null;
|
||||
|
||||
const stopCurrentProcessAsync = async (): Promise<void> => {
|
||||
const previousProcess = currentProcess;
|
||||
currentProcess = null;
|
||||
if (!previousProcess) {
|
||||
return;
|
||||
}
|
||||
killProcessGroup(previousProcess.child);
|
||||
await Promise.race([
|
||||
previousProcess.catch(() => {}),
|
||||
sleepAsync(PROCESS_EXIT_TIMEOUT_MS).then(() => {
|
||||
ctx.logger.debug('The previous upterm host process did not exit in time.');
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
const getConnectedClientCountAsync = async (): Promise<number> => {
|
||||
for (let attempt = 1; attempt <= CLIENT_COUNT_READ_ATTEMPTS; attempt++) {
|
||||
const adminSocketPath = await findAdminSocketPathAsync(uptermSocketDir);
|
||||
const session = adminSocketPath
|
||||
? await readCurrentSessionJsonAsync(uptermPath, adminSocketPath)
|
||||
: null;
|
||||
if (session && typeof session.clientCount === 'number') {
|
||||
return session.clientCount;
|
||||
}
|
||||
if (attempt < CLIENT_COUNT_READ_ATTEMPTS) {
|
||||
await sleepAsync(CLIENT_COUNT_READ_RETRY_MS);
|
||||
}
|
||||
}
|
||||
throw new SystemError('Could not read the SSH client count from the upterm admin socket.', {
|
||||
trackingCode: 'SSH_CLIENT_COUNT_UNREADABLE',
|
||||
});
|
||||
};
|
||||
|
||||
const dialAsync = async (): Promise<SshConnectionConfig> => {
|
||||
await stopCurrentProcessAsync();
|
||||
await fs.rm(uptermSocketDir, { recursive: true, force: true }).catch(err => {
|
||||
ctx.logger.debug({ err }, 'Failed to clear the previous SSH socket directory.');
|
||||
});
|
||||
|
||||
ctx.logger.debug('Connecting to the SSH relay.');
|
||||
// --force-command is what each connecting SSH client runs (a login shell). The `sleep` after
|
||||
// `--` is the host-side process that keeps `upterm host` up while nobody is connected.
|
||||
const uptermProcess = spawn(
|
||||
uptermPath,
|
||||
[
|
||||
'host',
|
||||
'--server',
|
||||
relayServerUrl,
|
||||
'--accept',
|
||||
'--skip-host-key-check',
|
||||
'-i',
|
||||
hostKeyPath,
|
||||
'--force-command',
|
||||
forceCommandPath,
|
||||
'--',
|
||||
'bash',
|
||||
'-lc',
|
||||
`sleep ${UPTERM_KEEPALIVE_SLEEP_SECONDS}`,
|
||||
],
|
||||
{
|
||||
// upterm puts its admin socket under XDG_RUNTIME_DIR; point it at our state dir so we can
|
||||
// find it for `session current` and clean it up on stop/redial.
|
||||
env: { ...ctx.env, XDG_RUNTIME_DIR: stateDir },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
}
|
||||
);
|
||||
uptermProcess.catch(err =>
|
||||
ctx.logger.debug({ err: redactSpawnErrorForLog(err) }, 'The upterm host process exited.')
|
||||
);
|
||||
uptermProcess.child.unref();
|
||||
currentProcess = uptermProcess;
|
||||
|
||||
let output = '';
|
||||
const appendChunk = (chunk: Buffer | string): void => {
|
||||
output += chunk.toString();
|
||||
};
|
||||
uptermProcess.child.stdout?.on('data', appendChunk);
|
||||
uptermProcess.child.stderr?.on('data', appendChunk);
|
||||
|
||||
return await waitForConnectionConfigAsync(uptermPath, uptermSocketDir, () => output);
|
||||
};
|
||||
|
||||
const stopAsync = async (): Promise<void> => {
|
||||
await stopCurrentProcessAsync();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
let connectionConfig: SshConnectionConfig;
|
||||
try {
|
||||
connectionConfig = await dialAsync();
|
||||
} catch (err) {
|
||||
await stopAsync();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
get connectionConfig() {
|
||||
return connectionConfig;
|
||||
},
|
||||
getConnectedClientCountAsync,
|
||||
isAlive: () => currentProcess != null && isChildProcessAlive(currentProcess.child),
|
||||
redialAsync: async () => {
|
||||
connectionConfig = await dialAsync();
|
||||
return connectionConfig;
|
||||
},
|
||||
stopAsync,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "22.4.0",
|
||||
"version": "23.0.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "22.4.0"
|
||||
"@expo/steps": "23.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "22.0.0",
|
||||
"version": "23.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "22.0.0",
|
||||
"version": "23.0.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "22.0.0",
|
||||
"version": "23.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/logger": "23.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/turtle-spawn": "23.0.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
+162
-145
File diff suppressed because it is too large.
Load diff
Generated
+551
-23
@@ -14158,6 +14158,35 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "customEvent",
|
||||
"description": "A single custom event by the `id` of an `AppObserveCustomEvent`. Null when the app has no\nsuch event, including when it has aged out of retention.",
|
||||
"args": [
|
||||
{
|
||||
"name": "id",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "OBJECT",
|
||||
"name": "AppObserveCustomEvent",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "customEventCounts",
|
||||
"description": null,
|
||||
@@ -14396,6 +14425,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Filter to these platforms. Cannot be set together with platform.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "startTime",
|
||||
"description": null,
|
||||
@@ -14453,6 +14502,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Filter to these platforms. Cannot be set together with platform.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "startTime",
|
||||
"description": null,
|
||||
@@ -14618,6 +14687,35 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "event",
|
||||
"description": "A single metric event by the `id` of an `AppObserveEvent`. Null when the app has no such\nevent, including when it has aged out of retention or the id is not one this API issued.\n\n`sessionEventCount` and `userEventCount` are always null here: they are aggregates over a\ntime range, and a single event does not supply one.",
|
||||
"args": [
|
||||
{
|
||||
"name": "id",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "OBJECT",
|
||||
"name": "AppObserveEvent",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "events",
|
||||
"description": null,
|
||||
@@ -15028,7 +15126,7 @@
|
||||
},
|
||||
{
|
||||
"name": "updates",
|
||||
"description": null,
|
||||
"description": "Update download activity (the Recent updates list). Not available on the free tier or legacy plans.",
|
||||
"args": [
|
||||
{
|
||||
"name": "input",
|
||||
@@ -16394,12 +16492,28 @@
|
||||
"name": "platform",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Filter to these platforms. Cannot be set together with platform. One of platform or platforms is required.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
@@ -16659,6 +16773,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Filter to these platforms. Cannot be set together with platform.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "propertyFilters",
|
||||
"description": null,
|
||||
@@ -17524,6 +17658,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Filter to these platforms. Cannot be set together with platform.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "startTime",
|
||||
"description": null,
|
||||
@@ -17797,6 +17951,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Filter to these platforms. Cannot be set together with platform.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "severity",
|
||||
"description": "Restrict to fatal-only or non-fatal-only errors.",
|
||||
@@ -18268,6 +18442,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Filter to these platforms. Cannot be set together with platform.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "startTime",
|
||||
"description": null,
|
||||
@@ -18561,6 +18755,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Filter to these platforms. Cannot be set together with platform.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "startTime",
|
||||
"description": null,
|
||||
@@ -19312,6 +19526,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Filter to these platforms. Cannot be set together with platform.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "routeName",
|
||||
"description": null,
|
||||
@@ -19705,6 +19939,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Filter to these platforms. Cannot be set together with platform.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "routeNames",
|
||||
"description": null,
|
||||
@@ -20094,6 +20348,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Filter to these platforms. Cannot be set together with platform.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "startTime",
|
||||
"description": null,
|
||||
@@ -20470,6 +20744,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Filter to these platforms. Cannot be set together with platform.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "startTime",
|
||||
"description": null,
|
||||
@@ -20510,7 +20804,7 @@
|
||||
},
|
||||
{
|
||||
"name": "downloadCount",
|
||||
"description": "Downloads recorded in range across platforms; null when none were recorded (and always for the embedded bundle).",
|
||||
"description": "Downloads recorded in range across platforms; null when none were recorded\n(and always for the embedded bundle). Download data is paid-only: on the\nfree tier and legacy plans this is always null.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
@@ -20522,7 +20816,7 @@
|
||||
},
|
||||
{
|
||||
"name": "downloads",
|
||||
"description": "One entry per platform with downloads in range; empty for the embedded bundle.",
|
||||
"description": "One entry per platform with downloads in range; empty for the embedded bundle and on the free tier and legacy plans.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -21499,6 +21793,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Filter to these platforms. Cannot be set together with platform.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "startTime",
|
||||
"description": null,
|
||||
@@ -21896,6 +22210,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Filter to these platforms. Cannot be set together with platform.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "routeName",
|
||||
"description": null,
|
||||
@@ -22403,6 +22737,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Filter to these platforms. Cannot be set together with platform.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "AppObservePlatform",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "startTime",
|
||||
"description": null,
|
||||
@@ -37355,6 +37709,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "resourceClass",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
"name": "DeviceRunSessionResourceClass",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "sdkVersion",
|
||||
"description": "Expo SDK version used to select an Expo Go application archive. Only supported when expoGo is\ntrue. If omitted, the current Expo Go archive for the platform is used.",
|
||||
@@ -42847,6 +43213,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "applicationArchiveUrl",
|
||||
"description": "Direct application archive URL installed and launched for this session. Null when\nthe session uses an EAS Build or starts without an app.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "artifacts",
|
||||
"description": null,
|
||||
@@ -42871,6 +43249,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "build",
|
||||
"description": "EAS Build installed and launched for this session. Null when the session uses\na direct application archive URL or starts without an app.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "OBJECT",
|
||||
"name": "Build",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "createdAt",
|
||||
"description": null,
|
||||
@@ -43724,6 +44114,29 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "DeviceRunSessionResourceClass",
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": null,
|
||||
"interfaces": null,
|
||||
"enumValues": [
|
||||
{
|
||||
"name": "LARGE",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "MEDIUM",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "DeviceRunSessionStatus",
|
||||
@@ -75023,24 +75436,16 @@
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "turtleBuildId",
|
||||
"name": "target",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "turtleJobRunId",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "TurtleSshTargetInput",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
@@ -75119,6 +75524,72 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "TurtleSshTargetInput",
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "id",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "TurtleSshTargetType",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"interfaces": null,
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "TurtleSshTargetType",
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": null,
|
||||
"interfaces": null,
|
||||
"enumValues": [
|
||||
{
|
||||
"name": "BUILD",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "JOB_RUN",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "TurtleSshTransportType",
|
||||
@@ -94676,6 +95147,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "cancelReason",
|
||||
"description": "Why the server canceled this run. Null for manually canceled runs and for\nruns canceled before the reason was recorded.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "UNION",
|
||||
"name": "WorkflowRunCancelReason",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "createdAt",
|
||||
"description": null,
|
||||
@@ -95040,6 +95523,45 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "UNION",
|
||||
"name": "WorkflowRunCancelReason",
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": null,
|
||||
"interfaces": null,
|
||||
"enumValues": null,
|
||||
"possibleTypes": [
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowRunCancelReasonConcurrencyGroup",
|
||||
"ofType": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowRunCancelReasonConcurrencyGroup",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "cancelingWorkflowRun",
|
||||
"description": "The newer run in the same concurrency group whose creation canceled this run.\nNull if that run no longer exists.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowRun",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowRunEdge",
|
||||
@@ -95621,6 +96143,12 @@
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "WAITING",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "22.4.0",
|
||||
"version": "23.0.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,11 +48,11 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/eas-json": "22.0.0",
|
||||
"@expo/eas-build-job": "23.0.0",
|
||||
"@expo/eas-json": "23.0.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/logger": "23.0.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "22.4.0",
|
||||
"@expo/steps": "23.0.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -122,7 +122,13 @@ describe(SimulatorGet, () => {
|
||||
}
|
||||
|
||||
it('emits JSON when --json is passed', async () => {
|
||||
const session = makeDeviceRunSession();
|
||||
const session = makeDeviceRunSession({
|
||||
type: DeviceRunSessionType.ServeSim,
|
||||
remoteConfig: {
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewUrl: 'https://preview.example.com',
|
||||
},
|
||||
});
|
||||
mockByIdAsync.mockResolvedValue(session);
|
||||
|
||||
const { command, getContextAsync } = createCommand(['--id', 'session-123', '--json']);
|
||||
@@ -137,7 +143,7 @@ describe(SimulatorGet, () => {
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
name: undefined,
|
||||
type: 'agent-device',
|
||||
type: 'web-preview-only',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
platform: AppPlatform.Ios,
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AppPlatform,
|
||||
CreateDeviceRunSessionMutation,
|
||||
DeviceRunSessionByIdQuery,
|
||||
DeviceRunSessionResourceClass,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
JobRunStatus,
|
||||
@@ -331,6 +332,33 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a ServeSim session for --type web-preview-only', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
type: DeviceRunSessionType.ServeSim,
|
||||
remoteConfig: {
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewUrl: 'https://preview.example.test',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--type',
|
||||
'web-preview-only',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ type: DeviceRunSessionType.ServeSim })
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('https://preview.example.test'));
|
||||
});
|
||||
|
||||
it('overwrites .env.eas-simulator when outputting dotenv and the file exists', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
@@ -396,6 +424,26 @@ describe(Simulator, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes --max-idle-time-minutes to the createDeviceRunSession mutation', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--max-idle-time-minutes',
|
||||
'30',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'project-123',
|
||||
name: undefined,
|
||||
packageVersion: undefined,
|
||||
platform: AppPlatform.Ios,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
maxIdleTimeMinutes: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it(`throws when ${EAS_SIMULATOR_SESSION_ID} is already present with --no-force`, async () => {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'existing-session';
|
||||
|
||||
@@ -502,6 +550,35 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('omits resourceClass when --resource-class is not set', async () => {
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1]).not.toHaveProperty('resourceClass');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['large', DeviceRunSessionResourceClass.Large],
|
||||
['medium', DeviceRunSessionResourceClass.Medium],
|
||||
] as const)(
|
||||
'forwards --resource-class %s to the create mutation',
|
||||
async (flag, resourceClass) => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--resource-class',
|
||||
flag,
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ resourceClass })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('forwards --build-id to the create mutation', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
|
||||
@@ -111,7 +111,7 @@ describe(SimulatorList, () => {
|
||||
}
|
||||
|
||||
it('emits JSON when --json is passed', async () => {
|
||||
const session = makeSession();
|
||||
const session = makeSession({ type: DeviceRunSessionType.ServeSim });
|
||||
mockListByAppIdAsync.mockResolvedValue(makeConnection([session]));
|
||||
|
||||
const { command, getContextAsync } = createCommand(['--json']);
|
||||
@@ -132,7 +132,7 @@ describe(SimulatorList, () => {
|
||||
{
|
||||
id: 'session-123',
|
||||
name: undefined,
|
||||
type: 'agent-device',
|
||||
type: 'web-preview-only',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
platform: AppPlatform.Ios,
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
@@ -162,6 +162,8 @@ describe(SimulatorList, () => {
|
||||
'new',
|
||||
'--type',
|
||||
'appium',
|
||||
'--type',
|
||||
'web-preview-only',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--name',
|
||||
@@ -179,7 +181,7 @@ describe(SimulatorList, () => {
|
||||
after: 'page-cursor',
|
||||
filter: {
|
||||
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
|
||||
types: [DeviceRunSessionType.Appium],
|
||||
types: [DeviceRunSessionType.Appium, DeviceRunSessionType.ServeSim],
|
||||
platforms: [AppPlatform.Ios],
|
||||
name: 'checkout',
|
||||
},
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
} from '../../simulator/env';
|
||||
import { resolveExpoGoSdkVersionAsync } from '../../simulator/expoGo';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_RESOURCE_CLASS_FLAG_VALUES,
|
||||
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
DeviceRunSessionRemoteConfig,
|
||||
@@ -101,7 +103,8 @@ export default class Simulator extends EasCommand {
|
||||
'Expo or development-client URL to open in the installed application after it launches.',
|
||||
}),
|
||||
type: Flags.option({
|
||||
description: 'Type of simulator session to create',
|
||||
description:
|
||||
'Type of simulator session to create. All session types include a web preview. agent-device, appium, and argent also include an automation interface; web-preview-only includes no automation interface.',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
default: DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice],
|
||||
})(),
|
||||
@@ -114,6 +117,16 @@ export default class Simulator extends EasCommand {
|
||||
'Maximum duration of the simulator session in minutes before it is automatically stopped. Only customizable on paid plans. Defaults to a value derived from the job run priority when omitted.',
|
||||
min: 0,
|
||||
}),
|
||||
'max-idle-time-minutes': Flags.integer({
|
||||
description:
|
||||
'Stop the simulator session automatically after this many minutes without session activity. When omitted, the session has no idle timeout and runs until its maximum duration.',
|
||||
min: 0,
|
||||
}),
|
||||
'resource-class': Flags.option({
|
||||
description: 'The instance type that will be used to run this simulator session',
|
||||
hidden: true,
|
||||
options: Object.values(DEVICE_RUN_SESSION_RESOURCE_CLASS_FLAG_VALUES),
|
||||
})(),
|
||||
force: Flags.boolean({
|
||||
description:
|
||||
'[default: true] Create a new simulator session even when an existing simulator session is present in the environment.',
|
||||
@@ -172,6 +185,9 @@ export default class Simulator extends EasCommand {
|
||||
const sdkVersionFromFlag = flags['sdk-version']?.trim() || undefined;
|
||||
const launchArgs = flags['launch-arg'];
|
||||
const openUrl = flags['open-url']?.trim() || undefined;
|
||||
const resourceClass = flags['resource-class']
|
||||
? DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE[flags['resource-class']]
|
||||
: undefined;
|
||||
|
||||
if (sdkVersionFromFlag && !flags['expo-go']) {
|
||||
throw new EasCommandError('The --sdk-version flag can only be used with --expo-go.');
|
||||
@@ -228,7 +244,9 @@ export default class Simulator extends EasCommand {
|
||||
...(expoGoSdkVersion ? { expoGo: true, sdkVersion: expoGoSdkVersion } : {}),
|
||||
...(launchArgs?.length ? { launchArgs } : {}),
|
||||
...(openUrl ? { openUrl } : {}),
|
||||
...(resourceClass ? { resourceClass } : {}),
|
||||
maxRunTimeMinutes: flags['max-duration-minutes'],
|
||||
maxIdleTimeMinutes: flags['max-idle-time-minutes'],
|
||||
});
|
||||
deviceRunSessionId = session.id;
|
||||
nullthrows(session.turtleJobRun?.id, 'Expected simulator session to start');
|
||||
|
||||
@@ -61,7 +61,8 @@ export default class SimulatorList extends EasCommand {
|
||||
multiple: true,
|
||||
})(),
|
||||
type: Flags.option({
|
||||
description: 'Filter by session type (repeatable)',
|
||||
description:
|
||||
'Filter by session type (repeatable). All session types include a web preview. agent-device, appium, and argent also include an automation interface; web-preview-only includes no automation interface.',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
multiple: true,
|
||||
})(),
|
||||
|
||||
+104
-6
@@ -2173,6 +2173,11 @@ export type AppNotificationPreferenceInput = {
|
||||
export type AppObserve = {
|
||||
__typename?: 'AppObserve';
|
||||
appVersions: Array<AppObserveAppVersion>;
|
||||
/**
|
||||
* A single custom event by the `id` of an `AppObserveCustomEvent`. Null when the app has no
|
||||
* such event, including when it has aged out of retention.
|
||||
*/
|
||||
customEvent?: Maybe<AppObserveCustomEvent>;
|
||||
customEventCounts: AppObserveCustomEventCounts;
|
||||
customEventList: AppObserveCustomEventListConnection;
|
||||
customEventNames: AppObserveCustomEventNames;
|
||||
@@ -2185,6 +2190,14 @@ export type AppObserve = {
|
||||
errorStats: AppObserveErrorStats;
|
||||
/** Time-bucketed exception counts split into fatal vs non-fatal, for the stacked-bar chart. */
|
||||
errorTimeSeries: AppObserveErrorTimeSeries;
|
||||
/**
|
||||
* A single metric event by the `id` of an `AppObserveEvent`. Null when the app has no such
|
||||
* event, including when it has aged out of retention or the id is not one this API issued.
|
||||
*
|
||||
* `sessionEventCount` and `userEventCount` are always null here: they are aggregates over a
|
||||
* time range, and a single event does not supply one.
|
||||
*/
|
||||
event?: Maybe<AppObserveEvent>;
|
||||
events: AppObserveEventsConnection;
|
||||
/** Highest Expo SDK version seen in telemetry over the trailing 30 days; null when none was reported. */
|
||||
latestExpoSdkVersion?: Maybe<Scalars['String']['output']>;
|
||||
@@ -2208,6 +2221,7 @@ export type AppObserve = {
|
||||
* universe used by `appVersions` headline counts.
|
||||
*/
|
||||
uniqueActiveUserCount: Scalars['Int']['output'];
|
||||
/** Update download activity (the Recent updates list). Not available on the free tier or legacy plans. */
|
||||
updates: AppObserveUpdatesConnection;
|
||||
};
|
||||
|
||||
@@ -2217,6 +2231,11 @@ export type AppObserveAppVersionsArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveCustomEventArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveCustomEventCountsArgs = {
|
||||
input: AppObserveCustomEventCountsInput;
|
||||
};
|
||||
@@ -2242,6 +2261,7 @@ export type AppObserveCustomEventNamesArgs = {
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
orderBy?: InputMaybe<AppObserveCustomEventNamesOrderBy>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
@@ -2249,6 +2269,7 @@ export type AppObserveCustomEventNamesArgs = {
|
||||
export type AppObserveEnvironmentsArgs = {
|
||||
endTime?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
startTime?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
};
|
||||
|
||||
@@ -2273,6 +2294,11 @@ export type AppObserveErrorTimeSeriesArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveEventArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveEventsArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -2479,7 +2505,9 @@ export type AppObserveCustomEventCountsInput = {
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
eventName: Scalars['String']['input'];
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
platform: AppObservePlatform;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
/** Filter to these platforms. Cannot be set together with platform. One of platform or platforms is required. */
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
@@ -2508,6 +2536,8 @@ export type AppObserveCustomEventListFilter = {
|
||||
eventName?: InputMaybe<Scalars['String']['input']>;
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
/** Filter to these platforms. Cannot be set together with platform. */
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
propertyFilters?: InputMaybe<Array<AppObserveCustomEventPropertyFilter>>;
|
||||
sessionId?: InputMaybe<Scalars['String']['input']>;
|
||||
startTime?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
@@ -2615,6 +2645,8 @@ export type AppObserveErrorGroupBreakdownInput = {
|
||||
fingerprint: Scalars['String']['input'];
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
/** Filter to these platforms. Cannot be set together with platform. */
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
@@ -2647,6 +2679,8 @@ export type AppObserveErrorGroupsInput = {
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
orderBy?: InputMaybe<AppObserveErrorGroupsOrderBy>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
/** Filter to these platforms. Cannot be set together with platform. */
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
/** Restrict to fatal-only or non-fatal-only errors. */
|
||||
severity?: InputMaybe<AppObserveErrorSeverity>;
|
||||
/** Restrict to a capture source (e.g. global). */
|
||||
@@ -2710,6 +2744,8 @@ export type AppObserveErrorStatsInput = {
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
/** Filter to these platforms. Cannot be set together with platform. */
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
@@ -2746,6 +2782,8 @@ export type AppObserveErrorTimeSeriesInput = {
|
||||
fingerprint?: InputMaybe<Scalars['String']['input']>;
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
/** Filter to these platforms. Cannot be set together with platform. */
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
@@ -2817,6 +2855,8 @@ export type AppObserveEventsFilter = {
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
metricName?: InputMaybe<Scalars['String']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
/** Filter to these platforms. Cannot be set together with platform. */
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
routeName?: InputMaybe<Scalars['String']['input']>;
|
||||
sessionId?: InputMaybe<Scalars['String']['input']>;
|
||||
startTime?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
@@ -2871,6 +2911,8 @@ export type AppObserveNavigationRoutesFilter = {
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
/** Filter to these platforms. Cannot be set together with platform. */
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
routeNames?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
@@ -2924,6 +2966,8 @@ export type AppObserveOverviewEngagementInput = {
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
/** Filter to these platforms. Cannot be set together with platform. */
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
@@ -2971,6 +3015,8 @@ export type AppObserveOverviewStabilityInput = {
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
/** Filter to these platforms. Cannot be set together with platform. */
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
@@ -2978,9 +3024,13 @@ export type AppObserveOverviewUpdate = {
|
||||
__typename?: 'AppObserveOverviewUpdate';
|
||||
/** Null for the embedded bundle. */
|
||||
appUpdateId?: Maybe<Scalars['ID']['output']>;
|
||||
/** Downloads recorded in range across platforms; null when none were recorded (and always for the embedded bundle). */
|
||||
/**
|
||||
* Downloads recorded in range across platforms; null when none were recorded
|
||||
* (and always for the embedded bundle). Download data is paid-only: on the
|
||||
* free tier and legacy plans this is always null.
|
||||
*/
|
||||
downloadCount?: Maybe<Scalars['Int']['output']>;
|
||||
/** One entry per platform with downloads in range; empty for the embedded bundle. */
|
||||
/** One entry per platform with downloads in range; empty for the embedded bundle and on the free tier and legacy plans. */
|
||||
downloads: Array<AppObserveOverviewUpdateDownload>;
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
@@ -3092,6 +3142,8 @@ export type AppObserveReleasesInput = {
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
metricNames?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
/** Filter to these platforms. Cannot be set together with platform. */
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
@@ -3129,6 +3181,8 @@ export type AppObserveTimeSeriesInput = {
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
metricName: Scalars['String']['input'];
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
/** Filter to these platforms. Cannot be set together with platform. */
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
routeName?: InputMaybe<Scalars['String']['input']>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
@@ -3180,6 +3234,8 @@ export type AppObserveUpdatesInput = {
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
orderBy?: InputMaybe<AppObserveUpdatesOrderBy>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
/** Filter to these platforms. Cannot be set together with platform. */
|
||||
platforms?: InputMaybe<Array<AppObservePlatform>>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
@@ -5293,6 +5349,7 @@ export type CreateDeviceRunSessionInput = {
|
||||
*/
|
||||
packageVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
platform: AppPlatform;
|
||||
resourceClass?: InputMaybe<DeviceRunSessionResourceClass>;
|
||||
/**
|
||||
* Expo SDK version used to select an Expo Go application archive. Only supported when expoGo is
|
||||
* true. If omitted, the current Expo Go archive for the platform is used.
|
||||
@@ -6043,7 +6100,17 @@ export type DeploymentsMutationDeleteWorkerDeploymentByIdentifierArgs = {
|
||||
export type DeviceRunSession = {
|
||||
__typename?: 'DeviceRunSession';
|
||||
app: App;
|
||||
/**
|
||||
* Direct application archive URL installed and launched for this session. Null when
|
||||
* the session uses an EAS Build or starts without an app.
|
||||
*/
|
||||
applicationArchiveUrl?: Maybe<Scalars['String']['output']>;
|
||||
artifacts: Array<DeviceRunSessionArtifact>;
|
||||
/**
|
||||
* EAS Build installed and launched for this session. Null when the session uses
|
||||
* a direct application archive URL or starts without an app.
|
||||
*/
|
||||
build?: Maybe<Build>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
finishedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
@@ -6176,6 +6243,11 @@ export type DeviceRunSessionQueryByIdArgs = {
|
||||
|
||||
export type DeviceRunSessionRemoteConfig = AgentDeviceRunSessionRemoteConfig | AppiumRunSessionRemoteConfig | ArgentRunSessionRemoteConfig | ServeSimRunSessionRemoteConfig;
|
||||
|
||||
export enum DeviceRunSessionResourceClass {
|
||||
Large = 'LARGE',
|
||||
Medium = 'MEDIUM'
|
||||
}
|
||||
|
||||
export enum DeviceRunSessionStatus {
|
||||
Errored = 'ERRORED',
|
||||
InProgress = 'IN_PROGRESS',
|
||||
@@ -10644,8 +10716,7 @@ export type TurtleSshSessionMutation = {
|
||||
export type TurtleSshSessionMutationCreateOrUpdateTurtleSshSessionArgs = {
|
||||
connectionConfig: TurtleSshConnectionConfigInput;
|
||||
sessionSettings: TurtleSshSessionSettingsInput;
|
||||
turtleBuildId?: InputMaybe<Scalars['ID']['input']>;
|
||||
turtleJobRunId?: InputMaybe<Scalars['ID']['input']>;
|
||||
target: TurtleSshTargetInput;
|
||||
};
|
||||
|
||||
export type TurtleSshSessionSettings = {
|
||||
@@ -10657,6 +10728,16 @@ export type TurtleSshSessionSettingsInput = {
|
||||
idleTimeoutSeconds: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
export type TurtleSshTargetInput = {
|
||||
id: Scalars['ID']['input'];
|
||||
type: TurtleSshTargetType;
|
||||
};
|
||||
|
||||
export enum TurtleSshTargetType {
|
||||
Build = 'BUILD',
|
||||
JobRun = 'JOB_RUN'
|
||||
}
|
||||
|
||||
export enum TurtleSshTransportType {
|
||||
UptermV1 = 'UPTERM_V1'
|
||||
}
|
||||
@@ -13277,6 +13358,11 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
|
||||
__typename?: 'WorkflowRun';
|
||||
activityTimestamp: Scalars['DateTime']['output'];
|
||||
actor?: Maybe<Actor>;
|
||||
/**
|
||||
* Why the server canceled this run. Null for manually canceled runs and for
|
||||
* runs canceled before the reason was recorded.
|
||||
*/
|
||||
cancelReason?: Maybe<WorkflowRunCancelReason>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
durationSeconds?: Maybe<Scalars['Int']['output']>;
|
||||
errors: Array<WorkflowRunError>;
|
||||
@@ -13303,6 +13389,17 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
|
||||
workflowRevision?: Maybe<WorkflowRevision>;
|
||||
};
|
||||
|
||||
export type WorkflowRunCancelReason = WorkflowRunCancelReasonConcurrencyGroup;
|
||||
|
||||
export type WorkflowRunCancelReasonConcurrencyGroup = {
|
||||
__typename?: 'WorkflowRunCancelReasonConcurrencyGroup';
|
||||
/**
|
||||
* The newer run in the same concurrency group whose creation canceled this run.
|
||||
* Null if that run no longer exists.
|
||||
*/
|
||||
cancelingWorkflowRun?: Maybe<WorkflowRun>;
|
||||
};
|
||||
|
||||
export type WorkflowRunEdge = {
|
||||
__typename?: 'WorkflowRunEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
@@ -13392,7 +13489,8 @@ export enum WorkflowRunStatus {
|
||||
Failure = 'FAILURE',
|
||||
InProgress = 'IN_PROGRESS',
|
||||
New = 'NEW',
|
||||
Success = 'SUCCESS'
|
||||
Success = 'SUCCESS',
|
||||
Waiting = 'WAITING'
|
||||
}
|
||||
|
||||
export type WorkflowRunTimeRangeInput = {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { DeviceRunSessionType } from '../../graphql/generated';
|
||||
import { DeviceRunSessionResourceClass, DeviceRunSessionType } from '../../graphql/generated';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
deviceRunSessionTypeToFlagValue,
|
||||
formatRemoteSessionInstructions,
|
||||
getRemoteSessionEnvironmentVariables,
|
||||
} from '../utils';
|
||||
@@ -37,3 +40,28 @@ describe('Appium simulator configuration', () => {
|
||||
expect(instructions).not.toContain('https://appium.example.test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('simulator session type flags', () => {
|
||||
it('maps web-preview-only to the ServeSim GraphQL enum', () => {
|
||||
expect(DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE['web-preview-only']).toBe(
|
||||
DeviceRunSessionType.ServeSim
|
||||
);
|
||||
expect(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.ServeSim]).toBe(
|
||||
'web-preview-only'
|
||||
);
|
||||
expect(deviceRunSessionTypeToFlagValue(DeviceRunSessionType.ServeSim)).toBe('web-preview-only');
|
||||
expect(DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE['serve-sim']).toBeUndefined();
|
||||
expect(DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE['web-preview']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('simulator resource class flags', () => {
|
||||
it('maps CLI values to the GraphQL enum', () => {
|
||||
expect(DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE.large).toBe(
|
||||
DeviceRunSessionResourceClass.Large
|
||||
);
|
||||
expect(DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE.medium).toBe(
|
||||
DeviceRunSessionResourceClass.Medium
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { DeviceRunSessionByIdQuery, DeviceRunSessionType } from '../graphql/generated';
|
||||
import {
|
||||
DeviceRunSessionByIdQuery,
|
||||
DeviceRunSessionResourceClass,
|
||||
DeviceRunSessionType,
|
||||
} from '../graphql/generated';
|
||||
import { link } from '../log';
|
||||
|
||||
type DeviceRunSessionByIdResult = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
@@ -24,7 +28,7 @@ export const DEVICE_RUN_SESSION_TYPE_FLAG_VALUES: Record<DeviceRunSessionType, s
|
||||
[DeviceRunSessionType.AgentDevice]: 'agent-device',
|
||||
[DeviceRunSessionType.Appium]: 'appium',
|
||||
[DeviceRunSessionType.Argent]: 'argent',
|
||||
[DeviceRunSessionType.ServeSim]: 'serve-sim',
|
||||
[DeviceRunSessionType.ServeSim]: 'web-preview-only',
|
||||
};
|
||||
|
||||
export const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
|
||||
@@ -37,6 +41,23 @@ export function deviceRunSessionTypeToFlagValue(type: DeviceRunSessionType): str
|
||||
return DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[type];
|
||||
}
|
||||
|
||||
export const DEVICE_RUN_SESSION_RESOURCE_CLASS_FLAG_VALUES: Record<
|
||||
DeviceRunSessionResourceClass,
|
||||
string
|
||||
> = {
|
||||
[DeviceRunSessionResourceClass.Large]: 'large',
|
||||
[DeviceRunSessionResourceClass.Medium]: 'medium',
|
||||
};
|
||||
|
||||
export const DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE = Object.fromEntries(
|
||||
(
|
||||
Object.entries(DEVICE_RUN_SESSION_RESOURCE_CLASS_FLAG_VALUES) as [
|
||||
DeviceRunSessionResourceClass,
|
||||
string,
|
||||
][]
|
||||
).map(([resourceClass, value]) => [value, resourceClass])
|
||||
) as Record<string, DeviceRunSessionResourceClass>;
|
||||
|
||||
export function getRemoteSessionEnvironmentVariables(
|
||||
remoteConfig: DeviceRunSessionRemoteConfig
|
||||
): Record<string, string> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "22.0.0",
|
||||
"version": "23.0.0",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/eas-build-job": "23.0.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "22.4.0",
|
||||
"version": "23.0.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,11 +26,11 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "22.4.0",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/build-tools": "23.0.0",
|
||||
"@expo/eas-build-job": "23.0.0",
|
||||
"@expo/logger": "23.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/turtle-spawn": "23.0.0",
|
||||
"bunyan": "^1.8.15",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "22.0.0",
|
||||
"version": "23.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "22.4.0",
|
||||
"version": "23.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,8 +27,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/eas-build-job": "23.0.0",
|
||||
"@expo/logger": "23.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/template-file",
|
||||
"version": "22.0.0",
|
||||
"version": "23.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/turtle-spawn",
|
||||
"version": "22.0.0",
|
||||
"version": "23.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/logger": "23.0.0",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -17,6 +17,8 @@ jest.mock('node:fs', () => jest.requireMock('fs'));
|
||||
jest.mock('@expo/build-tools', () => {
|
||||
return {
|
||||
...jest.requireActual('@expo/build-tools'),
|
||||
downloadCacheAsync: jest.fn(),
|
||||
uploadCacheAsync: jest.fn(),
|
||||
uploadWithSignedUrl: jest.fn(),
|
||||
};
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "22.4.0",
|
||||
"version": "23.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,23 +19,23 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "22.4.0",
|
||||
"@expo/build-tools": "23.0.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "22.0.0",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/eas-build-job": "23.0.0",
|
||||
"@expo/logger": "23.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "22.4.0",
|
||||
"@expo/template-file": "22.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/steps": "23.0.0",
|
||||
"@expo/template-file": "23.0.0",
|
||||
"@expo/turtle-spawn": "23.0.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"check-disk-space": "3.4.0",
|
||||
"fast-glob": "3.3.2",
|
||||
"filesize": "7.0.0",
|
||||
"fs-extra": "11.2.0",
|
||||
"json-stable-stringify": "1.3.0",
|
||||
"koa": "3.1.2",
|
||||
"koa-body": "7.0.1",
|
||||
"koa-router": "14.0.0",
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { BuildContext, CacheManager, uploadWithSignedUrl } from '@expo/build-tools';
|
||||
import downloadFile from '@expo/downloader';
|
||||
import {
|
||||
BuildContext,
|
||||
CacheManager,
|
||||
downloadCacheAsync,
|
||||
uploadCacheAsync,
|
||||
} from '@expo/build-tools';
|
||||
import { BuildJob } from '@expo/eas-build-job';
|
||||
import filesize from 'filesize';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs-extra';
|
||||
import stringify from 'json-stable-stringify';
|
||||
import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
import * as tar from 'tar';
|
||||
|
||||
import config from './config';
|
||||
|
||||
type ContentLengthRange = { minSizeBytes: number; maxSizeBytes: number };
|
||||
|
||||
export class GCSCacheManager implements CacheManager {
|
||||
private skipCacheUpdate = false;
|
||||
|
||||
@@ -18,11 +20,7 @@ export class GCSCacheManager implements CacheManager {
|
||||
return;
|
||||
}
|
||||
const paths = this.getPaths(ctx);
|
||||
if (
|
||||
(ctx.job?.cache?.disabled ?? false) ||
|
||||
!config.buildCache.gcsSignedUploadUrlForBuildCache ||
|
||||
paths.length === 0
|
||||
) {
|
||||
if ((ctx.job?.cache?.disabled ?? false) || paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,23 +45,22 @@ export class GCSCacheManager implements CacheManager {
|
||||
}
|
||||
|
||||
const archiveSize = (await fs.stat(archivePath)).size;
|
||||
const { maxSizeBytes } = this.allowedContentLengthRange;
|
||||
const friendlyMaxSize = filesize(maxSizeBytes);
|
||||
const friendlyArchiveSize = filesize(archiveSize);
|
||||
|
||||
if (archiveSize > maxSizeBytes) {
|
||||
ctx.logger.error(
|
||||
`Unable to save cache. Max size is ${friendlyMaxSize} but archive size is ${friendlyArchiveSize}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadWithSignedUrl({
|
||||
signedUrl: config.buildCache.gcsSignedUploadUrlForBuildCache,
|
||||
srcGeneratorAsync: async () => {
|
||||
return fs.createReadStream(archivePath);
|
||||
},
|
||||
await uploadCacheAsync({
|
||||
logger: ctx.logger,
|
||||
jobId: nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'),
|
||||
expoApiServerURL: nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set'),
|
||||
robotAccessToken: nullthrows(
|
||||
ctx.job.secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
),
|
||||
paths,
|
||||
key: this.getCacheKey(ctx),
|
||||
archivePath,
|
||||
size: archiveSize,
|
||||
platform: ctx.job.platform,
|
||||
force: ctx.job.cache?.clear,
|
||||
});
|
||||
} catch (err: any) {
|
||||
ctx.logger.error({ err });
|
||||
@@ -72,27 +69,47 @@ export class GCSCacheManager implements CacheManager {
|
||||
|
||||
public async restoreCache(ctx: BuildContext<BuildJob>): Promise<void> {
|
||||
const paths = this.getPaths(ctx);
|
||||
if (
|
||||
(ctx.job?.cache?.disabled ?? false) ||
|
||||
!config.buildCache.gcsSignedBuildCacheDownloadUrl ||
|
||||
paths.length === 0
|
||||
) {
|
||||
if ((ctx.job?.cache?.disabled ?? false) || paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.job.cache?.clear) {
|
||||
// Skip restore; saveCache will force upload the new archive for this key.
|
||||
return;
|
||||
}
|
||||
|
||||
const archivePath = path.join(ctx.workingdir, 'cache-restore.tar.gz');
|
||||
try {
|
||||
const { archivePath: downloadedArchivePath } = await downloadCacheAsync({
|
||||
logger: ctx.logger,
|
||||
jobId: nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'),
|
||||
expoApiServerURL: nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set'),
|
||||
robotAccessToken: nullthrows(
|
||||
ctx.job.secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
),
|
||||
paths,
|
||||
key: this.getCacheKey(ctx),
|
||||
keyPrefixes: [],
|
||||
platform: ctx.job.platform,
|
||||
});
|
||||
await fs.move(downloadedArchivePath, archivePath, { overwrite: true });
|
||||
await fs.remove(path.dirname(downloadedArchivePath));
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 404) {
|
||||
ctx.logger.info('No cache found for this key');
|
||||
} else {
|
||||
ctx.logger.error({ err });
|
||||
this.skipCacheUpdate = true; // if restore failed we don't want to update cache with new values
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.logger.info('Restoring files from cache:');
|
||||
paths.forEach(filePath => {
|
||||
ctx.logger.info(`- ${filePath}`);
|
||||
});
|
||||
|
||||
const archivePath = path.join(ctx.workingdir, 'cache-restore.tar.gz');
|
||||
try {
|
||||
await downloadFile(config.buildCache.gcsSignedBuildCacheDownloadUrl, archivePath, {});
|
||||
} catch (err: any) {
|
||||
ctx.logger.error({ err });
|
||||
this.skipCacheUpdate = true; // if restore failed we don't want to update cache with new values
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await tar.extract(
|
||||
{
|
||||
@@ -119,23 +136,18 @@ export class GCSCacheManager implements CacheManager {
|
||||
);
|
||||
}
|
||||
|
||||
private get allowedContentLengthRange(): ContentLengthRange {
|
||||
const headers = config.buildCache.gcsSignedUploadUrlForBuildCache?.headers;
|
||||
const defaultValues = { minSizeBytes: -Infinity, maxSizeBytes: Infinity };
|
||||
|
||||
if (!headers) {
|
||||
return defaultValues;
|
||||
}
|
||||
|
||||
const values = headers['x-goog-content-length-range'];
|
||||
if (!values) {
|
||||
return defaultValues;
|
||||
}
|
||||
|
||||
const [min, max] = values.split(',');
|
||||
return {
|
||||
minSizeBytes: Number(min),
|
||||
maxSizeBytes: Number(max),
|
||||
private getCacheKey(ctx: BuildContext<BuildJob>): string {
|
||||
const cacheForKey = { ...(ctx.job.cache ?? {}) };
|
||||
delete cacheForKey.clear;
|
||||
const keySource = {
|
||||
cache: cacheForKey,
|
||||
workflow: ctx.job.type,
|
||||
sdkVersion: ctx.metadata?.sdkVersion ?? '',
|
||||
};
|
||||
const base64CacheConfig = Buffer.from(
|
||||
nullthrows(stringify(keySource), 'Failed to stringify cache key source')
|
||||
).toString('base64');
|
||||
const hash = crypto.createHash('sha256').update(base64CacheConfig).digest('hex');
|
||||
return `eas-build-cache-${hash}`;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { uploadWithSignedUrl } from '@expo/build-tools';
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { Cache, Platform } from '@expo/eas-build-job';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { downloadCacheAsync, uploadCacheAsync } from '@expo/build-tools';
|
||||
import { Cache, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
// These tests use real fs and directories in os.tmpdir().
|
||||
// Something about tar v7 makes mocking fs with memfs problematic.
|
||||
@@ -15,15 +12,6 @@ import { Readable } from 'stream';
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
|
||||
jest.mock('@expo/downloader', () => {
|
||||
return jest.fn();
|
||||
});
|
||||
jest.mock('../config', () => ({
|
||||
buildCache: {
|
||||
gcsSignedUploadUrlForBuildCache: 'fakeurl',
|
||||
gcsSignedBuildCacheDownloadUrl: 'fakeUrl',
|
||||
},
|
||||
}));
|
||||
jest.mock('../sentry', () => ({
|
||||
capture: jest.fn(),
|
||||
}));
|
||||
@@ -41,10 +29,34 @@ describe(GCSCacheManager, () => {
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
await fs.rm(outsideDir, { recursive: true, force: true });
|
||||
jest.mocked(uploadWithSignedUrl).mockReset();
|
||||
jest.mocked(downloadFile).mockReset();
|
||||
jest.mocked(uploadCacheAsync).mockReset();
|
||||
jest.mocked(downloadCacheAsync).mockReset();
|
||||
});
|
||||
|
||||
function createMockCtx(cacheConfig: Partial<Cache>) {
|
||||
return {
|
||||
logger: { info: jest.fn(), error: jest.fn() },
|
||||
workingdir: tmpDir,
|
||||
buildDirectory: `${tmpDir}/build`,
|
||||
env: {
|
||||
EAS_BUILD_ID: 'build-id',
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
},
|
||||
metadata: {
|
||||
sdkVersion: '55.0.0',
|
||||
},
|
||||
job: {
|
||||
platform: Platform.ANDROID,
|
||||
type: Workflow.GENERIC,
|
||||
projectRootDirectory: '.',
|
||||
cache: cacheConfig,
|
||||
secrets: {
|
||||
robotAccessToken: 'robot-token',
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
async function saveAndRestoreCacheAsync(
|
||||
setupBeforeSave: () => Promise<void> | void,
|
||||
setupBeforeRestore: () => Promise<void> | void,
|
||||
@@ -52,37 +64,34 @@ describe(GCSCacheManager, () => {
|
||||
): Promise<void> {
|
||||
await setupBeforeSave();
|
||||
const manager = new GCSCacheManager();
|
||||
const mockCtx = {
|
||||
logger: { info: jest.fn(), error: console.error },
|
||||
workingdir: tmpDir,
|
||||
buildDirectory: `${tmpDir}/build`,
|
||||
job: {
|
||||
platform: Platform.ANDROID,
|
||||
projectRootDirectory: '.',
|
||||
cache: cacheConfig,
|
||||
},
|
||||
} as any;
|
||||
let tarReadStream: Readable;
|
||||
jest.mocked(uploadWithSignedUrl).mockImplementation(async ({ srcGeneratorAsync }) => {
|
||||
const result = await srcGeneratorAsync();
|
||||
tarReadStream = result;
|
||||
return randomUUID();
|
||||
const mockCtx = createMockCtx(cacheConfig);
|
||||
let tarContent: Buffer | undefined;
|
||||
jest.mocked(uploadCacheAsync).mockImplementation(async ({ archivePath }) => {
|
||||
tarContent = await fs.readFile(archivePath);
|
||||
});
|
||||
|
||||
await manager.saveCache(mockCtx);
|
||||
const chunks = [];
|
||||
for await (const chunk of tarReadStream!) {
|
||||
chunks.push(chunk);
|
||||
if (!tarContent) {
|
||||
throw new Error('Expected cache archive to be uploaded');
|
||||
}
|
||||
const tarContent = Buffer.concat(chunks);
|
||||
const uploadedTarContent = tarContent;
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
await fs.rm(outsideDir, { recursive: true, force: true });
|
||||
await setupBeforeRestore();
|
||||
jest.mocked(downloadFile).mockImplementation(async (_url, archivePath: string) => {
|
||||
await fs.mkdirp(mockCtx.workingdir);
|
||||
await fs.writeFile(archivePath, tarContent);
|
||||
let downloadDir: string | undefined;
|
||||
jest.mocked(downloadCacheAsync).mockImplementation(async () => {
|
||||
downloadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cache-download-'));
|
||||
const archivePath = path.join(downloadDir, 'cache.tar.gz');
|
||||
await fs.writeFile(archivePath, uploadedTarContent);
|
||||
return { archivePath, matchedKey: 'matched-cache-key' };
|
||||
});
|
||||
await manager.restoreCache(mockCtx);
|
||||
|
||||
const uploadedKey = jest.mocked(uploadCacheAsync).mock.calls[0][0].key;
|
||||
expect(downloadCacheAsync).toHaveBeenCalledWith(expect.objectContaining({ key: uploadedKey }));
|
||||
expect(downloadDir).toBeDefined();
|
||||
await expect(fs.pathExists(downloadDir!)).resolves.toBe(false);
|
||||
}
|
||||
|
||||
test('save and restore for a single file', async () => {
|
||||
@@ -189,4 +198,109 @@ describe(GCSCacheManager, () => {
|
||||
).resolves.toBe('index.ts');
|
||||
await expect(fs.pathExists(path.join(tmpDir, 'cache-restore.tar.gz'))).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('does not save or restore when cache is disabled', async () => {
|
||||
const manager = new GCSCacheManager();
|
||||
const mockCtx = createMockCtx({ disabled: true, paths: ['index.ts'] });
|
||||
await fs.outputFile(path.join(tmpDir, 'build', 'index.ts'), 'index.ts');
|
||||
|
||||
await manager.restoreCache(mockCtx);
|
||||
await manager.saveCache(mockCtx);
|
||||
|
||||
expect(downloadCacheAsync).not.toHaveBeenCalled();
|
||||
expect(uploadCacheAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not save or restore when cache paths are empty', async () => {
|
||||
const manager = new GCSCacheManager();
|
||||
const mockCtx = createMockCtx({ paths: [] });
|
||||
|
||||
await manager.restoreCache(mockCtx);
|
||||
await manager.saveCache(mockCtx);
|
||||
|
||||
expect(downloadCacheAsync).not.toHaveBeenCalled();
|
||||
expect(uploadCacheAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skips restore but saves cache when clear is set', async () => {
|
||||
const manager = new GCSCacheManager();
|
||||
const mockCtx = createMockCtx({ clear: true, paths: ['index.ts'] });
|
||||
await fs.outputFile(path.join(tmpDir, 'build', 'index.ts'), 'index.ts');
|
||||
jest.mocked(uploadCacheAsync).mockResolvedValue();
|
||||
|
||||
await manager.restoreCache(mockCtx);
|
||||
await manager.saveCache(mockCtx);
|
||||
|
||||
expect(downloadCacheAsync).not.toHaveBeenCalled();
|
||||
expect(uploadCacheAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
force: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('saves after a cache miss', async () => {
|
||||
const manager = new GCSCacheManager();
|
||||
const mockCtx = createMockCtx({ paths: ['index.ts'] });
|
||||
await fs.outputFile(path.join(tmpDir, 'build', 'index.ts'), 'index.ts');
|
||||
jest.mocked(downloadCacheAsync).mockRejectedValue({ response: { status: 404 } });
|
||||
|
||||
await manager.restoreCache(mockCtx);
|
||||
await manager.saveCache(mockCtx);
|
||||
|
||||
expect(mockCtx.logger.info).toHaveBeenCalledWith('No cache found for this key');
|
||||
const requestedKey = jest.mocked(downloadCacheAsync).mock.calls[0][0].key;
|
||||
expect(uploadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
force: undefined,
|
||||
key: requestedKey,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('does not save when restore fails', async () => {
|
||||
const manager = new GCSCacheManager();
|
||||
const mockCtx = createMockCtx({ paths: ['index.ts'] });
|
||||
await fs.outputFile(path.join(tmpDir, 'build', 'index.ts'), 'index.ts');
|
||||
jest.mocked(downloadCacheAsync).mockRejectedValue(new Error('download failed'));
|
||||
|
||||
await manager.restoreCache(mockCtx);
|
||||
await manager.saveCache(mockCtx);
|
||||
|
||||
expect(uploadCacheAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cache key is stable when clear changes and job cache is not mutated', async () => {
|
||||
const manager = new GCSCacheManager();
|
||||
const cacheConfig = {
|
||||
clear: true,
|
||||
disabled: false,
|
||||
key: 'custom-cache-key',
|
||||
paths: ['index.ts'],
|
||||
};
|
||||
const mockCtx = createMockCtx(cacheConfig);
|
||||
await fs.outputFile(path.join(tmpDir, 'build', 'index.ts'), 'index.ts');
|
||||
jest.mocked(uploadCacheAsync).mockResolvedValue();
|
||||
|
||||
await manager.saveCache(mockCtx);
|
||||
const keyWithClear = jest.mocked(uploadCacheAsync).mock.calls[0][0].key;
|
||||
const forceWithClear = jest.mocked(uploadCacheAsync).mock.calls[0][0].force;
|
||||
|
||||
mockCtx.job.cache = { ...cacheConfig, clear: false };
|
||||
await manager.saveCache(mockCtx);
|
||||
const keyWithoutClear = jest.mocked(uploadCacheAsync).mock.calls[1][0].key;
|
||||
const forceWithoutClear = jest.mocked(uploadCacheAsync).mock.calls[1][0].force;
|
||||
|
||||
expect(keyWithClear).toBe(keyWithoutClear);
|
||||
expect(keyWithClear).toMatch(/^eas-build-cache-[a-f0-9]{64}$/);
|
||||
expect(forceWithClear).toBe(true);
|
||||
expect(forceWithoutClear).toBe(false);
|
||||
expect(cacheConfig).toEqual({
|
||||
clear: true,
|
||||
disabled: false,
|
||||
key: 'custom-cache-key',
|
||||
paths: ['index.ts'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { TurtleSshSession } from '@expo/build-tools';
|
||||
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
|
||||
|
||||
import { startSshSessionPhaseAsync } from '../sshSession';
|
||||
|
||||
jest.mock('@expo/build-tools', () => ({
|
||||
TurtleSshSession: {
|
||||
getSshRelayServerUrl: jest.fn(() => 'wss://relay.expo.dev'),
|
||||
getSshIdleTimeoutSeconds: jest.fn(() => 0),
|
||||
startSshSessionAsync: jest.fn(),
|
||||
superviseSshSessionAsync: jest.fn(),
|
||||
},
|
||||
formatSecondsForLog: jest.fn(() => '15 minutes'),
|
||||
}));
|
||||
|
||||
const mocked = jest.mocked(TurtleSshSession);
|
||||
|
||||
function createLogger(): any {
|
||||
const logger: any = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
logger.child = jest.fn(() => logger);
|
||||
return logger;
|
||||
}
|
||||
|
||||
function createContext({ logger }: { logger: any }): any {
|
||||
return {
|
||||
env: { __WORKFLOW_JOB_ID: 'wj-1' },
|
||||
job: { ssh: { idleTimeoutSeconds: 0, relayServerUrl: 'wss://relay.expo.dev' } },
|
||||
logger,
|
||||
markBuildPhaseHasWarnings: jest.fn(),
|
||||
// The real implementation runs the phase body and honors doNotMarkEnd; for these tests we only
|
||||
// need the body to run.
|
||||
runBuildPhase: jest.fn(async (_phase: BuildPhase, body: () => Promise<void>) => {
|
||||
await body();
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function endPhaseCalls(logger: any): any[] {
|
||||
return logger.info.mock.calls.filter(
|
||||
([meta]: any[]) =>
|
||||
meta != null && typeof meta === 'object' && meta.marker === LogMarker.END_PHASE
|
||||
);
|
||||
}
|
||||
|
||||
describe(startSshSessionPhaseAsync, () => {
|
||||
let handle: {
|
||||
getConnectedClientCountAsync: jest.Mock;
|
||||
ensureConnectedAsync: jest.Mock;
|
||||
stopAsync: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
handle = {
|
||||
getConnectedClientCountAsync: jest.fn(async () => 0),
|
||||
ensureConnectedAsync: jest.fn(async () => {}),
|
||||
stopAsync: jest.fn(async () => {}),
|
||||
};
|
||||
mocked.startSshSessionAsync.mockResolvedValue({ handle, idleTimeoutSeconds: 0 } as any);
|
||||
mocked.superviseSshSessionAsync.mockResolvedValue(undefined as any);
|
||||
});
|
||||
|
||||
it('opens the session inside SSH_SESSION and leaves the phase open', async () => {
|
||||
const logger = createLogger();
|
||||
const ctx = createContext({ logger });
|
||||
|
||||
const { done } = await startSshSessionPhaseAsync({
|
||||
ctx,
|
||||
buildId: 'jr-1',
|
||||
logger,
|
||||
hasJobFinished: () => false,
|
||||
});
|
||||
|
||||
expect(ctx.runBuildPhase).toHaveBeenCalledWith(BuildPhase.SSH_SESSION, expect.any(Function), {
|
||||
doNotMarkEnd: true,
|
||||
});
|
||||
expect(mocked.startSshSessionAsync).toHaveBeenCalledWith(ctx, {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
relayServerUrl: 'wss://relay.expo.dev',
|
||||
idleTimeoutSeconds: 0,
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'SSH session ready. Connect with: eas workflow:ssh jr-1'
|
||||
);
|
||||
expect(done).toBeInstanceOf(Promise);
|
||||
await done;
|
||||
});
|
||||
|
||||
it('mentions the idle timeout when it is non-zero', async () => {
|
||||
const logger = createLogger();
|
||||
const ctx = createContext({ logger });
|
||||
mocked.getSshIdleTimeoutSeconds.mockReturnValue(900);
|
||||
mocked.startSshSessionAsync.mockResolvedValue({ handle, idleTimeoutSeconds: 900 } as any);
|
||||
|
||||
const { done } = await startSshSessionPhaseAsync({
|
||||
ctx,
|
||||
buildId: 'jr-1',
|
||||
logger,
|
||||
hasJobFinished: () => true,
|
||||
});
|
||||
await done;
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('then closes after 15 minutes with no client connected')
|
||||
);
|
||||
});
|
||||
|
||||
it('returns before the session ends so the job is not blocked', async () => {
|
||||
const logger = createLogger();
|
||||
const ctx = createContext({ logger });
|
||||
let releaseSupervision = (): void => {};
|
||||
mocked.superviseSshSessionAsync.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>(resolve => {
|
||||
releaseSupervision = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const { done } = await startSshSessionPhaseAsync({
|
||||
ctx,
|
||||
buildId: 'jr-1',
|
||||
logger,
|
||||
hasJobFinished: () => false,
|
||||
});
|
||||
|
||||
expect(done).toBeDefined();
|
||||
expect(handle.stopAsync).not.toHaveBeenCalled();
|
||||
releaseSupervision();
|
||||
await done;
|
||||
expect(handle.stopAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tears the tunnel down and closes the phase once supervision returns', async () => {
|
||||
const logger = createLogger();
|
||||
const ctx = createContext({ logger });
|
||||
|
||||
const { done } = await startSshSessionPhaseAsync({
|
||||
ctx,
|
||||
buildId: 'jr-1',
|
||||
logger,
|
||||
hasJobFinished: () => true,
|
||||
});
|
||||
await done;
|
||||
|
||||
expect(handle.stopAsync).toHaveBeenCalled();
|
||||
expect(endPhaseCalls(logger)).toEqual([
|
||||
[expect.objectContaining({ result: BuildPhaseResult.SUCCESS }), expect.any(String)],
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes the job-finished signal through to the supervisor', async () => {
|
||||
const logger = createLogger();
|
||||
const ctx = createContext({ logger });
|
||||
let jobFinished = false;
|
||||
|
||||
const { done } = await startSshSessionPhaseAsync({
|
||||
ctx,
|
||||
buildId: 'jr-1',
|
||||
logger,
|
||||
hasJobFinished: () => jobFinished,
|
||||
});
|
||||
await done;
|
||||
|
||||
const { hasJobFinished, handle: passedHandle } =
|
||||
mocked.superviseSshSessionAsync.mock.calls[0][0];
|
||||
expect(hasJobFinished()).toBe(false);
|
||||
jobFinished = true;
|
||||
expect(hasJobFinished()).toBe(true);
|
||||
expect(passedHandle).toBe(handle);
|
||||
});
|
||||
|
||||
it('closes the phase with a warning when supervision throws', async () => {
|
||||
const logger = createLogger();
|
||||
const ctx = createContext({ logger });
|
||||
mocked.superviseSshSessionAsync.mockRejectedValue(new Error('relay went away'));
|
||||
|
||||
const { done } = await startSshSessionPhaseAsync({
|
||||
ctx,
|
||||
buildId: 'jr-1',
|
||||
logger,
|
||||
hasJobFinished: () => true,
|
||||
});
|
||||
await done;
|
||||
|
||||
expect(handle.stopAsync).toHaveBeenCalled();
|
||||
expect(endPhaseCalls(logger)).toEqual([
|
||||
[expect.objectContaining({ result: BuildPhaseResult.WARNING }), expect.any(String)],
|
||||
]);
|
||||
});
|
||||
|
||||
it('warns when tearing down the tunnel fails after supervision', async () => {
|
||||
const logger = createLogger();
|
||||
const ctx = createContext({ logger });
|
||||
handle.stopAsync.mockRejectedValue(new Error('already gone'));
|
||||
|
||||
const { done } = await startSshSessionPhaseAsync({
|
||||
ctx,
|
||||
buildId: 'jr-1',
|
||||
logger,
|
||||
hasJobFinished: () => true,
|
||||
});
|
||||
await done;
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Failed to tear down the SSH tunnel.'
|
||||
);
|
||||
expect(endPhaseCalls(logger)).toEqual([
|
||||
[expect.objectContaining({ result: BuildPhaseResult.SUCCESS }), expect.any(String)],
|
||||
]);
|
||||
});
|
||||
|
||||
it('lets the job continue and closes the phase when the session cannot be opened', async () => {
|
||||
const logger = createLogger();
|
||||
const ctx = createContext({ logger });
|
||||
mocked.startSshSessionAsync.mockRejectedValue(new Error('no relay'));
|
||||
|
||||
const { done } = await startSshSessionPhaseAsync({
|
||||
ctx,
|
||||
buildId: 'jr-1',
|
||||
logger,
|
||||
hasJobFinished: () => false,
|
||||
});
|
||||
|
||||
expect(done).toBeUndefined();
|
||||
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
|
||||
expect(mocked.superviseSshSessionAsync).not.toHaveBeenCalled();
|
||||
expect(endPhaseCalls(logger)).toEqual([
|
||||
[expect.objectContaining({ result: BuildPhaseResult.WARNING }), expect.any(String)],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Artifacts, BuildContext, Builders, runGenericJobAsync } from '@expo/build-tools';
|
||||
import {
|
||||
Artifacts,
|
||||
BuildContext,
|
||||
Builders,
|
||||
TurtleSshSession,
|
||||
runGenericJobAsync,
|
||||
} from '@expo/build-tools';
|
||||
import {
|
||||
Android,
|
||||
BuildJob,
|
||||
@@ -17,6 +23,7 @@ import config from './config';
|
||||
import { displayWorkerRuntimeInfo } from './displayRuntimeInfo';
|
||||
import { Analytics, Event, logProjectDependenciesAsync } from './external/analytics';
|
||||
import { prepareRuntimeEnvironment } from './runtimeEnvironment';
|
||||
import { startSshSessionPhaseAsync } from './sshSession';
|
||||
import { cleanUpWorkingdir } from './workingdir';
|
||||
|
||||
export async function build({
|
||||
@@ -29,6 +36,8 @@ export async function build({
|
||||
analytics: Analytics;
|
||||
}): Promise<Artifacts> {
|
||||
const { job, logger } = ctx;
|
||||
let sshSessionDone: Promise<void> | undefined;
|
||||
let hasJobFinished = false;
|
||||
try {
|
||||
analytics.logEvent(Event.WORKER_BUILD_START, {});
|
||||
|
||||
@@ -49,6 +58,15 @@ export async function build({
|
||||
}
|
||||
});
|
||||
|
||||
if (TurtleSshSession.isSshEnabled(ctx.job)) {
|
||||
({ done: sshSessionDone } = await startSshSessionPhaseAsync({
|
||||
ctx,
|
||||
buildId,
|
||||
logger,
|
||||
hasJobFinished: () => hasJobFinished,
|
||||
}));
|
||||
}
|
||||
|
||||
let artifacts: Artifacts;
|
||||
|
||||
switch (job.platform) {
|
||||
@@ -89,6 +107,9 @@ export async function build({
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
// Lets the SSH supervisor start its idle countdown and tear the tunnel down.
|
||||
hasJobFinished = true;
|
||||
await sshSessionDone;
|
||||
if (config.env === 'development') {
|
||||
await cleanUpWorkingdir();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { BuildContext, TurtleSshSession, formatSecondsForLog } from '@expo/build-tools';
|
||||
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
export async function startSshSessionPhaseAsync({
|
||||
ctx,
|
||||
buildId,
|
||||
logger,
|
||||
hasJobFinished,
|
||||
}: {
|
||||
ctx: BuildContext;
|
||||
buildId: string;
|
||||
logger: bunyan;
|
||||
hasJobFinished: () => boolean;
|
||||
}): Promise<{ done?: Promise<void> }> {
|
||||
let done: Promise<void> | undefined;
|
||||
|
||||
await ctx.runBuildPhase(
|
||||
BuildPhase.SSH_SESSION,
|
||||
async () => {
|
||||
const phaseStartedAt = performance.now();
|
||||
try {
|
||||
ctx.logger.info('Opening an SSH session for this job.');
|
||||
const target = {
|
||||
type: ctx.job.platform ? ('BUILD' as const) : ('JOB_RUN' as const),
|
||||
id: buildId,
|
||||
};
|
||||
const { handle, idleTimeoutSeconds } = await TurtleSshSession.startSshSessionAsync(ctx, {
|
||||
target,
|
||||
relayServerUrl: TurtleSshSession.getSshRelayServerUrl(ctx.job),
|
||||
idleTimeoutSeconds: TurtleSshSession.getSshIdleTimeoutSeconds(ctx.job),
|
||||
});
|
||||
ctx.logger.info(`SSH session ready. Connect with: eas workflow:ssh ${buildId}`);
|
||||
ctx.logger.info(
|
||||
idleTimeoutSeconds === 0
|
||||
? 'It stays open for the whole job and closes once the job finishes and no client is connected.'
|
||||
: `It stays open for the whole job, then closes after ${formatSecondsForLog(idleTimeoutSeconds)} with no client connected. The worker stays up until then.`
|
||||
);
|
||||
|
||||
const sshLogger = logger.child({ phase: BuildPhase.SSH_SESSION });
|
||||
done = (async () => {
|
||||
let result = BuildPhaseResult.SUCCESS;
|
||||
try {
|
||||
await TurtleSshSession.superviseSshSessionAsync({
|
||||
handle,
|
||||
idleTimeoutSeconds,
|
||||
hasJobFinished,
|
||||
logger: sshLogger,
|
||||
});
|
||||
} catch (err) {
|
||||
result = BuildPhaseResult.WARNING;
|
||||
sshLogger.warn({ err }, 'The SSH session ended unexpectedly.');
|
||||
} finally {
|
||||
await handle.stopAsync().catch(err => {
|
||||
sshLogger.warn({ err }, 'Failed to tear down the SSH tunnel.');
|
||||
});
|
||||
sshLogger.info(
|
||||
{
|
||||
marker: LogMarker.END_PHASE,
|
||||
result,
|
||||
durationMs: Math.round(performance.now() - phaseStartedAt),
|
||||
},
|
||||
`End phase: ${BuildPhase.SSH_SESSION}`
|
||||
);
|
||||
}
|
||||
})();
|
||||
} catch (err) {
|
||||
ctx.logger.warn(
|
||||
{ err },
|
||||
'Failed to open the SSH session. The job will continue without it.'
|
||||
);
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
ctx.logger.info(
|
||||
{
|
||||
marker: LogMarker.END_PHASE,
|
||||
result: BuildPhaseResult.WARNING,
|
||||
durationMs: Math.round(performance.now() - phaseStartedAt),
|
||||
},
|
||||
`End phase: ${BuildPhase.SSH_SESSION}`
|
||||
);
|
||||
}
|
||||
},
|
||||
{ doNotMarkEnd: true }
|
||||
);
|
||||
|
||||
return { done };
|
||||
}
|
||||
@@ -2291,24 +2291,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:22.4.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:23.0.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/downloader": "npm:22.0.0"
|
||||
"@expo/eas-build-job": "npm:22.0.0"
|
||||
"@expo/downloader": "npm:23.0.0"
|
||||
"@expo/eas-build-job": "npm:23.0.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:22.0.0"
|
||||
"@expo/logger": "npm:23.0.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/plist": "npm:^0.3.5"
|
||||
"@expo/repack-app": "npm:~0.9.0"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:22.4.0"
|
||||
"@expo/template-file": "npm:22.0.0"
|
||||
"@expo/turtle-spawn": "npm:22.0.0"
|
||||
"@expo/steps": "npm:23.0.0"
|
||||
"@expo/template-file": "npm:23.0.0"
|
||||
"@expo/turtle-spawn": "npm:23.0.0"
|
||||
"@expo/xcpretty": "npm:^4.3.1"
|
||||
"@ngrok/ngrok": "npm:1.7.0"
|
||||
"@sentry/node": "npm:7.77.0"
|
||||
@@ -2533,7 +2533,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/downloader@npm:22.0.0, @expo/downloader@workspace:packages/downloader":
|
||||
"@expo/downloader@npm:23.0.0, @expo/downloader@workspace:packages/downloader":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/downloader@workspace:packages/downloader"
|
||||
dependencies:
|
||||
@@ -2549,13 +2549,13 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:22.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:23.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:22.0.0"
|
||||
"@expo/logger": "npm:23.0.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/turtle-spawn": "npm:22.0.0"
|
||||
"@expo/turtle-spawn": "npm:23.0.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
jest: "npm:^29.7.0"
|
||||
@@ -2568,12 +2568,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:22.0.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:23.0.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-json@workspace:packages/eas-json"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.23.5"
|
||||
"@expo/eas-build-job": "npm:22.0.0"
|
||||
"@expo/eas-build-job": "npm:23.0.0"
|
||||
"@tsconfig/node20": "npm:20.1.8"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -2687,7 +2687,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/logger@npm:22.0.0, @expo/logger@workspace:packages/logger":
|
||||
"@expo/logger@npm:23.0.0, @expo/logger@workspace:packages/logger":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/logger@workspace:packages/logger"
|
||||
dependencies:
|
||||
@@ -2887,12 +2887,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:22.4.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:23.0.0, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
"@expo/eas-build-job": "npm:22.0.0"
|
||||
"@expo/logger": "npm:22.0.0"
|
||||
"@expo/eas-build-job": "npm:23.0.0"
|
||||
"@expo/logger": "npm:23.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
@@ -2919,7 +2919,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/template-file@npm:22.0.0, @expo/template-file@workspace:packages/template-file":
|
||||
"@expo/template-file@npm:23.0.0, @expo/template-file@workspace:packages/template-file":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/template-file@workspace:packages/template-file"
|
||||
dependencies:
|
||||
@@ -2942,11 +2942,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/turtle-spawn@npm:22.0.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
"@expo/turtle-spawn@npm:23.0.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:22.0.0"
|
||||
"@expo/logger": "npm:23.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
@@ -2962,18 +2962,17 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:22.4.0"
|
||||
"@expo/build-tools": "npm:23.0.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/downloader": "npm:22.0.0"
|
||||
"@expo/eas-build-job": "npm:22.0.0"
|
||||
"@expo/logger": "npm:22.0.0"
|
||||
"@expo/eas-build-job": "npm:23.0.0"
|
||||
"@expo/logger": "npm:23.0.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:22.4.0"
|
||||
"@expo/template-file": "npm:22.0.0"
|
||||
"@expo/turtle-spawn": "npm:22.0.0"
|
||||
"@expo/steps": "npm:23.0.0"
|
||||
"@expo/template-file": "npm:23.0.0"
|
||||
"@expo/turtle-spawn": "npm:23.0.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
"@types/babel__core": "npm:7.20.5"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -2994,6 +2993,7 @@ __metadata:
|
||||
filesize: "npm:7.0.0"
|
||||
fs-extra: "npm:11.2.0"
|
||||
jest: "npm:29.7.0"
|
||||
json-stable-stringify: "npm:1.3.0"
|
||||
koa: "npm:3.1.2"
|
||||
koa-body: "npm:7.0.1"
|
||||
koa-router: "npm:14.0.0"
|
||||
@@ -8332,6 +8332,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"async-function@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "async-function@npm:1.0.0"
|
||||
checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"async-generator-function@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "async-generator-function@npm:1.0.0"
|
||||
checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"async-limiter@npm:~1.0.0":
|
||||
version: 1.0.1
|
||||
resolution: "async-limiter@npm:1.0.1"
|
||||
@@ -8892,7 +8906,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"call-bound@npm:^1.0.2":
|
||||
"call-bind@npm:^1.0.8":
|
||||
version: 1.0.9
|
||||
resolution: "call-bind@npm:1.0.9"
|
||||
dependencies:
|
||||
call-bind-apply-helpers: "npm:^1.0.2"
|
||||
es-define-property: "npm:^1.0.1"
|
||||
get-intrinsic: "npm:^1.3.0"
|
||||
set-function-length: "npm:^1.2.2"
|
||||
checksum: 10c0/a6621f6da1444481919ce3b4983dff725691e0754d3507ae483ce56e54985f2da7d6f1df512c56dbf28660745cf1ca52553f1fc9aef5557f3ce353ef14fab714
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"call-bound@npm:^1.0.2, call-bound@npm:^1.0.4":
|
||||
version: 1.0.4
|
||||
resolution: "call-bound@npm:1.0.4"
|
||||
dependencies:
|
||||
@@ -9727,7 +9753,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:22.4.0"
|
||||
"@expo/steps": "npm:23.0.0"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
@@ -10055,6 +10081,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"define-data-property@npm:^1.1.4":
|
||||
version: 1.1.4
|
||||
resolution: "define-data-property@npm:1.1.4"
|
||||
dependencies:
|
||||
es-define-property: "npm:^1.0.0"
|
||||
es-errors: "npm:^1.3.0"
|
||||
gopd: "npm:^1.0.1"
|
||||
checksum: 10c0/dea0606d1483eb9db8d930d4eac62ca0fa16738b0b3e07046cddfacf7d8c868bbe13fa0cb263eb91c7d0d527960dc3f2f2471a69ed7816210307f6744fe62e37
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"define-lazy-prop@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "define-lazy-prop@npm:2.0.0"
|
||||
@@ -10300,11 +10337,11 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:22.4.0"
|
||||
"@expo/eas-build-job": "npm:22.0.0"
|
||||
"@expo/logger": "npm:22.0.0"
|
||||
"@expo/build-tools": "npm:23.0.0"
|
||||
"@expo/eas-build-job": "npm:23.0.0"
|
||||
"@expo/logger": "npm:23.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:22.0.0"
|
||||
"@expo/turtle-spawn": "npm:23.0.0"
|
||||
"@types/bunyan": "npm:^1.8.11"
|
||||
"@types/fs-extra": "npm:^11.0.4"
|
||||
"@types/hapi__joi": "npm:^17.1.14"
|
||||
@@ -10358,11 +10395,11 @@ __metadata:
|
||||
"@expo/code-signing-certificates": "npm:0.0.5"
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/eas-build-job": "npm:22.0.0"
|
||||
"@expo/eas-json": "npm:22.0.0"
|
||||
"@expo/eas-build-job": "npm:23.0.0"
|
||||
"@expo/eas-json": "npm:23.0.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:22.0.0"
|
||||
"@expo/logger": "npm:23.0.0"
|
||||
"@expo/multipart-body-parser": "npm:2.0.0"
|
||||
"@expo/osascript": "npm:2.1.4"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -10374,7 +10411,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:22.4.0"
|
||||
"@expo/steps": "npm:23.0.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:6.3.1"
|
||||
"@graphql-codegen/introspection": "npm:5.0.2"
|
||||
@@ -10654,7 +10691,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es-define-property@npm:^1.0.1":
|
||||
"es-define-property@npm:^1.0.0, es-define-property@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "es-define-property@npm:1.0.1"
|
||||
checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c
|
||||
@@ -11409,6 +11446,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"generator-function@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "generator-function@npm:2.0.1"
|
||||
checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gensync@npm:^1.0.0-beta.2":
|
||||
version: 1.0.0-beta.2
|
||||
resolution: "gensync@npm:1.0.0-beta.2"
|
||||
@@ -11430,6 +11474,27 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-intrinsic@npm:^1.2.4":
|
||||
version: 1.3.1
|
||||
resolution: "get-intrinsic@npm:1.3.1"
|
||||
dependencies:
|
||||
async-function: "npm:^1.0.0"
|
||||
async-generator-function: "npm:^1.0.0"
|
||||
call-bind-apply-helpers: "npm:^1.0.2"
|
||||
es-define-property: "npm:^1.0.1"
|
||||
es-errors: "npm:^1.3.0"
|
||||
es-object-atoms: "npm:^1.1.1"
|
||||
function-bind: "npm:^1.1.2"
|
||||
generator-function: "npm:^2.0.0"
|
||||
get-proto: "npm:^1.0.1"
|
||||
gopd: "npm:^1.2.0"
|
||||
has-symbols: "npm:^1.1.0"
|
||||
hasown: "npm:^2.0.2"
|
||||
math-intrinsics: "npm:^1.1.0"
|
||||
checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0":
|
||||
version: 1.3.0
|
||||
resolution: "get-intrinsic@npm:1.3.0"
|
||||
@@ -11772,7 +11837,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gopd@npm:^1.2.0":
|
||||
"gopd@npm:^1.0.1, gopd@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "gopd@npm:1.2.0"
|
||||
checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead
|
||||
@@ -12005,6 +12070,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"has-property-descriptors@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "has-property-descriptors@npm:1.0.2"
|
||||
dependencies:
|
||||
es-define-property: "npm:^1.0.0"
|
||||
checksum: 10c0/253c1f59e80bb476cf0dde8ff5284505d90c3bdb762983c3514d36414290475fe3fd6f574929d84de2a8eec00d35cf07cb6776205ff32efd7c50719125f00236
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"has-symbols@npm:^1.0.3":
|
||||
version: 1.0.3
|
||||
resolution: "has-symbols@npm:1.0.3"
|
||||
@@ -12798,6 +12872,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isarray@npm:^2.0.5":
|
||||
version: 2.0.5
|
||||
resolution: "isarray@npm:2.0.5"
|
||||
checksum: 10c0/4199f14a7a13da2177c66c31080008b7124331956f47bca57dd0b6ea9f11687aa25e565a2c7a2b519bc86988d10398e3049a1f5df13c9f6b7664154690ae79fd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isarray@npm:~1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "isarray@npm:1.0.0"
|
||||
@@ -13699,6 +13780,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"json-stable-stringify@npm:1.3.0":
|
||||
version: 1.3.0
|
||||
resolution: "json-stable-stringify@npm:1.3.0"
|
||||
dependencies:
|
||||
call-bind: "npm:^1.0.8"
|
||||
call-bound: "npm:^1.0.4"
|
||||
isarray: "npm:^2.0.5"
|
||||
jsonify: "npm:^0.0.1"
|
||||
object-keys: "npm:^1.1.1"
|
||||
checksum: 10c0/8b3ff19e4c23c0ad591a49bc3a015d89a538db787d12fe9c4072e1d64d8cfa481f8c37719c629c3d84e848847617bf49f5fee894cf1d25959ab5b67e1c517f31
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"json-stringify-nice@npm:^1.1.4":
|
||||
version: 1.1.4
|
||||
resolution: "json-stringify-nice@npm:1.1.4"
|
||||
@@ -13773,6 +13867,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsonify@npm:^0.0.1":
|
||||
version: 0.0.1
|
||||
resolution: "jsonify@npm:0.0.1"
|
||||
checksum: 10c0/7f5499cdd59a0967ed35bda48b7cec43d850bbc8fb955cdd3a1717bb0efadbe300724d5646de765bb7a99fc1c3ab06eb80d93503c6faaf99b4ff50a3326692f6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsonparse@npm:^1.2.0, jsonparse@npm:^1.3.1":
|
||||
version: 1.3.1
|
||||
resolution: "jsonparse@npm:1.3.1"
|
||||
@@ -15748,6 +15849,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"object-keys@npm:^1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "object-keys@npm:1.1.1"
|
||||
checksum: 10c0/b11f7ccdbc6d406d1f186cdadb9d54738e347b2692a14439ca5ac70c225fa6db46db809711b78589866d47b25fc3e8dee0b4c722ac751e11180f9380e3d8601d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"object-treeify@npm:^1.1.33":
|
||||
version: 1.1.33
|
||||
resolution: "object-treeify@npm:1.1.33"
|
||||
@@ -17488,6 +17596,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"set-function-length@npm:^1.2.2":
|
||||
version: 1.2.2
|
||||
resolution: "set-function-length@npm:1.2.2"
|
||||
dependencies:
|
||||
define-data-property: "npm:^1.1.4"
|
||||
es-errors: "npm:^1.3.0"
|
||||
function-bind: "npm:^1.1.2"
|
||||
get-intrinsic: "npm:^1.2.4"
|
||||
gopd: "npm:^1.0.1"
|
||||
has-property-descriptors: "npm:^1.0.2"
|
||||
checksum: 10c0/82850e62f412a258b71e123d4ed3873fa9377c216809551192bb6769329340176f109c2eeae8c22a8d386c76739855f78e8716515c818bcaef384b51110f0f3c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"set-interval-async@npm:3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "set-interval-async@npm:3.0.3"
|
||||
|
||||
Reference in new issue
Block a user