Skip to content

Commit

Permalink
[ENG-9500] Allow calling functions with arguments in template files
Browse files Browse the repository at this point in the history
  • Loading branch information
khamilowicz committed Apr 16, 2024
1 parent 5ea94dd commit 3a80812
Show file tree
Hide file tree
Showing 18 changed files with 685 additions and 103 deletions.
136 changes: 136 additions & 0 deletions packages/build-tools/src/steps/functions/__tests__/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';

import {
BuildRuntimePlatform,
BuildStepGlobalContext,
ExternalBuildContextProvider,
CacheManager,
} from '@expo/steps';
import { anything, capture, instance, mock, reset, verify, when } from 'ts-mockito';

import { createLogger } from '../../../__mocks__/@expo/logger';
import { createRestoreCacheBuildFunction, createSaveCacheBuildFunction } from '../cache';

const cacheSaveBuildFunction = createSaveCacheBuildFunction();
const cacheRestoreBuildFunction = createRestoreCacheBuildFunction();

const providerMock = mock<ExternalBuildContextProvider>();
const cacheManagerMock = mock<CacheManager>();

const cacheManager = instance(cacheManagerMock);
const initialCache = { downloadUrls: {} };

const provider = instance(providerMock);

let ctx: BuildStepGlobalContext;

const existingKey =
'c7d8e33243968f8675ec0463ad89e11c1e754723695ab9b23dfb8f9ddd389a28-value-8b6e2366e2a2ff8b43556a1dcc5f1cf97ddcf4cdf3c8f9a6d54e0efe2e747922';

describe('cache functions', () => {
let key: string;
let paths: string[];
beforeEach(async () => {
key = '${ hashFiles("./src/*") }-value';
paths = ['path1', 'path2'];
reset(cacheManagerMock);
reset(providerMock);

const projectSourceDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'project-'));
when(providerMock.logger).thenReturn(createLogger());
when(providerMock.runtimePlatform).thenReturn(BuildRuntimePlatform.LINUX);
when(providerMock.staticContext()).thenReturn({ some: 'key', job: { cache: initialCache } });
when(providerMock.cacheManager).thenReturn(cacheManager);
when(providerMock.projectSourceDirectory).thenReturn(projectSourceDirectory);
when(providerMock.defaultWorkingDirectory).thenReturn(projectSourceDirectory);
when(providerMock.projectTargetDirectory).thenReturn(projectSourceDirectory);

ctx = new BuildStepGlobalContext(provider, false);

await fs.mkdir(path.join(projectSourceDirectory, 'src'));
await fs.writeFile(path.join(projectSourceDirectory, 'src', 'path1'), 'placeholder');
await fs.writeFile(path.join(projectSourceDirectory, 'src', 'path2'), 'placeholder');
});

describe('cacheRestoreBuildFunction', () => {
test('has correct identifiers', () => {
expect(cacheRestoreBuildFunction.id).toBe('restore-cache');
expect(cacheRestoreBuildFunction.namespace).toBe('eas');
expect(cacheRestoreBuildFunction.name).toBe('Restore Cache');
});

test('restores cache if it exists', async () => {
when(cacheManagerMock.restoreCache(anything(), anything()));
initialCache.downloadUrls = { [existingKey]: 'url' };

const buildStep = cacheRestoreBuildFunction.createBuildStepFromFunctionCall(ctx, {
callInputs: { key, paths },
});

when(providerMock.defaultWorkingDirectory).thenReturn('/tmp');

await buildStep.executeAsync();

verify(cacheManagerMock.restoreCache(anything(), anything())).once();

const [, cache] = capture(cacheManagerMock.restoreCache).first();
expect(cache.key).toMatch(/^\w+-value/);
expect(cache.paths).toStrictEqual(paths);
});

test("doesn't restore cache if it doesn't exist", async () => {
when(cacheManagerMock.restoreCache(anything(), anything()));
initialCache.downloadUrls = { invalidkey: 'url' };

const buildStep = cacheRestoreBuildFunction.createBuildStepFromFunctionCall(ctx, {
callInputs: { key, paths },
});

await buildStep.executeAsync();

verify(cacheManagerMock.restoreCache(anything(), anything())).never();
});
});

