From c6d55227a140b7e58526c008a50dae638a70f966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Thu, 23 Jul 2026 22:43:42 +0200 Subject: [PATCH 1/7] [steps] Store build step outputs in a Map --- .../installMaestro.test.ts | 2 +- .../__tests__/configureIosCredentials.test.ts | 2 +- .../__tests__/configureIosVersion.test.ts | 2 +- .../steps/functions/__tests__/deploy.test.ts | 44 +++++++++++-------- .../steps/functions/__tests__/export.test.ts | 4 +- .../functions/__tests__/readAppConfig.test.ts | 4 +- .../functions/__tests__/readIpaInfo.test.ts | 8 ++-- .../__tests__/readPackageJson.test.ts | 4 +- .../steps/functions/__tests__/repack.test.ts | 6 +-- .../__tests__/waitForPosthogMetric.test.ts | 2 +- packages/steps/src/BuildStep.ts | 25 ++++++----- packages/steps/src/BuildStepOutput.ts | 11 ++--- .../src/__tests__/BuildConfigParser-test.ts | 21 ++++----- .../steps/src/__tests__/BuildFunction-test.ts | 4 +- .../steps/src/__tests__/BuildStep-test.ts | 6 +-- .../src/__tests__/BuildStepOutput-test.ts | 25 ++++++++--- .../src/__tests__/StepsConfigParser-test.ts | 16 +++---- 17 files changed, 102 insertions(+), 84 deletions(-) diff --git a/packages/build-tools/src/steps/functions/__integration-tests__/installMaestro.test.ts b/packages/build-tools/src/steps/functions/__integration-tests__/installMaestro.test.ts index 88b8e789bd..f3b7529830 100644 --- a/packages/build-tools/src/steps/functions/__integration-tests__/installMaestro.test.ts +++ b/packages/build-tools/src/steps/functions/__integration-tests__/installMaestro.test.ts @@ -38,7 +38,7 @@ describe('createInstallMaestroBuildFunction', () => { ); await expect(downgradeStep.executeAsync()).resolves.not.toThrow(); - expect(downgradeStep.outputById.maestro_version.value).toBe('1.40.0'); + expect(downgradeStep.outputById.get('maestro_version')!.value).toBe('1.40.0'); const latestStep = installMaestro.createBuildStepFromFunctionCall( createGlobalContextMock({ logger }), diff --git a/packages/build-tools/src/steps/functions/__tests__/configureIosCredentials.test.ts b/packages/build-tools/src/steps/functions/__tests__/configureIosCredentials.test.ts index a0d4662a19..2b6e25a0b8 100644 --- a/packages/build-tools/src/steps/functions/__tests__/configureIosCredentials.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/configureIosCredentials.test.ts @@ -76,6 +76,6 @@ describe(configureIosCredentialsFunction, () => { await buildStep.executeAsync(); expect(configureCredentialsAsync).toHaveBeenCalled(); - expect(buildStep.outputById.target_names.value).toBe(JSON.stringify(['app', 'widget'])); + expect(buildStep.outputById.get('target_names')!.value).toBe(JSON.stringify(['app', 'widget'])); }); }); diff --git a/packages/build-tools/src/steps/functions/__tests__/configureIosVersion.test.ts b/packages/build-tools/src/steps/functions/__tests__/configureIosVersion.test.ts index b0e7241a72..5e1b19efc5 100644 --- a/packages/build-tools/src/steps/functions/__tests__/configureIosVersion.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/configureIosVersion.test.ts @@ -215,7 +215,7 @@ describe(configureIosVersionFunction, () => { }).executeAsync(); expect(prepareSpy).toHaveBeenCalledTimes(1); - expect(configureIosCredentialsStep.outputById.target_names.value).toBe( + expect(configureIosCredentialsStep.outputById.get('target_names')!.value).toBe( JSON.stringify(['app', 'widget']) ); expect(updateVersionsAsync).toHaveBeenCalledWith( diff --git a/packages/build-tools/src/steps/functions/__tests__/deploy.test.ts b/packages/build-tools/src/steps/functions/__tests__/deploy.test.ts index c361fe34db..ed161d56f4 100644 --- a/packages/build-tools/src/steps/functions/__tests__/deploy.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/deploy.test.ts @@ -69,12 +69,14 @@ describe(createEasDeployBuildFunction, () => { logger: buildStep.ctx.logger, }), }); - expect(buildStep.outputById.deploy_json.value).toBe(mockDeployStdout); - expect(buildStep.outputById.deploy_url.value).toBe('https://example.prod'); - expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev'); - expect(buildStep.outputById.deploy_identifier.value).toBe('abc'); - expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard'); - expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias'); + expect(buildStep.outputById.get('deploy_json')!.value).toBe(mockDeployStdout); + expect(buildStep.outputById.get('deploy_url')!.value).toBe('https://example.prod'); + expect(buildStep.outputById.get('deploy_deployment_url')!.value).toBe('https://example.dev'); + expect(buildStep.outputById.get('deploy_identifier')!.value).toBe('abc'); + expect(buildStep.outputById.get('deploy_dashboard_url')!.value).toBe( + 'https://expo.dev/dashboard' + ); + expect(buildStep.outputById.get('deploy_alias_url')!.value).toBe('https://example.alias'); }); it('forwards workflow step env to the eas deploy process', async () => { @@ -217,12 +219,12 @@ describe(createEasDeployBuildFunction, () => { const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {}); await buildStep.executeAsync(); - expect(buildStep.outputById.deploy_json.value).toBe('not-json'); + expect(buildStep.outputById.get('deploy_json')!.value).toBe('not-json'); expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith( expect.objectContaining({ err: expect.anything() }), expect.stringContaining('Failed to parse') ); - expect(buildStep.outputById.deploy_url.value).toBeUndefined(); + expect(buildStep.outputById.get('deploy_url')!.value).toBeUndefined(); }); it('sets outputs from partial deploy JSON and leaves missing fields undefined', async () => { @@ -249,12 +251,16 @@ describe(createEasDeployBuildFunction, () => { const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {}); await buildStep.executeAsync(); - expect(buildStep.outputById.deploy_json.value).toBe(partialDeployStdout); - expect(buildStep.outputById.deploy_url.value).toBe('https://example.dev-only'); - expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev-only'); - expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard-only'); - expect(buildStep.outputById.deploy_identifier.value).toBeUndefined(); - expect(buildStep.outputById.deploy_alias_url.value).toBeUndefined(); + expect(buildStep.outputById.get('deploy_json')!.value).toBe(partialDeployStdout); + expect(buildStep.outputById.get('deploy_url')!.value).toBe('https://example.dev-only'); + expect(buildStep.outputById.get('deploy_deployment_url')!.value).toBe( + 'https://example.dev-only' + ); + expect(buildStep.outputById.get('deploy_dashboard_url')!.value).toBe( + 'https://expo.dev/dashboard-only' + ); + expect(buildStep.outputById.get('deploy_identifier')!.value).toBeUndefined(); + expect(buildStep.outputById.get('deploy_alias_url')!.value).toBeUndefined(); }); it('sets deploy_url from aliases[0].url when production is absent but aliases exist', async () => { @@ -282,9 +288,11 @@ describe(createEasDeployBuildFunction, () => { const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {}); await buildStep.executeAsync(); - expect(buildStep.outputById.deploy_url.value).toBe('https://example.alias-first'); - expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.deployment'); - expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias-first'); - expect(buildStep.outputById.deploy_identifier.value).toBe('partial-alias'); + expect(buildStep.outputById.get('deploy_url')!.value).toBe('https://example.alias-first'); + expect(buildStep.outputById.get('deploy_deployment_url')!.value).toBe( + 'https://example.deployment' + ); + expect(buildStep.outputById.get('deploy_alias_url')!.value).toBe('https://example.alias-first'); + expect(buildStep.outputById.get('deploy_identifier')!.value).toBe('partial-alias'); }); }); diff --git a/packages/build-tools/src/steps/functions/__tests__/export.test.ts b/packages/build-tools/src/steps/functions/__tests__/export.test.ts index 1597553d33..2cdb79844d 100644 --- a/packages/build-tools/src/steps/functions/__tests__/export.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/export.test.ts @@ -47,7 +47,7 @@ describe(createEasExportBuildFunction, () => { stdio: 'pipe', }, }); - expect(buildStep.outputById.export_dir.value).toBe('dist'); + expect(buildStep.outputById.get('export_dir')!.value).toBe('dist'); }); it('passes optional flags matching expo export CLI', async () => { @@ -86,7 +86,7 @@ describe(createEasExportBuildFunction, () => { ], }) ); - expect(buildStep.outputById.export_dir.value).toBe('web-out'); + expect(buildStep.outputById.get('export_dir')!.value).toBe('web-out'); }); it('uses resolved package manager (e.g. yarn)', async () => { diff --git a/packages/build-tools/src/steps/functions/__tests__/readAppConfig.test.ts b/packages/build-tools/src/steps/functions/__tests__/readAppConfig.test.ts index a976b8b6d5..59864ce49c 100644 --- a/packages/build-tools/src/steps/functions/__tests__/readAppConfig.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/readAppConfig.test.ts @@ -39,7 +39,7 @@ describe(createReadAppConfigBuildFunction, () => { expect(stepLogger.info).toHaveBeenCalledWith( JSON.stringify({ name: 'my-app', slug: 'my-app-slug', version: '1.2.3' }, null, 2) ); - const firstOutput = buildStep.outputById.app_config.value; + const firstOutput = buildStep.outputById.get('app_config')!.value; expect(firstOutput).toBeDefined(); expect(JSON.parse(firstOutput!)).toEqual({ name: 'my-app', @@ -67,7 +67,7 @@ describe(createReadAppConfigBuildFunction, () => { await buildStep.executeAsync(); - const secondOutput = buildStep.outputById.app_config.value; + const secondOutput = buildStep.outputById.get('app_config')!.value; expect(secondOutput).toBeDefined(); expect(JSON.parse(secondOutput!)).toEqual({ name: 'my-app', diff --git a/packages/build-tools/src/steps/functions/__tests__/readIpaInfo.test.ts b/packages/build-tools/src/steps/functions/__tests__/readIpaInfo.test.ts index baac5b7b4a..9c8a2b1784 100644 --- a/packages/build-tools/src/steps/functions/__tests__/readIpaInfo.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/readIpaInfo.test.ts @@ -64,8 +64,10 @@ describe(createReadIpaInfoBuildFunction, () => { }); await buildStep.executeAsync(); - expect(buildStep.outputById.bundle_identifier.value).toBe('dev.expo.SmallestAppExample'); - expect(buildStep.outputById.bundle_short_version.value).toBe('1.0'); - expect(buildStep.outputById.bundle_version.value).toBe('1'); + expect(buildStep.outputById.get('bundle_identifier')!.value).toBe( + 'dev.expo.SmallestAppExample' + ); + expect(buildStep.outputById.get('bundle_short_version')!.value).toBe('1.0'); + expect(buildStep.outputById.get('bundle_version')!.value).toBe('1'); }); }); diff --git a/packages/build-tools/src/steps/functions/__tests__/readPackageJson.test.ts b/packages/build-tools/src/steps/functions/__tests__/readPackageJson.test.ts index ea12477ccf..bd1313d267 100644 --- a/packages/build-tools/src/steps/functions/__tests__/readPackageJson.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/readPackageJson.test.ts @@ -25,7 +25,7 @@ describe(createReadPackageJsonBuildFunction, () => { stepLogger, globalContext.defaultWorkingDirectory ); - expect(buildStep.outputById.package_json.value).toBe( + expect(buildStep.outputById.get('package_json')!.value).toBe( JSON.stringify({ name: 'app', version: '1.0.0' }) ); }); @@ -45,6 +45,6 @@ describe(createReadPackageJsonBuildFunction, () => { expect(stepLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), }); - expect(buildStep.outputById.package_json.value).toBeUndefined(); + expect(buildStep.outputById.get('package_json')!.value).toBeUndefined(); }); }); diff --git a/packages/build-tools/src/steps/functions/__tests__/repack.test.ts b/packages/build-tools/src/steps/functions/__tests__/repack.test.ts index 4e08b8bff1..502c41322e 100644 --- a/packages/build-tools/src/steps/functions/__tests__/repack.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/repack.test.ts @@ -44,7 +44,7 @@ describe(createRepackBuildFunction, () => { ); await repackStep.executeAsync(); - expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.ipa$/); + expect(repackStep.outputById.get('output_path')!.value).toMatch(/repacked-.*\.ipa$/); }); it('should rename generated aab output path to apk', async () => { @@ -64,7 +64,7 @@ describe(createRepackBuildFunction, () => { ); await repackStep.executeAsync(); - expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.apk$/); + expect(repackStep.outputById.get('output_path')!.value).toMatch(/repacked-.*\.apk$/); }); it('should throw for unsupported platforms', async () => { @@ -103,7 +103,7 @@ describe(createRepackBuildFunction, () => { ); await repackStep.executeAsync(); - const tmpDir = path.dirname(repackStep.outputById['output_path'].value as string); + const tmpDir = path.dirname(repackStep.outputById.get('output_path')!.value as string); const keystoreFiles = await fg(`${tmpDir}/keystore*`, { onlyFiles: true }); expect(keystoreFiles.length).toBe(0); }); diff --git a/packages/build-tools/src/steps/functions/__tests__/waitForPosthogMetric.test.ts b/packages/build-tools/src/steps/functions/__tests__/waitForPosthogMetric.test.ts index 3d2dd54ce8..1e336c431a 100644 --- a/packages/build-tools/src/steps/functions/__tests__/waitForPosthogMetric.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/waitForPosthogMetric.test.ts @@ -57,7 +57,7 @@ describe(createWaitForPosthogMetricFunction, () => { query: { kind: 'HogQLQuery' }, refresh: 'blocking', }); - expect(step.outputById.value.value).toBe('3'); + expect(step.outputById.get('value')!.value).toBe('3'); }); it('throws when credentials are missing', async () => { diff --git a/packages/steps/src/BuildStep.ts b/packages/steps/src/BuildStep.ts index 9c0dac2e9c..861466f3f8 100644 --- a/packages/steps/src/BuildStep.ts +++ b/packages/steps/src/BuildStep.ts @@ -13,6 +13,7 @@ import { BuildStepInput, BuildStepInputById, makeBuildStepInputByIdMap } from '. import { BuildStepOutput, BuildStepOutputById, + BuildStepOutputByIdMap, SerializedBuildStepOutput, makeBuildStepOutputByIdMap, } from './BuildStepOutput'; @@ -76,11 +77,11 @@ export class BuildStepOutputAccessor { public readonly id: string, public readonly displayName: string, protected readonly executed: boolean, - protected readonly outputById: BuildStepOutputById + protected readonly outputById: BuildStepOutputByIdMap ) {} public get outputs(): BuildStepOutput[] { - return Object.values(this.outputById); + return [...this.outputById.values()]; } public getOutputValueByName(name: string): string | undefined { @@ -92,11 +93,11 @@ export class BuildStepOutputAccessor { if (!this.hasOutputParameter(name)) { throw new BuildStepRuntimeError(`Step "${this.displayName}" does not have output "${name}".`); } - return this.outputById[name].value; + return this.outputById.get(name)!.value; } public hasOutputParameter(name: string): boolean { - return name in this.outputById; + return this.outputById.has(name); } public serialize(): SerializedBuildStepOutputAccessor { @@ -104,7 +105,7 @@ export class BuildStepOutputAccessor { id: this.id, executed: this.executed, outputById: Object.fromEntries( - Object.entries(this.outputById).map(([key, value]) => [key, value.serialize()]) + [...this.outputById].map(([key, value]) => [key, value.serialize()]) ), displayName: this.displayName, }; @@ -113,7 +114,7 @@ export class BuildStepOutputAccessor { public static deserialize( serialized: SerializedBuildStepOutputAccessor ): BuildStepOutputAccessor { - const outputById = Object.fromEntries( + const outputById = new Map( Object.entries(serialized.outputById).map(([key, value]) => [ key, BuildStepOutput.deserialize(value), @@ -134,7 +135,7 @@ export class BuildStep extends BuildStepOutputAccessor { public readonly displayName: string; public readonly supportedRuntimePlatforms?: BuildRuntimePlatform[]; public readonly inputs?: BuildStepInput[]; - public readonly outputById: BuildStepOutputById; + public readonly outputById: BuildStepOutputByIdMap; public readonly command?: string; public readonly fn?: BuildStepFunction; public readonly shell: string; @@ -502,7 +503,7 @@ export class BuildStep extends BuildStepOutputAccessor { }, ]) ), - outputs: this.outputById, + outputs: Object.fromEntries(this.outputById), env: this.getScriptEnv(), signal: signal ?? undefined, }); @@ -556,23 +557,23 @@ export class BuildStep extends BuildStepOutputAccessor { const files = await fs.readdir(outputsDir); for (const outputId of files) { - if (!(outputId in this.outputById)) { + if (!this.outputById.has(outputId)) { const newOutput = new BuildStepOutput(this.ctx.global, { id: outputId, stepDisplayName: this.displayName, required: false, }); - this.outputById[outputId] = newOutput; + this.outputById.set(outputId, newOutput); } const file = path.join(outputsDir, outputId); const rawContents = await fs.readFile(file, 'utf-8'); const decodedContents = Buffer.from(rawContents, 'base64').toString('utf-8'); - this.outputById[outputId].set(decodedContents); + this.outputById.get(outputId)!.set(decodedContents); } const nonSetRequiredOutputIds: string[] = []; - for (const output of Object.values(this.outputById)) { + for (const output of this.outputById.values()) { try { const value = output.value; this.ctx.logger.debug(`Output parameter "${output.id}" is set to "${value}"`); diff --git a/packages/steps/src/BuildStepOutput.ts b/packages/steps/src/BuildStepOutput.ts index bb24839040..4cffaf0e23 100644 --- a/packages/steps/src/BuildStepOutput.ts +++ b/packages/steps/src/BuildStepOutput.ts @@ -2,6 +2,7 @@ import { BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepRuntimeError } from './errors'; export type BuildStepOutputById = Record; +export type BuildStepOutputByIdMap = Map; export type BuildStepOutputProvider = ( ctx: BuildStepGlobalContext, stepDisplayName: string @@ -92,12 +93,6 @@ export class BuildStepOutput { } } -export function makeBuildStepOutputByIdMap(outputs?: BuildStepOutput[]): BuildStepOutputById { - if (outputs === undefined) { - return {}; - } - return outputs.reduce((acc, output) => { - acc[output.id] = output; - return acc; - }, {} as BuildStepOutputById); +export function makeBuildStepOutputByIdMap(outputs?: BuildStepOutput[]): BuildStepOutputByIdMap { + return new Map(outputs?.map(output => [output.id, output])); } diff --git a/packages/steps/src/__tests__/BuildConfigParser-test.ts b/packages/steps/src/__tests__/BuildConfigParser-test.ts index 97d9bec9d5..97786a26d7 100644 --- a/packages/steps/src/__tests__/BuildConfigParser-test.ts +++ b/packages/steps/src/__tests__/BuildConfigParser-test.ts @@ -269,7 +269,8 @@ describe(BuildConfigParser, () => { expect(step1.command).toMatchSnapshot(); expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); expect(step1.shell).toBe('/bin/bash -eo pipefail'); - const { first_name, last_name } = step1.outputById; + const first_name = step1.outputById.get('first_name')!; + const last_name = step1.outputById.get('last_name')!; expect(first_name.id).toBe('first_name'); expect(first_name.required).toBe(true); expect(last_name.id).toBe('last_name'); @@ -293,14 +294,14 @@ describe(BuildConfigParser, () => { expect(step2.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); expect(step2.shell).toBe('/bin/bash -eo pipefail'); const step2Outputs = step2.outputById; - expect(step2Outputs.first_name.id).toBe('first_name'); - expect(step2Outputs.first_name.required).toBe(true); - expect(step2Outputs.middle_name.id).toBe('middle_name'); - expect(step2Outputs.middle_name.required).toBe(false); - expect(step2Outputs.last_name.id).toBe('last_name'); - expect(step2Outputs.last_name.required).toBe(true); - expect(step2Outputs.nickname.id).toBe('nickname'); - expect(step2Outputs.nickname.required).toBe(true); + expect(step2Outputs.get('first_name')!.id).toBe('first_name'); + expect(step2Outputs.get('first_name')!.required).toBe(true); + expect(step2Outputs.get('middle_name')!.id).toBe('middle_name'); + expect(step2Outputs.get('middle_name')!.required).toBe(false); + expect(step2Outputs.get('last_name')!.id).toBe('last_name'); + expect(step2Outputs.get('last_name')!.required).toBe(true); + expect(step2Outputs.get('nickname')!.id).toBe('nickname'); + expect(step2Outputs.get('nickname')!.required).toBe(true); }); it('parses functions and function calls', async () => { @@ -397,7 +398,7 @@ describe(BuildConfigParser, () => { expect(step4.command).toBe('set-output value 6'); expect(step4.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); expect(step4.shell).toBe('/bin/bash -eo pipefail'); - const { value } = step4.outputById; + const value = step4.outputById.get('value')!; expect(value.id).toBe('value'); expect(value.required).toBe(true); expect(step4.stepEnvOverrides).toMatchObject({}); diff --git a/packages/steps/src/__tests__/BuildFunction-test.ts b/packages/steps/src/__tests__/BuildFunction-test.ts index ceaa4578cc..083da196e7 100644 --- a/packages/steps/src/__tests__/BuildFunction-test.ts +++ b/packages/steps/src/__tests__/BuildFunction-test.ts @@ -236,8 +236,8 @@ describe(BuildFunction, () => { expect(step.inputs?.[0].id).toBe('input1'); expect(step.inputs?.[1].id).toBe('input2'); expect(step.inputs?.[2].id).toBe('input3'); - expect(step.outputById.output1).toBeDefined(); - expect(step.outputById.output2).toBeDefined(); + expect(step.outputById.get('output1')).toBeDefined(); + expect(step.outputById.get('output2')).toBeDefined(); }); it('passes values to build inputs', () => { const ctx = createGlobalContextMock(); diff --git a/packages/steps/src/__tests__/BuildStep-test.ts b/packages/steps/src/__tests__/BuildStep-test.ts index 75bf0df5e8..49a61b51dd 100644 --- a/packages/steps/src/__tests__/BuildStep-test.ts +++ b/packages/steps/src/__tests__/BuildStep-test.ts @@ -464,7 +464,7 @@ describe(BuildStep, () => { command, }); await step.executeAsync(); - const abc = nullthrows(step.outputById.abc); + const abc = nullthrows(step.outputById.get('abc')); expect(abc?.value).toBe('123'); }); @@ -510,7 +510,7 @@ describe(BuildStep, () => { fn, }); await step.executeAsync(); - const abc = nullthrows(step.outputById.abc); + const abc = nullthrows(step.outputById.get('abc')); expect(abc?.value).toBe('123'); }); }); @@ -607,7 +607,7 @@ describe(BuildStep, () => { command, }); await step.executeAsync(); - const abc = nullthrows(step.outputById.abc); + const abc = nullthrows(step.outputById.get('abc')); expect(abc?.value).toBe('d o m i n i k'); }); diff --git a/packages/steps/src/__tests__/BuildStepOutput-test.ts b/packages/steps/src/__tests__/BuildStepOutput-test.ts index fedbf5e755..8945edadb4 100644 --- a/packages/steps/src/__tests__/BuildStepOutput-test.ts +++ b/packages/steps/src/__tests__/BuildStepOutput-test.ts @@ -77,11 +77,11 @@ describe(BuildStepOutput, () => { }); describe(makeBuildStepOutputByIdMap, () => { - it('returns empty object when inputs are undefined', () => { - expect(makeBuildStepOutputByIdMap(undefined)).toEqual({}); + it('returns empty map when inputs are undefined', () => { + expect(makeBuildStepOutputByIdMap(undefined)).toEqual(new Map()); }); - it('returns object with outputs indexed by their ids', () => { + it('returns map with outputs indexed by their ids', () => { const ctx = createGlobalContextMock(); const outputs: BuildStepOutput[] = [ new BuildStepOutput(ctx, { @@ -96,8 +96,21 @@ describe(makeBuildStepOutputByIdMap, () => { }), ]; const result = makeBuildStepOutputByIdMap(outputs); - expect(Object.keys(result).length).toBe(2); - expect(result.abc1).toBeDefined(); - expect(result.abc2).toBeDefined(); + expect(result.size).toBe(2); + expect(result.get('abc1')).toBeDefined(); + expect(result.get('abc2')).toBeDefined(); + }); + + it('supports output ids that are special object property names', () => { + const ctx = createGlobalContextMock(); + const output = new BuildStepOutput(ctx, { + id: '__proto__', + stepDisplayName: 'test1', + required: true, + }); + + const result = makeBuildStepOutputByIdMap([output]); + + expect(result.get('__proto__')).toBe(output); }); }); diff --git a/packages/steps/src/__tests__/StepsConfigParser-test.ts b/packages/steps/src/__tests__/StepsConfigParser-test.ts index 0811db5a4d..390abc8ba3 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-test.ts @@ -286,7 +286,7 @@ describe(StepsConfigParser, () => { expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); expect(step1.stepEnvOverrides).toEqual({}); expect(step1.inputs).toBeUndefined(); - expect(step1.outputById).toStrictEqual({}); + expect(step1.outputById).toStrictEqual(new Map()); expect(step1.ifCondition).toBeUndefined(); const step2 = result.buildSteps[1]; @@ -299,7 +299,7 @@ describe(StepsConfigParser, () => { a: 'b', }); expect(step2.inputs).toBeUndefined(); - expect(step2.outputById).toStrictEqual({}); + expect(step2.outputById).toStrictEqual(new Map()); expect(step2.ifCondition).toBeUndefined(); const step3 = result.buildSteps[2]; @@ -314,13 +314,11 @@ describe(StepsConfigParser, () => { }); expect(step3.inputs).toBeUndefined(); expect(step3.outputById).toBeDefined(); - expect(Object.keys(step3.outputById)).toHaveLength(3); + expect(step3.outputById.size).toBe(3); assert(step3.outputById); - const { - my_output: output1, - my_optional_output: output2, - my_optional_output_without_required: output3, - } = step3.outputById; + const output1 = step3.outputById.get('my_output')!; + const output2 = step3.outputById.get('my_optional_output')!; + const output3 = step3.outputById.get('my_optional_output_without_required')!; expect(output1.id).toBe('my_output'); expect(output1.required).toBe(true); expect(output2.id).toBe('my_optional_output'); @@ -386,7 +384,7 @@ describe(StepsConfigParser, () => { expect(input4.defaultValue).toBeUndefined(); expect(input4.rawValue).toBe('${ step3.my_output }'); expect(input4.required).toBe(true); - expect(step4.outputById).toStrictEqual({}); + expect(step4.outputById).toStrictEqual(new Map()); expect(step4.ifCondition).toBe('${ ctx.job.platform } == "android"'); }); }); From bbccacb5e68a652b7b2a7a54378072a152274859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Thu, 23 Jul 2026 22:49:19 +0200 Subject: [PATCH 2/7] Clarify build step output collection types --- packages/steps/src/BuildStep.ts | 9 +++++---- packages/steps/src/BuildStepOutput.ts | 5 ++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/steps/src/BuildStep.ts b/packages/steps/src/BuildStep.ts index 861466f3f8..a669f87f11 100644 --- a/packages/steps/src/BuildStep.ts +++ b/packages/steps/src/BuildStep.ts @@ -13,7 +13,6 @@ import { BuildStepInput, BuildStepInputById, makeBuildStepInputByIdMap } from '. import { BuildStepOutput, BuildStepOutputById, - BuildStepOutputByIdMap, SerializedBuildStepOutput, makeBuildStepOutputByIdMap, } from './BuildStepOutput'; @@ -51,6 +50,8 @@ export enum BuildStepLogMarker { END_STEP = 'end-step', } +export type BuildStepFunctionOutputs = Record; + export type BuildStepFunction = ( ctx: BuildStepContext, { @@ -59,7 +60,7 @@ export type BuildStepFunction = ( env, }: { inputs: { [key: string]: { value: unknown } }; - outputs: BuildStepOutputById; + outputs: BuildStepFunctionOutputs; env: BuildStepEnv; signal?: AbortSignal; } @@ -77,7 +78,7 @@ export class BuildStepOutputAccessor { public readonly id: string, public readonly displayName: string, protected readonly executed: boolean, - protected readonly outputById: BuildStepOutputByIdMap + protected readonly outputById: BuildStepOutputById ) {} public get outputs(): BuildStepOutput[] { @@ -135,7 +136,7 @@ export class BuildStep extends BuildStepOutputAccessor { public readonly displayName: string; public readonly supportedRuntimePlatforms?: BuildRuntimePlatform[]; public readonly inputs?: BuildStepInput[]; - public readonly outputById: BuildStepOutputByIdMap; + public readonly outputById: BuildStepOutputById; public readonly command?: string; public readonly fn?: BuildStepFunction; public readonly shell: string; diff --git a/packages/steps/src/BuildStepOutput.ts b/packages/steps/src/BuildStepOutput.ts index 4cffaf0e23..71f882436b 100644 --- a/packages/steps/src/BuildStepOutput.ts +++ b/packages/steps/src/BuildStepOutput.ts @@ -1,8 +1,7 @@ import { BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepRuntimeError } from './errors'; -export type BuildStepOutputById = Record; -export type BuildStepOutputByIdMap = Map; +export type BuildStepOutputById = Map; export type BuildStepOutputProvider = ( ctx: BuildStepGlobalContext, stepDisplayName: string @@ -93,6 +92,6 @@ export class BuildStepOutput { } } -export function makeBuildStepOutputByIdMap(outputs?: BuildStepOutput[]): BuildStepOutputByIdMap { +export function makeBuildStepOutputByIdMap(outputs?: BuildStepOutput[]): BuildStepOutputById { return new Map(outputs?.map(output => [output.id, output])); } From 79f4060031b2fdef10d8ed86a3541642b7b14d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Fri, 24 Jul 2026 00:00:09 +0200 Subject: [PATCH 3/7] Test special output names in custom functions --- .../my-custom-ts-function/build/index.js | 1 + .../my-custom-ts-function/src/index.ts | 2 ++ .../__tests__/runCustomFunction-test.ts | 29 ++++++++----------- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/build/index.js b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/build/index.js index ead9068a5b..d41c9adb1e 100644 --- a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/build/index.js +++ b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/build/index.js @@ -25,6 +25,7 @@ function myTsFunction(ctx, { inputs, outputs, env, }) { outputs.name.set('Brent'); outputs.num.set('123'); outputs.obj.set(JSON.stringify({ foo: 'bar' })); // TODO: add support for other types of outputs then string + outputs.__proto__.set('prototype output'); ctx.logger.info('Setting env vars'); env['MY_ENV_VAR'] = 'my-value'; }); diff --git a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/src/index.ts b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/src/index.ts index 2f80c51770..ad3497554d 100644 --- a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/src/index.ts +++ b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/src/index.ts @@ -16,6 +16,7 @@ interface MyTsFunctionOutputs { name: BuildStepOutput; num: BuildStepOutput; obj: BuildStepOutput; + __proto__: BuildStepOutput; } async function myTsFunctionAsync( @@ -45,6 +46,7 @@ async function myTsFunctionAsync( outputs.name.set('Brent'); outputs.num.set('123'); outputs.obj.set(JSON.stringify({ foo: 'bar' })); // TODO: add support for other types of outputs then string + outputs.__proto__.set('prototype output'); ctx.logger.info('Setting env vars'); env['MY_ENV_VAR'] = 'my-value'; diff --git a/packages/steps/src/scripts/__tests__/runCustomFunction-test.ts b/packages/steps/src/scripts/__tests__/runCustomFunction-test.ts index 65809c798d..eb52ecfcc6 100644 --- a/packages/steps/src/scripts/__tests__/runCustomFunction-test.ts +++ b/packages/steps/src/scripts/__tests__/runCustomFunction-test.ts @@ -28,23 +28,16 @@ describe('runCustomFunction', () => { projectSourceDirectory, logger, }); - const outputs = { - name: new BuildStepOutput(ctx.global, { - id: 'name', - stepDisplayName: 'test', - required: true, - }), - num: new BuildStepOutput(ctx.global, { - id: 'num', - stepDisplayName: 'test', - required: true, - }), - obj: new BuildStepOutput(ctx.global, { - id: 'obj', - stepDisplayName: 'test', - required: true, - }), - }; + const outputs = Object.fromEntries( + ['name', 'num', 'obj', '__proto__'].map(id => [ + id, + new BuildStepOutput(ctx.global, { + id, + stepDisplayName: 'test', + required: true, + }), + ]) + ); const inputs = { name: new BuildStepInput(ctx.global, { id: 'name', @@ -101,6 +94,8 @@ describe('runCustomFunction', () => { outputs, }); await expect(promise).resolves.not.toThrow(); + const rawPrototypeOutput = await fs.readFile(path.join(outputsDir, '__proto__'), 'utf-8'); + expect(Buffer.from(rawPrototypeOutput, 'base64').toString('utf-8')).toBe('prototype output'); } finally { await cleanUpStepTemporaryDirectoriesAsync(ctx.global, 'test'); } From b93d8bfb2e2fb5727a8eb55274e04171bc726706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Fri, 24 Jul 2026 20:57:48 +0200 Subject: [PATCH 4/7] Store build step inputs in a Map --- packages/steps/src/BuildFunction.ts | 2 +- packages/steps/src/BuildFunctionGroup.ts | 9 ++-- packages/steps/src/BuildStep.ts | 5 ++- packages/steps/src/BuildStepInput.ts | 10 +---- .../steps/src/__tests__/BuildFunction-test.ts | 31 +++++++++++++ .../src/__tests__/BuildFunctionGroup-test.ts | 35 +++++++++++++++ .../steps/src/__tests__/BuildStep-test.ts | 23 ++++++++++ .../src/__tests__/BuildStepInput-test.ts | 45 ++++++++++++------- .../my-custom-ts-function/build/index.js | 2 +- .../my-custom-ts-function/src/index.ts | 3 +- .../__tests__/runCustomFunction-test.ts | 16 ++++++- 11 files changed, 147 insertions(+), 34 deletions(-) create mode 100644 packages/steps/src/__tests__/BuildFunctionGroup-test.ts diff --git a/packages/steps/src/BuildFunction.ts b/packages/steps/src/BuildFunction.ts index b6d64c79ac..715f41cce8 100644 --- a/packages/steps/src/BuildFunction.ts +++ b/packages/steps/src/BuildFunction.ts @@ -138,7 +138,7 @@ export class BuildFunction { const inputs = this.inputProviders?.map(inputProvider => { const input = inputProvider(ctx, buildStepId); - if (input.id in callInputs) { + if (Object.hasOwn(callInputs, input.id)) { input.set(callInputs[input.id]); } return input; diff --git a/packages/steps/src/BuildFunctionGroup.ts b/packages/steps/src/BuildFunctionGroup.ts index 2eb1e9ca3f..bb6753cf32 100644 --- a/packages/steps/src/BuildFunctionGroup.ts +++ b/packages/steps/src/BuildFunctionGroup.ts @@ -2,13 +2,14 @@ import { BuildFunctionCallInputs } from './BuildFunction'; import { BuildStep } from './BuildStep'; import { BuildStepGlobalContext } from './BuildStepContext'; import { - BuildStepInputById, + BuildStepInput, BuildStepInputProvider, makeBuildStepInputByIdMap, } from './BuildStepInput'; import { BuildConfigError } from './errors'; export type BuildFunctionGroupById = Record; +export type BuildFunctionGroupInputs = Record; export class BuildFunctionGroup { public readonly namespace: string; @@ -35,7 +36,7 @@ export class BuildFunctionGroup { { inputs, }: { - inputs: BuildStepInputById; + inputs: BuildFunctionGroupInputs; } ) => BuildStep[]; }) { @@ -46,13 +47,13 @@ export class BuildFunctionGroup { this.createBuildStepsFromFunctionGroupCall = (ctx, { callInputs = {} } = {}) => { const inputs = this.inputProviders?.map(inputProvider => { const input = inputProvider(ctx, id); - if (input.id in callInputs) { + if (Object.hasOwn(callInputs, input.id)) { input.set(callInputs[input.id]); } return input; }); return createBuildStepsFromFunctionGroupCall(ctx, { - inputs: makeBuildStepInputByIdMap(inputs), + inputs: Object.fromEntries(makeBuildStepInputByIdMap(inputs)), }); }; } diff --git a/packages/steps/src/BuildStep.ts b/packages/steps/src/BuildStep.ts index a669f87f11..dd4802f446 100644 --- a/packages/steps/src/BuildStep.ts +++ b/packages/steps/src/BuildStep.ts @@ -51,6 +51,7 @@ export enum BuildStepLogMarker { } export type BuildStepFunctionOutputs = Record; +export type BuildStepFunctionInputs = Record; export type BuildStepFunction = ( ctx: BuildStepContext, @@ -59,7 +60,7 @@ export type BuildStepFunction = ( outputs, env, }: { - inputs: { [key: string]: { value: unknown } }; + inputs: BuildStepFunctionInputs; outputs: BuildStepFunctionOutputs; env: BuildStepEnv; signal?: AbortSignal; @@ -494,7 +495,7 @@ export class BuildStep extends BuildStepOutputAccessor { await this.fn(this.ctx, { inputs: Object.fromEntries( - Object.entries(this.inputById).map(([key, input]) => [ + [...this.inputById].map(([key, input]) => [ key, { value: input.getValue({ diff --git a/packages/steps/src/BuildStepInput.ts b/packages/steps/src/BuildStepInput.ts index 2b7d61f505..4d8c9f52d4 100644 --- a/packages/steps/src/BuildStepInput.ts +++ b/packages/steps/src/BuildStepInput.ts @@ -26,7 +26,7 @@ export type BuildStepInputValueType< ? number : Record; -export type BuildStepInputById = Record; +export type BuildStepInputById = Map; export type BuildStepInputProvider = ( ctx: BuildStepGlobalContext, stepId: string @@ -256,11 +256,5 @@ export function getDisallowedInputValueError( } export function makeBuildStepInputByIdMap(inputs?: BuildStepInput[]): BuildStepInputById { - if (inputs === undefined) { - return {}; - } - return inputs.reduce((acc, input) => { - acc[input.id] = input; - return acc; - }, {} as BuildStepInputById); + return new Map(inputs?.map(input => [input.id, input])); } diff --git a/packages/steps/src/__tests__/BuildFunction-test.ts b/packages/steps/src/__tests__/BuildFunction-test.ts index 083da196e7..422d72efaa 100644 --- a/packages/steps/src/__tests__/BuildFunction-test.ts +++ b/packages/steps/src/__tests__/BuildFunction-test.ts @@ -310,6 +310,37 @@ describe(BuildFunction, () => { b: 2, }); }); + it('handles __proto__ as an input name', () => { + const ctx = createGlobalContextMock(); + const func = new BuildFunction({ + id: 'test1', + command: 'echo test', + inputProviders: [ + BuildStepInput.createProvider({ + id: '__proto__', + defaultValue: 'default', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + }); + + const stepWithoutValue = func.createBuildStepFromFunctionCall(ctx); + expect( + stepWithoutValue.inputs?.[0].getValue({ + interpolationContext: ctx.getInterpolationContext(), + }) + ).toBe('default'); + + const stepWithValue = func.createBuildStepFromFunctionCall(ctx, { + callInputs: Object.fromEntries([['__proto__', 'provided']]), + }); + expect( + stepWithValue.inputs?.[0].getValue({ + interpolationContext: ctx.getInterpolationContext(), + }) + ).toBe('provided'); + }); it('passes env to build step', () => { const ctx = createGlobalContextMock(); const func = new BuildFunction({ diff --git a/packages/steps/src/__tests__/BuildFunctionGroup-test.ts b/packages/steps/src/__tests__/BuildFunctionGroup-test.ts new file mode 100644 index 0000000000..f334ee1734 --- /dev/null +++ b/packages/steps/src/__tests__/BuildFunctionGroup-test.ts @@ -0,0 +1,35 @@ +import { createGlobalContextMock } from './utils/context'; +import { BuildFunctionGroup } from '../BuildFunctionGroup'; +import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput'; + +describe(BuildFunctionGroup, () => { + it('handles __proto__ as an input name', () => { + const ctx = createGlobalContextMock(); + const receivedValues: unknown[] = []; + const group = new BuildFunctionGroup({ + namespace: 'test', + id: 'group', + inputProviders: [ + BuildStepInput.createProvider({ + id: '__proto__', + defaultValue: 'default', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + createBuildStepsFromFunctionGroupCall: (_ctx, { inputs }) => { + receivedValues.push( + inputs.__proto__.getValue({ interpolationContext: ctx.getInterpolationContext() }) + ); + return []; + }, + }); + + group.createBuildStepsFromFunctionGroupCall(ctx); + group.createBuildStepsFromFunctionGroupCall(ctx, { + callInputs: Object.fromEntries([['__proto__', 'provided']]), + }); + + expect(receivedValues).toEqual(['default', 'provided']); + }); +}); diff --git a/packages/steps/src/__tests__/BuildStep-test.ts b/packages/steps/src/__tests__/BuildStep-test.ts index 49a61b51dd..306aa097fd 100644 --- a/packages/steps/src/__tests__/BuildStep-test.ts +++ b/packages/steps/src/__tests__/BuildStep-test.ts @@ -812,6 +812,29 @@ describe(BuildStep, () => { expect(step.getOutputValueByName('abc')).toBe('bar1 bar2 true 27'); }); + + it('passes an input named __proto__ to the function', async () => { + const input = new BuildStepInput(baseStepCtx, { + id: '__proto__', + stepDisplayName: 'test1', + defaultValue: 'prototype input', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + const fn = jest.fn((_ctx, { inputs }) => { + expect(inputs.__proto__.value).toBe('prototype input'); + }); + const step = new BuildStep(baseStepCtx, { + id: 'test1', + displayName: 'test1', + inputs: [input], + fn, + }); + + await step.executeAsync(); + + expect(fn).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/packages/steps/src/__tests__/BuildStepInput-test.ts b/packages/steps/src/__tests__/BuildStepInput-test.ts index 8a28e7c3ae..15a628a6ec 100644 --- a/packages/steps/src/__tests__/BuildStepInput-test.ts +++ b/packages/steps/src/__tests__/BuildStepInput-test.ts @@ -943,11 +943,11 @@ describe(BuildStepInput, () => { }); describe(makeBuildStepInputByIdMap, () => { - it('returns empty object when inputs are undefined', () => { - expect(makeBuildStepInputByIdMap(undefined)).toEqual({}); + it('returns empty map when inputs are undefined', () => { + expect(makeBuildStepInputByIdMap(undefined)).toEqual(new Map()); }); - it('returns object with inputs indexed by their ids', () => { + it('returns map with inputs indexed by their ids', () => { const ctx = createGlobalContextMock(); const inputs: BuildStepInput[] = [ new BuildStepInput(ctx, { @@ -973,17 +973,32 @@ describe(makeBuildStepInputByIdMap, () => { }), ]; const result = makeBuildStepInputByIdMap(inputs); - expect(Object.keys(result).length).toBe(3); - expect(result.foo1).toBeDefined(); - expect(result.foo2).toBeDefined(); - expect(result.foo1.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe( - 'bar1' - ); - expect(result.foo2.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe( - 'bar2' - ); - expect(result.foo3.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe( - true - ); + expect(result.size).toBe(3); + expect(result.get('foo1')).toBeDefined(); + expect(result.get('foo2')).toBeDefined(); + expect( + result.get('foo1')!.getValue({ interpolationContext: ctx.getInterpolationContext() }) + ).toBe('bar1'); + expect( + result.get('foo2')!.getValue({ interpolationContext: ctx.getInterpolationContext() }) + ).toBe('bar2'); + expect( + result.get('foo3')!.getValue({ interpolationContext: ctx.getInterpolationContext() }) + ).toBe(true); + }); + + it('supports input ids that are special object property names', () => { + const ctx = createGlobalContextMock(); + const input = new BuildStepInput(ctx, { + id: '__proto__', + stepDisplayName: 'test1', + defaultValue: 'value', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + + const result = makeBuildStepInputByIdMap([input]); + + expect(result.get('__proto__')).toBe(input); }); }); diff --git a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/build/index.js b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/build/index.js index d41c9adb1e..6beb5aa4c2 100644 --- a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/build/index.js +++ b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/build/index.js @@ -25,7 +25,7 @@ function myTsFunction(ctx, { inputs, outputs, env, }) { outputs.name.set('Brent'); outputs.num.set('123'); outputs.obj.set(JSON.stringify({ foo: 'bar' })); // TODO: add support for other types of outputs then string - outputs.__proto__.set('prototype output'); + outputs.__proto__.set(inputs.__proto__.value); ctx.logger.info('Setting env vars'); env['MY_ENV_VAR'] = 'my-value'; }); diff --git a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/src/index.ts b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/src/index.ts index ad3497554d..f6baba2a72 100644 --- a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/src/index.ts +++ b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/src/index.ts @@ -10,6 +10,7 @@ interface MyTsFunctionInputs { name: BuildStepInput; num: BuildStepInput; obj: BuildStepInput; + __proto__: BuildStepInput; } interface MyTsFunctionOutputs { @@ -46,7 +47,7 @@ async function myTsFunctionAsync( outputs.name.set('Brent'); outputs.num.set('123'); outputs.obj.set(JSON.stringify({ foo: 'bar' })); // TODO: add support for other types of outputs then string - outputs.__proto__.set('prototype output'); + outputs.__proto__.set(inputs.__proto__.value); ctx.logger.info('Setting env vars'); env['MY_ENV_VAR'] = 'my-value'; diff --git a/packages/steps/src/scripts/__tests__/runCustomFunction-test.ts b/packages/steps/src/scripts/__tests__/runCustomFunction-test.ts index eb52ecfcc6..c6a825d5a9 100644 --- a/packages/steps/src/scripts/__tests__/runCustomFunction-test.ts +++ b/packages/steps/src/scripts/__tests__/runCustomFunction-test.ts @@ -61,6 +61,18 @@ describe('runCustomFunction', () => { inputs.name.set('foo'); inputs.num.set(123); inputs.obj.set({ foo: 'bar' }); + const inputsWithSpecialName = Object.fromEntries([ + ...Object.entries(inputs), + [ + '__proto__', + new BuildStepInput(ctx.global, { + id: '__proto__', + stepDisplayName: 'test', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }).set('prototype input'), + ], + ]); try { const outputsDir = getTemporaryOutputsDirPath(ctx.global, 'test'); @@ -82,7 +94,7 @@ describe('runCustomFunction', () => { PATH: newPath, }, inputs: Object.fromEntries( - Object.entries(inputs).map(([id, input]) => [ + Object.entries(inputsWithSpecialName).map(([id, input]) => [ id, { value: input.getValue({ @@ -95,7 +107,7 @@ describe('runCustomFunction', () => { }); await expect(promise).resolves.not.toThrow(); const rawPrototypeOutput = await fs.readFile(path.join(outputsDir, '__proto__'), 'utf-8'); - expect(Buffer.from(rawPrototypeOutput, 'base64').toString('utf-8')).toBe('prototype output'); + expect(Buffer.from(rawPrototypeOutput, 'base64').toString('utf-8')).toBe('prototype input'); } finally { await cleanUpStepTemporaryDirectoriesAsync(ctx.global, 'test'); } From bf1779d24d3170e68a0196d0948fa4f1f75b0b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Fri, 24 Jul 2026 21:20:25 +0200 Subject: [PATCH 5/7] Update composite output Map assertion --- .../StepsConfigParser-composite-functions-outputs-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-outputs-test.ts b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-outputs-test.ts index ea7e4cbb4d..e5faf6c7f4 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-outputs-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-outputs-test.ts @@ -40,7 +40,7 @@ describe('StepsConfigParser local composite functions', () => { expect(outputsStep.displayName).toBe('Setup'); expect(outputsStep.command).toBeUndefined(); expect(outputsStep.fn).toBeDefined(); - expect(Object.keys(outputsStep.outputById)).toEqual(['version']); + expect([...outputsStep.outputById.keys()]).toEqual(['version']); }); it('uses a generated call id for the outputs node when the caller has no id', async () => { From 10beff2d2bfd971c5976e2771eb104d51d8f2d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Fri, 24 Jul 2026 21:42:22 +0200 Subject: [PATCH 6/7] Use null-prototype step parameter records --- .../installMaestro.test.ts | 2 +- .../__tests__/configureIosCredentials.test.ts | 2 +- .../__tests__/configureIosVersion.test.ts | 2 +- .../steps/functions/__tests__/deploy.test.ts | 44 +++----- .../steps/functions/__tests__/export.test.ts | 4 +- .../functions/__tests__/readAppConfig.test.ts | 4 +- .../functions/__tests__/readIpaInfo.test.ts | 8 +- .../__tests__/readPackageJson.test.ts | 4 +- .../steps/functions/__tests__/repack.test.ts | 6 +- .../__tests__/waitForPosthogMetric.test.ts | 2 +- packages/steps/src/BuildFunctionGroup.ts | 9 +- packages/steps/src/BuildStep.ts | 103 ++++++++---------- packages/steps/src/BuildStepInput.ts | 11 +- packages/steps/src/BuildStepOutput.ts | 11 +- .../src/__tests__/BuildConfigParser-test.ts | 21 ++-- .../steps/src/__tests__/BuildFunction-test.ts | 4 +- .../steps/src/__tests__/BuildStep-test.ts | 55 +++++++++- .../src/__tests__/BuildStepInput-test.ts | 45 ++++---- .../src/__tests__/BuildStepOutput-test.ts | 27 +++-- ...Parser-composite-functions-outputs-test.ts | 2 +- .../src/__tests__/StepsConfigParser-test.ts | 16 +-- packages/steps/src/utils/record.ts | 4 + 22 files changed, 221 insertions(+), 165 deletions(-) create mode 100644 packages/steps/src/utils/record.ts diff --git a/packages/build-tools/src/steps/functions/__integration-tests__/installMaestro.test.ts b/packages/build-tools/src/steps/functions/__integration-tests__/installMaestro.test.ts index f3b7529830..88b8e789bd 100644 --- a/packages/build-tools/src/steps/functions/__integration-tests__/installMaestro.test.ts +++ b/packages/build-tools/src/steps/functions/__integration-tests__/installMaestro.test.ts @@ -38,7 +38,7 @@ describe('createInstallMaestroBuildFunction', () => { ); await expect(downgradeStep.executeAsync()).resolves.not.toThrow(); - expect(downgradeStep.outputById.get('maestro_version')!.value).toBe('1.40.0'); + expect(downgradeStep.outputById.maestro_version.value).toBe('1.40.0'); const latestStep = installMaestro.createBuildStepFromFunctionCall( createGlobalContextMock({ logger }), diff --git a/packages/build-tools/src/steps/functions/__tests__/configureIosCredentials.test.ts b/packages/build-tools/src/steps/functions/__tests__/configureIosCredentials.test.ts index 2b6e25a0b8..a0d4662a19 100644 --- a/packages/build-tools/src/steps/functions/__tests__/configureIosCredentials.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/configureIosCredentials.test.ts @@ -76,6 +76,6 @@ describe(configureIosCredentialsFunction, () => { await buildStep.executeAsync(); expect(configureCredentialsAsync).toHaveBeenCalled(); - expect(buildStep.outputById.get('target_names')!.value).toBe(JSON.stringify(['app', 'widget'])); + expect(buildStep.outputById.target_names.value).toBe(JSON.stringify(['app', 'widget'])); }); }); diff --git a/packages/build-tools/src/steps/functions/__tests__/configureIosVersion.test.ts b/packages/build-tools/src/steps/functions/__tests__/configureIosVersion.test.ts index 5e1b19efc5..b0e7241a72 100644 --- a/packages/build-tools/src/steps/functions/__tests__/configureIosVersion.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/configureIosVersion.test.ts @@ -215,7 +215,7 @@ describe(configureIosVersionFunction, () => { }).executeAsync(); expect(prepareSpy).toHaveBeenCalledTimes(1); - expect(configureIosCredentialsStep.outputById.get('target_names')!.value).toBe( + expect(configureIosCredentialsStep.outputById.target_names.value).toBe( JSON.stringify(['app', 'widget']) ); expect(updateVersionsAsync).toHaveBeenCalledWith( diff --git a/packages/build-tools/src/steps/functions/__tests__/deploy.test.ts b/packages/build-tools/src/steps/functions/__tests__/deploy.test.ts index ed161d56f4..c361fe34db 100644 --- a/packages/build-tools/src/steps/functions/__tests__/deploy.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/deploy.test.ts @@ -69,14 +69,12 @@ describe(createEasDeployBuildFunction, () => { logger: buildStep.ctx.logger, }), }); - expect(buildStep.outputById.get('deploy_json')!.value).toBe(mockDeployStdout); - expect(buildStep.outputById.get('deploy_url')!.value).toBe('https://example.prod'); - expect(buildStep.outputById.get('deploy_deployment_url')!.value).toBe('https://example.dev'); - expect(buildStep.outputById.get('deploy_identifier')!.value).toBe('abc'); - expect(buildStep.outputById.get('deploy_dashboard_url')!.value).toBe( - 'https://expo.dev/dashboard' - ); - expect(buildStep.outputById.get('deploy_alias_url')!.value).toBe('https://example.alias'); + expect(buildStep.outputById.deploy_json.value).toBe(mockDeployStdout); + expect(buildStep.outputById.deploy_url.value).toBe('https://example.prod'); + expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev'); + expect(buildStep.outputById.deploy_identifier.value).toBe('abc'); + expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard'); + expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias'); }); it('forwards workflow step env to the eas deploy process', async () => { @@ -219,12 +217,12 @@ describe(createEasDeployBuildFunction, () => { const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {}); await buildStep.executeAsync(); - expect(buildStep.outputById.get('deploy_json')!.value).toBe('not-json'); + expect(buildStep.outputById.deploy_json.value).toBe('not-json'); expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith( expect.objectContaining({ err: expect.anything() }), expect.stringContaining('Failed to parse') ); - expect(buildStep.outputById.get('deploy_url')!.value).toBeUndefined(); + expect(buildStep.outputById.deploy_url.value).toBeUndefined(); }); it('sets outputs from partial deploy JSON and leaves missing fields undefined', async () => { @@ -251,16 +249,12 @@ describe(createEasDeployBuildFunction, () => { const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {}); await buildStep.executeAsync(); - expect(buildStep.outputById.get('deploy_json')!.value).toBe(partialDeployStdout); - expect(buildStep.outputById.get('deploy_url')!.value).toBe('https://example.dev-only'); - expect(buildStep.outputById.get('deploy_deployment_url')!.value).toBe( - 'https://example.dev-only' - ); - expect(buildStep.outputById.get('deploy_dashboard_url')!.value).toBe( - 'https://expo.dev/dashboard-only' - ); - expect(buildStep.outputById.get('deploy_identifier')!.value).toBeUndefined(); - expect(buildStep.outputById.get('deploy_alias_url')!.value).toBeUndefined(); + expect(buildStep.outputById.deploy_json.value).toBe(partialDeployStdout); + expect(buildStep.outputById.deploy_url.value).toBe('https://example.dev-only'); + expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev-only'); + expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard-only'); + expect(buildStep.outputById.deploy_identifier.value).toBeUndefined(); + expect(buildStep.outputById.deploy_alias_url.value).toBeUndefined(); }); it('sets deploy_url from aliases[0].url when production is absent but aliases exist', async () => { @@ -288,11 +282,9 @@ describe(createEasDeployBuildFunction, () => { const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {}); await buildStep.executeAsync(); - expect(buildStep.outputById.get('deploy_url')!.value).toBe('https://example.alias-first'); - expect(buildStep.outputById.get('deploy_deployment_url')!.value).toBe( - 'https://example.deployment' - ); - expect(buildStep.outputById.get('deploy_alias_url')!.value).toBe('https://example.alias-first'); - expect(buildStep.outputById.get('deploy_identifier')!.value).toBe('partial-alias'); + expect(buildStep.outputById.deploy_url.value).toBe('https://example.alias-first'); + expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.deployment'); + expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias-first'); + expect(buildStep.outputById.deploy_identifier.value).toBe('partial-alias'); }); }); diff --git a/packages/build-tools/src/steps/functions/__tests__/export.test.ts b/packages/build-tools/src/steps/functions/__tests__/export.test.ts index 2cdb79844d..1597553d33 100644 --- a/packages/build-tools/src/steps/functions/__tests__/export.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/export.test.ts @@ -47,7 +47,7 @@ describe(createEasExportBuildFunction, () => { stdio: 'pipe', }, }); - expect(buildStep.outputById.get('export_dir')!.value).toBe('dist'); + expect(buildStep.outputById.export_dir.value).toBe('dist'); }); it('passes optional flags matching expo export CLI', async () => { @@ -86,7 +86,7 @@ describe(createEasExportBuildFunction, () => { ], }) ); - expect(buildStep.outputById.get('export_dir')!.value).toBe('web-out'); + expect(buildStep.outputById.export_dir.value).toBe('web-out'); }); it('uses resolved package manager (e.g. yarn)', async () => { diff --git a/packages/build-tools/src/steps/functions/__tests__/readAppConfig.test.ts b/packages/build-tools/src/steps/functions/__tests__/readAppConfig.test.ts index 59864ce49c..a976b8b6d5 100644 --- a/packages/build-tools/src/steps/functions/__tests__/readAppConfig.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/readAppConfig.test.ts @@ -39,7 +39,7 @@ describe(createReadAppConfigBuildFunction, () => { expect(stepLogger.info).toHaveBeenCalledWith( JSON.stringify({ name: 'my-app', slug: 'my-app-slug', version: '1.2.3' }, null, 2) ); - const firstOutput = buildStep.outputById.get('app_config')!.value; + const firstOutput = buildStep.outputById.app_config.value; expect(firstOutput).toBeDefined(); expect(JSON.parse(firstOutput!)).toEqual({ name: 'my-app', @@ -67,7 +67,7 @@ describe(createReadAppConfigBuildFunction, () => { await buildStep.executeAsync(); - const secondOutput = buildStep.outputById.get('app_config')!.value; + const secondOutput = buildStep.outputById.app_config.value; expect(secondOutput).toBeDefined(); expect(JSON.parse(secondOutput!)).toEqual({ name: 'my-app', diff --git a/packages/build-tools/src/steps/functions/__tests__/readIpaInfo.test.ts b/packages/build-tools/src/steps/functions/__tests__/readIpaInfo.test.ts index 9c8a2b1784..baac5b7b4a 100644 --- a/packages/build-tools/src/steps/functions/__tests__/readIpaInfo.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/readIpaInfo.test.ts @@ -64,10 +64,8 @@ describe(createReadIpaInfoBuildFunction, () => { }); await buildStep.executeAsync(); - expect(buildStep.outputById.get('bundle_identifier')!.value).toBe( - 'dev.expo.SmallestAppExample' - ); - expect(buildStep.outputById.get('bundle_short_version')!.value).toBe('1.0'); - expect(buildStep.outputById.get('bundle_version')!.value).toBe('1'); + expect(buildStep.outputById.bundle_identifier.value).toBe('dev.expo.SmallestAppExample'); + expect(buildStep.outputById.bundle_short_version.value).toBe('1.0'); + expect(buildStep.outputById.bundle_version.value).toBe('1'); }); }); diff --git a/packages/build-tools/src/steps/functions/__tests__/readPackageJson.test.ts b/packages/build-tools/src/steps/functions/__tests__/readPackageJson.test.ts index bd1313d267..ea12477ccf 100644 --- a/packages/build-tools/src/steps/functions/__tests__/readPackageJson.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/readPackageJson.test.ts @@ -25,7 +25,7 @@ describe(createReadPackageJsonBuildFunction, () => { stepLogger, globalContext.defaultWorkingDirectory ); - expect(buildStep.outputById.get('package_json')!.value).toBe( + expect(buildStep.outputById.package_json.value).toBe( JSON.stringify({ name: 'app', version: '1.0.0' }) ); }); @@ -45,6 +45,6 @@ describe(createReadPackageJsonBuildFunction, () => { expect(stepLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), }); - expect(buildStep.outputById.get('package_json')!.value).toBeUndefined(); + expect(buildStep.outputById.package_json.value).toBeUndefined(); }); }); diff --git a/packages/build-tools/src/steps/functions/__tests__/repack.test.ts b/packages/build-tools/src/steps/functions/__tests__/repack.test.ts index 502c41322e..4e08b8bff1 100644 --- a/packages/build-tools/src/steps/functions/__tests__/repack.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/repack.test.ts @@ -44,7 +44,7 @@ describe(createRepackBuildFunction, () => { ); await repackStep.executeAsync(); - expect(repackStep.outputById.get('output_path')!.value).toMatch(/repacked-.*\.ipa$/); + expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.ipa$/); }); it('should rename generated aab output path to apk', async () => { @@ -64,7 +64,7 @@ describe(createRepackBuildFunction, () => { ); await repackStep.executeAsync(); - expect(repackStep.outputById.get('output_path')!.value).toMatch(/repacked-.*\.apk$/); + expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.apk$/); }); it('should throw for unsupported platforms', async () => { @@ -103,7 +103,7 @@ describe(createRepackBuildFunction, () => { ); await repackStep.executeAsync(); - const tmpDir = path.dirname(repackStep.outputById.get('output_path')!.value as string); + const tmpDir = path.dirname(repackStep.outputById['output_path'].value as string); const keystoreFiles = await fg(`${tmpDir}/keystore*`, { onlyFiles: true }); expect(keystoreFiles.length).toBe(0); }); diff --git a/packages/build-tools/src/steps/functions/__tests__/waitForPosthogMetric.test.ts b/packages/build-tools/src/steps/functions/__tests__/waitForPosthogMetric.test.ts index 1e336c431a..3d2dd54ce8 100644 --- a/packages/build-tools/src/steps/functions/__tests__/waitForPosthogMetric.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/waitForPosthogMetric.test.ts @@ -57,7 +57,7 @@ describe(createWaitForPosthogMetricFunction, () => { query: { kind: 'HogQLQuery' }, refresh: 'blocking', }); - expect(step.outputById.get('value')!.value).toBe('3'); + expect(step.outputById.value.value).toBe('3'); }); it('throws when credentials are missing', async () => { diff --git a/packages/steps/src/BuildFunctionGroup.ts b/packages/steps/src/BuildFunctionGroup.ts index bb6753cf32..d0d1e69660 100644 --- a/packages/steps/src/BuildFunctionGroup.ts +++ b/packages/steps/src/BuildFunctionGroup.ts @@ -2,14 +2,13 @@ import { BuildFunctionCallInputs } from './BuildFunction'; import { BuildStep } from './BuildStep'; import { BuildStepGlobalContext } from './BuildStepContext'; import { - BuildStepInput, + BuildStepInputById, BuildStepInputProvider, - makeBuildStepInputByIdMap, + makeBuildStepInputById, } from './BuildStepInput'; import { BuildConfigError } from './errors'; export type BuildFunctionGroupById = Record; -export type BuildFunctionGroupInputs = Record; export class BuildFunctionGroup { public readonly namespace: string; @@ -36,7 +35,7 @@ export class BuildFunctionGroup { { inputs, }: { - inputs: BuildFunctionGroupInputs; + inputs: BuildStepInputById; } ) => BuildStep[]; }) { @@ -53,7 +52,7 @@ export class BuildFunctionGroup { return input; }); return createBuildStepsFromFunctionGroupCall(ctx, { - inputs: Object.fromEntries(makeBuildStepInputByIdMap(inputs)), + inputs: makeBuildStepInputById(inputs), }); }; } diff --git a/packages/steps/src/BuildStep.ts b/packages/steps/src/BuildStep.ts index dd4802f446..f863f7374a 100644 --- a/packages/steps/src/BuildStep.ts +++ b/packages/steps/src/BuildStep.ts @@ -9,12 +9,12 @@ import { BuildRuntimePlatform } from './BuildRuntimePlatform'; import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope'; import { BuildStepContext, BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepEnv } from './BuildStepEnv'; -import { BuildStepInput, BuildStepInputById, makeBuildStepInputByIdMap } from './BuildStepInput'; +import { BuildStepInput, BuildStepInputById, makeBuildStepInputById } from './BuildStepInput'; import { BuildStepOutput, BuildStepOutputById, SerializedBuildStepOutput, - makeBuildStepOutputByIdMap, + makeBuildStepOutputById, } from './BuildStepOutput'; import { cleanUpStepTemporaryDirectoriesAsync, @@ -35,6 +35,7 @@ import { BIN_PATH } from './utils/shell/bin'; import { getShellCommandAndArgs } from './utils/shell/command'; import { spawnAsync } from './utils/shell/spawn'; import { interpolateWithInputs, interpolateWithOutputs } from './utils/template'; +import { createNullPrototypeRecord } from './utils/record'; export enum BuildStepStatus { NEW = 'new', @@ -50,7 +51,6 @@ export enum BuildStepLogMarker { END_STEP = 'end-step', } -export type BuildStepFunctionOutputs = Record; export type BuildStepFunctionInputs = Record; export type BuildStepFunction = ( @@ -61,7 +61,7 @@ export type BuildStepFunction = ( env, }: { inputs: BuildStepFunctionInputs; - outputs: BuildStepFunctionOutputs; + outputs: BuildStepOutputById; env: BuildStepEnv; signal?: AbortSignal; } @@ -83,7 +83,7 @@ export class BuildStepOutputAccessor { ) {} public get outputs(): BuildStepOutput[] { - return [...this.outputById.values()]; + return Object.values(this.outputById); } public getOutputValueByName(name: string): string | undefined { @@ -95,11 +95,11 @@ export class BuildStepOutputAccessor { if (!this.hasOutputParameter(name)) { throw new BuildStepRuntimeError(`Step "${this.displayName}" does not have output "${name}".`); } - return this.outputById.get(name)!.value; + return this.outputById[name].value; } public hasOutputParameter(name: string): boolean { - return this.outputById.has(name); + return Object.hasOwn(this.outputById, name); } public serialize(): SerializedBuildStepOutputAccessor { @@ -107,7 +107,7 @@ export class BuildStepOutputAccessor { id: this.id, executed: this.executed, outputById: Object.fromEntries( - [...this.outputById].map(([key, value]) => [key, value.serialize()]) + Object.entries(this.outputById).map(([key, value]) => [key, value.serialize()]) ), displayName: this.displayName, }; @@ -116,11 +116,8 @@ export class BuildStepOutputAccessor { public static deserialize( serialized: SerializedBuildStepOutputAccessor ): BuildStepOutputAccessor { - const outputById = new Map( - Object.entries(serialized.outputById).map(([key, value]) => [ - key, - BuildStepOutput.deserialize(value), - ]) + const outputById = makeBuildStepOutputById( + Object.values(serialized.outputById).map(value => BuildStepOutput.deserialize(value)) ); return new BuildStepOutputAccessor( serialized.id, @@ -197,14 +194,14 @@ export class BuildStep extends BuildStepOutputAccessor { ) { assert(command !== undefined || fn !== undefined, 'Either command or fn must be defined.'); assert(!(command !== undefined && fn !== undefined), 'Command and fn cannot be both set.'); - const outputById = makeBuildStepOutputByIdMap(outputs); + const outputById = makeBuildStepOutputById(outputs); super(id, displayName, false, outputById); this.id = id; this.displayName = displayName; this.supportedRuntimePlatforms = maybeSupportedRuntimePlatforms; this.inputs = inputs; - this.inputById = makeBuildStepInputByIdMap(inputs); + this.inputById = makeBuildStepInputById(inputs); this.outputById = outputById; this.fn = fn; this.command = command; @@ -354,18 +351,14 @@ export class BuildStep extends BuildStepOutputAccessor { } private evaluateOwnStepInputs(): Record { - return ( - this.inputs?.reduce( - (acc, input) => { - acc[input.id] = input.getValue({ - interpolationContext: this.getInterpolationContext(), - skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined, - }); - return acc; - }, - {} as Record - ) ?? {} - ); + const inputsById = createNullPrototypeRecord(); + for (const input of this.inputs ?? []) { + inputsById[input.id] = input.getValue({ + interpolationContext: this.getInterpolationContext(), + skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined, + }); + } + return inputsById; } private evaluateIfCondition( @@ -494,18 +487,8 @@ export class BuildStep extends BuildStepOutputAccessor { assert(this.fn, 'Function (fn) must be defined'); await this.fn(this.ctx, { - inputs: Object.fromEntries( - [...this.inputById].map(([key, input]) => [ - key, - { - value: input.getValue({ - interpolationContext: this.getInterpolationContext(), - skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined, - }), - }, - ]) - ), - outputs: Object.fromEntries(this.outputById), + inputs: this.getFunctionInputs(), + outputs: this.outputById, env: this.getScriptEnv(), signal: signal ?? undefined, }); @@ -513,6 +496,19 @@ export class BuildStep extends BuildStepOutputAccessor { this.ctx.logger.debug(`Script completed successfully`); } + private getFunctionInputs(): BuildStepFunctionInputs { + const functionInputs = createNullPrototypeRecord<{ value: unknown }>(); + for (const [key, input] of Object.entries(this.inputById)) { + functionInputs[key] = { + value: input.getValue({ + interpolationContext: this.getInterpolationContext(), + skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined, + }), + }; + } + return functionInputs; + } + private interpolateInputsOutputsAndGlobalContextInTemplate( template: string, inputs?: BuildStepInput[] @@ -528,18 +524,15 @@ export class BuildStep extends BuildStepOutputAccessor { this.getLegacyStepOutputValue(path) ); } - const vars = inputs.reduce( - (acc, input) => { - const value = input.getValue({ - interpolationContext: this.getInterpolationContext(), - skipLegacyOutputInterpolation, - }); - acc[input.id] = - typeof value === 'object' ? JSON.stringify(value) : (value?.toString() ?? ''); - return acc; - }, - {} as Record - ); + const vars = createNullPrototypeRecord(); + for (const input of inputs) { + const value = input.getValue({ + interpolationContext: this.getInterpolationContext(), + skipLegacyOutputInterpolation, + }); + vars[input.id] = + typeof value === 'object' ? JSON.stringify(value) : (value?.toString() ?? ''); + } const interpolatedWithInputsAndGlobalContext = interpolateWithInputs( this.ctx.global.interpolate(template), vars @@ -559,23 +552,23 @@ export class BuildStep extends BuildStepOutputAccessor { const files = await fs.readdir(outputsDir); for (const outputId of files) { - if (!this.outputById.has(outputId)) { + if (!Object.hasOwn(this.outputById, outputId)) { const newOutput = new BuildStepOutput(this.ctx.global, { id: outputId, stepDisplayName: this.displayName, required: false, }); - this.outputById.set(outputId, newOutput); + this.outputById[outputId] = newOutput; } const file = path.join(outputsDir, outputId); const rawContents = await fs.readFile(file, 'utf-8'); const decodedContents = Buffer.from(rawContents, 'base64').toString('utf-8'); - this.outputById.get(outputId)!.set(decodedContents); + this.outputById[outputId].set(decodedContents); } const nonSetRequiredOutputIds: string[] = []; - for (const output of this.outputById.values()) { + for (const output of Object.values(this.outputById)) { try { const value = output.value; this.ctx.logger.debug(`Output parameter "${output.id}" is set to "${value}"`); diff --git a/packages/steps/src/BuildStepInput.ts b/packages/steps/src/BuildStepInput.ts index 4d8c9f52d4..d5d4387722 100644 --- a/packages/steps/src/BuildStepInput.ts +++ b/packages/steps/src/BuildStepInput.ts @@ -8,6 +8,7 @@ import { BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX, interpolateWithOutputs, } from './utils/template'; +import { createNullPrototypeRecord } from './utils/record'; export enum BuildStepInputValueTypeName { STRING = 'string', @@ -26,7 +27,7 @@ export type BuildStepInputValueType< ? number : Record; -export type BuildStepInputById = Map; +export type BuildStepInputById = Record; export type BuildStepInputProvider = ( ctx: BuildStepGlobalContext, stepId: string @@ -255,6 +256,10 @@ export function getDisallowedInputValueError( 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 { - return new Map(inputs?.map(input => [input.id, input])); +export function makeBuildStepInputById(inputs?: BuildStepInput[]): BuildStepInputById { + const inputById = createNullPrototypeRecord(); + for (const input of inputs ?? []) { + inputById[input.id] = input; + } + return inputById; } diff --git a/packages/steps/src/BuildStepOutput.ts b/packages/steps/src/BuildStepOutput.ts index 71f882436b..38ebbdc4f4 100644 --- a/packages/steps/src/BuildStepOutput.ts +++ b/packages/steps/src/BuildStepOutput.ts @@ -1,7 +1,8 @@ import { BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepRuntimeError } from './errors'; +import { createNullPrototypeRecord } from './utils/record'; -export type BuildStepOutputById = Map; +export type BuildStepOutputById = Record; export type BuildStepOutputProvider = ( ctx: BuildStepGlobalContext, stepDisplayName: string @@ -92,6 +93,10 @@ export class BuildStepOutput { } } -export function makeBuildStepOutputByIdMap(outputs?: BuildStepOutput[]): BuildStepOutputById { - return new Map(outputs?.map(output => [output.id, output])); +export function makeBuildStepOutputById(outputs?: BuildStepOutput[]): BuildStepOutputById { + const outputById = createNullPrototypeRecord(); + for (const output of outputs ?? []) { + outputById[output.id] = output; + } + return outputById; } diff --git a/packages/steps/src/__tests__/BuildConfigParser-test.ts b/packages/steps/src/__tests__/BuildConfigParser-test.ts index 97786a26d7..97d9bec9d5 100644 --- a/packages/steps/src/__tests__/BuildConfigParser-test.ts +++ b/packages/steps/src/__tests__/BuildConfigParser-test.ts @@ -269,8 +269,7 @@ describe(BuildConfigParser, () => { expect(step1.command).toMatchSnapshot(); expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); expect(step1.shell).toBe('/bin/bash -eo pipefail'); - const first_name = step1.outputById.get('first_name')!; - const last_name = step1.outputById.get('last_name')!; + const { first_name, last_name } = step1.outputById; expect(first_name.id).toBe('first_name'); expect(first_name.required).toBe(true); expect(last_name.id).toBe('last_name'); @@ -294,14 +293,14 @@ describe(BuildConfigParser, () => { expect(step2.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); expect(step2.shell).toBe('/bin/bash -eo pipefail'); const step2Outputs = step2.outputById; - expect(step2Outputs.get('first_name')!.id).toBe('first_name'); - expect(step2Outputs.get('first_name')!.required).toBe(true); - expect(step2Outputs.get('middle_name')!.id).toBe('middle_name'); - expect(step2Outputs.get('middle_name')!.required).toBe(false); - expect(step2Outputs.get('last_name')!.id).toBe('last_name'); - expect(step2Outputs.get('last_name')!.required).toBe(true); - expect(step2Outputs.get('nickname')!.id).toBe('nickname'); - expect(step2Outputs.get('nickname')!.required).toBe(true); + expect(step2Outputs.first_name.id).toBe('first_name'); + expect(step2Outputs.first_name.required).toBe(true); + expect(step2Outputs.middle_name.id).toBe('middle_name'); + expect(step2Outputs.middle_name.required).toBe(false); + expect(step2Outputs.last_name.id).toBe('last_name'); + expect(step2Outputs.last_name.required).toBe(true); + expect(step2Outputs.nickname.id).toBe('nickname'); + expect(step2Outputs.nickname.required).toBe(true); }); it('parses functions and function calls', async () => { @@ -398,7 +397,7 @@ describe(BuildConfigParser, () => { expect(step4.command).toBe('set-output value 6'); expect(step4.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); expect(step4.shell).toBe('/bin/bash -eo pipefail'); - const value = step4.outputById.get('value')!; + const { value } = step4.outputById; expect(value.id).toBe('value'); expect(value.required).toBe(true); expect(step4.stepEnvOverrides).toMatchObject({}); diff --git a/packages/steps/src/__tests__/BuildFunction-test.ts b/packages/steps/src/__tests__/BuildFunction-test.ts index 422d72efaa..63e1bad232 100644 --- a/packages/steps/src/__tests__/BuildFunction-test.ts +++ b/packages/steps/src/__tests__/BuildFunction-test.ts @@ -236,8 +236,8 @@ describe(BuildFunction, () => { expect(step.inputs?.[0].id).toBe('input1'); expect(step.inputs?.[1].id).toBe('input2'); expect(step.inputs?.[2].id).toBe('input3'); - expect(step.outputById.get('output1')).toBeDefined(); - expect(step.outputById.get('output2')).toBeDefined(); + expect(step.outputById.output1).toBeDefined(); + expect(step.outputById.output2).toBeDefined(); }); it('passes values to build inputs', () => { const ctx = createGlobalContextMock(); diff --git a/packages/steps/src/__tests__/BuildStep-test.ts b/packages/steps/src/__tests__/BuildStep-test.ts index 306aa097fd..2f445b0fa7 100644 --- a/packages/steps/src/__tests__/BuildStep-test.ts +++ b/packages/steps/src/__tests__/BuildStep-test.ts @@ -412,6 +412,34 @@ describe(BuildStep, () => { expect(step.getOutputValueByName('foo2')).toBe('bar linux {"foo":"bar","baz":[1,"aaa"]}'); }); + it('interpolates an input named __proto__ in a command template', async () => { + const step = new BuildStep(baseStepCtx, { + id: 'test1', + displayName: 'test1', + inputs: [ + new BuildStepInput(baseStepCtx, { + id: '__proto__', + stepDisplayName: 'test1', + defaultValue: 'prototype input', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + outputs: [ + new BuildStepOutput(baseStepCtx, { + id: 'result', + stepDisplayName: 'test1', + required: true, + }), + ], + command: "set-output result '${inputs.__proto__}'", + }); + + await step.executeAsync(); + + expect(step.getOutputValueByName('result')).toBe('prototype input'); + }); + it('interpolates the outputs in command template', async () => { const stepWithOutput = new BuildFunction({ id: 'func', @@ -464,7 +492,7 @@ describe(BuildStep, () => { command, }); await step.executeAsync(); - const abc = nullthrows(step.outputById.get('abc')); + const abc = nullthrows(step.outputById.abc); expect(abc?.value).toBe('123'); }); @@ -510,7 +538,7 @@ describe(BuildStep, () => { fn, }); await step.executeAsync(); - const abc = nullthrows(step.outputById.get('abc')); + const abc = nullthrows(step.outputById.abc); expect(abc?.value).toBe('123'); }); }); @@ -607,7 +635,7 @@ describe(BuildStep, () => { command, }); await step.executeAsync(); - const abc = nullthrows(step.outputById.get('abc')); + const abc = nullthrows(step.outputById.abc); expect(abc?.value).toBe('d o m i n i k'); }); @@ -1308,6 +1336,27 @@ describe(BuildStep.prototype.shouldExecuteStep, () => { expect(step.shouldExecuteStep()).toBe(true); }); + it('returns true when an input named __proto__ matches', () => { + const ctx = createGlobalContextMock(); + const step = new BuildStep(ctx, { + id: 'test1', + displayName: 'Test 1', + command: 'echo 123', + inputs: [ + new BuildStepInput(ctx, { + id: '__proto__', + stepDisplayName: 'Test 1', + defaultValue: 'prototype input', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + ifCondition: 'inputs.__proto__ === "prototype input"', + }); + + expect(step.shouldExecuteStep()).toBe(true); + }); + it('returns true when an eas value matches', () => { const ctx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }); const step = new BuildStep(ctx, { diff --git a/packages/steps/src/__tests__/BuildStepInput-test.ts b/packages/steps/src/__tests__/BuildStepInput-test.ts index 15a628a6ec..3c1daa5f54 100644 --- a/packages/steps/src/__tests__/BuildStepInput-test.ts +++ b/packages/steps/src/__tests__/BuildStepInput-test.ts @@ -5,7 +5,7 @@ import { BuildStep } from '../BuildStep'; import { BuildStepInput, BuildStepInputValueTypeName, - makeBuildStepInputByIdMap, + makeBuildStepInputById, } from '../BuildStepInput'; import { BuildStepRuntimeError } from '../errors'; @@ -942,12 +942,15 @@ describe(BuildStepInput, () => { }); }); -describe(makeBuildStepInputByIdMap, () => { - it('returns empty map when inputs are undefined', () => { - expect(makeBuildStepInputByIdMap(undefined)).toEqual(new Map()); +describe(makeBuildStepInputById, () => { + it('returns an empty null-prototype object when inputs are undefined', () => { + const result = makeBuildStepInputById(undefined); + + expect(Object.keys(result)).toEqual([]); + expect(Object.getPrototypeOf(result)).toBeNull(); }); - it('returns map with inputs indexed by their ids', () => { + it('returns a null-prototype object with inputs indexed by their ids', () => { const ctx = createGlobalContextMock(); const inputs: BuildStepInput[] = [ new BuildStepInput(ctx, { @@ -972,19 +975,20 @@ describe(makeBuildStepInputByIdMap, () => { required: true, }), ]; - const result = makeBuildStepInputByIdMap(inputs); - expect(result.size).toBe(3); - expect(result.get('foo1')).toBeDefined(); - expect(result.get('foo2')).toBeDefined(); - expect( - result.get('foo1')!.getValue({ interpolationContext: ctx.getInterpolationContext() }) - ).toBe('bar1'); - expect( - result.get('foo2')!.getValue({ interpolationContext: ctx.getInterpolationContext() }) - ).toBe('bar2'); - expect( - result.get('foo3')!.getValue({ interpolationContext: ctx.getInterpolationContext() }) - ).toBe(true); + const result = makeBuildStepInputById(inputs); + expect(Object.getPrototypeOf(result)).toBeNull(); + expect(Object.keys(result)).toHaveLength(3); + expect(result.foo1).toBeDefined(); + expect(result.foo2).toBeDefined(); + expect(result.foo1.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe( + 'bar1' + ); + expect(result.foo2.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe( + 'bar2' + ); + expect(result.foo3.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe( + true + ); }); it('supports input ids that are special object property names', () => { @@ -997,8 +1001,9 @@ describe(makeBuildStepInputByIdMap, () => { allowedValueTypeName: BuildStepInputValueTypeName.STRING, }); - const result = makeBuildStepInputByIdMap([input]); + const result = makeBuildStepInputById([input]); - expect(result.get('__proto__')).toBe(input); + expect(result.__proto__).toBe(input); + expect(Object.hasOwn(result, '__proto__')).toBe(true); }); }); diff --git a/packages/steps/src/__tests__/BuildStepOutput-test.ts b/packages/steps/src/__tests__/BuildStepOutput-test.ts index 8945edadb4..d2dfb3a584 100644 --- a/packages/steps/src/__tests__/BuildStepOutput-test.ts +++ b/packages/steps/src/__tests__/BuildStepOutput-test.ts @@ -1,6 +1,6 @@ import { createGlobalContextMock } from './utils/context'; import { BuildStep } from '../BuildStep'; -import { BuildStepOutput, makeBuildStepOutputByIdMap } from '../BuildStepOutput'; +import { BuildStepOutput, makeBuildStepOutputById } from '../BuildStepOutput'; import { BuildStepRuntimeError } from '../errors'; describe(BuildStepOutput, () => { @@ -76,12 +76,15 @@ describe(BuildStepOutput, () => { }); }); -describe(makeBuildStepOutputByIdMap, () => { - it('returns empty map when inputs are undefined', () => { - expect(makeBuildStepOutputByIdMap(undefined)).toEqual(new Map()); +describe(makeBuildStepOutputById, () => { + it('returns an empty null-prototype object when outputs are undefined', () => { + const result = makeBuildStepOutputById(undefined); + + expect(Object.keys(result)).toEqual([]); + expect(Object.getPrototypeOf(result)).toBeNull(); }); - it('returns map with outputs indexed by their ids', () => { + it('returns a null-prototype object with outputs indexed by their ids', () => { const ctx = createGlobalContextMock(); const outputs: BuildStepOutput[] = [ new BuildStepOutput(ctx, { @@ -95,10 +98,11 @@ describe(makeBuildStepOutputByIdMap, () => { required: true, }), ]; - const result = makeBuildStepOutputByIdMap(outputs); - expect(result.size).toBe(2); - expect(result.get('abc1')).toBeDefined(); - expect(result.get('abc2')).toBeDefined(); + const result = makeBuildStepOutputById(outputs); + expect(Object.getPrototypeOf(result)).toBeNull(); + expect(Object.keys(result)).toHaveLength(2); + expect(result.abc1).toBeDefined(); + expect(result.abc2).toBeDefined(); }); it('supports output ids that are special object property names', () => { @@ -109,8 +113,9 @@ describe(makeBuildStepOutputByIdMap, () => { required: true, }); - const result = makeBuildStepOutputByIdMap([output]); + const result = makeBuildStepOutputById([output]); - expect(result.get('__proto__')).toBe(output); + expect(result.__proto__).toBe(output); + expect(Object.hasOwn(result, '__proto__')).toBe(true); }); }); diff --git a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-outputs-test.ts b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-outputs-test.ts index e5faf6c7f4..ea7e4cbb4d 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-composite-functions-outputs-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-composite-functions-outputs-test.ts @@ -40,7 +40,7 @@ describe('StepsConfigParser local composite functions', () => { expect(outputsStep.displayName).toBe('Setup'); expect(outputsStep.command).toBeUndefined(); expect(outputsStep.fn).toBeDefined(); - expect([...outputsStep.outputById.keys()]).toEqual(['version']); + expect(Object.keys(outputsStep.outputById)).toEqual(['version']); }); it('uses a generated call id for the outputs node when the caller has no id', async () => { diff --git a/packages/steps/src/__tests__/StepsConfigParser-test.ts b/packages/steps/src/__tests__/StepsConfigParser-test.ts index 390abc8ba3..561ca9982f 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-test.ts @@ -286,7 +286,7 @@ describe(StepsConfigParser, () => { expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); expect(step1.stepEnvOverrides).toEqual({}); expect(step1.inputs).toBeUndefined(); - expect(step1.outputById).toStrictEqual(new Map()); + expect(Object.keys(step1.outputById)).toEqual([]); expect(step1.ifCondition).toBeUndefined(); const step2 = result.buildSteps[1]; @@ -299,7 +299,7 @@ describe(StepsConfigParser, () => { a: 'b', }); expect(step2.inputs).toBeUndefined(); - expect(step2.outputById).toStrictEqual(new Map()); + expect(Object.keys(step2.outputById)).toEqual([]); expect(step2.ifCondition).toBeUndefined(); const step3 = result.buildSteps[2]; @@ -314,11 +314,13 @@ describe(StepsConfigParser, () => { }); expect(step3.inputs).toBeUndefined(); expect(step3.outputById).toBeDefined(); - expect(step3.outputById.size).toBe(3); + expect(Object.keys(step3.outputById)).toHaveLength(3); assert(step3.outputById); - const output1 = step3.outputById.get('my_output')!; - const output2 = step3.outputById.get('my_optional_output')!; - const output3 = step3.outputById.get('my_optional_output_without_required')!; + const { + my_output: output1, + my_optional_output: output2, + my_optional_output_without_required: output3, + } = step3.outputById; expect(output1.id).toBe('my_output'); expect(output1.required).toBe(true); expect(output2.id).toBe('my_optional_output'); @@ -384,7 +386,7 @@ describe(StepsConfigParser, () => { expect(input4.defaultValue).toBeUndefined(); expect(input4.rawValue).toBe('${ step3.my_output }'); expect(input4.required).toBe(true); - expect(step4.outputById).toStrictEqual(new Map()); + expect(Object.keys(step4.outputById)).toEqual([]); expect(step4.ifCondition).toBe('${ ctx.job.platform } == "android"'); }); }); diff --git a/packages/steps/src/utils/record.ts b/packages/steps/src/utils/record.ts new file mode 100644 index 0000000000..f3a8ad4528 --- /dev/null +++ b/packages/steps/src/utils/record.ts @@ -0,0 +1,4 @@ +/** Creates a dictionary that safely supports arbitrary user-defined keys such as `__proto__`. */ +export function createNullPrototypeRecord(): Record { + return Object.create(null) as Record; +} From 11b4800a2f1aaf61c32f0a3ce0f587cbe1757fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Fri, 24 Jul 2026 22:02:34 +0200 Subject: [PATCH 7/7] Clarify safe record handling --- AGENTS.md | 4 ++ packages/steps/src/BuildStep.ts | 15 +++---- packages/steps/src/BuildStepInput.ts | 4 +- packages/steps/src/BuildStepOutput.ts | 4 +- .../steps/src/__tests__/BuildStep-test.ts | 40 +++++++++++++++++++ .../steps/src/utils/__tests__/record-test.ts | 12 ++++++ packages/steps/src/utils/record.ts | 6 +-- 7 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 packages/steps/src/utils/__tests__/record-test.ts diff --git a/AGENTS.md b/AGENTS.md index 11ec338b15..c44eb51e56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,3 +4,7 @@ When working with this repository follow instructions from CLAUDE.md. - [./CLAUDE.md](./CLAUDE.md) - Before committing, run the formatter on modified files. +- Use null-prototype records for object-shaped dictionaries keyed by user-controlled identifiers. + Keep these dictionaries as records throughout runtime code; only convert them at explicit + serialization boundaries. Use `Map` for internal collections that do not need an object-shaped + API or JSON representation. diff --git a/packages/steps/src/BuildStep.ts b/packages/steps/src/BuildStep.ts index f863f7374a..de62b23960 100644 --- a/packages/steps/src/BuildStep.ts +++ b/packages/steps/src/BuildStep.ts @@ -35,7 +35,7 @@ import { BIN_PATH } from './utils/shell/bin'; import { getShellCommandAndArgs } from './utils/shell/command'; import { spawnAsync } from './utils/shell/spawn'; import { interpolateWithInputs, interpolateWithOutputs } from './utils/template'; -import { createNullPrototypeRecord } from './utils/record'; +import { createEmptyRecord } from './utils/record'; export enum BuildStepStatus { NEW = 'new', @@ -116,9 +116,10 @@ export class BuildStepOutputAccessor { public static deserialize( serialized: SerializedBuildStepOutputAccessor ): BuildStepOutputAccessor { - const outputById = makeBuildStepOutputById( - Object.values(serialized.outputById).map(value => BuildStepOutput.deserialize(value)) - ); + const outputById = createEmptyRecord(); + for (const [key, value] of Object.entries(serialized.outputById)) { + outputById[key] = BuildStepOutput.deserialize(value); + } return new BuildStepOutputAccessor( serialized.id, serialized.displayName, @@ -351,7 +352,7 @@ export class BuildStep extends BuildStepOutputAccessor { } private evaluateOwnStepInputs(): Record { - const inputsById = createNullPrototypeRecord(); + const inputsById = createEmptyRecord>(); for (const input of this.inputs ?? []) { inputsById[input.id] = input.getValue({ interpolationContext: this.getInterpolationContext(), @@ -497,7 +498,7 @@ export class BuildStep extends BuildStepOutputAccessor { } private getFunctionInputs(): BuildStepFunctionInputs { - const functionInputs = createNullPrototypeRecord<{ value: unknown }>(); + const functionInputs = createEmptyRecord(); for (const [key, input] of Object.entries(this.inputById)) { functionInputs[key] = { value: input.getValue({ @@ -524,7 +525,7 @@ export class BuildStep extends BuildStepOutputAccessor { this.getLegacyStepOutputValue(path) ); } - const vars = createNullPrototypeRecord(); + const vars = createEmptyRecord>(); for (const input of inputs) { const value = input.getValue({ interpolationContext: this.getInterpolationContext(), diff --git a/packages/steps/src/BuildStepInput.ts b/packages/steps/src/BuildStepInput.ts index d5d4387722..3b45d8c102 100644 --- a/packages/steps/src/BuildStepInput.ts +++ b/packages/steps/src/BuildStepInput.ts @@ -8,7 +8,7 @@ import { BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX, interpolateWithOutputs, } from './utils/template'; -import { createNullPrototypeRecord } from './utils/record'; +import { createEmptyRecord } from './utils/record'; export enum BuildStepInputValueTypeName { STRING = 'string', @@ -257,7 +257,7 @@ export function getDisallowedInputValueError( } export function makeBuildStepInputById(inputs?: BuildStepInput[]): BuildStepInputById { - const inputById = createNullPrototypeRecord(); + const inputById = createEmptyRecord(); for (const input of inputs ?? []) { inputById[input.id] = input; } diff --git a/packages/steps/src/BuildStepOutput.ts b/packages/steps/src/BuildStepOutput.ts index 38ebbdc4f4..a286346446 100644 --- a/packages/steps/src/BuildStepOutput.ts +++ b/packages/steps/src/BuildStepOutput.ts @@ -1,6 +1,6 @@ import { BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepRuntimeError } from './errors'; -import { createNullPrototypeRecord } from './utils/record'; +import { createEmptyRecord } from './utils/record'; export type BuildStepOutputById = Record; export type BuildStepOutputProvider = ( @@ -94,7 +94,7 @@ export class BuildStepOutput { } export function makeBuildStepOutputById(outputs?: BuildStepOutput[]): BuildStepOutputById { - const outputById = createNullPrototypeRecord(); + const outputById = createEmptyRecord(); for (const output of outputs ?? []) { outputById[output.id] = output; } diff --git a/packages/steps/src/__tests__/BuildStep-test.ts b/packages/steps/src/__tests__/BuildStep-test.ts index 2f445b0fa7..e655458112 100644 --- a/packages/steps/src/__tests__/BuildStep-test.ts +++ b/packages/steps/src/__tests__/BuildStep-test.ts @@ -1210,6 +1210,46 @@ describe(BuildStep.deserialize, () => { expect(step.displayName).toBe('Test 1'); expect(step.getOutputValueByName('abc')).toBe('123'); }); + + it('preserves serialized output keys when they differ from embedded output ids', () => { + const step = BuildStep.deserialize({ + id: 'test1', + displayName: 'Test 1', + executed: true, + outputById: { + serialized_key: { + id: 'embedded_id', + stepDisplayName: 'Test 1', + required: true, + value: '123', + }, + }, + }); + + expect(step.getOutputValueByName('serialized_key')).toBe('123'); + expect(step.hasOutputParameter('embedded_id')).toBe(false); + }); + + it('deserializes an output keyed by __proto__', () => { + const step = BuildStep.deserialize({ + id: 'test1', + displayName: 'Test 1', + executed: true, + outputById: Object.fromEntries([ + [ + '__proto__', + { + id: '__proto__', + stepDisplayName: 'Test 1', + required: true, + value: '123', + }, + ], + ]), + }); + + expect(step.getOutputValueByName('__proto__')).toBe('123'); + }); }); describe(BuildStep.prototype.shouldExecuteStep, () => { diff --git a/packages/steps/src/utils/__tests__/record-test.ts b/packages/steps/src/utils/__tests__/record-test.ts new file mode 100644 index 0000000000..329b5b24f9 --- /dev/null +++ b/packages/steps/src/utils/__tests__/record-test.ts @@ -0,0 +1,12 @@ +import { createEmptyRecord } from '../record'; + +describe(createEmptyRecord, () => { + it('creates a typed null-prototype record', () => { + const record = createEmptyRecord>(); + record.a = 1; + record.b = 2; + + expect(record).toEqual({ a: 1, b: 2 }); + expect(Object.getPrototypeOf(record)).toBeNull(); + }); +}); diff --git a/packages/steps/src/utils/record.ts b/packages/steps/src/utils/record.ts index f3a8ad4528..67622d8017 100644 --- a/packages/steps/src/utils/record.ts +++ b/packages/steps/src/utils/record.ts @@ -1,4 +1,4 @@ -/** Creates a dictionary that safely supports arbitrary user-defined keys such as `__proto__`. */ -export function createNullPrototypeRecord(): Record { - return Object.create(null) as Record; +/** Creates an empty dictionary that safely supports arbitrary keys such as `__proto__`. */ +export function createEmptyRecord>(): T { + return Object.create(null) as T; }