mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,15 @@
|
||||
"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 REFRESH token from an
|
||||
// `opencode auth login` ChatGPT sign-in (copy `.openai.refresh` out of OpenCode's
|
||||
// auth.json); OpenCode mints short-lived access tokens from it on demand. 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_REFRESH_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
|
||||
|
||||
@@ -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.5.0' }}
|
||||
|
||||
concurrency:
|
||||
group: ai-code-review-cmd-${{ github.event.issue.number }}
|
||||
@@ -104,7 +104,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_REFRESH_TOKEN: ${{ secrets.CODEX_OAUTH_REFRESH_TOKEN }}
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_REFRESH_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.5.0' }}
|
||||
|
||||
concurrency:
|
||||
group: ai-code-review-dismiss-${{ github.event.issue.number }}
|
||||
|
||||
@@ -13,8 +13,8 @@ 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.5.x.
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.5.0' }}
|
||||
|
||||
concurrency:
|
||||
group: ai-code-review-${{ github.event.pull_request.number }}
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
- 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' }}
|
||||
EXPECTED: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_REFRESH_TOKEN' }}
|
||||
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 .)
|
||||
@@ -135,9 +135,13 @@ 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 refresh token (auth.mode "oauth" in
|
||||
# .expo-code-review/config.jsonc reads this env var); OpenCode mints
|
||||
# access tokens from it. All reviews run on the subscription.
|
||||
CODEX_OAUTH_REFRESH_TOKEN: ${{ secrets.CODEX_OAUTH_REFRESH_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_REFRESH_TOKEN' }}
|
||||
# Optional: override the model used by every agent.
|
||||
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
|
||||
AGENTS: ${{ steps.resolve.outputs.agents }}
|
||||
|
||||
+14
-1
@@ -8,12 +8,24 @@ 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] 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))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [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))
|
||||
|
||||
## [21.1.0](https://github.com/expo/eas-cli/releases/tag/v21.1.0) - 2026-07-22
|
||||
@@ -28,6 +40,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
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "21.2.0",
|
||||
"version": "21.3.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "21.1.0",
|
||||
"version": "21.3.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.3.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.3.0",
|
||||
"@expo/template-file": "21.0.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "21.1.0",
|
||||
"version": "21.3.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.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "21.1.0",
|
||||
"version": "21.3.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,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>;
|
||||
@@ -26,6 +26,7 @@ export * from './context';
|
||||
export * from './generic';
|
||||
export * from './hooks';
|
||||
export * from './step';
|
||||
export * from './compositeFunction';
|
||||
export * from './submission-config';
|
||||
export * from './projectPackage';
|
||||
|
||||
|
||||
+243
-115
File diff suppressed because it is too large.
Load diff
Generated
+1143
-1413
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "21.2.0",
|
||||
"version": "21.3.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.3.0",
|
||||
"@expo/eas-json": "21.3.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.3.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"
|
||||
},
|
||||
|
||||
+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],
|
||||
|
||||
@@ -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';
|
||||
@@ -101,12 +102,14 @@ 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,
|
||||
});
|
||||
const { names, isTruncated } = await withObservePlanGateHandlingAsync(() =>
|
||||
ObserveQuery.customEventNamesAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
})
|
||||
);
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveCustomEventNamesJson(names, isTruncated));
|
||||
@@ -124,17 +127,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,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);
|
||||
}
|
||||
}
|
||||
+214
-279
@@ -89,8 +89,6 @@ export type Account = {
|
||||
appStoreConnectApiKeys: Array<AppStoreConnectApiKey>;
|
||||
appStoreConnectApiKeysPaginated: AccountAppStoreConnectApiKeysConnection;
|
||||
appleAppIdentifiers: Array<AppleAppIdentifier>;
|
||||
/** @deprecated Use appleDevicesPaginated */
|
||||
appleDevices: Array<AppleDevice>;
|
||||
appleDevicesPaginated: AccountAppleDevicesConnection;
|
||||
/** @deprecated Use appleDistributionCertificatesPaginated */
|
||||
appleDistributionCertificates: Array<AppleDistributionCertificate>;
|
||||
@@ -197,6 +195,8 @@ export type Account = {
|
||||
users: Array<UserPermission>;
|
||||
/** Vexo account connection for this account */
|
||||
vexoAccountConnection?: Maybe<VexoAccountConnection>;
|
||||
/** Most recently active first */
|
||||
viewerDashboardChats: DashboardChatConnection;
|
||||
/** Notification preferences of the viewer for this account */
|
||||
viewerNotificationPreferences: Array<NotificationPreferenceItem>;
|
||||
/** Permission info for the viewer on this account */
|
||||
@@ -245,17 +245,6 @@ export type AccountAppleAppIdentifiersArgs = {
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* An account is a container owning projects, credentials, billing and other organization
|
||||
* data and settings. Actors may own and be members of accounts.
|
||||
*/
|
||||
export type AccountAppleDevicesArgs = {
|
||||
identifier?: InputMaybe<Scalars['String']['input']>;
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
offset?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* An account is a container owning projects, credentials, billing and other organization
|
||||
* data and settings. Actors may own and be members of accounts.
|
||||
@@ -492,6 +481,18 @@ export type AccountTimelineActivityArgs = {
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* An account is a container owning projects, credentials, billing and other organization
|
||||
* data and settings. Actors may own and be members of accounts.
|
||||
*/
|
||||
export type AccountViewerDashboardChatsArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type AccountAppStoreConnectApiKeysConnection = {
|
||||
__typename?: 'AccountAppStoreConnectApiKeysConnection';
|
||||
edges: Array<AccountAppStoreConnectApiKeysEdge>;
|
||||
@@ -1387,8 +1388,6 @@ export type AndroidSubmissionConfig = {
|
||||
__typename?: 'AndroidSubmissionConfig';
|
||||
/** @deprecated applicationIdentifier is deprecated and will be auto-detected on submit */
|
||||
applicationIdentifier?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated archiveType is deprecated and will be null */
|
||||
archiveType?: Maybe<SubmissionAndroidArchiveType>;
|
||||
releaseStatus?: Maybe<SubmissionAndroidReleaseStatus>;
|
||||
rollout?: Maybe<Scalars['Float']['output']>;
|
||||
track: Scalars['String']['output'];
|
||||
@@ -1416,11 +1415,6 @@ export type App = Project & {
|
||||
appStoreConnectApp?: Maybe<AppStoreConnectApp>;
|
||||
/** Connection status for this project's App Store Connect-triggered workflows. */
|
||||
appStoreConnectWorkflowConnectionStatus: AppStoreConnectWorkflowConnectionStatus;
|
||||
/**
|
||||
* ios.appStoreUrl field from most recent classic update manifest
|
||||
* @deprecated Classic updates have been deprecated.
|
||||
*/
|
||||
appStoreUrl?: Maybe<Scalars['String']['output']>;
|
||||
assetLimitPerUpdateGroup: Scalars['Int']['output'];
|
||||
branchesPaginated: AppBranchesConnection;
|
||||
buildProfiles: Array<Scalars['String']['output']>;
|
||||
@@ -1450,11 +1444,6 @@ export type App = Project & {
|
||||
githubJobRunTriggers: Array<GitHubJobRunTrigger>;
|
||||
githubRepository?: Maybe<GitHubRepository>;
|
||||
githubRepositorySettings?: Maybe<GitHubRepositorySettings>;
|
||||
/**
|
||||
* githubUrl field from most recent classic update manifest
|
||||
* @deprecated Classic updates have been deprecated.
|
||||
*/
|
||||
githubUrl?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* Info about the icon specified in the most recent classic update manifest
|
||||
* @deprecated Classic updates have been deprecated.
|
||||
@@ -1468,35 +1457,19 @@ export type App = Project & {
|
||||
internalDistributionBuildPrivacy: AppInternalDistributionBuildPrivacy;
|
||||
/** iOS app credentials for the project */
|
||||
iosAppCredentials: Array<IosAppCredentials>;
|
||||
/**
|
||||
* Whether the latest classic update publish is using a deprecated SDK version
|
||||
* @deprecated Classic updates have been deprecated.
|
||||
*/
|
||||
isDeprecated: Scalars['Boolean']['output'];
|
||||
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
|
||||
/** @deprecated No longer supported */
|
||||
lastPublishedTime: Scalars['DateTime']['output'];
|
||||
/** Time of the last user activity (update, branch, submission). */
|
||||
latestActivity: Scalars['DateTime']['output'];
|
||||
latestAppVersionByPlatformAndApplicationIdentifier?: Maybe<AppVersion>;
|
||||
/** @deprecated Classic updates have been deprecated. */
|
||||
latestReleaseForReleaseChannel?: Maybe<AppRelease>;
|
||||
logRocketProject?: Maybe<LogRocketProject>;
|
||||
name: Scalars['String']['output'];
|
||||
observe: AppObserve;
|
||||
ownerAccount: Account;
|
||||
/** @deprecated No longer supported */
|
||||
packageName: Scalars['String']['output'];
|
||||
/**
|
||||
* android.playStoreUrl field from most recent classic update manifest
|
||||
* @deprecated Classic updates have been deprecated.
|
||||
*/
|
||||
playStoreUrl?: Maybe<Scalars['String']['output']>;
|
||||
posthogProject?: Maybe<PostHogProject>;
|
||||
/** @deprecated No longer supported */
|
||||
privacy: Scalars['String']['output'];
|
||||
/** @deprecated No longer supported */
|
||||
privacySetting: AppPrivacy;
|
||||
profileImageUrl?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* Whether there have been any classic update publishes
|
||||
@@ -1506,8 +1479,6 @@ export type App = Project & {
|
||||
/** App query field for querying details about an app's push notifications */
|
||||
pushNotifications: AppPushNotifications;
|
||||
pushSecurityEnabled: Scalars['Boolean']['output'];
|
||||
/** @deprecated Legacy access tokens are deprecated */
|
||||
requiresAccessTokenForPushSecurity: Scalars['Boolean']['output'];
|
||||
resourceClassExperiment?: Maybe<ResourceClassExperiment>;
|
||||
/** Runtimes associated with this app */
|
||||
runtimes: RuntimesConnection;
|
||||
@@ -1712,13 +1683,6 @@ export type AppLatestAppVersionByPlatformAndApplicationIdentifierArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppLatestReleaseForReleaseChannelArgs = {
|
||||
platform: AppPlatform;
|
||||
releaseChannel: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppProfileImageUrlArgs = {
|
||||
width?: InputMaybe<AppProfileImageWidth>;
|
||||
@@ -2400,6 +2364,8 @@ export type AppObserveCustomEvent = {
|
||||
deviceModel: Scalars['String']['output'];
|
||||
deviceOs: Scalars['String']['output'];
|
||||
deviceOsVersion: Scalars['String']['output'];
|
||||
/** Human-friendly label from the expo.log.display_name attribute; null when the event was logged without one. */
|
||||
displayName?: Maybe<Scalars['String']['output']>;
|
||||
easClientId: Scalars['String']['output'];
|
||||
environment?: Maybe<Scalars['String']['output']>;
|
||||
errorFingerprint?: Maybe<Scalars['String']['output']>;
|
||||
@@ -2816,7 +2782,7 @@ export type AppObserveNavigationRoutesFilter = {
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
platform: AppObservePlatform;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
routeNames?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
@@ -2860,7 +2826,7 @@ export type AppObserveReleasesInput = {
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
metricNames?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
platform: AppObservePlatform;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
@@ -2897,7 +2863,7 @@ export type AppObserveTimeSeriesInput = {
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
metricName: Scalars['String']['input'];
|
||||
platform: AppObservePlatform;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
routeName?: InputMaybe<Scalars['String']['input']>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
@@ -2948,7 +2914,7 @@ export type AppObserveUpdatesInput = {
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
orderBy?: InputMaybe<AppObserveUpdatesOrderBy>;
|
||||
platform: AppObservePlatform;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
@@ -3036,11 +3002,6 @@ export type AppPushNotificationsInsightsTotalNotificationsSentArgs = {
|
||||
|
||||
export type AppQuery = {
|
||||
__typename?: 'AppQuery';
|
||||
/**
|
||||
* Public apps in the app directory
|
||||
* @deprecated App directory no longer supported
|
||||
*/
|
||||
all: Array<App>;
|
||||
/** Look up app by dev domain name, if one has been created */
|
||||
byDevDomainName: App;
|
||||
byFullName: App;
|
||||
@@ -3049,14 +3010,6 @@ export type AppQuery = {
|
||||
};
|
||||
|
||||
|
||||
export type AppQueryAllArgs = {
|
||||
filter: AppsFilter;
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
offset?: InputMaybe<Scalars['Int']['input']>;
|
||||
sort: AppSort;
|
||||
};
|
||||
|
||||
|
||||
export type AppQueryByDevDomainNameArgs = {
|
||||
name: Scalars['DevDomainName']['input'];
|
||||
};
|
||||
@@ -3071,27 +3024,6 @@ export type AppQueryByIdArgs = {
|
||||
appId: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type AppRelease = {
|
||||
__typename?: 'AppRelease';
|
||||
hash: Scalars['String']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
manifest: Scalars['JSON']['output'];
|
||||
publishedTime: Scalars['DateTime']['output'];
|
||||
publishingUsername: Scalars['String']['output'];
|
||||
runtimeVersion?: Maybe<Scalars['String']['output']>;
|
||||
s3Key: Scalars['String']['output'];
|
||||
s3Url: Scalars['String']['output'];
|
||||
sdkVersion: Scalars['String']['output'];
|
||||
version: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export enum AppSort {
|
||||
/** Sort by recently published */
|
||||
RecentlyPublished = 'RECENTLY_PUBLISHED',
|
||||
/** Sort by highest trendScore */
|
||||
Viewed = 'VIEWED'
|
||||
}
|
||||
|
||||
export type AppStoreConnectApiKey = {
|
||||
__typename?: 'AppStoreConnectApiKey';
|
||||
account: Account;
|
||||
@@ -3406,6 +3338,7 @@ export type AppWorkflowRunEdge = {
|
||||
|
||||
export type AppWorkflowRunFilterInput = {
|
||||
requestedGitRef?: InputMaybe<Scalars['String']['input']>;
|
||||
searchTerm?: InputMaybe<Scalars['String']['input']>;
|
||||
status?: InputMaybe<WorkflowRunStatus>;
|
||||
timeRange?: InputMaybe<WorkflowRunTimeRangeInput>;
|
||||
workflowId?: InputMaybe<Scalars['ID']['input']>;
|
||||
@@ -3861,13 +3794,6 @@ export type AppleTeamUpdateInput = {
|
||||
appleTeamType?: InputMaybe<AppleTeamType>;
|
||||
};
|
||||
|
||||
export enum AppsFilter {
|
||||
/** Featured Projects */
|
||||
Featured = 'FEATURED',
|
||||
/** New Projects */
|
||||
New = 'NEW'
|
||||
}
|
||||
|
||||
export type ArgentRunSessionRemoteConfig = {
|
||||
__typename?: 'ArgentRunSessionRemoteConfig';
|
||||
toolsAuthToken?: Maybe<Scalars['String']['output']>;
|
||||
@@ -4199,8 +4125,6 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
|
||||
gitCommitHash?: Maybe<Scalars['String']['output']>;
|
||||
gitCommitMessage?: Maybe<Scalars['String']['output']>;
|
||||
gitRef?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated Use 'githubRepository' field instead */
|
||||
githubRepositoryOwnerAndName?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
/** Queue position is 1-indexed */
|
||||
initialQueuePosition?: Maybe<Scalars['Int']['output']>;
|
||||
@@ -4209,6 +4133,8 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
|
||||
isForIosSimulator: Scalars['Boolean']['output'];
|
||||
isGitWorkingTreeDirty?: Maybe<Scalars['Boolean']['output']>;
|
||||
isWaived: Scalars['Boolean']['output'];
|
||||
logFileUrls: Array<Scalars['String']['output']>;
|
||||
/** @deprecated Use logFileUrls instead */
|
||||
logFiles: Array<Scalars['String']['output']>;
|
||||
maxBuildTimeSeconds: Scalars['Int']['output'];
|
||||
/** Retry time starts after completedAt */
|
||||
@@ -4350,8 +4276,6 @@ export type BuildArtifacts = {
|
||||
applicationArchiveUrl?: Maybe<Scalars['String']['output']>;
|
||||
buildArtifactsUrl?: Maybe<Scalars['String']['output']>;
|
||||
buildUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated Use 'runtime.fingerprint.debugInfoUrl' instead. */
|
||||
fingerprintUrl?: Maybe<Scalars['String']['output']>;
|
||||
xcodeBuildLogsUrl?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
@@ -4610,7 +4534,8 @@ export enum BuildPhase {
|
||||
UploadApplicationArchive = 'UPLOAD_APPLICATION_ARCHIVE',
|
||||
/** @deprecated No longer supported */
|
||||
UploadArtifacts = 'UPLOAD_ARTIFACTS',
|
||||
UploadBuildArtifacts = 'UPLOAD_BUILD_ARTIFACTS'
|
||||
UploadBuildArtifacts = 'UPLOAD_BUILD_ARTIFACTS',
|
||||
UploadEmbeddedBundle = 'UPLOAD_EMBEDDED_BUNDLE'
|
||||
}
|
||||
|
||||
export type BuildPlanCreditThresholdExceededMetadata = {
|
||||
@@ -4995,6 +4920,11 @@ export type CreateDeviceRunSessionArtifactUploadSessionResult = {
|
||||
uploadSession: DeviceRunSessionArtifactUploadSession;
|
||||
};
|
||||
|
||||
export type CreateDeviceRunSessionEventLogUploadSessionResult = {
|
||||
__typename?: 'CreateDeviceRunSessionEventLogUploadSessionResult';
|
||||
uploadSession: DeviceRunSessionEventLogUploadSession;
|
||||
};
|
||||
|
||||
export type CreateDeviceRunSessionInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
/**
|
||||
@@ -5316,6 +5246,135 @@ export enum CustomDomainStatus {
|
||||
TimedOut = 'TIMED_OUT'
|
||||
}
|
||||
|
||||
/** A dashboard AI chat conversation, private to the user who created it. */
|
||||
export type DashboardChat = {
|
||||
__typename?: 'DashboardChat';
|
||||
account: Account;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
lastMessageAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
/** Most recent first */
|
||||
messages: DashboardChatMessageConnection;
|
||||
title?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
|
||||
/** A dashboard AI chat conversation, private to the user who created it. */
|
||||
export type DashboardChatMessagesArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type DashboardChatConnection = {
|
||||
__typename?: 'DashboardChatConnection';
|
||||
edges: Array<DashboardChatEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type DashboardChatEdge = {
|
||||
__typename?: 'DashboardChatEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: DashboardChat;
|
||||
};
|
||||
|
||||
export type DashboardChatMessage = {
|
||||
__typename?: 'DashboardChatMessage';
|
||||
/** Client-supplied; unique per chat, not globally */
|
||||
clientMessageId: Scalars['String']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
/** UIMessage parts, stored verbatim, ordered by index */
|
||||
parts: Array<Scalars['JSONObject']['output']>;
|
||||
role: DashboardChatMessageRole;
|
||||
status?: Maybe<DashboardChatMessageStatus>;
|
||||
};
|
||||
|
||||
export type DashboardChatMessageConnection = {
|
||||
__typename?: 'DashboardChatMessageConnection';
|
||||
edges: Array<DashboardChatMessageEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type DashboardChatMessageEdge = {
|
||||
__typename?: 'DashboardChatMessageEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: DashboardChatMessage;
|
||||
};
|
||||
|
||||
export enum DashboardChatMessageRole {
|
||||
Assistant = 'ASSISTANT',
|
||||
User = 'USER'
|
||||
}
|
||||
|
||||
/** Terminal state of an assistant message */
|
||||
export enum DashboardChatMessageStatus {
|
||||
Canceled = 'CANCELED',
|
||||
Completed = 'COMPLETED',
|
||||
Error = 'ERROR'
|
||||
}
|
||||
|
||||
export type DashboardChatMutation = {
|
||||
__typename?: 'DashboardChatMutation';
|
||||
/** Persist a user message. Idempotent on clientMessageId. */
|
||||
addUserMessage: DashboardChatMessage;
|
||||
/** Create a new chat owned by the viewer */
|
||||
createChat: DashboardChat;
|
||||
/** Delete a chat and all of its messages */
|
||||
deleteChat: DashboardChat;
|
||||
/** Rename a chat */
|
||||
renameChat: DashboardChat;
|
||||
/**
|
||||
* Persist an assistant message, replacing parts when a message with the
|
||||
* same clientMessageId already exists.
|
||||
*/
|
||||
upsertAssistantMessage: DashboardChatMessage;
|
||||
};
|
||||
|
||||
|
||||
export type DashboardChatMutationAddUserMessageArgs = {
|
||||
accountID: Scalars['ID']['input'];
|
||||
chatID: Scalars['ID']['input'];
|
||||
clientMessageId: Scalars['String']['input'];
|
||||
parts: Array<Scalars['JSONObject']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type DashboardChatMutationCreateChatArgs = {
|
||||
accountID: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type DashboardChatMutationDeleteChatArgs = {
|
||||
chatID: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type DashboardChatMutationRenameChatArgs = {
|
||||
chatID: Scalars['ID']['input'];
|
||||
title: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type DashboardChatMutationUpsertAssistantMessageArgs = {
|
||||
accountID: Scalars['ID']['input'];
|
||||
chatID: Scalars['ID']['input'];
|
||||
clientMessageId: Scalars['String']['input'];
|
||||
parts: Array<Scalars['JSONObject']['input']>;
|
||||
status: DashboardChatMessageStatus;
|
||||
};
|
||||
|
||||
export type DashboardChatQuery = {
|
||||
__typename?: 'DashboardChatQuery';
|
||||
/** Get dashboard chat by ID - entry point to the graph */
|
||||
byId?: Maybe<DashboardChat>;
|
||||
};
|
||||
|
||||
|
||||
export type DashboardChatQueryByIdArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export enum DashboardViewPin {
|
||||
Activity = 'ACTIVITY',
|
||||
Overview = 'OVERVIEW'
|
||||
@@ -5469,11 +5528,6 @@ export type DeleteUpdateChannelResult = {
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type DeleteUpdateGroupResult = {
|
||||
__typename?: 'DeleteUpdateGroupResult';
|
||||
group: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type DeleteWebhookResult = {
|
||||
__typename?: 'DeleteWebhookResult';
|
||||
id: Scalars['ID']['output'];
|
||||
@@ -5679,6 +5733,12 @@ export type DeviceRunSessionArtifactUploadSession = {
|
||||
url: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type DeviceRunSessionEventLogUploadSession = {
|
||||
__typename?: 'DeviceRunSessionEventLogUploadSession';
|
||||
headers: Scalars['JSONObject']['output'];
|
||||
url: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type DeviceRunSessionFilterInput = {
|
||||
platforms?: InputMaybe<Array<AppPlatform>>;
|
||||
statuses?: InputMaybe<Array<DeviceRunSessionStatus>>;
|
||||
@@ -5691,6 +5751,11 @@ export type DeviceRunSessionMutation = {
|
||||
createArtifactUploadSession: CreateDeviceRunSessionArtifactUploadSessionResult;
|
||||
/** Create a device run session */
|
||||
createDeviceRunSession: DeviceRunSession;
|
||||
/**
|
||||
* Create a standard artifact and a two-hour upload URL for repeatedly
|
||||
* overwriting its structured event log while the backing job run is running.
|
||||
*/
|
||||
createEventLogUploadSession: CreateDeviceRunSessionEventLogUploadSessionResult;
|
||||
/**
|
||||
* Ensure a device run session is stopped. Idempotent: if the session has already
|
||||
* finished, the existing session is returned unchanged (an ERRORED session stays
|
||||
@@ -5713,6 +5778,11 @@ export type DeviceRunSessionMutationCreateDeviceRunSessionArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type DeviceRunSessionMutationCreateEventLogUploadSessionArgs = {
|
||||
deviceRunSessionId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type DeviceRunSessionMutationEnsureDeviceRunSessionStoppedArgs = {
|
||||
deviceRunSessionId: Scalars['ID']['input'];
|
||||
};
|
||||
@@ -8249,12 +8319,6 @@ export type MeMutation = {
|
||||
scheduleCurrentUserDeletion: BackgroundJobReceipt;
|
||||
/** Schedule deletion of a SSO user. Actor must be an owner on the SSO user's SSO account. */
|
||||
scheduleSSOUserDeletionAsSSOAccountOwner: BackgroundJobReceipt;
|
||||
/**
|
||||
* Legacy user preferences are no longer stored; this mutation accepts and discards
|
||||
* its input. Use userPreference.set instead.
|
||||
* @deprecated No longer stored; this mutation has no effect. Use userPreference.set instead.
|
||||
*/
|
||||
setPreferences: UserPreferences;
|
||||
/** Set the user's primary second factor device */
|
||||
setPrimarySecondFactorDevice: SecondFactorBooleanResult;
|
||||
/** Transfer project to a different Account */
|
||||
@@ -8332,11 +8396,6 @@ export type MeMutationScheduleSsoUserDeletionAsSsoAccountOwnerArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MeMutationSetPreferencesArgs = {
|
||||
preferences: UserPreferencesInput;
|
||||
};
|
||||
|
||||
|
||||
export type MeMutationSetPrimarySecondFactorDeviceArgs = {
|
||||
userSecondFactorDeviceId: Scalars['ID']['input'];
|
||||
};
|
||||
@@ -8736,20 +8795,6 @@ export type ProjectPublicData = {
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type ProjectQuery = {
|
||||
__typename?: 'ProjectQuery';
|
||||
/** @deprecated See byAccountNameAndSlug */
|
||||
byUsernameAndSlug: Project;
|
||||
};
|
||||
|
||||
|
||||
export type ProjectQueryByUsernameAndSlugArgs = {
|
||||
platform?: InputMaybe<Scalars['String']['input']>;
|
||||
sdkVersions?: InputMaybe<Array<InputMaybe<Scalars['String']['input']>>>;
|
||||
slug: Scalars['String']['input'];
|
||||
username: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type PublicArtifacts = {
|
||||
__typename?: 'PublicArtifacts';
|
||||
applicationArchiveUrl?: Maybe<Scalars['String']['output']>;
|
||||
@@ -8989,6 +9034,8 @@ export type RootMutation = {
|
||||
convexProject: ConvexProjectMutation;
|
||||
convexTeamConnection: ConvexTeamConnectionMutation;
|
||||
customDomain: CustomDomainMutation;
|
||||
/** Mutations for dashboard chats */
|
||||
dashboardChat: DashboardChatMutation;
|
||||
deployments: DeploymentsMutation;
|
||||
/** Mutations that assign or modify DevDomainNames for apps */
|
||||
devDomainName: AppDevDomainNameMutation;
|
||||
@@ -9119,11 +9166,6 @@ export type RootQuery = {
|
||||
account: AccountQuery;
|
||||
/** Top-level query object for querying AccountSSOConfigurationPublicData */
|
||||
accountSSOConfigurationPublicData: AccountSsoConfigurationPublicDataQuery;
|
||||
/**
|
||||
* Public apps in the app directory
|
||||
* @deprecated Use 'all' field under 'app'.
|
||||
*/
|
||||
allPublicApps?: Maybe<Array<Maybe<App>>>;
|
||||
app: AppQuery;
|
||||
/**
|
||||
* Look up app by app id
|
||||
@@ -9157,6 +9199,8 @@ export type RootQuery = {
|
||||
channels: ChannelQuery;
|
||||
/** Top-level query object for querying Convex Integration information. */
|
||||
convexIntegration: ConvexIntegrationQuery;
|
||||
/** Top-level query object for querying dashboard chats. */
|
||||
dashboardChat: DashboardChatQuery;
|
||||
/** Top-level query object for querying Deployments. */
|
||||
deployments: DeploymentQuery;
|
||||
deviceRunSessionArtifacts: DeviceRunSessionArtifactQuery;
|
||||
@@ -9199,8 +9243,6 @@ export type RootQuery = {
|
||||
meUserActor?: Maybe<UserActor>;
|
||||
/** Top-level query object for querying PostHog Integration information. */
|
||||
posthogIntegration: PostHogIntegrationQuery;
|
||||
/** @deprecated Snacks and apps should be queried separately */
|
||||
project: ProjectQuery;
|
||||
/** Top-level query object for querying Runtimes. */
|
||||
runtimes: RuntimeQuery;
|
||||
snack: SnackQuery;
|
||||
@@ -9212,19 +9254,10 @@ export type RootQuery = {
|
||||
updates: UpdateQuery;
|
||||
/** fetch all updates in a group */
|
||||
updatesByGroup: Array<Update>;
|
||||
/**
|
||||
* Top-level query object for querying Users.
|
||||
* @deprecated Public user queries are no longer supported
|
||||
*/
|
||||
user: UserQuery;
|
||||
/** Top-level query object for querying UserActorPublicData publicly. */
|
||||
userActorPublicData: UserActorPublicDataQuery;
|
||||
/** Top-level query object for querying User Audit Logs. */
|
||||
userAuditLogs: UserAuditLogQuery;
|
||||
/** @deprecated Use 'byId' field under 'user'. */
|
||||
userByUserId?: Maybe<User>;
|
||||
/** @deprecated Use 'byUsername' field under 'user'. */
|
||||
userByUsername?: Maybe<User>;
|
||||
/** Top-level query object for querying UserInvitationPublicData publicly. */
|
||||
userInvitationPublicData: UserInvitationPublicDataQuery;
|
||||
/** Query interface for user preferences */
|
||||
@@ -9248,14 +9281,6 @@ export type RootQuery = {
|
||||
};
|
||||
|
||||
|
||||
export type RootQueryAllPublicAppsArgs = {
|
||||
filter: AppsFilter;
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
offset?: InputMaybe<Scalars['Int']['input']>;
|
||||
sort: AppSort;
|
||||
};
|
||||
|
||||
|
||||
export type RootQueryAppByAppIdArgs = {
|
||||
appId: Scalars['String']['input'];
|
||||
};
|
||||
@@ -9266,16 +9291,6 @@ export type RootQueryUpdatesByGroupArgs = {
|
||||
platform?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type RootQueryUserByUserIdArgs = {
|
||||
userId: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type RootQueryUserByUsernameArgs = {
|
||||
username: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type Runtime = {
|
||||
__typename?: 'Runtime';
|
||||
app: App;
|
||||
@@ -9394,11 +9409,6 @@ export type SsoUser = Actor & UserActor & {
|
||||
/** Coalesced project activity for all apps belonging to all accounts this user belongs to. Only resolves for the viewer. */
|
||||
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
|
||||
appCount: Scalars['Int']['output'];
|
||||
/**
|
||||
* Apps this user has published. If this user is the viewer, this field returns the apps the user has access to.
|
||||
* @deprecated Use Account.appsPaginated instead
|
||||
*/
|
||||
apps: Array<App>;
|
||||
bestContactEmail?: Maybe<Scalars['String']['output']>;
|
||||
created: Scalars['DateTime']['output'];
|
||||
/** Discord account linked to a user */
|
||||
@@ -9444,14 +9454,6 @@ export type SsoUserActivityTimelineProjectActivitiesArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Represents a human SSO (not robot) actor. */
|
||||
export type SsoUserAppsArgs = {
|
||||
includeUnpublished?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
limit: Scalars['Int']['input'];
|
||||
offset: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** Represents a human SSO (not robot) actor. */
|
||||
export type SsoUserFeatureGatesArgs = {
|
||||
filter?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
@@ -9658,11 +9660,6 @@ export type SnackQuery = {
|
||||
__typename?: 'SnackQuery';
|
||||
/** Get snack by hashId */
|
||||
byHashId: Snack;
|
||||
/**
|
||||
* Get snack by hashId
|
||||
* @deprecated Use byHashId
|
||||
*/
|
||||
byId: Snack;
|
||||
};
|
||||
|
||||
|
||||
@@ -9670,11 +9667,6 @@ export type SnackQueryByHashIdArgs = {
|
||||
hashId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type SnackQueryByIdArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export enum StandardOffer {
|
||||
/** $29 USD per month, 30 day trial */
|
||||
Default = 'DEFAULT',
|
||||
@@ -9833,11 +9825,6 @@ export type Submission = ActivityTimelineProjectActivity & {
|
||||
workflowJob?: Maybe<WorkflowJob>;
|
||||
};
|
||||
|
||||
export enum SubmissionAndroidArchiveType {
|
||||
Aab = 'AAB',
|
||||
Apk = 'APK'
|
||||
}
|
||||
|
||||
export enum SubmissionAndroidReleaseStatus {
|
||||
Completed = 'COMPLETED',
|
||||
Draft = 'DRAFT',
|
||||
@@ -10519,11 +10506,6 @@ export type UpdateInsightsTotalUniqueUsersArgs = {
|
||||
|
||||
export type UpdateMutation = {
|
||||
__typename?: 'UpdateMutation';
|
||||
/**
|
||||
* Delete an EAS update group
|
||||
* @deprecated Use scheduleUpdateGroupDeletion instead
|
||||
*/
|
||||
deleteUpdateGroup: DeleteUpdateGroupResult;
|
||||
/** Delete an EAS update group in the background */
|
||||
scheduleUpdateGroupDeletion: BackgroundJobReceipt;
|
||||
/** Set code signing info for an update */
|
||||
@@ -10533,11 +10515,6 @@ export type UpdateMutation = {
|
||||
};
|
||||
|
||||
|
||||
export type UpdateMutationDeleteUpdateGroupArgs = {
|
||||
group: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type UpdateMutationScheduleUpdateGroupDeletionArgs = {
|
||||
group: Scalars['ID']['input'];
|
||||
};
|
||||
@@ -10624,8 +10601,9 @@ export type UpdatesTimelineFilter = {
|
||||
platform?: InputMaybe<AppPlatform>;
|
||||
runtimeVersions?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
/**
|
||||
* Case-insensitive substring match on update group message and branch name.
|
||||
* Embedded updates are matched on channel and runtime version.
|
||||
* Case-insensitive substring match on update group message, branch name, and runtime version,
|
||||
* plus prefix match on update ID, update group ID, and git commit hash.
|
||||
* Embedded updates are matched on channel, runtime version, and ID prefix.
|
||||
*/
|
||||
searchTerm?: InputMaybe<Scalars['String']['input']>;
|
||||
types?: InputMaybe<Array<UpdatesTimelineItemType>>;
|
||||
@@ -10739,11 +10717,6 @@ export type User = Actor & UserActor & {
|
||||
/** Coalesced project activity for all apps belonging to all accounts this user belongs to. Only resolves for the viewer. */
|
||||
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
|
||||
appCount: Scalars['Int']['output'];
|
||||
/**
|
||||
* Apps this user has published
|
||||
* @deprecated Use Account.appsPaginated instead
|
||||
*/
|
||||
apps: Array<App>;
|
||||
bestContactEmail?: Maybe<Scalars['String']['output']>;
|
||||
created: Scalars['DateTime']['output'];
|
||||
/** Discord account linked to a user */
|
||||
@@ -10767,8 +10740,6 @@ export type User = Actor & UserActor & {
|
||||
hasPendingUserInvitations: Scalars['Boolean']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
isExpoAdmin: Scalars['Boolean']['output'];
|
||||
/** @deprecated No longer supported */
|
||||
isLegacy: Scalars['Boolean']['output'];
|
||||
isSecondFactorAuthenticationEnabled: Scalars['Boolean']['output'];
|
||||
isStaffModeEnabled: Scalars['Boolean']['output'];
|
||||
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
|
||||
@@ -10805,14 +10776,6 @@ export type UserActivityTimelineProjectActivitiesArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Represents a human (not robot) actor. */
|
||||
export type UserAppsArgs = {
|
||||
includeUnpublished?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
limit: Scalars['Int']['input'];
|
||||
offset: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** Represents a human (not robot) actor. */
|
||||
export type UserFeatureGatesArgs = {
|
||||
filter?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
@@ -10845,11 +10808,6 @@ export type UserActor = {
|
||||
*/
|
||||
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
|
||||
appCount: Scalars['Int']['output'];
|
||||
/**
|
||||
* Apps this user has published
|
||||
* @deprecated Use Account.appsPaginated instead
|
||||
*/
|
||||
apps: Array<App>;
|
||||
bestContactEmail?: Maybe<Scalars['String']['output']>;
|
||||
created: Scalars['DateTime']['output'];
|
||||
/** Discord account linked to a user */
|
||||
@@ -10898,14 +10856,6 @@ export type UserActorActivityTimelineProjectActivitiesArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** A human user (type User or SSOUser) that can login to the Expo website, use Expo services, and be a member of accounts. */
|
||||
export type UserActorAppsArgs = {
|
||||
includeUnpublished?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
limit: Scalars['Int']['input'];
|
||||
offset: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** A human user (type User or SSOUser) that can login to the Expo website, use Expo services, and be a member of accounts. */
|
||||
export type UserActorFeatureGatesArgs = {
|
||||
filter?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
@@ -10934,8 +10884,6 @@ export type UserActorPublicData = {
|
||||
id: Scalars['ID']['output'];
|
||||
lastName?: Maybe<Scalars['String']['output']>;
|
||||
primaryAccountProfileImageUrl: Scalars['String']['output'];
|
||||
/** @deprecated Use primaryAccountProfileImageUrl instead */
|
||||
profilePhoto: Scalars['String']['output'];
|
||||
/** Snacks associated with this user's personal account */
|
||||
snacks: Array<Snack>;
|
||||
username: Scalars['String']['output'];
|
||||
@@ -11268,8 +11216,6 @@ export type UserPermission = {
|
||||
id: Scalars['ID']['output'];
|
||||
permissions: Array<Permission>;
|
||||
role: Role;
|
||||
/** @deprecated User type is deprecated */
|
||||
user?: Maybe<User>;
|
||||
userActor?: Maybe<UserActor>;
|
||||
};
|
||||
|
||||
@@ -11324,10 +11270,6 @@ export type UserPreferences = {
|
||||
onboarding?: Maybe<UserPreferencesOnboarding>;
|
||||
};
|
||||
|
||||
export type UserPreferencesInput = {
|
||||
onboarding?: InputMaybe<UserPreferencesOnboardingInput>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set by website. Used by CLI to continue onboarding process on user's machine - clone repository,
|
||||
* install dependencies etc.
|
||||
@@ -11342,29 +11284,6 @@ export type UserPreferencesOnboarding = {
|
||||
platform?: Maybe<AppPlatform>;
|
||||
};
|
||||
|
||||
export type UserPreferencesOnboardingInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
deviceType?: InputMaybe<OnboardingDeviceType>;
|
||||
environment?: InputMaybe<OnboardingEnvironment>;
|
||||
isCLIDone?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
lastUsed: Scalars['String']['input'];
|
||||
platform?: InputMaybe<AppPlatform>;
|
||||
};
|
||||
|
||||
export type UserQuery = {
|
||||
__typename?: 'UserQuery';
|
||||
/**
|
||||
* Query a User by username
|
||||
* @deprecated Public user queries are no longer supported
|
||||
*/
|
||||
byUsername: User;
|
||||
};
|
||||
|
||||
|
||||
export type UserQueryByUsernameArgs = {
|
||||
username: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
/** A second factor device belonging to a User */
|
||||
export type UserSecondFactorDevice = {
|
||||
__typename?: 'UserSecondFactorDevice';
|
||||
@@ -12445,13 +12364,10 @@ export type WorkflowJob = {
|
||||
credentialsAppleDeviceRegistrationRequest?: Maybe<AppleDeviceRegistrationRequest>;
|
||||
/**
|
||||
* All test case attempt rows produced by this job's own execution (turtle job
|
||||
* run), ordered by path then retryCount. Unlike deviceTestCaseResults and
|
||||
* allDeviceTestCaseResults, this never includes rows from other jobs in the
|
||||
* workflow retry chain.
|
||||
* run), ordered by path then retryCount. Unlike allDeviceTestCaseResults,
|
||||
* this never includes rows from other jobs in the workflow retry chain.
|
||||
*/
|
||||
deviceTestCaseResultAttempts: Array<WorkflowDeviceTestCaseResult>;
|
||||
/** @deprecated Use deviceTestCaseResultAttempts instead */
|
||||
deviceTestCaseResults: Array<WorkflowDeviceTestCaseResult>;
|
||||
environment?: Maybe<Scalars['String']['output']>;
|
||||
errors: Array<WorkflowJobError>;
|
||||
id: Scalars['ID']['output'];
|
||||
@@ -12459,6 +12375,11 @@ export type WorkflowJob = {
|
||||
name: Scalars['String']['output'];
|
||||
outputs: Scalars['JSONObject']['output'];
|
||||
requiredJobKeys: Array<Scalars['String']['output']>;
|
||||
/**
|
||||
* Updates in the group being rolled out by an UPDATE_ROLLOUT job, resolved from the job's
|
||||
* update_group_id output. Empty for other job types or when the group has no updates.
|
||||
*/
|
||||
rolloutUpdateGroup: Array<Update>;
|
||||
status: WorkflowJobStatus;
|
||||
turtleBuild?: Maybe<Build>;
|
||||
turtleJobRun?: Maybe<JobRun>;
|
||||
@@ -12495,8 +12416,6 @@ export type WorkflowJobApproval = {
|
||||
id: Scalars['ID']['output'];
|
||||
reviewingActor?: Maybe<Actor>;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
/** @deprecated Use reviewingActor instead */
|
||||
userActor?: Maybe<UserActor>;
|
||||
workflowJob: WorkflowJob;
|
||||
};
|
||||
|
||||
@@ -12560,7 +12479,8 @@ export enum WorkflowJobType {
|
||||
Slack = 'SLACK',
|
||||
Submission = 'SUBMISSION',
|
||||
Testflight = 'TESTFLIGHT',
|
||||
Update = 'UPDATE'
|
||||
Update = 'UPDATE',
|
||||
UpdateRollout = 'UPDATE_ROLLOUT'
|
||||
}
|
||||
|
||||
export type WorkflowProjectSourceInput = {
|
||||
@@ -14050,6 +13970,14 @@ export type CreateAccountScopedUploadSessionMutationVariables = Exact<{
|
||||
|
||||
export type CreateAccountScopedUploadSessionMutation = { __typename?: 'RootMutation', uploadSession: { __typename?: 'UploadSession', createAccountScopedUploadSession: any } };
|
||||
|
||||
export type CreateAppScopedUploadSessionMutationVariables = Exact<{
|
||||
appID: Scalars['ID']['input'];
|
||||
type: AppUploadSessionType;
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateAppScopedUploadSessionMutation = { __typename?: 'RootMutation', uploadSession: { __typename?: 'UploadSession', createAppScopedUploadSession: any } };
|
||||
|
||||
export type CreateWebhookMutationVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
webhookInput: WebhookInput;
|
||||
@@ -14157,14 +14085,6 @@ export type AccountBillingPeriodQueryVariables = Exact<{
|
||||
|
||||
export type AccountBillingPeriodQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, name: string, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any } } } };
|
||||
|
||||
export type SimulatorAvailabilityQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
filter?: InputMaybe<Array<Scalars['String']['input']> | Scalars['String']['input']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type SimulatorAvailabilityQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, ownerAccount: { __typename?: 'Account', id: string, name: string, accountFeatureGates: any } } } };
|
||||
|
||||
export type AppByIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
}>;
|
||||
@@ -14195,6 +14115,13 @@ export type AppByFullNameQuery = { __typename?: 'RootQuery', app: { __typename?:
|
||||
| { __typename?: 'User', id: string }
|
||||
}> }, githubRepository?: { __typename?: 'GitHubRepository', id: string, metadata: { __typename?: 'GitHubRepositoryMetadata', id: string, githubRepoOwnerName: string, githubRepoName: string } } | null } } };
|
||||
|
||||
export type AppByIdProfileImageUrlQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type AppByIdProfileImageUrlQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, profileImageUrl?: string | null } } };
|
||||
|
||||
export type AppByIdWorkflowsQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
}>;
|
||||
@@ -14480,6 +14407,14 @@ export type ConvexProjectByAppIdQueryVariables = Exact<{
|
||||
|
||||
export type ConvexProjectByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, convexProject?: { __typename?: 'ConvexProject', id: string, convexProjectIdentifier: string, convexProjectName: string, convexProjectSlug: string, createdAt: any, updatedAt: any, convexTeamConnection: { __typename?: 'ConvexTeamConnection', id: string, convexTeamIdentifier: string, convexTeamName: string, convexTeamSlug: string, hasBeenClaimed: boolean, createdAt: any, updatedAt: any, invitedAt?: any | null, invitedEmail?: string | null } } | null } } };
|
||||
|
||||
export type SimulatorAvailabilityQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
filter?: InputMaybe<Array<Scalars['String']['input']> | Scalars['String']['input']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type SimulatorAvailabilityQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, ownerAccount: { __typename?: 'Account', id: string, name: string, accountFeatureGates: any } } } };
|
||||
|
||||
export type DeviceRunSessionByIdQueryVariables = Exact<{
|
||||
deviceRunSessionId: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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.3.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.3.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.3.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.3.0",
|
||||
"@expo/eas-build-job": "21.3.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.3.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.3.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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Runtime scope for one expanded composite function call.
|
||||
*
|
||||
* Composite functions are flattened into the workflow, but inner steps still need composite-function-local semantics:
|
||||
* `${{ steps.read }}` must mean the inner `read` step, not a workflow step with the same id.
|
||||
* This class overlays a `steps` view built from the call's own children on top of the global interpolation context.
|
||||
* success()/failure() use global workflow status (GitHub composite action parity).
|
||||
*/
|
||||
import { JobInterpolationContext } from '@expo/eas-build-job';
|
||||
|
||||
import type { BuildStepOutputAccessor } from './BuildStep';
|
||||
import { BuildStepGlobalContext } from './BuildStepContext';
|
||||
import { BuildStepEnv } from './BuildStepEnv';
|
||||
import { BuildStepInput } from './BuildStepInput';
|
||||
import { BuildStepRuntimeError } from './errors';
|
||||
import {
|
||||
resolveInterpolatedTarget,
|
||||
stringifyInterpolatedResult,
|
||||
} from './utils/compositeFunctionInterpolation';
|
||||
|
||||
export type EvaluateIfExpression = (
|
||||
expression: string,
|
||||
context: JobInterpolationContext
|
||||
) => boolean;
|
||||
|
||||
type ScopedInterpolationContext = JobInterpolationContext & { inputs: Record<string, unknown> };
|
||||
|
||||
export class BuildStepCompositeFunctionScope {
|
||||
public readonly parent?: BuildStepCompositeFunctionScope;
|
||||
public readonly env?: BuildStepEnv;
|
||||
private readonly ctx: BuildStepGlobalContext;
|
||||
private readonly ifCondition?: string;
|
||||
private readonly compositeFunctionPath: string;
|
||||
private readonly inputs: Map<string, BuildStepInput>;
|
||||
private readonly providedInputKeys: ReadonlySet<string>;
|
||||
// Filled by the expander after construction; children and scope need each other.
|
||||
private readonly childrenByLocalId: Map<string, BuildStepOutputAccessor>;
|
||||
private cachedIsActive?: boolean;
|
||||
// Detects cycles while resolving input default values.
|
||||
private readonly resolvingInputs = new Set<string>();
|
||||
|
||||
constructor({
|
||||
ctx,
|
||||
parent,
|
||||
ifCondition,
|
||||
env,
|
||||
compositeFunctionPath,
|
||||
inputs,
|
||||
providedInputKeys,
|
||||
childrenByLocalId,
|
||||
}: {
|
||||
ctx: BuildStepGlobalContext;
|
||||
parent?: BuildStepCompositeFunctionScope;
|
||||
ifCondition?: string;
|
||||
env?: BuildStepEnv;
|
||||
compositeFunctionPath: string;
|
||||
inputs: Map<string, BuildStepInput>;
|
||||
providedInputKeys: ReadonlySet<string>;
|
||||
childrenByLocalId: Map<string, BuildStepOutputAccessor>;
|
||||
}) {
|
||||
this.ctx = ctx;
|
||||
this.parent = parent;
|
||||
this.ifCondition = ifCondition;
|
||||
this.env = env;
|
||||
this.compositeFunctionPath = compositeFunctionPath;
|
||||
this.inputs = inputs;
|
||||
this.providedInputKeys = providedInputKeys;
|
||||
this.childrenByLocalId = childrenByLocalId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the parent chain; the call-site `if` gates the whole composite function call, not individual inner steps.
|
||||
* Memoized: global status can change mid-expansion, and re-evaluating would flip a passed
|
||||
* success() gate and skip remaining always()/failure() inner steps.
|
||||
*/
|
||||
public isActive(evaluate: EvaluateIfExpression): boolean {
|
||||
if (this.parent && !this.parent.isActive(evaluate)) {
|
||||
return false;
|
||||
}
|
||||
this.cachedIsActive ??= this.evaluateCallIfCondition(evaluate);
|
||||
return this.cachedIsActive;
|
||||
}
|
||||
|
||||
// Call-site if uses caller env/inputs/steps, not expanded inner steps.
|
||||
private evaluateCallIfCondition(evaluate: EvaluateIfExpression): boolean {
|
||||
if (!this.ifCondition) {
|
||||
return !this.ctx.hasAnyPreviousStepFailed;
|
||||
}
|
||||
const callerBase: JobInterpolationContext = {
|
||||
...this.ctx.getInterpolationContext(),
|
||||
env: this.ctx.env,
|
||||
};
|
||||
const env = { ...this.ctx.env, ...this.resolveInheritedEnv(callerBase) };
|
||||
const baseContext = this.ctx.getIfConditionContext({
|
||||
inputs: {},
|
||||
env,
|
||||
}) as JobInterpolationContext;
|
||||
const context = this.parent
|
||||
? this.parent.getScopedInterpolationContext(baseContext)
|
||||
: baseContext;
|
||||
return evaluate(this.ifCondition, context);
|
||||
}
|
||||
|
||||
public getScopedInterpolationContext(base: JobInterpolationContext): JobInterpolationContext {
|
||||
// Spread `base` into a new object; never spread the returned context, as that would eagerly
|
||||
// resolve every lazy input getter.
|
||||
const scoped: ScopedInterpolationContext = {
|
||||
...base,
|
||||
steps: this.buildStepsView(),
|
||||
inputs: this.buildInputsObject(base),
|
||||
};
|
||||
return scoped;
|
||||
}
|
||||
|
||||
/** Caller scope for `with:` values and call-site env. */
|
||||
public getCallerInterpolationContext(base: JobInterpolationContext): JobInterpolationContext {
|
||||
return this.parent ? this.parent.getScopedInterpolationContext(base) : base;
|
||||
}
|
||||
|
||||
private resolveScopeEnv(base: JobInterpolationContext, parentEnv: BuildStepEnv): BuildStepEnv {
|
||||
if (!this.env) {
|
||||
return {};
|
||||
}
|
||||
const callerBase: JobInterpolationContext = { ...base, env: { ...base.env, ...parentEnv } };
|
||||
const callerContext = this.getCallerInterpolationContext(callerBase);
|
||||
return Object.fromEntries(
|
||||
Object.entries(this.env).map(([key, value]) => [
|
||||
key,
|
||||
stringifyInterpolatedResult(resolveInterpolatedTarget(value, callerContext)),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/** Merge call-site env from each scope level, outer to inner; inner keys win. */
|
||||
public resolveInheritedEnv(base: JobInterpolationContext): BuildStepEnv {
|
||||
const parentEnv = this.parent?.resolveInheritedEnv(base) ?? {};
|
||||
return { ...parentEnv, ...this.resolveScopeEnv(base, parentEnv) };
|
||||
}
|
||||
|
||||
// Workflow hides prefixed ids; re-expose the call's children under their local ids.
|
||||
private buildStepsView(): JobInterpolationContext['steps'] {
|
||||
const view: JobInterpolationContext['steps'] = {};
|
||||
for (const [localId, child] of this.childrenByLocalId) {
|
||||
view[localId] = {
|
||||
outputs: Object.fromEntries(child.outputs.map(output => [output.id, output.rawValue])),
|
||||
};
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
private buildInputsObject(base: JobInterpolationContext): Record<string, unknown> {
|
||||
const inputs: Record<string, unknown> = {};
|
||||
for (const [name, input] of this.inputs) {
|
||||
Object.defineProperty(inputs, name, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => this.resolveInputValue(name, input, base),
|
||||
});
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
|
||||
private resolveInputValue(
|
||||
name: string,
|
||||
input: BuildStepInput,
|
||||
base: JobInterpolationContext
|
||||
): unknown {
|
||||
if (this.resolvingInputs.has(name)) {
|
||||
throw new BuildStepRuntimeError(
|
||||
`Composite function "${this.compositeFunctionPath}" input "${name}" references itself, directly or indirectly, through its default value.`
|
||||
);
|
||||
}
|
||||
this.resolvingInputs.add(name);
|
||||
try {
|
||||
const isProvided = this.providedInputKeys.has(name);
|
||||
// Env binds at the call boundary so inner step `env:` overrides do not leak into inputs.
|
||||
const boundBase: JobInterpolationContext = {
|
||||
...base,
|
||||
env: this.resolveCompositeFunctionBoundaryEnv(base),
|
||||
};
|
||||
// Caller-provided values resolve in the caller's scope; defaults resolve in this composite function's.
|
||||
const interpolationContext = isProvided
|
||||
? this.getCallerInterpolationContext(boundBase)
|
||||
: this.getScopedInterpolationContext(boundBase);
|
||||
return input.getValue({ interpolationContext, skipLegacyOutputInterpolation: true });
|
||||
} finally {
|
||||
this.resolvingInputs.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Global + inherited call-site env; inputs ignore inner overrides.
|
||||
public resolveCompositeFunctionBoundaryEnv(base: JobInterpolationContext): BuildStepEnv {
|
||||
const boundaryBase: JobInterpolationContext = { ...base, env: this.ctx.env };
|
||||
return { ...this.ctx.env, ...this.resolveInheritedEnv(boundaryBase) };
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ export interface ExternalBuildContextProvider {
|
||||
export interface SerializedBuildStepGlobalContext {
|
||||
stepsInternalBuildDirectory: string;
|
||||
stepById: Record<string, SerializedBuildStepOutputAccessor>;
|
||||
// Absent on older serialized payloads; treat as empty.
|
||||
internalStepIds?: string[];
|
||||
provider: SerializedExternalBuildContextProvider;
|
||||
skipCleanup: boolean;
|
||||
}
|
||||
@@ -58,6 +60,8 @@ export class BuildStepGlobalContext {
|
||||
private didCheckOut = false;
|
||||
private _hasAnyPreviousStepFailed = false;
|
||||
private stepById: Record<string, BuildStepOutputAccessor> = {};
|
||||
// Prefixed expansion steps, omitted from the workflow steps view.
|
||||
private internalStepIds = new Set<string>();
|
||||
constructor(
|
||||
private readonly provider: ExternalBuildContextProvider,
|
||||
public readonly skipCleanup: boolean
|
||||
@@ -91,19 +95,21 @@ export class BuildStepGlobalContext {
|
||||
public get staticContext(): StaticJobInterpolationContext {
|
||||
return {
|
||||
...this.provider.staticContext(),
|
||||
steps: Object.fromEntries(
|
||||
Object.values(this.stepById).map(step => [
|
||||
steps: this.buildStepsInterpolationMap(),
|
||||
};
|
||||
}
|
||||
|
||||
private buildStepsInterpolationMap(): StaticJobInterpolationContext['steps'] {
|
||||
return Object.fromEntries(
|
||||
Object.values(this.stepById)
|
||||
.filter(step => !this.internalStepIds.has(step.id))
|
||||
.map(step => [
|
||||
step.id,
|
||||
{
|
||||
outputs: Object.fromEntries(
|
||||
step.outputs.map(output => {
|
||||
return [output.id, output.rawValue];
|
||||
})
|
||||
),
|
||||
outputs: Object.fromEntries(step.outputs.map(output => [output.id, output.rawValue])),
|
||||
},
|
||||
])
|
||||
),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
public updateEnv(updatedEnv: BuildStepEnv): void {
|
||||
@@ -112,11 +118,14 @@ export class BuildStepGlobalContext {
|
||||
|
||||
public registerStep(step: BuildStep): void {
|
||||
this.stepById[step.id] = step;
|
||||
if (step.isCompositeFunctionInternal) {
|
||||
this.internalStepIds.add(step.id);
|
||||
}
|
||||
}
|
||||
|
||||
public getStepOutputValue(path: string): string | undefined {
|
||||
const { stepId, outputId } = parseOutputPath(path);
|
||||
if (!(stepId in this.stepById)) {
|
||||
if (!(stepId in this.stepById) || this.internalStepIds.has(stepId)) {
|
||||
throw new BuildStepRuntimeError(`Step "${stepId}" does not exist.`);
|
||||
}
|
||||
return this.stepById[stepId].getOutputValueByName(outputId);
|
||||
@@ -263,6 +272,7 @@ export class BuildStepGlobalContext {
|
||||
stepById: Object.fromEntries(
|
||||
Object.entries(this.stepById).map(([id, step]) => [id, step.serialize()])
|
||||
),
|
||||
internalStepIds: [...this.internalStepIds],
|
||||
provider: {
|
||||
projectSourceDirectory: this.provider.projectSourceDirectory,
|
||||
projectTargetDirectory: this.provider.projectTargetDirectory,
|
||||
@@ -295,6 +305,7 @@ export class BuildStepGlobalContext {
|
||||
for (const [id, stepOutputAccessor] of Object.entries(serialized.stepById)) {
|
||||
ctx.stepById[id] = BuildStepOutputAccessor.deserialize(stepOutputAccessor);
|
||||
}
|
||||
ctx.internalStepIds = new Set(serialized.internalStepIds ?? []);
|
||||
ctx.stepsInternalBuildDirectory = serialized.stepsInternalBuildDirectory;
|
||||
|
||||
return ctx;
|
||||
@@ -308,7 +319,7 @@ export interface SerializedBuildStepContext {
|
||||
|
||||
export class BuildStepContext {
|
||||
public readonly logger: bunyan;
|
||||
public readonly relativeWorkingDirectory?: string;
|
||||
private _relativeWorkingDirectory?: string;
|
||||
|
||||
constructor(
|
||||
private readonly ctx: BuildStepGlobalContext,
|
||||
@@ -321,7 +332,15 @@ export class BuildStepContext {
|
||||
}
|
||||
) {
|
||||
this.logger = logger ?? ctx.baseLogger;
|
||||
this.relativeWorkingDirectory = relativeWorkingDirectory;
|
||||
this._relativeWorkingDirectory = relativeWorkingDirectory;
|
||||
}
|
||||
|
||||
public get relativeWorkingDirectory(): string | undefined {
|
||||
return this._relativeWorkingDirectory;
|
||||
}
|
||||
|
||||
public updateRelativeWorkingDirectory(value: string | undefined): void {
|
||||
this._relativeWorkingDirectory = value;
|
||||
}
|
||||
|
||||
public get global(): BuildStepGlobalContext {
|
||||
|
||||
@@ -88,8 +88,10 @@ export class BuildStepInput<
|
||||
|
||||
public getValue({
|
||||
interpolationContext,
|
||||
skipLegacyOutputInterpolation = false,
|
||||
}: {
|
||||
interpolationContext: JobInterpolationContext;
|
||||
skipLegacyOutputInterpolation?: boolean;
|
||||
}): R extends true ? BuildStepInputValueType<T> : BuildStepInputValueType<T> | undefined {
|
||||
const rawValue = this._value ?? this.defaultValue;
|
||||
if (this.required && rawValue === undefined) {
|
||||
@@ -124,10 +126,13 @@ export class BuildStepInput<
|
||||
// so this will never be true.
|
||||
assert(interpolatedValue !== undefined);
|
||||
const valueInterpolatedWithGlobalContext = this.ctx.interpolate(interpolatedValue);
|
||||
const valueInterpolatedWithOutputsAndGlobalContext = interpolateWithOutputs(
|
||||
valueInterpolatedWithGlobalContext,
|
||||
path => this.ctx.getStepOutputValue(path) ?? ''
|
||||
);
|
||||
// Composite functions support only `${{ }}`; legacy `${ steps.* }` stays literal in composite-function-scoped inputs.
|
||||
const valueInterpolatedWithOutputsAndGlobalContext = skipLegacyOutputInterpolation
|
||||
? valueInterpolatedWithGlobalContext
|
||||
: interpolateWithOutputs(
|
||||
valueInterpolatedWithGlobalContext,
|
||||
path => this.ctx.getStepOutputValue(path) ?? ''
|
||||
);
|
||||
returnValue = this.parseInputValueToAllowedType(valueInterpolatedWithOutputsAndGlobalContext);
|
||||
}
|
||||
return returnValue;
|
||||
@@ -231,6 +236,25 @@ export class BuildStepInput<
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw allowed-values check; skips step/context refs that only resolve at runtime. */
|
||||
export function getDisallowedInputValueError(
|
||||
input: BuildStepInput,
|
||||
stepDisplayName: string
|
||||
): string | undefined {
|
||||
if (input.rawValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (input.isRawValueStepOrContextReference() || input.isRawValueOneOfAllowedValues()) {
|
||||
return undefined;
|
||||
}
|
||||
const rendered =
|
||||
typeof input.rawValue === 'object' ? JSON.stringify(input.rawValue) : String(input.rawValue);
|
||||
const allowedValues = (input.allowedValues ?? [])
|
||||
.map(value => (typeof value === 'object' ? JSON.stringify(value) : `"${value}"`))
|
||||
.join(', ');
|
||||
return `Input parameter "${input.id}" for step "${stepDisplayName}" is set to "${rendered}" which is not one of the allowed values: ${allowedValues}.`;
|
||||
}
|
||||
|
||||
export function makeBuildStepInputByIdMap(inputs?: BuildStepInput[]): BuildStepInputById {
|
||||
if (inputs === undefined) {
|
||||
return {};
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from 'path';
|
||||
|
||||
import { BuildRuntimePlatform } from './BuildRuntimePlatform';
|
||||
import { BuildStep } from './BuildStep';
|
||||
import { BuildStepInputValueTypeName } from './BuildStepInput';
|
||||
import { BuildStepInputValueTypeName, getDisallowedInputValueError } from './BuildStepInput';
|
||||
// Type-only to keep the runtime module graph acyclic (BuildWorkflow imports
|
||||
// hooks.ts, which imports this module for the shared aggregate checks).
|
||||
import type { BuildWorkflow } from './BuildWorkflow';
|
||||
@@ -128,17 +128,12 @@ function validateInputs(steps: readonly BuildStep[]): BuildConfigError[] {
|
||||
if (currentStepInput.defaultValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!currentStepInput.isRawValueOneOfAllowedValues()) {
|
||||
const error = new BuildConfigError(
|
||||
`Input parameter "${currentStepInput.id}" for step "${
|
||||
currentStep.displayName
|
||||
}" is set to "${
|
||||
currentStepInput.rawValue
|
||||
}" which is not one of the allowed values: ${nullthrows(currentStepInput.allowedValues)
|
||||
.map(i => `"${i}"`)
|
||||
.join(', ')}.`
|
||||
);
|
||||
errors.push(error);
|
||||
const disallowedValueError = getDisallowedInputValueError(
|
||||
currentStepInput,
|
||||
currentStep.displayName
|
||||
);
|
||||
if (disallowedValueError) {
|
||||
errors.push(new BuildConfigError(disallowedValueError));
|
||||
}
|
||||
const paths =
|
||||
typeof currentStepInput.defaultValue === 'string'
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { JobInterpolationContext } from '@expo/eas-build-job';
|
||||
|
||||
import { BuildStep, BuildStepFunction } from './BuildStep';
|
||||
import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope';
|
||||
import { BuildStepGlobalContext } from './BuildStepContext';
|
||||
import { BuildStepOutput } from './BuildStepOutput';
|
||||
import {
|
||||
resolveInterpolatedTarget,
|
||||
stringifyInterpolatedResult,
|
||||
} from './utils/compositeFunctionInterpolation';
|
||||
|
||||
/**
|
||||
* Parse-time node for one composite function call (`uses: ./...`).
|
||||
* Flattening contributes children (and this node only when it declares outputs);
|
||||
* when it runs, it resolves output templates onto the caller's step id.
|
||||
*/
|
||||
export class CompositeBuildStep extends BuildStep {
|
||||
public readonly children: BuildStep[];
|
||||
|
||||
constructor(
|
||||
ctx: BuildStepGlobalContext,
|
||||
{
|
||||
id,
|
||||
displayName,
|
||||
scope,
|
||||
children,
|
||||
outputTemplates,
|
||||
}: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
scope: BuildStepCompositeFunctionScope;
|
||||
children: BuildStep[];
|
||||
outputTemplates: Array<{ name: string; template: string }>;
|
||||
}
|
||||
) {
|
||||
const outputs = outputTemplates.map(
|
||||
({ name }) =>
|
||||
new BuildStepOutput(ctx, {
|
||||
id: name,
|
||||
stepDisplayName: displayName,
|
||||
required: true,
|
||||
})
|
||||
);
|
||||
// Closes over locals only, never `this`: created before super() runs.
|
||||
const fn: BuildStepFunction = (stepCtx, { outputs: outputById, env: stepEnv }) => {
|
||||
const base: JobInterpolationContext = {
|
||||
...stepCtx.global.getInterpolationContext(),
|
||||
env: stepEnv,
|
||||
};
|
||||
const scopedContext = scope.getScopedInterpolationContext(base);
|
||||
for (const { name, template } of outputTemplates) {
|
||||
outputById[name].set(
|
||||
stringifyInterpolatedResult(resolveInterpolatedTarget(template, scopedContext))
|
||||
);
|
||||
}
|
||||
};
|
||||
super(ctx, {
|
||||
id,
|
||||
displayName,
|
||||
outputs,
|
||||
fn,
|
||||
// `scope` already applies the call-site `if`, so `always()` disables the default skip-on-failure.
|
||||
ifCondition: '${{ always() }}',
|
||||
compositeFunctionScope: scope,
|
||||
});
|
||||
this.children = children;
|
||||
}
|
||||
|
||||
// Safe during super(): outputById is assigned before registerSelf() runs.
|
||||
public get hasDeclaredOutputs(): boolean {
|
||||
return this.outputs.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nested calls stay hidden; a top-level call is public only when it declares outputs.
|
||||
* Consulted by registerSelf() in the base constructor; may only read state assigned
|
||||
* before that call (outputById, compositeFunctionScope), never fields like `children`.
|
||||
*/
|
||||
public override get isCompositeFunctionInternal(): boolean {
|
||||
return this.compositeFunctionScope?.parent !== undefined || !this.hasDeclaredOutputs;
|
||||
}
|
||||
|
||||
// Skip the registry when there are no outputs so we do not shadow a public step that reuses this id.
|
||||
protected override registerSelf(ctx: BuildStepGlobalContext): void {
|
||||
if (this.hasDeclaredOutputs) {
|
||||
super.registerSelf(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
public getFlattenedSteps(): BuildStep[] {
|
||||
const flattened = this.children.flatMap(child =>
|
||||
child instanceof CompositeBuildStep ? child.getFlattenedSteps() : [child]
|
||||
);
|
||||
return this.hasDeclaredOutputs ? [...flattened, this] : flattened;
|
||||
}
|
||||
|
||||
// Suppress the step-section log markers so log viewers do not render this bookkeeping node as a phantom step.
|
||||
protected override logStepStart(): void {}
|
||||
|
||||
protected override logStepSuccess(): void {}
|
||||
|
||||
protected override logStepSkipped(): void {}
|
||||
|
||||
// Open a step section on failure so error lines do not bleed into the previous step.
|
||||
protected override logStepFailed(error: Error): void {
|
||||
super.logStepStart();
|
||||
super.logStepFailed(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Expands local composite functions (`uses: ./path/to/function`) into a
|
||||
* {@link CompositeBuildStep} tree at parse time.
|
||||
*
|
||||
* Each call becomes a node with prefixed child ids (`caller__inner`); the parser
|
||||
* flattens the tree into the workflow. Expanded steps carry a
|
||||
* {@link BuildStepCompositeFunctionScope} so `${{ steps.* }}` and `${{ inputs.* }}`
|
||||
* resolve against composite-function-local names.
|
||||
*/
|
||||
import {
|
||||
CompositeFunctionCatalog,
|
||||
CompositeFunctionConfig,
|
||||
FunctionStep,
|
||||
ShellStep,
|
||||
Step,
|
||||
isStepFunctionStep,
|
||||
isStepShellStep,
|
||||
} from '@expo/eas-build-job';
|
||||
|
||||
import { BuildFunctionById } from './BuildFunction';
|
||||
import { BuildFunctionGroupById } from './BuildFunctionGroup';
|
||||
import { BuildStep, BuildStepOutputAccessor } from './BuildStep';
|
||||
import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope';
|
||||
import { BuildStepGlobalContext } from './BuildStepContext';
|
||||
import { BuildStepEnv } from './BuildStepEnv';
|
||||
import {
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
getDisallowedInputValueError,
|
||||
} from './BuildStepInput';
|
||||
import { CompositeBuildStep } from './CompositeBuildStep';
|
||||
import { BuildConfigError } from './errors';
|
||||
import { duplicates } from './utils/expodash/duplicates';
|
||||
import {
|
||||
getLocalCompositeFunctionCallWorkingDirectoryError,
|
||||
isLocalCompositeFunctionPath,
|
||||
parseLocalCompositeFunctionPath,
|
||||
} from './utils/localCompositeFunctions';
|
||||
import { createBuildStepOutputsFromDefinition, getShellStepDisplayName } from './utils/step';
|
||||
|
||||
const MAX_COMPOSITE_FUNCTION_NESTING_DEPTH = 10;
|
||||
|
||||
export type FunctionMaps = {
|
||||
buildFunctionById: BuildFunctionById;
|
||||
buildFunctionGroupById: BuildFunctionGroupById;
|
||||
};
|
||||
|
||||
type CompositeFunctionCall = {
|
||||
compositeFunctionPath: string;
|
||||
/** Caller-assigned id used as prefix for all inner step ids. */
|
||||
syntheticStepId: string;
|
||||
name?: string;
|
||||
/** Caller-provided input values, consumed when composite function inputs are interpolated. */
|
||||
callWith?: Record<string, unknown>;
|
||||
callIf?: string;
|
||||
parentScope?: BuildStepCompositeFunctionScope;
|
||||
inheritedEnv?: BuildStepEnv;
|
||||
};
|
||||
|
||||
type StepOverrides = {
|
||||
env?: BuildStepEnv;
|
||||
workingDirectory?: string;
|
||||
ifCondition?: string;
|
||||
};
|
||||
|
||||
export class CompositeFunctionExpander {
|
||||
constructor(
|
||||
private readonly ctx: BuildStepGlobalContext,
|
||||
private readonly compositeFunctionCatalog: CompositeFunctionCatalog,
|
||||
private readonly functionMaps: FunctionMaps
|
||||
) {}
|
||||
|
||||
public expandCompositeFunctionStep(
|
||||
step: FunctionStep,
|
||||
compositeFunctionPath: string,
|
||||
syntheticStepId: string
|
||||
): CompositeBuildStep {
|
||||
this.rejectCompositeFunctionCallWorkingDirectory(step);
|
||||
return this.expand(
|
||||
{
|
||||
compositeFunctionPath,
|
||||
syntheticStepId,
|
||||
name: step.name,
|
||||
callWith: step.with,
|
||||
callIf: step.if,
|
||||
inheritedEnv: step.env,
|
||||
},
|
||||
new Set<string>()
|
||||
);
|
||||
}
|
||||
|
||||
// The call step expands away; `working_directory` on it would never apply.
|
||||
private rejectCompositeFunctionCallWorkingDirectory(step: FunctionStep): void {
|
||||
if (step.working_directory !== undefined) {
|
||||
throw new BuildConfigError(getLocalCompositeFunctionCallWorkingDirectoryError(step.uses));
|
||||
}
|
||||
}
|
||||
|
||||
// `visited.size` is the current composite function nesting depth.
|
||||
private expand(call: CompositeFunctionCall, visited: ReadonlySet<string>): CompositeBuildStep {
|
||||
const { compositeFunctionPath, syntheticStepId } = call;
|
||||
this.guardAgainstRunawayRecursion(compositeFunctionPath, visited);
|
||||
|
||||
const compositeFunction = this.lookupCompositeFunction(compositeFunctionPath);
|
||||
const compositeFunctionDisplayName =
|
||||
call.name ?? compositeFunction.name ?? compositeFunctionPath;
|
||||
const innerSteps = compositeFunction.runs.steps;
|
||||
|
||||
const innerStepIds = this.buildInnerStepIdMap(
|
||||
innerSteps,
|
||||
syntheticStepId,
|
||||
compositeFunctionPath
|
||||
);
|
||||
const { inputs, providedInputKeys } = this.buildCompositeFunctionInputs(
|
||||
compositeFunction,
|
||||
compositeFunctionPath,
|
||||
call.callWith
|
||||
);
|
||||
|
||||
const nestedVisited = new Set(visited).add(compositeFunctionPath);
|
||||
|
||||
const childrenByLocalId = new Map<string, BuildStepOutputAccessor>();
|
||||
const scope = new BuildStepCompositeFunctionScope({
|
||||
ctx: this.ctx,
|
||||
parent: call.parentScope,
|
||||
ifCondition: call.callIf,
|
||||
env: call.inheritedEnv,
|
||||
compositeFunctionPath,
|
||||
inputs,
|
||||
providedInputKeys,
|
||||
childrenByLocalId,
|
||||
});
|
||||
|
||||
const children = innerSteps.map((innerStep, index) => {
|
||||
const { localId, newId } = innerStepIds[index];
|
||||
const child = this.expandInnerStep(innerStep, newId, {
|
||||
compositeFunctionPath,
|
||||
scope,
|
||||
nestedVisited,
|
||||
});
|
||||
// Omit output-less nested nodes: `${{ steps.mid }}` is undefined today,
|
||||
// and `{ outputs: {} }` would make it truthy.
|
||||
if (!(child instanceof CompositeBuildStep) || child.hasDeclaredOutputs) {
|
||||
childrenByLocalId.set(localId, child);
|
||||
}
|
||||
return child;
|
||||
});
|
||||
|
||||
const outputTemplates = Object.entries(compositeFunction.outputs ?? {}).map(
|
||||
([name, output]) => ({ name, template: output.value })
|
||||
);
|
||||
|
||||
return new CompositeBuildStep(this.ctx, {
|
||||
id: syntheticStepId,
|
||||
displayName: compositeFunctionDisplayName,
|
||||
scope,
|
||||
children,
|
||||
outputTemplates,
|
||||
});
|
||||
}
|
||||
|
||||
private guardAgainstRunawayRecursion(
|
||||
compositeFunctionPath: string,
|
||||
visited: ReadonlySet<string>
|
||||
): void {
|
||||
if (visited.has(compositeFunctionPath)) {
|
||||
const cyclePath = [...visited, compositeFunctionPath].join(' -> ');
|
||||
throw new BuildConfigError(
|
||||
`Detected a cycle while expanding composite functions: ${cyclePath}. A composite function cannot reference itself, directly or indirectly.`
|
||||
);
|
||||
}
|
||||
if (visited.size >= MAX_COMPOSITE_FUNCTION_NESTING_DEPTH) {
|
||||
throw new BuildConfigError(
|
||||
`Maximum composite function nesting depth (${MAX_COMPOSITE_FUNCTION_NESTING_DEPTH}) exceeded while expanding composite function "${compositeFunctionPath}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private lookupCompositeFunction(compositeFunctionPath: string): CompositeFunctionConfig {
|
||||
const compositeFunction = this.compositeFunctionCatalog[compositeFunctionPath];
|
||||
if (!compositeFunction) {
|
||||
throw new BuildConfigError(
|
||||
`Local composite function "${compositeFunctionPath}" does not exist. Expected a "function.yml" (or "function.yaml") file at "${compositeFunctionPath}" relative to the EAS project root (convention: ".eas/functions/<name>").`
|
||||
);
|
||||
}
|
||||
return compositeFunction;
|
||||
}
|
||||
|
||||
private expandInnerStep(
|
||||
innerStep: Step,
|
||||
newId: string,
|
||||
{
|
||||
compositeFunctionPath,
|
||||
scope,
|
||||
nestedVisited,
|
||||
}: {
|
||||
compositeFunctionPath: string;
|
||||
scope: BuildStepCompositeFunctionScope;
|
||||
nestedVisited: ReadonlySet<string>;
|
||||
}
|
||||
): BuildStep {
|
||||
const overrides = this.resolveStepOverrides(innerStep);
|
||||
|
||||
if (isStepFunctionStep(innerStep)) {
|
||||
if (isLocalCompositeFunctionPath(innerStep.uses)) {
|
||||
this.rejectCompositeFunctionCallWorkingDirectory(innerStep);
|
||||
return this.expandNestedCompositeFunctionCall(innerStep, {
|
||||
compositeFunctionPath: parseLocalCompositeFunctionPath(innerStep.uses),
|
||||
newId,
|
||||
overrides,
|
||||
scope,
|
||||
visited: nestedVisited,
|
||||
});
|
||||
}
|
||||
return this.createExpandedFunctionStep(innerStep, {
|
||||
newId,
|
||||
overrides,
|
||||
scope,
|
||||
compositeFunctionPath,
|
||||
});
|
||||
}
|
||||
if (isStepShellStep(innerStep)) {
|
||||
return this.createExpandedShellStep(innerStep, { newId, overrides, scope });
|
||||
}
|
||||
throw new BuildConfigError(
|
||||
`Invalid step configuration in composite function "${compositeFunctionPath}". Step must be a shell or function step.`
|
||||
);
|
||||
}
|
||||
|
||||
private resolveStepOverrides(innerStep: Step): StepOverrides {
|
||||
return {
|
||||
env: innerStep.env,
|
||||
ifCondition: innerStep.if,
|
||||
workingDirectory: innerStep.working_directory,
|
||||
};
|
||||
}
|
||||
|
||||
private expandNestedCompositeFunctionCall(
|
||||
innerStep: FunctionStep,
|
||||
{
|
||||
compositeFunctionPath,
|
||||
newId,
|
||||
overrides,
|
||||
scope,
|
||||
visited,
|
||||
}: {
|
||||
compositeFunctionPath: string;
|
||||
newId: string;
|
||||
overrides: StepOverrides;
|
||||
scope: BuildStepCompositeFunctionScope;
|
||||
visited: ReadonlySet<string>;
|
||||
}
|
||||
): CompositeBuildStep {
|
||||
return this.expand(
|
||||
{
|
||||
compositeFunctionPath,
|
||||
syntheticStepId: newId,
|
||||
name: innerStep.name,
|
||||
callWith: innerStep.with,
|
||||
callIf: overrides.ifCondition,
|
||||
parentScope: scope,
|
||||
inheritedEnv: overrides.env,
|
||||
},
|
||||
visited
|
||||
);
|
||||
}
|
||||
|
||||
private createExpandedShellStep(
|
||||
step: ShellStep,
|
||||
{
|
||||
newId,
|
||||
overrides,
|
||||
scope,
|
||||
}: {
|
||||
newId: string;
|
||||
overrides: StepOverrides;
|
||||
scope: BuildStepCompositeFunctionScope;
|
||||
}
|
||||
): BuildStep {
|
||||
const command = step.run;
|
||||
const displayName = getShellStepDisplayName(step);
|
||||
const outputs =
|
||||
step.outputs && createBuildStepOutputsFromDefinition(this.ctx, step.outputs, displayName);
|
||||
return new BuildStep(this.ctx, {
|
||||
id: newId,
|
||||
displayName,
|
||||
outputs,
|
||||
workingDirectory: overrides.workingDirectory,
|
||||
shell: step.shell,
|
||||
command,
|
||||
env: overrides.env,
|
||||
ifCondition: overrides.ifCondition,
|
||||
compositeFunctionScope: scope,
|
||||
__metricsId: step.__metrics_id,
|
||||
});
|
||||
}
|
||||
|
||||
private createExpandedFunctionStep(
|
||||
step: FunctionStep,
|
||||
{
|
||||
newId,
|
||||
overrides,
|
||||
scope,
|
||||
compositeFunctionPath,
|
||||
}: {
|
||||
newId: string;
|
||||
overrides: StepOverrides;
|
||||
scope: BuildStepCompositeFunctionScope;
|
||||
compositeFunctionPath: string;
|
||||
}
|
||||
): BuildStep {
|
||||
const functionId = step.uses;
|
||||
const { buildFunctionById, buildFunctionGroupById } = this.functionMaps;
|
||||
const maybeFunctionGroup = buildFunctionGroupById[functionId];
|
||||
if (maybeFunctionGroup) {
|
||||
throw new BuildConfigError(
|
||||
`Function group "${functionId}" cannot be used inside a composite function. Function groups expand to multiple steps with their own ids and do not support composite-function-level id, env, if, or working_directory overrides. Use individual function steps instead.`
|
||||
);
|
||||
}
|
||||
|
||||
const buildFunction = buildFunctionById[functionId];
|
||||
if (!buildFunction) {
|
||||
throw new BuildConfigError(
|
||||
`Composite function "${compositeFunctionPath}" calls non-existent function "${functionId}".`
|
||||
);
|
||||
}
|
||||
|
||||
const callInputs = step.with as Record<string, unknown> | undefined;
|
||||
|
||||
return buildFunction.createBuildStepFromFunctionCall(this.ctx, {
|
||||
id: newId,
|
||||
name: step.name,
|
||||
callInputs,
|
||||
workingDirectory: overrides.workingDirectory,
|
||||
shell: step.shell,
|
||||
env: overrides.env,
|
||||
ifCondition: overrides.ifCondition,
|
||||
compositeFunctionScope: scope,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns globally unique ids to inner steps. Without namespacing, two
|
||||
* composite function calls with inner step `read` would collide.
|
||||
*/
|
||||
private buildInnerStepIdMap(
|
||||
innerSteps: Step[],
|
||||
syntheticStepId: string,
|
||||
compositeFunctionPath: string
|
||||
): Array<{ localId: string; newId: string }> {
|
||||
const declaredIdList = innerSteps.map(step => step.id).filter((id): id is string => !!id);
|
||||
const duplicatedIds = duplicates(declaredIdList);
|
||||
if (duplicatedIds.length > 0) {
|
||||
throw new BuildConfigError(
|
||||
`Composite function "${compositeFunctionPath}" declares duplicated step IDs: ${duplicatedIds
|
||||
.map(id => `"${id}"`)
|
||||
.join(', ')}. Step IDs within a composite function must be unique.`
|
||||
);
|
||||
}
|
||||
const declaredIds = new Set(declaredIdList);
|
||||
let generatedStepIdCounter = 0;
|
||||
return innerSteps.map(innerStep => {
|
||||
let localId = innerStep.id;
|
||||
if (!localId) {
|
||||
do {
|
||||
localId = `composite_function_step_${++generatedStepIdCounter}`;
|
||||
} while (declaredIds.has(localId));
|
||||
}
|
||||
return { localId, newId: `${syntheticStepId}__${localId}` };
|
||||
});
|
||||
}
|
||||
|
||||
// Nullish `with` values are treated as absent so defaults resolve in the composite function's own scope.
|
||||
private buildCompositeFunctionInputs(
|
||||
compositeFunction: CompositeFunctionConfig,
|
||||
compositeFunctionPath: string,
|
||||
callWith: Record<string, unknown> | undefined
|
||||
): { inputs: Map<string, BuildStepInput>; providedInputKeys: Set<string> } {
|
||||
const inputs = new Map<string, BuildStepInput>();
|
||||
const providedInputKeys = new Set<string>();
|
||||
for (const declared of compositeFunction.inputs ?? []) {
|
||||
const definition =
|
||||
typeof declared === 'string'
|
||||
? {
|
||||
name: declared,
|
||||
type: 'string',
|
||||
required: false,
|
||||
allowedValues: undefined as unknown[] | undefined,
|
||||
defaultValue: undefined as unknown,
|
||||
}
|
||||
: {
|
||||
name: declared.name,
|
||||
type: declared.type,
|
||||
required: declared.required ?? false,
|
||||
allowedValues: declared.allowed_values,
|
||||
defaultValue: declared.default_value,
|
||||
};
|
||||
const input = new BuildStepInput(this.ctx, {
|
||||
id: definition.name,
|
||||
stepDisplayName: compositeFunctionPath,
|
||||
defaultValue: definition.defaultValue,
|
||||
required: definition.required,
|
||||
allowedValues: definition.allowedValues,
|
||||
allowedValueTypeName: this.toInputValueTypeName(
|
||||
definition.type,
|
||||
compositeFunctionPath,
|
||||
definition.name
|
||||
),
|
||||
});
|
||||
if (callWith && Object.prototype.hasOwnProperty.call(callWith, definition.name)) {
|
||||
const value = callWith[definition.name];
|
||||
if (value !== undefined) {
|
||||
input.set(value);
|
||||
}
|
||||
if (value != null) {
|
||||
providedInputKeys.add(definition.name);
|
||||
}
|
||||
}
|
||||
if (definition.required && input.rawValue === undefined) {
|
||||
throw new BuildConfigError(
|
||||
`Input parameter "${definition.name}" for step "${compositeFunctionPath}" is required but it was not set.`
|
||||
);
|
||||
}
|
||||
const disallowedValueError = getDisallowedInputValueError(input, compositeFunctionPath);
|
||||
if (disallowedValueError) {
|
||||
throw new BuildConfigError(disallowedValueError);
|
||||
}
|
||||
inputs.set(definition.name, input);
|
||||
}
|
||||
return { inputs, providedInputKeys };
|
||||
}
|
||||
|
||||
private toInputValueTypeName(
|
||||
type: string,
|
||||
compositeFunctionPath: string,
|
||||
inputName: string
|
||||
): BuildStepInputValueTypeName {
|
||||
const supported = Object.values(BuildStepInputValueTypeName) as string[];
|
||||
if (!supported.includes(type)) {
|
||||
throw new BuildConfigError(
|
||||
`Composite function "${compositeFunctionPath}" input "${inputName}" has unsupported type "${type}". Supported types: ${supported.join(
|
||||
', '
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
return type as BuildStepInputValueTypeName;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CompositeFunctionCatalog,
|
||||
FunctionStep,
|
||||
HookAnchorId,
|
||||
Hooks,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
import assert from 'node:assert';
|
||||
|
||||
import { AbstractConfigParser } from './AbstractConfigParser';
|
||||
import { CompositeFunctionExpander, FunctionMaps } from './CompositeFunctionExpander';
|
||||
import { BuildFunction, BuildFunctionById, createBuildFunctionByIdMapping } from './BuildFunction';
|
||||
import {
|
||||
BuildFunctionGroup,
|
||||
@@ -28,10 +30,16 @@ import {
|
||||
createBuildStepFromShellStep,
|
||||
validateAllStepFunctionsExist,
|
||||
} from './hooks';
|
||||
import {
|
||||
isLocalCompositeFunctionPath,
|
||||
parseLocalCompositeFunctionPath,
|
||||
} from './utils/localCompositeFunctions';
|
||||
|
||||
export class StepsConfigParser extends AbstractConfigParser {
|
||||
private readonly steps: Step[];
|
||||
private readonly hooks: Hooks;
|
||||
/** Pre-loaded composite function configs keyed by normalized path (e.g. `./.eas/functions/setup`). */
|
||||
private readonly compositeFunctionCatalog: CompositeFunctionCatalog;
|
||||
|
||||
constructor(
|
||||
ctx: BuildStepGlobalContext,
|
||||
@@ -40,6 +48,7 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
hooks,
|
||||
externalFunctions,
|
||||
externalFunctionGroups,
|
||||
compositeFunctionCatalog,
|
||||
}: {
|
||||
steps: Step[];
|
||||
// Required (not `hooks?:`) so a call site cannot silently forget to pass
|
||||
@@ -47,6 +56,7 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
hooks: Hooks | undefined;
|
||||
externalFunctions?: BuildFunction[];
|
||||
externalFunctionGroups?: BuildFunctionGroup[];
|
||||
compositeFunctionCatalog?: CompositeFunctionCatalog;
|
||||
}
|
||||
) {
|
||||
super(ctx, {
|
||||
@@ -56,6 +66,7 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
|
||||
this.steps = steps;
|
||||
this.hooks = hooks ?? {};
|
||||
this.compositeFunctionCatalog = compositeFunctionCatalog ?? {};
|
||||
}
|
||||
|
||||
protected async parseConfigToBuildStepsAndBuildFunctionByIdMappingAsync(): Promise<{
|
||||
@@ -76,6 +87,14 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
const buildFunctionGroupById = createBuildFunctionGroupByIdMapping(
|
||||
this.externalFunctionGroups ?? []
|
||||
);
|
||||
const compositeFunctionExpander = new CompositeFunctionExpander(
|
||||
this.ctx,
|
||||
this.compositeFunctionCatalog,
|
||||
{
|
||||
buildFunctionById,
|
||||
buildFunctionGroupById,
|
||||
}
|
||||
);
|
||||
|
||||
// Only the job's own steps are scanned — steps constructed from hooks are
|
||||
// never treated as anchors (no nesting). Construction order (before →
|
||||
@@ -85,9 +104,10 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
const hooksByAnchorStep = new Map<BuildStep, AnchorHooks>();
|
||||
|
||||
for (const stepConfig of validatedSteps) {
|
||||
const maybeFunctionGroup = isStepFunctionStep(stepConfig)
|
||||
? buildFunctionGroupById[stepConfig.uses]
|
||||
: undefined;
|
||||
const maybeFunctionGroup =
|
||||
isStepFunctionStep(stepConfig) && !isLocalCompositeFunctionPath(stepConfig.uses)
|
||||
? buildFunctionGroupById[stepConfig.uses]
|
||||
: undefined;
|
||||
if (maybeFunctionGroup !== undefined) {
|
||||
// The group expands FIRST (its internal steps get their ids), then the
|
||||
// anchors found among expanded steps get their hook steps constructed.
|
||||
@@ -113,14 +133,36 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
continue;
|
||||
}
|
||||
|
||||
const maps = { buildFunctionById, buildFunctionGroupById };
|
||||
const anchorId = StepsConfigParser.resolveStepAnchor(stepConfig, buildFunctionById);
|
||||
if (anchorId === undefined) {
|
||||
buildSteps.push(this.createBuildStepFromNonGroupStepConfig(stepConfig, buildFunctionById));
|
||||
buildSteps.push(
|
||||
...this.createBuildStepsFromNonGroupStepConfig(
|
||||
stepConfig,
|
||||
maps,
|
||||
compositeFunctionExpander
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const maps = { buildFunctionById, buildFunctionGroupById };
|
||||
// Rejected regardless of expansion size: the anchor would land on an
|
||||
// expanded inner step, and hooks never fire inside a composite function.
|
||||
if (isStepFunctionStep(stepConfig) && isLocalCompositeFunctionPath(stepConfig.uses)) {
|
||||
throw new BuildConfigError(
|
||||
'Hook anchors are not supported on local composite function steps.'
|
||||
);
|
||||
}
|
||||
const before = this.constructHookSideEntries(anchorId, 'before', validatedHooks, maps);
|
||||
const anchorStep = this.createBuildStepFromNonGroupStepConfig(stepConfig, buildFunctionById);
|
||||
const createdSteps = this.createBuildStepsFromNonGroupStepConfig(
|
||||
stepConfig,
|
||||
maps,
|
||||
compositeFunctionExpander
|
||||
);
|
||||
assert(
|
||||
createdSteps.length === 1,
|
||||
'a non-composite step config must create exactly one build step'
|
||||
);
|
||||
const anchorStep = createdSteps[0];
|
||||
buildSteps.push(anchorStep);
|
||||
const after = this.constructHookSideEntries(anchorId, 'after', validatedHooks, maps);
|
||||
if (before.length > 0 || after.length > 0) {
|
||||
@@ -214,36 +256,54 @@ export class StepsConfigParser extends AbstractConfigParser {
|
||||
return constructHookEntriesFromValidatedSteps(this.ctx, hookSteps, maps);
|
||||
}
|
||||
|
||||
private createBuildStepFromNonGroupStepConfig(
|
||||
private createBuildStepsFromNonGroupStepConfig(
|
||||
stepConfig: Step,
|
||||
buildFunctionById: BuildFunctionById
|
||||
): BuildStep {
|
||||
maps: FunctionMaps,
|
||||
compositeFunctionExpander: CompositeFunctionExpander
|
||||
): BuildStep[] {
|
||||
if (isStepShellStep(stepConfig)) {
|
||||
return createBuildStepFromShellStep(this.ctx, stepConfig);
|
||||
} else if (isStepFunctionStep(stepConfig)) {
|
||||
return this.createBuildStepFromFunctionStepConfig(stepConfig, buildFunctionById);
|
||||
} else {
|
||||
throw new BuildConfigError(
|
||||
'Invalid job step configuration detected. Step must be shell or function step'
|
||||
return [createBuildStepFromShellStep(this.ctx, stepConfig)];
|
||||
}
|
||||
if (isStepFunctionStep(stepConfig)) {
|
||||
return this.createBuildStepsFromFunctionStepConfig(
|
||||
stepConfig,
|
||||
maps,
|
||||
compositeFunctionExpander
|
||||
);
|
||||
}
|
||||
throw new BuildConfigError(
|
||||
'Invalid job step configuration detected. Step must be shell or function step'
|
||||
);
|
||||
}
|
||||
|
||||
private createBuildStepFromFunctionStepConfig(
|
||||
private createBuildStepsFromFunctionStepConfig(
|
||||
step: FunctionStep,
|
||||
buildFunctionById: BuildFunctionById
|
||||
): BuildStep {
|
||||
{ buildFunctionById }: FunctionMaps,
|
||||
compositeFunctionExpander: CompositeFunctionExpander
|
||||
): BuildStep[] {
|
||||
if (isLocalCompositeFunctionPath(step.uses)) {
|
||||
return compositeFunctionExpander
|
||||
.expandCompositeFunctionStep(
|
||||
step,
|
||||
parseLocalCompositeFunctionPath(step.uses),
|
||||
BuildStep.getNewId(step.id)
|
||||
)
|
||||
.getFlattenedSteps();
|
||||
}
|
||||
|
||||
const buildFunction = buildFunctionById[step.uses];
|
||||
assert(buildFunction, 'function ID must be ID of function or function group');
|
||||
|
||||
return buildFunction.createBuildStepFromFunctionCall(this.ctx, {
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
callInputs: step.with,
|
||||
workingDirectory: step.working_directory,
|
||||
shell: step.shell,
|
||||
env: step.env,
|
||||
ifCondition: step.if,
|
||||
});
|
||||
return [
|
||||
buildFunction.createBuildStepFromFunctionCall(this.ctx, {
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
callInputs: step.with,
|
||||
workingDirectory: step.working_directory,
|
||||
shell: step.shell,
|
||||
env: step.env,
|
||||
ifCondition: step.if,
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1042,6 +1042,40 @@ describe(validateAllFunctionsExist, () => {
|
||||
validateAllFunctionsExist(buildConfig, { externalFunctionIds: [] });
|
||||
}).toThrowError(/Calling non-existent functions: "eas\/build"/);
|
||||
});
|
||||
test('rejects local composite function references with a dedicated error', () => {
|
||||
const buildConfig: BuildConfig = {
|
||||
build: {
|
||||
steps: ['./.eas/functions/my-function'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
validateAllFunctionsExist(buildConfig, { externalFunctionIds: [] });
|
||||
}).toThrowError(
|
||||
/Local composite functions \("\.\/\.eas\/functions\/my-function"\) are not supported in "\.eas\/build\/\*\.yml" custom builds\. Local composite functions can only be used in EAS workflows \("\.eas\/workflows\/\*\.yml"\)\./
|
||||
);
|
||||
});
|
||||
test('rejects object-form local composite function references with a dedicated error', () => {
|
||||
const buildConfig: BuildConfig = {
|
||||
build: {
|
||||
steps: [
|
||||
{
|
||||
'./.eas/functions/my-function': {
|
||||
inputs: {
|
||||
foo: 'bar',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
validateAllFunctionsExist(buildConfig, { externalFunctionIds: [] });
|
||||
}).toThrow(
|
||||
/Local composite functions \("\.\/\.eas\/functions\/my-function"\) are not supported in "\.eas\/build\/\*\.yml" custom builds\. Local composite functions can only be used in EAS workflows \("\.eas\/workflows\/\*\.yml"\)\./
|
||||
);
|
||||
});
|
||||
test('non-existent namespaced functions with skipNamespacedFunctionsOrFunctionGroupsCheck = false', () => {
|
||||
const buildConfig: BuildConfig = {
|
||||
build: {
|
||||
|
||||
@@ -10,10 +10,12 @@ import { GENERATED_STEP_ID_REGEX } from './utils/stepId';
|
||||
import { BuildFunction } from '../BuildFunction';
|
||||
import { BuildRuntimePlatform } from '../BuildRuntimePlatform';
|
||||
import { BuildStep, BuildStepFunction, BuildStepStatus } from '../BuildStep';
|
||||
import { BuildStepCompositeFunctionScope } from '../BuildStepCompositeFunctionScope';
|
||||
import { BuildStepContext, BuildStepGlobalContext } from '../BuildStepContext';
|
||||
import { BuildStepEnv } from '../BuildStepEnv';
|
||||
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
|
||||
import { BuildStepOutput } from '../BuildStepOutput';
|
||||
import { BuildWorkflow } from '../BuildWorkflow';
|
||||
import { BuildStepRuntimeError } from '../errors';
|
||||
import { nullthrows } from '../utils/nullthrows';
|
||||
import { spawnAsync } from '../utils/shell/spawn';
|
||||
@@ -630,6 +632,46 @@ describe(BuildStep, () => {
|
||||
expect(error).toBeInstanceOf(BuildStepRuntimeError);
|
||||
expect(error.message).toMatch(/Some required outputs have not been set: "abc"/);
|
||||
});
|
||||
|
||||
it('skips a later default-gated inner step when a required output is missing on an earlier one', async () => {
|
||||
const compositeFunctionScope = new BuildStepCompositeFunctionScope({
|
||||
ctx: baseStepCtx,
|
||||
compositeFunctionPath: 'test-action',
|
||||
inputs: new Map(),
|
||||
providedInputKeys: new Set(),
|
||||
childrenByLocalId: new Map(),
|
||||
});
|
||||
|
||||
const firstStep = new BuildStep(baseStepCtx, {
|
||||
id: 'first',
|
||||
displayName: 'first',
|
||||
command: 'echo 123',
|
||||
outputs: [
|
||||
new BuildStepOutput(baseStepCtx, {
|
||||
id: 'abc',
|
||||
stepDisplayName: 'first',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
compositeFunctionScope,
|
||||
});
|
||||
|
||||
const secondStep = new BuildStep(baseStepCtx, {
|
||||
id: 'second',
|
||||
displayName: 'second',
|
||||
command: 'echo 456',
|
||||
compositeFunctionScope,
|
||||
});
|
||||
|
||||
// Via workflow so recordFailure marks global status; second step then skips.
|
||||
const workflow = new BuildWorkflow(baseStepCtx, {
|
||||
buildSteps: [firstStep, secondStep],
|
||||
buildFunctions: {},
|
||||
});
|
||||
const error = await getErrorAsync<BuildStepRuntimeError>(() => workflow.executeAsync());
|
||||
expect(error.message).toMatch(/Some required outputs have not been set: "abc"/);
|
||||
expect(secondStep.status).toBe(BuildStepStatus.SKIPPED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fn', () => {
|
||||
@@ -1120,6 +1162,26 @@ describe(BuildStep.deserialize, () => {
|
||||
});
|
||||
|
||||
describe(BuildStep.prototype.shouldExecuteStep, () => {
|
||||
it('does not evaluate inputs for a step without an if condition after a failure', () => {
|
||||
const ctx = createGlobalContextMock();
|
||||
ctx.markAsFailed();
|
||||
const step = new BuildStep(ctx, {
|
||||
id: 'test1',
|
||||
displayName: 'Test 1',
|
||||
command: 'echo 123',
|
||||
inputs: [
|
||||
new BuildStepInput(ctx, {
|
||||
id: 'required',
|
||||
stepDisplayName: 'Test 1',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(step.shouldExecuteStep()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when if condition is always and previous steps failed', () => {
|
||||
const ctx = createGlobalContextMock();
|
||||
ctx.markAsFailed();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { JobInterpolationContext } from '@expo/eas-build-job';
|
||||
import { instance, mock, when } from 'ts-mockito';
|
||||
|
||||
import { createGlobalContextMock } from './utils/context';
|
||||
import { BuildStep } from '../BuildStep';
|
||||
import { BuildStepCompositeFunctionScope } from '../BuildStepCompositeFunctionScope';
|
||||
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
|
||||
import { BuildStepOutput } from '../BuildStepOutput';
|
||||
import { interpolateJobContext } from '../interpolation';
|
||||
|
||||
describe(BuildStepCompositeFunctionScope, () => {
|
||||
const baseContext = {} as unknown as JobInterpolationContext;
|
||||
|
||||
function makeScope(): BuildStepCompositeFunctionScope {
|
||||
const ctx = createGlobalContextMock();
|
||||
|
||||
const versionOutput = mock<BuildStepOutput>();
|
||||
when(versionOutput.id).thenReturn('version');
|
||||
when(versionOutput.rawValue).thenReturn('1.0.0');
|
||||
const innerStep = mock<BuildStep>();
|
||||
when(innerStep.outputs).thenReturn([instance(versionOutput)]);
|
||||
|
||||
const greeting = new BuildStepInput(ctx, {
|
||||
id: 'greeting',
|
||||
stepDisplayName: 'test-action',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
});
|
||||
greeting.set('hello');
|
||||
return new BuildStepCompositeFunctionScope({
|
||||
ctx,
|
||||
compositeFunctionPath: 'test-action',
|
||||
inputs: new Map([['greeting', greeting]]),
|
||||
providedInputKeys: new Set(['greeting']),
|
||||
childrenByLocalId: new Map([['build', instance(innerStep)]]),
|
||||
});
|
||||
}
|
||||
|
||||
it('exposes declared inputs and composite-function-local step aliases in the interpolation context', () => {
|
||||
const scope = makeScope();
|
||||
const context = scope.getScopedInterpolationContext(baseContext);
|
||||
expect(interpolateJobContext({ target: '${{ inputs.greeting }}', context })).toBe('hello');
|
||||
expect(interpolateJobContext({ target: '${{ steps.build.outputs.version }}', context })).toBe(
|
||||
'1.0.0'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for references outside the composite function scope', () => {
|
||||
const scope = makeScope();
|
||||
const context = scope.getScopedInterpolationContext(baseContext);
|
||||
expect(interpolateJobContext({ target: '${{ inputs.gretting }}', context })).toBeUndefined();
|
||||
expect(
|
||||
interpolateJobContext({ target: '${{ steps.checkout.outputs.sha }}', context })
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -172,6 +172,7 @@ describe(BuildStepGlobalContext, () => {
|
||||
|
||||
const mockStep = mock<BuildStep>();
|
||||
when(mockStep.id).thenReturn('abc');
|
||||
when(mockStep.isCompositeFunctionInternal).thenReturn(false);
|
||||
when(mockStep.getOutputValueByName('def')).thenReturn('ghi');
|
||||
const step = instance(mockStep);
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import assert from 'node:assert';
|
||||
|
||||
import {
|
||||
SETUP,
|
||||
echoFunction,
|
||||
parseCompositeFunctions,
|
||||
} from './StepsConfigParser-composite-functions-test-utils';
|
||||
import { getErrorAsync } from './utils/error';
|
||||
import { BuildFunctionGroup } from '../BuildFunctionGroup';
|
||||
import { BuildConfigError, BuildWorkflowError } from '../errors';
|
||||
|
||||
describe('StepsConfigParser local composite functions', () => {
|
||||
describe('expansion', () => {
|
||||
it('expands a single-level composite function and leaves input templates raw', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
name: 'Setup',
|
||||
inputs: [{ name: 'greeting', type: 'string', default_value: 'hello' }],
|
||||
runs: {
|
||||
steps: [
|
||||
{ id: 'read', run: 'set-output version "1.0.0"' },
|
||||
{ run: 'echo "${{ inputs.greeting }}"' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup', with: { greeting: 'hi' } }],
|
||||
});
|
||||
|
||||
expect(workflow.buildSteps).toHaveLength(2);
|
||||
|
||||
const [readStep, echoStep] = workflow.buildSteps;
|
||||
expect(readStep.id).toBe('setup__read');
|
||||
expect(readStep.displayName).toBe('read');
|
||||
expect(echoStep.id).toBe('setup__composite_function_step_1');
|
||||
expect(echoStep.command).toBe('echo "${{ inputs.greeting }}"');
|
||||
});
|
||||
|
||||
it('generates a synthetic id when the caller step has no id', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
name: 'Setup',
|
||||
runs: {
|
||||
steps: [
|
||||
{ id: 'read', run: 'set-output version "1.0.0"' },
|
||||
{ id: 'echo', run: 'echo "${{ steps.read.outputs.version }}"' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP }],
|
||||
});
|
||||
|
||||
const [readStep, echoStep] = workflow.buildSteps;
|
||||
const syntheticStepId = readStep.id.split('__')[0];
|
||||
|
||||
expect(workflow.buildSteps).toHaveLength(2);
|
||||
expect(syntheticStepId).toMatch(/^step-\d{3,}$/);
|
||||
expect(readStep.id).toBe(`${syntheticStepId}__read`);
|
||||
expect(echoStep.command).toBe('echo "${{ steps.read.outputs.version }}"');
|
||||
});
|
||||
|
||||
it('keeps templated inner step names raw at parse time', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
inputs: [{ name: 'env', type: 'string', default_value: 'staging' }],
|
||||
runs: {
|
||||
steps: [{ name: 'Deploy ${{ inputs.env }}', run: 'echo deploy' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup', with: { env: 'production' } }],
|
||||
});
|
||||
|
||||
expect(workflow.buildSteps[0].displayName).toBe('Deploy ${{ inputs.env }}');
|
||||
});
|
||||
|
||||
it('derives the fallback display name of an unnamed inner shell step from the raw run', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
inputs: [{ name: 'target', type: 'string', default_value: 'build' }],
|
||||
runs: {
|
||||
steps: [{ run: '${{ inputs.target }} do-thing' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup', with: { target: 'release' } }],
|
||||
});
|
||||
|
||||
const [innerStep] = workflow.buildSteps;
|
||||
expect(innerStep.command).toBe('${{ inputs.target }} do-thing');
|
||||
expect(innerStep.displayName).toBe('${{ inputs.target }} do-thing');
|
||||
});
|
||||
|
||||
it('expands nested composites with accumulated prefixes', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
'./.eas/functions/outer': {
|
||||
runs: { steps: [{ uses: './.eas/functions/inner', id: 'mid' }] },
|
||||
},
|
||||
'./.eas/functions/inner': {
|
||||
runs: { steps: [{ id: 'leaf', run: 'echo leaf' }] },
|
||||
},
|
||||
},
|
||||
steps: [{ uses: './.eas/functions/outer', id: 'top' }],
|
||||
});
|
||||
expect(workflow.buildSteps.map(s => s.id)).toEqual(['top__mid__leaf']);
|
||||
});
|
||||
|
||||
it('expands a composite function defined at the project root ("uses: ./")', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
'./.': {
|
||||
name: 'Root function',
|
||||
runs: { steps: [{ id: 'root-step', run: 'echo root' }] },
|
||||
},
|
||||
},
|
||||
steps: [{ uses: './', id: 'root' }],
|
||||
});
|
||||
expect(workflow.buildSteps.map(s => s.id)).toEqual(['root__root-step']);
|
||||
});
|
||||
|
||||
it('expands a composite function defined at the project root parent ("uses: ../")', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
'..': {
|
||||
runs: { steps: [{ id: 'parent-step', run: 'echo parent' }] },
|
||||
},
|
||||
},
|
||||
steps: [{ uses: '../', id: 'parent' }],
|
||||
});
|
||||
expect(workflow.buildSteps.map(s => s.id)).toEqual(['parent__parent-step']);
|
||||
});
|
||||
|
||||
it('avoids collisions between generated inner step ids and declared inner step ids', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
runs: {
|
||||
steps: [
|
||||
{ run: 'echo first (no id)' },
|
||||
{ id: 'composite_function_step_1', run: 'echo second (declared)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup' }],
|
||||
});
|
||||
|
||||
expect(workflow.buildSteps.map(s => s.id)).toEqual([
|
||||
'setup__composite_function_step_2',
|
||||
'setup__composite_function_step_1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe('resolution errors', () => {
|
||||
it('throws a clear error for an unknown composite function', async () => {
|
||||
await expect(
|
||||
parseCompositeFunctions({
|
||||
steps: [{ uses: './.eas/functions/missing', id: 'x' }],
|
||||
})
|
||||
).rejects.toThrow(/Local composite function ".\/.eas\/functions\/missing"/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'direct self-reference',
|
||||
{
|
||||
'./.eas/functions/loop': {
|
||||
runs: { steps: [{ uses: './.eas/functions/loop', id: 'again' }] },
|
||||
},
|
||||
},
|
||||
[{ uses: './.eas/functions/loop', id: 'loop' }],
|
||||
],
|
||||
[
|
||||
'indirect reference',
|
||||
{
|
||||
'./.eas/functions/a': { runs: { steps: [{ uses: './.eas/functions/b', id: 'b' }] } },
|
||||
'./.eas/functions/b': { runs: { steps: [{ uses: './.eas/functions/a', id: 'a' }] } },
|
||||
},
|
||||
[{ uses: './.eas/functions/a', id: 'a' }],
|
||||
],
|
||||
])('detects %s cycles', async (_, catalog, steps) => {
|
||||
await expect(parseCompositeFunctions({ catalog, steps })).rejects.toThrow(/cycle/i);
|
||||
});
|
||||
|
||||
it('errors when a composite function declares duplicated inner step ids', async () => {
|
||||
const error = await getErrorAsync<BuildConfigError>(() =>
|
||||
parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
runs: {
|
||||
steps: [
|
||||
{ run: 'echo one', id: 'dup' },
|
||||
{ run: 'echo two', id: 'dup' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup' }],
|
||||
})
|
||||
);
|
||||
expect(error).toBeInstanceOf(BuildConfigError);
|
||||
expect(error.message).toBe(
|
||||
`Composite function "${SETUP}" declares duplicated step IDs: "dup". Step IDs within a composite function must be unique.`
|
||||
);
|
||||
});
|
||||
|
||||
it('errors when an inner step references a non-existent function', async () => {
|
||||
const error = await getErrorAsync<BuildConfigError>(() =>
|
||||
parseCompositeFunctions({
|
||||
catalog: {
|
||||
'./.eas/functions/wrap': {
|
||||
runs: { steps: [{ uses: 'eas/typo', id: 'echo' }] },
|
||||
},
|
||||
},
|
||||
steps: [{ uses: './.eas/functions/wrap', id: 'wrap' }],
|
||||
externalFunctions: [echoFunction()],
|
||||
})
|
||||
);
|
||||
expect(error).toBeInstanceOf(BuildConfigError);
|
||||
expect(error.message).toBe(
|
||||
'Composite function "./.eas/functions/wrap" calls non-existent function "eas/typo".'
|
||||
);
|
||||
});
|
||||
|
||||
it('errors when an inner step uses a function group', async () => {
|
||||
await expect(
|
||||
parseCompositeFunctions({
|
||||
catalog: {
|
||||
'./.eas/functions/wrap': {
|
||||
runs: { steps: [{ uses: 'eas/build', id: 'build' }] },
|
||||
},
|
||||
},
|
||||
steps: [{ uses: './.eas/functions/wrap', id: 'wrap' }],
|
||||
externalFunctionGroups: [
|
||||
new BuildFunctionGroup({
|
||||
namespace: 'eas',
|
||||
id: 'build',
|
||||
createBuildStepsFromFunctionGroupCall: () => [],
|
||||
}),
|
||||
],
|
||||
})
|
||||
).rejects.toThrow(/Function group "eas\/build" cannot be used inside a composite function/);
|
||||
});
|
||||
|
||||
it('allows composite function chains at the maximum nesting depth without a cycle', async () => {
|
||||
const entries: Record<string, unknown> = {};
|
||||
for (let i = 0; i < 10; i++) {
|
||||
entries[`./.eas/functions/a${i}`] =
|
||||
i < 9
|
||||
? { runs: { steps: [{ uses: `./.eas/functions/a${i + 1}`, id: `s${i}` }] } }
|
||||
: { runs: { steps: [{ run: 'echo leaf' }] } };
|
||||
}
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: entries,
|
||||
steps: [{ uses: './.eas/functions/a0', id: 'top' }],
|
||||
});
|
||||
expect(workflow).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws when composite function nesting exceeds the maximum depth without a cycle', async () => {
|
||||
const entries: Record<string, unknown> = {};
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
entries[`./.eas/functions/a${i}`] =
|
||||
i < 10
|
||||
? { runs: { steps: [{ uses: `./.eas/functions/a${i + 1}`, id: `s${i}` }] } }
|
||||
: { runs: { steps: [{ run: 'echo leaf' }] } };
|
||||
}
|
||||
const error = await getErrorAsync<BuildConfigError>(() =>
|
||||
parseCompositeFunctions({
|
||||
catalog: entries,
|
||||
steps: [{ uses: './.eas/functions/a0', id: 'top' }],
|
||||
})
|
||||
);
|
||||
expect(error).toBeInstanceOf(BuildConfigError);
|
||||
expect(error.message).toMatch(/Maximum composite function nesting depth \(10\) exceeded/);
|
||||
});
|
||||
});
|
||||
describe('step id collisions', () => {
|
||||
it('reports a clear error when a user step id collides with an expanded composite function step id', async () => {
|
||||
const error = await getErrorAsync<BuildWorkflowError>(() =>
|
||||
parseCompositeFunctions({
|
||||
catalog: { [SETUP]: { runs: { steps: [{ id: 'read', run: 'true' }] } } },
|
||||
steps: [
|
||||
{ id: 'setup__read', run: 'true' },
|
||||
{ uses: SETUP, id: 'setup' },
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(error).toBeInstanceOf(BuildWorkflowError);
|
||||
assert(error instanceof BuildWorkflowError);
|
||||
expect(error.errors[0].message).toBe('Duplicated step IDs: "setup__read"');
|
||||
});
|
||||
});
|
||||
describe('expression tokenization', () => {
|
||||
it('substitutes inputs adjacent to arithmetic operators in inner run commands', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
inputs: [
|
||||
{ name: 'total', type: 'number', default_value: 10 },
|
||||
{ name: 'count', type: 'number', default_value: 2 },
|
||||
],
|
||||
runs: {
|
||||
steps: [{ run: 'echo ${{ inputs.total/inputs.count }}' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup' }],
|
||||
});
|
||||
expect(workflow.buildSteps[0].command).toBe('echo ${{ inputs.total/inputs.count }}');
|
||||
});
|
||||
|
||||
it('does not truncate inner expressions at }} inside string literals', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
inputs: [{ name: 'platform', type: 'string', default_value: 'android' }],
|
||||
runs: {
|
||||
steps: [{ run: 'echo hi', if: '${{ contains(inputs.platform, "a}}b") }}' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup' }],
|
||||
});
|
||||
expect(workflow.buildSteps[0].ifCondition).toBe('${{ contains(inputs.platform, "a}}b") }}');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
SETUP,
|
||||
actionReadingInput,
|
||||
echoInputAction,
|
||||
parseCompositeFunctions,
|
||||
passThroughFunction,
|
||||
} from './StepsConfigParser-composite-functions-test-utils';
|
||||
import { getErrorAsync } from './utils/error';
|
||||
import { BuildConfigError, BuildStepRuntimeError } from '../errors';
|
||||
|
||||
describe('StepsConfigParser local composite functions', () => {
|
||||
describe('input handling', () => {
|
||||
it('uses the composite function input default when the caller omits the value', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: echoInputAction('greeting', {
|
||||
name: 'greeting',
|
||||
type: 'string',
|
||||
default_value: 'hello',
|
||||
}),
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup' }],
|
||||
});
|
||||
expect(workflow.buildSteps[0].command).toBe('echo "${{ inputs.greeting }}"');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'boolean',
|
||||
{ name: 'enabled', type: 'boolean' },
|
||||
{ enabled: true },
|
||||
'echo "${{ inputs.enabled }}"',
|
||||
],
|
||||
['number', { name: 'count', type: 'number' }, { count: 3 }, 'echo "${{ inputs.count }}"'],
|
||||
])(
|
||||
'accepts an explicit %s value, leaving the command raw',
|
||||
async (_typeLabel, inputDef, withInputs, expectedCommand) => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: { [SETUP]: echoInputAction(inputDef.name, inputDef) },
|
||||
steps: [{ uses: SETUP, id: 'setup', with: withInputs }],
|
||||
});
|
||||
expect(workflow.buildSteps[0].command).toBe(expectedCommand);
|
||||
}
|
||||
);
|
||||
|
||||
it('ignores unknown caller inputs, matching function-step behavior', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: { [SETUP]: actionReadingInput({ name: 'greeting', type: 'string' }) },
|
||||
steps: [{ uses: SETUP, id: 'setup', with: { greetng: 'hi' } }],
|
||||
externalFunctions: [passThroughFunction()],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(
|
||||
workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out')
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'never referenced',
|
||||
{
|
||||
inputs: [{ name: 'token', type: 'string', required: true }],
|
||||
runs: { steps: [{ id: 'inner', uses: 'test/passthrough', with: { value: 'static' } }] },
|
||||
},
|
||||
],
|
||||
['referenced', actionReadingInput({ name: 'token', type: 'string', required: true })],
|
||||
])('rejects at parse time a missing required input that is %s', async (_, actionConfig) => {
|
||||
const error = await getErrorAsync<BuildConfigError>(() =>
|
||||
parseCompositeFunctions({
|
||||
catalog: { [SETUP]: actionConfig },
|
||||
steps: [{ uses: SETUP, id: 'setup' }],
|
||||
externalFunctions: [passThroughFunction()],
|
||||
})
|
||||
);
|
||||
expect(error).toBeInstanceOf(BuildConfigError);
|
||||
expect(error.message).toMatch(
|
||||
/Input parameter "token" for step ".+" is required but it was not set/
|
||||
);
|
||||
});
|
||||
|
||||
// Required-ness is a static presence check, like for regular function steps.
|
||||
// Whether a provided interpolation resolves to a value is not validated.
|
||||
it('treats a required input set to an interpolation as provided, even one resolving to undefined', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: actionReadingInput({ name: 'token', type: 'string', required: true }),
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup', with: { token: '${{ env.UNSET_VAR }}' } }],
|
||||
externalFunctions: [passThroughFunction()],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(
|
||||
workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out')
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a provided input has the wrong type',
|
||||
actionReadingInput({ name: 'count', type: 'number' }),
|
||||
[{ uses: SETUP, id: 'setup', with: { count: 'two' } }],
|
||||
/Input parameter "count" for step ".+" must be of type "number"/,
|
||||
],
|
||||
[
|
||||
'a string does not parse as JSON for a json input',
|
||||
actionReadingInput({ name: 'config', type: 'json' }),
|
||||
[{ uses: SETUP, id: 'setup', with: { config: 'literal' } }],
|
||||
/Input parameter "config" for step ".+" must be of type "json"/,
|
||||
],
|
||||
])('errors at runtime when %s', async (_, actionConfig, steps, message) => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: { [SETUP]: actionConfig },
|
||||
steps,
|
||||
externalFunctions: [passThroughFunction()],
|
||||
});
|
||||
const error = await getErrorAsync<BuildStepRuntimeError>(() => workflow.executeAsync());
|
||||
expect(error).toBeInstanceOf(BuildStepRuntimeError);
|
||||
expect(error.message).toMatch(message);
|
||||
});
|
||||
|
||||
it('coerces a provided string value to the declared number type', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
inputs: [{ name: 'count', type: 'number' }],
|
||||
runs: {
|
||||
steps: [
|
||||
{
|
||||
id: 'inner',
|
||||
uses: 'test/passthrough',
|
||||
// Without number coercion, `'2' + 1` would render as `21`.
|
||||
with: { value: 'next-${{ inputs.count + 1 }}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup', with: { count: '2' } }],
|
||||
externalFunctions: [passThroughFunction()],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(
|
||||
workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out')
|
||||
).toBe('next-3');
|
||||
});
|
||||
|
||||
it('falls back to the default resolved in the composite function scope when the caller passes null', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: actionReadingInput({ name: 'greeting', type: 'string', default_value: 'hello' }),
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup', with: { greeting: null } }],
|
||||
externalFunctions: [passThroughFunction()],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(
|
||||
workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out')
|
||||
).toBe('hello');
|
||||
});
|
||||
|
||||
it('accepts a provided literal value that is one of allowed_values', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: actionReadingInput({
|
||||
name: 'greeting',
|
||||
type: 'string',
|
||||
allowed_values: ['hi', 'hello'],
|
||||
}),
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup', with: { greeting: 'hello' } }],
|
||||
externalFunctions: [passThroughFunction()],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(
|
||||
workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out')
|
||||
).toBe('hello');
|
||||
});
|
||||
|
||||
it('rejects at parse time a provided literal value outside allowed_values', async () => {
|
||||
const error = await getErrorAsync<BuildConfigError>(() =>
|
||||
parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: actionReadingInput({
|
||||
name: 'greeting',
|
||||
type: 'string',
|
||||
allowed_values: ['hi', 'hello'],
|
||||
}),
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup', with: { greeting: 'bye' } }],
|
||||
externalFunctions: [passThroughFunction()],
|
||||
})
|
||||
);
|
||||
expect(error).toBeInstanceOf(BuildConfigError);
|
||||
expect(error.message).toMatch(/not one of the allowed values/);
|
||||
});
|
||||
|
||||
it('resolves an undeclared input reference to an empty value', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
inputs: [{ name: 'greeting', type: 'string', default_value: 'hello' }],
|
||||
runs: {
|
||||
steps: [
|
||||
{
|
||||
id: 'inner',
|
||||
uses: 'test/passthrough',
|
||||
with: { value: '${{ inputs.gretting }}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup' }],
|
||||
externalFunctions: [passThroughFunction()],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(
|
||||
workflow.buildSteps.find(s => s.id === 'setup__inner')?.getOutputValueByName('out')
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import {
|
||||
SETUP,
|
||||
failingFunction,
|
||||
parseCompositeFunctions,
|
||||
passThroughFunction,
|
||||
setVersionFunction,
|
||||
} from './StepsConfigParser-composite-functions-test-utils';
|
||||
import { getErrorAsync } from './utils/error';
|
||||
import { BuildStepStatus } from '../BuildStep';
|
||||
|
||||
describe('StepsConfigParser local composite functions', () => {
|
||||
describe('outputs', () => {
|
||||
it('creates a CompositeBuildStep that exposes declared composite function outputs', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
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"' },
|
||||
{ run: 'echo "${{ inputs.greeting }}"' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup', with: { greeting: 'hi' } }],
|
||||
});
|
||||
|
||||
expect(workflow.buildSteps).toHaveLength(3);
|
||||
|
||||
const [readStep, echoStep, outputsStep] = workflow.buildSteps;
|
||||
expect(readStep.id).toBe('setup__read');
|
||||
expect(readStep.displayName).toBe('read');
|
||||
expect(echoStep.id).toBe('setup__composite_function_step_1');
|
||||
expect(echoStep.command).toBe('echo "${{ inputs.greeting }}"');
|
||||
|
||||
expect(outputsStep.id).toBe('setup');
|
||||
expect(outputsStep.displayName).toBe('Setup');
|
||||
expect(outputsStep.command).toBeUndefined();
|
||||
expect(outputsStep.fn).toBeDefined();
|
||||
expect(Object.keys(outputsStep.outputById)).toEqual(['version']);
|
||||
});
|
||||
|
||||
it('uses a generated call id for the outputs node when the caller has no id', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
name: 'Setup',
|
||||
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
|
||||
runs: {
|
||||
steps: [
|
||||
{ id: 'read', run: 'set-output version "1.0.0"' },
|
||||
{ id: 'echo', run: 'echo "${{ steps.read.outputs.version }}"' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP }],
|
||||
});
|
||||
|
||||
const [readStep, echoStep, outputsStep] = workflow.buildSteps;
|
||||
const callStepId = readStep.id.split('__')[0];
|
||||
|
||||
expect(callStepId).toMatch(/^step-\d{3,}$/);
|
||||
expect(readStep.id).toBe(`${callStepId}__read`);
|
||||
expect(echoStep.command).toBe('echo "${{ steps.read.outputs.version }}"');
|
||||
expect(outputsStep.id).toBe(callStepId);
|
||||
});
|
||||
|
||||
it('uses the caller step name for the CompositeBuildStep display name', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
name: 'Setup',
|
||||
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
|
||||
runs: {
|
||||
steps: [{ id: 'read', run: 'set-output version "1.0.0"' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup', name: 'Prepare release' }],
|
||||
});
|
||||
|
||||
const outputsStep = workflow.buildSteps[1];
|
||||
expect(outputsStep.id).toBe('setup');
|
||||
expect(outputsStep.displayName).toBe('Prepare release');
|
||||
});
|
||||
|
||||
it('sets composite composite function outputs via fn without shell interpolation', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
|
||||
runs: {
|
||||
steps: [{ id: 'read', uses: 'test/set-version' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup' }],
|
||||
externalFunctions: [setVersionFunction()],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
|
||||
const outputsStep = workflow.buildSteps[1];
|
||||
expect(outputsStep.getOutputValueByName('version')).toBe('$(echo injected)');
|
||||
});
|
||||
|
||||
it('leaves legacy ${ steps.* } references in composite function outputs uninterpolated', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
outputs: { version: { value: '${ steps.read.outputs.version }' } },
|
||||
runs: {
|
||||
steps: [{ id: 'read', uses: 'test/set-version' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup' }],
|
||||
externalFunctions: [setVersionFunction()],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
|
||||
const outputsStep = workflow.buildSteps[1];
|
||||
expect(outputsStep.getOutputValueByName('version')).toBe('${ steps.read.outputs.version }');
|
||||
});
|
||||
|
||||
it('runs the outputs step after a failed inner step when caller uses always(), setting composite function outputs to empty strings', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
|
||||
runs: {
|
||||
steps: [{ id: 'read', uses: 'test/fail' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup', if: '${{ always() }}' }],
|
||||
externalFunctions: [failingFunction()],
|
||||
});
|
||||
|
||||
const innerStep = workflow.buildSteps[0];
|
||||
const outputsStep = workflow.buildSteps[1];
|
||||
expect(outputsStep.ifCondition).toBe('${{ always() }}');
|
||||
|
||||
const error = await getErrorAsync<Error>(() => workflow.executeAsync());
|
||||
expect(error.message).toBe('inner failed');
|
||||
|
||||
expect(innerStep.status).toBe(BuildStepStatus.FAIL);
|
||||
expect(outputsStep.status).toBe(BuildStepStatus.SUCCESS);
|
||||
expect(outputsStep.getOutputValueByName('version')).toBe('');
|
||||
});
|
||||
|
||||
it('exposes outputs from both the outer and a nested inner action', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
'./.eas/functions/outer': {
|
||||
outputs: { outer_version: { value: '${{ steps.mid.outputs.inner_version }}' } },
|
||||
runs: { steps: [{ uses: './.eas/functions/inner', id: 'mid' }] },
|
||||
},
|
||||
'./.eas/functions/inner': {
|
||||
outputs: { inner_version: { value: '${{ steps.read.outputs.version }}' } },
|
||||
runs: { steps: [{ id: 'read', uses: 'test/set-version' }] },
|
||||
},
|
||||
},
|
||||
steps: [{ uses: './.eas/functions/outer', id: 'top' }],
|
||||
externalFunctions: [setVersionFunction()],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
|
||||
const innerOutputsStep = workflow.buildSteps.find(s => s.id === 'top__mid');
|
||||
const outerOutputsStep = workflow.buildSteps.find(s => s.id === 'top');
|
||||
expect(innerOutputsStep?.getOutputValueByName('inner_version')).toBe('$(echo injected)');
|
||||
expect(outerOutputsStep?.getOutputValueByName('outer_version')).toBe('$(echo injected)');
|
||||
});
|
||||
|
||||
it('resolves call-site env in the caller scope for the outputs step', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
outputs: { out: { value: '${{ env.FOO }}' } },
|
||||
runs: {
|
||||
steps: [{ id: 'inner', uses: 'test/passthrough', with: { value: '${{ env.FOO }}' } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [
|
||||
{ id: 'prev', uses: 'test/set-version' },
|
||||
{ uses: SETUP, id: 'setup', env: { FOO: '${{ steps.prev.outputs.version }}' } },
|
||||
],
|
||||
externalFunctions: [setVersionFunction(), passThroughFunction()],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
|
||||
const innerStep = workflow.buildSteps.find(s => s.id === 'setup__inner');
|
||||
const outputsStep = workflow.buildSteps.find(s => s.id === 'setup');
|
||||
expect(innerStep?.getOutputValueByName('out')).toBe('$(echo injected)');
|
||||
expect(outputsStep?.getOutputValueByName('out')).toBe('$(echo injected)');
|
||||
});
|
||||
|
||||
it('resolves inputs referenced in output templates', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
inputs: [
|
||||
{ name: 'greeting', type: 'string' },
|
||||
{ name: 'farewell', type: 'string', default_value: 'bye' },
|
||||
],
|
||||
outputs: {
|
||||
greeting: { value: '${{ inputs.greeting }}' },
|
||||
farewell: { value: '${{ inputs.farewell }}' },
|
||||
},
|
||||
runs: {
|
||||
steps: [{ id: 'noop', uses: 'test/passthrough', with: { value: 'x' } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup', with: { greeting: 'hi' } }],
|
||||
externalFunctions: [passThroughFunction()],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
|
||||
const outputsStep = workflow.buildSteps.find(s => s.id === 'setup');
|
||||
expect(outputsStep?.getOutputValueByName('greeting')).toBe('hi');
|
||||
expect(outputsStep?.getOutputValueByName('farewell')).toBe('bye');
|
||||
});
|
||||
|
||||
it('skips the outputs step when the call-site if skips the whole action', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
|
||||
runs: {
|
||||
steps: [{ id: 'read', uses: 'test/set-version' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [
|
||||
{ uses: SETUP, id: 'setup', if: '${{ false }}' },
|
||||
{
|
||||
id: 'after',
|
||||
uses: 'test/passthrough',
|
||||
with: { value: '${{ steps.setup.outputs.version }}' },
|
||||
},
|
||||
],
|
||||
externalFunctions: [setVersionFunction(), passThroughFunction()],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
|
||||
const innerStep = workflow.buildSteps.find(s => s.id === 'setup__read');
|
||||
const outputsStep = workflow.buildSteps.find(s => s.id === 'setup');
|
||||
const afterStep = workflow.buildSteps.find(s => s.id === 'after');
|
||||
expect(innerStep?.status).toBe(BuildStepStatus.SKIPPED);
|
||||
expect(outputsStep?.status).toBe(BuildStepStatus.SKIPPED);
|
||||
expect(afterStep?.getOutputValueByName('out')).toBe('');
|
||||
});
|
||||
|
||||
it('hides flattened inner step ids from the workflow realm while exposing declared outputs', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
|
||||
runs: {
|
||||
steps: [{ id: 'read', uses: 'test/set-version' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [
|
||||
{ uses: SETUP, id: 'setup' },
|
||||
{
|
||||
id: 'public',
|
||||
uses: 'test/passthrough',
|
||||
with: { value: '${{ steps.setup.outputs.version }}' },
|
||||
},
|
||||
{
|
||||
id: 'leak',
|
||||
uses: 'test/passthrough',
|
||||
with: { value: '${{ steps.setup__read.outputs.version }}' },
|
||||
},
|
||||
],
|
||||
externalFunctions: [setVersionFunction(), passThroughFunction()],
|
||||
});
|
||||
|
||||
await workflow.executeAsync();
|
||||
|
||||
const outputFor = (id: string): string | undefined =>
|
||||
workflow.buildSteps.find(s => s.id === id)?.getOutputValueByName('out');
|
||||
expect(outputFor('public')).toBe('$(echo injected)');
|
||||
expect(workflow.buildSteps.some(s => s.id === 'setup__read')).toBe(true);
|
||||
expect(outputFor('leak')).toBe('');
|
||||
});
|
||||
});
|
||||
describe('skipped inner steps', () => {
|
||||
it('resolves ${{ }} outputs referencing a skipped inner step to empty strings and leaves legacy refs literal', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
[SETUP]: {
|
||||
outputs: {
|
||||
version: { value: '${ steps.read.outputs.version }' },
|
||||
version_expr: { value: '${{ steps.read.outputs.version }}' },
|
||||
},
|
||||
runs: {
|
||||
steps: [{ id: 'read', uses: 'test/set-version', if: '${{ 1 == 2 }}' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: SETUP, id: 'setup' }],
|
||||
externalFunctions: [setVersionFunction()],
|
||||
});
|
||||
|
||||
await workflow.executeAsync();
|
||||
|
||||
const innerStep = workflow.buildSteps[0];
|
||||
const outputsStep = workflow.buildSteps[1];
|
||||
expect(innerStep.status).toBe(BuildStepStatus.SKIPPED);
|
||||
expect(outputsStep.status).toBe(BuildStepStatus.SUCCESS);
|
||||
expect(outputsStep.getOutputValueByName('version')).toBe('${ steps.read.outputs.version }');
|
||||
expect(outputsStep.getOutputValueByName('version_expr')).toBe('');
|
||||
});
|
||||
|
||||
it('resolves an out-of-scope action output reference to an empty string', async () => {
|
||||
const workflow = await parseCompositeFunctions({
|
||||
catalog: {
|
||||
'./.eas/functions/notify': {
|
||||
outputs: { sha: { value: '${{ steps.checkout.outputs.sha }}' } },
|
||||
runs: {
|
||||
steps: [
|
||||
{ id: 'noop', uses: 'test/passthrough', with: { value: 'x' }, if: '${{ false }}' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [{ uses: './.eas/functions/notify', id: 'notify' }],
|
||||
externalFunctions: [passThroughFunction()],
|
||||
});
|
||||
await workflow.executeAsync();
|
||||
expect(workflow.buildSteps.find(s => s.id === 'notify')?.getOutputValueByName('sha')).toBe(
|
||||
''
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,125 @@
|
||||
import { CompositeFunctionCatalog, CompositeFunctionConfigZ, Step } from '@expo/eas-build-job';
|
||||
|
||||
import { createGlobalContextMock } from './utils/context';
|
||||
import { BuildFunction } from '../BuildFunction';
|
||||
import { BuildFunctionGroup } from '../BuildFunctionGroup';
|
||||
import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput';
|
||||
import { BuildStepOutput } from '../BuildStepOutput';
|
||||
import { BuildWorkflow } from '../BuildWorkflow';
|
||||
import { StepsConfigParser } from '../StepsConfigParser';
|
||||
|
||||
export const SETUP = './.eas/functions/setup';
|
||||
|
||||
export function makeCatalog(entries: Record<string, unknown>): CompositeFunctionCatalog {
|
||||
const catalog: CompositeFunctionCatalog = {};
|
||||
for (const [compositeFunctionPath, raw] of Object.entries(entries)) {
|
||||
catalog[compositeFunctionPath] = CompositeFunctionConfigZ.parse(raw);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
export async function parseCompositeFunctions(options: {
|
||||
catalog?: Record<string, unknown>;
|
||||
steps: Step[];
|
||||
externalFunctions?: BuildFunction[];
|
||||
externalFunctionGroups?: BuildFunctionGroup[];
|
||||
}): Promise<BuildWorkflow> {
|
||||
const ctx = createGlobalContextMock();
|
||||
const parser = new StepsConfigParser(ctx, {
|
||||
steps: options.steps,
|
||||
hooks: undefined,
|
||||
compositeFunctionCatalog: makeCatalog(options.catalog ?? {}),
|
||||
externalFunctions: options.externalFunctions,
|
||||
externalFunctionGroups: options.externalFunctionGroups,
|
||||
});
|
||||
return parser.parseAsync();
|
||||
}
|
||||
|
||||
export function echoFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'echo',
|
||||
fn: () => {},
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'value',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function setVersionFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'test',
|
||||
id: 'set-version',
|
||||
fn: (_ctx, { outputs }) => {
|
||||
outputs.version.set('$(echo injected)');
|
||||
},
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'version',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function failingFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'test',
|
||||
id: 'fail',
|
||||
fn: () => {
|
||||
throw new Error('inner failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function passThroughFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'test',
|
||||
id: 'passthrough',
|
||||
fn: (_ctx, { inputs, outputs }) => {
|
||||
outputs.out.set(String(inputs.value.value ?? ''));
|
||||
},
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'value',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputProviders: [BuildStepOutput.createProvider({ id: 'out', required: true })],
|
||||
});
|
||||
}
|
||||
|
||||
export function captureEnvFunction(
|
||||
sink: (env: Record<string, string | undefined>) => void
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'test',
|
||||
id: 'capture-env',
|
||||
fn: (_ctx, { env }) => {
|
||||
sink(env);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function echoInputAction(inputName: string, input: Record<string, unknown>) {
|
||||
return {
|
||||
inputs: [input],
|
||||
runs: { steps: [{ run: `echo "\${{ inputs.${inputName} }}"` }] },
|
||||
};
|
||||
}
|
||||
|
||||
export function actionReadingInput(input: Record<string, unknown>) {
|
||||
return {
|
||||
inputs: [input],
|
||||
runs: {
|
||||
steps: [
|
||||
{ id: 'inner', uses: 'test/passthrough', with: { value: `\${{ inputs.${input.name} }}` } },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hooks, Step } from '@expo/eas-build-job';
|
||||
import { CompositeFunctionCatalog, Hooks, Step } from '@expo/eas-build-job';
|
||||
|
||||
import { makeCatalog } from './StepsConfigParser-composite-functions-test-utils';
|
||||
import { createGlobalContextMock } from './utils/context';
|
||||
import { getErrorAsync } from './utils/error';
|
||||
import { BuildFunction } from '../BuildFunction';
|
||||
@@ -38,12 +39,14 @@ async function parseWorkflowAsync({
|
||||
hooks,
|
||||
externalFunctions,
|
||||
externalFunctionGroups,
|
||||
compositeFunctionCatalog,
|
||||
}: {
|
||||
ctx: BuildStepGlobalContext;
|
||||
steps: Step[];
|
||||
hooks: Hooks | undefined;
|
||||
externalFunctions?: BuildFunction[];
|
||||
externalFunctionGroups?: BuildFunctionGroup[];
|
||||
compositeFunctionCatalog?: CompositeFunctionCatalog;
|
||||
}): Promise<BuildWorkflow> {
|
||||
const parser = new StepsConfigParser(ctx, {
|
||||
steps,
|
||||
@@ -53,6 +56,7 @@ async function parseWorkflowAsync({
|
||||
createCheckoutFunction(),
|
||||
],
|
||||
externalFunctionGroups,
|
||||
compositeFunctionCatalog,
|
||||
});
|
||||
return await parser.parseAsync();
|
||||
}
|
||||
@@ -260,6 +264,18 @@ describe('StepsConfigParser hook construction', () => {
|
||||
expect(error).toBeInstanceOf(BuildConfigError);
|
||||
expect(error.message).toMatch(/nonexistent_function/);
|
||||
});
|
||||
|
||||
it('rejects a hook step using a local composite function with BuildConfigError, not an assertion crash', async () => {
|
||||
const error = await getErrorAsync<BuildConfigError>(async () => {
|
||||
await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: 'eas/install_node_modules' }],
|
||||
hooks: { before_install_node_modules: [{ uses: './.eas/functions/setup' }] },
|
||||
});
|
||||
});
|
||||
expect(error).toBeInstanceOf(BuildConfigError);
|
||||
expect(error.message).toMatch(/not supported in hooks/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructHookEntriesAsync (public API)', () => {
|
||||
@@ -487,6 +503,111 @@ describe('StepsConfigParser hooks with function groups', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('StepsConfigParser hooks with composite functions', () => {
|
||||
let ctx: BuildStepGlobalContext;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createGlobalContextMock();
|
||||
});
|
||||
|
||||
it('never treats an anchored function inside a composite function as a hook trigger', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: './.eas/functions/setup', id: 'setup' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ run: 'echo never' }],
|
||||
after_install_node_modules: [{ run: 'echo never' }],
|
||||
},
|
||||
compositeFunctionCatalog: makeCatalog({
|
||||
'./.eas/functions/setup': {
|
||||
runs: { steps: [{ uses: 'eas/install_node_modules' }] },
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(workflow.buildSteps.map(step => step.displayName)).toEqual(['Install node modules']);
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(0);
|
||||
// Structural invariant, not just parse-time behavior: the expanded step
|
||||
// carries NO anchor mark, so a future runtime discovery mechanism (the
|
||||
// native hook runner) cannot resolve it as an anchor occurrence either.
|
||||
expect(workflow.buildSteps[0].__hookId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('anchors only the job-level occurrence when the same function is also called inside a composite function', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [
|
||||
{ uses: './.eas/functions/setup', id: 'setup' },
|
||||
{ uses: 'eas/install_node_modules' },
|
||||
],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }],
|
||||
after_install_node_modules: [{ run: 'echo after', id: 'after-hook' }],
|
||||
},
|
||||
compositeFunctionCatalog: makeCatalog({
|
||||
'./.eas/functions/setup': {
|
||||
runs: { steps: [{ uses: 'eas/install_node_modules' }] },
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(1);
|
||||
expect(orderedDisplayNames(workflow)).toEqual([
|
||||
'Install node modules',
|
||||
'before-hook',
|
||||
'Install node modules',
|
||||
'after-hook',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a REGISTERED stamp on a composite call even when it expands into a single step', async () => {
|
||||
const error = await getErrorAsync<BuildConfigError>(async () => {
|
||||
await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: './.eas/functions/setup', id: 'setup', __hook_id: 'install_node_modules' }],
|
||||
hooks: { before_install_node_modules: [{ run: 'echo never' }] },
|
||||
compositeFunctionCatalog: makeCatalog({
|
||||
'./.eas/functions/setup': {
|
||||
runs: { steps: [{ run: 'echo setup' }] },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
expect(error).toBeInstanceOf(BuildConfigError);
|
||||
expect(error.message).toBe('Hook anchors are not supported on local composite function steps.');
|
||||
});
|
||||
|
||||
it('rejects a REGISTERED stamp on a composite call that expands into multiple steps', async () => {
|
||||
const error = await getErrorAsync<BuildConfigError>(async () => {
|
||||
await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: './.eas/functions/setup', id: 'setup', __hook_id: 'install_node_modules' }],
|
||||
hooks: { before_install_node_modules: [{ run: 'echo never' }] },
|
||||
compositeFunctionCatalog: makeCatalog({
|
||||
'./.eas/functions/setup': {
|
||||
runs: { steps: [{ run: 'echo one' }, { run: 'echo two' }] },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
expect(error).toBeInstanceOf(BuildConfigError);
|
||||
expect(error.message).toBe('Hook anchors are not supported on local composite function steps.');
|
||||
});
|
||||
|
||||
it('treats an UNREGISTERED-stamped composite call as an inert ordinary step (skew tolerance)', async () => {
|
||||
const workflow = await parseWorkflowAsync({
|
||||
ctx,
|
||||
steps: [{ uses: './.eas/functions/setup', id: 'setup', __hook_id: 'some_future_anchor' }],
|
||||
hooks: { before_install_node_modules: [{ run: 'echo never' }] },
|
||||
compositeFunctionCatalog: makeCatalog({
|
||||
'./.eas/functions/setup': {
|
||||
runs: { steps: [{ uses: 'eas/install_node_modules' }] },
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(workflow.buildSteps.map(step => step.displayName)).toEqual(['Install node modules']);
|
||||
expect(workflow.hooksByAnchorStep.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StepsConfigParser hook validation view', () => {
|
||||
let ctx: BuildStepGlobalContext;
|
||||
|
||||
|
||||
+12
-20
@@ -15,9 +15,10 @@ import {
|
||||
} from './BuildFunctionGroup';
|
||||
import { BuildStep } from './BuildStep';
|
||||
import { BuildStepGlobalContext } from './BuildStepContext';
|
||||
import { BuildStepOutput } from './BuildStepOutput';
|
||||
import { collectAggregateStepErrors } from './BuildWorkflowValidator';
|
||||
import { BuildConfigError, BuildWorkflowError } from './errors';
|
||||
import { isLocalCompositeFunctionPath } from './utils/localCompositeFunctions';
|
||||
import { createBuildStepOutputsFromDefinition, getShellStepDisplayName } from './utils/step';
|
||||
|
||||
/**
|
||||
* One entry per AUTHORED hook step — the unit the user wrote. The wrapper
|
||||
@@ -125,6 +126,11 @@ export function constructHookEntriesFromValidatedSteps(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (isLocalCompositeFunctionPath(step.uses)) {
|
||||
throw new BuildConfigError(
|
||||
`Local composite function steps ("uses: ${step.uses}") are not supported in hooks.`
|
||||
);
|
||||
}
|
||||
const maybeFunctionGroup = buildFunctionGroupById[step.uses];
|
||||
if (maybeFunctionGroup !== undefined) {
|
||||
entries.push({
|
||||
@@ -159,25 +165,11 @@ export function createBuildStepFromShellStep(
|
||||
ctx: BuildStepGlobalContext,
|
||||
step: ShellStep
|
||||
): BuildStep {
|
||||
const id = BuildStep.getNewId(step.id);
|
||||
const displayName =
|
||||
step.name ??
|
||||
step.id ??
|
||||
step.run
|
||||
.split('\n')
|
||||
.find(line => line.trim())
|
||||
?.trim() ??
|
||||
step.run;
|
||||
const outputs = step.outputs?.map(
|
||||
entry =>
|
||||
new BuildStepOutput(ctx, {
|
||||
id: entry.name,
|
||||
stepDisplayName: displayName,
|
||||
required: entry.required ?? true,
|
||||
})
|
||||
);
|
||||
const displayName = getShellStepDisplayName(step);
|
||||
const outputs =
|
||||
step.outputs && createBuildStepOutputsFromDefinition(ctx, step.outputs, displayName);
|
||||
return new BuildStep(ctx, {
|
||||
id,
|
||||
id: BuildStep.getNewId(step.id),
|
||||
displayName,
|
||||
outputs,
|
||||
workingDirectory: step.working_directory,
|
||||
@@ -202,7 +194,7 @@ export function validateAllStepFunctionsExist(
|
||||
): void {
|
||||
const calledFunctionsOrFunctionGroupsSet = new Set<string>();
|
||||
for (const step of steps) {
|
||||
if (step.uses) {
|
||||
if (step.uses && !isLocalCompositeFunctionPath(step.uses)) {
|
||||
calledFunctionsOrFunctionGroupsSet.add(step.uses);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { JobInterpolationContext } from '@expo/eas-build-job';
|
||||
|
||||
import { createGlobalContextMock } from '../../__tests__/utils/context';
|
||||
import {
|
||||
containsUnresolvedTemplateReference,
|
||||
resolveInterpolatedTarget,
|
||||
stringifyInterpolatedResult,
|
||||
} from '../compositeFunctionInterpolation';
|
||||
|
||||
function contextWith({
|
||||
steps = {},
|
||||
env = {},
|
||||
}: {
|
||||
steps?: JobInterpolationContext['steps'];
|
||||
env?: Record<string, string | undefined>;
|
||||
} = {}): JobInterpolationContext {
|
||||
const globalCtx = createGlobalContextMock();
|
||||
return { ...globalCtx.getInterpolationContext(), steps, env } as JobInterpolationContext;
|
||||
}
|
||||
|
||||
describe(resolveInterpolatedTarget, () => {
|
||||
it('passes literal targets through unchanged', () => {
|
||||
expect(resolveInterpolatedTarget('literal', contextWith())).toBe('literal');
|
||||
});
|
||||
|
||||
it('resolves ${{ }} expressions against the context', () => {
|
||||
expect(resolveInterpolatedTarget('${{ env.FOO }}', contextWith({ env: { FOO: 'bar' } }))).toBe(
|
||||
'bar'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves legacy ${ steps.* } references untouched', () => {
|
||||
expect(resolveInterpolatedTarget('v${ steps.read.version }', contextWith())).toBe(
|
||||
'v${ steps.read.version }'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns non-string results as-is', () => {
|
||||
expect(resolveInterpolatedTarget('${{ fromJSON(\'{"a":1}\') }}', contextWith())).toEqual({
|
||||
a: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(stringifyInterpolatedResult, () => {
|
||||
it('renders nullish values as an empty string and objects as JSON', () => {
|
||||
expect(stringifyInterpolatedResult(undefined)).toBe('');
|
||||
expect(stringifyInterpolatedResult(null)).toBe('');
|
||||
expect(stringifyInterpolatedResult({ a: 1 })).toBe('{"a":1}');
|
||||
expect(stringifyInterpolatedResult(3)).toBe('3');
|
||||
});
|
||||
});
|
||||
|
||||
describe(containsUnresolvedTemplateReference, () => {
|
||||
it('recognizes ${{ }} template references', () => {
|
||||
expect(containsUnresolvedTemplateReference('${{ steps.a.outputs.v }}')).toBe(true);
|
||||
expect(containsUnresolvedTemplateReference(' ${{ steps.a.outputs.v }} ')).toBe(true);
|
||||
expect(containsUnresolvedTemplateReference('${{ steps.a.outputs.v }}\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes ${{ }} template references embedded in a larger string', () => {
|
||||
expect(containsUnresolvedTemplateReference('${{ steps.a.outputs.v }}px')).toBe(true);
|
||||
expect(containsUnresolvedTemplateReference('${{ inputs.p }}-variant')).toBe(true);
|
||||
expect(containsUnresolvedTemplateReference('a-${{ env.FOO }}-b')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not recognize legacy ${ } spans, which are unsupported inside actions', () => {
|
||||
expect(containsUnresolvedTemplateReference('${ steps.a.outputs.v }')).toBe(false);
|
||||
expect(containsUnresolvedTemplateReference('${ steps.a.v }')).toBe(false);
|
||||
expect(containsUnresolvedTemplateReference('a-${ steps.a.outputs.v }-b')).toBe(false);
|
||||
expect(containsUnresolvedTemplateReference('${ inputs.count }')).toBe(false);
|
||||
expect(containsUnresolvedTemplateReference('echo ${FOO:-bar}')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for plain strings and non-strings', () => {
|
||||
expect(containsUnresolvedTemplateReference('plain')).toBe(false);
|
||||
expect(containsUnresolvedTemplateReference(5)).toBe(false);
|
||||
expect(containsUnresolvedTemplateReference(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
isLocalCompositeFunctionPath,
|
||||
parseLocalCompositeFunctionPath,
|
||||
} from '../localCompositeFunctions';
|
||||
|
||||
describe(isLocalCompositeFunctionPath, () => {
|
||||
it('recognizes relative paths as local composite function paths', () => {
|
||||
expect(isLocalCompositeFunctionPath('./.eas/functions/setup')).toBe(true);
|
||||
expect(isLocalCompositeFunctionPath('../../shared/functions/setup')).toBe(true);
|
||||
expect(isLocalCompositeFunctionPath(' ./.eas/functions/setup/ ')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects function ids and absolute or backslash-prefixed paths', () => {
|
||||
expect(isLocalCompositeFunctionPath('eas/build')).toBe(false);
|
||||
expect(isLocalCompositeFunctionPath('/functions/setup')).toBe(false);
|
||||
expect(isLocalCompositeFunctionPath('..\\functions\\setup')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseLocalCompositeFunctionPath, () => {
|
||||
it('parses local composite function paths', () => {
|
||||
expect(parseLocalCompositeFunctionPath('./.eas/functions/setup')).toBe(
|
||||
'./.eas/functions/setup'
|
||||
);
|
||||
expect(parseLocalCompositeFunctionPath('../../shared/functions/setup')).toBe(
|
||||
'../../shared/functions/setup'
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes local composite function paths', () => {
|
||||
expect(parseLocalCompositeFunctionPath(' ./.eas/functions/setup/ ')).toBe(
|
||||
'./.eas/functions/setup'
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses equivalent paths to the same canonical path', () => {
|
||||
expect(parseLocalCompositeFunctionPath('././.eas/functions/setup')).toBe(
|
||||
'./.eas/functions/setup'
|
||||
);
|
||||
expect(parseLocalCompositeFunctionPath('./.eas/functions/other/../setup')).toBe(
|
||||
'./.eas/functions/setup'
|
||||
);
|
||||
expect(parseLocalCompositeFunctionPath('../shared/other/../functions/setup')).toBe(
|
||||
'../shared/functions/setup'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the "./" prefix for under-root directories whose name starts with ".."', () => {
|
||||
expect(parseLocalCompositeFunctionPath('./..functions/setup')).toBe('./..functions/setup');
|
||||
});
|
||||
|
||||
it('canonicalizes paths pointing at the project root or its parent', () => {
|
||||
expect(parseLocalCompositeFunctionPath('./')).toBe('./.');
|
||||
expect(parseLocalCompositeFunctionPath(' ./ ')).toBe('./.');
|
||||
expect(parseLocalCompositeFunctionPath('../')).toBe('..');
|
||||
expect(parseLocalCompositeFunctionPath('./..')).toBe('..');
|
||||
});
|
||||
|
||||
it('is stable when re-parsing its own canonical output', () => {
|
||||
expect(parseLocalCompositeFunctionPath('./.')).toBe('./.');
|
||||
expect(parseLocalCompositeFunctionPath('../.')).toBe('..');
|
||||
});
|
||||
|
||||
it('throws for backslash-based paths', () => {
|
||||
expect(() => parseLocalCompositeFunctionPath('./functions\\setup')).toThrow(
|
||||
/must not contain backslashes/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for interpolated local composite function paths', () => {
|
||||
expect(() => parseLocalCompositeFunctionPath('./.eas/functions/${{ inputs.name }}')).toThrow(
|
||||
/must not contain interpolation/
|
||||
);
|
||||
});
|
||||
|
||||
it('parses local composite function paths that contain }}${{ as literal characters', () => {
|
||||
expect(parseLocalCompositeFunctionPath('./.eas/functions/weird}}${{name')).toBe(
|
||||
'./.eas/functions/weird}}${{name'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { JobInterpolationContext } from '@expo/eas-build-job';
|
||||
|
||||
import { interpolateJobContext } from '../interpolation';
|
||||
|
||||
// Composite functions support only `${{ }}`; legacy `${ steps.* }` is intentionally left uninterpolated here.
|
||||
export function resolveInterpolatedTarget(
|
||||
target: unknown,
|
||||
context: JobInterpolationContext
|
||||
): unknown {
|
||||
return interpolateJobContext({ target, context });
|
||||
}
|
||||
|
||||
export function stringifyInterpolatedResult(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function stringifyOptionalInterpolatedResult(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
return stringifyInterpolatedResult(value);
|
||||
}
|
||||
|
||||
// Matches `${{ }}` only; legacy `${ steps.* }` is unsupported inside actions.
|
||||
export function containsUnresolvedTemplateReference(value: unknown): boolean {
|
||||
return typeof value === 'string' && value.includes('${{');
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import path from 'path';
|
||||
|
||||
import { BuildConfigError } from '../errors';
|
||||
|
||||
// Local composite functions referenced via `uses: ./path` or `uses: ../path` in EAS workflows.
|
||||
// Not supported in `.eas/build/*.yml` custom build configs.
|
||||
|
||||
const JOB_CONTEXT_INTERPOLATION_REGEXP = /\$\{\{(.+?)\}\}/;
|
||||
|
||||
function doesLocalCompositeFunctionPathRequireInterpolation(uses: string): boolean {
|
||||
return JOB_CONTEXT_INTERPOLATION_REGEXP.test(uses);
|
||||
}
|
||||
|
||||
export function parseLocalCompositeFunctionPath(uses: string): string {
|
||||
const trimmed = uses.trim();
|
||||
// The composite function catalog is built before the workflow runs, so a local path must be
|
||||
// known statically.
|
||||
if (doesLocalCompositeFunctionPathRequireInterpolation(trimmed)) {
|
||||
throw new BuildConfigError(
|
||||
`Local composite function path "${trimmed}" must not contain interpolation ("\${{ ... }}"). The "uses" path for a local composite function must be a static, literal path.`
|
||||
);
|
||||
}
|
||||
if (trimmed.includes('\\')) {
|
||||
throw new BuildConfigError(
|
||||
`Local composite function path "${trimmed}" must not contain backslashes. Use forward slashes as path separators.`
|
||||
);
|
||||
}
|
||||
const normalized = path.posix.normalize(trimmed.replace(/\/+$/, ''));
|
||||
if (normalized === '..' || normalized.startsWith('../')) {
|
||||
return normalized;
|
||||
}
|
||||
return `./${normalized}`;
|
||||
}
|
||||
|
||||
export function isLocalCompositeFunctionPath(uses: string): boolean {
|
||||
const trimmed = uses.trim();
|
||||
return trimmed.startsWith('./') || trimmed.startsWith('../');
|
||||
}
|
||||
|
||||
export function getLocalCompositeFunctionCallWorkingDirectoryError(uses: string): string {
|
||||
return `"working_directory" is not supported on a step that calls a local composite function ("uses: ${uses.trim()}"). Set "working_directory" on the steps inside the composite function instead.`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ShellStep } from '@expo/eas-build-job';
|
||||
|
||||
import { BuildStepGlobalContext } from '../BuildStepContext';
|
||||
import { BuildStepOutput } from '../BuildStepOutput';
|
||||
|
||||
export function getShellStepDisplayName(step: ShellStep): string {
|
||||
return (
|
||||
step.name ??
|
||||
step.id ??
|
||||
step.run
|
||||
.split('\n')
|
||||
.find(line => line.trim())
|
||||
?.trim() ??
|
||||
step.run
|
||||
);
|
||||
}
|
||||
|
||||
export function createBuildStepOutputsFromDefinition(
|
||||
ctx: BuildStepGlobalContext,
|
||||
stepOutputs: Required<ShellStep>['outputs'],
|
||||
stepDisplayName: string
|
||||
): BuildStepOutput[] {
|
||||
return stepOutputs.map(
|
||||
entry =>
|
||||
new BuildStepOutput(ctx, {
|
||||
id: entry.name,
|
||||
stepDisplayName,
|
||||
required: entry.required ?? true,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "21.1.0",
|
||||
"version": "21.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,16 +19,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "21.1.0",
|
||||
"@expo/build-tools": "21.3.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "21.0.0",
|
||||
"@expo/eas-build-job": "21.1.0",
|
||||
"@expo/eas-build-job": "21.3.0",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@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.3.0",
|
||||
"@expo/template-file": "21.0.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
|
||||
@@ -2291,14 +2291,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:21.1.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:21.3.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/downloader": "npm:21.0.0"
|
||||
"@expo/eas-build-job": "npm:21.1.0"
|
||||
"@expo/eas-build-job": "npm:21.3.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:21.0.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -2306,7 +2306,7 @@ __metadata:
|
||||
"@expo/repack-app": "npm:~0.6.1"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:21.1.0"
|
||||
"@expo/steps": "npm:21.3.0"
|
||||
"@expo/template-file": "npm:21.0.2"
|
||||
"@expo/turtle-spawn": "npm:21.0.0"
|
||||
"@expo/xcpretty": "npm:^4.3.1"
|
||||
@@ -2547,7 +2547,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:21.1.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:21.3.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
|
||||
dependencies:
|
||||
@@ -2566,12 +2566,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:21.1.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:21.3.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-json@workspace:packages/eas-json"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.23.5"
|
||||
"@expo/eas-build-job": "npm:21.1.0"
|
||||
"@expo/eas-build-job": "npm:21.3.0"
|
||||
"@tsconfig/node20": "npm:20.1.8"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -2884,11 +2884,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:21.1.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:21.3.0, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
"@expo/eas-build-job": "npm:21.1.0"
|
||||
"@expo/eas-build-job": "npm:21.3.0"
|
||||
"@expo/logger": "npm:21.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
@@ -2957,16 +2957,16 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:21.1.0"
|
||||
"@expo/build-tools": "npm:21.3.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/downloader": "npm:21.0.0"
|
||||
"@expo/eas-build-job": "npm:21.1.0"
|
||||
"@expo/eas-build-job": "npm:21.3.0"
|
||||
"@expo/logger": "npm:21.0.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:21.1.0"
|
||||
"@expo/steps": "npm:21.3.0"
|
||||
"@expo/template-file": "npm:21.0.2"
|
||||
"@expo/turtle-spawn": "npm:21.0.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
@@ -9722,7 +9722,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:21.1.0"
|
||||
"@expo/steps": "npm:21.3.0"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
@@ -10294,8 +10294,8 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:21.1.0"
|
||||
"@expo/eas-build-job": "npm:21.1.0"
|
||||
"@expo/build-tools": "npm:21.3.0"
|
||||
"@expo/eas-build-job": "npm:21.3.0"
|
||||
"@expo/logger": "npm:21.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:21.0.0"
|
||||
@@ -10352,8 +10352,8 @@ __metadata:
|
||||
"@expo/code-signing-certificates": "npm:0.0.5"
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/eas-build-job": "npm:21.1.0"
|
||||
"@expo/eas-json": "npm:21.1.0"
|
||||
"@expo/eas-build-job": "npm:21.3.0"
|
||||
"@expo/eas-json": "npm:21.3.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:21.0.0"
|
||||
@@ -10368,7 +10368,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:21.1.0"
|
||||
"@expo/steps": "npm:21.3.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:6.3.1"
|
||||
"@graphql-codegen/introspection": "npm:5.0.2"
|
||||
|
||||
Reference in new issue
Block a user