describe('cacheSaveBuildFunction', () => {
test('has correct identifiers', () => {
expect(cacheSaveBuildFunction.id).toBe('save-cache');
expect(cacheSaveBuildFunction.namespace).toBe('eas');
expect(cacheSaveBuildFunction.name).toBe('Save Cache');
});

test('saves cache if it does not exist', async () => {
when(cacheManagerMock.restoreCache(anything(), anything()));

initialCache.downloadUrls = {};

const buildStep = cacheSaveBuildFunction.createBuildStepFromFunctionCall(ctx, {
callInputs: { key, paths },
});

await buildStep.executeAsync();

verify(cacheManagerMock.saveCache(anything(), anything())).once();

const [, cache] = capture(cacheManagerMock.saveCache).first();
expect(cache?.key).toMatch(/^\w+-value/);
expect(cache?.paths).toStrictEqual(paths);
});

test("doesn't save cache if it exists", async () => {
when(cacheManagerMock.restoreCache(anything(), anything()));

initialCache.downloadUrls = { [existingKey]: 'url' };

const buildStep = cacheSaveBuildFunction.createBuildStepFromFunctionCall(ctx, {
callInputs: { key, paths },
});

await buildStep.executeAsync();

verify(cacheManagerMock.saveCache(anything(), anything())).never();
});
});
});
1 change: 1 addition & 0 deletions packages/steps/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ if [[ "$npm_lifecycle_event" == "prepack" ]]; then
echo 'Removing "dist_commonjs" and "dist_esm" folders...'
rm -rf dist_commonjs dist_esm
fi
rm -rf dist_commonjs dist_esm

