Skip to content
Open
4 changes: 2 additions & 2 deletions packages/build-tools/src/common/jobHooks.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {
BuildJob,
CompositeFunctionCatalog,
ErrorCode,
HookAnchorId,
HookKey,
LocalFunctionCatalog,
UserError,
parseHookKey,
validateSteps,
Expand Down Expand Up @@ -98,7 +98,7 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
// outputs accumulate across keys.
const hookEntriesByKey: Partial<Record<HookKey, HookEntry[]>> = {};
const orderedSteps: BuildStep[] = [];
const compositeFunctionCatalog: CompositeFunctionCatalog = {};
const compositeFunctionCatalog: LocalFunctionCatalog = {};
const loadCompositeFunction = createLocalCompositeFunctionLoader(
ctx.getReactNativeProjectDirectory(),
{ logger: ctx.logger }
Expand Down
18 changes: 18 additions & 0 deletions packages/eas-build-job/src/__tests__/compositeFunction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ describe('CompositeFunctionConfigZ', () => {
});
});

it('rejects the camelCase spellings the legacy shape accepts', () => {
const config = {
supportedRuntimePlatforms: ['darwin'],
inputs: [{ name: 'greeting', defaultValue: 'Hi' }],
runs: { steps: [{ run: 'echo hello' }] },
};
expect(() => CompositeFunctionConfigZ.parse(config)).toThrow(ZodError);
expect(() => CompositeFunctionConfigZ.parse(config)).toThrow(/supportedRuntimePlatforms/);
});

it('rejects unknown top-level keys', () => {
const config = {
unknown_field: true,
Expand All @@ -75,4 +85,12 @@ describe('CompositeFunctionConfigZ', () => {
);
}
});

it('rejects the single-step output shape', () => {
const config = {
outputs: [{ name: 'version' }],
runs: { steps: [{ run: 'echo hello' }] },
};
expect(() => CompositeFunctionConfigZ.parse(config)).toThrow(ZodError);
});
});
161 changes: 161 additions & 0 deletions packages/eas-build-job/src/__tests__/legacyFunction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { ZodError } from 'zod';

import { LegacyCommandFunctionConfigZ, LegacyPathFunctionConfigZ } from '../legacyFunction';

describe('LegacyCommandFunctionConfigZ', () => {
it('accepts a command function with inputs, outputs, shell and supported platforms', () => {
const config = {
name: 'Say hi',
inputs: [
'name',
{ name: 'greeting', type: 'string', default_value: 'Hi', allowed_values: ['Hi', 'Hello'] },
{ name: 'loud', type: 'boolean', required: false },
{ name: 'suffix' },
],
outputs: ['greeted', { name: 'skipped', required: false }],
command: 'echo "${ inputs.greeting }, ${ inputs.name }!"',
shell: 'sh',
supported_platforms: ['darwin', 'linux'],
};
expect(LegacyCommandFunctionConfigZ.parse(config)).toEqual({
...config,
inputs: [
'name',
{ name: 'greeting', type: 'string', default_value: 'Hi', allowed_values: ['Hi', 'Hello'] },
{ name: 'loud', type: 'boolean', required: false },
{ name: 'suffix', type: 'string' },
],
});
});

it('accepts a minimal config with only command', () => {
const config = { command: 'echo hi' };
expect(LegacyCommandFunctionConfigZ.parse(config)).toEqual(config);
});

it('accepts camelCase supportedRuntimePlatforms and normalizes it to supported_platforms', () => {
const config = { command: 'echo hi', supportedRuntimePlatforms: ['darwin'] };
expect(LegacyCommandFunctionConfigZ.parse(config)).toEqual({
command: 'echo hi',
supported_platforms: ['darwin'],
});
});

it('accepts camelCase input keys and normalizes them to the snake_case spellings', () => {
const config = {
command: 'echo hi',
inputs: [
{
name: 'greeting',
allowedValueType: 'string',
defaultValue: 'Hi',
allowedValues: ['Hi', 'Hello'],
},
],
};
expect(LegacyCommandFunctionConfigZ.parse(config)).toEqual({
command: 'echo hi',
inputs: [
{ name: 'greeting', type: 'string', default_value: 'Hi', allowed_values: ['Hi', 'Hello'] },
],
});
});

it('accepts mixed spellings across different fields', () => {
const config = {
command: 'echo hi',
supportedRuntimePlatforms: ['linux'],
inputs: [{ name: 'greeting', type: 'string', defaultValue: 'Hi', allowed_values: ['Hi'] }],
};
expect(LegacyCommandFunctionConfigZ.parse(config)).toEqual({
command: 'echo hi',
supported_platforms: ['linux'],
inputs: [{ name: 'greeting', type: 'string', default_value: 'Hi', allowed_values: ['Hi'] }],
});
});

it('rejects a config declaring both spellings of the same field', () => {
const config = {
command: 'echo hi',
supported_platforms: ['darwin'],
supportedRuntimePlatforms: ['linux'],
};
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError);
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(/supportedRuntimePlatforms/);
});

it('rejects an input declaring both spellings of the same field', () => {
const config = {
command: 'echo hi',
inputs: [{ name: 'greeting', default_value: 'Hi', defaultValue: 'Hello' }],
};
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError);
});

