mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcc7201ca8 | ||
|
|
bf3f259bc4 | ||
|
|
1a573679e5 | ||
|
|
bb589f1d36 | ||
|
|
83b86b8ee6 | ||
|
|
730c142c46 | ||
|
|
82de82bf7a | ||
|
|
d63d58203d | ||
|
|
702d8808d3 | ||
|
|
a98d929f14 | ||
|
|
8d005fe9c3 | ||
|
|
8dc6bffe36 | ||
|
|
3784a50399 | ||
|
|
ddf386597c | ||
|
|
a2cf4c658d | ||
|
|
23d1ed3af5 | ||
|
|
417f0df466 | ||
|
|
591db3b142 | ||
|
|
246d0c0794 | ||
|
|
f299ccb493 | ||
|
|
adab98404e | ||
|
|
a8676fe92d | ||
|
|
68c87fa6fb | ||
|
|
1010e3b997 | ||
|
|
a8e8c796dd | ||
|
|
fcea341834 | ||
|
|
197edf5ac6 | ||
|
|
a48ad7acd4 |
No files matched your search
@@ -1,11 +1,10 @@
|
||||
---
|
||||
description: Security and secrets. Injection, credential or secret leakage, unsafe shell/child-process use, missing validation at trust boundaries.
|
||||
alwaysRun: true
|
||||
# Security is the highest-stakes agent and benefits most from stronger threat-model
|
||||
# reasoning, so it runs on Opus even though the other specialists use the Sonnet
|
||||
# default. Scoped to this one agent to limit the extra latency/rate-limit cost;
|
||||
# subdivide-on-timeout + the per-fetch deadline keep a slow Opus pass from hanging.
|
||||
model: anthropic/claude-opus-4-8
|
||||
# No model override: security runs on the config default (gpt-5.5, on the
|
||||
# subscription). To give it a stronger model, use the metered openai-api alias
|
||||
# from the mixed auth.providers setup (see the @expo/code-review-cli README) —
|
||||
# pro-tier models are excluded from the subscription.
|
||||
---
|
||||
|
||||
# Security & secrets
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
{
|
||||
// Default model for the specialist reviewers (and the cross-file pass). Sonnet
|
||||
// is the quality/speed sweet spot for finding real bugs. Per-agent frontmatter
|
||||
// can override this; the coordinator runs on Haiku (see coordinator.md) since it
|
||||
// only consolidates text. Override everything at runtime with REVIEWER_MODEL
|
||||
// (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini-fast for a cheaper local run).
|
||||
"model": "anthropic/claude-sonnet-5",
|
||||
// Default model for every pass — served by the ChatGPT/Codex subscription (see
|
||||
// auth below), so reviews have no marginal cost. Per-agent frontmatter can
|
||||
// override this. Override everything at runtime with REVIEWER_MODEL
|
||||
// (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini for a cheaper local run).
|
||||
"model": "openai/gpt-5.5",
|
||||
|
||||
// Agents are every markdown file in agents/. shared.md (prepended to all) and
|
||||
// coordinator.md are reserved. Per-agent overrides go in each file's frontmatter.
|
||||
@@ -31,12 +30,17 @@
|
||||
"breakGlass": { "marker": "/skip-review" },
|
||||
"commentTag": "expo-ai-code-reviewer",
|
||||
|
||||
// Use a Claude Pro/Max OAuth token (from `claude setup-token`) rather than an
|
||||
// x-api-key. The token is read from this env var and injected into an isolated
|
||||
// OpenCode auth.json as a bearer credential.
|
||||
// ChatGPT/Codex subscription (OAuth): tokenEnv holds the ACCESS token from an
|
||||
// `opencode auth login` ChatGPT sign-in (`ecr setup-auth` extracts it) — a
|
||||
// plain bearer with no rotation involvement; re-mint before its ~10-day expiry
|
||||
// (doctor + the run preflight warn first). NEVER use the refresh token here:
|
||||
// it is single-use and dies on first rotation. All models above are on the
|
||||
// subscription's allowlist, so reviews draw no metered spend. (To add pro-tier
|
||||
// models later, see the mixed auth.providers setup in the @expo/code-review-cli
|
||||
// README.)
|
||||
"auth": {
|
||||
"mode": "oauth",
|
||||
"provider": "anthropic",
|
||||
"tokenEnv": "DO_NOT_USE_EXPERIMENTAL_ANTHROPIC_API_KEY"
|
||||
"provider": "openai",
|
||||
"tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
---
|
||||
# The coordinator makes the final call — de-duping, re-judging severity, and
|
||||
# deciding — so it runs on Opus: consolidation quality matters more here than the
|
||||
# small serial-tail latency it adds (no repo tools, so it's a single bounded pass).
|
||||
model: anthropic/claude-opus-4-8
|
||||
# No model override: the coordinator runs on the config default (gpt-5.5, on the
|
||||
# subscription). It only consolidates text (no repo tools), so the default is
|
||||
# plenty; override here if consolidation quality ever needs a stronger model.
|
||||
---
|
||||
|
||||
# Coordinator — consolidation & decision
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1887_635)">
|
||||
<rect width="128" height="128" rx="64" fill="#18191B"/>
|
||||
<path d="M99.6237 27.9758C119.134 47.4859 119.024 79.2583 99.3412 98.9413C79.6583 118.624 47.8858 118.734 28.3758 99.2237C8.86596 79.7136 8.976 47.9418 28.6589 28.2589C48.3418 8.57602 80.1136 8.46598 99.6237 27.9758Z" stroke="url(#paint0_linear_1887_635)" stroke-width="3.24"/>
|
||||
<path d="M114.38 64C114.38 72.6638 109.008 80.7192 99.8828 86.6855C90.7722 92.6425 78.0904 96.3799 64 96.3799C49.9096 96.3799 37.2278 92.6425 28.1172 86.6855C18.9922 80.7192 13.6201 72.6638 13.6201 64C13.6201 55.3362 18.9922 47.2808 28.1172 41.3144C37.2278 35.3575 49.9096 31.6201 64 31.6201C78.0904 31.6201 90.7722 35.3575 99.8828 41.3145C109.008 47.2808 114.38 55.3362 114.38 64Z" stroke="url(#paint1_linear_1887_635)" stroke-width="3.24"/>
|
||||
<path d="M64 13.6201C72.6638 13.6201 80.7192 18.9922 86.6855 28.1172C92.6425 37.2278 96.3799 49.9096 96.3799 64C96.3799 78.0904 92.6425 90.7722 86.6855 99.8828C80.7192 109.008 72.6638 114.38 64 114.38C55.3362 114.38 47.2808 109.008 41.3145 99.8828C35.3575 90.7722 31.6201 78.0904 31.6201 64C31.6201 49.9096 35.3575 37.2278 41.3145 28.1172C47.2808 18.9922 55.3362 13.6201 64 13.6201Z" stroke="url(#paint2_linear_1887_635)" stroke-width="3.24"/>
|
||||
<path d="M66.0082 42.48H61.9918C60.1376 42.48 58.4378 43.4711 57.5835 45.0503L40.5592 76.5206C40.164 77.2512 40.1343 78.1142 40.4784 78.8683L41.8881 81.9568C42.7652 83.8787 45.527 84.0723 46.6859 82.2932L62.8238 57.5187C63.0773 57.1295 63.5218 56.8932 64 56.8932C64.4782 56.8932 64.9227 57.1295 65.1761 57.5187L81.3141 82.2932C82.473 84.0723 85.2348 83.8787 86.1119 81.9568L87.5215 78.8683C87.8657 78.1142 87.836 77.2512 87.4408 76.5206L70.4165 45.0503C69.5622 43.4711 67.8624 42.48 66.0082 42.48Z" fill="url(#paint3_linear_1887_635)"/>
|
||||
</g>
|
||||
<rect x="1" y="1" width="126" height="126" rx="63" stroke="white" stroke-opacity="0.3" stroke-width="2"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_1887_635" x1="112.4" y1="20.4" x2="138.422" y2="-10.3513" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_1887_635" x1="130" y1="88" x2="233.2" y2="134.8" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_1887_635" x1="86" y1="30.4" x2="130.8" y2="-75.2" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_1887_635" x1="64" y1="84" x2="64" y2="210.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_1887_635">
|
||||
<rect width="128" height="128" rx="64" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -19,7 +19,7 @@ permissions:
|
||||
|
||||
env:
|
||||
# Published reviewer run via npx (override with repo variable ECR_VERSION).
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.2.3' }}
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.6.0' }}
|
||||
|
||||
concurrency:
|
||||
group: ai-code-review-cmd-${{ github.event.issue.number }}
|
||||
@@ -77,12 +77,18 @@ jobs:
|
||||
# config, and never `gh pr checkout` the PR head. The reviewer engine itself
|
||||
# is the PUBLISHED @expo/code-review-cli (fetched by npx), not built from any
|
||||
# checkout, so attacker-controlled PR code never runs here. The diff + PR
|
||||
# metadata come from the API (`gh pr diff`/`gh pr view`).
|
||||
# metadata come from the API (`gh pr diff`/`gh pr view`); `ecr ci` (0.6.0+)
|
||||
# loads configuration from the PR's immutable base commit and reads source
|
||||
# from a head worktree scrubbed of ambient runtime config — the same trust
|
||||
# model as the pull_request workflow.
|
||||
- name: Checkout (base ref only — never the PR head)
|
||||
if: steps.cmd.outputs.run == 'true'
|
||||
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
# The CLI's git fetches authenticate through `gh` from GH_TOKEN; keep the
|
||||
# token out of .git/config.
|
||||
persist-credentials: false
|
||||
|
||||
# Corepack so setup-node's yarn probe doesn't fail on the repo's yarn@4 pin
|
||||
# (the reviewer runs via npx, not yarn).
|
||||
@@ -104,7 +110,8 @@ jobs:
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DO_NOT_USE_EXPERIMENTAL_ANTHROPIC_API_KEY: ${{ secrets.DO_NOT_USE_EXPERIMENTAL_ANTHROPIC_API_KEY }}
|
||||
CODEX_OAUTH_ACCESS_TOKEN: ${{ secrets.CODEX_OAUTH_ACCESS_TOKEN }}
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_ACCESS_TOKEN' }}
|
||||
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
|
||||
AGENTS: ${{ steps.cmd.outputs.agents }}
|
||||
ROUTE: ${{ steps.cmd.outputs.route }}
|
||||
|
||||
@@ -18,7 +18,7 @@ permissions:
|
||||
|
||||
env:
|
||||
# Published reviewer run via npx (override with repo variable ECR_VERSION).
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.2.3' }}
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.6.0' }}
|
||||
|
||||
concurrency:
|
||||
group: ai-code-review-dismiss-${{ github.event.issue.number }}
|
||||
@@ -80,6 +80,9 @@ jobs:
|
||||
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
# The CLI's git fetches authenticate through `gh` from GH_TOKEN; keep the
|
||||
# token out of .git/config.
|
||||
persist-credentials: false
|
||||
|
||||
# Corepack so setup-node's yarn probe doesn't fail on the repo's yarn@4 pin
|
||||
# (dismiss runs the CLI via npx, not yarn).
|
||||
|
||||
@@ -13,8 +13,9 @@ permissions:
|
||||
env:
|
||||
# The reviewer is the published @expo/code-review-cli package, run via npx — the
|
||||
# repo only carries the `.expo-code-review/` config, not the engine source.
|
||||
# Override with a repo variable ECR_VERSION to pin/bump; defaults to 0.2.x.
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.2.3' }}
|
||||
# Override with a repo variable ECR_VERSION to pin/bump; defaults to 0.6.x
|
||||
# (0.6.0 = trusted base-commit configuration + PR-head runtime-config scrub).
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.6.0' }}
|
||||
|
||||
concurrency:
|
||||
group: ai-code-review-${{ github.event.pull_request.number }}
|
||||
@@ -77,36 +78,22 @@ jobs:
|
||||
echo "agents=$agents" >> "$GITHUB_OUTPUT"
|
||||
echo "route=$route" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout
|
||||
# SECURITY: check out the PR's immutable BASE commit, never the PR head or
|
||||
# merge ref — everything on this runner's disk comes from a commit that
|
||||
# already merged. `ecr ci` (0.6.0+) additionally enforces this itself: it
|
||||
# materializes the base commit via the GitHub API for configuration and the
|
||||
# head commit (OID-pinned, scrubbed of ambient runtime config like
|
||||
# opencode.json/plugins/AGENTS.md/.env) for source reads. persist-credentials
|
||||
# off — the CLI's git fetches authenticate through `gh` from GH_TOKEN, so the
|
||||
# token never lands in .git/config.
|
||||
- name: Checkout (base commit only — never the PR head)
|
||||
if: steps.resolve.outputs.run == 'true'
|
||||
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
# The reviewer reads the working tree at HEAD and gets the diff from the
|
||||
# API (`gh pr diff`), so a shallow checkout is enough — no full history.
|
||||
# The diff comes from the API (`gh pr diff`), so a shallow checkout is enough.
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
# SECURITY: this pull_request workflow checks out the PR's code, which includes
|
||||
# .expo-code-review/config.jsonc. Its `auth.tokenEnv` names the env var the CLI
|
||||
# forwards as the model credential, so a PR could repoint it at another secret
|
||||
# in the runner. Refuse to run unless tokenEnv is the expected value. That value
|
||||
# comes from the workflow default / a repo variable — NOT from the PR's diff — so
|
||||
# a config-only change can't move it, and the first PR that introduces the config
|
||||
# passes because its tokenEnv already matches the default. (Backstop: the
|
||||
# published CLI's own denylist refuses GITHUB_TOKEN/cloud-cred names regardless.
|
||||
# Full fix: load config from the trusted base ref once this reviewer is on the
|
||||
# default branch — see ROADMAP.)
|
||||
- name: Guard config.jsonc tokenEnv
|
||||
if: steps.resolve.outputs.run == 'true'
|
||||
env:
|
||||
EXPECTED: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'DO_NOT_USE_EXPERIMENTAL_ANTHROPIC_API_KEY' }}
|
||||
run: |
|
||||
values=$(grep -oE '"tokenEnv"[[:space:]]*:[[:space:]]*"[A-Za-z0-9_]+"' .expo-code-review/config.jsonc | sed -E 's/.*"([A-Za-z0-9_]+)"$/\1/')
|
||||
count=$(printf '%s\n' "$values" | grep -c .)
|
||||
if [ "$count" != "1" ] || [ "$values" != "$EXPECTED" ]; then
|
||||
echo "::error::.expo-code-review/config.jsonc auth.tokenEnv must be \"$EXPECTED\" (found: \"${values:-none}\"). Refusing to run so a PR can't redirect which secret is forwarded to the model provider."
|
||||
exit 1
|
||||
fi
|
||||
echo "config tokenEnv OK: $values"
|
||||
persist-credentials: false
|
||||
|
||||
# eas-cli pins yarn@4 via package.json "packageManager", so actions/setup-node's
|
||||
# environment probe runs `yarn` and the global yarn 1.x refuses unless corepack
|
||||
@@ -126,6 +113,20 @@ jobs:
|
||||
# tries to save an empty yarn cache and errors ("path(s) ... do not exist").
|
||||
package-manager-cache: false
|
||||
|
||||
# SECURITY: the TRUSTED BASE checkout above includes .expo-code-review/
|
||||
# config.jsonc, whose auth.tokenEnv names the env var the CLI forwards as the
|
||||
# model credential. `ecr verify-config` is the canonical guard (ships with the
|
||||
# CLI): it sweeps every config (root + routing + all scopes, referenced or
|
||||
# not) with the engine's real JSONC parser and refuses unless tokenEnv appears
|
||||
# exactly once, in a ROOT-owned file, equal to ECR_EXPECTED_TOKEN_ENV — so a
|
||||
# config change can't repoint it at another runner secret or sneak in a
|
||||
# JSON-escaped key. Layer 2; layer 1 is the same runtime lock inside `ecr ci`.
|
||||
- name: Guard config tokenEnv (root + routing + all scopes)
|
||||
if: steps.resolve.outputs.run == 'true'
|
||||
env:
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_ACCESS_TOKEN' }}
|
||||
run: npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr verify-config
|
||||
|
||||
- name: Run AI review
|
||||
if: steps.resolve.outputs.run == 'true'
|
||||
# npx installs @expo/code-review-cli and its deps (incl. the `opencode`
|
||||
@@ -135,9 +136,14 @@ jobs:
|
||||
env:
|
||||
# GitHub token scoped by the permissions block above (PR comments only).
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Claude Pro/Max OAuth token (auth.mode "oauth" in .expo-code-review/
|
||||
# config.jsonc reads this env var). Not an x-api-key.
|
||||
DO_NOT_USE_EXPERIMENTAL_ANTHROPIC_API_KEY: ${{ secrets.DO_NOT_USE_EXPERIMENTAL_ANTHROPIC_API_KEY }}
|
||||
# ChatGPT/Codex subscription ACCESS token (auth.mode "oauth" in
|
||||
# .expo-code-review/config.jsonc reads this env var) — used as-is, no
|
||||
# rotation involvement; re-mint before its ~10-day expiry. All reviews
|
||||
# run on the subscription.
|
||||
CODEX_OAUTH_ACCESS_TOKEN: ${{ secrets.CODEX_OAUTH_ACCESS_TOKEN }}
|
||||
# Layer-1 runtime auth lock (mirrors the guard step): the CLI refuses to
|
||||
# run when the tokenEnv the loaded config honors differs from this.
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_ACCESS_TOKEN' }}
|
||||
# Optional: override the model used by every agent.
|
||||
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
|
||||
AGENTS: ${{ steps.resolve.outputs.agents }}
|
||||
|
||||
+29
-1
@@ -8,10 +8,36 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas simulator:availability` to check whether EAS Simulator is enabled for the current project account. ([#4045](https://github.com/expo/eas-cli/pull/4045) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add experimental `eas simulator:events` command. ([#4001](https://github.com/expo/eas-cli/pull/4001) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Collect and upload Argent tool-server session events during remote simulator sessions, mirroring agent-device event collection. ([#4067](https://github.com/expo/eas-cli/pull/4067) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add `--name` to `eas simulator:start` and show simulator session names in `eas simulator:get` and `eas simulator:list`. ([#4099](https://github.com/expo/eas-cli/pull/4099) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Apply --session-id in observe:events summary mode. ([#4100](https://github.com/expo/eas-cli/pull/4100) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Suggest running `agent-device` via `npx` in EAS Simulator session instructions. ([#4070](https://github.com/expo/eas-cli/pull/4070) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Refresh README as an EAS product landing page. ([#3844](https://github.com/expo/eas-cli/pull/3844) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Fix the EAS logo not rendering in the repository root README. ([#4093](https://github.com/expo/eas-cli/pull/4093) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [21.3.0](https://github.com/expo/eas-cli/releases/tag/v21.3.0) - 2026-07-27
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Handle new server-side errors thrown when observe features are blocked. ([#4042](https://github.com/expo/eas-cli/pull/4042) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add `eas project:icon:set` to upload a project icon from the command line. ([#4068](https://github.com/expo/eas-cli/pull/4068) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `--account` flag to `eas init` to choose the account that should own the project, enabling non-interactive project creation (`eas init --account <name> --non-interactive`), and improve unconfigured-project error messages to list the accounts you can create projects in. ([#4057](https://github.com/expo/eas-cli/pull/4057) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Add `--json` flag to `eas init` to print the linked project (status, project ID, owner, slug, dashboard URL) as JSON to stdout, and add usage examples to `eas init --help`. ([#4079](https://github.com/expo/eas-cli/pull/4079) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `eas testflight:feedback` and `eas testflight:crashes` to read TestFlight tester feedback (comments, device info, screenshot URLs) and crash reports (including full crash logs) from App Store Connect. Both support `--offset`/`--limit` pagination, `--json`, and looking up a single submission by ID or by the App Store Connect URL that EAS workflow triggers expose as `${{ app_store_connect.beta_feedback.url }}`. ([#4084](https://github.com/expo/eas-cli/pull/4084) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [21.2.0](https://github.com/expo/eas-cli/releases/tag/v21.2.0) - 2026-07-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas simulator:availability` to check whether EAS Simulator is enabled for the current project account. ([#4045](https://github.com/expo/eas-cli/pull/4045) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Calibrate the experimental AI code reviewer's agent prompts and noise config to eas-cli's conventions. ([#4065](https://github.com/expo/eas-cli/pull/4065) by [@brentvatne](https://github.com/brentvatne))
|
||||
@@ -28,6 +54,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
- [eas-cli] Fix `eas update` failing with a server error when run inside an EAS Build. ([#4048](https://github.com/expo/eas-cli/pull/4048) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Avoid splitting an emoji when truncating a long git commit message, which produced an invalid commit message the build server rejected. ([#4055](https://github.com/expo/eas-cli/pull/4055) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Prompt for the account that should own a not-yet-configured EAS project when linking it from commands like `eas build`, `eas submit`, and `eas update`, instead of silently defaulting to the personal account. ([#4054](https://github.com/expo/eas-cli/pull/4054) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
|
||||
## [21.0.3](https://github.com/expo/eas-cli/releases/tag/v21.0.3) - 2026-07-22
|
||||
|
||||
@@ -87,6 +114,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [build-tools] Skip embedded bundle upload for development client builds instead of warning that the bundle is missing. ([#3940](https://github.com/expo/eas-cli/pull/3940) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Accept a navigation metric name in observe:metrics when passed in on the command line. ([#3973](https://github.com/expo/eas-cli/pull/3973) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Retry uploading assets that don't finish processing during `eas update`, instead of failing the update. ([#3918](https://github.com/expo/eas-cli/pull/3918) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Show network and HTTP response details for EAS Build request failures when GraphQL errors are empty. ([#3982](https://github.com/expo/eas-cli/pull/3982) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "21.2.0",
|
||||
"version": "21.4.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "21.1.0",
|
||||
"version": "21.4.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -39,14 +39,14 @@
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "21.0.0",
|
||||
"@expo/eas-build-job": "21.1.0",
|
||||
"@expo/eas-build-job": "21.4.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "21.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": "21.1.0",
|
||||
"@expo/steps": "21.4.0",
|
||||
"@expo/template-file": "21.0.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
|
||||
// Redirect ~/.argent (where the tool-server writes its state file and event log) to a temp
|
||||
// home so waitForArgentToolServerStateAsync — which lives in the module under test and cannot
|
||||
// be mocked directly — can read a real state file we control.
|
||||
jest.mock('node:os', () => {
|
||||
const actual = jest.requireActual('node:os');
|
||||
const actualPath = jest.requireActual('node:path');
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => actualPath.join(actual.tmpdir(), 'eas-argent-orchestration-home'),
|
||||
};
|
||||
});
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/processes', () => ({ isProcessDescendantOfAsync: jest.fn() }));
|
||||
jest.mock('../../utils/argentArtifacts', () => ({ pollArgentArtifactsForUploadAsync: jest.fn() }));
|
||||
jest.mock('../../utils/argentEvents', () => ({
|
||||
...jest.requireActual('../../utils/argentEvents'),
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
startServeSimWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const TEST_HOME = path.join(os.tmpdir(), 'eas-argent-orchestration-home');
|
||||
const ARGENT_STATE_DIR = path.join(TEST_HOME, '.argent');
|
||||
const EXPECTED_EVENT_LOG_PATH = path.join(ARGENT_STATE_DIR, 'tool-server-events.jsonl');
|
||||
|
||||
const mockStopAsync = jest.fn();
|
||||
|
||||
describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue(undefined as never);
|
||||
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
|
||||
jest.mocked(pollArgentArtifactsForUploadAsync).mockResolvedValue(undefined);
|
||||
mockStopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({ stopAsync: mockStopAsync });
|
||||
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
jest.mocked(getNgrokAuthtokenOrThrow).mockReturnValue('ngrok-token');
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(spawnDetached).mockReturnValue({ pid: 4242, getOutput: () => '' });
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue('https://argent-abc.tunnel.example.com');
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
|
||||
// The (real) tool-server state wait reads this file from the redirected ~/.argent.
|
||||
await fs.promises.mkdir(ARGENT_STATE_DIR, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json'),
|
||||
JSON.stringify({ port: 5678, pid: 9999, token: 'tool-server-token' })
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('enables the event log flag, shares one path, and starts/stops the collector', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: { package_version: { value: undefined } },
|
||||
outputs: {},
|
||||
env: { EXISTING: 'value' },
|
||||
} as never
|
||||
);
|
||||
|
||||
// (1) The event log flag is enabled, before the tool-server is launched.
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
const enableEventLogIndex = spawnCalls.findIndex(
|
||||
([command, args]) =>
|
||||
command === 'bunx' && Array.isArray(args) && args.includes('tool-server-event-log')
|
||||
);
|
||||
expect(enableEventLogIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[enableEventLogIndex]).toBeLessThan(
|
||||
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
// (2) The tool-server and the collector are pinned to the exact same event log path.
|
||||
const serverEnv = jest.mocked(spawnDetached).mock.calls[0][0].env;
|
||||
expect(serverEnv.ARGENT_EVENT_LOG).toBe(EXPECTED_EVENT_LOG_PATH);
|
||||
expect(serverEnv.EXISTING).toBe('value');
|
||||
expect(jest.mocked(startArgentEventCollectionAsync).mock.calls[0][0]).toMatchObject({
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
eventLogPath: EXPECTED_EVENT_LOG_PATH,
|
||||
});
|
||||
|
||||
// (3) Collection starts (before we wait for the session to stop) and (4) is torn down
|
||||
// afterwards, exactly once.
|
||||
expect(startArgentEventCollectionAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(startArgentEventCollectionAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockStopAsync.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,16 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Sentry } from '../../../sentry';
|
||||
import {
|
||||
MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
stopArgentEventCollectionSafelyAsync,
|
||||
waitForArgentToolServerStateAsync,
|
||||
warnIfArgentPackageVersionCannotBeVerified,
|
||||
} from '../startArgentRemoteSession';
|
||||
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
describe(warnIfArgentPackageVersionCannotBeVerified, () => {
|
||||
const warn = jest.fn();
|
||||
const logger = { warn } as unknown as bunyan;
|
||||
@@ -143,3 +147,36 @@ describe(waitForArgentToolServerStateAsync, () => {
|
||||
).rejects.toThrow(`state file belonging to process ${process.pid}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe(stopArgentEventCollectionSafelyAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reports an unexpected stop failure without rejecting', async () => {
|
||||
const error = new Error('stop failed');
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
|
||||
await expect(
|
||||
stopArgentEventCollectionSafelyAsync({
|
||||
eventCollection: { stopAsync: jest.fn().mockRejectedValue(error) },
|
||||
deviceRunSessionId: 'session-id',
|
||||
logger,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not finish argent session event collection',
|
||||
error,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: { phase: 'argent-event-collection', operation: 'stop' },
|
||||
extras: { deviceRunSessionId: 'session-id' },
|
||||
}
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: error },
|
||||
'Could not finish argent session event collection.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import path from 'node:path';
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/deviceRunSessionEvents';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Sentry } from '../../sentry';
|
||||
import { isProcessDescendantOfAsync } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
@@ -31,9 +32,17 @@ import {
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
|
||||
export const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.12.0';
|
||||
// 0.16.0 is the first version that exposes the tool-server event log flag; keeping the floor
|
||||
// here lets us enable it (and the artifacts list endpoint) unconditionally below.
|
||||
export const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.16.0';
|
||||
const ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG = 'artifacts-list-endpoint';
|
||||
// Tells the tool-server to write its structured event log so we can collect session events.
|
||||
const ARGENT_EVENT_LOG_FLAG = 'tool-server-event-log';
|
||||
const ARGENT_STATE_DIR = path.join(os.homedir(), '.argent');
|
||||
// Pin the event log path explicitly and hand the same value to the tool-server (via
|
||||
// ARGENT_EVENT_LOG) and the collector, so an ambient ARGENT_EVENT_LOG or a future change to
|
||||
// Argent's default can never make the two disagree.
|
||||
const ARGENT_EVENT_LOG_PATH = path.join(ARGENT_STATE_DIR, ARGENT_EVENT_LOG_FILENAME);
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
const ArgentToolServerStateSchema = z.object({
|
||||
@@ -87,6 +96,13 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
await spawn(
|
||||
'bunx',
|
||||
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
|
||||
// Keep Argent itself in foreground mode under the detached bunx process. This preserves
|
||||
// the bunx -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
@@ -102,7 +118,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
'0',
|
||||
'--force',
|
||||
],
|
||||
env,
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH },
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
@@ -142,6 +158,13 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
signal: artifactPollSignal,
|
||||
});
|
||||
|
||||
const eventCollection = await startArgentEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
eventLogPath: ARGENT_EVENT_LOG_PATH,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const publicToolsUrl = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
@@ -184,6 +207,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
await stopArgentEventCollectionSafelyAsync({ eventCollection, deviceRunSessionId, logger });
|
||||
artifactPollAbortController.abort();
|
||||
try {
|
||||
await artifactPollingPromise;
|
||||
@@ -197,6 +221,28 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopArgentEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
}: {
|
||||
eventCollection: { stopAsync: () => Promise<void> };
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await eventCollection.stopAsync();
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not finish argent session event collection', error, {
|
||||
level: 'warning',
|
||||
tags: { phase: 'argent-event-collection', operation: 'stop' },
|
||||
extras: { deviceRunSessionId },
|
||||
});
|
||||
logger.warn({ err: error }, 'Could not finish argent session event collection.');
|
||||
}
|
||||
}
|
||||
|
||||
export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
packageVersion,
|
||||
logger,
|
||||
|
||||
+10
-5
@@ -6,7 +6,7 @@ import path from 'node:path';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../deviceRunSessionEvents';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../agentDeviceEvents';
|
||||
|
||||
const mockEventLogStream = {
|
||||
init: jest.fn(async () => undefined),
|
||||
@@ -370,7 +370,8 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
agentDeviceEventParseFailures: { 'invalid-json': 1, 'invalid-event': 1 },
|
||||
producer: 'agent-device',
|
||||
eventParseFailures: { 'invalid-json': 1, 'invalid-event': 1 },
|
||||
parseFailureCount: 2,
|
||||
},
|
||||
'Could not parse 2 agent-device event log records.'
|
||||
@@ -446,7 +447,7 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
await waitForAsync(() =>
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: reportingError },
|
||||
'Agent-device event collection poller failed.'
|
||||
'Event collection poller for agent-device failed.'
|
||||
)
|
||||
);
|
||||
await expect(collection.stopAsync()).resolves.toBeUndefined();
|
||||
@@ -457,7 +458,7 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(2);
|
||||
expect(Sentry.capture).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'Agent-device event collection poller failed',
|
||||
'Event collection poller for agent-device failed',
|
||||
reportingError,
|
||||
{
|
||||
level: 'warning',
|
||||
@@ -502,7 +503,11 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
}),
|
||||
{
|
||||
level: 'warning',
|
||||
tags: { phase: 'device-run-session-event-collection', operation: 'setup' },
|
||||
tags: {
|
||||
phase: 'device-run-session-event-collection',
|
||||
operation: 'setup',
|
||||
producer: 'agent-device',
|
||||
},
|
||||
extras: { deviceRunSessionId: 'session-id' },
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,324 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { startArgentEventCollectionAsync } from '../argentEvents';
|
||||
|
||||
const mockEventLogStream = {
|
||||
init: jest.fn(async () => undefined),
|
||||
write: jest.fn(),
|
||||
cleanUp: jest.fn(async () => undefined),
|
||||
};
|
||||
|
||||
jest.mock('../../../logging/RemoteLoggerStream', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => mockEventLogStream),
|
||||
}));
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
const EVENT_LOG_FILENAME = 'tool-server-events.jsonl';
|
||||
|
||||
describe(startArgentEventCollectionAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('maps tool lifecycle records onto the shared operation vocabulary', async () => {
|
||||
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'argent-events-'));
|
||||
const eventLogFile = path.join(stateDir, EVENT_LOG_FILENAME);
|
||||
await fs.promises.writeFile(
|
||||
eventLogFile,
|
||||
`${JSON.stringify(
|
||||
bunyanRecord({
|
||||
level: 30,
|
||||
type: 'tool.invoked',
|
||||
toolId: 'screenshot',
|
||||
toolInvocationId: 'call-1',
|
||||
msg: 'Capturing screenshot.',
|
||||
time: '2026-07-10T12:00:00.000Z',
|
||||
})
|
||||
)}\n`
|
||||
);
|
||||
const ctx = createContext();
|
||||
const logger = createLogger();
|
||||
const collection = await startArgentEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
eventLogPath: eventLogFile,
|
||||
logger,
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForAsync(() => expect(mockEventLogStream.write).toHaveBeenCalledTimes(1));
|
||||
await fs.promises.appendFile(
|
||||
eventLogFile,
|
||||
`${JSON.stringify(
|
||||
bunyanRecord({
|
||||
level: 30,
|
||||
type: 'tool.completed',
|
||||
toolId: 'screenshot',
|
||||
toolInvocationId: 'call-1',
|
||||
durationMs: 12.34,
|
||||
msg: 'Captured screenshot.',
|
||||
time: '2026-07-10T12:00:01.000Z',
|
||||
})
|
||||
)}\n`
|
||||
);
|
||||
await waitForAsync(() => expect(mockEventLogStream.write).toHaveBeenCalledTimes(2));
|
||||
} finally {
|
||||
await collection.stopAsync();
|
||||
await fs.promises.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
deviceRunSessionId: 'session-id',
|
||||
});
|
||||
expect(RemoteLoggerStream).toHaveBeenCalledWith({
|
||||
logger,
|
||||
uploadMethod: {
|
||||
signedUrl: {
|
||||
url: 'https://uploads.expo.test/events',
|
||||
headers: { 'content-disposition': 'attachment; filename="events.ndjson"' },
|
||||
},
|
||||
},
|
||||
options: { uploadIntervalMs: 5_000 },
|
||||
});
|
||||
expect(mockEventLogStream.init).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventLogStream.cleanUp).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventLogStream.write).toHaveBeenNthCalledWith(1, {
|
||||
v: 1,
|
||||
eventId: 'argent:session-id:tool-server-events:1',
|
||||
ts: '2026-07-10T12:00:00.000Z',
|
||||
producer: 'argent',
|
||||
type: 'operation.started',
|
||||
operationId: 'call-1',
|
||||
summary: 'Capturing screenshot.',
|
||||
data: { toolId: 'screenshot', sourceType: 'tool.invoked', sourceLevel: 30 },
|
||||
});
|
||||
expect(mockEventLogStream.write).toHaveBeenNthCalledWith(2, {
|
||||
v: 1,
|
||||
eventId: 'argent:session-id:tool-server-events:2',
|
||||
ts: '2026-07-10T12:00:01.000Z',
|
||||
producer: 'argent',
|
||||
type: 'operation.completed',
|
||||
operationId: 'call-1',
|
||||
outcome: 'success',
|
||||
durationMs: 12.34,
|
||||
summary: 'Captured screenshot.',
|
||||
data: { toolId: 'screenshot', sourceType: 'tool.completed', sourceLevel: 30 },
|
||||
});
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks failed tool records as failed operations and keeps their failure signal', async () => {
|
||||
const failureSignal = {
|
||||
error_code: 'REGISTRY_TOOL_FAILURE_UNCLASSIFIED',
|
||||
failure_stage: 'registry_tool_failed_event',
|
||||
failure_area: 'registry',
|
||||
error_kind: 'unknown',
|
||||
};
|
||||
const event = await collectSingleEventAsync(
|
||||
bunyanRecord({
|
||||
level: 50,
|
||||
type: 'tool.failed',
|
||||
toolId: 'screenshot',
|
||||
toolInvocationId: 'call-2',
|
||||
failureSignal,
|
||||
msg: 'Failed to capture screenshot.',
|
||||
time: '2026-07-10T12:00:02.000Z',
|
||||
})
|
||||
);
|
||||
|
||||
expect(event).toEqual({
|
||||
v: 1,
|
||||
eventId: 'argent:session-id:tool-server-events:1',
|
||||
ts: '2026-07-10T12:00:02.000Z',
|
||||
producer: 'argent',
|
||||
type: 'operation.completed',
|
||||
operationId: 'call-2',
|
||||
outcome: 'failure',
|
||||
summary: 'Failed to capture screenshot.',
|
||||
data: {
|
||||
toolId: 'screenshot',
|
||||
failureSignal,
|
||||
sourceType: 'tool.failed',
|
||||
sourceLevel: 50,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through non-tool lifecycle records unchanged', async () => {
|
||||
const event = await collectSingleEventAsync(
|
||||
bunyanRecord({
|
||||
level: 30,
|
||||
type: 'service.state_change',
|
||||
serviceId: 'ax-service',
|
||||
from: 'stopped',
|
||||
to: 'running',
|
||||
msg: 'Service ax-service changed state from stopped to running.',
|
||||
time: '2026-07-10T12:00:03.000Z',
|
||||
})
|
||||
);
|
||||
|
||||
expect(event).toEqual({
|
||||
v: 1,
|
||||
eventId: 'argent:session-id:tool-server-events:1',
|
||||
ts: '2026-07-10T12:00:03.000Z',
|
||||
producer: 'argent',
|
||||
type: 'service.state_change',
|
||||
summary: 'Service ax-service changed state from stopped to running.',
|
||||
data: {
|
||||
serviceId: 'ax-service',
|
||||
from: 'stopped',
|
||||
to: 'running',
|
||||
sourceType: 'service.state_change',
|
||||
sourceLevel: 30,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// `type` and `msg` are both required by Argent's EventLogRecord, so a record missing either
|
||||
// is a broken upstream contract worth reporting — not something to paper over with an
|
||||
// invented summary.
|
||||
it.each([
|
||||
['type', { time: '2026-07-10T12:00:00.000Z', msg: 'No type.' }],
|
||||
['msg', { time: '2026-07-10T12:00:00.000Z', type: 'tool.invoked', toolId: 'tap' }],
|
||||
])('reports a record missing %s without emitting an event', async (_field, record) => {
|
||||
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'argent-events-'));
|
||||
const eventLogPath = path.join(stateDir, EVENT_LOG_FILENAME);
|
||||
await fs.promises.writeFile(eventLogPath, `${JSON.stringify(bunyanRecord(record))}\n`);
|
||||
const collection = await startArgentEventCollectionAsync({
|
||||
ctx: createContext(),
|
||||
deviceRunSessionId: 'session-id',
|
||||
eventLogPath,
|
||||
logger: createLogger(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForAsync(() => expect(Sentry.capture).toHaveBeenCalledTimes(1));
|
||||
} finally {
|
||||
await collection.stopAsync();
|
||||
await fs.promises.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(mockEventLogStream.write).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith('Could not parse an argent event log record', {
|
||||
level: 'warning',
|
||||
tags: { phase: 'argent-event-collection', reason: 'invalid-event' },
|
||||
extras: { deviceRunSessionId: 'session-id', lineNumber: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces a non-ENOENT discovery error instead of treating it as no events', async () => {
|
||||
// A regular file standing in for the state directory makes access() fail with ENOTDIR
|
||||
// (not ENOENT), which must propagate to the collector's Sentry reporting.
|
||||
const notADirectory = path.join(
|
||||
await fs.promises.mkdtemp(path.join(os.tmpdir(), 'argent-events-')),
|
||||
'regular-file'
|
||||
);
|
||||
await fs.promises.writeFile(notADirectory, '');
|
||||
const eventLogPath = path.join(notADirectory, EVENT_LOG_FILENAME);
|
||||
const collection = await startArgentEventCollectionAsync({
|
||||
ctx: createContext(),
|
||||
deviceRunSessionId: 'session-id',
|
||||
eventLogPath,
|
||||
logger: createLogger(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForAsync(() => expect(Sentry.capture).toHaveBeenCalledTimes(1));
|
||||
} finally {
|
||||
await collection.stopAsync();
|
||||
await fs.promises.rm(notADirectory, { force: true });
|
||||
}
|
||||
|
||||
expect(mockEventLogStream.write).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not collect argent events',
|
||||
expect.objectContaining({ code: 'ENOTDIR' }),
|
||||
{
|
||||
level: 'warning',
|
||||
tags: { phase: 'argent-event-collection' },
|
||||
extras: { deviceRunSessionId: 'session-id' },
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function collectSingleEventAsync(
|
||||
record: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'argent-events-'));
|
||||
const eventLogPath = path.join(stateDir, EVENT_LOG_FILENAME);
|
||||
await fs.promises.writeFile(eventLogPath, `${JSON.stringify(record)}\n`);
|
||||
const collection = await startArgentEventCollectionAsync({
|
||||
ctx: createContext(),
|
||||
deviceRunSessionId: 'session-id',
|
||||
eventLogPath,
|
||||
logger: createLogger(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForAsync(() => expect(mockEventLogStream.write).toHaveBeenCalledTimes(1));
|
||||
} finally {
|
||||
await collection.stopAsync();
|
||||
await fs.promises.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return mockEventLogStream.write.mock.calls[0][0];
|
||||
}
|
||||
|
||||
function bunyanRecord(fields: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
name: 'argent-tool-server',
|
||||
hostname: 'test-host',
|
||||
pid: 4242,
|
||||
v: 0,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
function createContext(): CustomBuildContext {
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createEventLogUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/events',
|
||||
headers: { 'content-disposition': 'attachment; filename="events.ndjson"' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
return { graphqlClient: { mutation } } as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function createLogger(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
async function waitForAsync(assertion: () => void): Promise<void> {
|
||||
const deadline = Date.now() + 2_000;
|
||||
for (;;) {
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
} catch (err) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw err;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
type DeviceRunSessionEvent,
|
||||
type DeviceRunSessionEventParseFailure,
|
||||
startDeviceRunSessionEventCollectionAsync,
|
||||
} from './deviceRunSessionEvents';
|
||||
|
||||
const AGENT_DEVICE_PRODUCER = 'agent-device';
|
||||
|
||||
const AGENT_DEVICE_EVENT_KIND_TO_TYPE: Record<string, string> = {
|
||||
'request.started': 'operation.started',
|
||||
'request.finished': 'operation.completed',
|
||||
'action.recorded': 'interaction.recorded',
|
||||
};
|
||||
|
||||
const AgentDeviceEventSchema = z
|
||||
.object({
|
||||
version: z.number(),
|
||||
ts: z.string(),
|
||||
session: z.string(),
|
||||
kind: z.string(),
|
||||
requestId: z.string().optional().catch(undefined),
|
||||
command: z.string().optional().catch(undefined),
|
||||
status: z.string().optional().catch(undefined),
|
||||
summary: z.string().optional().catch(undefined),
|
||||
details: z.record(z.string(), z.unknown()).optional().catch(undefined),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type AgentDeviceEvent = z.infer<typeof AgentDeviceEventSchema>;
|
||||
|
||||
export async function startAgentDeviceEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
stateDir,
|
||||
logger,
|
||||
pollIntervalMs,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
stateDir: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<{ stopAsync: () => Promise<void> }> {
|
||||
return startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
pollIntervalMs,
|
||||
source: {
|
||||
producer: AGENT_DEVICE_PRODUCER,
|
||||
findEventFilesAsync: () => findAgentDeviceEventFilesAsync(stateDir),
|
||||
sourceKeyForFile: eventFile => path.basename(path.dirname(eventFile)),
|
||||
parseLine: ({ line, sourceKey, sequenceNumber, deviceRunSessionId }) => {
|
||||
const { event, failure } = parseAgentDeviceEvent(line);
|
||||
if (!event) {
|
||||
return { failure };
|
||||
}
|
||||
return {
|
||||
event: normalizeAgentDeviceEvent({
|
||||
event,
|
||||
sequenceNumber,
|
||||
sourceSessionDirectory: sourceKey,
|
||||
deviceRunSessionId,
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function findAgentDeviceEventFilesAsync(stateDir: string): Promise<string[]> {
|
||||
const sessionsDir = path.join(stateDir, 'sessions');
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = await fs.promises.readdir(sessionsDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return entries
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => path.join(sessionsDir, entry.name, 'events.ndjson'));
|
||||
}
|
||||
|
||||
type ParsedAgentDeviceEvent = {
|
||||
event?: AgentDeviceEvent;
|
||||
failure?: DeviceRunSessionEventParseFailure;
|
||||
};
|
||||
|
||||
function parseAgentDeviceEvent(line: string): ParsedAgentDeviceEvent {
|
||||
if (!line.trim()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const result = AgentDeviceEventSchema.safeParse(JSON.parse(line));
|
||||
return result.success ? { event: result.data } : { failure: 'invalid-event' };
|
||||
} catch {
|
||||
return { failure: 'invalid-json' };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAgentDeviceEvent({
|
||||
event,
|
||||
sequenceNumber,
|
||||
sourceSessionDirectory,
|
||||
deviceRunSessionId,
|
||||
}: {
|
||||
event: AgentDeviceEvent;
|
||||
sequenceNumber: number;
|
||||
sourceSessionDirectory: string;
|
||||
deviceRunSessionId: string;
|
||||
}): DeviceRunSessionEvent {
|
||||
const type = AGENT_DEVICE_EVENT_KIND_TO_TYPE[event.kind] ?? event.kind;
|
||||
const durationMs = event.details?.durationMs;
|
||||
const outcome =
|
||||
event.status === 'ok' ? 'success' : event.status === 'error' ? 'failure' : undefined;
|
||||
const summary =
|
||||
event.summary ??
|
||||
(event.kind === 'request.started'
|
||||
? `Started ${event.command ?? 'activity'}`
|
||||
: event.kind === 'request.finished'
|
||||
? `Finished ${event.command ?? 'activity'}`
|
||||
: event.kind === 'action.recorded'
|
||||
? `Recorded ${event.command ?? 'activity'}`
|
||||
: `${event.kind}${event.command ? `: ${event.command}` : ''}`);
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
// Consumers use eventId to deduplicate events across polls. Keep the per-file sequence
|
||||
// monotonic across source-file truncations so an ID is never reused during collection.
|
||||
eventId: `agent-device:${deviceRunSessionId}:${sourceSessionDirectory}:${sequenceNumber}`,
|
||||
ts: event.ts,
|
||||
producer: 'agent-device',
|
||||
type,
|
||||
...(event.requestId ? { operationId: event.requestId } : {}),
|
||||
...(outcome ? { outcome } : {}),
|
||||
...(typeof durationMs === 'number' ? { durationMs } : {}),
|
||||
summary,
|
||||
data: {
|
||||
...event.details,
|
||||
session: event.session,
|
||||
sourceVersion: event.version,
|
||||
...(event.status ? { sourceStatus: event.status } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
type DeviceRunSessionEvent,
|
||||
type DeviceRunSessionEventParseFailure,
|
||||
startDeviceRunSessionEventCollectionAsync,
|
||||
} from './deviceRunSessionEvents';
|
||||
|
||||
const ARGENT_PRODUCER = 'argent';
|
||||
// Argent's tool-server writes its event log to `~/.argent/tool-server-events.jsonl`
|
||||
// (its default $ARGENT_EVENT_LOG path) once the `tool-server-event-log` flag is on.
|
||||
export const ARGENT_EVENT_LOG_FILENAME = 'tool-server-events.jsonl';
|
||||
|
||||
// Argent records are bunyan log lines tagged with a `type`. Map the tool-lifecycle
|
||||
// types onto the shared operation vocabulary; anything else (service.*,
|
||||
// tool_server.*, future types) passes through unchanged, mirroring agent-device.
|
||||
const ARGENT_EVENT_TYPE_TO_TYPE: Record<string, string> = {
|
||||
'tool.invoked': 'operation.started',
|
||||
'tool.completed': 'operation.completed',
|
||||
'tool.failed': 'operation.completed',
|
||||
};
|
||||
|
||||
// Bunyan bookkeeping plus the fields we promote to the top level of a
|
||||
// DeviceRunSessionEvent — excluded from the free-form `data` bag so it carries
|
||||
// only the record's own context (toolId, serviceId, failureSignal, ...).
|
||||
const NON_DATA_KEYS = new Set([
|
||||
'name',
|
||||
'hostname',
|
||||
'pid',
|
||||
'v',
|
||||
'time',
|
||||
'msg',
|
||||
'level',
|
||||
'type',
|
||||
'toolInvocationId',
|
||||
'durationMs',
|
||||
]);
|
||||
|
||||
const ArgentEventSchema = z
|
||||
.object({
|
||||
time: z.string(),
|
||||
type: z.string(),
|
||||
// Argent's EventLogRecord requires `msg` and every emit site sets it to the
|
||||
// human-readable description of the event, so it is our summary verbatim.
|
||||
msg: z.string(),
|
||||
level: z.number().optional().catch(undefined),
|
||||
toolId: z.string().optional().catch(undefined),
|
||||
toolInvocationId: z.string().optional().catch(undefined),
|
||||
durationMs: z.number().optional().catch(undefined),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type ArgentEvent = z.infer<typeof ArgentEventSchema>;
|
||||
|
||||
type ParsedArgentEvent = {
|
||||
event?: ArgentEvent;
|
||||
failure?: DeviceRunSessionEventParseFailure;
|
||||
};
|
||||
|
||||
export async function startArgentEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
eventLogPath,
|
||||
logger,
|
||||
pollIntervalMs,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
// Absolute path Argent's tool-server writes its event log to (its `ARGENT_EVENT_LOG`).
|
||||
// The caller pins this on both sides so the collector and the tool-server never disagree.
|
||||
eventLogPath: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<{ stopAsync: () => Promise<void> }> {
|
||||
return startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
pollIntervalMs,
|
||||
source: {
|
||||
producer: ARGENT_PRODUCER,
|
||||
findEventFilesAsync: () => findArgentEventFilesAsync(eventLogPath),
|
||||
sourceKeyForFile: eventFile => path.basename(eventFile, path.extname(eventFile)),
|
||||
parseLine: ({ line, sourceKey, sequenceNumber, deviceRunSessionId }) => {
|
||||
const { event, failure } = parseArgentEvent(line);
|
||||
if (!event) {
|
||||
return { failure };
|
||||
}
|
||||
return {
|
||||
event: normalizeArgentEvent({ event, sequenceNumber, sourceKey, deviceRunSessionId }),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function findArgentEventFilesAsync(eventLogPath: string): Promise<string[]> {
|
||||
try {
|
||||
await fs.promises.access(eventLogPath);
|
||||
return [eventLogPath];
|
||||
} catch (err) {
|
||||
// The tool-server has not created the file yet — expected until the first event is written.
|
||||
// Surface any other error (permissions, I/O) to the engine so it reaches Sentry.
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgentEvent(line: string): ParsedArgentEvent {
|
||||
if (!line.trim()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const result = ArgentEventSchema.safeParse(JSON.parse(line));
|
||||
return result.success ? { event: result.data } : { failure: 'invalid-event' };
|
||||
} catch {
|
||||
return { failure: 'invalid-json' };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeArgentEvent({
|
||||
event,
|
||||
sequenceNumber,
|
||||
sourceKey,
|
||||
deviceRunSessionId,
|
||||
}: {
|
||||
event: ArgentEvent;
|
||||
sequenceNumber: number;
|
||||
sourceKey: string;
|
||||
deviceRunSessionId: string;
|
||||
}): DeviceRunSessionEvent {
|
||||
const type = ARGENT_EVENT_TYPE_TO_TYPE[event.type] ?? event.type;
|
||||
const outcome =
|
||||
event.type === 'tool.completed'
|
||||
? 'success'
|
||||
: event.type === 'tool.failed'
|
||||
? 'failure'
|
||||
: undefined;
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(event)) {
|
||||
if (!NON_DATA_KEYS.has(key) && value !== undefined) {
|
||||
data[key] = value;
|
||||
}
|
||||
}
|
||||
data.sourceType = event.type;
|
||||
if (typeof event.level === 'number') {
|
||||
data.sourceLevel = event.level;
|
||||
}
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
// Consumers use eventId to deduplicate events across polls. Keep the per-file sequence
|
||||
// monotonic across source-file truncations so an ID is never reused during collection.
|
||||
eventId: `argent:${deviceRunSessionId}:${sourceKey}:${sequenceNumber}`,
|
||||
ts: event.time,
|
||||
producer: 'argent',
|
||||
type,
|
||||
...(event.toolInvocationId ? { operationId: event.toolInvocationId } : {}),
|
||||
...(outcome ? { outcome } : {}),
|
||||
...(typeof event.durationMs === 'number' ? { durationMs: event.durationMs } : {}),
|
||||
summary: event.msg,
|
||||
data,
|
||||
};
|
||||
}
|
||||
@@ -2,10 +2,8 @@ import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import RemoteLoggerStream from '../../logging/RemoteLoggerStream';
|
||||
@@ -15,26 +13,6 @@ import { type SignedUrl } from '../../storage/uploadWithSignedUrl';
|
||||
const POLL_INTERVAL_MS = 1_000;
|
||||
const UPLOAD_INTERVAL_MS = 5_000;
|
||||
|
||||
const AGENT_DEVICE_EVENT_KIND_TO_TYPE: Record<string, string> = {
|
||||
'request.started': 'operation.started',
|
||||
'request.finished': 'operation.completed',
|
||||
'action.recorded': 'interaction.recorded',
|
||||
};
|
||||
|
||||
const AgentDeviceEventSchema = z
|
||||
.object({
|
||||
version: z.number(),
|
||||
ts: z.string(),
|
||||
session: z.string(),
|
||||
kind: z.string(),
|
||||
requestId: z.string().optional().catch(undefined),
|
||||
command: z.string().optional().catch(undefined),
|
||||
status: z.string().optional().catch(undefined),
|
||||
summary: z.string().optional().catch(undefined),
|
||||
details: z.record(z.string(), z.unknown()).optional().catch(undefined),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CREATE_DEVICE_RUN_SESSION_EVENT_LOG_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateDeviceRunSessionEventLogUploadSession($deviceRunSessionId: ID!) {
|
||||
deviceRunSession {
|
||||
@@ -48,8 +26,12 @@ const CREATE_DEVICE_RUN_SESSION_EVENT_LOG_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
}
|
||||
`);
|
||||
|
||||
type AgentDeviceEvent = z.infer<typeof AgentDeviceEventSchema>;
|
||||
|
||||
/**
|
||||
* The normalized, producer-agnostic event shape uploaded to the API server.
|
||||
* Every remote-session producer (agent-device, argent, ...) tails its own
|
||||
* event log and maps its records onto this common contract so consumers render
|
||||
* a single unified session timeline.
|
||||
*/
|
||||
export type DeviceRunSessionEvent = {
|
||||
v: 1;
|
||||
eventId: string;
|
||||
@@ -63,6 +45,44 @@ export type DeviceRunSessionEvent = {
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DeviceRunSessionEventParseFailure = 'invalid-json' | 'invalid-event';
|
||||
|
||||
export type DeviceRunSessionEventParseResult = {
|
||||
event?: DeviceRunSessionEvent;
|
||||
failure?: DeviceRunSessionEventParseFailure;
|
||||
};
|
||||
|
||||
/**
|
||||
* Producer-specific adapter plugged into the generic collection engine. It only
|
||||
* has to say where its event files live and how to turn one raw NDJSON line
|
||||
* into a {@link DeviceRunSessionEvent}; the engine owns tailing, upload, polling
|
||||
* and failure reporting.
|
||||
*/
|
||||
export type DeviceRunSessionEventSource = {
|
||||
/**
|
||||
* Stable producer identifier, e.g. `agent-device` or `argent`. Also used
|
||||
* verbatim in diagnostic messages, which are phrased so it never needs
|
||||
* recasing.
|
||||
*/
|
||||
producer: string;
|
||||
/** Discover the NDJSON event files to tail (absolute paths). */
|
||||
findEventFilesAsync: () => Promise<string[]>;
|
||||
/** Namespace component of the event ID derived from a tailed file path. */
|
||||
sourceKeyForFile: (eventFile: string) => string;
|
||||
/**
|
||||
* Parse one raw NDJSON line. `sequenceNumber` is monotonic per file (kept
|
||||
* stable across truncations) and `sourceKey` namespaces the event ID so an ID
|
||||
* is never reused during collection. Blank lines should return an empty
|
||||
* result so they neither emit an event nor count as a parse failure.
|
||||
*/
|
||||
parseLine: (args: {
|
||||
line: string;
|
||||
sourceKey: string;
|
||||
sequenceNumber: number;
|
||||
deviceRunSessionId: string;
|
||||
}) => DeviceRunSessionEventParseResult;
|
||||
};
|
||||
|
||||
type EventFileState = {
|
||||
offset: number;
|
||||
nextSequenceNumber: number;
|
||||
@@ -71,25 +91,20 @@ type EventFileState = {
|
||||
decoder: StringDecoder;
|
||||
};
|
||||
|
||||
type AgentDeviceEventParseFailure = 'invalid-json' | 'invalid-event';
|
||||
type AgentDeviceEventParseResult = {
|
||||
event?: AgentDeviceEvent;
|
||||
failure?: AgentDeviceEventParseFailure;
|
||||
};
|
||||
|
||||
export async function startAgentDeviceEventCollectionAsync({
|
||||
export async function startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
stateDir,
|
||||
source,
|
||||
logger,
|
||||
pollIntervalMs = POLL_INTERVAL_MS,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
stateDir: string;
|
||||
source: DeviceRunSessionEventSource;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<{ stopAsync: () => Promise<void> }> {
|
||||
const { producer } = source;
|
||||
let didReportEventLogFailure = false;
|
||||
const reportEventLogFailure = (error: Error, operation: 'setup' | 'cleanup'): void => {
|
||||
if (didReportEventLogFailure) {
|
||||
@@ -98,7 +113,7 @@ export async function startAgentDeviceEventCollectionAsync({
|
||||
didReportEventLogFailure = true;
|
||||
Sentry.capture('Could not persist device run session events', error, {
|
||||
level: 'warning',
|
||||
tags: { phase: 'device-run-session-event-collection', operation },
|
||||
tags: { phase: 'device-run-session-event-collection', operation, producer },
|
||||
extras: { deviceRunSessionId },
|
||||
});
|
||||
};
|
||||
@@ -124,13 +139,13 @@ export async function startAgentDeviceEventCollectionAsync({
|
||||
const states = new Map<string, EventFileState>();
|
||||
const controller = new AbortController();
|
||||
let parseFailureCount = 0;
|
||||
const parseFailureCounts: Record<AgentDeviceEventParseFailure, number> = {
|
||||
const parseFailureCounts: Record<DeviceRunSessionEventParseFailure, number> = {
|
||||
'invalid-json': 0,
|
||||
'invalid-event': 0,
|
||||
};
|
||||
let didReportCollectionFailure = false;
|
||||
const collectAsync = async (): Promise<void> => {
|
||||
const eventFiles = await findAgentDeviceEventFilesAsync(stateDir);
|
||||
const eventFiles = await source.findEventFilesAsync();
|
||||
await Promise.all(
|
||||
eventFiles.map(async eventFile => {
|
||||
const state = states.get(eventFile) ?? {
|
||||
@@ -144,6 +159,7 @@ export async function startAgentDeviceEventCollectionAsync({
|
||||
await collectEventFileAsync({
|
||||
eventFile,
|
||||
state,
|
||||
source,
|
||||
deviceRunSessionId,
|
||||
writeEvent: event => eventLogStream.write(event),
|
||||
onParseFailure: ({ failure, lineNumber }) => {
|
||||
@@ -153,12 +169,12 @@ export async function startAgentDeviceEventCollectionAsync({
|
||||
return;
|
||||
}
|
||||
logger.warn(
|
||||
{ agentDeviceEventParseFailure: failure, lineNumber },
|
||||
'Could not parse an agent-device event log record.'
|
||||
{ producer, eventParseFailure: failure, lineNumber },
|
||||
`Could not parse an ${producer} event log record.`
|
||||
);
|
||||
Sentry.capture('Could not parse an agent-device event log record', {
|
||||
Sentry.capture(`Could not parse an ${producer} event log record`, {
|
||||
level: 'warning',
|
||||
tags: { phase: 'agent-device-event-collection', reason: failure },
|
||||
tags: { phase: `${producer}-event-collection`, reason: failure },
|
||||
extras: { deviceRunSessionId, lineNumber },
|
||||
});
|
||||
},
|
||||
@@ -176,10 +192,10 @@ export async function startAgentDeviceEventCollectionAsync({
|
||||
}
|
||||
didReportCollectionFailure = true;
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
logger.warn({ err: error }, 'Could not collect agent-device events.');
|
||||
Sentry.capture('Could not collect agent-device events', error, {
|
||||
logger.warn({ err: error }, `Could not collect ${producer} events.`);
|
||||
Sentry.capture(`Could not collect ${producer} events`, error, {
|
||||
level: 'warning',
|
||||
tags: { phase: 'agent-device-event-collection' },
|
||||
tags: { phase: `${producer}-event-collection` },
|
||||
extras: { deviceRunSessionId },
|
||||
});
|
||||
}
|
||||
@@ -200,10 +216,10 @@ export async function startAgentDeviceEventCollectionAsync({
|
||||
})()
|
||||
.catch(err => {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
logger.warn({ err: error }, 'Agent-device event collection poller failed.');
|
||||
Sentry.capture('Agent-device event collection poller failed', error, {
|
||||
logger.warn({ err: error }, `Event collection poller for ${producer} failed.`);
|
||||
Sentry.capture(`Event collection poller for ${producer} failed`, error, {
|
||||
level: 'warning',
|
||||
tags: { phase: 'agent-device-event-collection', operation: 'poll' },
|
||||
tags: { phase: `${producer}-event-collection`, operation: 'poll' },
|
||||
extras: { deviceRunSessionId },
|
||||
});
|
||||
})
|
||||
@@ -218,12 +234,12 @@ export async function startAgentDeviceEventCollectionAsync({
|
||||
await collectSafelyAsync();
|
||||
if (parseFailureCount > 1) {
|
||||
logger.warn(
|
||||
{ agentDeviceEventParseFailures: parseFailureCounts, parseFailureCount },
|
||||
`Could not parse ${parseFailureCount} agent-device event log records.`
|
||||
{ producer, eventParseFailures: parseFailureCounts, parseFailureCount },
|
||||
`Could not parse ${parseFailureCount} ${producer} event log records.`
|
||||
);
|
||||
Sentry.capture('Could not parse multiple agent-device event log records', {
|
||||
Sentry.capture(`Could not parse multiple ${producer} event log records`, {
|
||||
level: 'warning',
|
||||
tags: { phase: 'agent-device-event-collection' },
|
||||
tags: { phase: `${producer}-event-collection` },
|
||||
extras: { deviceRunSessionId, parseFailureCount, parseFailureCounts },
|
||||
});
|
||||
}
|
||||
@@ -260,35 +276,23 @@ async function createEventLogUploadSessionAsync(
|
||||
};
|
||||
}
|
||||
|
||||
async function findAgentDeviceEventFilesAsync(stateDir: string): Promise<string[]> {
|
||||
const sessionsDir = path.join(stateDir, 'sessions');
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = await fs.promises.readdir(sessionsDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return entries
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => path.join(sessionsDir, entry.name, 'events.ndjson'));
|
||||
}
|
||||
|
||||
async function collectEventFileAsync({
|
||||
eventFile,
|
||||
state,
|
||||
source,
|
||||
deviceRunSessionId,
|
||||
writeEvent,
|
||||
onParseFailure,
|
||||
}: {
|
||||
eventFile: string;
|
||||
state: EventFileState;
|
||||
source: DeviceRunSessionEventSource;
|
||||
deviceRunSessionId: string;
|
||||
writeEvent: (event: DeviceRunSessionEvent) => void;
|
||||
onParseFailure: (failure: { failure: AgentDeviceEventParseFailure; lineNumber: number }) => void;
|
||||
onParseFailure: (failure: {
|
||||
failure: DeviceRunSessionEventParseFailure;
|
||||
lineNumber: number;
|
||||
}) => void;
|
||||
}): Promise<void> {
|
||||
let fileSize: number;
|
||||
try {
|
||||
@@ -309,6 +313,7 @@ async function collectEventFileAsync({
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceKey = source.sourceKeyForFile(eventFile);
|
||||
const handle = await fs.promises.open(eventFile, 'r');
|
||||
try {
|
||||
const buffer = new Uint8Array(new ArrayBuffer(fileSize - state.offset));
|
||||
@@ -321,80 +326,21 @@ async function collectEventFileAsync({
|
||||
for (const line of lines) {
|
||||
const lineNumber = state.nextLineNumber++;
|
||||
const sequenceNumber = state.nextSequenceNumber++;
|
||||
const { event, failure } = parseAgentDeviceEvent(line);
|
||||
const { event, failure } = source.parseLine({
|
||||
line,
|
||||
sourceKey,
|
||||
sequenceNumber,
|
||||
deviceRunSessionId,
|
||||
});
|
||||
if (failure) {
|
||||
onParseFailure({ failure, lineNumber });
|
||||
}
|
||||
if (!event) {
|
||||
continue;
|
||||
}
|
||||
const deviceRunSessionEvent = normalizeAgentDeviceEvent({
|
||||
event,
|
||||
sequenceNumber,
|
||||
sourceSessionDirectory: path.basename(path.dirname(eventFile)),
|
||||
deviceRunSessionId,
|
||||
});
|
||||
writeEvent(deviceRunSessionEvent);
|
||||
writeEvent(event);
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function parseAgentDeviceEvent(line: string): AgentDeviceEventParseResult {
|
||||
if (!line.trim()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const result = AgentDeviceEventSchema.safeParse(JSON.parse(line));
|
||||
return result.success ? { event: result.data } : { failure: 'invalid-event' };
|
||||
} catch {
|
||||
return { failure: 'invalid-json' };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAgentDeviceEvent({
|
||||
event,
|
||||
sequenceNumber,
|
||||
sourceSessionDirectory,
|
||||
deviceRunSessionId,
|
||||
}: {
|
||||
event: AgentDeviceEvent;
|
||||
sequenceNumber: number;
|
||||
sourceSessionDirectory: string;
|
||||
deviceRunSessionId: string;
|
||||
}): DeviceRunSessionEvent {
|
||||
const type = AGENT_DEVICE_EVENT_KIND_TO_TYPE[event.kind] ?? event.kind;
|
||||
const durationMs = event.details?.durationMs;
|
||||
const outcome =
|
||||
event.status === 'ok' ? 'success' : event.status === 'error' ? 'failure' : undefined;
|
||||
const summary =
|
||||
event.summary ??
|
||||
(event.kind === 'request.started'
|
||||
? `Started ${event.command ?? 'activity'}`
|
||||
: event.kind === 'request.finished'
|
||||
? `Finished ${event.command ?? 'activity'}`
|
||||
: event.kind === 'action.recorded'
|
||||
? `Recorded ${event.command ?? 'activity'}`
|
||||
: `${event.kind}${event.command ? `: ${event.command}` : ''}`);
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
// Consumers use eventId to deduplicate events across polls. Keep the per-file sequence
|
||||
// monotonic across source-file truncations so an ID is never reused during collection.
|
||||
eventId: `agent-device:${deviceRunSessionId}:${sourceSessionDirectory}:${sequenceNumber}`,
|
||||
ts: event.ts,
|
||||
producer: 'agent-device',
|
||||
type,
|
||||
...(event.requestId ? { operationId: event.requestId } : {}),
|
||||
...(outcome ? { outcome } : {}),
|
||||
...(typeof durationMs === 'number' ? { durationMs } : {}),
|
||||
summary,
|
||||
data: {
|
||||
...event.details,
|
||||
session: event.session,
|
||||
sourceVersion: event.version,
|
||||
...(event.status ? { sourceStatus: event.status } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "21.1.0",
|
||||
"version": "21.4.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "21.1.0"
|
||||
"@expo/steps": "21.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "21.1.0",
|
||||
"version": "21.4.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { CompositeFunctionConfigZ } from '../compositeFunction';
|
||||
|
||||
describe('CompositeFunctionConfigZ', () => {
|
||||
it('accepts a valid local composite function config', () => {
|
||||
const config = {
|
||||
name: 'Setup',
|
||||
inputs: [{ name: 'greeting', type: 'string', default_value: 'hello' }],
|
||||
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
|
||||
runs: {
|
||||
steps: [{ id: 'read', run: 'set-output version "1.0.0"' }],
|
||||
},
|
||||
};
|
||||
expect(CompositeFunctionConfigZ.parse(config)).toEqual(config);
|
||||
});
|
||||
|
||||
it('accepts shorthand input names', () => {
|
||||
const config = {
|
||||
inputs: ['greeting'],
|
||||
runs: {
|
||||
steps: [{ run: 'echo hello' }],
|
||||
},
|
||||
};
|
||||
expect(CompositeFunctionConfigZ.parse(config)).toEqual(config);
|
||||
});
|
||||
|
||||
it('accepts a minimal config with only runs.steps', () => {
|
||||
const config = {
|
||||
runs: {
|
||||
steps: [{ run: 'echo hello' }],
|
||||
},
|
||||
};
|
||||
expect(CompositeFunctionConfigZ.parse(config)).toEqual(config);
|
||||
});
|
||||
|
||||
it('applies default input type "string" when omitted', () => {
|
||||
const config = {
|
||||
inputs: [{ name: 'greeting' }],
|
||||
runs: {
|
||||
steps: [{ run: 'echo hello' }],
|
||||
},
|
||||
};
|
||||
expect(CompositeFunctionConfigZ.parse(config)).toEqual({
|
||||
inputs: [{ name: 'greeting', type: 'string' }],
|
||||
runs: {
|
||||
steps: [{ run: 'echo hello' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown top-level keys', () => {
|
||||
const config = {
|
||||
unknown_field: true,
|
||||
runs: {
|
||||
steps: [{ run: 'echo hello' }],
|
||||
},
|
||||
};
|
||||
expect(() => CompositeFunctionConfigZ.parse(config)).toThrow(ZodError);
|
||||
expect(() => CompositeFunctionConfigZ.parse(config)).toThrow(/unknown_field/);
|
||||
});
|
||||
|
||||
it('errors when runs.steps is empty', () => {
|
||||
const config = {
|
||||
name: 'Broken',
|
||||
runs: { steps: [] },
|
||||
};
|
||||
expect(() => CompositeFunctionConfigZ.parse(config)).toThrow(ZodError);
|
||||
try {
|
||||
CompositeFunctionConfigZ.parse(config);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ZodError);
|
||||
expect((err as ZodError).issues[0]?.message).toMatch(
|
||||
/must declare at least one step under "runs.steps"/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { DeviceRunSessionEvent, parseDeviceRunSessionEvents } from '../deviceRunSession';
|
||||
|
||||
describe('parseDeviceRunSessionEvents', () => {
|
||||
it('parses, deduplicates, and sorts events from the dedicated event log', () => {
|
||||
const first = createEvent({
|
||||
eventId: 'first',
|
||||
ts: '2026-07-10T12:00:00.000Z',
|
||||
});
|
||||
const second = createEvent({
|
||||
eventId: 'second',
|
||||
ts: '2026-07-10T12:00:01.000Z',
|
||||
});
|
||||
|
||||
expect(
|
||||
parseDeviceRunSessionEvents(`${second}\nplain text\n${first}\n${second}`).map(
|
||||
({ eventId }) => eventId
|
||||
)
|
||||
).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
it('ignores malformed events', () => {
|
||||
expect(parseDeviceRunSessionEvents(`plain text\n${JSON.stringify({ v: 1 })}`)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores events with invalid timestamps', () => {
|
||||
expect(
|
||||
parseDeviceRunSessionEvents(
|
||||
`${createEvent({ eventId: 'invalid', ts: 'not-a-date' })}\n${createEvent({
|
||||
eventId: 'valid',
|
||||
})}`
|
||||
).map(({ eventId }) => eventId)
|
||||
).toEqual(['valid']);
|
||||
});
|
||||
});
|
||||
|
||||
function createEvent(overrides: Partial<DeviceRunSessionEvent> = {}): string {
|
||||
return JSON.stringify(createEventValue(overrides));
|
||||
}
|
||||
|
||||
function createEventValue(overrides: Partial<DeviceRunSessionEvent> = {}): DeviceRunSessionEvent {
|
||||
return {
|
||||
v: 1,
|
||||
eventId: 'event-id',
|
||||
ts: '2026-07-10T12:00:00.000Z',
|
||||
producer: 'agent-device',
|
||||
type: 'operation.started',
|
||||
summary: 'Started tap',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Schema for local composite functions, reusable step groups referenced via `uses:` in EAS
|
||||
* workflows (`.eas/workflows/*.yml`) or inline job step definitions.
|
||||
*
|
||||
* This module defines the shape of a composite function configuration file (`function.yml`).
|
||||
* Callers that load composite function files format validation errors from `CompositeFunctionConfigZ`.
|
||||
* Local composite functions are not supported in `.eas/build/*.yml` custom build config files.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
import { StepZ } from './step';
|
||||
|
||||
const CompositeFunctionInputValueTypeNameZ = z.enum(['string', 'boolean', 'number', 'json']);
|
||||
|
||||
const CompositeFunctionInputValueZ = z.union([
|
||||
z.string(),
|
||||
z.boolean(),
|
||||
z.number(),
|
||||
z.array(z.unknown()),
|
||||
z.record(z.string(), z.unknown()),
|
||||
]);
|
||||
|
||||
const CompositeFunctionInputZ = z.union([
|
||||
z
|
||||
.string()
|
||||
.describe('Shorthand for an input name with default type "string" and no default value.'),
|
||||
z
|
||||
.object({
|
||||
name: z.string(),
|
||||
type: CompositeFunctionInputValueTypeNameZ.default('string'),
|
||||
default_value: CompositeFunctionInputValueZ.optional(),
|
||||
allowed_values: z.array(CompositeFunctionInputValueZ).optional(),
|
||||
required: z.boolean().optional(),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
const CompositeFunctionOutputZ = z
|
||||
.object({
|
||||
description: z.string().optional(),
|
||||
/**
|
||||
* @example
|
||||
* value: '${{ steps.read.outputs.version }}'
|
||||
*/
|
||||
value: z.string().describe('Expression that resolves to the output value.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const CompositeFunctionConfigZ = z
|
||||
.object({
|
||||
/**
|
||||
* @example
|
||||
* name: Setup
|
||||
*/
|
||||
name: z.string().optional().describe('Display name of the composite function.'),
|
||||
description: z.string().optional(),
|
||||
/**
|
||||
* @example
|
||||
* inputs:
|
||||
* - greeting
|
||||
* - name: platform
|
||||
* type: string
|
||||
* default_value: ios
|
||||
*/
|
||||
inputs: z
|
||||
.array(CompositeFunctionInputZ)
|
||||
.optional()
|
||||
.describe('Inputs accepted by the composite function.'),
|
||||
/**
|
||||
* @example
|
||||
* outputs:
|
||||
* version:
|
||||
* value: '${{ steps.read.outputs.version }}'
|
||||
*/
|
||||
outputs: z
|
||||
.record(z.string(), CompositeFunctionOutputZ)
|
||||
.optional()
|
||||
.describe('Named outputs exposed by the composite function to its caller.'),
|
||||
runs: z.object({
|
||||
/**
|
||||
* @example
|
||||
* runs:
|
||||
* steps:
|
||||
* - id: read
|
||||
* run: set-output version "1.0.0"
|
||||
*/
|
||||
steps: z
|
||||
.array(StepZ)
|
||||
.min(1, {
|
||||
message: 'A composite function must declare at least one step under "runs.steps".',
|
||||
})
|
||||
.describe('Steps executed when the composite function is invoked.'),
|
||||
}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Structure of a local composite function configuration file (`function.yml`).
|
||||
*
|
||||
* @example
|
||||
* name: Setup
|
||||
* inputs:
|
||||
* - greeting
|
||||
* outputs:
|
||||
* version:
|
||||
* value: '${{ steps.read.outputs.version }}'
|
||||
* runs:
|
||||
* steps:
|
||||
* - id: read
|
||||
* run: set-output version "1.0.0"
|
||||
*/
|
||||
export type CompositeFunctionConfig = z.infer<typeof CompositeFunctionConfigZ>;
|
||||
|
||||
export type CompositeFunctionCatalog = Record<string, CompositeFunctionConfig>;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Canonical record format for the activity events emitted during a remote device run (simulator)
|
||||
* session. Producers (e.g. the worker and agent-device) write these as NDJSON to the
|
||||
* session-events artifact and consumers (e.g. EAS CLI and the website) read them back. Keep this
|
||||
* schema in sync across every producer and consumer.
|
||||
*/
|
||||
export const DeviceRunSessionEventZ = z
|
||||
.object({
|
||||
v: z.literal(1),
|
||||
eventId: z.string(),
|
||||
ts: z.string().refine(ts => !Number.isNaN(new Date(ts).getTime())),
|
||||
producer: z.string(),
|
||||
type: z.string(),
|
||||
operationId: z.string().optional(),
|
||||
outcome: z.enum(['success', 'failure']).optional(),
|
||||
durationMs: z.number().optional(),
|
||||
summary: z.string(),
|
||||
data: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type DeviceRunSessionEvent = z.infer<typeof DeviceRunSessionEventZ>;
|
||||
|
||||
/**
|
||||
* Parse the NDJSON session-events log into canonical event records, de-duplicating by `eventId`
|
||||
* and sorting chronologically by `ts`. Malformed or incomplete lines (including records with
|
||||
* invalid timestamps) are ignored so that a partially-flushed log is still readable.
|
||||
*/
|
||||
export function parseDeviceRunSessionEvents(eventLog: string): DeviceRunSessionEvent[] {
|
||||
const events = new Map<string, DeviceRunSessionEvent>();
|
||||
|
||||
for (const line of eventLog.split('\n')) {
|
||||
try {
|
||||
const result = DeviceRunSessionEventZ.safeParse(JSON.parse(line));
|
||||
if (result.success) {
|
||||
events.set(result.data.eventId, result.data);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed or incomplete records.
|
||||
}
|
||||
}
|
||||
|
||||
return [...events.values()].sort(
|
||||
(left, right) => new Date(left.ts).getTime() - new Date(right.ts).getTime()
|
||||
);
|
||||
}
|
||||
@@ -26,8 +26,10 @@ export * from './context';
|
||||
export * from './generic';
|
||||
export * from './hooks';
|
||||
export * from './step';
|
||||
export * from './compositeFunction';
|
||||
export * from './submission-config';
|
||||
export * from './projectPackage';
|
||||
export * from './deviceRunSession';
|
||||
|
||||
const version = require('../package.json').version;
|
||||
export { version };
|
||||
+343
-148
File diff suppressed because it is too large.
Load diff
Generated
+2332
-1413
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "21.2.0",
|
||||
"version": "21.4.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,8 +48,8 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "21.1.0",
|
||||
"@expo/eas-json": "21.1.0",
|
||||
"@expo/eas-build-job": "21.4.0",
|
||||
"@expo/eas-json": "21.4.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "21.0.0",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.1.0",
|
||||
"@expo/steps": "21.4.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -221,6 +221,9 @@
|
||||
"secret": {
|
||||
"description": "manage project and account secrets"
|
||||
},
|
||||
"testflight": {
|
||||
"description": "read TestFlight feedback and crashes"
|
||||
},
|
||||
"update": {
|
||||
"description": "manage individual updates"
|
||||
},
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
const packageRoot = path.join(__dirname, '..');
|
||||
const readmePath = path.join(packageRoot, 'README.md');
|
||||
const readmeHeaderPath = path.join(__dirname, 'readme-header.md');
|
||||
|
||||
(async () => {
|
||||
// Patch `oclif readme` path and link generation
|
||||
let readmeContent = await fs.readFile('README.md', 'utf8');
|
||||
readmeContent = readmeContent.replace(/src\/commands/g, 'packages/eas-cli/src/commands');
|
||||
await fs.writeFile('README.md', readmeContent);
|
||||
let readmeContent = await fs.readFile(readmePath, 'utf8');
|
||||
readmeContent = readmeContent
|
||||
.replace(/\[src\/commands/g, '[packages/eas-cli/src/commands')
|
||||
.replace(
|
||||
/https:\/\/github\.com\/expo\/eas-cli\/blob\/v([^/]+)\/src\/commands/g,
|
||||
'https://github.com/expo/eas-cli/blob/v$1/packages/eas-cli/src/commands'
|
||||
)
|
||||
.replace(
|
||||
/packages\/eas-cli\/(?:packages\/eas-cli\/)+src\/commands/g,
|
||||
'packages/eas-cli/src/commands'
|
||||
);
|
||||
|
||||
const commandReferenceMarker = '# Commands\n\n<!-- commands -->';
|
||||
const commandReferenceStart = readmeContent.indexOf(commandReferenceMarker);
|
||||
if (commandReferenceStart === -1) {
|
||||
throw new Error(`Could not find generated command reference marker in ${readmePath}`);
|
||||
}
|
||||
|
||||
const readmeHeader = await fs.readFile(readmeHeaderPath, 'utf8');
|
||||
readmeContent = `${readmeHeader.trimEnd()}\n\n${readmeContent.slice(commandReferenceStart)}`;
|
||||
|
||||
await fs.writeFile(readmePath, readmeContent);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Patched README path generation');
|
||||
console.log('Patched README path generation and header');
|
||||
})();
|
||||
@@ -0,0 +1,117 @@
|
||||
<!-- Everything above the "# Commands" section is generated from packages/eas-cli/scripts/readme-header.md. Edit that file, not README.md; `yarn version` regenerates README.md and overwrites this content. -->
|
||||
|
||||
<!-- The logo src is an absolute URL on purpose: this file is also the repository root README (README.md is a symlink to it), so it renders at two different depths and no single relative path resolves in both. -->
|
||||
|
||||
<p align="center">
|
||||
<a href="https://expo.dev/services">
|
||||
<img alt="EAS" height="96" src="https://raw.githubusercontent.com/expo/eas-cli/main/.github/resources/eas.svg">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">EAS CLI</h1>
|
||||
|
||||
<p align="center">Ship Expo and React Native apps from your terminal: build, submit, update, deploy, and automate with EAS.</p>
|
||||
|
||||
<p align="center">
|
||||
<a aria-label="eas documentation" href="https://docs.expo.dev/eas/">📚 Documentation</a>
|
||||
 • 
|
||||
<a aria-label="eas cli reference" href="https://docs.expo.dev/eas/cli/">📖 CLI Reference</a>
|
||||
 • 
|
||||
<a aria-label="eas" href="https://expo.dev/services">🚀 EAS</a>
|
||||
 • 
|
||||
<a aria-label="expo blog" href="https://expo.dev/blog">📝 Blog</a>
|
||||
 • 
|
||||
<a aria-label="expo changelog" href="https://expo.dev/changelog">📰 Changelog</a>
|
||||
 • 
|
||||
<a aria-label="contribute to eas cli" href="https://github.com/expo/eas-cli/blob/main/CONTRIBUTING.md">👏 Contribute</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a aria-label="npm version" href="https://www.npmjs.com/package/eas-cli" target="_blank">
|
||||
<img alt="npm version" src="https://img.shields.io/npm/v/eas-cli.svg?style=for-the-badge&label=npm&labelColor=000000&color=4630EB" />
|
||||
</a>
|
||||
<a aria-label="npm downloads" href="https://npm-stat.com/charts.html?package=eas-cli" target="_blank">
|
||||
<img alt="downloads" src="https://img.shields.io/npm/dm/eas-cli.svg?style=for-the-badge&labelColor=000000&color=33CC12&label=downloads" />
|
||||
</a>
|
||||
<a aria-label="License: MIT" href="https://github.com/expo/eas-cli/blob/main/LICENSE" target="_blank">
|
||||
<img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-success.svg?style=for-the-badge&labelColor=000000&color=33CC12" />
|
||||
</a>
|
||||
<a aria-label="Join the Expo Discord" href="https://chat.expo.dev" target="_blank">
|
||||
<img alt="Discord" src="https://img.shields.io/discord/695411232856997968.svg?style=for-the-badge&color=5865F2&logo=discord&logoColor=FFFFFF" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
EAS CLI is the command-line interface for [Expo Application Services (EAS)](https://expo.dev/services): deeply integrated cloud services for Expo and React Native apps, from the team behind Expo.
|
||||
|
||||
It gives you a terminal-native path from source code to production: compile signed Android and iOS binaries in the cloud, submit them to the app stores, push over-the-air updates, deploy web apps and API routes, and automate the entire release process, from your machine or from CI.
|
||||
|
||||
## Quick start
|
||||
|
||||
Install the CLI, log in to your Expo account, and link your project:
|
||||
|
||||
```sh
|
||||
npm install --global eas-cli
|
||||
eas login
|
||||
eas init
|
||||
```
|
||||
|
||||
Then ship:
|
||||
|
||||
```sh
|
||||
# Compile installable Android and iOS binaries in the cloud
|
||||
eas build --platform all
|
||||
|
||||
# Submit the latest builds to Google Play and the App Store
|
||||
eas submit --platform all
|
||||
|
||||
# Push an over-the-air update to your users
|
||||
eas update --branch production --message "Fix checkout crash"
|
||||
```
|
||||
|
||||
Prefer not to install globally? Every command also works with `npx eas-cli@latest`:
|
||||
|
||||
```sh
|
||||
npx eas-cli@latest build --platform ios
|
||||
```
|
||||
|
||||
New to EAS? Follow [Create your first build](https://docs.expo.dev/build/setup/) for a complete walkthrough.
|
||||
|
||||
## What you can do
|
||||
|
||||
- **[EAS Build](https://docs.expo.dev/build/introduction/)** (`eas build`): compile and sign Android and iOS apps with custom native code in the cloud, manage app credentials, and share internal distribution builds.
|
||||
- **[EAS Submit](https://docs.expo.dev/deploy/submit-to-app-stores/)** (`eas submit`): upload your app to Google Play and App Store Connect with one command.
|
||||
- **[EAS Update](https://docs.expo.dev/eas-update/introduction/)** (`eas update`): push JavaScript and asset fixes directly to users, with branches, channels, runtime versions, rollouts, and rollbacks.
|
||||
- **[EAS Workflows](https://docs.expo.dev/eas/workflows/get-started/)** (`eas workflow`): automate development and release with CI/CD jobs that build, test, submit, update, and deploy your app from `.eas/workflows`.
|
||||
- **[EAS Hosting](https://docs.expo.dev/eas/hosting/introduction/)** (`eas deploy`): deploy Expo Router and React Native web apps and API routes.
|
||||
- **[EAS Metadata](https://docs.expo.dev/eas/metadata/)** (`eas metadata:push`): maintain your app store presence from the command line (in preview).
|
||||
- **Project operations** (`eas env`, `eas credentials`, `eas device`, `eas channel`, …): manage environment variables, signing credentials, Apple devices, update channels, webhooks, and project settings.
|
||||
|
||||
## Version policy
|
||||
|
||||
EAS CLI is designed to be installed globally or run with `npx`. Installing `eas-cli` into project dependencies is strongly discouraged because it can cause dependency conflicts that are difficult to debug.
|
||||
|
||||
If you want to enforce the `eas-cli` version for your project, use the `"cli.version"` field in [eas.json](https://docs.expo.dev/eas/json/):
|
||||
|
||||
```json
|
||||
{
|
||||
"cli": {
|
||||
"version": ">=21.0.0"
|
||||
},
|
||||
"build": {
|
||||
// build profiles
|
||||
},
|
||||
"submit": {
|
||||
// submit profiles
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Learn more
|
||||
|
||||
- [EAS documentation](https://docs.expo.dev/eas/) for the full service overview.
|
||||
- [EAS CLI reference](https://docs.expo.dev/eas/cli/) for command usage, flags, and arguments.
|
||||
- [Expo dashboard](https://expo.dev/) to view projects, builds, submissions, updates, and workflows.
|
||||
- [Expo changelog](https://expo.dev/changelog) and [blog](https://expo.dev/blog) for product updates.
|
||||
- [Discord](https://chat.expo.dev) for community support.
|
||||
@@ -25,23 +25,27 @@ beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `[
|
||||
{
|
||||
"name": "GraphQLError",
|
||||
"extensions": {},
|
||||
"message": "Error 1"
|
||||
},
|
||||
{
|
||||
"name": "GraphQLError",
|
||||
"extensions": {},
|
||||
"message": "Error 2"
|
||||
},
|
||||
{
|
||||
"name": "GraphQLError",
|
||||
"extensions": {},
|
||||
"message": "Error 3"
|
||||
}
|
||||
]`;
|
||||
const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `Build request error details:
|
||||
{
|
||||
"message": "[GraphQL] Error 1\\n[GraphQL] Error 2\\n[GraphQL] Error 3",
|
||||
"graphQLErrors": [
|
||||
{
|
||||
"name": "GraphQLError",
|
||||
"extensions": {},
|
||||
"message": "Error 1"
|
||||
},
|
||||
{
|
||||
"name": "GraphQLError",
|
||||
"extensions": {},
|
||||
"message": "Error 2"
|
||||
},
|
||||
{
|
||||
"name": "GraphQLError",
|
||||
"extensions": {},
|
||||
"message": "Error 3"
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
const EXPECTED_EAS_BUILD_DOWN_MESSAGE = `EAS Build is down for maintenance. Try again later. Check ${link(
|
||||
'https://status.expo.dev/'
|
||||
@@ -110,7 +114,7 @@ describe(Build.name, () => {
|
||||
handleBuildRequestError(error, platform);
|
||||
} catch {}
|
||||
|
||||
expect(logDebugSpy).toBeCalledWith(undefined);
|
||||
expect(logDebugSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does it with iOS', async () => {
|
||||
@@ -122,7 +126,7 @@ describe(Build.name, () => {
|
||||
handleBuildRequestError(error, platform);
|
||||
} catch {}
|
||||
|
||||
expect(logDebugSpy).toBeCalledWith(undefined);
|
||||
expect(logDebugSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -507,6 +511,135 @@ describe(Build.name, () => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
});
|
||||
describe('with empty GraphQL errors and network response details', () => {
|
||||
it('throws base Error class with network and response details', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const error = new CombinedError({
|
||||
graphQLErrors: [],
|
||||
networkError: new Error('Request failed: 400 (Bad Request)'),
|
||||
response: {
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
headers: {
|
||||
get: (headerName: string) =>
|
||||
headerName === 'expo-request-id' ? mockRequestId : null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const expectedMessage =
|
||||
`${EXPECTED_GENERIC_MESSAGE}\n` +
|
||||
'Network error: Request failed: 400 (Bad Request)\n' +
|
||||
'Response status: 400 Bad Request\n' +
|
||||
`Request ID: ${mockRequestId}`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
|
||||
it('logs network and response details to debug', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const error = new CombinedError({
|
||||
graphQLErrors: [],
|
||||
networkError: new Error('Request failed: 400 (Bad Request)'),
|
||||
response: {
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
headers: {
|
||||
get: (headerName: string) =>
|
||||
headerName === 'expo-request-id' ? mockRequestId : null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
handleBuildRequestError(error, platform);
|
||||
} catch {}
|
||||
|
||||
expect(logDebugSpy).toBeCalledWith(expect.stringContaining('"graphQLErrors": []'));
|
||||
expect(logDebugSpy).toBeCalledWith(
|
||||
expect.stringContaining('"message": "[Network] Request failed: 400 (Bad Request)"')
|
||||
);
|
||||
expect(logDebugSpy).toBeCalledWith(
|
||||
expect.stringContaining('"message": "Request failed: 400 (Bad Request)"')
|
||||
);
|
||||
expect(logDebugSpy).toBeCalledWith(expect.stringContaining('"status": 400'));
|
||||
expect(logDebugSpy).toBeCalledWith(
|
||||
expect.stringContaining(`"expo-request-id": "${mockRequestId}"`)
|
||||
);
|
||||
});
|
||||
|
||||
it('includes the request ID from the x-request-id header when expo-request-id is missing', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const error = new CombinedError({
|
||||
graphQLErrors: [],
|
||||
networkError: new Error('Request failed: 400 (Bad Request)'),
|
||||
response: {
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
headers: {
|
||||
get: (headerName: string) =>
|
||||
headerName === 'x-request-id' ? mockRequestId : null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const expectedMessage =
|
||||
`${EXPECTED_GENERIC_MESSAGE}\n` +
|
||||
'Network error: Request failed: 400 (Bad Request)\n' +
|
||||
'Response status: 400 Bad Request\n' +
|
||||
`Request ID: ${mockRequestId}`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
});
|
||||
describe('with the same request ID in GraphQL error extensions and response headers', () => {
|
||||
it('does not duplicate the request ID line', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
|
||||
const error = new CombinedError({
|
||||
graphQLErrors: [graphQLError],
|
||||
response: {
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
headers: {
|
||||
get: (headerName: string) =>
|
||||
headerName === 'expo-request-id' ? mockRequestId : null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const expectedMessage =
|
||||
`${EXPECTED_GENERIC_MESSAGE}\n` +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Error message: Error 1\n' +
|
||||
'Response status: 400 Bad Request';
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
});
|
||||
describe('with no GraphQL, network, or response details', () => {
|
||||
it('falls back to the top-level error message', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const error = { graphQLErrors: [], message: 'Something went wrong' };
|
||||
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nError message: Something went wrong`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -231,12 +231,17 @@ const SERVER_SIDE_DEFINED_ERRORS: Record<string, typeof EasCommandError> = {
|
||||
};
|
||||
|
||||
export function handleBuildRequestError(error: any, platform: Platform): never {
|
||||
Log.debug(JSON.stringify(error.graphQLErrors, null, 2));
|
||||
logBuildRequestErrorDebugInfo(error);
|
||||
|
||||
const graphQLErrorCode: string = error?.graphQLErrors?.[0]?.extensions?.errorCode;
|
||||
if (graphQLErrorCode in SERVER_SIDE_DEFINED_ERRORS) {
|
||||
const graphQLErrors: GraphQLError[] = Array.isArray(error?.graphQLErrors)
|
||||
? error.graphQLErrors
|
||||
: [];
|
||||
const graphQLErrorCode: string | undefined = graphQLErrors[0]?.extensions?.errorCode as
|
||||
| string
|
||||
| undefined;
|
||||
if (graphQLErrorCode && graphQLErrorCode in SERVER_SIDE_DEFINED_ERRORS) {
|
||||
const ErrorClass: typeof EasCommandError = SERVER_SIDE_DEFINED_ERRORS[graphQLErrorCode];
|
||||
throw new ErrorClass(error?.graphQLErrors?.[0]?.message);
|
||||
throw new ErrorClass(graphQLErrors[0]?.message);
|
||||
} else if (graphQLErrorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE') {
|
||||
throw new EasBuildDownForMaintenanceError(
|
||||
`EAS Build is down for maintenance. Try again later. Check ${link(
|
||||
@@ -247,25 +252,128 @@ export function handleBuildRequestError(error: any, platform: Platform): never {
|
||||
throw new EasBuildTooManyPendingBuildsError(
|
||||
`You have already reached the maximum number of pending ${requestedPlatformDisplayNames[platform]} builds for your account. Try again later.`
|
||||
);
|
||||
} else if (error?.graphQLErrors) {
|
||||
const errorMessage = error.graphQLErrors
|
||||
.map((graphQLError: GraphQLError) => {
|
||||
const requestIdLine = graphQLError?.extensions?.requestId
|
||||
? `\nRequest ID: ${graphQLError.extensions.requestId}`
|
||||
: '';
|
||||
const errorMessageLine = graphQLError?.message
|
||||
? `\nError message: ${graphQLError.message}`
|
||||
: '';
|
||||
return `${requestIdLine}${errorMessageLine}`;
|
||||
})
|
||||
.join('');
|
||||
} else if (Array.isArray(error?.graphQLErrors)) {
|
||||
const errorDetails = formatBuildRequestErrorDetails(error, graphQLErrors);
|
||||
throw new Error(
|
||||
`Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.${errorMessage}`
|
||||
`Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.${errorDetails}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
function formatBuildRequestErrorDetails(error: any, graphQLErrors: GraphQLError[]): string {
|
||||
const details: string[] = graphQLErrors
|
||||
.map((graphQLError: GraphQLError) => {
|
||||
const requestIdLine = graphQLError?.extensions?.requestId
|
||||
? `\nRequest ID: ${graphQLError.extensions.requestId}`
|
||||
: '';
|
||||
const errorMessageLine = graphQLError?.message
|
||||
? `\nError message: ${graphQLError.message}`
|
||||
: '';
|
||||
return `${requestIdLine}${errorMessageLine}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (error?.networkError?.message) {
|
||||
details.push(`\nNetwork error: ${error.networkError.message}`);
|
||||
}
|
||||
|
||||
const response = error?.response;
|
||||
if (response?.status !== undefined || response?.statusText) {
|
||||
const status = [response.status, response.statusText]
|
||||
.filter(value => value !== undefined && value !== '')
|
||||
.join(' ');
|
||||
details.push(`\nResponse status: ${status}`);
|
||||
}
|
||||
|
||||
const responseRequestId = getResponseRequestId(response);
|
||||
const graphQLRequestIds = new Set(
|
||||
graphQLErrors.map(graphQLError => graphQLError?.extensions?.requestId)
|
||||
);
|
||||
if (responseRequestId && !graphQLRequestIds.has(responseRequestId)) {
|
||||
details.push(`\nRequest ID: ${responseRequestId}`);
|
||||
}
|
||||
|
||||
if (details.length === 0 && error?.message) {
|
||||
details.push(`\nError message: ${error.message}`);
|
||||
}
|
||||
|
||||
return details.join('');
|
||||
}
|
||||
|
||||
function getResponseRequestId(response: any): string | null {
|
||||
return (
|
||||
response?.headers?.get?.('expo-request-id') ?? response?.headers?.get?.('x-request-id') ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function logBuildRequestErrorDebugInfo(error: any): void {
|
||||
const debugInfo = collectBuildRequestErrorDebugInfo(error);
|
||||
if (debugInfo) {
|
||||
Log.debug(`Build request error details:\n${JSON.stringify(debugInfo, null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function collectBuildRequestErrorDebugInfo(error: any): Record<string, unknown> | null {
|
||||
if (!Array.isArray(error?.graphQLErrors) && !error?.response && !error?.networkError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const debugInfo: Record<string, unknown> = {};
|
||||
if (error?.message) {
|
||||
debugInfo.message = error.message;
|
||||
}
|
||||
if (Array.isArray(error?.graphQLErrors)) {
|
||||
debugInfo.graphQLErrors = error.graphQLErrors;
|
||||
}
|
||||
if (error?.networkError) {
|
||||
debugInfo.networkError = collectErrorDebugInfo(error.networkError);
|
||||
}
|
||||
|
||||
const response = collectResponseDebugInfo(error?.response);
|
||||
if (response) {
|
||||
debugInfo.response = response;
|
||||
}
|
||||
|
||||
return debugInfo;
|
||||
}
|
||||
|
||||
function collectErrorDebugInfo(error: any): Record<string, unknown> {
|
||||
const debugInfo: Record<string, unknown> = {};
|
||||
for (const property of ['name', 'message', 'code', 'type', 'errno', 'syscall', 'stack']) {
|
||||
if (error?.[property]) {
|
||||
debugInfo[property] = error[property];
|
||||
}
|
||||
}
|
||||
return debugInfo;
|
||||
}
|
||||
|
||||
function collectResponseDebugInfo(response: any): Record<string, unknown> | null {
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const debugInfo: Record<string, unknown> = {};
|
||||
for (const property of ['status', 'statusText', 'url']) {
|
||||
if (response[property] !== undefined && response[property] !== '') {
|
||||
debugInfo[property] = response[property];
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const headerName of ['expo-request-id', 'x-request-id', 'content-type']) {
|
||||
const headerValue = response?.headers?.get?.(headerName);
|
||||
if (headerValue) {
|
||||
headers[headerName] = headerValue;
|
||||
}
|
||||
}
|
||||
if (Object.keys(headers).length > 0) {
|
||||
debugInfo.headers = headers;
|
||||
}
|
||||
|
||||
return Object.keys(debugInfo).length > 0 ? debugInfo : null;
|
||||
}
|
||||
|
||||
async function uploadProjectAsync<TPlatform extends Platform>(
|
||||
ctx: BuildContext<TPlatform>
|
||||
): Promise<{
|
||||
|
||||
+163
@@ -7,6 +7,7 @@ import { AppQuery } from '../../../../graphql/queries/AppQuery';
|
||||
import { learnMore } from '../../../../log';
|
||||
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { isExpoInstalled } from '../../../../project/projectUtils';
|
||||
import { promptAsync } from '../../../../prompts';
|
||||
import SessionManager from '../../../../user/SessionManager';
|
||||
import { findProjectRootAsync } from '../findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { getProjectIdAsync } from '../getProjectIdAsync';
|
||||
@@ -23,11 +24,14 @@ jest.mock('../../../../ora', () => ({
|
||||
}));
|
||||
jest.mock('../../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
|
||||
jest.mock('../../../../project/projectUtils');
|
||||
jest.mock('../../../../prompts');
|
||||
|
||||
describe(getProjectIdAsync, () => {
|
||||
let sessionManager: SessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
jest
|
||||
.mocked(getConfigFilePaths)
|
||||
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
|
||||
@@ -74,6 +78,10 @@ describe(getProjectIdAsync, () => {
|
||||
|
||||
jest.mocked(findProjectRootAsync).mockResolvedValue('/app');
|
||||
|
||||
jest
|
||||
.mocked(promptAsync)
|
||||
.mockResolvedValue({ account: { id: 'account_id_1', name: 'notnotbrent' } });
|
||||
|
||||
// NOTE(@kitten): Updating this test is easiest by letting it fallback to `@expo/config`
|
||||
// This isn't a great solution, but the test is pretty involved
|
||||
jest.mocked(isExpoInstalled).mockReturnValue(false);
|
||||
@@ -338,6 +346,161 @@ describe(getProjectIdAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('account selection when project is not configured', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(getConfig)
|
||||
.mockReturnValue({ exp: { sdkVersion: '52.0.0', name: 'test', slug: 'test' } } as any);
|
||||
jest.mocked(modifyConfigAsync).mockResolvedValue({
|
||||
type: 'success',
|
||||
config: {
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '2345' } },
|
||||
},
|
||||
});
|
||||
jest
|
||||
.mocked(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync)
|
||||
.mockImplementation(async () => '2345');
|
||||
});
|
||||
|
||||
it('prompts for the account when the user has multiple accounts and no owner is set', async () => {
|
||||
jest
|
||||
.mocked(promptAsync)
|
||||
.mockResolvedValue({ account: { id: 'account_id_2', name: 'dominik' } });
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).resolves.toEqual('2345');
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ accountName: 'dominik', projectName: 'test' },
|
||||
{ nonInteractive: false },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the owner from the app config without prompting', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: { sdkVersion: '52.0.0', name: 'test', slug: 'test', owner: 'dominik' },
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'test', owner: 'dominik' },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).resolves.toEqual('2345');
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ accountName: 'dominik', projectName: 'test' },
|
||||
{ nonInteractive: false },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the only account without prompting when the user has a single account', async () => {
|
||||
const sessionManagerMock = mock<SessionManager>();
|
||||
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: 'user_id',
|
||||
email: '[email protected]',
|
||||
username: 'notnotbrent',
|
||||
primaryAccount: {
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
featureGates: {},
|
||||
},
|
||||
authenticationInfo: { accessToken: 'fake', sessionSecret: null },
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
instance(sessionManagerMock),
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).resolves.toEqual('2345');
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ accountName: 'notnotbrent', projectName: 'test' },
|
||||
{ nonInteractive: false },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for a robot user when no owner is set', async () => {
|
||||
const sessionManagerMock = mock<SessionManager>();
|
||||
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
|
||||
actor: {
|
||||
__typename: 'Robot',
|
||||
id: 'robot_id',
|
||||
accounts: [
|
||||
{
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
users: [{ role: Role.Admin, actor: { id: 'robot_id' } }],
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
featureGates: {},
|
||||
},
|
||||
authenticationInfo: { accessToken: 'fake', sessionSecret: null },
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
instance(sessionManagerMock),
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
'Project is not configured. When using a robot access token, run "eas init --account <name> --non-interactive" or set the "owner" field in your app config before managing the project. Accounts this token can create projects in: notnotbrent'
|
||||
);
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws in non-interactive mode when the user has multiple accounts and no owner is set', async () => {
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
|
||||
{ nonInteractive: true }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
|
||||
);
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if writing the ID back to the config fails', async () => {
|
||||
jest
|
||||
.mocked(getConfig)
|
||||
|
||||
@@ -8,13 +8,18 @@ import { findProjectRootAsync } from './findProjectDirAndVerifyProjectSetupAsync
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import {
|
||||
getAccountChoices,
|
||||
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp,
|
||||
} from '../../../project/accountSelection';
|
||||
import {
|
||||
createOrModifyExpoConfigAsync,
|
||||
getPrivateExpoConfigAsync,
|
||||
} from '../../../project/expoConfig';
|
||||
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import SessionManager from '../../../user/SessionManager';
|
||||
import { Actor, getActorUsername } from '../../../user/User';
|
||||
import { Actor, getActorUsername, getCreatableAccountNames } from '../../../user/User';
|
||||
|
||||
/**
|
||||
* Save an EAS project ID to the appropriate field in the app config.
|
||||
@@ -180,28 +185,12 @@ export async function validateOrSetProjectIdAsync({
|
||||
|
||||
Log.warn('EAS project not configured.');
|
||||
|
||||
const getDefaultAccountNameForEASProject = (exp: ExpoConfig, user: Actor): string => {
|
||||
if (exp.owner) {
|
||||
return exp.owner;
|
||||
}
|
||||
switch (user.__typename) {
|
||||
case 'User':
|
||||
return user.username;
|
||||
case 'SSOUser':
|
||||
return user.username;
|
||||
case 'PartnerActor':
|
||||
return user.username;
|
||||
case 'Robot':
|
||||
throw new Error(
|
||||
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
|
||||
);
|
||||
}
|
||||
};
|
||||
const accountName = await chooseAccountNameForEASProjectAsync(exp, actor, options);
|
||||
|
||||
const projectId = await fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
accountName: getDefaultAccountNameForEASProject(exp, actor),
|
||||
accountName,
|
||||
projectName: exp.slug,
|
||||
},
|
||||
{
|
||||
@@ -221,3 +210,51 @@ export async function validateOrSetProjectIdAsync({
|
||||
|
||||
return projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the account that should own a not-yet-configured EAS project. Uses the "owner" field
|
||||
* from the app config when set. Otherwise, when the actor has access to multiple accounts,
|
||||
* prompts for the account to use.
|
||||
*/
|
||||
async function chooseAccountNameForEASProjectAsync(
|
||||
exp: ExpoConfig,
|
||||
actor: Actor,
|
||||
options: { nonInteractive: boolean }
|
||||
): Promise<string> {
|
||||
if (exp.owner) {
|
||||
return exp.owner;
|
||||
}
|
||||
|
||||
if (actor.__typename === 'Robot') {
|
||||
throw new Error(
|
||||
`Project is not configured. When using a robot access token, run "eas init --account <name> --non-interactive" or set the "owner" field in your app config before managing the project. Accounts this token can create projects in: ${getCreatableAccountNames(
|
||||
actor
|
||||
).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const allAccounts = actor.accounts;
|
||||
if (allAccounts.length === 1) {
|
||||
return allAccounts[0].name;
|
||||
}
|
||||
|
||||
if (options.nonInteractive) {
|
||||
throw new Error(
|
||||
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
|
||||
);
|
||||
}
|
||||
|
||||
const choices = getAccountChoices(
|
||||
actor,
|
||||
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(actor)
|
||||
);
|
||||
|
||||
return (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'account',
|
||||
message: 'Which account should own this project?',
|
||||
choices,
|
||||
})
|
||||
).account.name;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppObservePlatform } from '../../../graphql/generated';
|
||||
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveCustomEventsAsync } from '../../../observe/fetchCustomEvents';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
|
||||
import {
|
||||
buildObserveCustomEventNamesJson,
|
||||
buildObserveCustomEventsEmptyWithSuggestionsJson,
|
||||
@@ -73,6 +77,31 @@ describe(ObserveEvents, () => {
|
||||
return command;
|
||||
}
|
||||
|
||||
function planGateError(): CombinedError {
|
||||
const serverMessage =
|
||||
'Subscription to EAS is required for this feature. ' +
|
||||
'Subscribe: https://expo.dev/accounts/acme/settings/billing';
|
||||
return new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(serverMessage, null, null, null, null, null, {
|
||||
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it('surfaces the plan-gate message when listing event names is not available on the plan', async () => {
|
||||
mockCustomEventNamesAsync.mockRejectedValueOnce(planGateError());
|
||||
const command = createCommand([]);
|
||||
await expect(command.runAsync()).rejects.toThrow(/Subscription to EAS is required/);
|
||||
});
|
||||
|
||||
it('surfaces the plan-gate message when querying events is not available on the plan', async () => {
|
||||
mockFetchObserveCustomEventsAsync.mockRejectedValueOnce(planGateError());
|
||||
const command = createCommand(['my_event']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/Subscription to EAS is required/);
|
||||
});
|
||||
|
||||
it('passes eventName arg to fetchObserveCustomEventsAsync', async () => {
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [{ id: 'evt-1' } as any],
|
||||
@@ -104,6 +133,17 @@ describe(ObserveEvents, () => {
|
||||
expect(options.eventName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('routes to fetchObserveCustomEventsAsync with the session filter when --session-id is set with no positional arg', async () => {
|
||||
const command = createCommand(['--session-id', 'session-xyz']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveCustomEventsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.eventName).toBeUndefined();
|
||||
expect(options.sessionId).toBe('session-xyz');
|
||||
});
|
||||
|
||||
it('throws when both an event name argument and --all-events are provided', async () => {
|
||||
const command = createCommand(['my_event', '--all-events']);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppPlatform } from '../../../graphql/generated';
|
||||
import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
|
||||
import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveMetricsSummary from '../metrics-summary';
|
||||
@@ -52,6 +56,24 @@ describe(ObserveMetricsSummary, () => {
|
||||
return command;
|
||||
}
|
||||
|
||||
it('surfaces the server plan-gate message when a metric is not available on the plan', async () => {
|
||||
const serverMessage =
|
||||
'Subscription to EAS is required for this feature. ' +
|
||||
'Subscribe: https://expo.dev/accounts/acme/settings/billing';
|
||||
mockFetchObserveMetricsSummaryAsync.mockRejectedValueOnce(
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(serverMessage, null, null, null, null, null, {
|
||||
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const command = createCommand(['--metric', 'nav_tti']);
|
||||
await expect(command.runAsync()).rejects.toThrow(serverMessage);
|
||||
});
|
||||
|
||||
it('fetches metrics with default parameters (both platforms)', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import {
|
||||
@@ -6,6 +9,7 @@ import {
|
||||
AppObservePlatform,
|
||||
} from '../../../graphql/generated';
|
||||
import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
|
||||
import { buildObserveEventsJson } from '../../../observe/formatEvents';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveMetrics from '../metrics';
|
||||
@@ -52,6 +56,24 @@ describe(ObserveMetrics, () => {
|
||||
return command;
|
||||
}
|
||||
|
||||
it('surfaces the server plan-gate message when the metric is not available on the plan', async () => {
|
||||
const serverMessage =
|
||||
'Subscription to EAS is required for this feature. ' +
|
||||
'Subscribe: https://expo.dev/accounts/acme/settings/billing';
|
||||
mockFetchObserveEventsAsync.mockRejectedValueOnce(
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(serverMessage, null, null, null, null, null, {
|
||||
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const command = createCommand(['nav_tti']);
|
||||
await expect(command.runAsync()).rejects.toThrow(serverMessage);
|
||||
});
|
||||
|
||||
it('accepts a navigation metric alias as the positional arg', async () => {
|
||||
const command = createCommand(['nav_tti']);
|
||||
await command.runAsync();
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppPlatform } from '../../../graphql/generated';
|
||||
import { fetchObserveNavigationRoutesAsync } from '../../../observe/fetchNavigationRoutes';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
|
||||
import {
|
||||
buildObserveNavigationRoutesJson,
|
||||
buildObserveNavigationRoutesTable,
|
||||
@@ -50,6 +54,24 @@ describe(ObserveRoutes, () => {
|
||||
return command;
|
||||
}
|
||||
|
||||
it('surfaces the server plan-gate message when navigation is not available on the plan', async () => {
|
||||
const serverMessage =
|
||||
'Subscription to EAS is required for this feature. ' +
|
||||
'Subscribe: https://expo.dev/accounts/acme/settings/billing';
|
||||
mockFetchObserveNavigationRoutesAsync.mockRejectedValueOnce(
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(serverMessage, null, null, null, null, null, {
|
||||
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const command = createCommand([]);
|
||||
await expect(command.runAsync()).rejects.toThrow(serverMessage);
|
||||
});
|
||||
|
||||
it('queries both platforms by default with all three navigation metric full names', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE } from '../../../observe/planGating';
|
||||
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
|
||||
import {
|
||||
fetchObserveSessionEventsAsync,
|
||||
fetchSessionLogCandidatesAsync,
|
||||
fetchSessionMetricCandidatesAsync,
|
||||
verifyObserveSessionAccessAsync,
|
||||
} from '../../../observe/fetchSessions';
|
||||
import {
|
||||
buildObserveSessionEventsJson,
|
||||
@@ -39,6 +44,7 @@ jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveSessionEventsAsync = jest.mocked(fetchObserveSessionEventsAsync);
|
||||
const mockVerifyObserveSessionAccessAsync = jest.mocked(verifyObserveSessionAccessAsync);
|
||||
const mockFetchSessionMetricCandidatesAsync = jest.mocked(fetchSessionMetricCandidatesAsync);
|
||||
const mockFetchSessionLogCandidatesAsync = jest.mocked(fetchSessionLogCandidatesAsync);
|
||||
const mockCustomEventNamesAsync = jest.mocked(ObserveQuery.customEventNamesAsync);
|
||||
@@ -162,6 +168,23 @@ describe(ObserveSession, () => {
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('surfaces the server plan-gate message when the timeline is plan-gated', async () => {
|
||||
const serverMessage =
|
||||
'Access to this feature is not included in your current plan. ' +
|
||||
'Upgrade your plan: https://expo.dev/accounts/acme/settings/billing';
|
||||
const gateError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(serverMessage, null, null, null, null, null, {
|
||||
errorCode: EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
|
||||
}),
|
||||
],
|
||||
});
|
||||
mockFetchObserveSessionEventsAsync.mockRejectedValueOnce(gateError);
|
||||
|
||||
const command = createCommand(['session-abc']);
|
||||
await expect(command.runAsync()).rejects.toThrow(serverMessage);
|
||||
});
|
||||
|
||||
// ---- new picker-mode behavior ----
|
||||
|
||||
it('errors when no session ID is provided in non-interactive mode', async () => {
|
||||
@@ -176,6 +199,36 @@ describe(ObserveSession, () => {
|
||||
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the plan-gate upgrade prompt before the picker when session access is blocked', async () => {
|
||||
mockVerifyObserveSessionAccessAsync.mockRejectedValueOnce(
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'Upgrade your plan to view session timelines.',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
errorCode: EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
|
||||
}
|
||||
),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const command = createCommand([]);
|
||||
await expect(command.runAsync()).rejects.toThrow(/Upgrade your plan to view session timelines/);
|
||||
|
||||
// The picker is never entered: no prompts, candidate fetches, or drill-down.
|
||||
expect(mockSelectAsync).not.toHaveBeenCalled();
|
||||
expect(mockCustomEventNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchSessionMetricCandidatesAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchSessionLogCandidatesAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects picker flags when a session ID is also provided', async () => {
|
||||
const command = createCommand(['session-abc', '--event-name', 'tti']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
buildObserveCustomEventsJson,
|
||||
buildObserveCustomEventsTable,
|
||||
} from '../../observe/formatCustomEvents';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { appObservePlatformFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
@@ -54,7 +55,8 @@ export default class ObserveEvents extends EasCommand {
|
||||
...ObserveAppVersionFlag,
|
||||
...ObserveUpdateIdFlag,
|
||||
'session-id': Flags.string({
|
||||
description: 'Filter by session ID',
|
||||
description:
|
||||
'Filter by session ID. When no event name is given, lists the events in the session instead of the event-name summary.',
|
||||
}),
|
||||
'all-events': Flags.boolean({
|
||||
description:
|
||||
@@ -100,13 +102,18 @@ export default class ObserveEvents extends EasCommand {
|
||||
|
||||
const platform = appObservePlatformFromFlag(flags.platform);
|
||||
|
||||
if (!args.eventName && !flags['all-events']) {
|
||||
const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
});
|
||||
// A session ID narrows to a single session, so show that session's events
|
||||
// (like --all-events) instead of the account-wide name+count summary, which
|
||||
// has no session filter.
|
||||
if (!args.eventName && !flags['all-events'] && !flags['session-id']) {
|
||||
const { names, isTruncated } = await withObservePlanGateHandlingAsync(() =>
|
||||
ObserveQuery.customEventNamesAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
})
|
||||
);
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveCustomEventNamesJson(names, isTruncated));
|
||||
@@ -124,17 +131,19 @@ export default class ObserveEvents extends EasCommand {
|
||||
return;
|
||||
}
|
||||
|
||||
const { events, pageInfo } = await fetchObserveCustomEventsAsync(graphqlClient, projectId, {
|
||||
eventName: args.eventName,
|
||||
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
appVersion: flags['app-version'],
|
||||
updateId: flags['update-id'],
|
||||
sessionId: flags['session-id'],
|
||||
});
|
||||
const { events, pageInfo } = await withObservePlanGateHandlingAsync(() =>
|
||||
fetchObserveCustomEventsAsync(graphqlClient, projectId, {
|
||||
eventName: args.eventName,
|
||||
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
appVersion: flags['app-version'],
|
||||
updateId: flags['update-id'],
|
||||
sessionId: flags['session-id'],
|
||||
})
|
||||
);
|
||||
|
||||
if (args.eventName && events.length === 0) {
|
||||
const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
resolveStatKey,
|
||||
} from '../../observe/formatMetrics';
|
||||
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
@@ -99,13 +100,15 @@ export default class ObserveMetricsSummary extends EasCommand {
|
||||
const platforms = appPlatformsFromFlag(flags.platform);
|
||||
|
||||
const { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts } =
|
||||
await fetchObserveMetricsAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
metricNames,
|
||||
platforms,
|
||||
startTime,
|
||||
endTime
|
||||
await withObservePlanGateHandlingAsync(() =>
|
||||
fetchObserveMetricsAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
metricNames,
|
||||
platforms,
|
||||
startTime,
|
||||
endTime
|
||||
)
|
||||
);
|
||||
|
||||
const argumentsStat = flags.stat?.length
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
|
||||
import { appObservePlatformFromFlag, appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
@@ -110,27 +111,29 @@ export default class ObserveMetrics extends EasCommand {
|
||||
const platform = appObservePlatformFromFlag(flags.platform);
|
||||
const platforms = appPlatformsFromFlag(flags.platform);
|
||||
|
||||
const [{ events, pageInfo }, totalEventCount] = await Promise.all([
|
||||
fetchObserveEventsAsync(graphqlClient, projectId, {
|
||||
metricName,
|
||||
orderBy,
|
||||
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
appVersion: flags['app-version'],
|
||||
updateId: flags['update-id'],
|
||||
}),
|
||||
fetchTotalEventCountAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
metricName,
|
||||
platforms,
|
||||
startTime,
|
||||
endTime
|
||||
),
|
||||
]);
|
||||
const [{ events, pageInfo }, totalEventCount] = await withObservePlanGateHandlingAsync(() =>
|
||||
Promise.all([
|
||||
fetchObserveEventsAsync(graphqlClient, projectId, {
|
||||
metricName,
|
||||
orderBy,
|
||||
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
appVersion: flags['app-version'],
|
||||
updateId: flags['update-id'],
|
||||
}),
|
||||
fetchTotalEventCountAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
metricName,
|
||||
platforms,
|
||||
startTime,
|
||||
endTime
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo));
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
resolveNavigationStatKey,
|
||||
} from '../../observe/formatNavigationRoutes';
|
||||
import { NAVIGATION_METRIC_ALIASES, resolveNavigationMetricName } from '../../observe/metricNames';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
@@ -113,10 +114,8 @@ export default class ObserveRoutes extends EasCommand {
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
const platforms = appPlatformsFromFlag(flags.platform);
|
||||
|
||||
const { routes, pageInfoByPlatform } = await fetchObserveNavigationRoutesAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
{
|
||||
const { routes, pageInfoByPlatform } = await withObservePlanGateHandlingAsync(() =>
|
||||
fetchObserveNavigationRoutesAsync(graphqlClient, projectId, {
|
||||
startTime,
|
||||
endTime,
|
||||
platforms,
|
||||
@@ -126,7 +125,7 @@ export default class ObserveRoutes extends EasCommand {
|
||||
updateId: flags['update-id'],
|
||||
buildNumber: flags['build-number'],
|
||||
routeNames,
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
fetchObserveSessionEventsAsync,
|
||||
fetchSessionLogCandidatesAsync,
|
||||
fetchSessionMetricCandidatesAsync,
|
||||
verifyObserveSessionAccessAsync,
|
||||
} from '../../observe/fetchSessions';
|
||||
import { ObserveProjectIdFlag, ObserveTimeRangeFlags } from '../../observe/flags';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import {
|
||||
buildObserveSessionEventsJson,
|
||||
buildObserveSessionEventsTable,
|
||||
@@ -112,6 +114,13 @@ export default class ObserveSession extends EasCommand {
|
||||
'A session ID argument is required in non-interactive mode. In interactive mode, you can omit the session ID to pick one from a list of events.'
|
||||
);
|
||||
} else {
|
||||
// Session timelines are a paid feature. Verify access before walking the
|
||||
// user through the interactive picker so a blocked plan gets the upgrade
|
||||
// prompt immediately, rather than after selecting an event (or a
|
||||
// misleading "No events found" when there are no candidates).
|
||||
await withObservePlanGateHandlingAsync(() =>
|
||||
verifyObserveSessionAccessAsync(graphqlClient, projectId)
|
||||
);
|
||||
sessionId = await pickSessionIdInteractivelyAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
@@ -122,10 +131,12 @@ export default class ObserveSession extends EasCommand {
|
||||
}
|
||||
|
||||
const { entries, metadata, hasMoreMetricEvents, hasMoreLogEvents } =
|
||||
await fetchObserveSessionEventsAsync(graphqlClient, projectId, {
|
||||
sessionId,
|
||||
limit: SESSION_PAGE_SIZE,
|
||||
});
|
||||
await withObservePlanGateHandlingAsync(() =>
|
||||
fetchObserveSessionEventsAsync(graphqlClient, projectId, {
|
||||
sessionId,
|
||||
limit: SESSION_PAGE_SIZE,
|
||||
})
|
||||
);
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { isExpoInstalled } from '../../../project/projectUtils';
|
||||
import { confirmAsync, promptAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ProjectInit from '../init';
|
||||
|
||||
jest.mock('fs');
|
||||
@@ -37,6 +38,7 @@ jest.mock('../../../ora', () => ({
|
||||
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
|
||||
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
|
||||
jest.mock('../../../project/projectUtils');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
let originalProcessArgv: string[];
|
||||
|
||||
@@ -535,4 +537,354 @@ describe(ProjectInit.name, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when account flag is provided', () => {
|
||||
it('uses the specified account without prompting', async () => {
|
||||
mockTestProject({});
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('0129');
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '0129',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
await new ProjectInit(['--account', 'jester'], commandOptions).run();
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(AppMutation.createAppAsync).mock.calls[0][1]).toEqual({
|
||||
accountId: jester.accounts[0].id,
|
||||
projectName: 'testing-123',
|
||||
});
|
||||
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '0129');
|
||||
});
|
||||
|
||||
it('creates the project non-interactively without requiring --force', async () => {
|
||||
mockTestProject({});
|
||||
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('0129');
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '0129',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
await new ProjectInit(['--account', 'jester', '--non-interactive'], commandOptions).run();
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '0129');
|
||||
});
|
||||
|
||||
it('links an existing project non-interactively without requiring --force', async () => {
|
||||
mockTestProject({});
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('123456');
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '123456',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
await new ProjectInit(['--account', 'jester', '--non-interactive'], commandOptions).run();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '123456');
|
||||
});
|
||||
|
||||
it('throws non-interactively without --account when no owner is set', async () => {
|
||||
mockTestProject({});
|
||||
|
||||
await expect(
|
||||
new ProjectInit(['--non-interactive'], commandOptions).run()
|
||||
).rejects.toThrowError(
|
||||
'You have access to multiple accounts. Choose the account that should own this project with the --account flag:'
|
||||
);
|
||||
|
||||
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the account does not exist or is not accessible', async () => {
|
||||
mockTestProject({});
|
||||
|
||||
await expect(
|
||||
new ProjectInit(['--account', 'nonexistent'], commandOptions).run()
|
||||
).rejects.toThrowError(
|
||||
`You are not able to create projects in the "nonexistent" account. Accounts you have permissions to create projects in: jester`
|
||||
);
|
||||
|
||||
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the actor only has a view-only role in the account', async () => {
|
||||
mockTestProject({});
|
||||
|
||||
await expect(
|
||||
new ProjectInit(['--account', 'other'], commandOptions).run()
|
||||
).rejects.toThrowError(
|
||||
`You are not able to create projects in the "other" account. Accounts you have permissions to create projects in: jester`
|
||||
);
|
||||
|
||||
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when the project is already linked to the specified account', async () => {
|
||||
mockTestProject({ configuredProjectId: '1234' });
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
await new ProjectInit(['--account', 'jester'], commandOptions).run();
|
||||
|
||||
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the project is already linked to a different account without --force', async () => {
|
||||
mockTestProject({ configuredProjectId: '1234' });
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester2/testing-123',
|
||||
ownerAccount: jester2.accounts[0],
|
||||
});
|
||||
|
||||
await expect(
|
||||
new ProjectInit(['--account', 'jester'], commandOptions).run()
|
||||
).rejects.toThrowError(
|
||||
`This project is already linked to @jester2 (ID: 1234). Pass --force to re-link it to a project owned by jester, or remove the "extra.eas.projectId" field from your app config.`
|
||||
);
|
||||
|
||||
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-links to the specified account when the project is already linked to a different account with --force', async () => {
|
||||
mockTestProject({ configuredProjectId: '1234' });
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('123456');
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
jest
|
||||
.mocked(AppQuery.byIdAsync)
|
||||
// ownership check for the currently linked project
|
||||
.mockResolvedValueOnce({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester2/testing-123',
|
||||
ownerAccount: jester2.accounts[0],
|
||||
})
|
||||
// owner/slug consistency check for the newly linked project
|
||||
.mockResolvedValue({
|
||||
id: '123456',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
await new ProjectInit(['--account', 'jester', '--force'], commandOptions).run();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '123456');
|
||||
});
|
||||
|
||||
it('re-links and overwrites a conflicting "owner" field with --force', async () => {
|
||||
mockTestProject({ configuredProjectId: '1234', configuredOwner: 'jester2' });
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('123456');
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
jest
|
||||
.mocked(AppQuery.byIdAsync)
|
||||
// ownership check for the currently linked project
|
||||
.mockResolvedValueOnce({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester2/testing-123',
|
||||
ownerAccount: jester2.accounts[0],
|
||||
})
|
||||
// owner/slug consistency check for the newly linked project
|
||||
.mockResolvedValue({
|
||||
id: '123456',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
await new ProjectInit(['--account', 'jester', '--force'], commandOptions).run();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '123456');
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalledWith('/test-project', {
|
||||
owner: 'jester',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the account conflicts with the owner field in the app config', async () => {
|
||||
mockTestProject({ configuredOwner: jester.accounts[0].name });
|
||||
|
||||
await expect(
|
||||
new ProjectInit(['--account', 'other'], commandOptions).run()
|
||||
).rejects.toThrowError(
|
||||
`The account specified with --account (other) does not match the "owner" field in your app config (jester). Provide a matching --account or update the "owner" field.`
|
||||
);
|
||||
|
||||
expect(saveProjectIdToAppConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when json flag is provided', () => {
|
||||
it('prints the created project as JSON', async () => {
|
||||
mockTestProject({});
|
||||
jest.mocked(AppMutation.createAppAsync).mockResolvedValue('0129');
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '0129',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
await new ProjectInit(
|
||||
['--account', 'jester', '--json', '--non-interactive'],
|
||||
commandOptions
|
||||
).run();
|
||||
|
||||
expect(enableJsonOutput).toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
status: 'created',
|
||||
projectId: '0129',
|
||||
owner: 'jester',
|
||||
slug: 'testing-123',
|
||||
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('prints the linked project as JSON when an existing project is found on the server', async () => {
|
||||
mockTestProject({});
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('123456');
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '123456',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
await new ProjectInit(
|
||||
['--account', 'jester', '--json', '--non-interactive'],
|
||||
commandOptions
|
||||
).run();
|
||||
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
status: 'linked',
|
||||
projectId: '123456',
|
||||
owner: 'jester',
|
||||
slug: 'testing-123',
|
||||
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('prints already-linked as JSON when the project is already configured', async () => {
|
||||
mockTestProject({ configuredProjectId: '1234', configuredOwner: jester.accounts[0].name });
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
await new ProjectInit(['--json', '--non-interactive'], commandOptions).run();
|
||||
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
status: 'already-linked',
|
||||
projectId: '1234',
|
||||
owner: 'jester',
|
||||
slug: 'testing-123',
|
||||
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('prints the linked project as JSON when linking with an explicit id', async () => {
|
||||
mockTestProject({});
|
||||
jest
|
||||
.mocked(createOrModifyExpoConfigAsync)
|
||||
.mockResolvedValue({ type: 'success', config: {} as any });
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
await new ProjectInit(['--id', '1234', '--json', '--non-interactive'], commandOptions).run();
|
||||
|
||||
expect(saveProjectIdToAppConfigAsync).toHaveBeenCalledWith('/test-project', '1234');
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
status: 'linked',
|
||||
projectId: '1234',
|
||||
owner: 'jester',
|
||||
slug: 'testing-123',
|
||||
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('implies non-interactive mode', async () => {
|
||||
mockTestProject({});
|
||||
|
||||
await expect(new ProjectInit(['--json'], commandOptions).run()).rejects.toThrowError(
|
||||
'You have access to multiple accounts. Choose the account that should own this project with the --account flag'
|
||||
);
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not print JSON without the json flag', async () => {
|
||||
mockTestProject({ configuredProjectId: '1234', configuredOwner: jester.accounts[0].name });
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
await new ProjectInit([], commandOptions).run();
|
||||
|
||||
expect(enableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Config } from '@oclif/core';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppUploadSessionType } from '../../../../graphql/generated';
|
||||
import { AppQuery } from '../../../../graphql/queries/AppQuery';
|
||||
import { uploadAppScopedFileAtPathToGCSAsync } from '../../../../uploads';
|
||||
import { sleepAsync } from '../../../../utils/promise';
|
||||
import ProjectIconSet from '../set';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('../../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
const spinner = {
|
||||
fail: jest.fn(),
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
succeed: jest.fn(),
|
||||
};
|
||||
spinner.start.mockReturnValue(spinner);
|
||||
return spinner;
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../../uploads');
|
||||
jest.mock('../../../../utils/promise');
|
||||
|
||||
const mockByIdAsync = jest.mocked(AppQuery.byIdAsync);
|
||||
const mockByIdProfileImageUrlAsync = jest.mocked(AppQuery.byIdProfileImageUrlAsync);
|
||||
const mockUploadAsync = jest.mocked(uploadAppScopedFileAtPathToGCSAsync);
|
||||
const mockSleepAsync = jest.mocked(sleepAsync);
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({
|
||||
failures: [],
|
||||
successes: [],
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(ProjectIconSet, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'project-123';
|
||||
|
||||
let now: number;
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
jest.clearAllMocks();
|
||||
|
||||
now = 0;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
mockSleepAsync.mockImplementation(async ms => {
|
||||
now += ms;
|
||||
});
|
||||
|
||||
mockByIdAsync.mockResolvedValue({
|
||||
id: projectId,
|
||||
fullName: '@test-account/test-project',
|
||||
slug: 'test-project',
|
||||
ownerAccount: { name: 'test-account' },
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ProjectIconSet {
|
||||
const command = new ProjectIconSet(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('uploads the icon and polls until the icon URL changes', async () => {
|
||||
vol.fromJSON({ '/app/icon.png': 'fake-png-bytes' });
|
||||
mockByIdProfileImageUrlAsync
|
||||
.mockResolvedValueOnce('https://example.com/old.png')
|
||||
.mockResolvedValueOnce('https://example.com/old.png')
|
||||
.mockResolvedValueOnce('https://example.com/new.png');
|
||||
|
||||
const command = createCommand(['/app/icon.png']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockUploadAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
type: AppUploadSessionType.ProfileImageUpload,
|
||||
appId: projectId,
|
||||
path: '/app/icon.png',
|
||||
});
|
||||
expect(mockByIdProfileImageUrlAsync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('succeeds when the project had no icon before', async () => {
|
||||
vol.fromJSON({ '/app/icon.jpg': 'fake-jpg-bytes' });
|
||||
mockByIdProfileImageUrlAsync
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce('https://example.com/new.png');
|
||||
|
||||
const command = createCommand(['/app/icon.jpg']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockUploadAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws when the file does not exist', async () => {
|
||||
const command = createCommand(['/app/missing.png']);
|
||||
await expect(command.runAsync()).rejects.toThrow('No file found at /app/missing.png');
|
||||
expect(mockUploadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws for an unsupported image format', async () => {
|
||||
vol.fromJSON({ '/app/icon.gif': 'fake-gif-bytes' });
|
||||
|
||||
const command = createCommand(['/app/icon.gif']);
|
||||
await expect(command.runAsync()).rejects.toThrow('Unsupported image format ".gif"');
|
||||
expect(mockUploadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws for a file over the size limit', async () => {
|
||||
vol.fromJSON({ '/app/icon.png': 'x'.repeat(10 * 1024 * 1024 + 1) });
|
||||
|
||||
const command = createCommand(['/app/icon.png']);
|
||||
await expect(command.runAsync()).rejects.toThrow('maximum allowed size is 10 MB');
|
||||
expect(mockUploadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the icon is not processed before the timeout', async () => {
|
||||
vol.fromJSON({ '/app/icon.png': 'fake-png-bytes' });
|
||||
mockByIdProfileImageUrlAsync.mockResolvedValue('https://example.com/old.png');
|
||||
|
||||
const command = createCommand(['/app/icon.png']);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'Timed out waiting for the icon to be processed'
|
||||
);
|
||||
expect(mockUploadAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Args } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { getProjectDashboardUrl } from '../../../build/utils/url';
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EASNonInteractiveFlag } from '../../../commandUtils/flags';
|
||||
import { AppUploadSessionType } from '../../../graphql/generated';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import Log, { link } from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { uploadAppScopedFileAtPathToGCSAsync } from '../../../uploads';
|
||||
import { sleepAsync } from '../../../utils/promise';
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
|
||||
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // matches the upload session's GCS limit
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const POLL_TIMEOUT_MS = 90_000;
|
||||
|
||||
export default class ProjectIconSet extends EasCommand {
|
||||
static override description = 'set the project icon displayed on the EAS dashboard';
|
||||
|
||||
static override args = {
|
||||
path: Args.string({
|
||||
required: true,
|
||||
description:
|
||||
'Path to the icon image (PNG or JPEG, at most 10 MB). Non-square images are center-cropped to a square.',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { path: imagePath },
|
||||
flags,
|
||||
} = await this.parse(ProjectIconSet);
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ProjectIconSet, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
await validateImageAsync(imagePath);
|
||||
|
||||
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
const projectDashboardUrl = getProjectDashboardUrl(app.ownerAccount.name, app.slug);
|
||||
const previousProfileImageUrl = await AppQuery.byIdProfileImageUrlAsync(
|
||||
graphqlClient,
|
||||
projectId
|
||||
);
|
||||
|
||||
const spinner = ora('Uploading project icon').start();
|
||||
try {
|
||||
await uploadAppScopedFileAtPathToGCSAsync(graphqlClient, {
|
||||
type: AppUploadSessionType.ProfileImageUpload,
|
||||
appId: projectId,
|
||||
path: imagePath,
|
||||
});
|
||||
|
||||
// The icon is processed asynchronously (resized and assigned to the
|
||||
// project by the server), so poll until the icon URL changes.
|
||||
spinner.text = 'Processing project icon';
|
||||
await pollForProfileImageChangeAsync(graphqlClient, {
|
||||
projectId,
|
||||
previousProfileImageUrl,
|
||||
projectDashboardUrl,
|
||||
});
|
||||
spinner.succeed(`Set icon for ${chalk.bold(app.fullName)}`);
|
||||
Log.withTick(`View it on the project page: ${link(projectDashboardUrl)}`);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to set project icon');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateImageAsync(imagePath: string): Promise<void> {
|
||||
if (!(await fs.pathExists(imagePath))) {
|
||||
throw new Error(`No file found at ${imagePath}`);
|
||||
}
|
||||
const extension = path.extname(imagePath).toLowerCase();
|
||||
if (!ALLOWED_EXTENSIONS.includes(extension)) {
|
||||
throw new Error(
|
||||
`Unsupported image format "${extension}". The icon must be a PNG or JPEG file.`
|
||||
);
|
||||
}
|
||||
const { size } = await fs.stat(imagePath);
|
||||
if (size > MAX_IMAGE_SIZE_BYTES) {
|
||||
throw new Error(
|
||||
`The image is ${(size / 1024 / 1024).toFixed(1)} MB, but the maximum allowed size is 10 MB.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollForProfileImageChangeAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectId,
|
||||
previousProfileImageUrl,
|
||||
projectDashboardUrl,
|
||||
}: {
|
||||
projectId: string;
|
||||
previousProfileImageUrl: string | null;
|
||||
projectDashboardUrl: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
|
||||
await sleepAsync(POLL_INTERVAL_MS);
|
||||
const profileImageUrl = await AppQuery.byIdProfileImageUrlAsync(graphqlClient, projectId);
|
||||
if (profileImageUrl && profileImageUrl !== previousProfileImageUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for the icon to be processed. It may still appear on the project page shortly: ${chalk.underline(
|
||||
projectDashboardUrl
|
||||
)}`
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,43 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import { getProjectDashboardUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import {
|
||||
ProjectInitResult,
|
||||
ensureOwnerSlugConsistencyAsync,
|
||||
initializeWithExplicitIDAsync,
|
||||
initializeWithoutExplicitIDAsync,
|
||||
} from '../../project/projectInitialization';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class ProjectInit extends EasCommand {
|
||||
static override description = 'create or link an EAS project';
|
||||
static override aliases = ['init'];
|
||||
|
||||
static override examples = [
|
||||
'$ eas init \t # Create or link a project interactively',
|
||||
'$ eas init --id <project-id> \t # Link to the project with the given ID',
|
||||
'$ eas init --account my-account --non-interactive \t # Create or link @my-account/<slug> without prompts',
|
||||
'$ eas init --account my-account --json --non-interactive \t # Same, and print the result as JSON to stdout',
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
id: Flags.string({
|
||||
description: 'ID of the EAS project to link',
|
||||
}),
|
||||
account: Flags.string({
|
||||
description: 'Name of the account that will own the project',
|
||||
exclusive: ['id'],
|
||||
}),
|
||||
force: Flags.boolean({
|
||||
description:
|
||||
'Whether to create a new project/link an existing project without additional prompts or overwrite any existing project ID when running with --id flag',
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
@@ -29,31 +46,50 @@ export default class ProjectInit extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
flags: { id: idArgument, force, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(ProjectInit);
|
||||
const { flags } = await this.parse(ProjectInit);
|
||||
const { id: idArgument, account: accountArgument, force } = flags;
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
projectDir,
|
||||
} = await this.getContextAsync(ProjectInit, { nonInteractive });
|
||||
|
||||
let idForConsistency: string;
|
||||
let result: ProjectInitResult;
|
||||
if (idArgument) {
|
||||
await initializeWithExplicitIDAsync(idArgument, projectDir, {
|
||||
const status = await initializeWithExplicitIDAsync(idArgument, projectDir, {
|
||||
force,
|
||||
nonInteractive,
|
||||
});
|
||||
idForConsistency = idArgument;
|
||||
result = { projectId: idArgument, status };
|
||||
} else {
|
||||
idForConsistency = await initializeWithoutExplicitIDAsync(graphqlClient, actor, projectDir, {
|
||||
result = await initializeWithoutExplicitIDAsync(graphqlClient, actor, projectDir, {
|
||||
force,
|
||||
nonInteractive,
|
||||
accountName: accountArgument,
|
||||
});
|
||||
}
|
||||
|
||||
await ensureOwnerSlugConsistencyAsync(graphqlClient, idForConsistency, projectDir, {
|
||||
force,
|
||||
nonInteractive,
|
||||
});
|
||||
const { owner, slug } = await ensureOwnerSlugConsistencyAsync(
|
||||
graphqlClient,
|
||||
result.projectId,
|
||||
projectDir,
|
||||
{
|
||||
force,
|
||||
nonInteractive,
|
||||
}
|
||||
);
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
status: result.status,
|
||||
projectId: result.projectId,
|
||||
owner,
|
||||
slug,
|
||||
dashboardUrl: getProjectDashboardUrl(owner, slug),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
DeviceRunSessionEventsByIdQuery,
|
||||
DeviceRunSessionStatus,
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../../log';
|
||||
import { loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import {
|
||||
type DeviceRunSessionEvent,
|
||||
downloadDeviceRunSessionEventsAsync,
|
||||
formatDeviceRunSessionEvent,
|
||||
projectDeviceRunSessionEventsForDisplay,
|
||||
} from '../../../simulator/events';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import { sleepAsync } from '../../../utils/promise';
|
||||
import SimulatorEvents from '../events';
|
||||
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/events');
|
||||
jest.mock('../../../utils/json');
|
||||
jest.mock('../../../utils/promise');
|
||||
|
||||
const mockEventsByIdAsync = jest.mocked(DeviceRunSessionQuery.eventsByIdAsync);
|
||||
const mockDownloadEventsAsync = jest.mocked(downloadDeviceRunSessionEventsAsync);
|
||||
const mockFormatEvent = jest.mocked(formatDeviceRunSessionEvent);
|
||||
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
const mockProjectEvents = jest.mocked(projectDeviceRunSessionEventsForDisplay);
|
||||
const mockSleepAsync = jest.mocked(sleepAsync);
|
||||
const mockDebug = jest.mocked(Log.debug);
|
||||
const mockLog = jest.mocked(Log.log);
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({ failures: [], successes: [] });
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(SimulatorEvents, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const projectDir = '/test/project';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockLoadSimulatorEnvAsync.mockResolvedValue();
|
||||
mockProjectEvents.mockImplementation(events => events);
|
||||
mockFormatEvent.mockImplementation(event => `formatted:${event.eventId}`);
|
||||
mockSleepAsync.mockResolvedValue();
|
||||
});
|
||||
|
||||
it('prints a JSON snapshot for the requested session', async () => {
|
||||
const session: DeviceRunSessionEventsByIdQuery['deviceRunSessions']['byId'] = {
|
||||
id: 'session-id',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
artifacts: [
|
||||
{
|
||||
id: 'event-artifact-id',
|
||||
downloadUrl: 'https://example.test/events',
|
||||
metadata: { __eas_type: 'session-events' },
|
||||
},
|
||||
],
|
||||
};
|
||||
const events = [
|
||||
{
|
||||
v: 1 as const,
|
||||
eventId: 'event-id',
|
||||
ts: '2026-07-10T12:00:00.000Z',
|
||||
producer: 'agent-device',
|
||||
type: 'operation.started',
|
||||
summary: 'Started tap',
|
||||
},
|
||||
];
|
||||
mockEventsByIdAsync.mockResolvedValue(session);
|
||||
mockDownloadEventsAsync.mockResolvedValue(events);
|
||||
const command = new SimulatorEvents(['--id', 'session-id', '--json'], getMockOclifConfig());
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockLoadSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
|
||||
expect(mockEventsByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-id');
|
||||
expect(mockDownloadEventsAsync).toHaveBeenCalledWith('https://example.test/events');
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
deviceRunSessionId: 'session-id',
|
||||
events,
|
||||
});
|
||||
expect(mockProjectEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints an empty snapshot when the session has no event artifact', async () => {
|
||||
mockEventsByIdAsync.mockResolvedValue({
|
||||
id: 'session-id',
|
||||
status: DeviceRunSessionStatus.Stopped,
|
||||
artifacts: [],
|
||||
});
|
||||
const command = new SimulatorEvents(['--id', 'session-id', '--json'], getMockOclifConfig());
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectDir,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockDownloadEventsAsync).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
deviceRunSessionId: 'session-id',
|
||||
events: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('prints a condensed text snapshot', async () => {
|
||||
const session = createSession(DeviceRunSessionStatus.Stopped);
|
||||
const events = [createEvent()];
|
||||
mockEventsByIdAsync.mockResolvedValue(session);
|
||||
mockDownloadEventsAsync.mockResolvedValue(events);
|
||||
const command = createCommand(['--id', 'session-id']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockProjectEvents).toHaveBeenCalledWith(events, {
|
||||
includeIncompleteOperations: true,
|
||||
});
|
||||
expect(mockFormatEvent).toHaveBeenCalledWith(events[0]);
|
||||
expect(mockLog).toHaveBeenCalledWith('formatted:event-id');
|
||||
});
|
||||
|
||||
it('refreshes twice after a followed running session stops before flushing incomplete events', async () => {
|
||||
const events = [createEvent()];
|
||||
mockEventsByIdAsync
|
||||
.mockResolvedValueOnce(createSession(DeviceRunSessionStatus.InProgress))
|
||||
.mockResolvedValueOnce(createSession(DeviceRunSessionStatus.Stopped))
|
||||
.mockResolvedValueOnce(createSession(DeviceRunSessionStatus.Stopped))
|
||||
.mockResolvedValueOnce(createSession(DeviceRunSessionStatus.Stopped));
|
||||
mockDownloadEventsAsync.mockResolvedValue(events);
|
||||
mockProjectEvents.mockImplementation((projectedEvents, options) =>
|
||||
options?.includeIncompleteOperations ? projectedEvents : []
|
||||
);
|
||||
const command = createCommand(['--id', 'session-id', '--follow']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEventsByIdAsync).toHaveBeenCalledTimes(4);
|
||||
expect(mockDownloadEventsAsync).toHaveBeenCalledTimes(4);
|
||||
expect(mockSleepAsync).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
mockProjectEvents.mock.calls.map(([, options]) => options?.includeIncompleteOperations)
|
||||
).toEqual([false, false, false, true]);
|
||||
expect(mockLog).toHaveBeenCalledTimes(1);
|
||||
expect(mockLog).toHaveBeenCalledWith('formatted:event-id');
|
||||
});
|
||||
|
||||
it('wakes from polling and flushes incomplete events when follow mode is interrupted', async () => {
|
||||
const existingInterruptListeners = new Set(process.listeners('SIGINT'));
|
||||
mockEventsByIdAsync.mockResolvedValue(createSession(DeviceRunSessionStatus.InProgress));
|
||||
const events = [createEvent()];
|
||||
mockDownloadEventsAsync.mockResolvedValue(events);
|
||||
mockProjectEvents.mockImplementation((projectedEvents, options) =>
|
||||
options?.includeIncompleteOperations ? projectedEvents : []
|
||||
);
|
||||
let notifySleepStarted: () => void;
|
||||
const sleepStarted = new Promise<void>(resolve => {
|
||||
notifySleepStarted = resolve;
|
||||
});
|
||||
mockSleepAsync.mockImplementationOnce(() => {
|
||||
notifySleepStarted();
|
||||
return new Promise<void>(() => {});
|
||||
});
|
||||
const command = createCommand(['--id', 'session-id', '--follow']);
|
||||
|
||||
const commandPromise = command.runAsync();
|
||||
await sleepStarted;
|
||||
const interruptHandler = process
|
||||
.listeners('SIGINT')
|
||||
.find(listener => !existingInterruptListeners.has(listener));
|
||||
expect(interruptHandler).toBeDefined();
|
||||
interruptHandler?.('SIGINT');
|
||||
await commandPromise;
|
||||
|
||||
expect(mockEventsByIdAsync).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
mockProjectEvents.mock.calls.map(([, options]) => options?.includeIncompleteOperations)
|
||||
).toEqual([false, true]);
|
||||
expect(mockLog).toHaveBeenCalledWith('formatted:event-id');
|
||||
expect(process.listeners('SIGINT')).toEqual([...existingInterruptListeners]);
|
||||
});
|
||||
|
||||
it('force exits on a second interrupt', async () => {
|
||||
const existingInterruptListeners = new Set(process.listeners('SIGINT'));
|
||||
mockEventsByIdAsync.mockResolvedValue(createSession(DeviceRunSessionStatus.InProgress));
|
||||
mockDownloadEventsAsync.mockResolvedValue([]);
|
||||
let notifySleepStarted: () => void;
|
||||
const sleepStarted = new Promise<void>(resolve => {
|
||||
notifySleepStarted = resolve;
|
||||
});
|
||||
mockSleepAsync.mockImplementationOnce(() => {
|
||||
notifySleepStarted();
|
||||
return new Promise<void>(() => {});
|
||||
});
|
||||
const exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code?: string | number | null): never => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
const command = createCommand(['--id', 'session-id', '--follow']);
|
||||
|
||||
try {
|
||||
const commandPromise = command.runAsync();
|
||||
await sleepStarted;
|
||||
const interruptHandler = process
|
||||
.listeners('SIGINT')
|
||||
.find(listener => !existingInterruptListeners.has(listener));
|
||||
expect(interruptHandler).toBeDefined();
|
||||
interruptHandler?.('SIGINT');
|
||||
expect(() => interruptHandler?.('SIGINT')).toThrow('process.exit(130)');
|
||||
await commandPromise;
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(130);
|
||||
expect(process.listeners('SIGINT')).toEqual([...existingInterruptListeners]);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries transient query and artifact download errors in follow mode', async () => {
|
||||
const events = [createEvent()];
|
||||
mockEventsByIdAsync
|
||||
.mockRejectedValueOnce(new Error('query unavailable'))
|
||||
.mockResolvedValueOnce(createSession(DeviceRunSessionStatus.InProgress))
|
||||
.mockResolvedValueOnce(createSession(DeviceRunSessionStatus.Stopped));
|
||||
mockDownloadEventsAsync
|
||||
.mockRejectedValueOnce(new Error('artifact unavailable'))
|
||||
.mockResolvedValueOnce(events);
|
||||
const command = createCommand(['--id', 'session-id', '--follow']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEventsByIdAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockDownloadEventsAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockSleepAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockDebug).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Failed to poll simulator session events: query unavailable'
|
||||
);
|
||||
expect(mockDebug).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'Failed to poll simulator session events: artifact unavailable'
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith('formatted:event-id');
|
||||
});
|
||||
|
||||
it('does not retry query errors outside follow mode', async () => {
|
||||
const existingInterruptListeners = process.listeners('SIGINT');
|
||||
mockEventsByIdAsync.mockRejectedValue(new Error('query unavailable'));
|
||||
const command = createCommand(['--id', 'session-id']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('query unavailable');
|
||||
|
||||
expect(mockSleepAsync).not.toHaveBeenCalled();
|
||||
expect(mockDebug).not.toHaveBeenCalled();
|
||||
expect(process.listeners('SIGINT')).toEqual(existingInterruptListeners);
|
||||
});
|
||||
|
||||
it('does not register an interrupt handler outside follow mode', async () => {
|
||||
const existingInterruptListeners = process.listeners('SIGINT');
|
||||
let notifyQueryStarted: () => void;
|
||||
const queryStarted = new Promise<void>(resolve => {
|
||||
notifyQueryStarted = resolve;
|
||||
});
|
||||
let resolveSession: (
|
||||
session: DeviceRunSessionEventsByIdQuery['deviceRunSessions']['byId']
|
||||
) => void = () => {
|
||||
throw new Error('Session promise was not initialized.');
|
||||
};
|
||||
const pendingSession = new Promise<
|
||||
DeviceRunSessionEventsByIdQuery['deviceRunSessions']['byId']
|
||||
>(resolve => {
|
||||
resolveSession = resolve;
|
||||
});
|
||||
mockEventsByIdAsync.mockImplementationOnce(() => {
|
||||
notifyQueryStarted();
|
||||
return pendingSession;
|
||||
});
|
||||
mockDownloadEventsAsync.mockResolvedValue([]);
|
||||
const command = createCommand(['--id', 'session-id']);
|
||||
|
||||
const commandPromise = command.runAsync();
|
||||
await queryStarted;
|
||||
expect(process.listeners('SIGINT')).toEqual(existingInterruptListeners);
|
||||
resolveSession(createSession(DeviceRunSessionStatus.Stopped));
|
||||
await commandPromise;
|
||||
});
|
||||
|
||||
it('rejects combining JSON and follow output', async () => {
|
||||
const command = new SimulatorEvents(
|
||||
['--id', 'session-id', '--json', '--follow'],
|
||||
getMockOclifConfig()
|
||||
);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('Use either --json or --follow, not both.');
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand(args: string[]): SimulatorEvents {
|
||||
const command = new SimulatorEvents(args, getMockOclifConfig());
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
|
||||
projectDir: '/test/project',
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
function createSession(
|
||||
status: DeviceRunSessionStatus
|
||||
): DeviceRunSessionEventsByIdQuery['deviceRunSessions']['byId'] {
|
||||
return {
|
||||
id: 'session-id',
|
||||
status,
|
||||
artifacts: [
|
||||
{
|
||||
id: 'event-artifact-id',
|
||||
downloadUrl: 'https://example.test/events',
|
||||
metadata: { __eas_type: 'session-events' },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createEvent(): DeviceRunSessionEvent {
|
||||
return {
|
||||
v: 1 as const,
|
||||
eventId: 'event-id',
|
||||
ts: '2026-07-10T12:00:00.000Z',
|
||||
producer: 'agent-device',
|
||||
type: 'operation.started',
|
||||
summary: 'Started tap',
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
JobRunStatus,
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../../log';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
@@ -46,6 +47,7 @@ const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): DeviceRunSessionById {
|
||||
return {
|
||||
id: 'session-123',
|
||||
name: null,
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
platform: AppPlatform.Ios,
|
||||
@@ -134,6 +136,7 @@ describe(SimulatorGet, () => {
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
name: undefined,
|
||||
type: 'agent-device',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
platform: AppPlatform.Ios,
|
||||
@@ -148,6 +151,26 @@ describe(SimulatorGet, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the session name in JSON and human output when the session is named', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession({ name: 'Checkout regression' }));
|
||||
|
||||
const { command } = createCommand(['--id', 'session-123', '--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'Checkout regression' })
|
||||
);
|
||||
|
||||
jest.clearAllMocks();
|
||||
mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue();
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession({ name: 'Checkout regression' }));
|
||||
|
||||
const { command: humanCommand } = createCommand(['--id', 'session-123']);
|
||||
await humanCommand.runAsync();
|
||||
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Checkout regression'));
|
||||
});
|
||||
|
||||
it(`uses ${EAS_SIMULATOR_SESSION_ID} from simulator env when --id is not passed`, async () => {
|
||||
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
const session = makeDeviceRunSession({ id: 'session-from-env' });
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
JobRunStatus,
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorList from '../list';
|
||||
|
||||
@@ -40,6 +41,7 @@ const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
function makeSession(overrides: Partial<DeviceRunSessionNode> = {}): DeviceRunSessionNode {
|
||||
return {
|
||||
id: 'session-123',
|
||||
name: null,
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
platform: AppPlatform.Ios,
|
||||
@@ -129,6 +131,7 @@ describe(SimulatorList, () => {
|
||||
sessions: [
|
||||
{
|
||||
id: 'session-123',
|
||||
name: undefined,
|
||||
type: 'agent-device',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
platform: AppPlatform.Ios,
|
||||
@@ -180,6 +183,26 @@ describe(SimulatorList, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('prints a name row for every session, named or not', async () => {
|
||||
mockListByAppIdAsync.mockResolvedValue(
|
||||
makeConnection([
|
||||
makeSession({ id: 'session-1', name: 'Checkout regression' }),
|
||||
makeSession({ id: 'session-2' }),
|
||||
])
|
||||
);
|
||||
|
||||
const { command } = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
const printed = jest
|
||||
.mocked(Log.log)
|
||||
.mock.calls.map(([entry]) => String(entry))
|
||||
.join('\n');
|
||||
expect(printed).toContain('Name: Checkout regression');
|
||||
expect(printed).toContain('Name: null');
|
||||
expect(printed.match(/Name:/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('runs non-interactively without --json', async () => {
|
||||
const session = makeSession({ id: 'session-1', turtleJobRun: null });
|
||||
mockListByAppIdAsync.mockResolvedValue(makeConnection([session]));
|
||||
|
||||
@@ -99,6 +99,7 @@ function makeCreatedDeviceRunSession(
|
||||
function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): DeviceRunSessionById {
|
||||
return {
|
||||
id: 'session-123',
|
||||
name: null,
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
platform: AppPlatform.Ios,
|
||||
@@ -189,6 +190,7 @@ describe(SimulatorStart, () => {
|
||||
});
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'project-123',
|
||||
name: undefined,
|
||||
packageVersion: undefined,
|
||||
platform: AppPlatform.Ios,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
@@ -231,7 +233,7 @@ describe(SimulatorStart, () => {
|
||||
[
|
||||
'🔑 Run the following to use agent-device with the simulator:',
|
||||
'',
|
||||
'eas simulator:exec agent-device <command>',
|
||||
'eas simulator:exec npx agent-device <command>',
|
||||
'',
|
||||
'🌐 Open the following URL in your browser to preview the simulator:',
|
||||
'',
|
||||
@@ -278,6 +280,7 @@ describe(SimulatorStart, () => {
|
||||
);
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'project-123',
|
||||
name: undefined,
|
||||
packageVersion: undefined,
|
||||
platform: AppPlatform.Ios,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
@@ -297,6 +300,7 @@ describe(SimulatorStart, () => {
|
||||
);
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'project-123',
|
||||
name: undefined,
|
||||
packageVersion: undefined,
|
||||
platform: AppPlatform.Ios,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
@@ -325,6 +329,48 @@ describe(SimulatorStart, () => {
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
|
||||
});
|
||||
|
||||
it('forwards --name to the create mutation', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--name',
|
||||
'Checkout regression',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ name: 'Checkout regression' })
|
||||
);
|
||||
});
|
||||
|
||||
it('trims --name before sending it', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--name',
|
||||
' Checkout regression ',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ name: 'Checkout regression' })
|
||||
);
|
||||
});
|
||||
|
||||
it('omits a blank --name instead of letting the server reject it', async () => {
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--name', ' ']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ name: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts to select the platform when --platform is omitted', async () => {
|
||||
mockPromptAsync.mockResolvedValueOnce({ selectedPlatform: AppPlatform.Android });
|
||||
mockByIdAsync
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import {
|
||||
type DeviceRunSessionEventsByIdQuery,
|
||||
DeviceRunSessionStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import {
|
||||
type DeviceRunSessionEvent,
|
||||
downloadDeviceRunSessionEventsAsync,
|
||||
formatDeviceRunSessionEvent,
|
||||
projectDeviceRunSessionEventsForDisplay,
|
||||
} from '../../simulator/events';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const POST_STOP_REFRESH_COUNT = 2;
|
||||
|
||||
export default class SimulatorEvents extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description =
|
||||
'[EXPERIMENTAL] show activity events from a remote simulator session';
|
||||
|
||||
static override flags = {
|
||||
id: Flags.string({
|
||||
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
}),
|
||||
follow: Flags.boolean({
|
||||
char: 'f',
|
||||
description: 'Keep watching for new events until the session ends.',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorEvents);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (jsonFlag && flags.follow) {
|
||||
throw new Error('Use either --json or --follow, not both.');
|
||||
}
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
projectDir,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorEvents, { nonInteractive });
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const deviceRunSessionId = flags.id ?? process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
if (!deviceRunSessionId) {
|
||||
throw new Error(
|
||||
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
|
||||
);
|
||||
}
|
||||
|
||||
const printedEventIds = new Set<string>();
|
||||
let observedRunningSession = false;
|
||||
let remainingPostStopRefreshes = 0;
|
||||
let latestEvents: DeviceRunSessionEvent[] = [];
|
||||
const abortController = new AbortController();
|
||||
const { signal } = abortController;
|
||||
const abortPromise = new Promise<void>(resolve => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
const interruptHandler = (): void => {
|
||||
if (signal.aborted) {
|
||||
process.exit(130);
|
||||
}
|
||||
abortController.abort();
|
||||
};
|
||||
if (flags.follow) {
|
||||
process.on('SIGINT', interruptHandler);
|
||||
}
|
||||
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
let session: DeviceRunSessionEventsByIdQuery['deviceRunSessions']['byId'];
|
||||
let events: DeviceRunSessionEvent[];
|
||||
try {
|
||||
session = await DeviceRunSessionQuery.eventsByIdAsync(graphqlClient, deviceRunSessionId);
|
||||
const eventArtifact = session.artifacts.find(
|
||||
artifact => artifact.metadata?.__eas_type === 'session-events'
|
||||
);
|
||||
events = eventArtifact
|
||||
? await downloadDeviceRunSessionEventsAsync(eventArtifact.downloadUrl)
|
||||
: [];
|
||||
} catch (err) {
|
||||
if (!flags.follow) {
|
||||
throw err;
|
||||
}
|
||||
Log.debug(
|
||||
`Failed to poll simulator session events: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
);
|
||||
await Promise.race([sleepAsync(POLL_INTERVAL_MS), abortPromise]);
|
||||
continue;
|
||||
}
|
||||
latestEvents = events;
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ deviceRunSessionId, events });
|
||||
return;
|
||||
}
|
||||
|
||||
const isRunning =
|
||||
session.status === DeviceRunSessionStatus.New ||
|
||||
session.status === DeviceRunSessionStatus.InProgress;
|
||||
if (isRunning) {
|
||||
observedRunningSession = true;
|
||||
remainingPostStopRefreshes = 0;
|
||||
} else if (observedRunningSession) {
|
||||
observedRunningSession = false;
|
||||
remainingPostStopRefreshes = POST_STOP_REFRESH_COUNT;
|
||||
}
|
||||
|
||||
const shouldRefreshAgain =
|
||||
flags.follow && !signal.aborted && (isRunning || remainingPostStopRefreshes > 0);
|
||||
printNewEvents(events, printedEventIds, !shouldRefreshAgain);
|
||||
|
||||
if (!shouldRefreshAgain) {
|
||||
if (events.length === 0) {
|
||||
Log.log('No simulator session activity has been recorded.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isRunning) {
|
||||
remainingPostStopRefreshes -= 1;
|
||||
}
|
||||
await Promise.race([sleepAsync(POLL_INTERVAL_MS), abortPromise]);
|
||||
}
|
||||
printNewEvents(latestEvents, printedEventIds, true);
|
||||
} finally {
|
||||
if (flags.follow) {
|
||||
process.removeListener('SIGINT', interruptHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printNewEvents(
|
||||
events: DeviceRunSessionEvent[],
|
||||
printedEventIds: Set<string>,
|
||||
includeIncompleteOperations: boolean
|
||||
): void {
|
||||
const displayEvents = projectDeviceRunSessionEventsForDisplay(events, {
|
||||
includeIncompleteOperations,
|
||||
});
|
||||
for (const event of displayEvents) {
|
||||
if (printedEventIds.has(event.eventId)) {
|
||||
continue;
|
||||
}
|
||||
Log.log(formatDeviceRunSessionEvent(event));
|
||||
printedEventIds.add(event.eventId);
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ export default class SimulatorGet extends EasCommand {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: session.id,
|
||||
name: session.name ?? undefined,
|
||||
type: deviceRunSessionTypeToFlagValue(session.type),
|
||||
status: session.status,
|
||||
platform: session.platform,
|
||||
@@ -120,6 +121,7 @@ export default class SimulatorGet extends EasCommand {
|
||||
function formatSessionFields(session: DeviceRunSessionById, deviceRunSessionUrl: string): string {
|
||||
return formatFields([
|
||||
{ label: 'ID', value: session.id },
|
||||
{ label: 'Name', value: formatNullable(session.name) },
|
||||
{ label: 'Type', value: session.type },
|
||||
{ label: 'Status', value: session.status },
|
||||
{ label: 'Platform', value: session.platform },
|
||||
|
||||
@@ -131,6 +131,7 @@ export default class SimulatorList extends EasCommand {
|
||||
printJsonOnlyOutput({
|
||||
sessions: sessions.map(session => ({
|
||||
id: session.id,
|
||||
name: session.name ?? undefined,
|
||||
type: deviceRunSessionTypeToFlagValue(session.type),
|
||||
status: session.status,
|
||||
platform: session.platform,
|
||||
@@ -163,6 +164,7 @@ export default class SimulatorList extends EasCommand {
|
||||
);
|
||||
const lines = [
|
||||
`ID: ${session.id}`,
|
||||
`Name: ${session.name ?? 'null'}`,
|
||||
`Type: ${session.type}`,
|
||||
`Status: ${session.status}`,
|
||||
`Platform: ${session.platform}`,
|
||||
|
||||
@@ -61,6 +61,10 @@ export default class SimulatorStart extends EasCommand {
|
||||
description: 'Device platform',
|
||||
options: PLATFORM_FLAG_VALUES,
|
||||
})(),
|
||||
name: Flags.string({
|
||||
description:
|
||||
'Human-readable name for the simulator session, shown in eas simulator:list and on expo.dev. Defaults to unnamed.',
|
||||
}),
|
||||
type: Flags.option({
|
||||
description: 'Type of simulator session to create',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
@@ -111,6 +115,10 @@ export default class SimulatorStart extends EasCommand {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
// The server rejects blank names, so trim here and treat a whitespace-only
|
||||
// --name as if it had been omitted rather than surfacing a validation error.
|
||||
const name = flags.name?.trim() || undefined;
|
||||
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const existingDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
if (existingDeviceRunSessionId && !flags.force) {
|
||||
@@ -136,6 +144,7 @@ export default class SimulatorStart extends EasCommand {
|
||||
try {
|
||||
const session = await DeviceRunSessionMutation.createDeviceRunSessionAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
name,
|
||||
platform,
|
||||
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
|
||||
packageVersion: flags['package-version'],
|
||||
@@ -224,6 +233,7 @@ export default class SimulatorStart extends EasCommand {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: deviceRunSessionId,
|
||||
name,
|
||||
type: flags.type,
|
||||
deviceRunSessionUrl,
|
||||
remoteConfig,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasPaginatedQueryFlags,
|
||||
getLimitFlagWithCustomValues,
|
||||
getPaginatedQueryOptions,
|
||||
} from '../../commandUtils/pagination';
|
||||
import { resolveTestFlightAppAsync } from '../../testflight/app';
|
||||
import {
|
||||
TESTFLIGHT_PAGE_SIZE,
|
||||
listAndRenderTestFlightCrashesAsync,
|
||||
viewAndRenderBetaFeedbackAsync,
|
||||
} from '../../testflight/queries';
|
||||
import { resolveBetaFeedbackTarget } from '../../testflight/target';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
|
||||
export default class TestFlightCrashes extends EasCommand {
|
||||
static override description =
|
||||
'display crashes reported by TestFlight testers, or the full crash log of a single crash';
|
||||
|
||||
static override examples = [
|
||||
'$ eas testflight:crashes \t # Show the most recent crashes',
|
||||
'$ eas testflight:crashes --limit 50 --offset 20 \t # Page through crashes',
|
||||
'$ eas testflight:crashes AAo2eIIfGzcb1BzuUv3xrh4 \t # Show the full crash log for one crash',
|
||||
'$ eas testflight:crashes --json \t # Print a page of crashes, with paging metadata, as JSON',
|
||||
'$ eas testflight:crashes ${{ app_store_connect.beta_feedback.url }} --json \t # Look up whatever an EAS workflow trigger reported',
|
||||
];
|
||||
|
||||
static override args = {
|
||||
id: Args.string({
|
||||
description:
|
||||
'ID or App Store Connect API URL of a single submission to show. Accepts ${{ app_store_connect.beta_feedback.id }} or ${{ app_store_connect.beta_feedback.url }} from an EAS workflow trigger.',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
type: Flags.option({
|
||||
description:
|
||||
'Kind of feedback the ID refers to. Only needed when passing a bare ID for screenshot feedback; a URL already encodes it.',
|
||||
options: ['crash', 'screenshot'] as const,
|
||||
})(),
|
||||
profile: Flags.string({
|
||||
char: 'e',
|
||||
description:
|
||||
'Name of the submit profile from eas.json used to resolve the bundle identifier and App Store Connect API key. Defaults to "production".',
|
||||
}),
|
||||
...EasPaginatedQueryFlags,
|
||||
limit: getLimitFlagWithCustomValues({ defaultTo: TESTFLIGHT_PAGE_SIZE, limit: 200 }),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.Analytics,
|
||||
...this.ContextOptions.Vcs,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(TestFlightCrashes);
|
||||
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
|
||||
const { json, nonInteractive } = paginatedQueryOptions;
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (flags.type && !args.id) {
|
||||
throw new Error('--type only applies when looking up a single submission by ID or URL.');
|
||||
}
|
||||
|
||||
// Resolved before authenticating so a malformed ID or URL fails fast.
|
||||
const target = args.id
|
||||
? resolveBetaFeedbackTarget({ idOrUrl: args.id, type: flags.type, defaultType: 'crash' })
|
||||
: null;
|
||||
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
privateProjectConfig: { exp, projectId, projectDir },
|
||||
analytics,
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(TestFlightCrashes, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const app = await resolveTestFlightAppAsync({
|
||||
actor,
|
||||
analytics,
|
||||
exp,
|
||||
graphqlClient,
|
||||
nonInteractive,
|
||||
profileName: flags.profile,
|
||||
projectDir,
|
||||
projectId,
|
||||
vcsClient,
|
||||
});
|
||||
|
||||
if (target) {
|
||||
await viewAndRenderBetaFeedbackAsync(app, target, { json });
|
||||
return;
|
||||
}
|
||||
|
||||
await listAndRenderTestFlightCrashesAsync(app, paginatedQueryOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasPaginatedQueryFlags,
|
||||
getLimitFlagWithCustomValues,
|
||||
getPaginatedQueryOptions,
|
||||
} from '../../commandUtils/pagination';
|
||||
import { resolveTestFlightAppAsync } from '../../testflight/app';
|
||||
import {
|
||||
TESTFLIGHT_PAGE_SIZE,
|
||||
listAndRenderTestFlightFeedbackAsync,
|
||||
viewAndRenderBetaFeedbackAsync,
|
||||
} from '../../testflight/queries';
|
||||
import { resolveBetaFeedbackTarget } from '../../testflight/target';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
|
||||
export default class TestFlightFeedback extends EasCommand {
|
||||
static override description =
|
||||
'display screenshot feedback submitted by TestFlight testers, including their comments, device information, and screenshot URLs';
|
||||
|
||||
static override examples = [
|
||||
'$ eas testflight:feedback \t # Show the most recent feedback submissions',
|
||||
'$ eas testflight:feedback --limit 50 --offset 20 \t # Page through submissions',
|
||||
'$ eas testflight:feedback --json \t # Print a page of feedback, with paging metadata, as JSON',
|
||||
'$ eas testflight:feedback AD8JvKbr0BK0Cj9OnM6WO6I \t # Show a single submission by ID',
|
||||
'$ eas testflight:feedback ${{ app_store_connect.beta_feedback.url }} --json \t # Look up whatever an EAS workflow trigger reported',
|
||||
];
|
||||
|
||||
static override args = {
|
||||
id: Args.string({
|
||||
description:
|
||||
'ID or App Store Connect API URL of a single submission to show. Accepts ${{ app_store_connect.beta_feedback.id }} or ${{ app_store_connect.beta_feedback.url }} from an EAS workflow trigger.',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
type: Flags.option({
|
||||
description:
|
||||
'Kind of feedback the ID refers to. Only needed when passing a bare ID for a crash; a URL already encodes it.',
|
||||
options: ['crash', 'screenshot'] as const,
|
||||
})(),
|
||||
profile: Flags.string({
|
||||
char: 'e',
|
||||
description:
|
||||
'Name of the submit profile from eas.json used to resolve the bundle identifier and App Store Connect API key. Defaults to "production".',
|
||||
}),
|
||||
...EasPaginatedQueryFlags,
|
||||
limit: getLimitFlagWithCustomValues({ defaultTo: TESTFLIGHT_PAGE_SIZE, limit: 200 }),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.Analytics,
|
||||
...this.ContextOptions.Vcs,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(TestFlightFeedback);
|
||||
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
|
||||
const { json, nonInteractive } = paginatedQueryOptions;
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (flags.type && !args.id) {
|
||||
throw new Error('--type only applies when looking up a single submission by ID or URL.');
|
||||
}
|
||||
|
||||
// Resolved before authenticating so a malformed ID or URL fails fast.
|
||||
const target = args.id
|
||||
? resolveBetaFeedbackTarget({ idOrUrl: args.id, type: flags.type, defaultType: 'screenshot' })
|
||||
: null;
|
||||
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
privateProjectConfig: { exp, projectId, projectDir },
|
||||
analytics,
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(TestFlightFeedback, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const app = await resolveTestFlightAppAsync({
|
||||
actor,
|
||||
analytics,
|
||||
exp,
|
||||
graphqlClient,
|
||||
nonInteractive,
|
||||
profileName: flags.profile,
|
||||
projectDir,
|
||||
projectId,
|
||||
vcsClient,
|
||||
});
|
||||
|
||||
if (target) {
|
||||
await viewAndRenderBetaFeedbackAsync(app, target, { json });
|
||||
return;
|
||||
}
|
||||
|
||||
await listAndRenderTestFlightFeedbackAsync(app, paginatedQueryOptions);
|
||||
}
|
||||
}
|
||||
+385
-281
File diff suppressed because it is too large.
Load diff
@@ -4,8 +4,11 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AccountUploadSessionType,
|
||||
AppUploadSessionType,
|
||||
CreateAccountScopedUploadSessionMutation,
|
||||
CreateAccountScopedUploadSessionMutationVariables,
|
||||
CreateAppScopedUploadSessionMutation,
|
||||
CreateAppScopedUploadSessionMutationVariables,
|
||||
CreateUploadSessionMutation,
|
||||
CreateUploadSessionMutationVariables,
|
||||
UploadSessionType,
|
||||
@@ -77,4 +80,36 @@ export const UploadSessionMutation = {
|
||||
);
|
||||
return data.uploadSession.createAccountScopedUploadSession;
|
||||
},
|
||||
async createAppScopedUploadSessionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
type,
|
||||
appID,
|
||||
}: {
|
||||
type: AppUploadSessionType;
|
||||
appID: string;
|
||||
}
|
||||
): Promise<SignedUrl> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
CreateAppScopedUploadSessionMutation,
|
||||
CreateAppScopedUploadSessionMutationVariables
|
||||
>(
|
||||
gql`
|
||||
mutation CreateAppScopedUploadSessionMutation($appID: ID!, $type: AppUploadSessionType!) {
|
||||
uploadSession {
|
||||
createAppScopedUploadSession(appID: $appID, type: $type)
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
type,
|
||||
appID,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.uploadSession.createAppScopedUploadSession;
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AppByFullNameQuery,
|
||||
AppByIdProfileImageUrlQuery,
|
||||
AppByIdQuery,
|
||||
AppByIdWorkflowRunsFilteredByStatusQuery,
|
||||
AppByIdWorkflowsQuery,
|
||||
@@ -72,6 +73,35 @@ export const AppQuery = {
|
||||
assert(data.app, 'GraphQL: `app` not defined in server response');
|
||||
return data.app.byFullName;
|
||||
},
|
||||
async byIdProfileImageUrlAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string
|
||||
): Promise<string | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppByIdProfileImageUrlQuery>(
|
||||
gql`
|
||||
query AppByIdProfileImageUrlQuery($appId: String!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
profileImageUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appId: projectId },
|
||||
{
|
||||
requestPolicy: 'network-only',
|
||||
additionalTypenames: ['App'],
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
assert(data.app, 'GraphQL: `app` not defined in server response');
|
||||
return data.app.byId.profileImageUrl ?? null;
|
||||
},
|
||||
async byIdWorkflowsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string
|
||||
|
||||
@@ -5,12 +5,43 @@ import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
DeviceRunSessionByIdQuery,
|
||||
DeviceRunSessionByIdQueryVariables,
|
||||
DeviceRunSessionEventsByIdQuery,
|
||||
DeviceRunSessionEventsByIdQueryVariables,
|
||||
DeviceRunSessionFilterInput,
|
||||
DeviceRunSessionsByAppIdQuery,
|
||||
DeviceRunSessionsByAppIdQueryVariables,
|
||||
} from '../generated';
|
||||
|
||||
export const DeviceRunSessionQuery = {
|
||||
async eventsByIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionId: string
|
||||
): Promise<DeviceRunSessionEventsByIdQuery['deviceRunSessions']['byId']> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<DeviceRunSessionEventsByIdQuery, DeviceRunSessionEventsByIdQueryVariables>(
|
||||
gql`
|
||||
query DeviceRunSessionEventsByIdQuery($deviceRunSessionId: ID!) {
|
||||
deviceRunSessions {
|
||||
byId(deviceRunSessionId: $deviceRunSessionId) {
|
||||
id
|
||||
status
|
||||
artifacts {
|
||||
id
|
||||
downloadUrl
|
||||
metadata
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ deviceRunSessionId },
|
||||
{ requestPolicy: 'network-only' }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.deviceRunSessions.byId;
|
||||
},
|
||||
async byIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionId: string
|
||||
@@ -23,6 +54,7 @@ export const DeviceRunSessionQuery = {
|
||||
deviceRunSessions {
|
||||
byId(deviceRunSessionId: $deviceRunSessionId) {
|
||||
id
|
||||
name
|
||||
status
|
||||
type
|
||||
platform
|
||||
@@ -111,6 +143,7 @@ export const DeviceRunSessionQuery = {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
name
|
||||
status
|
||||
type
|
||||
platform
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import {
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
AppObservePlatform,
|
||||
AppPlatform,
|
||||
} from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { EventsOrderPreset, fetchObserveEventsAsync, resolveOrderBy } from '../fetchEvents';
|
||||
import {
|
||||
EventsOrderPreset,
|
||||
fetchObserveEventsAsync,
|
||||
fetchTotalEventCountAsync,
|
||||
resolveOrderBy,
|
||||
} from '../fetchEvents';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../planGating';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
|
||||
@@ -274,3 +284,57 @@ describe(fetchObserveEventsAsync, () => {
|
||||
expect(calledVars).not.toHaveProperty('after');
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchTotalEventCountAsync, () => {
|
||||
const mockAppVersionsAsync = jest.mocked(ObserveQuery.appVersionsAsync);
|
||||
const mockGraphqlClient = {} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppVersionsAsync.mockReset();
|
||||
});
|
||||
|
||||
it('rethrows plan-gate errors instead of swallowing them as zero', async () => {
|
||||
const gateError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'Subscription to EAS is required for this feature.',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
}
|
||||
),
|
||||
],
|
||||
});
|
||||
mockAppVersionsAsync.mockRejectedValue(gateError);
|
||||
|
||||
await expect(
|
||||
fetchTotalEventCountAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
'expo.navigation.tti',
|
||||
[AppPlatform.Ios, AppPlatform.Android],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
)
|
||||
).rejects.toBe(gateError);
|
||||
});
|
||||
|
||||
it('swallows non-gate errors and counts them as zero', async () => {
|
||||
mockAppVersionsAsync.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const total = await fetchTotalEventCountAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
'expo.app_startup.tti',
|
||||
[AppPlatform.Ios],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(total).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { AppObserveAppVersion, AppObservePlatform, AppPlatform } from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { makeMetricsKey } from '../formatMetrics';
|
||||
import { fetchObserveMetricsAsync } from '../fetchMetrics';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../planGating';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
jest.mock('../../log');
|
||||
@@ -207,6 +211,36 @@ describe('fetchObserveMetricsAsync', () => {
|
||||
expect(metricsMap.size).toBe(0);
|
||||
});
|
||||
|
||||
it('rethrows plan-gate errors instead of swallowing them as a partial failure', async () => {
|
||||
const gateError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'Subscription to EAS is required for this feature.',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
}
|
||||
),
|
||||
],
|
||||
});
|
||||
mockAppVersionsAsync.mockRejectedValue(gateError);
|
||||
|
||||
await expect(
|
||||
fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.navigation.tti'],
|
||||
[AppPlatform.Ios, AppPlatform.Android],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
)
|
||||
).rejects.toBe(gateError);
|
||||
});
|
||||
|
||||
it('maps AppObservePlatform back to AppPlatform correctly in metricsMap keys', async () => {
|
||||
mockAppVersionsAsync.mockResolvedValue([
|
||||
makeAppVersion('3.0.0', [
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import {
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveNavigationRoute,
|
||||
@@ -7,6 +10,7 @@ import {
|
||||
} from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveNavigationRoutesAsync } from '../fetchNavigationRoutes';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../planGating';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
jest.mock('../../log');
|
||||
@@ -187,4 +191,32 @@ describe('fetchObserveNavigationRoutesAsync', () => {
|
||||
expect(routes[0].platform).toBe(AppPlatform.Ios);
|
||||
expect(pageInfoByPlatform.has(AppPlatform.Android)).toBe(false);
|
||||
});
|
||||
|
||||
it('rethrows plan-gate errors instead of swallowing them as a partial failure', async () => {
|
||||
const gateError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'Subscription to EAS is required for this feature.',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
}
|
||||
),
|
||||
],
|
||||
});
|
||||
mockNavigationRoutesAsync.mockRejectedValue(gateError);
|
||||
|
||||
await expect(
|
||||
fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios, AppPlatform.Android],
|
||||
limit: 50,
|
||||
})
|
||||
).rejects.toBe(gateError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import {
|
||||
EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
|
||||
isObservePlanGateError,
|
||||
withObservePlanGateHandlingAsync,
|
||||
} from '../planGating';
|
||||
|
||||
function graphqlErrorWithCode(errorCode: string, message = 'server message'): CombinedError {
|
||||
const graphQLError = new GraphQLError(message, null, null, null, null, null, { errorCode });
|
||||
return new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
}
|
||||
|
||||
describe(withObservePlanGateHandlingAsync, () => {
|
||||
it('returns the value when the operation succeeds', async () => {
|
||||
await expect(withObservePlanGateHandlingAsync(async () => 42)).resolves.toBe(42);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['plan-upgrade-required', EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE],
|
||||
['free-tier', EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE],
|
||||
])('surfaces the server %s message as an EasCommandError', async (_label, errorCode) => {
|
||||
const serverMessage = `gate message for ${errorCode}`;
|
||||
const promise = withObservePlanGateHandlingAsync(() => {
|
||||
throw graphqlErrorWithCode(errorCode, serverMessage);
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(EasCommandError);
|
||||
await expect(promise).rejects.toThrow(serverMessage);
|
||||
});
|
||||
|
||||
it('passes through GraphQL errors with a different code unchanged', async () => {
|
||||
const original = graphqlErrorWithCode('SOME_OTHER_ERROR');
|
||||
await expect(
|
||||
withObservePlanGateHandlingAsync(() => {
|
||||
throw original;
|
||||
})
|
||||
).rejects.toBe(original);
|
||||
});
|
||||
|
||||
it('passes through non-GraphQL errors unchanged', async () => {
|
||||
const original = new Error('network down');
|
||||
await expect(
|
||||
withObservePlanGateHandlingAsync(() => {
|
||||
throw original;
|
||||
})
|
||||
).rejects.toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isObservePlanGateError, () => {
|
||||
it.each([
|
||||
EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
|
||||
EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
])('is true for %s', errorCode => {
|
||||
expect(isObservePlanGateError(graphqlErrorWithCode(errorCode))).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a different GraphQL error code', () => {
|
||||
expect(isObservePlanGateError(graphqlErrorWithCode('SOME_OTHER_ERROR'))).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a non-GraphQL error', () => {
|
||||
expect(isObservePlanGateError(new Error('network down'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
PageInfo,
|
||||
} from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
import { isObservePlanGateError } from './planGating';
|
||||
|
||||
export enum EventsOrderPreset {
|
||||
Slowest = 'SLOWEST',
|
||||
@@ -112,7 +113,12 @@ export async function fetchTotalEventCountAsync(
|
||||
const metric = v.metrics.find(m => m.metricName === metricName);
|
||||
return sum + (metric?.eventCount ?? 0);
|
||||
}, 0);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// A plan gate is an account-wide rejection, not a per-platform failure —
|
||||
// let it propagate so the command surfaces the upgrade prompt.
|
||||
if (isObservePlanGateError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UpdateIdsMap,
|
||||
makeMetricsKey,
|
||||
} from './formatMetrics';
|
||||
import { isObservePlanGateError } from './planGating';
|
||||
import { appPlatformToObservePlatform } from './platforms';
|
||||
|
||||
export function validateDateFlag(value: string, flagName: string): void {
|
||||
@@ -48,6 +49,11 @@ export async function fetchObserveMetricsAsync(
|
||||
});
|
||||
return { appPlatform, appVersions };
|
||||
} catch (error: any) {
|
||||
// A plan gate is an account-wide rejection, not a per-platform failure —
|
||||
// let it propagate so the command surfaces the upgrade prompt.
|
||||
if (isObservePlanGateError(error)) {
|
||||
throw error;
|
||||
}
|
||||
Log.warn(`Failed to fetch observe data on ${observePlatform}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
import Log from '../log';
|
||||
import { isObservePlanGateError } from './planGating';
|
||||
import { appPlatformToObservePlatform } from './platforms';
|
||||
|
||||
export interface NavigationRouteWithPlatform {
|
||||
@@ -64,6 +65,11 @@ export async function fetchObserveNavigationRoutesAsync(
|
||||
});
|
||||
return { appPlatform, ...result };
|
||||
} catch (error: any) {
|
||||
// A plan gate is an account-wide rejection, not a per-platform failure —
|
||||
// let it propagate so the command surfaces the upgrade prompt.
|
||||
if (isObservePlanGateError(error)) {
|
||||
throw error;
|
||||
}
|
||||
Log.warn(`Failed to fetch navigation routes on ${observePlatform}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -99,6 +99,26 @@ export interface FetchSessionEventsResult {
|
||||
hasMoreLogEvents: boolean;
|
||||
}
|
||||
|
||||
// A syntactically valid UUID that matches no real session, used only to trip
|
||||
// the server-side session-timeline plan gate without fetching real data.
|
||||
const SESSION_ACCESS_PROBE_ID = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
/**
|
||||
* Issues a throwaway session-scoped query so the server-side session-timeline
|
||||
* plan gate fires (or doesn't) up front. Blocked plans reject with the coded
|
||||
* plan-gate error; allowed plans get an empty result that is discarded. Used to
|
||||
* check access before the interactive session picker runs.
|
||||
*/
|
||||
export async function verifyObserveSessionAccessAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string
|
||||
): Promise<void> {
|
||||
await fetchObserveSessionEventsAsync(graphqlClient, appId, {
|
||||
sessionId: SESSION_ACCESS_PROBE_ID,
|
||||
limit: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchObserveSessionEventsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { GraphqlError } from '../graphql/client';
|
||||
|
||||
// Must match the server's Observe plan-gate error codes (`ExpoErrorCode`). Both
|
||||
// signal "this Observe feature is not available on the account's current plan";
|
||||
// they differ only in which plans are blocked, which is decided server-side.
|
||||
export const EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE = 'EAS_OBSERVE_PLAN_UPGRADE_REQUIRED';
|
||||
export const EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE =
|
||||
'EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER';
|
||||
|
||||
const OBSERVE_PLAN_GATE_ERROR_CODES: ReadonlySet<string> = new Set([
|
||||
EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
|
||||
EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
]);
|
||||
|
||||
function findObservePlanGateMessage(error: unknown): string | undefined {
|
||||
if (!(error instanceof GraphqlError)) {
|
||||
return undefined;
|
||||
}
|
||||
return error.graphQLErrors.find(e =>
|
||||
OBSERVE_PLAN_GATE_ERROR_CODES.has(e?.extensions?.errorCode as string)
|
||||
)?.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an error is a server Observe plan-gate rejection. Useful where a
|
||||
* plan-gate error must escape error handling that would otherwise swallow it
|
||||
* (e.g. per-platform fetch loops that treat failures as empty results).
|
||||
*/
|
||||
export function isObservePlanGateError(error: unknown): boolean {
|
||||
return findObservePlanGateMessage(error) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an Observe GraphQL operation and, when the server rejects it because the
|
||||
* feature is not included in the account's plan, rethrows the server's upgrade
|
||||
* message (which links to the account's billing page) as an `EasCommandError`.
|
||||
* Reusable across Observe commands.
|
||||
*/
|
||||
export async function withObservePlanGateHandlingAsync<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
const planGateMessage = findObservePlanGateMessage(error);
|
||||
if (planGateMessage !== undefined) {
|
||||
throw new EasCommandError(planGateMessage);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Role } from '../../graphql/generated';
|
||||
import { Actor } from '../../user/User';
|
||||
import {
|
||||
getAccountChoices,
|
||||
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp,
|
||||
} from '../accountSelection';
|
||||
|
||||
const userActor: Actor = {
|
||||
__typename: 'User',
|
||||
id: 'user-id',
|
||||
email: '[email protected]',
|
||||
username: 'jane',
|
||||
primaryAccount: {
|
||||
id: 'account-id-personal',
|
||||
name: 'jane',
|
||||
users: [{ role: Role.Owner, actor: { id: 'user-id' } }],
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'account-id-org',
|
||||
name: 'acme',
|
||||
ownerUserActor: null,
|
||||
users: [{ role: Role.ViewOnly, actor: { id: 'user-id' } }],
|
||||
},
|
||||
{
|
||||
id: 'account-id-personal',
|
||||
name: 'jane',
|
||||
ownerUserActor: { id: 'user-id' },
|
||||
users: [{ role: Role.Owner, actor: { id: 'user-id' } }],
|
||||
},
|
||||
{
|
||||
id: 'account-id-team',
|
||||
name: 'bob',
|
||||
ownerUserActor: { id: 'other-user-id' },
|
||||
users: [{ role: Role.Admin, actor: { id: 'user-id' } }],
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
featureGates: {},
|
||||
} as any;
|
||||
|
||||
const robotActor: Actor = {
|
||||
__typename: 'Robot',
|
||||
id: 'robot-id',
|
||||
accounts: [
|
||||
{
|
||||
id: 'account-id-1',
|
||||
name: 'acme',
|
||||
users: [{ role: Role.Admin, actor: { id: 'robot-id' } }],
|
||||
},
|
||||
{
|
||||
id: 'account-id-2',
|
||||
name: 'other-org',
|
||||
users: [{ role: Role.ViewOnly, actor: { id: 'robot-id' } }],
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
featureGates: {},
|
||||
} as any;
|
||||
|
||||
describe(getAccountNamesWhereUserHasSufficientPermissionsToCreateApp, () => {
|
||||
it('excludes accounts where the actor has the view-only role', () => {
|
||||
expect(getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(userActor)).toEqual(
|
||||
new Set(['jane', 'bob'])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getAccountChoices, () => {
|
||||
it('lists the personal account first and annotates account types for users', () => {
|
||||
const choices = getAccountChoices(
|
||||
userActor,
|
||||
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(userActor)
|
||||
);
|
||||
|
||||
expect(choices.map(choice => choice.title)).toEqual(['jane', 'bob', 'acme']);
|
||||
expect(choices[0].description).toEqual('(Personal)');
|
||||
expect(choices[1].description).toEqual('(Team)');
|
||||
expect(choices[2].description).toEqual('(Organization) (Viewer Role)');
|
||||
});
|
||||
|
||||
it('annotates viewer role on the personal account when the user cannot create apps on it', () => {
|
||||
const viewerOnlyActor = {
|
||||
...userActor,
|
||||
accounts: [
|
||||
{
|
||||
id: 'account-id-personal',
|
||||
name: 'jane',
|
||||
ownerUserActor: { id: 'user-id' },
|
||||
users: [{ role: Role.ViewOnly, actor: { id: 'user-id' } }],
|
||||
},
|
||||
],
|
||||
} as any as Actor;
|
||||
|
||||
const choices = getAccountChoices(
|
||||
viewerOnlyActor,
|
||||
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(viewerOnlyActor)
|
||||
);
|
||||
|
||||
expect(choices).toHaveLength(1);
|
||||
expect(choices[0].description).toEqual('(Personal) (Viewer Role)');
|
||||
});
|
||||
|
||||
it('lists accounts without personal/team/organization annotations for robots', () => {
|
||||
const choices = getAccountChoices(
|
||||
robotActor,
|
||||
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(robotActor)
|
||||
);
|
||||
|
||||
expect(choices.map(choice => choice.title)).toEqual(['acme', 'other-org']);
|
||||
expect(choices[0].description).toBeUndefined();
|
||||
expect(choices[1].description).toEqual('(Viewer Role)');
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -15,7 +15,7 @@ describe(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync, () => {
|
||||
jester
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
|
||||
`EAS project not configured. To configure it non-interactively, choose the account that should own the project and run:\n\n eas init --account <name> --non-interactive\n\nAccounts you have permissions to create projects in: jester\n\nAlternatively, set the "owner" field in your app config, or run "eas init" to configure it interactively.`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Role } from '../graphql/generated';
|
||||
import { Choice } from '../prompts';
|
||||
import { Actor } from '../user/User';
|
||||
|
||||
export function getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(
|
||||
actor: Actor
|
||||
): Set<string> {
|
||||
return new Set(
|
||||
actor.accounts
|
||||
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
|
||||
.map(it => it.name)
|
||||
);
|
||||
}
|
||||
|
||||
export function getAccountChoices(
|
||||
actor: Actor,
|
||||
namesWithSufficientPermissions: Set<string>
|
||||
): Choice[] {
|
||||
const allAccounts = actor.accounts;
|
||||
|
||||
const sortedAccounts =
|
||||
actor.__typename === 'Robot'
|
||||
? allAccounts
|
||||
: [...allAccounts].sort((a, _b) =>
|
||||
actor.__typename === 'User' ? (a.name === actor.username ? -1 : 1) : 0
|
||||
);
|
||||
|
||||
if (actor.__typename !== 'Robot') {
|
||||
const personalAccount = allAccounts?.find(account => account?.ownerUserActor?.id === actor.id);
|
||||
|
||||
const personalAccountChoice = personalAccount
|
||||
? {
|
||||
title: personalAccount.name,
|
||||
value: personalAccount,
|
||||
description: !namesWithSufficientPermissions.has(personalAccount.name)
|
||||
? '(Personal) (Viewer Role)'
|
||||
: '(Personal)',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const userAccounts = allAccounts
|
||||
?.filter(account => account.ownerUserActor && account.name !== actor.username)
|
||||
.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
description: !namesWithSufficientPermissions.has(account.name)
|
||||
? '(Team) (Viewer Role)'
|
||||
: '(Team)',
|
||||
}));
|
||||
|
||||
const organizationAccounts = allAccounts
|
||||
?.filter(account => account.name !== actor.username && !account.ownerUserActor)
|
||||
.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
description: !namesWithSufficientPermissions.has(account.name)
|
||||
? '(Organization) (Viewer Role)'
|
||||
: '(Organization)',
|
||||
}));
|
||||
|
||||
let choices: Choice[] = [];
|
||||
if (personalAccountChoice) {
|
||||
choices = [personalAccountChoice];
|
||||
}
|
||||
|
||||
return [...choices, ...userAccounts, ...organizationAccounts].sort((a, _b) =>
|
||||
actor.__typename === 'User' ? (a.value.name === actor.username ? -1 : 1) : 0
|
||||
);
|
||||
}
|
||||
|
||||
return sortedAccounts.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
description: !namesWithSufficientPermissions.has(account.name) ? '(Viewer Role)' : undefined,
|
||||
}));
|
||||
}
|
||||
+11
-7
@@ -8,7 +8,7 @@ import { AppQuery } from '../graphql/queries/AppQuery';
|
||||
import { link } from '../log';
|
||||
import { ora } from '../ora';
|
||||
import { confirmAsync } from '../prompts';
|
||||
import { Actor } from '../user/User';
|
||||
import { Actor, getCreatableAccountNames } from '../user/User';
|
||||
|
||||
/**
|
||||
* 1. Looks for an existing project on EAS servers. If found, ask the user whether this is the
|
||||
@@ -30,18 +30,22 @@ export async function fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsyn
|
||||
const { accountName, projectName } = projectInfo;
|
||||
const projectFullName = `@${accountName}/${projectName}`;
|
||||
|
||||
if (options.nonInteractive) {
|
||||
throw new Error(
|
||||
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
|
||||
);
|
||||
}
|
||||
|
||||
const allAccounts = actor.accounts;
|
||||
const accountNamesWhereUserHasSufficientPublishPermissions = new Set(
|
||||
allAccounts
|
||||
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
|
||||
.map(it => it.name)
|
||||
);
|
||||
|
||||
if (options.nonInteractive) {
|
||||
throw new Error(
|
||||
`EAS project not configured. To configure it non-interactively, choose the account that should own the project and run:\n\n eas init --account <name> --non-interactive\n\nAccounts you have permissions to create projects in: ${getCreatableAccountNames(
|
||||
actor
|
||||
).join(
|
||||
', '
|
||||
)}\n\nAlternatively, set the "owner" field in your app config, or run "eas init" to configure it interactively.`
|
||||
);
|
||||
}
|
||||
const account = allAccounts.find(a => a.name === accountName);
|
||||
if (!account) {
|
||||
throw new Error(
|
||||
|
||||
@@ -2,25 +2,35 @@ import { ConfigError, ExpoConfig, getProjectConfigDescription } from '@expo/conf
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import {
|
||||
getAccountChoices,
|
||||
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp,
|
||||
} from './accountSelection';
|
||||
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from './expoConfig';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from './fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { getProjectDashboardUrl } from '../build/utils/url';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { saveProjectIdToAppConfigAsync } from '../commandUtils/context/contextUtils/getProjectIdAsync';
|
||||
import { validSlugName, validateFullNameAndSlug } from '../commandUtils/projectNameValidation';
|
||||
import { Role } from '../graphql/generated';
|
||||
import { AppMutation } from '../graphql/mutations/AppMutation';
|
||||
import { AppQuery } from '../graphql/queries/AppQuery';
|
||||
import Log, { link } from '../log';
|
||||
import { ora } from '../ora';
|
||||
import { Choice, confirmAsync, promptAsync } from '../prompts';
|
||||
import { Actor } from '../user/User';
|
||||
import { confirmAsync, promptAsync } from '../prompts';
|
||||
import { Actor, getCreatableAccountNames } from '../user/User';
|
||||
|
||||
export type InitializeMethodOptions = {
|
||||
force: boolean;
|
||||
nonInteractive: boolean;
|
||||
};
|
||||
|
||||
export type ProjectInitStatus = 'created' | 'linked' | 'already-linked';
|
||||
|
||||
export type ProjectInitResult = {
|
||||
projectId: string;
|
||||
status: ProjectInitStatus;
|
||||
};
|
||||
|
||||
async function saveProjectIdAndLogSuccessAsync(
|
||||
projectDir: string,
|
||||
projectId: string
|
||||
@@ -82,7 +92,7 @@ export async function ensureOwnerSlugConsistencyAsync(
|
||||
projectId: string,
|
||||
projectDir: string,
|
||||
{ force, nonInteractive }: InitializeMethodOptions
|
||||
): Promise<void> {
|
||||
): Promise<{ owner: string; slug: string }> {
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir);
|
||||
const appForProjectId = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
const correctOwner = appForProjectId.ownerAccount.name;
|
||||
@@ -131,59 +141,60 @@ export async function ensureOwnerSlugConsistencyAsync(
|
||||
} else if (!exp.slug) {
|
||||
await modifyExpoConfigAsync(projectDir, { slug: correctSlug });
|
||||
}
|
||||
|
||||
return { owner: correctOwner, slug: correctSlug };
|
||||
}
|
||||
|
||||
async function setExplicitIDAsync(
|
||||
projectId: string,
|
||||
projectDir: string,
|
||||
{ force, nonInteractive }: InitializeMethodOptions
|
||||
): Promise<void> {
|
||||
): Promise<ProjectInitStatus> {
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir);
|
||||
const existingProjectId = exp.extra?.eas?.projectId;
|
||||
|
||||
if (projectId === existingProjectId) {
|
||||
Log.succeed(`Project already linked (ID: ${chalk.bold(existingProjectId)})`);
|
||||
return;
|
||||
return 'already-linked';
|
||||
}
|
||||
|
||||
if (!existingProjectId) {
|
||||
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
|
||||
return;
|
||||
return 'linked';
|
||||
}
|
||||
|
||||
if (projectId !== existingProjectId) {
|
||||
if (force) {
|
||||
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Project is already linked to a different ID: ${chalk.bold(
|
||||
existingProjectId
|
||||
)}. Use --force flag to overwrite.`
|
||||
);
|
||||
}
|
||||
|
||||
const confirm = await confirmAsync({
|
||||
message: `Project is already linked to a different ID: ${chalk.bold(
|
||||
existingProjectId
|
||||
)}. Do you wish to overwrite it?`,
|
||||
});
|
||||
if (!confirm) {
|
||||
throw new Error('Aborting');
|
||||
}
|
||||
|
||||
if (force) {
|
||||
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
|
||||
return 'linked';
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Project is already linked to a different ID: ${chalk.bold(
|
||||
existingProjectId
|
||||
)}. Use --force flag to overwrite.`
|
||||
);
|
||||
}
|
||||
|
||||
const confirm = await confirmAsync({
|
||||
message: `Project is already linked to a different ID: ${chalk.bold(
|
||||
existingProjectId
|
||||
)}. Do you wish to overwrite it?`,
|
||||
});
|
||||
if (!confirm) {
|
||||
throw new Error('Aborting');
|
||||
}
|
||||
|
||||
await saveProjectIdAndLogSuccessAsync(projectDir, projectId);
|
||||
return 'linked';
|
||||
}
|
||||
|
||||
export async function initializeWithExplicitIDAsync(
|
||||
projectId: string,
|
||||
projectDir: string,
|
||||
{ force, nonInteractive }: InitializeMethodOptions
|
||||
): Promise<void> {
|
||||
await setExplicitIDAsync(projectId, projectDir, {
|
||||
): Promise<ProjectInitStatus> {
|
||||
return await setExplicitIDAsync(projectId, projectDir, {
|
||||
force,
|
||||
nonInteractive,
|
||||
});
|
||||
@@ -193,42 +204,70 @@ export async function initializeWithoutExplicitIDAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
actor: Actor,
|
||||
projectDir: string,
|
||||
{ force, nonInteractive }: InitializeMethodOptions
|
||||
): Promise<string> {
|
||||
{
|
||||
force,
|
||||
nonInteractive,
|
||||
accountName: accountNameArgument,
|
||||
}: InitializeMethodOptions & { accountName?: string }
|
||||
): Promise<ProjectInitResult> {
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir);
|
||||
const existingProjectId = exp.extra?.eas?.projectId;
|
||||
|
||||
if (existingProjectId) {
|
||||
Log.succeed(
|
||||
`Project already linked (ID: ${chalk.bold(
|
||||
existingProjectId
|
||||
)}). To re-configure, remove the "extra.eas.projectId" field from your app config.`
|
||||
);
|
||||
return existingProjectId;
|
||||
const ownerAccountName = accountNameArgument
|
||||
? (await AppQuery.byIdAsync(graphqlClient, existingProjectId)).ownerAccount.name
|
||||
: undefined;
|
||||
if (!accountNameArgument || ownerAccountName === accountNameArgument) {
|
||||
Log.succeed(
|
||||
`Project already linked (ID: ${chalk.bold(
|
||||
existingProjectId
|
||||
)}). To re-configure, remove the "extra.eas.projectId" field from your app config.`
|
||||
);
|
||||
return { projectId: existingProjectId, status: 'already-linked' };
|
||||
}
|
||||
if (!force) {
|
||||
throw new Error(
|
||||
`This project is already linked to @${ownerAccountName} (ID: ${existingProjectId}). Pass --force to re-link it to a project owned by ${accountNameArgument}, or remove the "extra.eas.projectId" field from your app config.`
|
||||
);
|
||||
}
|
||||
// --force with a different --account: fall through to re-link under the new account
|
||||
}
|
||||
|
||||
const allAccounts = actor.accounts;
|
||||
const accountNamesWhereUserHasSufficientPermissionsToCreateApp = new Set(
|
||||
allAccounts
|
||||
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
|
||||
.map(it => it.name)
|
||||
);
|
||||
const accountNamesWhereUserHasSufficientPermissionsToCreateApp =
|
||||
getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(actor);
|
||||
|
||||
// if no owner field, ask the user which account they want to use to create/link the project
|
||||
let accountName = exp.owner;
|
||||
if (accountNameArgument) {
|
||||
// with --force, a conflicting "owner" field is rewritten after linking
|
||||
if (exp.owner && exp.owner !== accountNameArgument && !force) {
|
||||
throw new Error(
|
||||
`The account specified with --account (${accountNameArgument}) does not match the "owner" field in your app config (${exp.owner}). Provide a matching --account or update the "owner" field.`
|
||||
);
|
||||
}
|
||||
if (!accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(accountNameArgument)) {
|
||||
throw new Error(
|
||||
`You are not able to create projects in the "${accountNameArgument}" account. Accounts you have permissions to create projects in: ${getCreatableAccountNames(
|
||||
actor
|
||||
).join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// if no --account flag or owner field, ask the user which account they want to use to create/link the project
|
||||
let accountName = accountNameArgument ?? exp.owner;
|
||||
if (!accountName) {
|
||||
if (allAccounts.length === 1) {
|
||||
accountName = allAccounts[0].name;
|
||||
} else if (nonInteractive) {
|
||||
if (!force) {
|
||||
throw new Error(
|
||||
`There are multiple accounts that you have access to: ${allAccounts
|
||||
.map(a => a.name)
|
||||
.join(
|
||||
', '
|
||||
)}. Explicitly set the owner property in your app config or run this command with the --force flag to proceed with a default account: ${
|
||||
`You have access to multiple accounts. Choose the account that should own this project with the --account flag:\n\n eas init --account <name> --non-interactive\n\nAccounts you have permissions to create projects in: ${getCreatableAccountNames(
|
||||
actor
|
||||
).join(
|
||||
', '
|
||||
)}\n\nAlternatively, set the "owner" field in your app config. (Deprecated: --force without --account will proceed with the default account ${
|
||||
allAccounts[0].name
|
||||
}.`
|
||||
}.)`
|
||||
);
|
||||
}
|
||||
accountName = allAccounts[0].name;
|
||||
@@ -257,13 +296,18 @@ export async function initializeWithoutExplicitIDAsync(
|
||||
const projectName = validSlugName(exp.slug); // This filters out invalid characters
|
||||
const projectFullName = `@${accountName}/${projectName}`;
|
||||
validateFullNameAndSlug(projectFullName, projectName);
|
||||
|
||||
// An explicit --account fully specifies the intended project (@account/slug), so linking or
|
||||
// creating it requires no confirmation in non-interactive mode.
|
||||
const skipConfirmation = force || (nonInteractive && accountNameArgument !== undefined);
|
||||
|
||||
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
|
||||
graphqlClient,
|
||||
accountName,
|
||||
projectName
|
||||
);
|
||||
if (existingProjectIdOnServer) {
|
||||
if (!force) {
|
||||
if (!skipConfirmation) {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Existing project found: ${projectFullName} (ID: ${existingProjectIdOnServer}). Use --force flag to continue with this project.`
|
||||
@@ -281,7 +325,7 @@ export async function initializeWithoutExplicitIDAsync(
|
||||
}
|
||||
|
||||
await saveProjectIdAndLogSuccessAsync(projectDir, existingProjectIdOnServer);
|
||||
return existingProjectIdOnServer;
|
||||
return { projectId: existingProjectIdOnServer, status: 'linked' };
|
||||
}
|
||||
|
||||
if (!accountNamesWhereUserHasSufficientPermissionsToCreateApp.has(accountName)) {
|
||||
@@ -290,7 +334,7 @@ export async function initializeWithoutExplicitIDAsync(
|
||||
);
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
if (!skipConfirmation) {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Project does not exist: ${projectFullName}. Use --force flag to create this project.`
|
||||
@@ -323,65 +367,5 @@ export async function initializeWithoutExplicitIDAsync(
|
||||
}
|
||||
|
||||
await saveProjectIdAndLogSuccessAsync(projectDir, createdProjectId);
|
||||
return createdProjectId;
|
||||
}
|
||||
|
||||
function getAccountChoices(actor: Actor, namesWithSufficientPermissions: Set<string>): Choice[] {
|
||||
const allAccounts = actor.accounts;
|
||||
|
||||
const sortedAccounts =
|
||||
actor.__typename === 'Robot'
|
||||
? allAccounts
|
||||
: [...allAccounts].sort((a, _b) =>
|
||||
actor.__typename === 'User' ? (a.name === actor.username ? -1 : 1) : 0
|
||||
);
|
||||
|
||||
if (actor.__typename !== 'Robot') {
|
||||
const personalAccount = allAccounts?.find(account => account?.ownerUserActor?.id === actor.id);
|
||||
|
||||
const personalAccountChoice = personalAccount
|
||||
? {
|
||||
title: personalAccount.name,
|
||||
value: personalAccount,
|
||||
description: !namesWithSufficientPermissions.has(personalAccount.name)
|
||||
? '(Personal) (Viewer Role)'
|
||||
: '(Personal)',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const userAccounts = allAccounts
|
||||
?.filter(account => account.ownerUserActor && account.name !== actor.username)
|
||||
.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
description: !namesWithSufficientPermissions.has(account.name)
|
||||
? '(Team) (Viewer Role)'
|
||||
: '(Team)',
|
||||
}));
|
||||
|
||||
const organizationAccounts = allAccounts
|
||||
?.filter(account => account.name !== actor.username && !account.ownerUserActor)
|
||||
.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
description: !namesWithSufficientPermissions.has(account.name)
|
||||
? '(Organization) (Viewer Role)'
|
||||
: '(Organization)',
|
||||
}));
|
||||
|
||||
let choices: Choice[] = [];
|
||||
if (personalAccountChoice) {
|
||||
choices = [personalAccountChoice];
|
||||
}
|
||||
|
||||
return [...choices, ...userAccounts, ...organizationAccounts].sort((a, _b) =>
|
||||
actor.__typename === 'User' ? (a.value.name === actor.username ? -1 : 1) : 0
|
||||
);
|
||||
}
|
||||
|
||||
return sortedAccounts.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
description: !namesWithSufficientPermissions.has(account.name) ? '(Viewer Role)' : undefined,
|
||||
}));
|
||||
return { projectId: createdProjectId, status: 'created' };
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import fetch, { RequestError, Response } from '../../fetch';
|
||||
import {
|
||||
type DeviceRunSessionEvent,
|
||||
downloadDeviceRunSessionEventsAsync,
|
||||
formatDeviceRunSessionEvent,
|
||||
projectDeviceRunSessionEventsForDisplay,
|
||||
} from '../events';
|
||||
|
||||
jest.mock('../../fetch', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('../../fetch'),
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
describe(projectDeviceRunSessionEventsForDisplay, () => {
|
||||
it('collapses completed operations and carries duration onto the recorded interaction', () => {
|
||||
const events = [
|
||||
createEventValue({
|
||||
eventId: 'started',
|
||||
type: 'operation.started',
|
||||
operationId: 'operation-id',
|
||||
summary: 'Started press',
|
||||
}),
|
||||
createEventValue({
|
||||
eventId: 'interaction',
|
||||
ts: '2026-07-10T12:00:01.000Z',
|
||||
type: 'interaction.recorded',
|
||||
operationId: 'operation-id',
|
||||
summary: 'Tapped @e13',
|
||||
}),
|
||||
createEventValue({
|
||||
eventId: 'completed',
|
||||
ts: '2026-07-10T12:00:02.000Z',
|
||||
type: 'operation.completed',
|
||||
operationId: 'operation-id',
|
||||
outcome: 'success',
|
||||
durationMs: 123,
|
||||
summary: 'Finished press',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(projectDeviceRunSessionEventsForDisplay(events)).toEqual([
|
||||
{
|
||||
...events[1],
|
||||
durationMs: 123,
|
||||
outcome: 'success',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves completed failures and successful operations without interactions', () => {
|
||||
const successful = createEventValue({
|
||||
eventId: 'successful',
|
||||
type: 'operation.completed',
|
||||
operationId: 'successful-operation',
|
||||
outcome: 'success',
|
||||
summary: 'Found 2 devices',
|
||||
});
|
||||
const failed = createEventValue({
|
||||
eventId: 'failed',
|
||||
ts: '2026-07-10T12:00:01.000Z',
|
||||
type: 'operation.completed',
|
||||
operationId: 'failed-operation',
|
||||
outcome: 'failure',
|
||||
summary: 'Failed install: INVALID_ARGS',
|
||||
});
|
||||
|
||||
expect(projectDeviceRunSessionEventsForDisplay([successful, failed])).toEqual([
|
||||
successful,
|
||||
failed,
|
||||
]);
|
||||
});
|
||||
|
||||
it('can withhold incomplete operations while following a running session', () => {
|
||||
const started = createEventValue({
|
||||
eventId: 'started',
|
||||
type: 'operation.started',
|
||||
operationId: 'operation-id',
|
||||
summary: 'Started install',
|
||||
});
|
||||
const interaction = createEventValue({
|
||||
eventId: 'interaction',
|
||||
type: 'interaction.recorded',
|
||||
operationId: 'another-operation-id',
|
||||
summary: 'Captured screenshot screenshot.png',
|
||||
});
|
||||
const standalone = createEventValue({
|
||||
eventId: 'standalone',
|
||||
type: 'control.recorded',
|
||||
summary: 'Stopping session',
|
||||
});
|
||||
|
||||
expect(
|
||||
projectDeviceRunSessionEventsForDisplay([started, interaction, standalone], {
|
||||
includeIncompleteOperations: false,
|
||||
})
|
||||
).toEqual([standalone]);
|
||||
expect(projectDeviceRunSessionEventsForDisplay([started, interaction, standalone])).toEqual([
|
||||
started,
|
||||
interaction,
|
||||
standalone,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps matching operation IDs from different producers separate', () => {
|
||||
const agentStarted = createEventValue({
|
||||
eventId: 'agent-started',
|
||||
operationId: 'shared-operation-id',
|
||||
summary: 'Started devices',
|
||||
});
|
||||
const agentCompleted = createEventValue({
|
||||
eventId: 'agent-completed',
|
||||
type: 'operation.completed',
|
||||
operationId: 'shared-operation-id',
|
||||
outcome: 'success',
|
||||
summary: 'Found 2 devices',
|
||||
});
|
||||
const serveSimStarted = createEventValue({
|
||||
eventId: 'serve-sim-started',
|
||||
producer: 'serve-sim',
|
||||
operationId: 'shared-operation-id',
|
||||
summary: 'Started simulator activity',
|
||||
});
|
||||
|
||||
expect(
|
||||
projectDeviceRunSessionEventsForDisplay([agentStarted, agentCompleted, serveSimStarted]).map(
|
||||
({ eventId }) => eventId
|
||||
)
|
||||
).toEqual(['agent-completed', 'serve-sim-started']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatDeviceRunSessionEvent, () => {
|
||||
it('formats a compact log line with the producer and duration', () => {
|
||||
expect(
|
||||
formatDeviceRunSessionEvent(
|
||||
createEventValue({
|
||||
ts: '2026-07-10T12:00:01.000Z',
|
||||
summary: 'Found 2 devices',
|
||||
durationMs: 123,
|
||||
})
|
||||
)
|
||||
).toBe('2026-07-10T12:00:01.000Z [agent-device] Found 2 devices (123ms)');
|
||||
});
|
||||
|
||||
it('includes a screenshot filename without exposing its full path', () => {
|
||||
expect(
|
||||
formatDeviceRunSessionEvent(
|
||||
createEventValue({
|
||||
summary: 'Ran screenshot',
|
||||
data: {
|
||||
command: 'screenshot',
|
||||
path: '/tmp/private/screenshot.png',
|
||||
},
|
||||
})
|
||||
)
|
||||
).toBe('2026-07-10T12:00:00.000Z [agent-device] Ran screenshot screenshot.png');
|
||||
});
|
||||
|
||||
it('strips ANSI escape sequences and control characters from terminal output', () => {
|
||||
expect(
|
||||
formatDeviceRunSessionEvent(
|
||||
createEventValue({
|
||||
ts: '\u001B[31m2026-07-10T12:00:00.000Z\u001B[0m\n',
|
||||
producer: 'agent\u0000-device',
|
||||
summary: 'Found\r2\tdevices\u0007',
|
||||
})
|
||||
)
|
||||
).toBe('2026-07-10T12:00:00.000Z [agent-device] Found2devices');
|
||||
});
|
||||
});
|
||||
|
||||
describe(downloadDeviceRunSessionEventsAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
it('treats a missing event object as an empty log', async () => {
|
||||
const response = new Response('', { status: 404 });
|
||||
jest.mocked(fetch).mockRejectedValue(new RequestError('not found', response));
|
||||
|
||||
await expect(
|
||||
downloadDeviceRunSessionEventsAsync('https://example.test/events')
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function createEventValue(overrides: Partial<DeviceRunSessionEvent> = {}): DeviceRunSessionEvent {
|
||||
return {
|
||||
v: 1,
|
||||
eventId: 'event-id',
|
||||
ts: '2026-07-10T12:00:00.000Z',
|
||||
producer: 'agent-device',
|
||||
type: 'operation.started',
|
||||
summary: 'Started tap',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { stripVTControlCharacters } from 'util';
|
||||
|
||||
import { type DeviceRunSessionEvent, parseDeviceRunSessionEvents } from '@expo/eas-build-job';
|
||||
|
||||
import fetch, { RequestError } from '../fetch';
|
||||
import { formatMilliseconds } from '../utils/timer';
|
||||
|
||||
export type { DeviceRunSessionEvent };
|
||||
|
||||
export async function downloadDeviceRunSessionEventsAsync(
|
||||
eventLogUrl: string
|
||||
): Promise<DeviceRunSessionEvent[]> {
|
||||
try {
|
||||
const response = await fetch(eventLogUrl);
|
||||
return parseDeviceRunSessionEvents(await response.text());
|
||||
} catch (error) {
|
||||
if (error instanceof RequestError && error.response.status === 404) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function projectDeviceRunSessionEventsForDisplay(
|
||||
events: DeviceRunSessionEvent[],
|
||||
{ includeIncompleteOperations = true }: { includeIncompleteOperations?: boolean } = {}
|
||||
): DeviceRunSessionEvent[] {
|
||||
const operations = new Map<
|
||||
string,
|
||||
{
|
||||
completed?: DeviceRunSessionEvent;
|
||||
interactions: DeviceRunSessionEvent[];
|
||||
}
|
||||
>();
|
||||
|
||||
for (const event of events) {
|
||||
if (!event.operationId) {
|
||||
continue;
|
||||
}
|
||||
const operationKey = getOperationKey(event);
|
||||
const operation = operations.get(operationKey) ?? { interactions: [] };
|
||||
if (event.type === 'operation.completed') {
|
||||
operation.completed = event;
|
||||
} else if (event.type === 'interaction.recorded') {
|
||||
operation.interactions.push(event);
|
||||
}
|
||||
operations.set(operationKey, operation);
|
||||
}
|
||||
|
||||
return events.flatMap(event => {
|
||||
if (!event.operationId) {
|
||||
return [event];
|
||||
}
|
||||
|
||||
const operation = operations.get(getOperationKey(event));
|
||||
const completed = operation?.completed;
|
||||
const interactions = operation?.interactions ?? [];
|
||||
|
||||
if (
|
||||
!includeIncompleteOperations &&
|
||||
!completed &&
|
||||
(event.type === 'operation.started' || event.type === 'interaction.recorded')
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (event.type === 'operation.started' && (completed || interactions.length > 0)) {
|
||||
return [];
|
||||
}
|
||||
if (event.type === 'interaction.recorded' && completed?.outcome === 'failure') {
|
||||
return [];
|
||||
}
|
||||
if (event.type === 'operation.completed' && completed?.outcome !== 'failure') {
|
||||
return interactions.length > 0 ? [] : [event];
|
||||
}
|
||||
if (
|
||||
event.type === 'interaction.recorded' &&
|
||||
completed &&
|
||||
interactions.at(-1)?.eventId === event.eventId
|
||||
) {
|
||||
return [{ ...event, durationMs: completed.durationMs, outcome: completed.outcome }];
|
||||
}
|
||||
|
||||
return [event];
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDeviceRunSessionEvent(event: DeviceRunSessionEvent): string {
|
||||
const screenshotFilename = getScreenshotFilename(event);
|
||||
const summary =
|
||||
screenshotFilename && !event.summary.includes(screenshotFilename)
|
||||
? `${event.summary} ${screenshotFilename}`
|
||||
: event.summary;
|
||||
const duration =
|
||||
event.durationMs === undefined ? '' : ` (${formatEventDuration(event.durationMs)})`;
|
||||
return stripTerminalControlCharacters(`${event.ts} [${event.producer}] ${summary}${duration}`);
|
||||
}
|
||||
|
||||
function getOperationKey(event: DeviceRunSessionEvent): string {
|
||||
return JSON.stringify([event.producer, event.operationId]);
|
||||
}
|
||||
|
||||
function formatEventDuration(durationMs: number): string {
|
||||
return durationMs < 1000 ? `${Math.round(durationMs)}ms` : formatMilliseconds(durationMs);
|
||||
}
|
||||
|
||||
function getScreenshotFilename(event: DeviceRunSessionEvent): string | undefined {
|
||||
if (event.data?.command !== 'screenshot' || typeof event.data.path !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
return event.data.path.split(/[\\/]/).at(-1);
|
||||
}
|
||||
|
||||
function stripTerminalControlCharacters(value: string): string {
|
||||
return [...stripVTControlCharacters(value)]
|
||||
.filter(character => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
return codePoint >= 0x20 && !(codePoint >= 0x7f && codePoint <= 0x9f);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export function formatRemoteSessionInstructions(
|
||||
? [
|
||||
'🔑 Run the following to use agent-device with the simulator:',
|
||||
'',
|
||||
'eas simulator:exec agent-device <command>',
|
||||
'eas simulator:exec npx agent-device <command>',
|
||||
]
|
||||
: [
|
||||
'🔑 Run the following in your shell to attach to the agent-device daemon:',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { App } from '@expo/apple-utils';
|
||||
import { EasJsonUtils } from '@expo/eas-json';
|
||||
|
||||
import { resolveTestFlightAppAsync } from '../app';
|
||||
import { CredentialsContext } from '../../credentials/context';
|
||||
import { AppStoreConnectApiKeyQuery as AccountAppStoreConnectApiKeyQuery } from '../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import { getRequestContext } from '../../credentials/ios/appstore/authenticate';
|
||||
import { getAppStoreAuthAsync } from '../../metadata/auth';
|
||||
|
||||
jest.mock('@expo/apple-utils', () => ({
|
||||
App: { findAsync: jest.fn() },
|
||||
Token: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/eas-json', () => ({
|
||||
EasJsonAccessor: { fromProjectPath: jest.fn() },
|
||||
EasJsonUtils: { getSubmitProfileAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../credentials/context', () => ({
|
||||
CredentialsContext: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery');
|
||||
jest.mock('../../credentials/ios/appstore/authenticate', () => ({
|
||||
getRequestContext: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../metadata/auth');
|
||||
jest.mock('../../project/ios/bundleIdentifier', () => ({
|
||||
getBundleIdentifierAsync: jest.fn().mockResolvedValue('com.example.app'),
|
||||
}));
|
||||
jest.mock('../../project/projectUtils', () => ({
|
||||
getOwnerAccountForProjectIdAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'account-id', name: 'account-name' }),
|
||||
}));
|
||||
|
||||
describe(resolveTestFlightAppAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(EasJsonUtils.getSubmitProfileAsync).mockResolvedValue({} as any);
|
||||
jest
|
||||
.mocked(getAppStoreAuthAsync)
|
||||
.mockRejectedValue(new Error('Configured API key cannot access the app'));
|
||||
jest.mocked(AccountAppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('forces Apple ID authentication for the final interactive fallback', async () => {
|
||||
const userAuthCtx = { authState: { context: { token: 'user-token' } } };
|
||||
const requestContext = { session: 'user' };
|
||||
const app = { attributes: { bundleId: 'com.example.app' } };
|
||||
const ensureUserAuthenticatedAsync = jest.fn().mockResolvedValue(userAuthCtx);
|
||||
jest.mocked(CredentialsContext).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
appStore: { ensureUserAuthenticatedAsync },
|
||||
}) as any
|
||||
);
|
||||
jest.mocked(getRequestContext).mockReturnValue(requestContext as any);
|
||||
jest.mocked(App.findAsync).mockResolvedValue(app as any);
|
||||
|
||||
const result = await resolveTestFlightAppAsync({
|
||||
actor: {} as any,
|
||||
analytics: {} as any,
|
||||
exp: { slug: 'example' } as any,
|
||||
graphqlClient: {} as any,
|
||||
nonInteractive: false,
|
||||
projectDir: '/app',
|
||||
projectId: 'project-id',
|
||||
vcsClient: {} as any,
|
||||
});
|
||||
|
||||
expect(getAppStoreAuthAsync).toHaveBeenCalledTimes(1);
|
||||
expect(ensureUserAuthenticatedAsync).toHaveBeenCalledTimes(1);
|
||||
expect(getRequestContext).toHaveBeenCalledWith(userAuthCtx);
|
||||
expect(App.findAsync).toHaveBeenCalledWith(requestContext, {
|
||||
bundleId: 'com.example.app',
|
||||
});
|
||||
expect(result).toBe(app);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { App, BetaCrashLog, BetaFeedbackCrashSubmission } from '@expo/apple-utils';
|
||||
|
||||
import { fetchTestFlightCrashAsync } from '../fetch';
|
||||
import { formatTestFlightCrashDetails } from '../format';
|
||||
|
||||
jest.mock('@expo/apple-utils', () => ({
|
||||
BetaCrashLog: { getCrashLogAsync: jest.fn() },
|
||||
BetaFeedbackCrashSubmission: { infoAsync: jest.fn() },
|
||||
}));
|
||||
|
||||
const app = { context: {} } as unknown as App;
|
||||
|
||||
const submission = {
|
||||
id: 'crash-id',
|
||||
attributes: {
|
||||
createdDate: '2026-07-20T10:00:00.000Z',
|
||||
comment: null,
|
||||
deviceModel: 'iPhone15,2',
|
||||
osVersion: '18.2',
|
||||
},
|
||||
};
|
||||
|
||||
describe(fetchTestFlightCrashAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(BetaFeedbackCrashSubmission.infoAsync).mockResolvedValue(submission as any);
|
||||
jest.mocked(BetaCrashLog.getCrashLogAsync).mockReset();
|
||||
});
|
||||
|
||||
it('returns the crash log when App Store Connect has one', async () => {
|
||||
jest
|
||||
.mocked(BetaCrashLog.getCrashLogAsync)
|
||||
.mockResolvedValue({ attributes: { logText: 'Thread 0 Crashed:' } } as any);
|
||||
|
||||
const { logText } = await fetchTestFlightCrashAsync(app, 'crash-id');
|
||||
|
||||
expect(logText).toBe('Thread 0 Crashed:');
|
||||
});
|
||||
|
||||
it('reports no log when App Store Connect answers without one', async () => {
|
||||
jest
|
||||
.mocked(BetaCrashLog.getCrashLogAsync)
|
||||
.mockResolvedValue({ attributes: { logText: undefined } } as any);
|
||||
|
||||
const { logText } = await fetchTestFlightCrashAsync(app, 'crash-id');
|
||||
|
||||
expect(logText).toBeNull();
|
||||
});
|
||||
|
||||
it('propagates a failed crash log request', async () => {
|
||||
const error = new Error('Request failed with status code 403');
|
||||
jest.mocked(BetaCrashLog.getCrashLogAsync).mockRejectedValue(error);
|
||||
|
||||
await expect(fetchTestFlightCrashAsync(app, 'crash-id')).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
it('preserves a non-Error rejection', async () => {
|
||||
jest.mocked(BetaCrashLog.getCrashLogAsync).mockRejectedValue('socket hang up');
|
||||
|
||||
await expect(fetchTestFlightCrashAsync(app, 'crash-id')).rejects.toBe('socket hang up');
|
||||
});
|
||||
});
|
||||
|
||||
describe(`${formatTestFlightCrashDetails.name} log states`, () => {
|
||||
const crash = {
|
||||
id: 'crash-id',
|
||||
createdDate: '2026-07-20T10:00:00.000Z',
|
||||
comment: null,
|
||||
deviceModel: 'iPhone15,2',
|
||||
osVersion: '18.2',
|
||||
locale: null,
|
||||
timeZone: null,
|
||||
architecture: null,
|
||||
connectionType: null,
|
||||
deviceFamily: null,
|
||||
appUptimeInMilliseconds: null,
|
||||
batteryPercentage: null,
|
||||
diskBytesAvailable: null,
|
||||
diskBytesTotal: null,
|
||||
buildVersion: null,
|
||||
testerName: null,
|
||||
testerEmail: null,
|
||||
};
|
||||
|
||||
it('prints the log when present', () => {
|
||||
expect(formatTestFlightCrashDetails(crash, 'Thread 0 Crashed:')).toContain('Thread 0 Crashed:');
|
||||
});
|
||||
|
||||
it('says no log is available when there is none', () => {
|
||||
expect(formatTestFlightCrashDetails(crash, null)).toContain(
|
||||
'No crash log is available for this submission yet.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { App } from '@expo/apple-utils';
|
||||
|
||||
import { getTestFlightCrashesQuery, getTestFlightFeedbackQuery } from '../fetch';
|
||||
|
||||
function createSubmission(overrides: Record<string, any> = {}): any {
|
||||
return {
|
||||
id: 'submission-id',
|
||||
attributes: {
|
||||
createdDate: '2026-07-20T10:00:00.000Z',
|
||||
comment: 'The login button does nothing',
|
||||
email: null,
|
||||
deviceModel: 'iPhone15,2',
|
||||
osVersion: '18.2',
|
||||
locale: 'en-GB',
|
||||
timeZone: 'Europe/London',
|
||||
architecture: 'arm64e',
|
||||
connectionType: 'WIFI',
|
||||
deviceFamily: 'IPHONE',
|
||||
appUptimeInMilliseconds: 12_000,
|
||||
batteryPercentage: 42,
|
||||
diskBytesAvailable: 1024 ** 3,
|
||||
diskBytesTotal: 4 * 1024 ** 3,
|
||||
build: { id: 'build-id', attributes: { version: '42' } },
|
||||
tester: {
|
||||
id: 'tester-id',
|
||||
attributes: { firstName: 'Jane', lastName: 'Doe', email: '[email protected]' },
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(submissions: any[]): App {
|
||||
return {
|
||||
getBetaFeedbackScreenshotSubmissionsAsync: jest.fn().mockResolvedValue(submissions),
|
||||
getBetaFeedbackCrashSubmissionsAsync: jest.fn().mockResolvedValue(submissions),
|
||||
} as unknown as App;
|
||||
}
|
||||
|
||||
describe(getTestFlightFeedbackQuery, () => {
|
||||
it('normalizes submissions, including the build and tester relationships', async () => {
|
||||
const query = getTestFlightFeedbackQuery(createApp([createSubmission({ screenshots: [] })]));
|
||||
|
||||
const items = await query.queryAsync(20, 0);
|
||||
|
||||
expect(await query.getTotalAsync()).toBe(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
id: 'submission-id',
|
||||
comment: 'The login button does nothing',
|
||||
deviceModel: 'iPhone15,2',
|
||||
osVersion: '18.2',
|
||||
buildVersion: '42',
|
||||
testerName: 'Jane Doe',
|
||||
testerEmail: '[email protected]',
|
||||
screenshots: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps screenshots', async () => {
|
||||
const query = getTestFlightFeedbackQuery(
|
||||
createApp([
|
||||
createSubmission({
|
||||
screenshots: [
|
||||
{
|
||||
url: 'https://example.com/screenshot.png',
|
||||
width: 1170,
|
||||
height: 2532,
|
||||
expirationDate: '2026-07-27T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const items = await query.queryAsync(20, 0);
|
||||
|
||||
expect(items[0].screenshots).toEqual([
|
||||
{
|
||||
url: 'https://example.com/screenshot.png',
|
||||
width: 1170,
|
||||
height: 2532,
|
||||
expirationDate: '2026-07-27T10:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the submission email when the tester relationship has none', async () => {
|
||||
const query = getTestFlightFeedbackQuery(
|
||||
createApp([
|
||||
createSubmission({
|
||||
screenshots: null,
|
||||
email: '[email protected]',
|
||||
tester: { id: 'tester-id', attributes: { firstName: 'Anonymous', lastName: null } },
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const items = await query.queryAsync(20, 0);
|
||||
|
||||
expect(items[0].testerName).toBe('Anonymous');
|
||||
expect(items[0].testerEmail).toBe('[email protected]');
|
||||
expect(items[0].screenshots).toEqual([]);
|
||||
});
|
||||
|
||||
it('serves pages by limit and offset from a single underlying fetch', async () => {
|
||||
const app = createApp([
|
||||
createSubmission({ screenshots: [], comment: 'first' }),
|
||||
createSubmission({ screenshots: [], comment: 'second' }),
|
||||
createSubmission({ screenshots: [], comment: 'third' }),
|
||||
]);
|
||||
const query = getTestFlightFeedbackQuery(app);
|
||||
|
||||
expect((await query.queryAsync(2, 0)).map(item => item.comment)).toEqual(['first', 'second']);
|
||||
expect((await query.queryAsync(2, 2)).map(item => item.comment)).toEqual(['third']);
|
||||
expect(await query.queryAsync(2, 5)).toEqual([]);
|
||||
expect(await query.getTotalAsync()).toBe(3);
|
||||
|
||||
// Paging must not re-hit App Store Connect for every page.
|
||||
expect(app.getBetaFeedbackScreenshotSubmissionsAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('control character sanitization', () => {
|
||||
it('strips terminal escape sequences from tester-supplied text', async () => {
|
||||
const query = getTestFlightFeedbackQuery(
|
||||
createApp([
|
||||
createSubmission({
|
||||
screenshots: [],
|
||||
// A tester could submit a comment that clears the screen and spoofs a prompt.
|
||||
comment: 'looks fine\u001b[2J\u001b[HFATAL: run `rm -rf /` to continue',
|
||||
tester: {
|
||||
id: 'tester-id',
|
||||
attributes: { firstName: 'Jane\u001b[31m', lastName: 'Doe', email: '[email protected]' },
|
||||
},
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const items = await query.queryAsync(20, 0);
|
||||
|
||||
expect(items[0].comment).toBe('looks fine[2J[HFATAL: run `rm -rf /` to continue');
|
||||
expect(items[0].comment).not.toContain('\u001b');
|
||||
expect(items[0].testerName).toBe('Jane[31m Doe');
|
||||
expect(items[0].testerName).not.toContain('\u001b');
|
||||
});
|
||||
|
||||
it('keeps newlines and tabs, and normalizes CRLF', async () => {
|
||||
const query = getTestFlightFeedbackQuery(
|
||||
createApp([
|
||||
createSubmission({
|
||||
screenshots: [],
|
||||
comment: 'line one\r\nline two\tindented\rline three',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const items = await query.queryAsync(20, 0);
|
||||
|
||||
expect(items[0].comment).toBe('line one\nline two\tindented\nline three');
|
||||
});
|
||||
});
|
||||
|
||||
describe(getTestFlightCrashesQuery, () => {
|
||||
it('normalizes crash submissions', async () => {
|
||||
const query = getTestFlightCrashesQuery(createApp([createSubmission({ comment: null })]));
|
||||
|
||||
const items = await query.queryAsync(20, 0);
|
||||
|
||||
expect(await query.getTotalAsync()).toBe(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
id: 'submission-id',
|
||||
comment: null,
|
||||
buildVersion: '42',
|
||||
testerName: 'Jane Doe',
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates submissions without build or tester relationships', async () => {
|
||||
const query = getTestFlightCrashesQuery(
|
||||
createApp([createSubmission({ build: undefined, tester: undefined, email: null })])
|
||||
);
|
||||
|
||||
const items = await query.queryAsync(20, 0);
|
||||
|
||||
expect(items[0].buildVersion).toBeNull();
|
||||
expect(items[0].testerName).toBeNull();
|
||||
expect(items[0].testerEmail).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { TestFlightCrash, TestFlightFeedback } from '../fetch';
|
||||
import {
|
||||
formatTestFlightCrash,
|
||||
formatTestFlightCrashDetails,
|
||||
formatTestFlightFeedback,
|
||||
} from '../format';
|
||||
|
||||
const submission: TestFlightCrash = {
|
||||
id: 'f7a1c2d3-1111-2222-3333-444455556666',
|
||||
createdDate: '2026-07-24T10:00:00.000Z',
|
||||
comment: 'Tapping Continue does nothing.\nHappens every time on cellular.',
|
||||
deviceModel: 'iPhone15,2',
|
||||
osVersion: '18.2',
|
||||
locale: 'en-GB',
|
||||
timeZone: 'Europe/London',
|
||||
architecture: 'arm64e',
|
||||
connectionType: 'MOBILE_DATA',
|
||||
deviceFamily: 'IPHONE',
|
||||
appUptimeInMilliseconds: 42_300,
|
||||
batteryPercentage: 61,
|
||||
diskBytesAvailable: 12 * 1024 ** 3,
|
||||
diskBytesTotal: 128 * 1024 ** 3,
|
||||
buildVersion: '128',
|
||||
testerName: 'Jane Doe',
|
||||
testerEmail: '[email protected]',
|
||||
};
|
||||
|
||||
const feedback: TestFlightFeedback = {
|
||||
...submission,
|
||||
screenshots: [
|
||||
{
|
||||
url: 'https://example.com/screenshot.png',
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
expirationDate: '2026-07-31T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe(formatTestFlightFeedback, () => {
|
||||
it('includes the tester, device, build, comment, and screenshots', () => {
|
||||
const output = formatTestFlightFeedback(feedback, 1);
|
||||
|
||||
expect(output).toContain('1. Feedback f7a1c2d3-1111-2222-3333-444455556666');
|
||||
expect(output).toContain('Jane Doe <[email protected]>');
|
||||
expect(output).toContain('iPhone15,2, iOS 18.2');
|
||||
expect(output).toContain('Build');
|
||||
expect(output).toContain('128');
|
||||
expect(output).toContain('battery 61%, disk 12.0 GB free of 128.0 GB, mobile data');
|
||||
expect(output).toContain('https://example.com/screenshot.png (1179x2556)');
|
||||
});
|
||||
|
||||
it('indents multi-line comments under their label', () => {
|
||||
const output = formatTestFlightFeedback(feedback, 1);
|
||||
|
||||
expect(output).toContain('Tapping Continue does nothing.\n Happens every time');
|
||||
});
|
||||
|
||||
it('marks feedback without a comment', () => {
|
||||
const output = formatTestFlightFeedback({ ...feedback, comment: null }, 1);
|
||||
|
||||
expect(output).toContain('(no comment)');
|
||||
});
|
||||
|
||||
it('omits the position when rendering a single submission', () => {
|
||||
const output = formatTestFlightFeedback(feedback);
|
||||
|
||||
expect(output).toContain('Feedback f7a1c2d3-1111-2222-3333-444455556666');
|
||||
expect(output).not.toContain('1. Feedback');
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatTestFlightCrash, () => {
|
||||
it('numbers the crash by its absolute position and omits absent optional fields', () => {
|
||||
const output = formatTestFlightCrash({ ...submission, comment: null, locale: null }, 5);
|
||||
|
||||
expect(output).toContain('5. Crash f7a1c2d3-1111-2222-3333-444455556666');
|
||||
expect(output).not.toContain('Comment');
|
||||
expect(output).not.toContain('Locale');
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatTestFlightCrashDetails, () => {
|
||||
it('appends the crash log', () => {
|
||||
const output = formatTestFlightCrashDetails(submission, 'Thread 0 Crashed:\n0 MyApp main + 32');
|
||||
|
||||
expect(output).toContain('Arch');
|
||||
expect(output).toContain('arm64e');
|
||||
expect(output).toContain('Uptime');
|
||||
expect(output).toContain('42.3s');
|
||||
expect(output).toContain('Crash log');
|
||||
expect(output).toContain('Thread 0 Crashed:\n0 MyApp main + 32');
|
||||
});
|
||||
|
||||
it('explains when no crash log is available', () => {
|
||||
const output = formatTestFlightCrashDetails(submission, null);
|
||||
|
||||
expect(output).toContain('No crash log is available for this submission yet.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { App } from '@expo/apple-utils';
|
||||
|
||||
import Log from '../../log';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { listAndRenderTestFlightCrashesAsync } from '../queries';
|
||||
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../prompts', () => ({
|
||||
selectAsync: jest.fn(),
|
||||
confirmAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
function createApp(count: number): App {
|
||||
const submissions = Array.from({ length: count }).map((_, index) => ({
|
||||
id: `crash-${index + 1}`,
|
||||
attributes: {
|
||||
createdDate: '2026-07-20T10:00:00.000Z',
|
||||
comment: null,
|
||||
deviceModel: 'iPhone15,2',
|
||||
osVersion: '18.2',
|
||||
},
|
||||
}));
|
||||
return {
|
||||
attributes: { bundleId: 'com.example.app' },
|
||||
getBetaFeedbackCrashSubmissionsAsync: jest.fn().mockResolvedValue(submissions),
|
||||
} as unknown as App;
|
||||
}
|
||||
|
||||
function loggedOutput(): string {
|
||||
return jest
|
||||
.mocked(Log.log)
|
||||
.mock.calls.map(call => String(call[0]))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
describe(listAndRenderTestFlightCrashesAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(Log.log).mockClear();
|
||||
jest.mocked(confirmAsync).mockReset();
|
||||
});
|
||||
|
||||
it('numbers items by their absolute position across interactive pages', async () => {
|
||||
// Accept the first "Load more?" prompt, decline the second.
|
||||
jest.mocked(confirmAsync).mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
||||
|
||||
await listAndRenderTestFlightCrashesAsync(createApp(6), {
|
||||
limit: 2,
|
||||
offset: 0,
|
||||
json: false,
|
||||
nonInteractive: false,
|
||||
});
|
||||
|
||||
const output = loggedOutput();
|
||||
expect(output).toContain('1. Crash crash-1');
|
||||
expect(output).toContain('2. Crash crash-2');
|
||||
expect(output).toContain('3. Crash crash-3');
|
||||
expect(output).toContain('4. Crash crash-4');
|
||||
expect(output).not.toContain('5. Crash');
|
||||
expect(jest.mocked(confirmAsync)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops prompting once the last page is rendered', async () => {
|
||||
await listAndRenderTestFlightCrashesAsync(createApp(2), {
|
||||
limit: 2,
|
||||
offset: 0,
|
||||
json: false,
|
||||
nonInteractive: false,
|
||||
});
|
||||
|
||||
expect(loggedOutput()).toContain('2. Crash crash-2');
|
||||
expect(jest.mocked(confirmAsync)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('honors offset when numbering a single non-interactive page', async () => {
|
||||
await listAndRenderTestFlightCrashesAsync(createApp(6), {
|
||||
limit: 2,
|
||||
offset: 4,
|
||||
json: false,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
const output = loggedOutput();
|
||||
expect(output).toContain('5. Crash crash-5');
|
||||
expect(output).toContain('6. Crash crash-6');
|
||||
expect(output).not.toContain('1. Crash');
|
||||
expect(jest.mocked(confirmAsync)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hints at remaining items when non-interactive', async () => {
|
||||
await listAndRenderTestFlightCrashesAsync(createApp(6), {
|
||||
limit: 2,
|
||||
offset: 0,
|
||||
json: false,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(loggedOutput()).toContain(
|
||||
'Showing 2 of 6 crashes. Use --offset and --limit to see more.'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports an empty collection', async () => {
|
||||
await listAndRenderTestFlightCrashesAsync(createApp(0), {
|
||||
limit: 2,
|
||||
offset: 0,
|
||||
json: false,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(loggedOutput()).toContain('No TestFlight crashes have been reported for');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { resolveBetaFeedbackTarget } from '../target';
|
||||
|
||||
describe(resolveBetaFeedbackTarget, () => {
|
||||
it('reads the id and type out of a crash submission URL', () => {
|
||||
expect(
|
||||
resolveBetaFeedbackTarget({
|
||||
idOrUrl:
|
||||
'https://api.appstoreconnect.apple.com/v1/betaFeedbackCrashSubmissions/AAo2eIIfGzcb1BzuUv3xrh4',
|
||||
defaultType: 'screenshot',
|
||||
})
|
||||
).toEqual({ id: 'AAo2eIIfGzcb1BzuUv3xrh4', type: 'crash' });
|
||||
});
|
||||
|
||||
it('reads the id and type out of a screenshot submission URL', () => {
|
||||
expect(
|
||||
resolveBetaFeedbackTarget({
|
||||
idOrUrl:
|
||||
'https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/AD8JvKbr0BK0Cj9OnM6WO6I',
|
||||
defaultType: 'crash',
|
||||
})
|
||||
).toEqual({ id: 'AD8JvKbr0BK0Cj9OnM6WO6I', type: 'screenshot' });
|
||||
});
|
||||
|
||||
it('falls back to the command default for a bare id', () => {
|
||||
expect(resolveBetaFeedbackTarget({ idOrUrl: 'some-id', defaultType: 'crash' })).toEqual({
|
||||
id: 'some-id',
|
||||
type: 'crash',
|
||||
});
|
||||
});
|
||||
|
||||
it('lets --type override the default for a bare id', () => {
|
||||
expect(
|
||||
resolveBetaFeedbackTarget({ idOrUrl: 'some-id', type: 'screenshot', defaultType: 'crash' })
|
||||
).toEqual({ id: 'some-id', type: 'screenshot' });
|
||||
});
|
||||
|
||||
it('rejects a --type that contradicts the URL', () => {
|
||||
expect(() =>
|
||||
resolveBetaFeedbackTarget({
|
||||
idOrUrl: 'https://api.appstoreconnect.apple.com/v1/betaFeedbackCrashSubmissions/abc',
|
||||
type: 'screenshot',
|
||||
defaultType: 'screenshot',
|
||||
})
|
||||
).toThrow('--type screenshot does not match the URL, which points at crash feedback');
|
||||
});
|
||||
|
||||
it('rejects a URL that is not a beta feedback submission', () => {
|
||||
expect(() =>
|
||||
resolveBetaFeedbackTarget({
|
||||
idOrUrl: 'https://appstoreconnect.apple.com/apps/6794186602/testflight/ios',
|
||||
defaultType: 'crash',
|
||||
})
|
||||
).toThrow('Could not tell whether');
|
||||
});
|
||||
|
||||
it('ignores query strings when reading the id', () => {
|
||||
expect(
|
||||
resolveBetaFeedbackTarget({
|
||||
idOrUrl:
|
||||
'https://api.appstoreconnect.apple.com/v1/betaFeedbackCrashSubmissions/abc?include=build',
|
||||
defaultType: 'screenshot',
|
||||
})
|
||||
).toEqual({ id: 'abc', type: 'crash' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { App, RequestContext, Token } from '@expo/apple-utils';
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { EasJsonAccessor, EasJsonUtils, SubmitProfile } from '@expo/eas-json';
|
||||
import { MissingEasJsonError } from '@expo/eas-json/build/errors';
|
||||
import assert from 'assert';
|
||||
|
||||
import { Analytics } from '../analytics/AnalyticsManager';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { CredentialsContext } from '../credentials/context';
|
||||
import { AppStoreConnectApiKeyQuery as AccountAppStoreConnectApiKeyQuery } from '../credentials/ios/api/graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import { getRequestContext } from '../credentials/ios/appstore/authenticate';
|
||||
import { AppStoreConnectApiKeyQuery } from '../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import Log from '../log';
|
||||
import { getAppStoreAuthAsync } from '../metadata/auth';
|
||||
import { getBundleIdentifierAsync } from '../project/ios/bundleIdentifier';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import { Actor } from '../user/User';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
/** How long the ASC JWTs we mint stay valid, matching the credentials service default. */
|
||||
const ASC_TOKEN_DURATION_SECONDS = 1200;
|
||||
|
||||
/**
|
||||
* TestFlight data lives in App Store Connect, so we resolve the bundle identifier and the ASC API
|
||||
* key from the submit profile the same way `eas metadata` does. When the project has no eas.json
|
||||
* we fall back to an empty profile, which resolves the bundle identifier from the app config and
|
||||
* the API key from env vars or the EAS credentials service.
|
||||
*/
|
||||
async function resolveSubmitProfileAsync({
|
||||
projectDir,
|
||||
profileName,
|
||||
}: {
|
||||
projectDir: string;
|
||||
profileName?: string;
|
||||
}): Promise<SubmitProfile<Platform.IOS>> {
|
||||
try {
|
||||
return await EasJsonUtils.getSubmitProfileAsync(
|
||||
EasJsonAccessor.fromProjectPath(projectDir),
|
||||
Platform.IOS,
|
||||
profileName
|
||||
);
|
||||
} catch (error: any) {
|
||||
if (error instanceof MissingEasJsonError && !profileName) {
|
||||
return {} as SubmitProfile<Platform.IOS>;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for the app in every ASC API key registered on the account that owns the project. This is
|
||||
* the resolution the Expo MCP server uses for its TestFlight tools, and it covers projects that
|
||||
* have no ASC API key assigned to their iOS submission credentials.
|
||||
*/
|
||||
async function findAppWithAccountAscApiKeysAsync({
|
||||
bundleId,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
}: {
|
||||
bundleId: string;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
}): Promise<App | null> {
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
const keys = await AccountAppStoreConnectApiKeyQuery.getAllForAccountAsync(
|
||||
graphqlClient,
|
||||
account.name
|
||||
);
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const { keyP8, keyIdentifier, issuerIdentifier } =
|
||||
await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, key.id);
|
||||
const context: RequestContext = {
|
||||
token: new Token({
|
||||
key: keyP8,
|
||||
keyId: keyIdentifier,
|
||||
issuerId: issuerIdentifier,
|
||||
duration: ASC_TOKEN_DURATION_SECONDS,
|
||||
}),
|
||||
};
|
||||
const app = await App.findAsync(context, { bundleId });
|
||||
if (app) {
|
||||
Log.log(
|
||||
`Using App Store Connect API Key ${key.name ?? key.keyIdentifier} from your Expo account.`
|
||||
);
|
||||
return app;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Never read `.message` off an unknown rejection: a non-Error value (a string, or
|
||||
// null from a wrapped fetch) would make this handler throw a TypeError, hiding the
|
||||
// real failure and aborting the loop instead of falling through to the next key.
|
||||
Log.debug(
|
||||
`ASC API Key ${key.keyIdentifier} cannot access ${bundleId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve the App Store Connect app that TestFlight feedback and crashes should be read from. */
|
||||
export async function resolveTestFlightAppAsync({
|
||||
actor,
|
||||
analytics,
|
||||
exp,
|
||||
graphqlClient,
|
||||
nonInteractive,
|
||||
profileName,
|
||||
projectDir,
|
||||
projectId,
|
||||
vcsClient,
|
||||
}: {
|
||||
actor: Actor;
|
||||
analytics: Analytics;
|
||||
exp: ExpoConfig;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
nonInteractive: boolean;
|
||||
profileName?: string;
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
vcsClient: Client;
|
||||
}): Promise<App> {
|
||||
const profile = await resolveSubmitProfileAsync({ projectDir, profileName });
|
||||
|
||||
const createCredentialsContext = (contextNonInteractive: boolean): CredentialsContext =>
|
||||
new CredentialsContext({
|
||||
projectInfo: { exp, projectId },
|
||||
projectDir,
|
||||
user: actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
nonInteractive: contextNonInteractive,
|
||||
vcsClient,
|
||||
});
|
||||
|
||||
// Prefer the key configured for this project: ASC env vars, the submit profile, or the key
|
||||
// assigned to its iOS submission credentials. Forced non-interactive so that a project without
|
||||
// any of those falls through to the account-wide lookup instead of prompting for an Apple login.
|
||||
try {
|
||||
const { app } = await getAppStoreAuthAsync({
|
||||
exp,
|
||||
credentialsCtx: createCredentialsContext(true),
|
||||
graphqlClient,
|
||||
nonInteractive: true,
|
||||
profile,
|
||||
projectDir,
|
||||
projectId,
|
||||
});
|
||||
return app;
|
||||
} catch (error: unknown) {
|
||||
Log.debug(
|
||||
`Could not resolve an App Store Connect API Key for this project: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
const bundleId =
|
||||
('bundleIdentifier' in profile ? profile.bundleIdentifier : undefined) ??
|
||||
(await getBundleIdentifierAsync(projectDir, exp, vcsClient));
|
||||
|
||||
const appFromAccountKeys = await findAppWithAccountAscApiKeysAsync({
|
||||
bundleId,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
if (appFromAccountKeys) {
|
||||
return appFromAccountKeys;
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`Could not find "${bundleId}" in App Store Connect with any App Store Connect API Key available to this project.\n` +
|
||||
'Provide a key with access to the app via:\n' +
|
||||
' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID\n' +
|
||||
' - eas.json submit profile: ascApiKeyPath, ascApiKeyId, ascApiKeyIssuerId\n' +
|
||||
' - EAS credentials service: run `eas credentials` to set up an API key'
|
||||
);
|
||||
}
|
||||
|
||||
// Last resort: an interactive Apple ID login.
|
||||
const credentialsCtx = createCredentialsContext(false);
|
||||
const authCtx = await credentialsCtx.appStore.ensureUserAuthenticatedAsync();
|
||||
const app = await App.findAsync(getRequestContext(authCtx), { bundleId });
|
||||
assert(app, `Failed to load app "${bundleId}" from App Store Connect`);
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
App,
|
||||
BetaCrashLog,
|
||||
BetaFeedbackCrashSubmission,
|
||||
BetaFeedbackScreenshotSubmission,
|
||||
} from '@expo/apple-utils';
|
||||
|
||||
/**
|
||||
* The largest page size the App Store Connect API accepts. The apple-utils helpers always walk
|
||||
* every page, so we ask for the largest pages possible and paginate client-side from there.
|
||||
*/
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
|
||||
/** Which kind of TestFlight beta feedback a submission is. Matches the workflow trigger context. */
|
||||
export type BetaFeedbackType = 'crash' | 'screenshot';
|
||||
|
||||
/** Screenshot attached to a piece of TestFlight feedback. Its URL expires after a short while. */
|
||||
export type TestFlightScreenshot = {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
expirationDate: string;
|
||||
};
|
||||
|
||||
/** Fields shared by TestFlight screenshot feedback and TestFlight crash submissions. */
|
||||
export type TestFlightSubmission = {
|
||||
id: string;
|
||||
createdDate: string;
|
||||
comment: string | null;
|
||||
deviceModel: string;
|
||||
osVersion: string;
|
||||
locale: string | null;
|
||||
timeZone: string | null;
|
||||
architecture: string | null;
|
||||
connectionType: string | null;
|
||||
deviceFamily: string | null;
|
||||
appUptimeInMilliseconds: number | null;
|
||||
batteryPercentage: number | null;
|
||||
diskBytesAvailable: number | null;
|
||||
diskBytesTotal: number | null;
|
||||
buildVersion: string | null;
|
||||
testerName: string | null;
|
||||
testerEmail: string | null;
|
||||
};
|
||||
|
||||
export type TestFlightFeedback = TestFlightSubmission & {
|
||||
screenshots: TestFlightScreenshot[];
|
||||
};
|
||||
|
||||
export type TestFlightCrash = TestFlightSubmission;
|
||||
|
||||
/**
|
||||
* A paginated view over one App Store Connect collection. App Store Connect has no offset-based
|
||||
* paging and apple-utils always walks every page, so the underlying fetch happens once and pages
|
||||
* are served from that result — which also makes the total count available for free.
|
||||
*/
|
||||
export type TestFlightQuery<T> = {
|
||||
queryAsync(limit: number, offset: number): Promise<T[]>;
|
||||
getTotalAsync(): Promise<number>;
|
||||
};
|
||||
|
||||
function createQuery<T>(fetchAllAsync: () => Promise<T[]>): TestFlightQuery<T> {
|
||||
let all: Promise<T[]> | undefined;
|
||||
const allAsync = async (): Promise<T[]> => {
|
||||
all ??= fetchAllAsync();
|
||||
return await all;
|
||||
};
|
||||
|
||||
return {
|
||||
async queryAsync(limit: number, offset: number): Promise<T[]> {
|
||||
return (await allAsync()).slice(offset, offset + limit);
|
||||
},
|
||||
async getTotalAsync(): Promise<number> {
|
||||
return (await allAsync()).length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `build` and `tester` relationships are requested through `includes`, so they land on the
|
||||
* model attributes at runtime without being part of the attribute types.
|
||||
*/
|
||||
type SubmissionRelationships = {
|
||||
build?: { id: string; attributes?: { version?: string | null } } | null;
|
||||
tester?: {
|
||||
id: string;
|
||||
attributes?: { firstName?: string | null; lastName?: string | null; email?: string | null };
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything here is attacker-influenced text: any TestFlight tester can put arbitrary bytes in a
|
||||
* feedback comment or their own name, and crash logs carry thread and symbol names from the build.
|
||||
* Terminal escape sequences in that text could clear the screen or spoof output when it is printed,
|
||||
* so strip control characters at the boundary — before the data reaches either the formatters or
|
||||
* `--json`. Newlines and tabs survive because multi-line comments and crash logs need them.
|
||||
*/
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
|
||||
|
||||
function sanitizeText(value: string): string {
|
||||
return value.replace(/\r\n?/g, '\n').replace(CONTROL_CHARACTERS, '');
|
||||
}
|
||||
|
||||
function sanitizeOptionalText(value: unknown): string | null {
|
||||
return typeof value === 'string' ? sanitizeText(value) : null;
|
||||
}
|
||||
|
||||
function formatTesterName(tester: SubmissionRelationships['tester']): string | null {
|
||||
const parts = [tester?.attributes?.firstName, tester?.attributes?.lastName].filter(
|
||||
(part): part is string => !!part
|
||||
);
|
||||
return parts.length > 0 ? sanitizeText(parts.join(' ')) : null;
|
||||
}
|
||||
|
||||
function normalizeSubmission(submission: {
|
||||
id: string;
|
||||
attributes: Record<string, any>;
|
||||
}): TestFlightSubmission {
|
||||
const attributes = submission.attributes as Record<string, any> & SubmissionRelationships;
|
||||
return {
|
||||
id: submission.id,
|
||||
createdDate: attributes.createdDate,
|
||||
comment: sanitizeOptionalText(attributes.comment),
|
||||
deviceModel: sanitizeText(attributes.deviceModel ?? ''),
|
||||
osVersion: sanitizeText(attributes.osVersion ?? ''),
|
||||
locale: sanitizeOptionalText(attributes.locale),
|
||||
timeZone: sanitizeOptionalText(attributes.timeZone),
|
||||
architecture: sanitizeOptionalText(attributes.architecture),
|
||||
connectionType: sanitizeOptionalText(attributes.connectionType),
|
||||
deviceFamily: sanitizeOptionalText(attributes.deviceFamily),
|
||||
appUptimeInMilliseconds: attributes.appUptimeInMilliseconds ?? null,
|
||||
batteryPercentage: attributes.batteryPercentage ?? null,
|
||||
diskBytesAvailable: attributes.diskBytesAvailable ?? null,
|
||||
diskBytesTotal: attributes.diskBytesTotal ?? null,
|
||||
buildVersion: sanitizeOptionalText(attributes.build?.attributes?.version),
|
||||
testerName: formatTesterName(attributes.tester),
|
||||
// The tester relationship hides the email of testers who joined through a public link, in
|
||||
// which case the submission itself still carries the address they submitted feedback with.
|
||||
testerEmail: sanitizeOptionalText(attributes.tester?.attributes?.email ?? attributes.email),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFeedback(submission: {
|
||||
id: string;
|
||||
attributes: Record<string, any>;
|
||||
}): TestFlightFeedback {
|
||||
return {
|
||||
...normalizeSubmission(submission),
|
||||
screenshots: ((submission.attributes.screenshots ?? []) as TestFlightScreenshot[]).map(
|
||||
screenshot => ({
|
||||
url: sanitizeText(screenshot.url ?? ''),
|
||||
width: screenshot.width,
|
||||
height: screenshot.height,
|
||||
expirationDate: screenshot.expirationDate,
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Screenshot feedback that TestFlight testers submitted for the app, newest first. */
|
||||
export function getTestFlightFeedbackQuery(app: App): TestFlightQuery<TestFlightFeedback> {
|
||||
return createQuery(async () => {
|
||||
const submissions = await app.getBetaFeedbackScreenshotSubmissionsAsync({
|
||||
query: { limit: MAX_PAGE_SIZE },
|
||||
});
|
||||
return submissions.map(normalizeFeedback);
|
||||
});
|
||||
}
|
||||
|
||||
/** Crashes that TestFlight testers reported for the app, newest first. */
|
||||
export function getTestFlightCrashesQuery(app: App): TestFlightQuery<TestFlightCrash> {
|
||||
return createQuery(async () => {
|
||||
const submissions = await app.getBetaFeedbackCrashSubmissionsAsync({
|
||||
query: { limit: MAX_PAGE_SIZE },
|
||||
});
|
||||
return submissions.map(normalizeSubmission);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a single screenshot feedback submission by ID. */
|
||||
export async function fetchTestFlightFeedbackAsync(
|
||||
app: App,
|
||||
feedbackId: string
|
||||
): Promise<TestFlightFeedback> {
|
||||
const submission = await BetaFeedbackScreenshotSubmission.infoAsync(app.context, {
|
||||
id: feedbackId,
|
||||
});
|
||||
return normalizeFeedback(submission);
|
||||
}
|
||||
|
||||
export type TestFlightCrashDetails = {
|
||||
crash: TestFlightCrash;
|
||||
/** The crash log, or `null` when App Store Connect has none for this submission. */
|
||||
logText: string | null;
|
||||
};
|
||||
|
||||
/** Fetch a single crash submission together with its full crash log. */
|
||||
export async function fetchTestFlightCrashAsync(
|
||||
app: App,
|
||||
crashId: string
|
||||
): Promise<TestFlightCrashDetails> {
|
||||
const submission = await BetaFeedbackCrashSubmission.infoAsync(app.context, { id: crashId });
|
||||
const crashLog = await BetaCrashLog.getCrashLogAsync(app.context, {
|
||||
betaFeedbackCrashSubmissionId: crashId,
|
||||
});
|
||||
return {
|
||||
crash: normalizeSubmission(submission),
|
||||
logText: sanitizeOptionalText(crashLog.attributes.logText),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { TestFlightCrash, TestFlightFeedback, TestFlightSubmission } from './fetch';
|
||||
import formatFields, { FormatFieldsItem } from '../utils/formatFields';
|
||||
import { fromNow } from '../utils/date';
|
||||
|
||||
function formatCreatedDate(createdDate: string): string {
|
||||
const date = new Date(createdDate);
|
||||
if (isNaN(date.getTime())) {
|
||||
return createdDate;
|
||||
}
|
||||
return `${date.toLocaleString()} (${fromNow(date)} ago)`;
|
||||
}
|
||||
|
||||
function formatDevice(submission: TestFlightSubmission): string {
|
||||
return [submission.deviceModel, `iOS ${submission.osVersion}`].join(', ');
|
||||
}
|
||||
|
||||
function formatTester(submission: TestFlightSubmission): string | null {
|
||||
if (submission.testerName && submission.testerEmail) {
|
||||
return `${submission.testerName} <${submission.testerEmail}>`;
|
||||
}
|
||||
return submission.testerName ?? submission.testerEmail ?? null;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
const gigabytes = bytes / 1024 ** 3;
|
||||
if (gigabytes >= 1) {
|
||||
return `${gigabytes.toFixed(1)} GB`;
|
||||
}
|
||||
return `${(bytes / 1024 ** 2).toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
function formatDeviceState(submission: TestFlightSubmission): string | null {
|
||||
const parts: string[] = [];
|
||||
if (submission.batteryPercentage !== null) {
|
||||
parts.push(`battery ${submission.batteryPercentage}%`);
|
||||
}
|
||||
if (submission.diskBytesAvailable !== null && submission.diskBytesTotal !== null) {
|
||||
parts.push(
|
||||
`disk ${formatBytes(submission.diskBytesAvailable)} free of ${formatBytes(
|
||||
submission.diskBytesTotal
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (submission.connectionType) {
|
||||
parts.push(submission.connectionType.toLowerCase().replace('_', ' '));
|
||||
}
|
||||
return parts.length > 0 ? parts.join(', ') : null;
|
||||
}
|
||||
|
||||
/** Indent every line of a possibly multi-line value so it lines up under its label. */
|
||||
function indentValue(value: string, indent: number): string {
|
||||
return value.split('\n').join(`\n${' '.repeat(indent)}`);
|
||||
}
|
||||
|
||||
function commonFields(submission: TestFlightSubmission): FormatFieldsItem[] {
|
||||
const tester = formatTester(submission);
|
||||
const deviceState = formatDeviceState(submission);
|
||||
return [
|
||||
{ label: 'Submitted', value: formatCreatedDate(submission.createdDate) },
|
||||
...(submission.buildVersion ? [{ label: 'Build', value: submission.buildVersion }] : []),
|
||||
{ label: 'Device', value: formatDevice(submission) },
|
||||
...(tester ? [{ label: 'Tester', value: tester }] : []),
|
||||
...(submission.locale ? [{ label: 'Locale', value: submission.locale }] : []),
|
||||
...(deviceState ? [{ label: 'State', value: deviceState }] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function formatBlock(heading: string, fields: FormatFieldsItem[]): string {
|
||||
// `formatFields` pads labels to the widest one, so multi-line values must be indented by that
|
||||
// width plus the two spaces it puts between label and value.
|
||||
const labelWidth = Math.max(...fields.map(field => field.label.length)) + 2;
|
||||
return [
|
||||
chalk.bold(heading),
|
||||
formatFields(fields.map(field => ({ ...field, value: indentValue(field.value, labelWidth) }))),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Prefix list entries with their absolute position, so `--offset` numbering stays meaningful. */
|
||||
function heading(kind: string, id: string, position?: number): string {
|
||||
return position === undefined ? `${kind} ${id}` : `${position}. ${kind} ${id}`;
|
||||
}
|
||||
|
||||
export function formatTestFlightFeedback(feedback: TestFlightFeedback, position?: number): string {
|
||||
const screenshots = feedback.screenshots.map(
|
||||
screenshot => `${screenshot.url} (${screenshot.width}x${screenshot.height})`
|
||||
);
|
||||
|
||||
return formatBlock(heading('Feedback', feedback.id, position), [
|
||||
...commonFields(feedback),
|
||||
{ label: 'Comment', value: feedback.comment ?? chalk.dim('(no comment)') },
|
||||
...(screenshots.length > 0 ? [{ label: 'Screenshots', value: screenshots.join('\n') }] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
export function formatTestFlightCrash(crash: TestFlightCrash, position?: number): string {
|
||||
return formatBlock(heading('Crash', crash.id, position), [
|
||||
...commonFields(crash),
|
||||
...(crash.comment ? [{ label: 'Comment', value: crash.comment }] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
export function formatTestFlightCrashDetails(
|
||||
crash: TestFlightCrash,
|
||||
logText: string | null
|
||||
): string {
|
||||
const details = formatBlock(heading('Crash', crash.id), [
|
||||
...commonFields(crash),
|
||||
...(crash.architecture ? [{ label: 'Arch', value: crash.architecture }] : []),
|
||||
...(crash.appUptimeInMilliseconds !== null
|
||||
? [{ label: 'Uptime', value: `${(crash.appUptimeInMilliseconds / 1000).toFixed(1)}s` }]
|
||||
: []),
|
||||
...(crash.comment ? [{ label: 'Comment', value: crash.comment }] : []),
|
||||
]);
|
||||
|
||||
return [
|
||||
details,
|
||||
'',
|
||||
chalk.bold('Crash log'),
|
||||
logText ?? chalk.dim('No crash log is available for this submission yet.'),
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { App } from '@expo/apple-utils';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
TestFlightCrash,
|
||||
TestFlightFeedback,
|
||||
TestFlightQuery,
|
||||
TestFlightSubmission,
|
||||
fetchTestFlightCrashAsync,
|
||||
fetchTestFlightFeedbackAsync,
|
||||
getTestFlightCrashesQuery,
|
||||
getTestFlightFeedbackQuery,
|
||||
} from './fetch';
|
||||
import {
|
||||
formatTestFlightCrash,
|
||||
formatTestFlightCrashDetails,
|
||||
formatTestFlightFeedback,
|
||||
} from './format';
|
||||
import { BetaFeedbackTarget } from './target';
|
||||
import { PaginatedQueryOptions } from '../commandUtils/pagination';
|
||||
import Log from '../log';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
import { paginatedQueryWithConfirmPromptAsync } from '../utils/queries';
|
||||
|
||||
/** Feedback and crashes render as multi-line blocks, so default to a smaller page than lists do. */
|
||||
export const TESTFLIGHT_PAGE_SIZE = 20;
|
||||
|
||||
type ListRendering<T extends TestFlightSubmission> = {
|
||||
/** Key the items are nested under in `--json` output. */
|
||||
jsonKey: string;
|
||||
formatItem: (item: T, position?: number) => string;
|
||||
emptyMessage: (bundleId: string) => string;
|
||||
loadMorePrompt: string;
|
||||
/** Hint printed after a page when more items exist but interactive paging is unavailable. */
|
||||
moreAvailableHint: (shown: number, total: number) => string;
|
||||
};
|
||||
|
||||
const FEEDBACK_RENDERING: ListRendering<TestFlightFeedback> = {
|
||||
jsonKey: 'feedback',
|
||||
formatItem: formatTestFlightFeedback,
|
||||
emptyMessage: bundleId =>
|
||||
`No TestFlight feedback has been submitted for ${chalk.bold(bundleId)}.`,
|
||||
loadMorePrompt: 'Load more feedback?',
|
||||
moreAvailableHint: (shown, total) =>
|
||||
`Showing ${shown} of ${total} feedback submissions. Use --offset and --limit to see more.`,
|
||||
};
|
||||
|
||||
const CRASHES_RENDERING: ListRendering<TestFlightCrash> = {
|
||||
jsonKey: 'crashes',
|
||||
formatItem: formatTestFlightCrash,
|
||||
emptyMessage: bundleId => `No TestFlight crashes have been reported for ${chalk.bold(bundleId)}.`,
|
||||
loadMorePrompt: 'Load more crashes?',
|
||||
moreAvailableHint: (shown, total) =>
|
||||
`Showing ${shown} of ${total} crashes. Use --offset and --limit to see more.`,
|
||||
};
|
||||
|
||||
async function listAndRenderAsync<T extends TestFlightSubmission>({
|
||||
bundleId,
|
||||
query,
|
||||
rendering,
|
||||
paginatedQueryOptions,
|
||||
}: {
|
||||
bundleId: string;
|
||||
query: TestFlightQuery<T>;
|
||||
rendering: ListRendering<T>;
|
||||
paginatedQueryOptions: PaginatedQueryOptions;
|
||||
}): Promise<void> {
|
||||
const { json, nonInteractive, offset } = paginatedQueryOptions;
|
||||
const limit = paginatedQueryOptions.limit ?? TESTFLIGHT_PAGE_SIZE;
|
||||
|
||||
if (json) {
|
||||
const [items, total] = await Promise.all([
|
||||
query.queryAsync(limit, offset),
|
||||
query.getTotalAsync(),
|
||||
]);
|
||||
printJsonOnlyOutput({
|
||||
[rendering.jsonKey]: items,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
hasNextPage: offset + items.length < total,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const renderPage = (items: T[], pageOffset: number): void => {
|
||||
Log.addNewLineIfNone();
|
||||
if (items.length === 0) {
|
||||
Log.log(
|
||||
pageOffset === 0
|
||||
? rendering.emptyMessage(bundleId)
|
||||
: chalk.dim('No items past this offset.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
Log.log(
|
||||
items.map((item, index) => rendering.formatItem(item, pageOffset + index + 1)).join('\n\n')
|
||||
);
|
||||
};
|
||||
|
||||
if (nonInteractive) {
|
||||
const items = await query.queryAsync(limit, offset);
|
||||
renderPage(items, offset);
|
||||
const total = await query.getTotalAsync();
|
||||
if (offset + items.length < total) {
|
||||
Log.newLine();
|
||||
Log.log(chalk.dim(rendering.moreAvailableHint(offset + items.length, total)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// `paginatedQueryWithConfirmPromptAsync` renders one page at a time without reporting the offset
|
||||
// it used, so track how many items have been rendered to keep the numbering absolute.
|
||||
let renderedCount = 0;
|
||||
await paginatedQueryWithConfirmPromptAsync({
|
||||
limit,
|
||||
offset,
|
||||
queryToPerform: (pageLimit, pageOffset) => query.queryAsync(pageLimit, pageOffset),
|
||||
promptOptions: {
|
||||
title: rendering.loadMorePrompt,
|
||||
renderListItems: items => {
|
||||
renderPage(items, offset + renderedCount);
|
||||
renderedCount += items.length;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** List screenshot feedback submitted by TestFlight testers. */
|
||||
export async function listAndRenderTestFlightFeedbackAsync(
|
||||
app: App,
|
||||
paginatedQueryOptions: PaginatedQueryOptions
|
||||
): Promise<void> {
|
||||
await listAndRenderAsync({
|
||||
bundleId: app.attributes.bundleId,
|
||||
query: getTestFlightFeedbackQuery(app),
|
||||
rendering: FEEDBACK_RENDERING,
|
||||
paginatedQueryOptions,
|
||||
});
|
||||
}
|
||||
|
||||
/** List crashes reported by TestFlight testers. */
|
||||
export async function listAndRenderTestFlightCrashesAsync(
|
||||
app: App,
|
||||
paginatedQueryOptions: PaginatedQueryOptions
|
||||
): Promise<void> {
|
||||
await listAndRenderAsync({
|
||||
bundleId: app.attributes.bundleId,
|
||||
query: getTestFlightCrashesQuery(app),
|
||||
rendering: CRASHES_RENDERING,
|
||||
paginatedQueryOptions,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single submission identified by an EAS workflow trigger's
|
||||
* `app_store_connect.beta_feedback` payload. Crashes are rendered with their full crash log.
|
||||
*/
|
||||
export async function viewAndRenderBetaFeedbackAsync(
|
||||
app: App,
|
||||
target: BetaFeedbackTarget,
|
||||
{ json }: { json: boolean }
|
||||
): Promise<void> {
|
||||
if (target.type === 'crash') {
|
||||
const { crash, logText } = await fetchTestFlightCrashAsync(app, target.id);
|
||||
if (json) {
|
||||
printJsonOnlyOutput({ crash, logText });
|
||||
return;
|
||||
}
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatTestFlightCrashDetails(crash, logText));
|
||||
return;
|
||||
}
|
||||
|
||||
const feedback = await fetchTestFlightFeedbackAsync(app, target.id);
|
||||
if (json) {
|
||||
printJsonOnlyOutput({ feedback });
|
||||
return;
|
||||
}
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatTestFlightFeedback(feedback));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { BetaFeedbackType } from './fetch';
|
||||
|
||||
/**
|
||||
* App Store Connect self-links, which are what EAS workflow triggers put in
|
||||
* `app_store_connect.beta_feedback.url`:
|
||||
*
|
||||
* https://api.appstoreconnect.apple.com/v1/betaFeedbackCrashSubmissions/<id>
|
||||
* https://api.appstoreconnect.apple.com/v1/betaFeedbackScreenshotSubmissions/<id>
|
||||
*/
|
||||
const FEEDBACK_URL_PATTERN =
|
||||
/\/(betaFeedbackCrashSubmissions|betaFeedbackScreenshotSubmissions)\/([^/?#]+)/i;
|
||||
|
||||
export type BetaFeedbackTarget = {
|
||||
id: string;
|
||||
type: BetaFeedbackType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a single submission to look up from what an EAS workflow trigger provides in
|
||||
* `app_store_connect.beta_feedback`: either the `url` (which encodes both `id` and `type`) or the
|
||||
* bare `id` alongside `type`. Without either, the command's own kind of feedback is assumed.
|
||||
*/
|
||||
export function resolveBetaFeedbackTarget({
|
||||
idOrUrl,
|
||||
type,
|
||||
defaultType,
|
||||
}: {
|
||||
idOrUrl: string;
|
||||
type?: BetaFeedbackType;
|
||||
defaultType: BetaFeedbackType;
|
||||
}): BetaFeedbackTarget {
|
||||
const match = idOrUrl.match(FEEDBACK_URL_PATTERN);
|
||||
if (match) {
|
||||
const urlType: BetaFeedbackType = match[1].toLowerCase().startsWith('betafeedbackcrash')
|
||||
? 'crash'
|
||||
: 'screenshot';
|
||||
if (type && type !== urlType) {
|
||||
throw new Error(
|
||||
`--type ${type} does not match the URL, which points at ${urlType} feedback. Omit --type when passing a URL.`
|
||||
);
|
||||
}
|
||||
return { id: decodeURIComponent(match[2]), type: urlType };
|
||||
}
|
||||
|
||||
if (/^[a-z][a-z\d+\-.]*:\/\//i.test(idOrUrl)) {
|
||||
throw new Error(
|
||||
`Could not tell whether "${idOrUrl}" points at crash or screenshot feedback. Pass the App Store Connect API URL from \${{ app_store_connect.beta_feedback.url }}, or pass the ID with --type.`
|
||||
);
|
||||
}
|
||||
|
||||
return { id: idOrUrl, type: type ?? defaultType };
|
||||
}
|
||||
@@ -5,7 +5,11 @@ import promiseRetry from 'promise-retry';
|
||||
|
||||
import { ExpoGraphqlClient } from './commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import fetch from './fetch';
|
||||
import { AccountUploadSessionType, UploadSessionType } from './graphql/generated';
|
||||
import {
|
||||
AccountUploadSessionType,
|
||||
AppUploadSessionType,
|
||||
UploadSessionType,
|
||||
} from './graphql/generated';
|
||||
import { SignedUrl, UploadSessionMutation } from './graphql/mutations/UploadSessionMutation';
|
||||
import { ProgressHandler } from './utils/progress';
|
||||
|
||||
@@ -53,6 +57,29 @@ export async function uploadAccountScopedFileAtPathToGCSAsync(
|
||||
return signedUrl.bucketKey;
|
||||
}
|
||||
|
||||
export async function uploadAppScopedFileAtPathToGCSAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
type,
|
||||
appId,
|
||||
path,
|
||||
handleProgressEvent = () => {},
|
||||
}: {
|
||||
type: AppUploadSessionType;
|
||||
appId: string;
|
||||
path: string;
|
||||
handleProgressEvent?: ProgressHandler;
|
||||
}
|
||||
): Promise<string> {
|
||||
const signedUrl = await UploadSessionMutation.createAppScopedUploadSessionAsync(graphqlClient, {
|
||||
type,
|
||||
appID: appId,
|
||||
});
|
||||
|
||||
await uploadWithSignedUrlWithProgressAsync(path, signedUrl, handleProgressEvent);
|
||||
return signedUrl.bucketKey;
|
||||
}
|
||||
|
||||
export async function uploadWithPresignedPostWithRetryAsync(
|
||||
file: string,
|
||||
presignedPost: PresignedPost,
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import { CurrentUserQuery, PartnerActor, Robot, SsoUser, User } from '../graphql/generated';
|
||||
import { CurrentUserQuery, PartnerActor, Robot, Role, SsoUser, User } from '../graphql/generated';
|
||||
|
||||
export type Actor = NonNullable<CurrentUserQuery['meActor']>;
|
||||
|
||||
/**
|
||||
* Names of the accounts where the actor can create projects (non-ViewOnly role), listed in the
|
||||
* same order as account selection prompts: the personal account first, then team accounts,
|
||||
* then organization accounts.
|
||||
*/
|
||||
export function getCreatableAccountNames(actor: Actor): string[] {
|
||||
const creatableAccounts = actor.accounts.filter(
|
||||
a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly
|
||||
);
|
||||
if (actor.__typename === 'Robot') {
|
||||
return creatableAccounts.map(it => it.name);
|
||||
}
|
||||
return [
|
||||
...creatableAccounts.filter(a => a.ownerUserActor?.id === actor.id),
|
||||
...creatableAccounts.filter(a => a.ownerUserActor && a.ownerUserActor.id !== actor.id),
|
||||
...creatableAccounts.filter(a => !a.ownerUserActor),
|
||||
].map(it => it.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the name of the actor, either normal user, sso user or robot user.
|
||||
* This should be used whenever the "current user" needs to be displayed.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Role } from '../../graphql/generated';
|
||||
import { Actor, getActorDisplayName } from '../User';
|
||||
import { Actor, getActorDisplayName, getCreatableAccountNames } from '../User';
|
||||
|
||||
const userStub: Actor = {
|
||||
__typename: 'User',
|
||||
@@ -72,3 +72,37 @@ describe('getActorDisplayName', () => {
|
||||
expect(getActorDisplayName({ ...robotStub, firstName: undefined })).toBe('robot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCreatableAccountNames', () => {
|
||||
it('lists the personal account first, then team accounts, then organizations, excluding view-only accounts', () => {
|
||||
const actor: Actor = {
|
||||
...userStub,
|
||||
accounts: [
|
||||
{
|
||||
id: 'account_id_1',
|
||||
name: 'some-org',
|
||||
users: [{ role: Role.Owner, actor: { id: 'userId' } }],
|
||||
},
|
||||
{
|
||||
id: 'account_id_2',
|
||||
name: 'other-user-team',
|
||||
ownerUserActor: { id: 'otherUserId', username: 'other-user' },
|
||||
users: [{ role: Role.Admin, actor: { id: 'userId' } }],
|
||||
},
|
||||
{
|
||||
id: 'account_id_3',
|
||||
name: 'view-only-org',
|
||||
users: [{ role: Role.ViewOnly, actor: { id: 'userId' } }],
|
||||
},
|
||||
{
|
||||
id: 'account_id_4',
|
||||
name: 'username',
|
||||
ownerUserActor: { id: 'userId', username: 'username' },
|
||||
users: [{ role: Role.Owner, actor: { id: 'userId' } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCreatableAccountNames(actor)).toEqual(['username', 'other-user-team', 'some-org']);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "21.1.0",
|
||||
"version": "21.4.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": "21.1.0",
|
||||
"@expo/eas-build-job": "21.4.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": "21.1.0",
|
||||
"version": "21.4.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,8 +26,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "21.1.0",
|
||||
"@expo/eas-build-job": "21.1.0",
|
||||
"@expo/build-tools": "21.4.0",
|
||||
"@expo/eas-build-job": "21.4.0",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "21.1.0",
|
||||
"version": "21.4.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,7 +27,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "21.1.0",
|
||||
"@expo/eas-build-job": "21.4.0",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BuildRuntimePlatform } from './BuildRuntimePlatform';
|
||||
import { BuildStepEnv } from './BuildStepEnv';
|
||||
import { BuildStepInputValueType, BuildStepInputValueTypeName } from './BuildStepInput';
|
||||
import { BuildConfigError, BuildWorkflowError } from './errors';
|
||||
import { isLocalCompositeFunctionPath } from './utils/localCompositeFunctions';
|
||||
import { BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX } from './utils/template';
|
||||
|
||||
export type BuildFunctions = Record<string, BuildFunctionConfig>;
|
||||
@@ -438,6 +439,18 @@ export function validateAllFunctionsExist(
|
||||
}
|
||||
}
|
||||
const calledFunctionsOrFunctionGroup = Array.from(calledFunctionsOrFunctionGroupsSet);
|
||||
const compositeFunctionPaths = calledFunctionsOrFunctionGroup.filter(
|
||||
isLocalCompositeFunctionPath
|
||||
);
|
||||
if (compositeFunctionPaths.length > 0) {
|
||||
throw new BuildConfigError(
|
||||
`Local composite functions (${compositeFunctionPaths
|
||||
.map(compositeFunctionPath => `"${compositeFunctionPath}"`)
|
||||
.join(
|
||||
', '
|
||||
)}) are not supported in ".eas/build/*.yml" custom builds. Local composite functions can only be used in EAS workflows (".eas/workflows/*.yml").`
|
||||
);
|
||||
}
|
||||
const externalFunctionIdsSet = new Set(externalFunctionIds);
|
||||
const externalFunctionGroupsIdsSet = new Set(externalFunctionGroupsIds);
|
||||
const nonExistentFunctionsOrFunctionGroups = calledFunctionsOrFunctionGroup.filter(
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'assert';
|
||||
|
||||
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
|
||||
import { BuildStep, BuildStepFunction } from './BuildStep';
|
||||
import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope';
|
||||
import { BuildStepGlobalContext } from './BuildStepContext';
|
||||
import { BuildStepEnv } from './BuildStepEnv';
|
||||
import { BuildStepInputProvider } from './BuildStepInput';
|
||||
@@ -117,6 +118,7 @@ export class BuildFunction {
|
||||
shell,
|
||||
env,
|
||||
ifCondition,
|
||||
compositeFunctionScope,
|
||||
timeoutMs,
|
||||
}: {
|
||||
id?: string;
|
||||
@@ -126,6 +128,7 @@ export class BuildFunction {
|
||||
shell?: string;
|
||||
env?: BuildStepEnv;
|
||||
ifCondition?: string;
|
||||
compositeFunctionScope?: BuildStepCompositeFunctionScope;
|
||||
timeoutMs?: number;
|
||||
} = {}
|
||||
): BuildStep {
|
||||
@@ -158,9 +161,14 @@ export class BuildFunction {
|
||||
supportedRuntimePlatforms: this.supportedRuntimePlatforms,
|
||||
env,
|
||||
ifCondition,
|
||||
compositeFunctionScope,
|
||||
timeoutMs,
|
||||
__metricsId: this.__metricsId,
|
||||
__hookId: this.__hookId,
|
||||
// The declaration does not survive composite function expansion: hooks
|
||||
// never fire around steps inside a composite function, so an expanded
|
||||
// step must not carry an anchor mark for any discovery mechanism
|
||||
// (parse-time or a future runtime scan) to find.
|
||||
__hookId: compositeFunctionScope === undefined ? this.__hookId : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
+181
-44
@@ -6,6 +6,7 @@ import path from 'path';
|
||||
import util from 'util';
|
||||
|
||||
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
|
||||
import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope';
|
||||
import { BuildStepContext, BuildStepGlobalContext } from './BuildStepContext';
|
||||
import { BuildStepEnv } from './BuildStepEnv';
|
||||
import { BuildStepInput, BuildStepInputById, makeBuildStepInputByIdMap } from './BuildStepInput';
|
||||
@@ -23,7 +24,13 @@ import {
|
||||
} from './BuildTemporaryFiles';
|
||||
import { BuildStepRuntimeError } from './errors';
|
||||
import { interpolateJobContext } from './interpolation';
|
||||
import { evaluateIfCondition } from './utils/jsepEval';
|
||||
import {
|
||||
containsUnresolvedTemplateReference,
|
||||
resolveInterpolatedTarget,
|
||||
stringifyInterpolatedResult,
|
||||
stringifyOptionalInterpolatedResult,
|
||||
} from './utils/compositeFunctionInterpolation';
|
||||
import { evaluateIfCondition as evaluateIfConditionExpression } from './utils/jsepEval';
|
||||
import { BIN_PATH } from './utils/shell/bin';
|
||||
import { getShellCommandAndArgs } from './utils/shell/command';
|
||||
import { spawnAsync } from './utils/shell/spawn';
|
||||
@@ -134,6 +141,7 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
public readonly ctx: BuildStepContext;
|
||||
public readonly stepEnvOverrides: BuildStepEnv;
|
||||
public readonly ifCondition?: string;
|
||||
public readonly compositeFunctionScope?: BuildStepCompositeFunctionScope;
|
||||
public readonly timeoutMs?: number;
|
||||
public readonly __metricsId?: string;
|
||||
public readonly __hookId?: HookAnchorId;
|
||||
@@ -162,6 +170,7 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
supportedRuntimePlatforms: maybeSupportedRuntimePlatforms,
|
||||
env,
|
||||
ifCondition,
|
||||
compositeFunctionScope,
|
||||
timeoutMs,
|
||||
__metricsId,
|
||||
__hookId,
|
||||
@@ -177,6 +186,7 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
supportedRuntimePlatforms?: BuildRuntimePlatform[];
|
||||
env?: BuildStepEnv;
|
||||
ifCondition?: string;
|
||||
compositeFunctionScope?: BuildStepCompositeFunctionScope;
|
||||
timeoutMs?: number;
|
||||
__metricsId?: string;
|
||||
__hookId?: HookAnchorId;
|
||||
@@ -197,6 +207,7 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
this.command = command;
|
||||
this.shell = shell ?? '/bin/bash -eo pipefail';
|
||||
this.ifCondition = ifCondition;
|
||||
this.compositeFunctionScope = compositeFunctionScope;
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.__metricsId = __metricsId;
|
||||
this.__hookId = __hookId;
|
||||
@@ -212,15 +223,26 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
this.outputsDir = getTemporaryOutputsDirPath(ctx, this.id);
|
||||
this.envsDir = getTemporaryEnvsDirPath(ctx, this.id);
|
||||
|
||||
this.registerSelf(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consulted by registerSelf() in the constructor; overrides may only read state
|
||||
* assigned before that call (outputById, compositeFunctionScope).
|
||||
*/
|
||||
public get isCompositeFunctionInternal(): boolean {
|
||||
return this.compositeFunctionScope !== undefined;
|
||||
}
|
||||
|
||||
protected registerSelf(ctx: BuildStepGlobalContext): void {
|
||||
ctx.registerStep(this);
|
||||
}
|
||||
|
||||
public async executeAsync(): Promise<void> {
|
||||
try {
|
||||
this.ctx.logger.info(
|
||||
{ marker: BuildStepLogMarker.START_STEP },
|
||||
`Executing build step "${this.displayName}"`
|
||||
);
|
||||
this.resolveTemplatedWorkingDirectoryIfNeeded();
|
||||
|
||||
this.logStepStart();
|
||||
this.status = BuildStepStatus.IN_PROGRESS;
|
||||
|
||||
await fs.mkdir(this.outputsDir, { recursive: true });
|
||||
@@ -269,10 +291,7 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
await executionPromise;
|
||||
}
|
||||
|
||||
this.ctx.logger.info(
|
||||
{ marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.SUCCESS },
|
||||
`Finished build step "${this.displayName}" successfully`
|
||||
);
|
||||
this.logStepSuccess();
|
||||
this.status = BuildStepStatus.SUCCESS;
|
||||
} catch (err) {
|
||||
// Downstream error handling relies on real Errors; wrap non-Error
|
||||
@@ -283,11 +302,7 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
: new BuildStepRuntimeError(
|
||||
`Build step "${this.displayName}" threw a non-Error value: ${util.inspect(err)}`
|
||||
);
|
||||
this.ctx.logger.error({ err: error });
|
||||
this.ctx.logger.error(
|
||||
{ marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.FAIL },
|
||||
`Build step "${this.displayName}" failed`
|
||||
);
|
||||
this.logStepFailed(error);
|
||||
this.status = BuildStepStatus.FAIL;
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -300,6 +315,7 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
} catch (error) {
|
||||
// If the step succeeded, we expect the outputs to be collected successfully.
|
||||
if (this.status === BuildStepStatus.SUCCESS) {
|
||||
// Rethrow so BuildWorkflow.recordFailure marks the global failure flag.
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -318,32 +334,86 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
}
|
||||
|
||||
public shouldExecuteStep(): boolean {
|
||||
const hasAnyPreviousStepFailed = this.ctx.global.hasAnyPreviousStepFailed;
|
||||
|
||||
if (!this.ifCondition) {
|
||||
return !hasAnyPreviousStepFailed;
|
||||
if (
|
||||
this.compositeFunctionScope &&
|
||||
!this.compositeFunctionScope.isActive(evaluateIfConditionExpression)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!this.ifCondition) {
|
||||
return !this.ctx.global.hasAnyPreviousStepFailed;
|
||||
}
|
||||
return this.evaluateIfCondition(this.ifCondition, {
|
||||
scope: this.compositeFunctionScope,
|
||||
env: this.getScriptEnv(),
|
||||
inputs: this.compositeFunctionScope ? {} : this.evaluateOwnStepInputs(),
|
||||
});
|
||||
}
|
||||
|
||||
return evaluateIfCondition(
|
||||
this.ifCondition,
|
||||
this.ctx.global.getIfConditionContext({
|
||||
inputs:
|
||||
this.inputs?.reduce(
|
||||
(acc, input) => {
|
||||
acc[input.id] = input.getValue({
|
||||
interpolationContext: this.getInterpolationContext(),
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
) ?? {},
|
||||
env: this.getScriptEnv(),
|
||||
})
|
||||
private evaluateOwnStepInputs(): Record<string, unknown> {
|
||||
return (
|
||||
this.inputs?.reduce(
|
||||
(acc, input) => {
|
||||
acc[input.id] = input.getValue({
|
||||
interpolationContext: this.getInterpolationContext(),
|
||||
skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined,
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
) ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
private evaluateIfCondition(
|
||||
ifCondition: string,
|
||||
{
|
||||
scope,
|
||||
env,
|
||||
inputs,
|
||||
}: {
|
||||
scope: BuildStepCompositeFunctionScope | undefined;
|
||||
env: BuildStepEnv;
|
||||
inputs: Record<string, unknown>;
|
||||
}
|
||||
): boolean {
|
||||
const baseContext = this.ctx.global.getIfConditionContext({
|
||||
inputs,
|
||||
env,
|
||||
}) as JobInterpolationContext;
|
||||
// Overlay the scope last so `inputs`/`steps` come from the composite function while `eas.*` stays global.
|
||||
const context = scope ? scope.getScopedInterpolationContext(baseContext) : baseContext;
|
||||
return evaluateIfConditionExpression(ifCondition, context);
|
||||
}
|
||||
|
||||
public skip(): void {
|
||||
this.status = BuildStepStatus.SKIPPED;
|
||||
this.logStepSkipped();
|
||||
}
|
||||
|
||||
protected logStepStart(): void {
|
||||
this.ctx.logger.info(
|
||||
{ marker: BuildStepLogMarker.START_STEP },
|
||||
`Executing build step "${this.displayName}"`
|
||||
);
|
||||
}
|
||||
|
||||
protected logStepSuccess(): void {
|
||||
this.ctx.logger.info(
|
||||
{ marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.SUCCESS },
|
||||
`Finished build step "${this.displayName}" successfully`
|
||||
);
|
||||
}
|
||||
|
||||
protected logStepFailed(error: Error): void {
|
||||
this.ctx.logger.error({ err: error });
|
||||
this.ctx.logger.error(
|
||||
{ marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.FAIL },
|
||||
`Build step "${this.displayName}" failed`
|
||||
);
|
||||
}
|
||||
|
||||
protected logStepSkipped(): void {
|
||||
this.ctx.logger.info(
|
||||
{ marker: BuildStepLogMarker.START_STEP },
|
||||
'Executing build step "${this.displayName}"'
|
||||
@@ -356,10 +426,11 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
}
|
||||
|
||||
private getInterpolationContext(): JobInterpolationContext {
|
||||
return {
|
||||
const base: JobInterpolationContext = {
|
||||
...this.ctx.global.getInterpolationContext(),
|
||||
env: this.getScriptEnv(),
|
||||
};
|
||||
return this.compositeFunctionScope?.getScopedInterpolationContext(base) ?? base;
|
||||
}
|
||||
|
||||
private async executeCommandAsync({ signal }: { signal: AbortSignal | null }): Promise<void> {
|
||||
@@ -423,7 +494,12 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
inputs: Object.fromEntries(
|
||||
Object.entries(this.inputById).map(([key, input]) => [
|
||||
key,
|
||||
{ value: input.getValue({ interpolationContext: this.getInterpolationContext() }) },
|
||||
{
|
||||
value: input.getValue({
|
||||
interpolationContext: this.getInterpolationContext(),
|
||||
skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined,
|
||||
}),
|
||||
},
|
||||
])
|
||||
),
|
||||
outputs: this.outputById,
|
||||
@@ -438,25 +514,42 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
template: string,
|
||||
inputs?: BuildStepInput[]
|
||||
): string {
|
||||
// Composite functions support only `${{ }}`; a literal `${ steps.x.y }` reaching bash is the accepted
|
||||
// behavior, so skip legacy output interpolation for composite-function-scoped steps.
|
||||
const skipLegacyOutputInterpolation = this.compositeFunctionScope !== undefined;
|
||||
if (!inputs) {
|
||||
return interpolateWithOutputs(
|
||||
this.ctx.global.interpolate(template),
|
||||
path => this.ctx.global.getStepOutputValue(path) ?? ''
|
||||
);
|
||||
const interpolatedWithGlobalContext = this.ctx.global.interpolate(template);
|
||||
return skipLegacyOutputInterpolation
|
||||
? interpolatedWithGlobalContext
|
||||
: interpolateWithOutputs(interpolatedWithGlobalContext, path =>
|
||||
this.getLegacyStepOutputValue(path)
|
||||
);
|
||||
}
|
||||
const vars = inputs.reduce(
|
||||
(acc, input) => {
|
||||
const value = input.getValue({ interpolationContext: this.getInterpolationContext() });
|
||||
const value = input.getValue({
|
||||
interpolationContext: this.getInterpolationContext(),
|
||||
skipLegacyOutputInterpolation,
|
||||
});
|
||||
acc[input.id] =
|
||||
typeof value === 'object' ? JSON.stringify(value) : (value?.toString() ?? '');
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
return interpolateWithOutputs(
|
||||
interpolateWithInputs(this.ctx.global.interpolate(template), vars),
|
||||
path => this.ctx.global.getStepOutputValue(path) ?? ''
|
||||
const interpolatedWithInputsAndGlobalContext = interpolateWithInputs(
|
||||
this.ctx.global.interpolate(template),
|
||||
vars
|
||||
);
|
||||
return skipLegacyOutputInterpolation
|
||||
? interpolatedWithInputsAndGlobalContext
|
||||
: interpolateWithOutputs(interpolatedWithInputsAndGlobalContext, path =>
|
||||
this.getLegacyStepOutputValue(path)
|
||||
);
|
||||
}
|
||||
|
||||
private getLegacyStepOutputValue(path: string): string {
|
||||
return this.ctx.global.getStepOutputValue(path) ?? '';
|
||||
}
|
||||
|
||||
private async collectAndValidateOutputsAsync(outputsDir: string): Promise<void> {
|
||||
@@ -512,8 +605,32 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
});
|
||||
}
|
||||
|
||||
private getInterpolatedEnvOverrides(): BuildStepEnv {
|
||||
const ownOverrides = this.stepEnvOverrides;
|
||||
if (!this.compositeFunctionScope) {
|
||||
return ownOverrides;
|
||||
}
|
||||
// Use global env here to avoid recursing through getScriptEnv.
|
||||
const base: JobInterpolationContext = {
|
||||
...this.ctx.global.getInterpolationContext(),
|
||||
env: this.ctx.global.env,
|
||||
};
|
||||
// Call-site env uses caller scope; step env uses action scope.
|
||||
const inheritedEnv = this.compositeFunctionScope.resolveInheritedEnv(base);
|
||||
const scoped = this.compositeFunctionScope.getScopedInterpolationContext(base);
|
||||
const ownEnv = Object.fromEntries(
|
||||
Object.entries(ownOverrides).map(([key, value]) => {
|
||||
if (typeof value !== 'string' || !containsUnresolvedTemplateReference(value)) {
|
||||
return [key, value];
|
||||
}
|
||||
return [key, stringifyInterpolatedResult(resolveInterpolatedTarget(value, scoped))];
|
||||
})
|
||||
);
|
||||
return { ...inheritedEnv, ...ownEnv };
|
||||
}
|
||||
|
||||
private getScriptEnv(): Record<string, string> {
|
||||
const effectiveEnv = { ...this.ctx.global.env, ...this.stepEnvOverrides };
|
||||
const effectiveEnv = { ...this.ctx.global.env, ...this.getInterpolatedEnvOverrides() };
|
||||
const currentPath = effectiveEnv.PATH ?? process.env.PATH;
|
||||
const newPath = currentPath ? `${BIN_PATH}:${currentPath}` : BIN_PATH;
|
||||
return {
|
||||
@@ -524,4 +641,24 @@ export class BuildStep extends BuildStepOutputAccessor {
|
||||
PATH: newPath,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveTemplatedWorkingDirectoryIfNeeded(): void {
|
||||
const scope = this.compositeFunctionScope;
|
||||
if (!scope) {
|
||||
return;
|
||||
}
|
||||
const relativeWorkingDirectory = this.ctx.relativeWorkingDirectory;
|
||||
if (
|
||||
relativeWorkingDirectory === undefined ||
|
||||
!containsUnresolvedTemplateReference(relativeWorkingDirectory)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const context = scope.getScopedInterpolationContext({
|
||||
...this.ctx.global.getInterpolationContext(),
|
||||
env: this.getScriptEnv(),
|
||||
});
|
||||
const resolved = resolveInterpolatedTarget(relativeWorkingDirectory, context);
|
||||
this.ctx.updateRelativeWorkingDirectory(stringifyOptionalInterpolatedResult(resolved));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user