echo 'Compiling TypeScript to JavaScript...'
node_modules/.bin/tsc --project tsconfig.build.json
Expand Down
1 change: 1 addition & 0 deletions packages/steps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"license": "BUSL-1.1",
"devDependencies": {
"@jest/globals": "^29.7.0",
"@types/glob": "^8.1.0",
"@types/jest": "^29.5.11",
"@types/lodash.clonedeep": "^4.5.9",
"@types/lodash.get": "^4.4.9",
Expand Down
164 changes: 87 additions & 77 deletions packages/steps/src/BuildConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import path from 'path';
import Joi from 'joi';
import YAML from 'yaml';

import { BuildConfigError, BuildWorkflowError } from './errors.js';
import { BuildRuntimePlatform } from './BuildRuntimePlatform.js';
import { BuildStepEnv } from './BuildStepEnv.js';
import {
BuildStepInputValueTypeWithRequired,
BuildStepInputValueTypeName,
BuildStepInputValueType,
BuildStepInputValueTypeName,
BuildStepInputValueTypeWithRequired,
} from './BuildStepInput.js';
import { BuildStepEnv } from './BuildStepEnv.js';
import { BuildConfigError, BuildWorkflowError } from './errors.js';
import { BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX } from './utils/template.js';

export type BuildFunctions = Record<string, BuildFunctionConfig>;
Expand Down Expand Up @@ -92,82 +92,86 @@ const BuildFunctionInputsSchema = Joi.array().items(
Joi.alternatives().conditional(Joi.ref('.'), {
is: Joi.string(),
then: Joi.string().required(),
otherwise: Joi.object({
name: Joi.string().required(),
defaultValue: Joi.when('allowedValues', {
is: Joi.exist(),
then: Joi.valid(Joi.in('allowedValues')).messages({
'any.only': '{{#label}} must be one of allowed values',
}),
})
.when('allowedValueType', {
is: BuildStepInputValueTypeName.STRING,
then: Joi.string().allow(''),
})
.when('allowedValueType', {
is: BuildStepInputValueTypeName.BOOLEAN,
then: Joi.alternatives(
Joi.boolean(),
Joi.string().pattern(
BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX,
'context or output reference regex pattern'
)
).messages({
'alternatives.types':
'{{#label}} must be a boolean or reference to output or context value',
otherwise: Joi.alternatives().conditional(Joi.ref('.'), {
is: Joi.array(),
then: Joi.array().required(),
otherwise: Joi.object({
name: Joi.string().required(),
defaultValue: Joi.when('allowedValues', {
is: Joi.exist(),
then: Joi.valid(Joi.in('allowedValues')).messages({
'any.only': '{{#label}} must be one of allowed values',
}),
})
.when('allowedValueType', {
is: BuildStepInputValueTypeName.NUMBER,
then: Joi.alternatives(
Joi.number(),
Joi.string().pattern(
BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX,
'context or output reference regex pattern'
)
).messages({
'alternatives.types':
'{{#label}} must be a number or reference to output or context value',
.when('allowedValueType', {
is: BuildStepInputValueTypeName.STRING,
then: Joi.string().allow(''),
})
.when('allowedValueType', {
is: BuildStepInputValueTypeName.BOOLEAN,
then: Joi.alternatives(
Joi.boolean(),
Joi.string().pattern(
BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX,
'context or output reference regex pattern'
)
).messages({
'alternatives.types':
'{{#label}} must be a boolean or reference to output or context value',
}),
})
.when('allowedValueType', {
is: BuildStepInputValueTypeName.NUMBER,
then: Joi.alternatives(
Joi.number(),
Joi.string().pattern(
BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX,
'context or output reference regex pattern'
)
).messages({
'alternatives.types':
'{{#label}} must be a number or reference to output or context value',
}),
})
.when('allowedValueType', {
is: BuildStepInputValueTypeName.JSON,
then: Joi.alternatives(
Joi.object(),
Joi.string().pattern(
BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX,
'context or output reference regex pattern'
)
).messages({
'alternatives.types':
'{{#label}} must be a object or reference to output or context value',
}),
}),
allowedValues: Joi.when('allowedValueType', {
is: BuildStepInputValueTypeName.STRING,
then: Joi.array().items(Joi.string().allow('')),
})
.when('allowedValueType', {
is: BuildStepInputValueTypeName.JSON,
then: Joi.alternatives(
Joi.object(),
Joi.string().pattern(
BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX,
'context or output reference regex pattern'
)
).messages({
'alternatives.types':
'{{#label}} must be a object or reference to output or context value',
.when('allowedValueType', {
is: BuildStepInputValueTypeName.BOOLEAN,
then: Joi.array().items(Joi.boolean()),
})
.when('allowedValueType', {
is: BuildStepInputValueTypeName.NUMBER,
then: Joi.array().items(Joi.number()),
})
.when('allowedValueType', {
is: BuildStepInputValueTypeName.JSON,
then: Joi.array().items(Joi.object()),
}),
}),
allowedValues: Joi.when('allowedValueType', {
is: BuildStepInputValueTypeName.STRING,
then: Joi.array().items(Joi.string().allow('')),
allowedValueType: Joi.string()
.valid(...Object.values(BuildStepInputValueTypeName))
.default(BuildStepInputValueTypeName.STRING),
required: Joi.boolean(),
})
.when('allowedValueType', {
is: BuildStepInputValueTypeName.BOOLEAN,
then: Joi.array().items(Joi.boolean()),
})
.when('allowedValueType', {
is: BuildStepInputValueTypeName.NUMBER,
then: Joi.array().items(Joi.number()),
})
.when('allowedValueType', {
is: BuildStepInputValueTypeName.JSON,
then: Joi.array().items(Joi.object()),
}),
allowedValueType: Joi.string()
.valid(...Object.values(BuildStepInputValueTypeName))
.default(BuildStepInputValueTypeName.STRING),
required: Joi.boolean(),
})
.rename('allowed_values', 'allowedValues')
.rename('default_value', 'defaultValue')
.rename('type', 'allowedValueType')
.required(),
.rename('allowed_values', 'allowedValues')
.rename('default_value', 'defaultValue')
.rename('type', 'allowedValueType')
.required(),
}),
})
);

Expand All @@ -185,7 +189,13 @@ const BuildFunctionCallSchema = Joi.object({
id: Joi.string(),
inputs: Joi.object().pattern(
Joi.string(),
Joi.alternatives().try(Joi.string().allow(''), Joi.boolean(), Joi.number(), Joi.object())
Joi.alternatives().try(
Joi.string().allow(''),
Joi.boolean(),
Joi.number(),
Joi.object(),
Joi.array().items(Joi.string().required())
)
),
name: Joi.string(),
workingDirectory: Joi.string(),
Expand Down Expand Up @@ -393,11 +403,11 @@ export function mergeConfigWithImportedFunctions(
}

export function isBuildStepCommandRun(step: BuildStepConfig): step is BuildStepCommandRun {
return Boolean(step) && typeof step === 'object' && typeof step.run === 'object';
return Boolean(step) && typeof step === 'object' && 'run' in step && typeof step.run === 'object';
}

export function isBuildStepBareCommandRun(step: BuildStepConfig): step is BuildStepBareCommandRun {
return Boolean(step) && typeof step === 'object' && typeof step.run === 'string';
return Boolean(step) && typeof step === 'object' && 'run' in step && typeof step.run === 'string';
}

export function isBuildStepFunctionCall(step: BuildStepConfig): step is BuildStepFunctionCall {
Expand Down
2 changes: 1 addition & 1 deletion packages/steps/src/BuildConfigParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ import {
} from './BuildConfig.js';
import { BuildFunction, BuildFunctionById } from './BuildFunction.js';
import { BuildStep } from './BuildStep.js';
import { BuildStepGlobalContext } from './BuildStepContext.js';
import {
BuildStepInput,
BuildStepInputProvider,
BuildStepInputValueTypeName,
} from './BuildStepInput.js';
import { BuildStepGlobalContext } from './BuildStepContext.js';
import { BuildStepOutput, BuildStepOutputProvider } from './BuildStepOutput.js';
import { BuildWorkflow } from './BuildWorkflow.js';
import { BuildWorkflowValidator } from './BuildWorkflowValidator.js';
Expand Down
Loading

0 comments on commit 3a80812

Please sign in to comment.