it('rejects unknown top-level keys', () => {
const config = { command: 'echo hi', runz: {} };
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError);
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(/runz/);
});

it('rejects description, which the legacy shape does not support', () => {
const config = { command: 'echo hi', description: 'x' };
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError);
});

it('rejects unknown supported platforms', () => {
const config = { command: 'echo hi', supported_platforms: ['windows'] };
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError);
});

it('rejects the composite output shape', () => {
const config = { command: 'echo hi', outputs: { version: { value: '1.0.0' } } };
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError);
});

it('rejects a config declaring runs.steps', () => {
const config = { command: 'echo hi', runs: { steps: [{ run: 'echo hello' }] } };
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError);
});

it('rejects a config also declaring path', () => {
const config = { command: 'echo hi', path: './fn' };
expect(() => LegacyCommandFunctionConfigZ.parse(config)).toThrow(ZodError);
});
});

describe('LegacyPathFunctionConfigZ', () => {
it('accepts a minimal config with only path', () => {
const config = { path: './my-function' };
expect(LegacyPathFunctionConfigZ.parse(config)).toEqual(config);
});

it('accepts shell alongside path', () => {
const config = { path: './my-function', shell: 'sh' };
expect(LegacyPathFunctionConfigZ.parse(config)).toEqual(config);
});

it('accepts camelCase supportedRuntimePlatforms and normalizes it to supported_platforms', () => {
const config = { path: './my-function', supportedRuntimePlatforms: ['linux'] };
expect(LegacyPathFunctionConfigZ.parse(config)).toEqual({
path: './my-function',
supported_platforms: ['linux'],
});
});

it('rejects unknown top-level keys', () => {
const config = { path: './fn', run: 'echo' };
expect(() => LegacyPathFunctionConfigZ.parse(config)).toThrow(ZodError);
expect(() => LegacyPathFunctionConfigZ.parse(config)).toThrow(/run/);
});

it('rejects a config declaring runs.steps', () => {
const config = { path: './fn', runs: { steps: [{ run: 'echo hello' }] } };
expect(() => LegacyPathFunctionConfigZ.parse(config)).toThrow(ZodError);
});

it('rejects a config also declaring command', () => {
const config = { path: './fn', command: 'echo hi' };
expect(() => LegacyPathFunctionConfigZ.parse(config)).toThrow(ZodError);
});
});
108 changes: 108 additions & 0 deletions packages/eas-build-job/src/__tests__/localFunction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { z } from 'zod';

import { LocalFunctionConfigZ, isLegacyFunctionConfig } from '../localFunction';

const GENERIC_ERROR_MESSAGE =
'A local function must declare exactly one of "runs.steps" (a composite function), "command" (a shell script) or "path" (a JavaScript function module), and its fields must match that shape.';

