Skip to content
Closed
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
13 changes: 5 additions & 8 deletions packages/steps/src/ActionExpander.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import { BuildStepEnv } from './BuildStepEnv';
import { BuildConfigError } from './errors';
import { duplicates } from './utils/expodash/duplicates';
import { isActionPath, parseActionPath } from './utils/localActions';
import { createBuildStepOutputsFromDefinition, getShellStepDisplayName } from './utils/step';
import {
createBuildStepOutputsFromDefinition,
getShellStepDisplayName,
mergeEnv,
} from './utils/step';

const MAX_ACTION_NESTING_DEPTH = 10;

Expand Down Expand Up @@ -328,10 +332,3 @@ export class ActionExpander {
return { stepIdMap, newIds };
}
}

function mergeEnv(base?: BuildStepEnv, overrides?: BuildStepEnv): BuildStepEnv | undefined {
if (!base && !overrides) {
return undefined;
}
return { ...base, ...overrides };
}
93 changes: 93 additions & 0 deletions packages/steps/src/utils/__tests__/actionInterpolation-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { JobInterpolationContext } from '@expo/eas-build-job';

import { createGlobalContextMock } from '../../__tests__/utils/context';
import {
containsUnresolvedTemplateReference,
resolveInterpolatedTarget,
stringifyInterpolatedResult,
} from '../actionInterpolation';

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, () => {
const neverResolve = (): string => '';

it('passes literal targets through unchanged', () => {
expect(resolveInterpolatedTarget('literal', contextWith(), neverResolve)).toBe('literal');
});

it('resolves ${{ }} expressions against the context', () => {
expect(
resolveInterpolatedTarget(
'${{ env.FOO }}',
contextWith({ env: { FOO: 'bar' } }),
neverResolve
)
).toBe('bar');
});

it('routes legacy ${ steps.* } references through the provided resolver', () => {
const resolver = (path: string): string => (path === 'steps.read.version' ? '1.2.3' : '');
expect(resolveInterpolatedTarget('v${ steps.read.version }', contextWith(), resolver)).toBe(
'v1.2.3'
);
});

it('returns non-string results as-is, without touching the legacy resolver', () => {
const context = contextWith();
const resolver = jest.fn<string, [string]>();
expect(resolveInterpolatedTarget('${{ fromJSON(\'{"a":1}\') }}', context, resolver)).toEqual({
a: 1,
});
expect(resolver).not.toHaveBeenCalled();
});
});

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('recognizes legacy ${ } step output references', () => {
expect(containsUnresolvedTemplateReference('${ steps.a.outputs.v }')).toBe(true);
expect(containsUnresolvedTemplateReference('${ steps.a.v }')).toBe(true);
expect(containsUnresolvedTemplateReference('a-${ steps.a.outputs.v }-b')).toBe(true);
});

it('does not recognize non-step legacy ${ } spans', () => {
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);
});
});
42 changes: 42 additions & 0 deletions packages/steps/src/utils/actionInterpolation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { JobInterpolationContext } from '@expo/eas-build-job';

import { BUILD_STEP_OUTPUT_EXPRESSION_REGEXP, interpolateWithOutputs } from './template';
import { interpolateJobContext } from '../interpolation';

// Non-string results skip the legacy resolver: `${ steps.* }` only appears inside strings.
export function resolveInterpolatedTarget(
target: unknown,
context: JobInterpolationContext,
legacyResolver: (path: string) => string
): unknown {
const resolved = interpolateJobContext({ target, context });
if (typeof resolved === 'string') {
return interpolateWithOutputs(resolved, legacyResolver);
}
return resolved;
}

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 `${{ }}` or legacy `${ steps.* }` only; shell `${FOO:-bar}` / `${ inputs.* }` are excluded.
export function containsUnresolvedTemplateReference(value: unknown): boolean {
return (
typeof value === 'string' &&
(value.includes('${{') || BUILD_STEP_OUTPUT_EXPRESSION_REGEXP.test(value))
);
}
8 changes: 8 additions & 0 deletions packages/steps/src/utils/step.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { ShellStep } from '@expo/eas-build-job';

import { BuildStepGlobalContext } from '../BuildStepContext';
import { BuildStepEnv } from '../BuildStepEnv';
import { BuildStepOutput } from '../BuildStepOutput';

export function mergeEnv(base?: BuildStepEnv, overrides?: BuildStepEnv): BuildStepEnv | undefined {
if (!base && !overrides) {
return undefined;
}
return { ...base, ...overrides };
}

export function getShellStepDisplayName(step: ShellStep): string {
return (
step.name ??
Expand Down
Loading