mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3202a7003 | ||
|
|
cc19ec8ecf | ||
|
|
ffc24c92c5 | ||
|
|
a8240eb7ce | ||
|
|
c8e1799100 | ||
|
|
262f8fb01b | ||
|
|
b3baf0ad7c | ||
|
|
6defce7119 | ||
|
|
4880dce0ab | ||
|
|
48b0e70af1 | ||
|
|
93fc345630 | ||
|
|
246d0c0794 | ||
|
|
f299ccb493 | ||
|
|
adab98404e | ||
|
|
a8676fe92d | ||
|
|
68c87fa6fb | ||
|
|
1010e3b997 | ||
|
|
a8e8c796dd | ||
|
|
fcea341834 | ||
|
|
197edf5ac6 | ||
|
|
a48ad7acd4 |
No files matched your search
@@ -37,6 +37,6 @@
|
||||
"auth": {
|
||||
"mode": "oauth",
|
||||
"provider": "anthropic",
|
||||
"tokenEnv": "DO_NOT_USE_EXPERIMENTAL_ANTHROPIC_API_KEY"
|
||||
"tokenEnv": "ANTHROPIC_OAUTH_API_KEY"
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ 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 }}
|
||||
ANTHROPIC_OAUTH_API_KEY: ${{ secrets.ANTHROPIC_OAUTH_API_KEY }}
|
||||
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
|
||||
AGENTS: ${{ steps.cmd.outputs.agents }}
|
||||
ROUTE: ${{ steps.cmd.outputs.route }}
|
||||
|
||||
@@ -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 || 'ANTHROPIC_OAUTH_API_KEY' }}
|
||||
run: |
|
||||
values=$(grep -oE '"tokenEnv"[[:space:]]*:[[:space:]]*"[A-Za-z0-9_]+"' .expo-code-review/config.jsonc | sed -E 's/.*"([A-Za-z0-9_]+)"$/\1/')
|
||||
count=$(printf '%s\n' "$values" | grep -c .)
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
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 }}
|
||||
ANTHROPIC_OAUTH_API_KEY: ${{ secrets.ANTHROPIC_OAUTH_API_KEY }}
|
||||
# Optional: override the model used by every agent.
|
||||
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
|
||||
AGENTS: ${{ steps.resolve.outputs.agents }}
|
||||
|
||||
+13
-1
@@ -8,10 +8,21 @@ 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))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Print an actionable error message that says to install the project's dependencies when they aren't installed and EAS CLI needs them to read the app config, instead of a confusing module resolution error. Reading the config with the copy of `@expo/config` bundled with EAS CLI is now reserved for old SDK versions whose `expo` package doesn't include Expo CLI; projects without the `expo` package get an error directing them to install it. ([#4080](https://github.com/expo/eas-cli/pull/4080) by [@ide](https://github.com/ide))
|
||||
|
||||
### 🧹 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))
|
||||
@@ -28,6 +39,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
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ eas --help COMMAND
|
||||
* [`eas observe:session [SESSIONID]`](#eas-observesession-sessionid)
|
||||
* [`eas observe:versions`](#eas-observeversions)
|
||||
* [`eas project:delete [NAME]`](#eas-projectdelete-name)
|
||||
* [`eas project:icon:set PATH`](#eas-projecticonset-path)
|
||||
* [`eas project:info`](#eas-projectinfo)
|
||||
* [`eas project:init`](#eas-projectinit)
|
||||
* [`eas project:new [PATH]`](#eas-projectnew-path)
|
||||
@@ -2218,6 +2219,26 @@ DESCRIPTION
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/delete.ts](https://github.com/expo/eas-cli/blob/v21.2.0/packages/eas-cli/src/commands/project/delete.ts)_
|
||||
|
||||
## `eas project:icon:set PATH`
|
||||
|
||||
set the project icon displayed on the EAS dashboard
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas project:icon:set PATH [--non-interactive]
|
||||
|
||||
ARGUMENTS
|
||||
PATH Path to the icon image (PNG or JPEG, at most 10 MB). Non-square images are center-cropped to a square.
|
||||
|
||||
FLAGS
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
set the project icon displayed on the EAS dashboard
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/icon/set.ts](https://github.com/expo/eas-cli/blob/v21.2.0/packages/eas-cli/src/commands/project/icon/set.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
information about the current project
|
||||
|
||||
Generated
+1143
-1413
File diff suppressed because it is too large.
Load diff
+173
@@ -1,5 +1,6 @@
|
||||
import { getConfig, getConfigFilePaths, modifyConfigAsync } from '@expo/config';
|
||||
import { vol } from 'memfs';
|
||||
import resolveFrom from 'resolve-from';
|
||||
import { anything, instance, mock, when } from 'ts-mockito';
|
||||
|
||||
import { Role } from '../../../../graphql/generated';
|
||||
@@ -7,12 +8,18 @@ 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';
|
||||
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('fs');
|
||||
jest.mock('resolve-from', () => {
|
||||
const resolveFrom: any = jest.fn();
|
||||
resolveFrom.silent = jest.fn();
|
||||
return resolveFrom;
|
||||
});
|
||||
|
||||
jest.mock('../../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../contextUtils/findProjectDirAndVerifyProjectSetupAsync');
|
||||
@@ -23,15 +30,22 @@ 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 });
|
||||
|
||||
// Simulate an old SDK project: `expo` is installed but doesn't include Expo CLI, so the
|
||||
// config is read with the copy of `@expo/config` bundled with EAS CLI.
|
||||
jest.mocked(resolveFrom.silent).mockReturnValue('/app/node_modules/expo/package.json');
|
||||
|
||||
const sessionManagerMock = mock<SessionManager>();
|
||||
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
|
||||
actor: {
|
||||
@@ -74,6 +88,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 +356,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(
|
||||
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
|
||||
);
|
||||
|
||||
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,11 +8,16 @@ 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';
|
||||
|
||||
@@ -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,49 @@ 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(
|
||||
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AppJSONConfig, PackageJSONConfig, getConfig } from '@expo/config';
|
||||
import chalk from 'chalk';
|
||||
import { vol } from 'memfs';
|
||||
import resolveFrom from 'resolve-from';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
@@ -21,6 +22,11 @@ import ProjectInit from '../init';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('resolve-from', () => {
|
||||
const resolveFrom: any = jest.fn();
|
||||
resolveFrom.silent = jest.fn();
|
||||
return resolveFrom;
|
||||
});
|
||||
jest.mock('../../../project/expoConfig', () => ({
|
||||
...jest.requireActual('../../../project/expoConfig'),
|
||||
createOrModifyExpoConfigAsync: jest.fn(),
|
||||
@@ -99,6 +105,9 @@ function mockTestProject(options: {
|
||||
// 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);
|
||||
// Simulate an old SDK project: `expo` is installed but doesn't include Expo CLI, so the config
|
||||
// is read with the copy of `@expo/config` bundled with EAS CLI.
|
||||
jest.mocked(resolveFrom.silent).mockReturnValue(projectRoot + '/node_modules/expo/package.json');
|
||||
}
|
||||
|
||||
const commandOptions = getMockOclifConfig({ root: '/test-project' });
|
||||
|
||||
@@ -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
|
||||
)}`
|
||||
);
|
||||
}
|
||||
+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,12 +1,20 @@
|
||||
import { getConfigFilePaths, modifyConfigAsync } from '@expo/config';
|
||||
import { getConfig, getConfigFilePaths, getPackageJson, modifyConfigAsync } from '@expo/config';
|
||||
import JsonFile from '@expo/json-file';
|
||||
import { writeFileSync } from 'fs-extra';
|
||||
import resolveFrom from 'resolve-from';
|
||||
|
||||
import { createOrModifyExpoConfigAsync } from '../expoConfig';
|
||||
import { createOrModifyExpoConfigAsync, getPrivateExpoConfigAsync } from '../expoConfig';
|
||||
import { isExpoInstalled } from '../projectUtils';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('@expo/json-file');
|
||||
jest.mock('resolve-from', () => {
|
||||
const resolveFrom: any = jest.fn();
|
||||
resolveFrom.silent = jest.fn();
|
||||
return resolveFrom;
|
||||
});
|
||||
jest.mock('../projectUtils');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
@@ -47,4 +55,34 @@ describe('expoConfig', () => {
|
||||
expect(modifyConfigAsync).toHaveBeenCalledWith('/app', { owner: 'ccheever' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrivateExpoConfigAsync when Expo CLI is not resolvable', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(getConfigFilePaths).mockReturnValue({
|
||||
staticConfigPath: '/app/app.json',
|
||||
dynamicConfigPath: null,
|
||||
});
|
||||
jest.mocked(isExpoInstalled).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('throws an actionable error when expo is declared in package.json but not installed', async () => {
|
||||
jest.mocked(resolveFrom.silent).mockReturnValue(undefined);
|
||||
jest.mocked(getPackageJson).mockReturnValue({ dependencies: { expo: '~53.0.0' } } as any);
|
||||
|
||||
await expect(getPrivateExpoConfigAsync('/app')).rejects.toThrow(
|
||||
/dependencies to be installed/
|
||||
);
|
||||
expect(getConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws an actionable error when expo is not declared in package.json', async () => {
|
||||
jest.mocked(resolveFrom.silent).mockReturnValue(undefined);
|
||||
jest.mocked(getPackageJson).mockReturnValue({ dependencies: {} } as any);
|
||||
|
||||
await expect(getPrivateExpoConfigAsync('/app')).rejects.toThrow(
|
||||
/The "expo" package was not found/
|
||||
);
|
||||
expect(getConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import { ExpoConfig, getConfig, getConfigFilePaths, modifyConfigAsync } from '@expo/config';
|
||||
import {
|
||||
ExpoConfig,
|
||||
getConfig,
|
||||
getConfigFilePaths,
|
||||
getPackageJson,
|
||||
modifyConfigAsync,
|
||||
} from '@expo/config';
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import { resolvePackageManager } from '@expo/package-manager';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import Joi from 'joi';
|
||||
import path from 'path';
|
||||
import resolveFrom from 'resolve-from';
|
||||
|
||||
import { isExpoInstalled } from './projectUtils';
|
||||
import { link } from '../log';
|
||||
import { spawnExpoCommand } from '../utils/expoCli';
|
||||
|
||||
export type PublicExpoConfig = Omit<
|
||||
@@ -63,12 +73,28 @@ async function getExpoConfigInternalAsync(
|
||||
}
|
||||
);
|
||||
exp = JSON.parse(stdout);
|
||||
} else {
|
||||
} else if (resolveFrom.silent(projectDir, 'expo/package.json')) {
|
||||
// The `expo` package is installed but Expo CLI is not part of it. This happens with old
|
||||
// SDK versions, which predate the local CLI. Since we can't run `expo config`, read the app
|
||||
// config with the copy of `@expo/config` that ships with EAS CLI.
|
||||
exp = getConfig(projectDir, {
|
||||
skipSDKVersionRequirement: true,
|
||||
...(opts.isPublicConfig ? { isPublicConfig: true } : {}),
|
||||
...(opts.skipPlugins ? { skipPlugins: true } : {}),
|
||||
}).exp;
|
||||
} else if (getPackageJson(projectDir)?.dependencies?.expo) {
|
||||
const installCommand = `${resolvePackageManager(projectDir) ?? 'npm'} install`;
|
||||
throw new Error(
|
||||
`EAS CLI needs your project's dependencies to be installed to read your app config. Run ${chalk.bold(
|
||||
installCommand
|
||||
)} in your project directory and run this command again.`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`The "expo" package was not found in your project's dependencies, needed to read your app config. Add "expo" to your dependencies and install it. Refer to the version compatibility table at: ${link(
|
||||
'https://docs.expo.dev/versions/latest/'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const { error } = MinimalAppConfigSchema.validate(exp, {
|
||||
|
||||
@@ -2,18 +2,21 @@ 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 { confirmAsync, promptAsync } from '../prompts';
|
||||
import { Actor } from '../user/User';
|
||||
|
||||
export type InitializeMethodOptions = {
|
||||
@@ -208,11 +211,8 @@ export async function initializeWithoutExplicitIDAsync(
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -325,63 +325,3 @@ 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,
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user