describe('LocalFunctionConfigZ', () => {
it('parses a composite function', () => {
const config = {
name: 'Setup',
inputs: ['greeting'],
outputs: { version: { value: '${{ steps.read.outputs.version }}' } },
runs: { steps: [{ id: 'read', run: 'set-output version "1.0.0"' }] },
};
const parsed = LocalFunctionConfigZ.parse(config);
expect(parsed).toEqual(config);
expect(isLegacyFunctionConfig(parsed)).toBe(false);
});

it('parses a command function', () => {
const config = { name: 'Say hi', inputs: ['name'], command: 'echo hi' };
const parsed = LocalFunctionConfigZ.parse(config);
expect(parsed).toEqual(config);
expect(isLegacyFunctionConfig(parsed)).toBe(true);
});

it('parses a path function', () => {
const parsed = LocalFunctionConfigZ.parse({ path: './my-function' });
expect(parsed).toEqual({ path: './my-function' });
expect(isLegacyFunctionConfig(parsed)).toBe(true);
});

it('parses a command function using the camelCase spellings of custom build configs', () => {
const parsed = LocalFunctionConfigZ.parse({
command: 'echo hi',
supportedRuntimePlatforms: ['darwin'],
});
expect(parsed).toEqual({ command: 'echo hi', supported_platforms: ['darwin'] });
expect(isLegacyFunctionConfig(parsed)).toBe(true);
});

it('surfaces the min-steps issue of the composite branch with its field path', () => {
const result = LocalFunctionConfigZ.safeParse({ runs: { steps: [] } });
expect(result.success).toBe(false);
expect(z.prettifyError(result.error!)).toMatch(
/must declare at least one step under "runs.steps"\.\n {2}→ at runs.steps/
);
});

it.each<[string, unknown, string]>([
[
'an unknown top-level key on a composite function',
{ shel: 'bash', runs: { steps: [{ run: 'echo hi' }] } },
'✖ Unrecognized key: "shel"',
],
[
'an unknown top-level key on a command function',
{ command: 'echo hi', runz: {} },
'✖ Unrecognized key: "runz"',
],
[
'an unknown top-level key on a path function',
{ path: './fn', run: 'echo' },
'✖ Unrecognized key: "run"',
],
[
'an unknown supported platform',
{ command: 'echo hi', supported_platforms: ['windows'] },
'✖ Invalid option: expected one of "darwin"|"linux" (at supported_platforms.0)',
],
[
'the composite output shape on a single-step function',
{ command: 'echo hi', outputs: { version: { value: '1.0.0' } } },
'✖ Invalid input: expected array, received object (at outputs)',
],
[
'a non-string command',
{ command: 42 },
'✖ Invalid input: expected string, received number (at command)',
],
])('rejects %s with the field-level branch error', (_description, config, expectedError) => {
const result = LocalFunctionConfigZ.safeParse(config);
expect(result.success).toBe(false);
expect(z.prettifyError(result.error!)).toBe(expectedError);
});

it.each<[string, unknown]>([
[
'a config mixing runs.steps with command',
{ command: 'echo hi', runs: { steps: [{ run: 'echo hello' }] } },
],
[
'a config mixing runs.steps with path',
{ path: './my-function', runs: { steps: [{ run: 'echo hello' }] } },
],
['a config declaring both command and path', { command: 'echo hi', path: './fn' }],
['a config declaring none of runs.steps, command and path', { name: 'Nothing' }],
['an empty mapping', {}],
['a string in place of a mapping', 'command: echo hi'],
['an array in place of a mapping', [{ command: 'echo hi' }]],
['null in place of a mapping', null],
])('rejects %s with the generic union message', (_description, config) => {
const result = LocalFunctionConfigZ.safeParse(config);
expect(result.success).toBe(false);
expect(result.error!.issues.map(issue => issue.message)).toEqual([GENERIC_ERROR_MESSAGE]);
});
});
19 changes: 12 additions & 7 deletions packages/eas-build-job/src/compositeFunction.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
/**
* 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.
* Schema for the composite shape of a local function: a reusable group of steps declared under
* `runs.steps` in a `function.yml` file. One branch of `LocalFunctionConfigZ` in `./localFunction`.
*/
import { z } from 'zod';

import { StepZ } from './step';

const CompositeFunctionInputValueTypeNameZ = z.enum(['string', 'boolean', 'number', 'json']);

export type CompositeFunctionInputValueTypeName = z.infer<
typeof CompositeFunctionInputValueTypeNameZ
>;

const CompositeFunctionInputValueZ = z.union([
z.string(),
z.boolean(),
Expand All @@ -20,7 +20,7 @@ const CompositeFunctionInputValueZ = z.union([
z.record(z.string(), z.unknown()),
]);

const CompositeFunctionInputZ = z.union([
export const CompositeFunctionInputZ = z.union([
z
.string()
.describe('Shorthand for an input name with default type "string" and no default value.'),
Expand Down Expand Up @@ -91,6 +91,10 @@ export const CompositeFunctionConfigZ = z
})
.describe('Steps executed when the composite function is invoked.'),
}),
command: z.never().optional(),
path: z.never().optional(),
shell: z.never().optional(),
supported_platforms: z.never().optional(),
})
.strict();

Expand All @@ -111,4 +115,5 @@ export const CompositeFunctionConfigZ = z
*/
export type CompositeFunctionConfig = z.infer<typeof CompositeFunctionConfigZ>;

/** @deprecated Use `LocalFunctionCatalog` for catalogs that may contain legacy functions. */
export type CompositeFunctionCatalog = Record<string, CompositeFunctionConfig>;
2 changes: 2 additions & 0 deletions packages/eas-build-job/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export * from './generic';
export * from './hooks';
export * from './step';
export * from './compositeFunction';
export * from './legacyFunction';
export * from './localFunction';
export * from './submission-config';
export * from './projectPackage';
export * from './deviceRunSession';
Expand Down
Loading
Loading