Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 172 additions & 2 deletions packages/eas-build-job/src/__tests__/compositeFunction.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { ZodError } from 'zod';
import { ZodError, z } from 'zod';

import { CompositeFunctionConfigZ } from '../compositeFunction';
import {
CompositeFunctionConfigZ,
LegacyFunctionConfigZ,
LocalFunctionConfigZ,
isLegacyFunctionConfig,
} from '../compositeFunction';

function parseErrorMessages(schema: z.ZodType, config: unknown): string[] {
const result = schema.safeParse(config);
expect(result.success).toBe(false);
return result.error!.issues.map(issue => issue.message);
}

describe('CompositeFunctionConfigZ', () => {
it('accepts a valid local composite function config', () => {
Expand Down Expand Up @@ -75,4 +86,163 @@ 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);
});
});

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

it('accepts a path function', () => {
const config = { name: 'Build', path: './my-function' };
expect(LegacyFunctionConfigZ.parse(config)).toEqual(config);
});

it('rejects a config declaring both command and path', () => {
expect(parseErrorMessages(LegacyFunctionConfigZ, { command: 'echo hi', path: './fn' })).toEqual([
'A local function must declare either "command" (a shell script) or "path" (a JavaScript function module), not both.',
]);
});

it('rejects a config declaring neither command nor path', () => {
expect(parseErrorMessages(LegacyFunctionConfigZ, { name: 'Nothing' })).toEqual([
'A local function must declare "runs.steps" (a composite function), "command" (a shell script) or "path" (a JavaScript function module).',
]);
});

it('rejects unknown top-level keys', () => {
expect(parseErrorMessages(LegacyFunctionConfigZ, { command: 'echo hi', runz: {} })).toEqual([
'Unrecognized key: "runz"',
]);
});

it('rejects unknown supported platforms', () => {
expect(
parseErrorMessages(LegacyFunctionConfigZ, {
command: 'echo hi',
supported_platforms: ['windows'],
})
).toEqual(['Invalid option: expected one of "darwin"|"linux"']);
});

it('rejects the composite output shape', () => {
expect(
LegacyFunctionConfigZ.safeParse({
command: 'echo hi',
outputs: { version: { value: '1.0.0' } },
}).success
).toBe(false);
});

it('rejects runs.steps', () => {
expect(
LegacyFunctionConfigZ.safeParse({
command: 'echo hi',
runs: { steps: [{ run: 'echo hello' }] },
}).success
).toBe(false);
});
});

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.each([
['command', { command: 'echo hi', runs: { steps: [{ run: 'echo hello' }] } }],
['path', { path: './my-function', runs: { steps: [{ run: 'echo hello' }] } }],
])('rejects a config mixing runs.steps with %s', (_field, config) => {
expect(parseErrorMessages(LocalFunctionConfigZ, config)).toEqual([
'A local function must declare either "runs.steps" (a composite function) or "command"/"path" (a single-step function), not both.',
]);
});

it('rejects a config declaring both command and path', () => {
expect(parseErrorMessages(LocalFunctionConfigZ, { command: 'echo hi', path: './fn' })).toEqual([
'A local function must declare either "command" (a shell script) or "path" (a JavaScript function module), not both.',
]);
});

it('rejects a config declaring none of runs.steps, command and path', () => {
expect(parseErrorMessages(LocalFunctionConfigZ, { name: 'Nothing' })).toEqual([
'A local function must declare "runs.steps" (a composite function), "command" (a shell script) or "path" (a JavaScript function module).',
]);
});

it.each([
['a string', 'command: echo hi'],
['an array', [{ command: 'echo hi' }]],
['null', null],
])('rejects %s in place of a mapping', (_description, config) => {
expect(parseErrorMessages(LocalFunctionConfigZ, config)).toEqual([
'A local function configuration file must contain a mapping of function fields.',
]);
});

it('reports composite function errors with their field paths', () => {
const result = LocalFunctionConfigZ.safeParse({ runs: { steps: [] }, unknown_field: true });
expect(result.success).toBe(false);
expect(z.prettifyError(result.error!)).toMatch(/unknown_field/);
expect(z.prettifyError(result.error!)).toMatch(
/must declare at least one step under "runs.steps"\.\n {2}→ at runs.steps/
);
});

it('reports single-step function errors with their field paths', () => {
const result = LocalFunctionConfigZ.safeParse({ command: 42 });
expect(result.success).toBe(false);
expect(z.prettifyError(result.error!)).toMatch(
/expected string, received number\n {2}→ at command/
);
});
});
187 changes: 182 additions & 5 deletions packages/eas-build-job/src/compositeFunction.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
/**
* Schema for local composite functions, reusable step groups referenced via `uses:` in EAS
* workflows (`.eas/workflows/*.yml`) or inline job step definitions.
* Schema for local functions, reusable units of work 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.
* This module defines the shape of a local function configuration file (`function.yml`). Such a
* file declares either a composite function (a group of steps under `runs.steps`) or a
* single-step function carrying a shell `command` or a `path` to a prebuilt JavaScript module,
* the shape custom build functions use in `.eas/build/*.yml` configs. The two shapes are mutually
* exclusive; `LocalFunctionConfigZ` accepts both and rejects mixtures.
*
* Callers that load local function files format validation errors from `LocalFunctionConfigZ`.
* Local functions are not supported in `.eas/build/*.yml` custom build config files.
*/
import { z } from 'zod';

Expand Down Expand Up @@ -91,6 +96,14 @@ export const CompositeFunctionConfigZ = z
})
.describe('Steps executed when the composite function is invoked.'),
}),

// Single-step function fields, declared so a composite config narrows against
// `LegacyFunctionConfig` in TypeScript (the `StepZ` pattern). Runtime rejection already
// comes from `.strict()`.
command: z.never().optional(),
path: z.never().optional(),
shell: z.never().optional(),
supported_platforms: z.never().optional(),
})
.strict();

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

const LegacyFunctionOutputZ = z.union([
z.string().describe('Shorthand for a required output name.'),
z
.object({
name: z.string(),
required: z.boolean().optional(),
})
.strict(),
]);

// Spelled out instead of imported from @expo/steps (`BuildRuntimePlatform`): this package is the
// dependency, not the dependent.
const LegacyFunctionPlatformZ = z.enum(['darwin', 'linux']);

const MIXED_SHAPES_ERROR =
'A local function must declare either "runs.steps" (a composite function) or "command"/"path" (a single-step function), not both.';
const COMMAND_AND_PATH_ERROR =
'A local function must declare either "command" (a shell script) or "path" (a JavaScript function module), not both.';
const NO_SHAPE_ERROR =
'A local function must declare "runs.steps" (a composite function), "command" (a shell script) or "path" (a JavaScript function module).';

/**
* The single-step function shape: the body of a custom build function from a `.eas/build/*.yml`
* config, usable as a `function.yml` verbatim.
*/
export const LegacyFunctionConfigZ = z
.object({
/**
* @example
* name: Say hi
*/
name: z.string().optional().describe('Display name of the 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 function. Required unless declared otherwise.'),
/**
* @example
* outputs:
* - name: version
* - name: sha
* required: false
*/
outputs: z
.array(LegacyFunctionOutputZ)
.optional()
.describe('Outputs the function sets. Required unless declared otherwise.'),
/**
* @example
* command: echo "Hi, ${ inputs.name }!"
*/
command: z.string().optional().describe('Shell script executed when the function is invoked.'),
/**
* @example
* path: ./my-function
*/
path: z
.string()
.optional()
.describe(
'Directory with a prebuilt JavaScript module and its package.json, resolved relative to the function file.'
),
shell: z.string().optional().describe('Shell to run "command" with.'),
supported_platforms: z
.array(LegacyFunctionPlatformZ)
.optional()
.describe('Runtime platforms the function can run on.'),

// See the note on `CompositeFunctionConfigZ.command`.
runs: z.never().optional(),
})
.strict()
.superRefine((config, ctx) => {
if (config.command !== undefined && config.path !== undefined) {
ctx.addIssue({ code: 'custom', message: COMMAND_AND_PATH_ERROR });
return;
}
if (config.command === undefined && config.path === undefined) {
ctx.addIssue({ code: 'custom', message: NO_SHAPE_ERROR });
}
});

/**
* Structure of a single-step local function configuration file (`function.yml`).
*
* @example
* name: Say hi
* inputs:
* - name
* command: echo "Hi, ${ inputs.name }!"
*/
export type LegacyFunctionConfig = z.infer<typeof LegacyFunctionConfigZ>;

// YAML never produces `undefined`, but an explicitly-undefined field in a hand-built object must
// read as absent so it routes the same way a missing field does.
function declaresField(config: Record<string, unknown>, field: string): boolean {
return field in config && config[field] !== undefined;
}

/**
* Both shapes of a local function configuration file, dispatched on which one the file declares.
*
* Sniffing the shape rather than unioning the two schemas keeps the errors precise: a bare union
* of strict objects reports every branch's failures for one typo.
*/
export const LocalFunctionConfigZ = z.unknown().transform((value, ctx) => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
ctx.addIssue({
code: 'custom',
message: 'A local function configuration file must contain a mapping of function fields.',
});
return z.NEVER;
}

const declared = value as Record<string, unknown>;
const declaresRuns = declaresField(declared, 'runs');
const declaresCommand = declaresField(declared, 'command');
const declaresPath = declaresField(declared, 'path');

if (declaresRuns && (declaresCommand || declaresPath)) {
ctx.addIssue({ code: 'custom', message: MIXED_SHAPES_ERROR });
return z.NEVER;
}
if (declaresCommand && declaresPath) {
ctx.addIssue({ code: 'custom', message: COMMAND_AND_PATH_ERROR });
return z.NEVER;
}
if (!declaresRuns && !declaresCommand && !declaresPath) {
ctx.addIssue({ code: 'custom', message: NO_SHAPE_ERROR });
return z.NEVER;
}

const result = declaresRuns
? CompositeFunctionConfigZ.safeParse(value)
: LegacyFunctionConfigZ.safeParse(value);
if (!result.success) {
// Re-emitted with their paths intact so `z.prettifyError` still points at the offending field.
// The cast only adds the index signature `addIssue` accepts on raw issues.
for (const issue of result.error.issues) {
ctx.addIssue(issue as z.core.$ZodSuperRefineIssue);
}
return z.NEVER;
}
return result.data;
});

/** A local function configuration file: either shape. */
export type LocalFunctionConfig = CompositeFunctionConfig | LegacyFunctionConfig;

export function isLegacyFunctionConfig(
config: LocalFunctionConfig
): config is LegacyFunctionConfig {
return config.runs === undefined;
}

export type CompositeFunctionCatalog = Record<string, CompositeFunctionConfig>;
Loading