From 51141162f8e3f7fdb56bf31dfd5e10ef908d251d Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 24 Sep 2026 13:11:13 -0600 Subject: [PATCH 01/21] feat: add TransportProvider abstraction and DeployPipeline scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a first-class transport layer so `componentSet.deploy()` can route metadata types through secondary endpoints (Connect API, presigned URLs, DataKit) alongside the standard Metadata API. - TransportProvider interface: handles(), deploy(), retrieve(), describe() - DeployPipeline: groups components by transport, runs before/after metadata phases, merges results into DeployResult - DeployResult: addTransportResults() + getAsyncTransportHandles() - componentSet.deploy() delegates to deployWithPipeline when transports are registered — no public API signature change - Registry: transport strategy hint on MetadataType.strategies --- src/client/index.ts | 15 + src/client/metadataApiDeploy.ts | 55 ++- src/client/transports/deployPipeline.ts | 185 +++++++++++ src/client/transports/index.ts | 33 ++ src/client/transports/types.ts | 59 ++++ src/collections/componentSet.ts | 48 ++- src/index.ts | 13 + src/registry/types.ts | 1 + test/client/transports/deployPipeline.test.ts | 312 ++++++++++++++++++ .../transports/deployResultTransport.test.ts | 157 +++++++++ 10 files changed, 873 insertions(+), 5 deletions(-) create mode 100644 src/client/transports/deployPipeline.ts create mode 100644 src/client/transports/index.ts create mode 100644 src/client/transports/types.ts create mode 100644 test/client/transports/deployPipeline.test.ts create mode 100644 test/client/transports/deployResultTransport.test.ts diff --git a/src/client/index.ts b/src/client/index.ts index 3ceb0d156..e631915d8 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -22,6 +22,21 @@ export { ScopedPostDeploy, } from './metadataApiDeploy'; export { MetadataApiRetrieve, RetrieveResult, ScopedPostRetrieve, ScopedPreRetrieve } from './metadataApiRetrieve'; +export { + TransportProvider, + TransportContext, + TransportResult, + AsyncTransportHandle, + TransportStatus, + TransportPhase, + TransportDescription, + DeployPipeline, + TransportGroup, + GroupedComponents, + DeployPlan, + DeployPlanPhase, + PipelineDeployResult, +} from './transports'; export { ComponentDeployment, ComponentRetrieval, diff --git a/src/client/metadataApiDeploy.ts b/src/client/metadataApiDeploy.ts index 8945d4157..992876aa2 100644 --- a/src/client/metadataApiDeploy.ts +++ b/src/client/metadataApiDeploy.ts @@ -49,6 +49,8 @@ import { uiBundleResourceFullNameToFilePath, } from './deployMessages'; import { parseDeployDiagnostic } from './diagnosticUtil'; +import { AsyncTransportHandle, TransportContext } from './transports/types'; +import { DeployPipeline, TransportGroup } from './transports/deployPipeline'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); @@ -56,6 +58,8 @@ const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sd // TODO: (NEXT MAJOR) this should just be a readonly object and not a class. export class DeployResult implements MetadataTransferResult { private fileResponses?: FileResponse[]; + private transportFileResponses: FileResponse[] = []; + private asyncTransportHandles: AsyncTransportHandle[] = []; public constructor( public readonly response: MetadataApiDeployStatus, @@ -64,21 +68,34 @@ export class DeployResult implements MetadataTransferResult { public readonly zipMeta?: { zipSize: number; zipFileCount?: number } ) {} + public addTransportResults(fileResponses: FileResponse[], asyncHandles: AsyncTransportHandle[] = []): void { + this.transportFileResponses.push(...fileResponses); + this.asyncTransportHandles.push(...asyncHandles); + // invalidate cached responses so next getFileResponses() call includes transport results + this.fileResponses = undefined; + } + public getFileResponses(): FileResponse[] { // this involves FS operations, so only perform once! if (!this.fileResponses) { this.fileResponses = [ // removes duplicates from the file responses by parsing the object into a string, used as the key of the map ...new Map( - (this.components - ? buildFileResponsesFromComponentSet(this.components)(this.response) - : buildFileResponses(this.response) - ).map((v) => [JSON.stringify(v), v]) + [ + ...(this.components + ? buildFileResponsesFromComponentSet(this.components)(this.response) + : buildFileResponses(this.response)), + ...this.transportFileResponses, + ].map((v) => [JSON.stringify(v), v]) ).values(), ]; } return this.fileResponses; } + + public getAsyncTransportHandles(): AsyncTransportHandle[] { + return this.asyncTransportHandles; + } } export type MetadataApiDeployOptions = { @@ -108,6 +125,18 @@ export class MetadataApiDeploy extends MetadataTransfer< rest: false, }, }; + + /** @internal */ + public transportFileResponses: FileResponse[] = []; + /** @internal */ + public transportAsyncHandles: AsyncTransportHandle[] = []; + /** @internal */ + public pendingAfterMetadata?: { + pipeline: DeployPipeline; + transports: TransportGroup[]; + context: TransportContext; + }; + private options: MetadataApiDeployOptions; private replacements: Map> = new Map(); private orgId?: string; @@ -319,12 +348,30 @@ export class MetadataApiDeploy extends MetadataTransfer< }` ); } + // run after-metadata transports (e.g., DataKit trigger) if any are pending + // wrapped in try/catch so a transport failure doesn't lose the successful MDAPI result + if (this.pendingAfterMetadata) { + try { + const { pipeline, transports, context } = this.pendingAfterMetadata; + const afterResults = await pipeline.runAfterMetadata(context, transports); + this.transportFileResponses.push(...afterResults.fileResponses); + this.transportAsyncHandles.push(...afterResults.asyncHandles); + } catch (err) { + this.logger.warn(`After-metadata transport failed: ${(err as Error).message}`); + } + } + const deployResult = new DeployResult( result, this.components, new Map(Array.from(this.replacements).map(([k, v]) => [k, Array.from(v)])), { zipSize: this.zipSize ?? 0, zipFileCount: this.zipFileCount } ); + + if (this.transportFileResponses.length > 0 || this.transportAsyncHandles.length > 0) { + deployResult.addTransportResults(this.transportFileResponses, this.transportAsyncHandles); + } + // only do event hooks if source, (NOT a metadata format) deploy if (this.options.components) { // this may not be set if you resume a deploy so that `pre` is skipped. diff --git a/src/client/transports/deployPipeline.ts b/src/client/transports/deployPipeline.ts new file mode 100644 index 000000000..1f66a784d --- /dev/null +++ b/src/client/transports/deployPipeline.ts @@ -0,0 +1,185 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Logger } from '@salesforce/core'; +import { SourceComponent } from '../../resolve/sourceComponent'; +import { FileResponse } from '../types'; +import { AsyncTransportHandle, TransportContext, TransportPhase, TransportProvider, TransportResult } from './types'; + +export type TransportGroup = { + transport: TransportProvider; + components: SourceComponent[]; +}; + +export type GroupedComponents = { + metadataApi: SourceComponent[]; + transports: TransportGroup[]; +}; + +export type DeployPlan = { + phases: DeployPlanPhase[]; +}; + +export type DeployPlanPhase = { + phase: TransportPhase | 'metadata-api'; + label: string; + endpoint: string; + components: Array<{ fullName: string; type: string }>; +}; + +export type PipelineDeployResult = { + fileResponses: FileResponse[]; + asyncHandles: AsyncTransportHandle[]; +}; + +export class DeployPipeline { + private readonly transports: TransportProvider[] = []; + private readonly logger: Logger; + + public constructor() { + this.logger = Logger.childFromRoot('DeployPipeline'); + } + + public registerTransport(transport: TransportProvider): void { + this.transports.push(transport); + } + + public groupByTransport(components: SourceComponent[], skipTypes?: string[]): GroupedComponents { + const skip = skipTypes ? new Set(skipTypes) : undefined; + const metadataApi: SourceComponent[] = []; + const transportMap = new Map(); + + for (const component of components) { + const transport = this.findTransportFor(component, skip); + if (transport) { + let group = transportMap.get(transport.name); + if (!group) { + group = { transport, components: [] }; + transportMap.set(transport.name, group); + } + group.components.push(component); + } + // all components go through metadata api — transport components + // also have metadata portions (.compute-meta.xml) that need MDAPI + metadataApi.push(component); + } + + return { + metadataApi, + transports: [...transportMap.values()], + }; + } + + public explain( + components: SourceComponent[], + operation: 'deploy' | 'retrieve' = 'deploy', + skipTypes?: string[] + ): DeployPlan { + const { metadataApi, transports } = this.groupByTransport(components, skipTypes); + const phases: DeployPlanPhase[] = []; + + const beforeMetadata = transports.filter((g) => g.transport.phase[operation] === 'before-metadata'); + const afterMetadata = transports.filter((g) => g.transport.phase[operation] === 'after-metadata'); + + for (const group of beforeMetadata) { + const desc = group.transport.describe(); + phases.push({ + phase: 'before-metadata', + label: desc.label, + endpoint: desc.endpoint, + components: group.components.map((c) => ({ fullName: c.fullName, type: c.type.name })), + }); + } + + phases.push({ + phase: 'metadata-api', + label: 'Metadata API', + endpoint: 'SOAP/REST', + components: metadataApi.map((c) => ({ fullName: c.fullName, type: c.type.name })), + }); + + for (const group of afterMetadata) { + const desc = group.transport.describe(); + phases.push({ + phase: 'after-metadata', + label: desc.label, + endpoint: desc.endpoint, + components: group.components.map((c) => ({ fullName: c.fullName, type: c.type.name })), + }); + } + + return { phases }; + } + + public async runBeforeMetadata( + context: TransportContext, + groups: TransportGroup[], + operation: 'deploy' | 'retrieve' = 'deploy' + ): Promise { + const beforeMetadata = groups.filter((g) => g.transport.phase[operation] === 'before-metadata'); + return this.runTransportGroups(beforeMetadata, context, operation); + } + + public async runAfterMetadata( + context: TransportContext, + groups: TransportGroup[], + operation: 'deploy' | 'retrieve' = 'deploy' + ): Promise { + const afterMetadata = groups.filter((g) => g.transport.phase[operation] === 'after-metadata'); + return this.runTransportGroups(afterMetadata, context, operation); + } + + public hasTransports(components: SourceComponent[], skipTypes?: string[]): boolean { + const skip = skipTypes ? new Set(skipTypes) : undefined; + return components.some((c) => this.findTransportFor(c, skip) !== undefined); + } + + private findTransportFor(component: SourceComponent, skipTypes?: Set): TransportProvider | undefined { + if (skipTypes?.has(component.type.name)) { + return undefined; + } + return this.transports.find((t) => t.handles(component)); + } + + private async runTransportGroups( + groups: TransportGroup[], + context: TransportContext, + operation: 'deploy' | 'retrieve' + ): Promise { + const allFileResponses: FileResponse[] = []; + const allAsyncHandles: AsyncTransportHandle[] = []; + + for (const group of groups) { + this.logger.debug( + `Running transport '${group.transport.name}' (${operation}) for ${group.components.length} component(s)` + ); + + const transportContext: TransportContext = { + ...context, + components: group.components, + }; + + // eslint-disable-next-line no-await-in-loop + const result: TransportResult = await group.transport[operation](transportContext); + allFileResponses.push(...result.fileResponses); + if (result.asyncResult) { + allAsyncHandles.push(result.asyncResult); + } + } + + return { fileResponses: allFileResponses, asyncHandles: allAsyncHandles }; + } +} diff --git a/src/client/transports/index.ts b/src/client/transports/index.ts new file mode 100644 index 000000000..ff87f838f --- /dev/null +++ b/src/client/transports/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + TransportProvider, + TransportContext, + TransportResult, + AsyncTransportHandle, + TransportStatus, + TransportPhase, + TransportDescription, +} from './types'; +export { + DeployPipeline, + TransportGroup, + GroupedComponents, + DeployPlan, + DeployPlanPhase, + PipelineDeployResult, +} from './deployPipeline'; diff --git a/src/client/transports/types.ts b/src/client/transports/types.ts new file mode 100644 index 000000000..c9a8bb3aa --- /dev/null +++ b/src/client/transports/types.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Connection, SfProject } from '@salesforce/core'; +import { SourceComponent } from '../../resolve/sourceComponent'; +import { FileResponse } from '../types'; + +export type TransportPhase = 'before-metadata' | 'after-metadata'; + +export type TransportStatus = 'Pending' | 'InProgress' | 'Succeeded' | 'Failed' | 'Unknown'; + +export type TransportDescription = { + label: string; + endpoint: string; +}; + +export type TransportContext = { + components: SourceComponent[]; + connection: Connection; + project: SfProject; + orgId: string; +}; + +export type TransportResult = { + fileResponses: FileResponse[]; + asyncResult?: AsyncTransportHandle; +}; + +export type AsyncTransportHandle = { + transportName: string; + status: TransportStatus; + message?: string; + checkStatus(): Promise; +}; + +export type TransportProvider = { + readonly name: string; + readonly phase: { + deploy: TransportPhase; + retrieve: TransportPhase; + }; + describe(): TransportDescription; + handles(component: SourceComponent): boolean; + deploy(context: TransportContext): Promise; + retrieve(context: TransportContext): Promise; +}; diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index 3566dd9a2..aeaf26379 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -30,6 +30,7 @@ import { objectHasSomeRealValues } from '../utils/decomposed'; import { MetadataApiDeploy, MetadataApiDeployOptions } from '../client/metadataApiDeploy'; import { MetadataApiRetrieve } from '../client/metadataApiRetrieve'; import type { MetadataApiRetrieveOptions } from '../client/types'; +import { DeployPipeline } from '../client/transports/deployPipeline'; import { XML_DECL, XML_NS_KEY, XML_NS_URL } from '../common/constants'; import { SourceComponent } from '../resolve/sourceComponent'; import { MetadataResolver } from '../resolve/metadataResolver'; @@ -89,6 +90,7 @@ export class ComponentSet extends LazyCollection { public fullName?: string; public forceIgnoredPaths?: Set; public botVersionFilters?: Array<{ botName: string; versionFilter: 'all' | 'highest' | number }>; + public deployPipeline?: DeployPipeline; private logger: Logger; private readonly registry: RegistryAccess; // all components stored here, regardless of what manifest they belong to @@ -397,6 +399,11 @@ export class ComponentSet extends LazyCollection { ); } + const pipeline = this.deployPipeline; + if (pipeline?.hasTransports(toDeploy)) { + return this.deployWithPipeline(options, toDeploy, pipeline); + } + const operationOptions = Object.assign({}, options, { components: this, registry: this.registry, @@ -522,7 +529,7 @@ export class ComponentSet extends LazyCollection { }); const toParse = await this.getObject(destructiveType); toParse.Package[XML_NS_KEY] = XML_NS_URL; - + return XML_DECL.concat(builder.build(toParse)); } @@ -738,6 +745,45 @@ export class ComponentSet extends LazyCollection { return destructiveChangesTypes; } + private async deployWithPipeline( + options: DeploySetOptions, + components: SourceComponent[], + pipeline: DeployPipeline + ): Promise { + const { transports } = pipeline.groupByTransport(components); + + const connection = + typeof options.usernameOrConnection === 'string' + ? await Connection.create({ authInfo: await AuthInfo.create({ username: options.usernameOrConnection }) }) + : options.usernameOrConnection; + + const transportContext = { + components, + connection, + project: await SfProject.resolve(this.projectDirectory), + orgId: connection.getAuthInfoFields().orgId ?? '', + }; + + const beforeResults = await pipeline.runBeforeMetadata(transportContext, transports); + + const operationOptions = Object.assign({}, options, { + components: this, + registry: this.registry, + apiVersion: this.apiVersion, + }); + + const mdapiDeploy = new MetadataApiDeploy(operationOptions); + await mdapiDeploy.start(); + + // store transport results and pending after-metadata groups on the deploy + // so they can be merged into DeployResult after polling completes + mdapiDeploy.transportFileResponses = beforeResults.fileResponses; + mdapiDeploy.transportAsyncHandles = beforeResults.asyncHandles; + mdapiDeploy.pendingAfterMetadata = { pipeline, transports, context: transportContext }; + + return mdapiDeploy; + } + /** * Returns an API version to use as the value of the `version` field * in a manifest (package.xml) for MDAPI calls in the following order diff --git a/src/index.ts b/src/index.ts index a23a937cb..875000f7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,19 @@ export { MetadataApiRetrieve, MetadataApiRetrieveOptions, RetrieveResult, + TransportProvider, + TransportContext, + TransportResult, + AsyncTransportHandle, + TransportStatus, + TransportPhase, + TransportDescription, + DeployPipeline, + TransportGroup, + GroupedComponents, + DeployPlan, + DeployPlanPhase, + PipelineDeployResult, ComponentDeployment, ComponentRetrieval, ComponentDiagnostic, diff --git a/src/registry/types.ts b/src/registry/types.ts index dc68fa737..b95922ca7 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -173,6 +173,7 @@ export type MetadataType = { | 'uiBundle'; decomposition?: 'topLevel' | 'folderPerType'; recomposition?: 'startEmpty'; + transport?: 'connectApi' | 'presignedUrl' | 'datakitApi'; }; }; diff --git a/test/client/transports/deployPipeline.test.ts b/test/client/transports/deployPipeline.test.ts new file mode 100644 index 000000000..437f8d98e --- /dev/null +++ b/test/client/transports/deployPipeline.test.ts @@ -0,0 +1,312 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { Connection, SfProject } from '@salesforce/core'; +import { + AsyncTransportHandle, + ComponentStatus, + DeployPipeline, + FileResponse, + RegistryAccess, + SourceComponent, + TransportContext, + TransportDescription, + TransportPhase, + TransportProvider, + TransportResult, +} from '../../../src'; + +const registryAccess = new RegistryAccess(); + +function createMockTransport( + name: string, + deployPhase: TransportPhase, + typeNames: string[], + fileResponses: FileResponse[] = [] +): TransportProvider { + const retrievePhase: TransportPhase = deployPhase === 'before-metadata' ? 'after-metadata' : 'before-metadata'; + return { + name, + phase: { deploy: deployPhase, retrieve: retrievePhase }, + describe(): TransportDescription { + return { label: `Mock ${name}`, endpoint: `https://example.com/${name}` }; + }, + handles(component: SourceComponent): boolean { + return typeNames.includes(component.type.name); + }, + async deploy(): Promise { + return { fileResponses }; + }, + async retrieve(): Promise { + return { fileResponses: [] }; + }, + }; +} + +function createMockComponent(typeName: string, fullName: string): SourceComponent { + const type = registryAccess.getTypeByName(typeName); + return new SourceComponent({ name: fullName, type }); +} + +describe('DeployPipeline', () => { + describe('groupByTransport', () => { + it('should put all components in metadataApi when no transports registered', () => { + const pipeline = new DeployPipeline(); + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const customObject = createMockComponent('CustomObject', 'MyObject__c'); + + const result = pipeline.groupByTransport([apexClass, customObject]); + + expect(result.metadataApi).to.have.lengthOf(2); + expect(result.transports).to.have.lengthOf(0); + }); + + it('should group components by transport when transports are registered', () => { + const pipeline = new DeployPipeline(); + const mockTransport = createMockTransport('connectApi', 'before-metadata', ['ApexClass']); + pipeline.registerTransport(mockTransport); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const customObject = createMockComponent('CustomObject', 'MyObject__c'); + + const result = pipeline.groupByTransport([apexClass, customObject]); + + // all components go through metadata api + expect(result.metadataApi).to.have.lengthOf(2); + // ApexClass also goes through transport + expect(result.transports).to.have.lengthOf(1); + expect(result.transports[0].transport.name).to.equal('connectApi'); + expect(result.transports[0].components).to.have.lengthOf(1); + expect(result.transports[0].components[0].fullName).to.equal('MyClass'); + }); + + it('should handle multiple transports', () => { + const pipeline = new DeployPipeline(); + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const customObject = createMockComponent('CustomObject', 'MyObject__c'); + const apexTrigger = createMockComponent('ApexTrigger', 'MyTrigger'); + + const result = pipeline.groupByTransport([apexClass, customObject, apexTrigger]); + + expect(result.metadataApi).to.have.lengthOf(3); + expect(result.transports).to.have.lengthOf(2); + + const connectGroup = result.transports.find((t) => t.transport.name === 'connectApi'); + const datakitGroup = result.transports.find((t) => t.transport.name === 'datakitApi'); + + expect(connectGroup?.components).to.have.lengthOf(1); + expect(datakitGroup?.components).to.have.lengthOf(1); + }); + }); + + describe('hasTransports', () => { + it('should return false when no transports registered', () => { + const pipeline = new DeployPipeline(); + const apexClass = createMockComponent('ApexClass', 'MyClass'); + + expect(pipeline.hasTransports([apexClass])).to.be.false; + }); + + it('should return false when no components match a transport', () => { + const pipeline = new DeployPipeline(); + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['CustomObject'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + + expect(pipeline.hasTransports([apexClass])).to.be.false; + }); + + it('should return true when a component matches a transport', () => { + const pipeline = new DeployPipeline(); + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + + expect(pipeline.hasTransports([apexClass])).to.be.true; + }); + + it('should return false when matching type is in skipTypes', () => { + const pipeline = new DeployPipeline(); + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + + expect(pipeline.hasTransports([apexClass], ['ApexClass'])).to.be.false; + }); + }); + + describe('skipTypes', () => { + it('should exclude skipped types from transport groups', () => { + const pipeline = new DeployPipeline(); + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const customObject = createMockComponent('CustomObject', 'MyObject__c'); + + const result = pipeline.groupByTransport([apexClass, customObject], ['ApexClass']); + + // all components still go through metadata api + expect(result.metadataApi).to.have.lengthOf(2); + // only CustomObject transport remains + expect(result.transports).to.have.lengthOf(1); + expect(result.transports[0].transport.name).to.equal('datakitApi'); + }); + + it('should exclude skipped types from explain plan', () => { + const pipeline = new DeployPipeline(); + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const customObject = createMockComponent('CustomObject', 'MyObject__c'); + + const plan = pipeline.explain([apexClass, customObject], 'deploy', ['ApexClass']); + + // before-metadata phase for connectApi is gone, only metadata-api + after-metadata remain + expect(plan.phases).to.have.lengthOf(2); + expect(plan.phases[0].phase).to.equal('metadata-api'); + expect(plan.phases[1].phase).to.equal('after-metadata'); + }); + }); + + describe('explain', () => { + it('should produce a plan with correct phase ordering', () => { + const pipeline = new DeployPipeline(); + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const customObject = createMockComponent('CustomObject', 'MyObject__c'); + const apexTrigger = createMockComponent('ApexTrigger', 'MyTrigger'); + + const plan = pipeline.explain([apexClass, customObject, apexTrigger]); + + expect(plan.phases).to.have.lengthOf(3); + expect(plan.phases[0].phase).to.equal('before-metadata'); + expect(plan.phases[0].label).to.equal('Mock connectApi'); + expect(plan.phases[1].phase).to.equal('metadata-api'); + expect(plan.phases[1].components).to.have.lengthOf(3); + expect(plan.phases[2].phase).to.equal('after-metadata'); + expect(plan.phases[2].label).to.equal('Mock datakitApi'); + }); + + it('should produce metadata-only plan when no transports match', () => { + const pipeline = new DeployPipeline(); + const apexClass = createMockComponent('ApexClass', 'MyClass'); + + const plan = pipeline.explain([apexClass]); + + expect(plan.phases).to.have.lengthOf(1); + expect(plan.phases[0].phase).to.equal('metadata-api'); + }); + }); + + describe('runBeforeMetadata / runAfterMetadata', () => { + const mockContext: TransportContext = { + components: [], + connection: {} as Connection, + project: {} as SfProject, + orgId: '00Dxx0000000000', + }; + + it('should run only before-metadata transports in runBeforeMetadata', async () => { + const pipeline = new DeployPipeline(); + const beforeResponse: FileResponse = { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }; + const beforeTransport = createMockTransport('connectApi', 'before-metadata', ['ApexClass'], [beforeResponse]); + const afterTransport = createMockTransport('datakitApi', 'after-metadata', ['CustomObject']); + pipeline.registerTransport(beforeTransport); + pipeline.registerTransport(afterTransport); + + const groups = [ + { transport: beforeTransport, components: [createMockComponent('ApexClass', 'MyClass')] }, + { transport: afterTransport, components: [createMockComponent('CustomObject', 'MyObject__c')] }, + ]; + + const result = await pipeline.runBeforeMetadata(mockContext, groups); + + expect(result.fileResponses).to.have.lengthOf(1); + expect(result.fileResponses[0].fullName).to.equal('MyApp'); + expect(result.asyncHandles).to.have.lengthOf(0); + }); + + it('should run only after-metadata transports in runAfterMetadata', async () => { + const pipeline = new DeployPipeline(); + const afterResponse: FileResponse = { + fullName: 'CustomerDataKit', + type: 'DataPackageDefinition', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/datapkg/CustomerDataKit.json', + }; + const beforeTransport = createMockTransport('connectApi', 'before-metadata', ['ApexClass']); + const afterTransport = createMockTransport('datakitApi', 'after-metadata', ['CustomObject'], [afterResponse]); + pipeline.registerTransport(beforeTransport); + pipeline.registerTransport(afterTransport); + + const groups = [ + { transport: beforeTransport, components: [createMockComponent('ApexClass', 'MyClass')] }, + { transport: afterTransport, components: [createMockComponent('CustomObject', 'MyObject__c')] }, + ]; + + const result = await pipeline.runAfterMetadata(mockContext, groups); + + expect(result.fileResponses).to.have.lengthOf(1); + expect(result.fileResponses[0].fullName).to.equal('CustomerDataKit'); + }); + + it('should collect async handles from transports', async () => { + const pipeline = new DeployPipeline(); + const asyncTransport: TransportProvider = { + name: 'connectApi', + phase: { deploy: 'before-metadata', retrieve: 'after-metadata' }, + describe: () => ({ label: 'Connect API', endpoint: '/connect/compute' }), + handles: () => true, + deploy: async () => ({ + fileResponses: [], + asyncResult: { + transportName: 'connectApi', + status: 'Pending' as const, + message: 'Deploy extension pending', + checkStatus: async (): Promise => ({ + transportName: 'connectApi', + status: 'Succeeded' as const, + checkStatus: async (): Promise => ({} as unknown as AsyncTransportHandle), + }), + }, + }), + retrieve: async () => ({ fileResponses: [] }), + }; + pipeline.registerTransport(asyncTransport); + + const groups = [{ transport: asyncTransport, components: [createMockComponent('ApexClass', 'MyClass')] }]; + + const result = await pipeline.runBeforeMetadata(mockContext, groups); + + expect(result.asyncHandles).to.have.lengthOf(1); + expect(result.asyncHandles[0].transportName).to.equal('connectApi'); + expect(result.asyncHandles[0].status).to.equal('Pending'); + }); + }); +}); diff --git a/test/client/transports/deployResultTransport.test.ts b/test/client/transports/deployResultTransport.test.ts new file mode 100644 index 000000000..e5bf1c576 --- /dev/null +++ b/test/client/transports/deployResultTransport.test.ts @@ -0,0 +1,157 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { + AsyncTransportHandle, + ComponentStatus, + DeployResult, + FileResponse, + MetadataApiDeployStatus, + RequestStatus, +} from '../../../src'; + +function createDeployStatus(overrides: Partial = {}): MetadataApiDeployStatus { + return { + id: '0Af000000000000', + status: RequestStatus.Succeeded, + success: true, + done: true, + checkOnly: false, + createdBy: '005000000000000', + createdByName: 'Test User', + createdDate: '2026-01-01T00:00:00.000Z', + details: {}, + ignoreWarnings: false, + lastModifiedDate: '2026-01-01T00:00:00.000Z', + numberComponentErrors: 0, + numberComponentsDeployed: 0, + numberComponentsTotal: 0, + numberTestErrors: 0, + numberTestsCompleted: 0, + numberTestsTotal: 0, + runTestsEnabled: false, + rollbackOnError: true, + ...overrides, + }; +} + +describe('DeployResult transport integration', () => { + it('should return empty async handles by default', () => { + const result = new DeployResult(createDeployStatus()); + expect(result.getAsyncTransportHandles()).to.have.lengthOf(0); + }); + + it('should include transport file responses in getFileResponses()', () => { + const result = new DeployResult(createDeployStatus()); + + const transportResponses: FileResponse[] = [ + { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }, + ]; + + result.addTransportResults(transportResponses); + + const fileResponses = result.getFileResponses(); + expect(fileResponses).to.have.lengthOf(1); + expect(fileResponses[0].fullName).to.equal('MyApp'); + }); + + it('should merge transport file responses with metadata file responses', () => { + const deployStatus = createDeployStatus({ + details: { + componentSuccesses: { + changed: 'true', + created: 'false', + deleted: 'false', + fileName: 'classes/MyClass.cls', + fullName: 'MyClass', + componentType: 'ApexClass', + success: 'true', + createdDate: '2026-01-01', + }, + }, + }); + + const result = new DeployResult(deployStatus); + + const transportResponses: FileResponse[] = [ + { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }, + ]; + + result.addTransportResults(transportResponses); + + const fileResponses = result.getFileResponses(); + expect(fileResponses).to.have.lengthOf(2); + + const types = fileResponses.map((r) => r.type); + expect(types).to.include('ApexClass'); + expect(types).to.include('PlatformComputeApp'); + }); + + it('should include async transport handles', () => { + const result = new DeployResult(createDeployStatus()); + + const asyncHandle: AsyncTransportHandle = { + transportName: 'connectApi', + status: 'Pending', + message: 'Deploy extension pending', + checkStatus: async (): Promise => ({ + transportName: 'connectApi', + status: 'Succeeded' as const, + checkStatus: async (): Promise => ({} as unknown as AsyncTransportHandle), + }), + }; + + result.addTransportResults([], [asyncHandle]); + + const handles = result.getAsyncTransportHandles(); + expect(handles).to.have.lengthOf(1); + expect(handles[0].transportName).to.equal('connectApi'); + expect(handles[0].status).to.equal('Pending'); + }); + + it('should invalidate cached responses when transport results are added', () => { + const result = new DeployResult(createDeployStatus()); + + // first call caches + const initial = result.getFileResponses(); + expect(initial).to.have.lengthOf(0); + + // add transport results + result.addTransportResults([ + { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }, + ]); + + // second call should include transport results + const updated = result.getFileResponses(); + expect(updated).to.have.lengthOf(1); + }); +}); From 3cc0e852c978d0489925e227e7bd4dfd27e78b43 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 24 Sep 2026 16:31:13 -0600 Subject: [PATCH 02/21] refactor: rename DeployPipeline to TransportPipeline The pipeline orchestrates both deploy and retrieve operations, so the name should not imply deploy-only semantics. --- src/client/index.ts | 8 ++--- src/client/metadataApiDeploy.ts | 4 +-- src/client/transports/index.ts | 10 +++--- ...deployPipeline.ts => transportPipeline.ts} | 22 ++++++------- src/collections/componentSet.ts | 8 ++--- src/index.ts | 8 ++--- ...line.test.ts => transportPipeline.test.ts} | 32 +++++++++---------- 7 files changed, 46 insertions(+), 46 deletions(-) rename src/client/transports/{deployPipeline.ts => transportPipeline.ts} (93%) rename test/client/transports/{deployPipeline.test.ts => transportPipeline.test.ts} (94%) diff --git a/src/client/index.ts b/src/client/index.ts index e631915d8..512f2d0b9 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -30,12 +30,12 @@ export { TransportStatus, TransportPhase, TransportDescription, - DeployPipeline, + TransportPipeline, TransportGroup, GroupedComponents, - DeployPlan, - DeployPlanPhase, - PipelineDeployResult, + TransportPlan, + TransportPlanPhase, + TransportPipelineResult, } from './transports'; export { ComponentDeployment, diff --git a/src/client/metadataApiDeploy.ts b/src/client/metadataApiDeploy.ts index 992876aa2..8c1695e8e 100644 --- a/src/client/metadataApiDeploy.ts +++ b/src/client/metadataApiDeploy.ts @@ -50,7 +50,7 @@ import { } from './deployMessages'; import { parseDeployDiagnostic } from './diagnosticUtil'; import { AsyncTransportHandle, TransportContext } from './transports/types'; -import { DeployPipeline, TransportGroup } from './transports/deployPipeline'; +import { TransportPipeline, TransportGroup } from './transports/transportPipeline'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); @@ -132,7 +132,7 @@ export class MetadataApiDeploy extends MetadataTransfer< public transportAsyncHandles: AsyncTransportHandle[] = []; /** @internal */ public pendingAfterMetadata?: { - pipeline: DeployPipeline; + pipeline: TransportPipeline; transports: TransportGroup[]; context: TransportContext; }; diff --git a/src/client/transports/index.ts b/src/client/transports/index.ts index ff87f838f..2b612e575 100644 --- a/src/client/transports/index.ts +++ b/src/client/transports/index.ts @@ -24,10 +24,10 @@ export { TransportDescription, } from './types'; export { - DeployPipeline, + TransportPipeline, TransportGroup, GroupedComponents, - DeployPlan, - DeployPlanPhase, - PipelineDeployResult, -} from './deployPipeline'; + TransportPlan, + TransportPlanPhase, + TransportPipelineResult, +} from './transportPipeline'; diff --git a/src/client/transports/deployPipeline.ts b/src/client/transports/transportPipeline.ts similarity index 93% rename from src/client/transports/deployPipeline.ts rename to src/client/transports/transportPipeline.ts index 1f66a784d..79296cf65 100644 --- a/src/client/transports/deployPipeline.ts +++ b/src/client/transports/transportPipeline.ts @@ -29,28 +29,28 @@ export type GroupedComponents = { transports: TransportGroup[]; }; -export type DeployPlan = { - phases: DeployPlanPhase[]; +export type TransportPlan = { + phases: TransportPlanPhase[]; }; -export type DeployPlanPhase = { +export type TransportPlanPhase = { phase: TransportPhase | 'metadata-api'; label: string; endpoint: string; components: Array<{ fullName: string; type: string }>; }; -export type PipelineDeployResult = { +export type TransportPipelineResult = { fileResponses: FileResponse[]; asyncHandles: AsyncTransportHandle[]; }; -export class DeployPipeline { +export class TransportPipeline { private readonly transports: TransportProvider[] = []; private readonly logger: Logger; public constructor() { - this.logger = Logger.childFromRoot('DeployPipeline'); + this.logger = Logger.childFromRoot('TransportPipeline'); } public registerTransport(transport: TransportProvider): void { @@ -87,9 +87,9 @@ export class DeployPipeline { components: SourceComponent[], operation: 'deploy' | 'retrieve' = 'deploy', skipTypes?: string[] - ): DeployPlan { + ): TransportPlan { const { metadataApi, transports } = this.groupByTransport(components, skipTypes); - const phases: DeployPlanPhase[] = []; + const phases: TransportPlanPhase[] = []; const beforeMetadata = transports.filter((g) => g.transport.phase[operation] === 'before-metadata'); const afterMetadata = transports.filter((g) => g.transport.phase[operation] === 'after-metadata'); @@ -128,7 +128,7 @@ export class DeployPipeline { context: TransportContext, groups: TransportGroup[], operation: 'deploy' | 'retrieve' = 'deploy' - ): Promise { + ): Promise { const beforeMetadata = groups.filter((g) => g.transport.phase[operation] === 'before-metadata'); return this.runTransportGroups(beforeMetadata, context, operation); } @@ -137,7 +137,7 @@ export class DeployPipeline { context: TransportContext, groups: TransportGroup[], operation: 'deploy' | 'retrieve' = 'deploy' - ): Promise { + ): Promise { const afterMetadata = groups.filter((g) => g.transport.phase[operation] === 'after-metadata'); return this.runTransportGroups(afterMetadata, context, operation); } @@ -158,7 +158,7 @@ export class DeployPipeline { groups: TransportGroup[], context: TransportContext, operation: 'deploy' | 'retrieve' - ): Promise { + ): Promise { const allFileResponses: FileResponse[] = []; const allAsyncHandles: AsyncTransportHandle[] = []; diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index aeaf26379..ef0270af2 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -30,7 +30,7 @@ import { objectHasSomeRealValues } from '../utils/decomposed'; import { MetadataApiDeploy, MetadataApiDeployOptions } from '../client/metadataApiDeploy'; import { MetadataApiRetrieve } from '../client/metadataApiRetrieve'; import type { MetadataApiRetrieveOptions } from '../client/types'; -import { DeployPipeline } from '../client/transports/deployPipeline'; +import { TransportPipeline } from '../client/transports/transportPipeline'; import { XML_DECL, XML_NS_KEY, XML_NS_URL } from '../common/constants'; import { SourceComponent } from '../resolve/sourceComponent'; import { MetadataResolver } from '../resolve/metadataResolver'; @@ -90,7 +90,7 @@ export class ComponentSet extends LazyCollection { public fullName?: string; public forceIgnoredPaths?: Set; public botVersionFilters?: Array<{ botName: string; versionFilter: 'all' | 'highest' | number }>; - public deployPipeline?: DeployPipeline; + public transportPipeline?: TransportPipeline; private logger: Logger; private readonly registry: RegistryAccess; // all components stored here, regardless of what manifest they belong to @@ -399,7 +399,7 @@ export class ComponentSet extends LazyCollection { ); } - const pipeline = this.deployPipeline; + const pipeline = this.transportPipeline; if (pipeline?.hasTransports(toDeploy)) { return this.deployWithPipeline(options, toDeploy, pipeline); } @@ -748,7 +748,7 @@ export class ComponentSet extends LazyCollection { private async deployWithPipeline( options: DeploySetOptions, components: SourceComponent[], - pipeline: DeployPipeline + pipeline: TransportPipeline ): Promise { const { transports } = pipeline.groupByTransport(components); diff --git a/src/index.ts b/src/index.ts index 875000f7f..864e9f7b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,12 +31,12 @@ export { TransportStatus, TransportPhase, TransportDescription, - DeployPipeline, + TransportPipeline, TransportGroup, GroupedComponents, - DeployPlan, - DeployPlanPhase, - PipelineDeployResult, + TransportPlan, + TransportPlanPhase, + TransportPipelineResult, ComponentDeployment, ComponentRetrieval, ComponentDiagnostic, diff --git a/test/client/transports/deployPipeline.test.ts b/test/client/transports/transportPipeline.test.ts similarity index 94% rename from test/client/transports/deployPipeline.test.ts rename to test/client/transports/transportPipeline.test.ts index 437f8d98e..d3ca0d668 100644 --- a/test/client/transports/deployPipeline.test.ts +++ b/test/client/transports/transportPipeline.test.ts @@ -19,7 +19,7 @@ import { Connection, SfProject } from '@salesforce/core'; import { AsyncTransportHandle, ComponentStatus, - DeployPipeline, + TransportPipeline, FileResponse, RegistryAccess, SourceComponent, @@ -62,10 +62,10 @@ function createMockComponent(typeName: string, fullName: string): SourceComponen return new SourceComponent({ name: fullName, type }); } -describe('DeployPipeline', () => { +describe('TransportPipeline', () => { describe('groupByTransport', () => { it('should put all components in metadataApi when no transports registered', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); const apexClass = createMockComponent('ApexClass', 'MyClass'); const customObject = createMockComponent('CustomObject', 'MyObject__c'); @@ -76,7 +76,7 @@ describe('DeployPipeline', () => { }); it('should group components by transport when transports are registered', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); const mockTransport = createMockTransport('connectApi', 'before-metadata', ['ApexClass']); pipeline.registerTransport(mockTransport); @@ -95,7 +95,7 @@ describe('DeployPipeline', () => { }); it('should handle multiple transports', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); @@ -118,14 +118,14 @@ describe('DeployPipeline', () => { describe('hasTransports', () => { it('should return false when no transports registered', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); const apexClass = createMockComponent('ApexClass', 'MyClass'); expect(pipeline.hasTransports([apexClass])).to.be.false; }); it('should return false when no components match a transport', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['CustomObject'])); const apexClass = createMockComponent('ApexClass', 'MyClass'); @@ -134,7 +134,7 @@ describe('DeployPipeline', () => { }); it('should return true when a component matches a transport', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); const apexClass = createMockComponent('ApexClass', 'MyClass'); @@ -143,7 +143,7 @@ describe('DeployPipeline', () => { }); it('should return false when matching type is in skipTypes', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); const apexClass = createMockComponent('ApexClass', 'MyClass'); @@ -154,7 +154,7 @@ describe('DeployPipeline', () => { describe('skipTypes', () => { it('should exclude skipped types from transport groups', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); @@ -171,7 +171,7 @@ describe('DeployPipeline', () => { }); it('should exclude skipped types from explain plan', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); @@ -189,7 +189,7 @@ describe('DeployPipeline', () => { describe('explain', () => { it('should produce a plan with correct phase ordering', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); @@ -209,7 +209,7 @@ describe('DeployPipeline', () => { }); it('should produce metadata-only plan when no transports match', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); const apexClass = createMockComponent('ApexClass', 'MyClass'); const plan = pipeline.explain([apexClass]); @@ -228,7 +228,7 @@ describe('DeployPipeline', () => { }; it('should run only before-metadata transports in runBeforeMetadata', async () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); const beforeResponse: FileResponse = { fullName: 'MyApp', type: 'PlatformComputeApp', @@ -253,7 +253,7 @@ describe('DeployPipeline', () => { }); it('should run only after-metadata transports in runAfterMetadata', async () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); const afterResponse: FileResponse = { fullName: 'CustomerDataKit', type: 'DataPackageDefinition', @@ -277,7 +277,7 @@ describe('DeployPipeline', () => { }); it('should collect async handles from transports', async () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); const asyncTransport: TransportProvider = { name: 'connectApi', phase: { deploy: 'before-metadata', retrieve: 'after-metadata' }, From 2026007cac87e7d0b22123e25e0067b89efe4e73 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Fri, 25 Sep 2026 10:11:05 -0600 Subject: [PATCH 03/21] chore: bump core --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index ada36441e..228a70f05 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "node": ">=22.0.0" }, "dependencies": { - "@salesforce/core": "^9.1.11", + "@salesforce/core": "^9.2.0", "@salesforce/kit": "^4.0.0", "@salesforce/ts-types": "^3.2.0", "@salesforce/types": "^1.6.0", diff --git a/yarn.lock b/yarn.lock index 6cfa31fdf..013026c0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -845,10 +845,10 @@ ts-retry-promise "^0.8.1" zod "^4.1.12" -"@salesforce/core@^9.1.11": - version "9.1.11" - resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-9.1.11.tgz#dd2226ac1d67434575aac92eaa3ad9c1a12dcc2e" - integrity sha512-EE4oGYsoKThank4dQ1mVIzJjS9yP6C6s7JzKDx+ki8AFAxgpposzLc6GYwZZq1tS0XaOfBL2x+8RrgEgZvXwnw== +"@salesforce/core@^9.2.0": + version "9.2.0" + resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-9.2.0.tgz#3067352aebe614cd8f2d061763e8c7a5280eb182" + integrity sha512-kiyl1iblLF9oIVfZzDCbtl8n9BHCXDCq2Mrg3W9+AB5ds3tvJHEGiITxuqcfy8AecFnkc/rBpERjvEpZLwrJyQ== dependencies: "@jsforce/jsforce-node" "^3.10.24" "@salesforce/kit" "^4.0.0" From 7b1151471ffdb229cea359a6ca4a4efdbf5d556e Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Fri, 25 Sep 2026 10:51:54 -0600 Subject: [PATCH 04/21] feat: wire transport pipeline into retrieve path and add skipTransports option - Add skipTransports option to MetadataApiDeployOptions and MetadataApiRetrieveOptions - Wire retrieve pipeline with both before-metadata and after-metadata phases - Guard deploy post() after-metadata transports on RequestStatus.Succeeded - Deduplicate transport file responses in RetrieveResult (matches DeployResult) - Extract runPendingAfterMetadataTransports helper in MetadataApiDeploy - Add retrieveWithPipeline in ComponentSet mirroring deployWithPipeline - Add tests for retrieve transport phases, skipTransports, error handling, dedup --- src/client/metadataApiDeploy.ts | 32 +- src/client/metadataApiRetrieve.ts | 55 +++- src/client/types.ts | 6 + src/collections/componentSet.ts | 44 ++- .../retrieveResultTransport.test.ts | 106 +++++++ .../transports/transportIntegration.test.ts | 281 ++++++++++++++++++ .../transports/transportPipeline.test.ts | 47 +++ 7 files changed, 551 insertions(+), 20 deletions(-) create mode 100644 test/client/transports/retrieveResultTransport.test.ts create mode 100644 test/client/transports/transportIntegration.test.ts diff --git a/src/client/metadataApiDeploy.ts b/src/client/metadataApiDeploy.ts index 8c1695e8e..cb6ffb6d0 100644 --- a/src/client/metadataApiDeploy.ts +++ b/src/client/metadataApiDeploy.ts @@ -38,6 +38,7 @@ import { MetadataApiDeployOptions as ApiOptions, MetadataApiDeployStatus, MetadataTransferResult, + RequestStatus, } from './types'; import { createResponses, @@ -109,6 +110,12 @@ export type MetadataApiDeployOptions = { */ mdapiPath?: string; registry?: RegistryAccess; + /** + * Metadata type names whose transports should be skipped (e.g., `['PlatformComputeApp']`). + * Components of these types will still deploy via the Metadata API but their + * secondary transport (Connect API, presigned URL, etc.) will not run. + */ + skipTransports?: string[]; } & MetadataTransferOptions; export class MetadataApiDeploy extends MetadataTransfer< @@ -348,18 +355,7 @@ export class MetadataApiDeploy extends MetadataTransfer< }` ); } - // run after-metadata transports (e.g., DataKit trigger) if any are pending - // wrapped in try/catch so a transport failure doesn't lose the successful MDAPI result - if (this.pendingAfterMetadata) { - try { - const { pipeline, transports, context } = this.pendingAfterMetadata; - const afterResults = await pipeline.runAfterMetadata(context, transports); - this.transportFileResponses.push(...afterResults.fileResponses); - this.transportAsyncHandles.push(...afterResults.asyncHandles); - } catch (err) { - this.logger.warn(`After-metadata transport failed: ${(err as Error).message}`); - } - } + await this.runPendingAfterMetadataTransports(result.status); const deployResult = new DeployResult( result, @@ -419,6 +415,18 @@ export class MetadataApiDeploy extends MetadataTransfer< } } + private async runPendingAfterMetadataTransports(status: RequestStatus): Promise { + if (status !== RequestStatus.Succeeded || !this.pendingAfterMetadata) return; + try { + const { pipeline, transports, context } = this.pendingAfterMetadata; + const afterResults = await pipeline.runAfterMetadata(context, transports); + this.transportFileResponses.push(...afterResults.fileResponses); + this.transportAsyncHandles.push(...afterResults.asyncHandles); + } catch (err) { + this.logger.warn(`After-metadata transport failed: ${(err as Error).message}`); + } + } + private async getZipBuffer(): Promise<{ zipBuffer: Buffer; zipFileCount?: number }> { const mdapiPath = this.options.mdapiPath; diff --git a/src/client/metadataApiRetrieve.ts b/src/client/metadataApiRetrieve.ts index ecc9ad87c..e0322647d 100644 --- a/src/client/metadataApiRetrieve.ts +++ b/src/client/metadataApiRetrieve.ts @@ -38,6 +38,8 @@ import { import { extract } from './retrieveExtract'; import { getPackageOptions } from './retrieveExtract'; import { MetadataApiRetrieveOptions } from './types'; +import { AsyncTransportHandle, TransportContext } from './transports/types'; +import { TransportPipeline, TransportGroup } from './transports/transportPipeline'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); @@ -48,6 +50,8 @@ export class RetrieveResult implements MetadataTransferResult { // rather than "Changed". private localComponents: ComponentSet; private fileResponses?: FileResponse[]; + private transportFileResponses: FileResponse[] = []; + private asyncTransportHandles: AsyncTransportHandle[] = []; /** * @param response The metadata retrieve response from the server @@ -69,7 +73,7 @@ export class RetrieveResult implements MetadataTransferResult { return this.fileResponses; } - this.fileResponses = []; + const responses: FileResponse[] = []; // construct failures if (this.response.messages) { @@ -80,7 +84,7 @@ export class RetrieveResult implements MetadataTransferResult { const matches = new RegExp(/.+'(.+)'.+'(.+)'/).exec(message.problem); if (matches) { const [typeName, fullName] = matches.slice(1); - this.fileResponses.push({ + responses.push({ fullName, type: typeName, state: ComponentStatus.Failed, @@ -88,7 +92,7 @@ export class RetrieveResult implements MetadataTransferResult { problemType: 'Error', }); } else { - this.fileResponses.push({ + responses.push({ fullName: '', type: '', problemType: 'Error', @@ -110,21 +114,36 @@ export class RetrieveResult implements MetadataTransferResult { if (!type.children || Object.values(type.children.types).some((t) => t.unaddressableWithoutParent)) { for (const filePath of retrievedComponent.walkContent()) { - this.fileResponses.push({ ...baseResponse, filePath } satisfies FileResponseSuccess); + responses.push({ ...baseResponse, filePath } satisfies FileResponseSuccess); } } if (xml) { - this.fileResponses.push({ ...baseResponse, filePath: xml } satisfies FileResponseSuccess); + responses.push({ ...baseResponse, filePath: xml } satisfies FileResponseSuccess); } } // Add file responses for components that support partial delete (e.g., DigitalExperience) // where pieces of the component were deleted in the org, then retrieved. - this.fileResponses.push(...(this.partialDeleteFileResponses ?? [])); + responses.push(...(this.partialDeleteFileResponses ?? [])); + + responses.push(...this.transportFileResponses); + + // deduplicate by serializing each response + this.fileResponses = [...new Map(responses.map((v) => [JSON.stringify(v), v])).values()]; return this.fileResponses; } + + public addTransportResults(fileResponses: FileResponse[], asyncHandles: AsyncTransportHandle[] = []): void { + this.transportFileResponses.push(...fileResponses); + this.asyncTransportHandles.push(...asyncHandles); + this.fileResponses = undefined; + } + + public getAsyncTransportHandles(): AsyncTransportHandle[] { + return this.asyncTransportHandles; + } } export class MetadataApiRetrieve extends MetadataTransfer< @@ -133,6 +152,16 @@ export class MetadataApiRetrieve extends MetadataTransfer< MetadataApiRetrieveOptions > { public static DEFAULT_OPTIONS: Partial = { merge: false }; + /** @internal */ + public transportFileResponses: FileResponse[] = []; + /** @internal */ + public transportAsyncHandles: AsyncTransportHandle[] = []; + /** @internal */ + public pendingAfterMetadata?: { + pipeline: TransportPipeline; + transports: TransportGroup[]; + context: TransportContext; + }; private readonly options: MetadataApiRetrieveOptions; private orgId?: string; @@ -212,6 +241,17 @@ export class MetadataApiRetrieve extends MetadataTransfer< })); } } + if (result.status === RequestStatus.Succeeded && this.pendingAfterMetadata) { + try { + const { pipeline, transports, context } = this.pendingAfterMetadata; + const afterResults = await pipeline.runAfterMetadata(context, transports, 'retrieve'); + this.transportFileResponses.push(...afterResults.fileResponses); + this.transportAsyncHandles.push(...afterResults.asyncHandles); + } catch (err) { + this.logger.warn(`After-metadata transport failed during retrieve: ${(err as Error).message}`); + } + } + componentSet ??= new ComponentSet(undefined, this.options.registry); const retrieveResult = new RetrieveResult( @@ -221,6 +261,9 @@ export class MetadataApiRetrieve extends MetadataTransfer< partialDeleteFileResponses, this.options.registry ); + if (this.transportFileResponses.length > 0 || this.transportAsyncHandles.length > 0) { + retrieveResult.addTransportResults(this.transportFileResponses, this.transportAsyncHandles); + } if (!isMdapiRetrieve && !this.options.suppressEvents) { // This should only be done when retrieving source format since retrieving // mdapi format has no conversion or events/hooks diff --git a/src/client/types.ts b/src/client/types.ts index 936dcc1d5..1519891d3 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -412,4 +412,10 @@ export type MetadataApiRetrieveOptions = MetadataTransferOptions & RetrieveOptions & { registry?: RegistryAccess; botVersionFilters?: BotVersionFilter[]; + /** + * Metadata type names whose transports should be skipped (e.g., `['PlatformComputeApp']`). + * Components of these types will still be retrieved via the Metadata API but their + * secondary transport (Connect API, presigned URL, etc.) will not run. + */ + skipTransports?: string[]; }; diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index ef0270af2..fe6aa310e 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -400,7 +400,7 @@ export class ComponentSet extends LazyCollection { } const pipeline = this.transportPipeline; - if (pipeline?.hasTransports(toDeploy)) { + if (pipeline?.hasTransports(toDeploy, options.skipTransports)) { return this.deployWithPipeline(options, toDeploy, pipeline); } @@ -443,6 +443,12 @@ export class ComponentSet extends LazyCollection { ); } + const pipeline = this.transportPipeline; + const toRetrieve = Array.from(this.getSourceComponents()); + if (pipeline?.hasTransports(toRetrieve, options.skipTransports)) { + return this.retrieveWithPipeline(operationOptions, toRetrieve, pipeline, options.skipTransports); + } + const mdapiRetrieve = new MetadataApiRetrieve(operationOptions); await mdapiRetrieve.start(); return mdapiRetrieve; @@ -750,7 +756,7 @@ export class ComponentSet extends LazyCollection { components: SourceComponent[], pipeline: TransportPipeline ): Promise { - const { transports } = pipeline.groupByTransport(components); + const { transports } = pipeline.groupByTransport(components, options.skipTransports); const connection = typeof options.usernameOrConnection === 'string' @@ -784,6 +790,40 @@ export class ComponentSet extends LazyCollection { return mdapiDeploy; } + private async retrieveWithPipeline( + operationOptions: MetadataApiRetrieveOptions, + components: SourceComponent[], + pipeline: TransportPipeline, + skipTransports?: string[] + ): Promise { + const { transports } = pipeline.groupByTransport(components, skipTransports); + + const connection = + typeof operationOptions.usernameOrConnection === 'string' + ? await Connection.create({ + authInfo: await AuthInfo.create({ username: operationOptions.usernameOrConnection }), + }) + : operationOptions.usernameOrConnection; + + const transportContext = { + components, + connection, + project: await SfProject.resolve(this.projectDirectory), + orgId: connection.getAuthInfoFields().orgId ?? '', + }; + + const beforeResults = await pipeline.runBeforeMetadata(transportContext, transports, 'retrieve'); + + const mdapiRetrieve = new MetadataApiRetrieve(operationOptions); + + mdapiRetrieve.transportFileResponses = beforeResults.fileResponses; + mdapiRetrieve.transportAsyncHandles = beforeResults.asyncHandles; + mdapiRetrieve.pendingAfterMetadata = { pipeline, transports, context: transportContext }; + + await mdapiRetrieve.start(); + return mdapiRetrieve; + } + /** * Returns an API version to use as the value of the `version` field * in a manifest (package.xml) for MDAPI calls in the following order diff --git a/test/client/transports/retrieveResultTransport.test.ts b/test/client/transports/retrieveResultTransport.test.ts new file mode 100644 index 000000000..3ce8c2049 --- /dev/null +++ b/test/client/transports/retrieveResultTransport.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { + AsyncTransportHandle, + ComponentSet, + ComponentStatus, + FileResponse, + MetadataApiRetrieveStatus, + RequestStatus, + RetrieveResult, +} from '../../../src'; + +function createRetrieveStatus(overrides: Partial = {}): MetadataApiRetrieveStatus { + return { + id: '09S000000000000', + status: RequestStatus.Succeeded, + success: true, + done: true, + fileProperties: [], + zipFile: '', + ...overrides, + }; +} + +describe('RetrieveResult transport integration', () => { + it('should return empty async handles by default', () => { + const result = new RetrieveResult(createRetrieveStatus(), new ComponentSet()); + expect(result.getAsyncTransportHandles()).to.have.lengthOf(0); + }); + + it('should include transport file responses in getFileResponses()', () => { + const result = new RetrieveResult(createRetrieveStatus(), new ComponentSet()); + + const transportResponses: FileResponse[] = [ + { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }, + ]; + + result.addTransportResults(transportResponses); + + const fileResponses = result.getFileResponses(); + expect(fileResponses).to.have.lengthOf(1); + expect(fileResponses[0].fullName).to.equal('MyApp'); + expect(fileResponses[0].type).to.equal('PlatformComputeApp'); + }); + + it('should include async transport handles', () => { + const result = new RetrieveResult(createRetrieveStatus(), new ComponentSet()); + + const asyncHandle: AsyncTransportHandle = { + transportName: 'connectApi', + status: 'Pending', + message: 'Retrieve extension pending', + checkStatus: async (): Promise => ({ + transportName: 'connectApi', + status: 'Succeeded' as const, + checkStatus: async (): Promise => ({} as unknown as AsyncTransportHandle), + }), + }; + + result.addTransportResults([], [asyncHandle]); + + const handles = result.getAsyncTransportHandles(); + expect(handles).to.have.lengthOf(1); + expect(handles[0].transportName).to.equal('connectApi'); + expect(handles[0].status).to.equal('Pending'); + }); + + it('should invalidate cached responses when transport results are added', () => { + const result = new RetrieveResult(createRetrieveStatus(), new ComponentSet()); + + const initial = result.getFileResponses(); + expect(initial).to.have.lengthOf(0); + + result.addTransportResults([ + { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }, + ]); + + const updated = result.getFileResponses(); + expect(updated).to.have.lengthOf(1); + }); +}); diff --git a/test/client/transports/transportIntegration.test.ts b/test/client/transports/transportIntegration.test.ts new file mode 100644 index 000000000..7073ebc69 --- /dev/null +++ b/test/client/transports/transportIntegration.test.ts @@ -0,0 +1,281 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { Connection, SfProject } from '@salesforce/core'; +import { + ComponentSet, + ComponentStatus, + FileResponse, + MetadataApiRetrieveStatus, + RegistryAccess, + RequestStatus, + RetrieveResult, + SourceComponent, + TransportContext, + TransportDescription, + TransportPhase, + TransportPipeline, + TransportProvider, + TransportResult, +} from '../../../src'; + +const registryAccess = new RegistryAccess(); + +function createMockTransport(overrides: { + name: string; + deployPhase: TransportPhase; + retrievePhase: TransportPhase; + typeNames: string[]; + deployResponses?: FileResponse[]; + retrieveResponses?: FileResponse[]; + deploySpy?: sinon.SinonSpy; + retrieveSpy?: sinon.SinonSpy; + shouldThrow?: 'deploy' | 'retrieve'; +}): TransportProvider { + return { + name: overrides.name, + phase: { deploy: overrides.deployPhase, retrieve: overrides.retrievePhase }, + describe(): TransportDescription { + return { label: `Mock ${overrides.name}`, endpoint: `https://example.com/${overrides.name}` }; + }, + handles(component: SourceComponent): boolean { + return overrides.typeNames.includes(component.type.name); + }, + async deploy(ctx: TransportContext): Promise { + overrides.deploySpy?.(ctx); + if (overrides.shouldThrow === 'deploy') throw new Error('deploy transport failed'); + return { fileResponses: overrides.deployResponses ?? [] }; + }, + async retrieve(ctx: TransportContext): Promise { + overrides.retrieveSpy?.(ctx); + if (overrides.shouldThrow === 'retrieve') throw new Error('retrieve transport failed'); + return { fileResponses: overrides.retrieveResponses ?? [] }; + }, + }; +} + +function createMockComponent(typeName: string, fullName: string): SourceComponent { + const type = registryAccess.getTypeByName(typeName); + return new SourceComponent({ name: fullName, type }); +} + +function createRetrieveStatus(overrides: Partial = {}): MetadataApiRetrieveStatus { + return { + id: '09S000000000000', + status: RequestStatus.Succeeded, + success: true, + done: true, + fileProperties: [], + zipFile: '', + ...overrides, + }; +} + +const mockContext: TransportContext = { + components: [], + connection: {} as Connection, + project: {} as SfProject, + orgId: '00Dxx0000000000', +}; + +describe('Transport integration', () => { + afterEach(() => sinon.restore()); + + describe('retrieve before-metadata transports', () => { + it('should run before-metadata retrieve transports via runBeforeMetadata', async () => { + const retrieveSpy = sinon.spy(); + const retrieveResponse: FileResponse = { + fullName: 'PreRetrieveApp', + type: 'CustomObject', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/objects/PreRetrieveApp.object-meta.xml', + }; + const transport = createMockTransport({ + name: 'datakitApi', + deployPhase: 'after-metadata', + retrievePhase: 'before-metadata', + typeNames: ['CustomObject'], + retrieveResponses: [retrieveResponse], + retrieveSpy, + }); + + const pipeline = new TransportPipeline(); + pipeline.registerTransport(transport); + + const groups = [{ transport, components: [createMockComponent('CustomObject', 'MyObject__c')] }]; + + const result = await pipeline.runBeforeMetadata(mockContext, groups, 'retrieve'); + + expect(retrieveSpy.calledOnce).to.be.true; + expect(result.fileResponses).to.have.lengthOf(1); + expect(result.fileResponses[0].fullName).to.equal('PreRetrieveApp'); + }); + + it('should not run after-metadata retrieve transports in runBeforeMetadata', async () => { + const retrieveSpy = sinon.spy(); + const transport = createMockTransport({ + name: 'connectApi', + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['ApexClass'], + retrieveSpy, + }); + + const pipeline = new TransportPipeline(); + pipeline.registerTransport(transport); + + const groups = [{ transport, components: [createMockComponent('ApexClass', 'MyClass')] }]; + + const result = await pipeline.runBeforeMetadata(mockContext, groups, 'retrieve'); + + expect(retrieveSpy.called).to.be.false; + expect(result.fileResponses).to.have.lengthOf(0); + }); + }); + + describe('transport failure handling', () => { + it('should propagate transport errors from runAfterMetadata for callers to catch', async () => { + const transport = createMockTransport({ + name: 'connectApi', + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['ApexClass'], + shouldThrow: 'retrieve', + }); + + const pipeline = new TransportPipeline(); + pipeline.registerTransport(transport); + + const groups = [{ transport, components: [createMockComponent('ApexClass', 'MyClass')] }]; + + try { + await pipeline.runAfterMetadata(mockContext, groups, 'retrieve'); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.equal('retrieve transport failed'); + } + }); + + it('should propagate transport errors from runBeforeMetadata for callers to catch', async () => { + const transport = createMockTransport({ + name: 'connectApi', + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['ApexClass'], + shouldThrow: 'deploy', + }); + + const pipeline = new TransportPipeline(); + pipeline.registerTransport(transport); + + const groups = [{ transport, components: [createMockComponent('ApexClass', 'MyClass')] }]; + + try { + await pipeline.runBeforeMetadata(mockContext, groups, 'deploy'); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.equal('deploy transport failed'); + } + }); + }); + + describe('skipTransports with retrieve', () => { + it('should exclude skipped types from retrieve explain plan', () => { + const pipeline = new TransportPipeline(); + pipeline.registerTransport( + createMockTransport({ + name: 'connectApi', + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['ApexClass'], + }) + ); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + + const planWithout = pipeline.explain([apexClass], 'retrieve'); + const planWith = pipeline.explain([apexClass], 'retrieve', ['ApexClass']); + + expect(planWithout.phases).to.have.lengthOf(2); + expect(planWith.phases).to.have.lengthOf(1); + expect(planWith.phases[0].phase).to.equal('metadata-api'); + }); + + it('should exclude skipped types from groupByTransport but keep in metadataApi', () => { + const pipeline = new TransportPipeline(); + pipeline.registerTransport( + createMockTransport({ + name: 'connectApi', + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['ApexClass', 'ApexTrigger'], + }) + ); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const apexTrigger = createMockComponent('ApexTrigger', 'MyTrigger'); + + const result = pipeline.groupByTransport([apexClass, apexTrigger], ['ApexClass']); + + expect(result.metadataApi).to.have.lengthOf(2); + expect(result.transports).to.have.lengthOf(1); + expect(result.transports[0].components).to.have.lengthOf(1); + expect(result.transports[0].components[0].type.name).to.equal('ApexTrigger'); + }); + }); + + describe('RetrieveResult deduplication', () => { + it('should deduplicate identical transport responses', () => { + const result = new RetrieveResult(createRetrieveStatus(), new ComponentSet()); + + const response: FileResponse = { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }; + + result.addTransportResults([response, response]); + + const fileResponses = result.getFileResponses(); + expect(fileResponses).to.have.lengthOf(1); + }); + + it('should keep distinct transport responses', () => { + const result = new RetrieveResult(createRetrieveStatus(), new ComponentSet()); + + result.addTransportResults([ + { + fullName: 'App1', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/App1/main.py', + }, + { + fullName: 'App2', + type: 'PlatformComputeApp', + state: ComponentStatus.Created, + filePath: 'force-app/main/default/compute/App2/main.py', + }, + ]); + + const fileResponses = result.getFileResponses(); + expect(fileResponses).to.have.lengthOf(2); + }); + }); +}); diff --git a/test/client/transports/transportPipeline.test.ts b/test/client/transports/transportPipeline.test.ts index d3ca0d668..53346ad1e 100644 --- a/test/client/transports/transportPipeline.test.ts +++ b/test/client/transports/transportPipeline.test.ts @@ -276,6 +276,53 @@ describe('TransportPipeline', () => { expect(result.fileResponses[0].fullName).to.equal('CustomerDataKit'); }); + it('should use retrieve phase ordering when operation is retrieve', async () => { + const pipeline = new TransportPipeline(); + const retrieveResponse: FileResponse = { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }; + // deploy: before-metadata → retrieve: after-metadata (flipped by createMockTransport) + const transport = createMockTransport('connectApi', 'before-metadata', ['ApexClass'], retrieveResponse ? [] : []); + // override retrieve to return responses + transport.retrieve = async (): Promise => ({ fileResponses: [retrieveResponse] }); + pipeline.registerTransport(transport); + + const groups = [{ transport, components: [createMockComponent('ApexClass', 'MyClass')] }]; + + // for deploy, this transport is before-metadata → runAfterMetadata should skip it + const deployResult = await pipeline.runAfterMetadata(mockContext, groups, 'deploy'); + expect(deployResult.fileResponses).to.have.lengthOf(0); + + // for retrieve, this transport is after-metadata → runAfterMetadata should run it + const retrieveResult = await pipeline.runAfterMetadata(mockContext, groups, 'retrieve'); + expect(retrieveResult.fileResponses).to.have.lengthOf(1); + expect(retrieveResult.fileResponses[0].fullName).to.equal('MyApp'); + }); + + it('should produce correct explain plan for retrieve operation', () => { + const pipeline = new TransportPipeline(); + // deploy: before-metadata → retrieve: after-metadata + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const customObject = createMockComponent('CustomObject', 'MyObject__c'); + + const deployPlan = pipeline.explain([apexClass, customObject], 'deploy'); + const retrievePlan = pipeline.explain([apexClass, customObject], 'retrieve'); + + // deploy: connectApi is before-metadata + expect(deployPlan.phases[0].phase).to.equal('before-metadata'); + expect(deployPlan.phases[0].label).to.equal('Mock connectApi'); + + // retrieve: connectApi is after-metadata (phases are flipped) + expect(retrievePlan.phases[0].phase).to.equal('metadata-api'); + expect(retrievePlan.phases[1].phase).to.equal('after-metadata'); + expect(retrievePlan.phases[1].label).to.equal('Mock connectApi'); + }); + it('should collect async handles from transports', async () => { const pipeline = new TransportPipeline(); const asyncTransport: TransportProvider = { From b4ef5254d7995fe1e128dadbb7c4adf8861062b2 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 24 Sep 2026 13:11:13 -0600 Subject: [PATCH 05/21] feat: add TransportProvider abstraction and DeployPipeline scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a first-class transport layer so `componentSet.deploy()` can route metadata types through secondary endpoints (Connect API, presigned URLs, DataKit) alongside the standard Metadata API. - TransportProvider interface: handles(), deploy(), retrieve(), describe() - DeployPipeline: groups components by transport, runs before/after metadata phases, merges results into DeployResult - DeployResult: addTransportResults() + getAsyncTransportHandles() - componentSet.deploy() delegates to deployWithPipeline when transports are registered — no public API signature change - Registry: transport strategy hint on MetadataType.strategies --- src/client/index.ts | 15 + src/client/transports/deployPipeline.ts | 185 +++++++++++ src/client/transports/index.ts | 33 ++ src/client/transports/types.ts | 59 ++++ src/collections/componentSet.ts | 46 +++ src/index.ts | 13 + src/registry/types.ts | 1 + test/client/transports/deployPipeline.test.ts | 312 ++++++++++++++++++ .../transports/deployResultTransport.test.ts | 157 +++++++++ 9 files changed, 821 insertions(+) create mode 100644 src/client/transports/deployPipeline.ts create mode 100644 src/client/transports/index.ts create mode 100644 src/client/transports/types.ts create mode 100644 test/client/transports/deployPipeline.test.ts create mode 100644 test/client/transports/deployResultTransport.test.ts diff --git a/src/client/index.ts b/src/client/index.ts index c1644e92a..c647b1f2e 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -22,6 +22,21 @@ export { ScopedPostDeploy, } from './metadataApiDeploy'; export { MetadataApiRetrieve, RetrieveResult, ScopedPostRetrieve, ScopedPreRetrieve } from './metadataApiRetrieve'; +export { + TransportProvider, + TransportContext, + TransportResult, + AsyncTransportHandle, + TransportStatus, + TransportPhase, + TransportDescription, + DeployPipeline, + TransportGroup, + GroupedComponents, + DeployPlan, + DeployPlanPhase, + PipelineDeployResult, +} from './transports'; export { ComponentDeployment, ComponentRetrieval, diff --git a/src/client/transports/deployPipeline.ts b/src/client/transports/deployPipeline.ts new file mode 100644 index 000000000..1f66a784d --- /dev/null +++ b/src/client/transports/deployPipeline.ts @@ -0,0 +1,185 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Logger } from '@salesforce/core'; +import { SourceComponent } from '../../resolve/sourceComponent'; +import { FileResponse } from '../types'; +import { AsyncTransportHandle, TransportContext, TransportPhase, TransportProvider, TransportResult } from './types'; + +export type TransportGroup = { + transport: TransportProvider; + components: SourceComponent[]; +}; + +export type GroupedComponents = { + metadataApi: SourceComponent[]; + transports: TransportGroup[]; +}; + +export type DeployPlan = { + phases: DeployPlanPhase[]; +}; + +export type DeployPlanPhase = { + phase: TransportPhase | 'metadata-api'; + label: string; + endpoint: string; + components: Array<{ fullName: string; type: string }>; +}; + +export type PipelineDeployResult = { + fileResponses: FileResponse[]; + asyncHandles: AsyncTransportHandle[]; +}; + +export class DeployPipeline { + private readonly transports: TransportProvider[] = []; + private readonly logger: Logger; + + public constructor() { + this.logger = Logger.childFromRoot('DeployPipeline'); + } + + public registerTransport(transport: TransportProvider): void { + this.transports.push(transport); + } + + public groupByTransport(components: SourceComponent[], skipTypes?: string[]): GroupedComponents { + const skip = skipTypes ? new Set(skipTypes) : undefined; + const metadataApi: SourceComponent[] = []; + const transportMap = new Map(); + + for (const component of components) { + const transport = this.findTransportFor(component, skip); + if (transport) { + let group = transportMap.get(transport.name); + if (!group) { + group = { transport, components: [] }; + transportMap.set(transport.name, group); + } + group.components.push(component); + } + // all components go through metadata api — transport components + // also have metadata portions (.compute-meta.xml) that need MDAPI + metadataApi.push(component); + } + + return { + metadataApi, + transports: [...transportMap.values()], + }; + } + + public explain( + components: SourceComponent[], + operation: 'deploy' | 'retrieve' = 'deploy', + skipTypes?: string[] + ): DeployPlan { + const { metadataApi, transports } = this.groupByTransport(components, skipTypes); + const phases: DeployPlanPhase[] = []; + + const beforeMetadata = transports.filter((g) => g.transport.phase[operation] === 'before-metadata'); + const afterMetadata = transports.filter((g) => g.transport.phase[operation] === 'after-metadata'); + + for (const group of beforeMetadata) { + const desc = group.transport.describe(); + phases.push({ + phase: 'before-metadata', + label: desc.label, + endpoint: desc.endpoint, + components: group.components.map((c) => ({ fullName: c.fullName, type: c.type.name })), + }); + } + + phases.push({ + phase: 'metadata-api', + label: 'Metadata API', + endpoint: 'SOAP/REST', + components: metadataApi.map((c) => ({ fullName: c.fullName, type: c.type.name })), + }); + + for (const group of afterMetadata) { + const desc = group.transport.describe(); + phases.push({ + phase: 'after-metadata', + label: desc.label, + endpoint: desc.endpoint, + components: group.components.map((c) => ({ fullName: c.fullName, type: c.type.name })), + }); + } + + return { phases }; + } + + public async runBeforeMetadata( + context: TransportContext, + groups: TransportGroup[], + operation: 'deploy' | 'retrieve' = 'deploy' + ): Promise { + const beforeMetadata = groups.filter((g) => g.transport.phase[operation] === 'before-metadata'); + return this.runTransportGroups(beforeMetadata, context, operation); + } + + public async runAfterMetadata( + context: TransportContext, + groups: TransportGroup[], + operation: 'deploy' | 'retrieve' = 'deploy' + ): Promise { + const afterMetadata = groups.filter((g) => g.transport.phase[operation] === 'after-metadata'); + return this.runTransportGroups(afterMetadata, context, operation); + } + + public hasTransports(components: SourceComponent[], skipTypes?: string[]): boolean { + const skip = skipTypes ? new Set(skipTypes) : undefined; + return components.some((c) => this.findTransportFor(c, skip) !== undefined); + } + + private findTransportFor(component: SourceComponent, skipTypes?: Set): TransportProvider | undefined { + if (skipTypes?.has(component.type.name)) { + return undefined; + } + return this.transports.find((t) => t.handles(component)); + } + + private async runTransportGroups( + groups: TransportGroup[], + context: TransportContext, + operation: 'deploy' | 'retrieve' + ): Promise { + const allFileResponses: FileResponse[] = []; + const allAsyncHandles: AsyncTransportHandle[] = []; + + for (const group of groups) { + this.logger.debug( + `Running transport '${group.transport.name}' (${operation}) for ${group.components.length} component(s)` + ); + + const transportContext: TransportContext = { + ...context, + components: group.components, + }; + + // eslint-disable-next-line no-await-in-loop + const result: TransportResult = await group.transport[operation](transportContext); + allFileResponses.push(...result.fileResponses); + if (result.asyncResult) { + allAsyncHandles.push(result.asyncResult); + } + } + + return { fileResponses: allFileResponses, asyncHandles: allAsyncHandles }; + } +} diff --git a/src/client/transports/index.ts b/src/client/transports/index.ts new file mode 100644 index 000000000..ff87f838f --- /dev/null +++ b/src/client/transports/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + TransportProvider, + TransportContext, + TransportResult, + AsyncTransportHandle, + TransportStatus, + TransportPhase, + TransportDescription, +} from './types'; +export { + DeployPipeline, + TransportGroup, + GroupedComponents, + DeployPlan, + DeployPlanPhase, + PipelineDeployResult, +} from './deployPipeline'; diff --git a/src/client/transports/types.ts b/src/client/transports/types.ts new file mode 100644 index 000000000..c9a8bb3aa --- /dev/null +++ b/src/client/transports/types.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Connection, SfProject } from '@salesforce/core'; +import { SourceComponent } from '../../resolve/sourceComponent'; +import { FileResponse } from '../types'; + +export type TransportPhase = 'before-metadata' | 'after-metadata'; + +export type TransportStatus = 'Pending' | 'InProgress' | 'Succeeded' | 'Failed' | 'Unknown'; + +export type TransportDescription = { + label: string; + endpoint: string; +}; + +export type TransportContext = { + components: SourceComponent[]; + connection: Connection; + project: SfProject; + orgId: string; +}; + +export type TransportResult = { + fileResponses: FileResponse[]; + asyncResult?: AsyncTransportHandle; +}; + +export type AsyncTransportHandle = { + transportName: string; + status: TransportStatus; + message?: string; + checkStatus(): Promise; +}; + +export type TransportProvider = { + readonly name: string; + readonly phase: { + deploy: TransportPhase; + retrieve: TransportPhase; + }; + describe(): TransportDescription; + handles(component: SourceComponent): boolean; + deploy(context: TransportContext): Promise; + retrieve(context: TransportContext): Promise; +}; diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index 091c93815..aeaf26379 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -30,6 +30,7 @@ import { objectHasSomeRealValues } from '../utils/decomposed'; import { MetadataApiDeploy, MetadataApiDeployOptions } from '../client/metadataApiDeploy'; import { MetadataApiRetrieve } from '../client/metadataApiRetrieve'; import type { MetadataApiRetrieveOptions } from '../client/types'; +import { DeployPipeline } from '../client/transports/deployPipeline'; import { XML_DECL, XML_NS_KEY, XML_NS_URL } from '../common/constants'; import { SourceComponent } from '../resolve/sourceComponent'; import { MetadataResolver } from '../resolve/metadataResolver'; @@ -89,6 +90,7 @@ export class ComponentSet extends LazyCollection { public fullName?: string; public forceIgnoredPaths?: Set; public botVersionFilters?: Array<{ botName: string; versionFilter: 'all' | 'highest' | number }>; + public deployPipeline?: DeployPipeline; private logger: Logger; private readonly registry: RegistryAccess; // all components stored here, regardless of what manifest they belong to @@ -397,6 +399,11 @@ export class ComponentSet extends LazyCollection { ); } + const pipeline = this.deployPipeline; + if (pipeline?.hasTransports(toDeploy)) { + return this.deployWithPipeline(options, toDeploy, pipeline); + } + const operationOptions = Object.assign({}, options, { components: this, registry: this.registry, @@ -738,6 +745,45 @@ export class ComponentSet extends LazyCollection { return destructiveChangesTypes; } + private async deployWithPipeline( + options: DeploySetOptions, + components: SourceComponent[], + pipeline: DeployPipeline + ): Promise { + const { transports } = pipeline.groupByTransport(components); + + const connection = + typeof options.usernameOrConnection === 'string' + ? await Connection.create({ authInfo: await AuthInfo.create({ username: options.usernameOrConnection }) }) + : options.usernameOrConnection; + + const transportContext = { + components, + connection, + project: await SfProject.resolve(this.projectDirectory), + orgId: connection.getAuthInfoFields().orgId ?? '', + }; + + const beforeResults = await pipeline.runBeforeMetadata(transportContext, transports); + + const operationOptions = Object.assign({}, options, { + components: this, + registry: this.registry, + apiVersion: this.apiVersion, + }); + + const mdapiDeploy = new MetadataApiDeploy(operationOptions); + await mdapiDeploy.start(); + + // store transport results and pending after-metadata groups on the deploy + // so they can be merged into DeployResult after polling completes + mdapiDeploy.transportFileResponses = beforeResults.fileResponses; + mdapiDeploy.transportAsyncHandles = beforeResults.asyncHandles; + mdapiDeploy.pendingAfterMetadata = { pipeline, transports, context: transportContext }; + + return mdapiDeploy; + } + /** * Returns an API version to use as the value of the `version` field * in a manifest (package.xml) for MDAPI calls in the following order diff --git a/src/index.ts b/src/index.ts index 4dd0cff7c..7b9188376 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,19 @@ export { MetadataApiRetrieve, MetadataApiRetrieveOptions, RetrieveResult, + TransportProvider, + TransportContext, + TransportResult, + AsyncTransportHandle, + TransportStatus, + TransportPhase, + TransportDescription, + DeployPipeline, + TransportGroup, + GroupedComponents, + DeployPlan, + DeployPlanPhase, + PipelineDeployResult, ComponentDeployment, ComponentRetrieval, ComponentDiagnostic, diff --git a/src/registry/types.ts b/src/registry/types.ts index dc68fa737..b95922ca7 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -173,6 +173,7 @@ export type MetadataType = { | 'uiBundle'; decomposition?: 'topLevel' | 'folderPerType'; recomposition?: 'startEmpty'; + transport?: 'connectApi' | 'presignedUrl' | 'datakitApi'; }; }; diff --git a/test/client/transports/deployPipeline.test.ts b/test/client/transports/deployPipeline.test.ts new file mode 100644 index 000000000..437f8d98e --- /dev/null +++ b/test/client/transports/deployPipeline.test.ts @@ -0,0 +1,312 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { Connection, SfProject } from '@salesforce/core'; +import { + AsyncTransportHandle, + ComponentStatus, + DeployPipeline, + FileResponse, + RegistryAccess, + SourceComponent, + TransportContext, + TransportDescription, + TransportPhase, + TransportProvider, + TransportResult, +} from '../../../src'; + +const registryAccess = new RegistryAccess(); + +function createMockTransport( + name: string, + deployPhase: TransportPhase, + typeNames: string[], + fileResponses: FileResponse[] = [] +): TransportProvider { + const retrievePhase: TransportPhase = deployPhase === 'before-metadata' ? 'after-metadata' : 'before-metadata'; + return { + name, + phase: { deploy: deployPhase, retrieve: retrievePhase }, + describe(): TransportDescription { + return { label: `Mock ${name}`, endpoint: `https://example.com/${name}` }; + }, + handles(component: SourceComponent): boolean { + return typeNames.includes(component.type.name); + }, + async deploy(): Promise { + return { fileResponses }; + }, + async retrieve(): Promise { + return { fileResponses: [] }; + }, + }; +} + +function createMockComponent(typeName: string, fullName: string): SourceComponent { + const type = registryAccess.getTypeByName(typeName); + return new SourceComponent({ name: fullName, type }); +} + +describe('DeployPipeline', () => { + describe('groupByTransport', () => { + it('should put all components in metadataApi when no transports registered', () => { + const pipeline = new DeployPipeline(); + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const customObject = createMockComponent('CustomObject', 'MyObject__c'); + + const result = pipeline.groupByTransport([apexClass, customObject]); + + expect(result.metadataApi).to.have.lengthOf(2); + expect(result.transports).to.have.lengthOf(0); + }); + + it('should group components by transport when transports are registered', () => { + const pipeline = new DeployPipeline(); + const mockTransport = createMockTransport('connectApi', 'before-metadata', ['ApexClass']); + pipeline.registerTransport(mockTransport); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const customObject = createMockComponent('CustomObject', 'MyObject__c'); + + const result = pipeline.groupByTransport([apexClass, customObject]); + + // all components go through metadata api + expect(result.metadataApi).to.have.lengthOf(2); + // ApexClass also goes through transport + expect(result.transports).to.have.lengthOf(1); + expect(result.transports[0].transport.name).to.equal('connectApi'); + expect(result.transports[0].components).to.have.lengthOf(1); + expect(result.transports[0].components[0].fullName).to.equal('MyClass'); + }); + + it('should handle multiple transports', () => { + const pipeline = new DeployPipeline(); + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const customObject = createMockComponent('CustomObject', 'MyObject__c'); + const apexTrigger = createMockComponent('ApexTrigger', 'MyTrigger'); + + const result = pipeline.groupByTransport([apexClass, customObject, apexTrigger]); + + expect(result.metadataApi).to.have.lengthOf(3); + expect(result.transports).to.have.lengthOf(2); + + const connectGroup = result.transports.find((t) => t.transport.name === 'connectApi'); + const datakitGroup = result.transports.find((t) => t.transport.name === 'datakitApi'); + + expect(connectGroup?.components).to.have.lengthOf(1); + expect(datakitGroup?.components).to.have.lengthOf(1); + }); + }); + + describe('hasTransports', () => { + it('should return false when no transports registered', () => { + const pipeline = new DeployPipeline(); + const apexClass = createMockComponent('ApexClass', 'MyClass'); + + expect(pipeline.hasTransports([apexClass])).to.be.false; + }); + + it('should return false when no components match a transport', () => { + const pipeline = new DeployPipeline(); + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['CustomObject'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + + expect(pipeline.hasTransports([apexClass])).to.be.false; + }); + + it('should return true when a component matches a transport', () => { + const pipeline = new DeployPipeline(); + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + + expect(pipeline.hasTransports([apexClass])).to.be.true; + }); + + it('should return false when matching type is in skipTypes', () => { + const pipeline = new DeployPipeline(); + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + + expect(pipeline.hasTransports([apexClass], ['ApexClass'])).to.be.false; + }); + }); + + describe('skipTypes', () => { + it('should exclude skipped types from transport groups', () => { + const pipeline = new DeployPipeline(); + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const customObject = createMockComponent('CustomObject', 'MyObject__c'); + + const result = pipeline.groupByTransport([apexClass, customObject], ['ApexClass']); + + // all components still go through metadata api + expect(result.metadataApi).to.have.lengthOf(2); + // only CustomObject transport remains + expect(result.transports).to.have.lengthOf(1); + expect(result.transports[0].transport.name).to.equal('datakitApi'); + }); + + it('should exclude skipped types from explain plan', () => { + const pipeline = new DeployPipeline(); + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const customObject = createMockComponent('CustomObject', 'MyObject__c'); + + const plan = pipeline.explain([apexClass, customObject], 'deploy', ['ApexClass']); + + // before-metadata phase for connectApi is gone, only metadata-api + after-metadata remain + expect(plan.phases).to.have.lengthOf(2); + expect(plan.phases[0].phase).to.equal('metadata-api'); + expect(plan.phases[1].phase).to.equal('after-metadata'); + }); + }); + + describe('explain', () => { + it('should produce a plan with correct phase ordering', () => { + const pipeline = new DeployPipeline(); + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const customObject = createMockComponent('CustomObject', 'MyObject__c'); + const apexTrigger = createMockComponent('ApexTrigger', 'MyTrigger'); + + const plan = pipeline.explain([apexClass, customObject, apexTrigger]); + + expect(plan.phases).to.have.lengthOf(3); + expect(plan.phases[0].phase).to.equal('before-metadata'); + expect(plan.phases[0].label).to.equal('Mock connectApi'); + expect(plan.phases[1].phase).to.equal('metadata-api'); + expect(plan.phases[1].components).to.have.lengthOf(3); + expect(plan.phases[2].phase).to.equal('after-metadata'); + expect(plan.phases[2].label).to.equal('Mock datakitApi'); + }); + + it('should produce metadata-only plan when no transports match', () => { + const pipeline = new DeployPipeline(); + const apexClass = createMockComponent('ApexClass', 'MyClass'); + + const plan = pipeline.explain([apexClass]); + + expect(plan.phases).to.have.lengthOf(1); + expect(plan.phases[0].phase).to.equal('metadata-api'); + }); + }); + + describe('runBeforeMetadata / runAfterMetadata', () => { + const mockContext: TransportContext = { + components: [], + connection: {} as Connection, + project: {} as SfProject, + orgId: '00Dxx0000000000', + }; + + it('should run only before-metadata transports in runBeforeMetadata', async () => { + const pipeline = new DeployPipeline(); + const beforeResponse: FileResponse = { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }; + const beforeTransport = createMockTransport('connectApi', 'before-metadata', ['ApexClass'], [beforeResponse]); + const afterTransport = createMockTransport('datakitApi', 'after-metadata', ['CustomObject']); + pipeline.registerTransport(beforeTransport); + pipeline.registerTransport(afterTransport); + + const groups = [ + { transport: beforeTransport, components: [createMockComponent('ApexClass', 'MyClass')] }, + { transport: afterTransport, components: [createMockComponent('CustomObject', 'MyObject__c')] }, + ]; + + const result = await pipeline.runBeforeMetadata(mockContext, groups); + + expect(result.fileResponses).to.have.lengthOf(1); + expect(result.fileResponses[0].fullName).to.equal('MyApp'); + expect(result.asyncHandles).to.have.lengthOf(0); + }); + + it('should run only after-metadata transports in runAfterMetadata', async () => { + const pipeline = new DeployPipeline(); + const afterResponse: FileResponse = { + fullName: 'CustomerDataKit', + type: 'DataPackageDefinition', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/datapkg/CustomerDataKit.json', + }; + const beforeTransport = createMockTransport('connectApi', 'before-metadata', ['ApexClass']); + const afterTransport = createMockTransport('datakitApi', 'after-metadata', ['CustomObject'], [afterResponse]); + pipeline.registerTransport(beforeTransport); + pipeline.registerTransport(afterTransport); + + const groups = [ + { transport: beforeTransport, components: [createMockComponent('ApexClass', 'MyClass')] }, + { transport: afterTransport, components: [createMockComponent('CustomObject', 'MyObject__c')] }, + ]; + + const result = await pipeline.runAfterMetadata(mockContext, groups); + + expect(result.fileResponses).to.have.lengthOf(1); + expect(result.fileResponses[0].fullName).to.equal('CustomerDataKit'); + }); + + it('should collect async handles from transports', async () => { + const pipeline = new DeployPipeline(); + const asyncTransport: TransportProvider = { + name: 'connectApi', + phase: { deploy: 'before-metadata', retrieve: 'after-metadata' }, + describe: () => ({ label: 'Connect API', endpoint: '/connect/compute' }), + handles: () => true, + deploy: async () => ({ + fileResponses: [], + asyncResult: { + transportName: 'connectApi', + status: 'Pending' as const, + message: 'Deploy extension pending', + checkStatus: async (): Promise => ({ + transportName: 'connectApi', + status: 'Succeeded' as const, + checkStatus: async (): Promise => ({} as unknown as AsyncTransportHandle), + }), + }, + }), + retrieve: async () => ({ fileResponses: [] }), + }; + pipeline.registerTransport(asyncTransport); + + const groups = [{ transport: asyncTransport, components: [createMockComponent('ApexClass', 'MyClass')] }]; + + const result = await pipeline.runBeforeMetadata(mockContext, groups); + + expect(result.asyncHandles).to.have.lengthOf(1); + expect(result.asyncHandles[0].transportName).to.equal('connectApi'); + expect(result.asyncHandles[0].status).to.equal('Pending'); + }); + }); +}); diff --git a/test/client/transports/deployResultTransport.test.ts b/test/client/transports/deployResultTransport.test.ts new file mode 100644 index 000000000..e5bf1c576 --- /dev/null +++ b/test/client/transports/deployResultTransport.test.ts @@ -0,0 +1,157 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { + AsyncTransportHandle, + ComponentStatus, + DeployResult, + FileResponse, + MetadataApiDeployStatus, + RequestStatus, +} from '../../../src'; + +function createDeployStatus(overrides: Partial = {}): MetadataApiDeployStatus { + return { + id: '0Af000000000000', + status: RequestStatus.Succeeded, + success: true, + done: true, + checkOnly: false, + createdBy: '005000000000000', + createdByName: 'Test User', + createdDate: '2026-01-01T00:00:00.000Z', + details: {}, + ignoreWarnings: false, + lastModifiedDate: '2026-01-01T00:00:00.000Z', + numberComponentErrors: 0, + numberComponentsDeployed: 0, + numberComponentsTotal: 0, + numberTestErrors: 0, + numberTestsCompleted: 0, + numberTestsTotal: 0, + runTestsEnabled: false, + rollbackOnError: true, + ...overrides, + }; +} + +describe('DeployResult transport integration', () => { + it('should return empty async handles by default', () => { + const result = new DeployResult(createDeployStatus()); + expect(result.getAsyncTransportHandles()).to.have.lengthOf(0); + }); + + it('should include transport file responses in getFileResponses()', () => { + const result = new DeployResult(createDeployStatus()); + + const transportResponses: FileResponse[] = [ + { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }, + ]; + + result.addTransportResults(transportResponses); + + const fileResponses = result.getFileResponses(); + expect(fileResponses).to.have.lengthOf(1); + expect(fileResponses[0].fullName).to.equal('MyApp'); + }); + + it('should merge transport file responses with metadata file responses', () => { + const deployStatus = createDeployStatus({ + details: { + componentSuccesses: { + changed: 'true', + created: 'false', + deleted: 'false', + fileName: 'classes/MyClass.cls', + fullName: 'MyClass', + componentType: 'ApexClass', + success: 'true', + createdDate: '2026-01-01', + }, + }, + }); + + const result = new DeployResult(deployStatus); + + const transportResponses: FileResponse[] = [ + { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }, + ]; + + result.addTransportResults(transportResponses); + + const fileResponses = result.getFileResponses(); + expect(fileResponses).to.have.lengthOf(2); + + const types = fileResponses.map((r) => r.type); + expect(types).to.include('ApexClass'); + expect(types).to.include('PlatformComputeApp'); + }); + + it('should include async transport handles', () => { + const result = new DeployResult(createDeployStatus()); + + const asyncHandle: AsyncTransportHandle = { + transportName: 'connectApi', + status: 'Pending', + message: 'Deploy extension pending', + checkStatus: async (): Promise => ({ + transportName: 'connectApi', + status: 'Succeeded' as const, + checkStatus: async (): Promise => ({} as unknown as AsyncTransportHandle), + }), + }; + + result.addTransportResults([], [asyncHandle]); + + const handles = result.getAsyncTransportHandles(); + expect(handles).to.have.lengthOf(1); + expect(handles[0].transportName).to.equal('connectApi'); + expect(handles[0].status).to.equal('Pending'); + }); + + it('should invalidate cached responses when transport results are added', () => { + const result = new DeployResult(createDeployStatus()); + + // first call caches + const initial = result.getFileResponses(); + expect(initial).to.have.lengthOf(0); + + // add transport results + result.addTransportResults([ + { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }, + ]); + + // second call should include transport results + const updated = result.getFileResponses(); + expect(updated).to.have.lengthOf(1); + }); +}); From bfaf07b31c26ff101e63d8e18f5a281680f39dbb Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 24 Sep 2026 16:31:13 -0600 Subject: [PATCH 06/21] refactor: rename DeployPipeline to TransportPipeline The pipeline orchestrates both deploy and retrieve operations, so the name should not imply deploy-only semantics. --- src/client/index.ts | 8 ++--- src/client/transports/index.ts | 10 +++--- ...deployPipeline.ts => transportPipeline.ts} | 22 ++++++------- src/collections/componentSet.ts | 8 ++--- src/index.ts | 8 ++--- ...line.test.ts => transportPipeline.test.ts} | 32 +++++++++---------- 6 files changed, 44 insertions(+), 44 deletions(-) rename src/client/transports/{deployPipeline.ts => transportPipeline.ts} (93%) rename test/client/transports/{deployPipeline.test.ts => transportPipeline.test.ts} (94%) diff --git a/src/client/index.ts b/src/client/index.ts index c647b1f2e..97ebbf64f 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -30,12 +30,12 @@ export { TransportStatus, TransportPhase, TransportDescription, - DeployPipeline, + TransportPipeline, TransportGroup, GroupedComponents, - DeployPlan, - DeployPlanPhase, - PipelineDeployResult, + TransportPlan, + TransportPlanPhase, + TransportPipelineResult, } from './transports'; export { ComponentDeployment, diff --git a/src/client/transports/index.ts b/src/client/transports/index.ts index ff87f838f..2b612e575 100644 --- a/src/client/transports/index.ts +++ b/src/client/transports/index.ts @@ -24,10 +24,10 @@ export { TransportDescription, } from './types'; export { - DeployPipeline, + TransportPipeline, TransportGroup, GroupedComponents, - DeployPlan, - DeployPlanPhase, - PipelineDeployResult, -} from './deployPipeline'; + TransportPlan, + TransportPlanPhase, + TransportPipelineResult, +} from './transportPipeline'; diff --git a/src/client/transports/deployPipeline.ts b/src/client/transports/transportPipeline.ts similarity index 93% rename from src/client/transports/deployPipeline.ts rename to src/client/transports/transportPipeline.ts index 1f66a784d..79296cf65 100644 --- a/src/client/transports/deployPipeline.ts +++ b/src/client/transports/transportPipeline.ts @@ -29,28 +29,28 @@ export type GroupedComponents = { transports: TransportGroup[]; }; -export type DeployPlan = { - phases: DeployPlanPhase[]; +export type TransportPlan = { + phases: TransportPlanPhase[]; }; -export type DeployPlanPhase = { +export type TransportPlanPhase = { phase: TransportPhase | 'metadata-api'; label: string; endpoint: string; components: Array<{ fullName: string; type: string }>; }; -export type PipelineDeployResult = { +export type TransportPipelineResult = { fileResponses: FileResponse[]; asyncHandles: AsyncTransportHandle[]; }; -export class DeployPipeline { +export class TransportPipeline { private readonly transports: TransportProvider[] = []; private readonly logger: Logger; public constructor() { - this.logger = Logger.childFromRoot('DeployPipeline'); + this.logger = Logger.childFromRoot('TransportPipeline'); } public registerTransport(transport: TransportProvider): void { @@ -87,9 +87,9 @@ export class DeployPipeline { components: SourceComponent[], operation: 'deploy' | 'retrieve' = 'deploy', skipTypes?: string[] - ): DeployPlan { + ): TransportPlan { const { metadataApi, transports } = this.groupByTransport(components, skipTypes); - const phases: DeployPlanPhase[] = []; + const phases: TransportPlanPhase[] = []; const beforeMetadata = transports.filter((g) => g.transport.phase[operation] === 'before-metadata'); const afterMetadata = transports.filter((g) => g.transport.phase[operation] === 'after-metadata'); @@ -128,7 +128,7 @@ export class DeployPipeline { context: TransportContext, groups: TransportGroup[], operation: 'deploy' | 'retrieve' = 'deploy' - ): Promise { + ): Promise { const beforeMetadata = groups.filter((g) => g.transport.phase[operation] === 'before-metadata'); return this.runTransportGroups(beforeMetadata, context, operation); } @@ -137,7 +137,7 @@ export class DeployPipeline { context: TransportContext, groups: TransportGroup[], operation: 'deploy' | 'retrieve' = 'deploy' - ): Promise { + ): Promise { const afterMetadata = groups.filter((g) => g.transport.phase[operation] === 'after-metadata'); return this.runTransportGroups(afterMetadata, context, operation); } @@ -158,7 +158,7 @@ export class DeployPipeline { groups: TransportGroup[], context: TransportContext, operation: 'deploy' | 'retrieve' - ): Promise { + ): Promise { const allFileResponses: FileResponse[] = []; const allAsyncHandles: AsyncTransportHandle[] = []; diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index aeaf26379..ef0270af2 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -30,7 +30,7 @@ import { objectHasSomeRealValues } from '../utils/decomposed'; import { MetadataApiDeploy, MetadataApiDeployOptions } from '../client/metadataApiDeploy'; import { MetadataApiRetrieve } from '../client/metadataApiRetrieve'; import type { MetadataApiRetrieveOptions } from '../client/types'; -import { DeployPipeline } from '../client/transports/deployPipeline'; +import { TransportPipeline } from '../client/transports/transportPipeline'; import { XML_DECL, XML_NS_KEY, XML_NS_URL } from '../common/constants'; import { SourceComponent } from '../resolve/sourceComponent'; import { MetadataResolver } from '../resolve/metadataResolver'; @@ -90,7 +90,7 @@ export class ComponentSet extends LazyCollection { public fullName?: string; public forceIgnoredPaths?: Set; public botVersionFilters?: Array<{ botName: string; versionFilter: 'all' | 'highest' | number }>; - public deployPipeline?: DeployPipeline; + public transportPipeline?: TransportPipeline; private logger: Logger; private readonly registry: RegistryAccess; // all components stored here, regardless of what manifest they belong to @@ -399,7 +399,7 @@ export class ComponentSet extends LazyCollection { ); } - const pipeline = this.deployPipeline; + const pipeline = this.transportPipeline; if (pipeline?.hasTransports(toDeploy)) { return this.deployWithPipeline(options, toDeploy, pipeline); } @@ -748,7 +748,7 @@ export class ComponentSet extends LazyCollection { private async deployWithPipeline( options: DeploySetOptions, components: SourceComponent[], - pipeline: DeployPipeline + pipeline: TransportPipeline ): Promise { const { transports } = pipeline.groupByTransport(components); diff --git a/src/index.ts b/src/index.ts index 7b9188376..be766aced 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,12 +31,12 @@ export { TransportStatus, TransportPhase, TransportDescription, - DeployPipeline, + TransportPipeline, TransportGroup, GroupedComponents, - DeployPlan, - DeployPlanPhase, - PipelineDeployResult, + TransportPlan, + TransportPlanPhase, + TransportPipelineResult, ComponentDeployment, ComponentRetrieval, ComponentDiagnostic, diff --git a/test/client/transports/deployPipeline.test.ts b/test/client/transports/transportPipeline.test.ts similarity index 94% rename from test/client/transports/deployPipeline.test.ts rename to test/client/transports/transportPipeline.test.ts index 437f8d98e..d3ca0d668 100644 --- a/test/client/transports/deployPipeline.test.ts +++ b/test/client/transports/transportPipeline.test.ts @@ -19,7 +19,7 @@ import { Connection, SfProject } from '@salesforce/core'; import { AsyncTransportHandle, ComponentStatus, - DeployPipeline, + TransportPipeline, FileResponse, RegistryAccess, SourceComponent, @@ -62,10 +62,10 @@ function createMockComponent(typeName: string, fullName: string): SourceComponen return new SourceComponent({ name: fullName, type }); } -describe('DeployPipeline', () => { +describe('TransportPipeline', () => { describe('groupByTransport', () => { it('should put all components in metadataApi when no transports registered', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); const apexClass = createMockComponent('ApexClass', 'MyClass'); const customObject = createMockComponent('CustomObject', 'MyObject__c'); @@ -76,7 +76,7 @@ describe('DeployPipeline', () => { }); it('should group components by transport when transports are registered', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); const mockTransport = createMockTransport('connectApi', 'before-metadata', ['ApexClass']); pipeline.registerTransport(mockTransport); @@ -95,7 +95,7 @@ describe('DeployPipeline', () => { }); it('should handle multiple transports', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); @@ -118,14 +118,14 @@ describe('DeployPipeline', () => { describe('hasTransports', () => { it('should return false when no transports registered', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); const apexClass = createMockComponent('ApexClass', 'MyClass'); expect(pipeline.hasTransports([apexClass])).to.be.false; }); it('should return false when no components match a transport', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['CustomObject'])); const apexClass = createMockComponent('ApexClass', 'MyClass'); @@ -134,7 +134,7 @@ describe('DeployPipeline', () => { }); it('should return true when a component matches a transport', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); const apexClass = createMockComponent('ApexClass', 'MyClass'); @@ -143,7 +143,7 @@ describe('DeployPipeline', () => { }); it('should return false when matching type is in skipTypes', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); const apexClass = createMockComponent('ApexClass', 'MyClass'); @@ -154,7 +154,7 @@ describe('DeployPipeline', () => { describe('skipTypes', () => { it('should exclude skipped types from transport groups', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); @@ -171,7 +171,7 @@ describe('DeployPipeline', () => { }); it('should exclude skipped types from explain plan', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); @@ -189,7 +189,7 @@ describe('DeployPipeline', () => { describe('explain', () => { it('should produce a plan with correct phase ordering', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); @@ -209,7 +209,7 @@ describe('DeployPipeline', () => { }); it('should produce metadata-only plan when no transports match', () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); const apexClass = createMockComponent('ApexClass', 'MyClass'); const plan = pipeline.explain([apexClass]); @@ -228,7 +228,7 @@ describe('DeployPipeline', () => { }; it('should run only before-metadata transports in runBeforeMetadata', async () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); const beforeResponse: FileResponse = { fullName: 'MyApp', type: 'PlatformComputeApp', @@ -253,7 +253,7 @@ describe('DeployPipeline', () => { }); it('should run only after-metadata transports in runAfterMetadata', async () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); const afterResponse: FileResponse = { fullName: 'CustomerDataKit', type: 'DataPackageDefinition', @@ -277,7 +277,7 @@ describe('DeployPipeline', () => { }); it('should collect async handles from transports', async () => { - const pipeline = new DeployPipeline(); + const pipeline = new TransportPipeline(); const asyncTransport: TransportProvider = { name: 'connectApi', phase: { deploy: 'before-metadata', retrieve: 'after-metadata' }, From f73d931a4dd1cd7c91cff2e91d0cb8bc218f044d Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Fri, 25 Sep 2026 10:51:54 -0600 Subject: [PATCH 07/21] feat: wire transport pipeline into retrieve path and add skipTransports option - Add skipTransports option to MetadataApiDeployOptions and MetadataApiRetrieveOptions - Wire retrieve pipeline with both before-metadata and after-metadata phases - Guard deploy post() after-metadata transports on RequestStatus.Succeeded - Deduplicate transport file responses in RetrieveResult (matches DeployResult) - Extract runPendingAfterMetadataTransports helper in MetadataApiDeploy - Add retrieveWithPipeline in ComponentSet mirroring deployWithPipeline - Add tests for retrieve transport phases, skipTransports, error handling, dedup --- src/client/metadataApiRetrieve.ts | 55 +++- src/client/types.ts | 6 + src/collections/componentSet.ts | 44 ++- .../retrieveResultTransport.test.ts | 106 +++++++ .../transports/transportIntegration.test.ts | 281 ++++++++++++++++++ .../transports/transportPipeline.test.ts | 47 +++ 6 files changed, 531 insertions(+), 8 deletions(-) create mode 100644 test/client/transports/retrieveResultTransport.test.ts create mode 100644 test/client/transports/transportIntegration.test.ts diff --git a/src/client/metadataApiRetrieve.ts b/src/client/metadataApiRetrieve.ts index ecc9ad87c..e0322647d 100644 --- a/src/client/metadataApiRetrieve.ts +++ b/src/client/metadataApiRetrieve.ts @@ -38,6 +38,8 @@ import { import { extract } from './retrieveExtract'; import { getPackageOptions } from './retrieveExtract'; import { MetadataApiRetrieveOptions } from './types'; +import { AsyncTransportHandle, TransportContext } from './transports/types'; +import { TransportPipeline, TransportGroup } from './transports/transportPipeline'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); @@ -48,6 +50,8 @@ export class RetrieveResult implements MetadataTransferResult { // rather than "Changed". private localComponents: ComponentSet; private fileResponses?: FileResponse[]; + private transportFileResponses: FileResponse[] = []; + private asyncTransportHandles: AsyncTransportHandle[] = []; /** * @param response The metadata retrieve response from the server @@ -69,7 +73,7 @@ export class RetrieveResult implements MetadataTransferResult { return this.fileResponses; } - this.fileResponses = []; + const responses: FileResponse[] = []; // construct failures if (this.response.messages) { @@ -80,7 +84,7 @@ export class RetrieveResult implements MetadataTransferResult { const matches = new RegExp(/.+'(.+)'.+'(.+)'/).exec(message.problem); if (matches) { const [typeName, fullName] = matches.slice(1); - this.fileResponses.push({ + responses.push({ fullName, type: typeName, state: ComponentStatus.Failed, @@ -88,7 +92,7 @@ export class RetrieveResult implements MetadataTransferResult { problemType: 'Error', }); } else { - this.fileResponses.push({ + responses.push({ fullName: '', type: '', problemType: 'Error', @@ -110,21 +114,36 @@ export class RetrieveResult implements MetadataTransferResult { if (!type.children || Object.values(type.children.types).some((t) => t.unaddressableWithoutParent)) { for (const filePath of retrievedComponent.walkContent()) { - this.fileResponses.push({ ...baseResponse, filePath } satisfies FileResponseSuccess); + responses.push({ ...baseResponse, filePath } satisfies FileResponseSuccess); } } if (xml) { - this.fileResponses.push({ ...baseResponse, filePath: xml } satisfies FileResponseSuccess); + responses.push({ ...baseResponse, filePath: xml } satisfies FileResponseSuccess); } } // Add file responses for components that support partial delete (e.g., DigitalExperience) // where pieces of the component were deleted in the org, then retrieved. - this.fileResponses.push(...(this.partialDeleteFileResponses ?? [])); + responses.push(...(this.partialDeleteFileResponses ?? [])); + + responses.push(...this.transportFileResponses); + + // deduplicate by serializing each response + this.fileResponses = [...new Map(responses.map((v) => [JSON.stringify(v), v])).values()]; return this.fileResponses; } + + public addTransportResults(fileResponses: FileResponse[], asyncHandles: AsyncTransportHandle[] = []): void { + this.transportFileResponses.push(...fileResponses); + this.asyncTransportHandles.push(...asyncHandles); + this.fileResponses = undefined; + } + + public getAsyncTransportHandles(): AsyncTransportHandle[] { + return this.asyncTransportHandles; + } } export class MetadataApiRetrieve extends MetadataTransfer< @@ -133,6 +152,16 @@ export class MetadataApiRetrieve extends MetadataTransfer< MetadataApiRetrieveOptions > { public static DEFAULT_OPTIONS: Partial = { merge: false }; + /** @internal */ + public transportFileResponses: FileResponse[] = []; + /** @internal */ + public transportAsyncHandles: AsyncTransportHandle[] = []; + /** @internal */ + public pendingAfterMetadata?: { + pipeline: TransportPipeline; + transports: TransportGroup[]; + context: TransportContext; + }; private readonly options: MetadataApiRetrieveOptions; private orgId?: string; @@ -212,6 +241,17 @@ export class MetadataApiRetrieve extends MetadataTransfer< })); } } + if (result.status === RequestStatus.Succeeded && this.pendingAfterMetadata) { + try { + const { pipeline, transports, context } = this.pendingAfterMetadata; + const afterResults = await pipeline.runAfterMetadata(context, transports, 'retrieve'); + this.transportFileResponses.push(...afterResults.fileResponses); + this.transportAsyncHandles.push(...afterResults.asyncHandles); + } catch (err) { + this.logger.warn(`After-metadata transport failed during retrieve: ${(err as Error).message}`); + } + } + componentSet ??= new ComponentSet(undefined, this.options.registry); const retrieveResult = new RetrieveResult( @@ -221,6 +261,9 @@ export class MetadataApiRetrieve extends MetadataTransfer< partialDeleteFileResponses, this.options.registry ); + if (this.transportFileResponses.length > 0 || this.transportAsyncHandles.length > 0) { + retrieveResult.addTransportResults(this.transportFileResponses, this.transportAsyncHandles); + } if (!isMdapiRetrieve && !this.options.suppressEvents) { // This should only be done when retrieving source format since retrieving // mdapi format has no conversion or events/hooks diff --git a/src/client/types.ts b/src/client/types.ts index 300922211..a12198550 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -417,4 +417,10 @@ export type MetadataApiRetrieveOptions = MetadataTransferOptions & RetrieveOptions & { registry?: RegistryAccess; botVersionFilters?: BotVersionFilter[]; + /** + * Metadata type names whose transports should be skipped (e.g., `['PlatformComputeApp']`). + * Components of these types will still be retrieved via the Metadata API but their + * secondary transport (Connect API, presigned URL, etc.) will not run. + */ + skipTransports?: string[]; }; diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index ef0270af2..fe6aa310e 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -400,7 +400,7 @@ export class ComponentSet extends LazyCollection { } const pipeline = this.transportPipeline; - if (pipeline?.hasTransports(toDeploy)) { + if (pipeline?.hasTransports(toDeploy, options.skipTransports)) { return this.deployWithPipeline(options, toDeploy, pipeline); } @@ -443,6 +443,12 @@ export class ComponentSet extends LazyCollection { ); } + const pipeline = this.transportPipeline; + const toRetrieve = Array.from(this.getSourceComponents()); + if (pipeline?.hasTransports(toRetrieve, options.skipTransports)) { + return this.retrieveWithPipeline(operationOptions, toRetrieve, pipeline, options.skipTransports); + } + const mdapiRetrieve = new MetadataApiRetrieve(operationOptions); await mdapiRetrieve.start(); return mdapiRetrieve; @@ -750,7 +756,7 @@ export class ComponentSet extends LazyCollection { components: SourceComponent[], pipeline: TransportPipeline ): Promise { - const { transports } = pipeline.groupByTransport(components); + const { transports } = pipeline.groupByTransport(components, options.skipTransports); const connection = typeof options.usernameOrConnection === 'string' @@ -784,6 +790,40 @@ export class ComponentSet extends LazyCollection { return mdapiDeploy; } + private async retrieveWithPipeline( + operationOptions: MetadataApiRetrieveOptions, + components: SourceComponent[], + pipeline: TransportPipeline, + skipTransports?: string[] + ): Promise { + const { transports } = pipeline.groupByTransport(components, skipTransports); + + const connection = + typeof operationOptions.usernameOrConnection === 'string' + ? await Connection.create({ + authInfo: await AuthInfo.create({ username: operationOptions.usernameOrConnection }), + }) + : operationOptions.usernameOrConnection; + + const transportContext = { + components, + connection, + project: await SfProject.resolve(this.projectDirectory), + orgId: connection.getAuthInfoFields().orgId ?? '', + }; + + const beforeResults = await pipeline.runBeforeMetadata(transportContext, transports, 'retrieve'); + + const mdapiRetrieve = new MetadataApiRetrieve(operationOptions); + + mdapiRetrieve.transportFileResponses = beforeResults.fileResponses; + mdapiRetrieve.transportAsyncHandles = beforeResults.asyncHandles; + mdapiRetrieve.pendingAfterMetadata = { pipeline, transports, context: transportContext }; + + await mdapiRetrieve.start(); + return mdapiRetrieve; + } + /** * Returns an API version to use as the value of the `version` field * in a manifest (package.xml) for MDAPI calls in the following order diff --git a/test/client/transports/retrieveResultTransport.test.ts b/test/client/transports/retrieveResultTransport.test.ts new file mode 100644 index 000000000..3ce8c2049 --- /dev/null +++ b/test/client/transports/retrieveResultTransport.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { + AsyncTransportHandle, + ComponentSet, + ComponentStatus, + FileResponse, + MetadataApiRetrieveStatus, + RequestStatus, + RetrieveResult, +} from '../../../src'; + +function createRetrieveStatus(overrides: Partial = {}): MetadataApiRetrieveStatus { + return { + id: '09S000000000000', + status: RequestStatus.Succeeded, + success: true, + done: true, + fileProperties: [], + zipFile: '', + ...overrides, + }; +} + +describe('RetrieveResult transport integration', () => { + it('should return empty async handles by default', () => { + const result = new RetrieveResult(createRetrieveStatus(), new ComponentSet()); + expect(result.getAsyncTransportHandles()).to.have.lengthOf(0); + }); + + it('should include transport file responses in getFileResponses()', () => { + const result = new RetrieveResult(createRetrieveStatus(), new ComponentSet()); + + const transportResponses: FileResponse[] = [ + { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }, + ]; + + result.addTransportResults(transportResponses); + + const fileResponses = result.getFileResponses(); + expect(fileResponses).to.have.lengthOf(1); + expect(fileResponses[0].fullName).to.equal('MyApp'); + expect(fileResponses[0].type).to.equal('PlatformComputeApp'); + }); + + it('should include async transport handles', () => { + const result = new RetrieveResult(createRetrieveStatus(), new ComponentSet()); + + const asyncHandle: AsyncTransportHandle = { + transportName: 'connectApi', + status: 'Pending', + message: 'Retrieve extension pending', + checkStatus: async (): Promise => ({ + transportName: 'connectApi', + status: 'Succeeded' as const, + checkStatus: async (): Promise => ({} as unknown as AsyncTransportHandle), + }), + }; + + result.addTransportResults([], [asyncHandle]); + + const handles = result.getAsyncTransportHandles(); + expect(handles).to.have.lengthOf(1); + expect(handles[0].transportName).to.equal('connectApi'); + expect(handles[0].status).to.equal('Pending'); + }); + + it('should invalidate cached responses when transport results are added', () => { + const result = new RetrieveResult(createRetrieveStatus(), new ComponentSet()); + + const initial = result.getFileResponses(); + expect(initial).to.have.lengthOf(0); + + result.addTransportResults([ + { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }, + ]); + + const updated = result.getFileResponses(); + expect(updated).to.have.lengthOf(1); + }); +}); diff --git a/test/client/transports/transportIntegration.test.ts b/test/client/transports/transportIntegration.test.ts new file mode 100644 index 000000000..7073ebc69 --- /dev/null +++ b/test/client/transports/transportIntegration.test.ts @@ -0,0 +1,281 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { Connection, SfProject } from '@salesforce/core'; +import { + ComponentSet, + ComponentStatus, + FileResponse, + MetadataApiRetrieveStatus, + RegistryAccess, + RequestStatus, + RetrieveResult, + SourceComponent, + TransportContext, + TransportDescription, + TransportPhase, + TransportPipeline, + TransportProvider, + TransportResult, +} from '../../../src'; + +const registryAccess = new RegistryAccess(); + +function createMockTransport(overrides: { + name: string; + deployPhase: TransportPhase; + retrievePhase: TransportPhase; + typeNames: string[]; + deployResponses?: FileResponse[]; + retrieveResponses?: FileResponse[]; + deploySpy?: sinon.SinonSpy; + retrieveSpy?: sinon.SinonSpy; + shouldThrow?: 'deploy' | 'retrieve'; +}): TransportProvider { + return { + name: overrides.name, + phase: { deploy: overrides.deployPhase, retrieve: overrides.retrievePhase }, + describe(): TransportDescription { + return { label: `Mock ${overrides.name}`, endpoint: `https://example.com/${overrides.name}` }; + }, + handles(component: SourceComponent): boolean { + return overrides.typeNames.includes(component.type.name); + }, + async deploy(ctx: TransportContext): Promise { + overrides.deploySpy?.(ctx); + if (overrides.shouldThrow === 'deploy') throw new Error('deploy transport failed'); + return { fileResponses: overrides.deployResponses ?? [] }; + }, + async retrieve(ctx: TransportContext): Promise { + overrides.retrieveSpy?.(ctx); + if (overrides.shouldThrow === 'retrieve') throw new Error('retrieve transport failed'); + return { fileResponses: overrides.retrieveResponses ?? [] }; + }, + }; +} + +function createMockComponent(typeName: string, fullName: string): SourceComponent { + const type = registryAccess.getTypeByName(typeName); + return new SourceComponent({ name: fullName, type }); +} + +function createRetrieveStatus(overrides: Partial = {}): MetadataApiRetrieveStatus { + return { + id: '09S000000000000', + status: RequestStatus.Succeeded, + success: true, + done: true, + fileProperties: [], + zipFile: '', + ...overrides, + }; +} + +const mockContext: TransportContext = { + components: [], + connection: {} as Connection, + project: {} as SfProject, + orgId: '00Dxx0000000000', +}; + +describe('Transport integration', () => { + afterEach(() => sinon.restore()); + + describe('retrieve before-metadata transports', () => { + it('should run before-metadata retrieve transports via runBeforeMetadata', async () => { + const retrieveSpy = sinon.spy(); + const retrieveResponse: FileResponse = { + fullName: 'PreRetrieveApp', + type: 'CustomObject', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/objects/PreRetrieveApp.object-meta.xml', + }; + const transport = createMockTransport({ + name: 'datakitApi', + deployPhase: 'after-metadata', + retrievePhase: 'before-metadata', + typeNames: ['CustomObject'], + retrieveResponses: [retrieveResponse], + retrieveSpy, + }); + + const pipeline = new TransportPipeline(); + pipeline.registerTransport(transport); + + const groups = [{ transport, components: [createMockComponent('CustomObject', 'MyObject__c')] }]; + + const result = await pipeline.runBeforeMetadata(mockContext, groups, 'retrieve'); + + expect(retrieveSpy.calledOnce).to.be.true; + expect(result.fileResponses).to.have.lengthOf(1); + expect(result.fileResponses[0].fullName).to.equal('PreRetrieveApp'); + }); + + it('should not run after-metadata retrieve transports in runBeforeMetadata', async () => { + const retrieveSpy = sinon.spy(); + const transport = createMockTransport({ + name: 'connectApi', + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['ApexClass'], + retrieveSpy, + }); + + const pipeline = new TransportPipeline(); + pipeline.registerTransport(transport); + + const groups = [{ transport, components: [createMockComponent('ApexClass', 'MyClass')] }]; + + const result = await pipeline.runBeforeMetadata(mockContext, groups, 'retrieve'); + + expect(retrieveSpy.called).to.be.false; + expect(result.fileResponses).to.have.lengthOf(0); + }); + }); + + describe('transport failure handling', () => { + it('should propagate transport errors from runAfterMetadata for callers to catch', async () => { + const transport = createMockTransport({ + name: 'connectApi', + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['ApexClass'], + shouldThrow: 'retrieve', + }); + + const pipeline = new TransportPipeline(); + pipeline.registerTransport(transport); + + const groups = [{ transport, components: [createMockComponent('ApexClass', 'MyClass')] }]; + + try { + await pipeline.runAfterMetadata(mockContext, groups, 'retrieve'); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.equal('retrieve transport failed'); + } + }); + + it('should propagate transport errors from runBeforeMetadata for callers to catch', async () => { + const transport = createMockTransport({ + name: 'connectApi', + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['ApexClass'], + shouldThrow: 'deploy', + }); + + const pipeline = new TransportPipeline(); + pipeline.registerTransport(transport); + + const groups = [{ transport, components: [createMockComponent('ApexClass', 'MyClass')] }]; + + try { + await pipeline.runBeforeMetadata(mockContext, groups, 'deploy'); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.equal('deploy transport failed'); + } + }); + }); + + describe('skipTransports with retrieve', () => { + it('should exclude skipped types from retrieve explain plan', () => { + const pipeline = new TransportPipeline(); + pipeline.registerTransport( + createMockTransport({ + name: 'connectApi', + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['ApexClass'], + }) + ); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + + const planWithout = pipeline.explain([apexClass], 'retrieve'); + const planWith = pipeline.explain([apexClass], 'retrieve', ['ApexClass']); + + expect(planWithout.phases).to.have.lengthOf(2); + expect(planWith.phases).to.have.lengthOf(1); + expect(planWith.phases[0].phase).to.equal('metadata-api'); + }); + + it('should exclude skipped types from groupByTransport but keep in metadataApi', () => { + const pipeline = new TransportPipeline(); + pipeline.registerTransport( + createMockTransport({ + name: 'connectApi', + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['ApexClass', 'ApexTrigger'], + }) + ); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const apexTrigger = createMockComponent('ApexTrigger', 'MyTrigger'); + + const result = pipeline.groupByTransport([apexClass, apexTrigger], ['ApexClass']); + + expect(result.metadataApi).to.have.lengthOf(2); + expect(result.transports).to.have.lengthOf(1); + expect(result.transports[0].components).to.have.lengthOf(1); + expect(result.transports[0].components[0].type.name).to.equal('ApexTrigger'); + }); + }); + + describe('RetrieveResult deduplication', () => { + it('should deduplicate identical transport responses', () => { + const result = new RetrieveResult(createRetrieveStatus(), new ComponentSet()); + + const response: FileResponse = { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }; + + result.addTransportResults([response, response]); + + const fileResponses = result.getFileResponses(); + expect(fileResponses).to.have.lengthOf(1); + }); + + it('should keep distinct transport responses', () => { + const result = new RetrieveResult(createRetrieveStatus(), new ComponentSet()); + + result.addTransportResults([ + { + fullName: 'App1', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/App1/main.py', + }, + { + fullName: 'App2', + type: 'PlatformComputeApp', + state: ComponentStatus.Created, + filePath: 'force-app/main/default/compute/App2/main.py', + }, + ]); + + const fileResponses = result.getFileResponses(); + expect(fileResponses).to.have.lengthOf(2); + }); + }); +}); diff --git a/test/client/transports/transportPipeline.test.ts b/test/client/transports/transportPipeline.test.ts index d3ca0d668..53346ad1e 100644 --- a/test/client/transports/transportPipeline.test.ts +++ b/test/client/transports/transportPipeline.test.ts @@ -276,6 +276,53 @@ describe('TransportPipeline', () => { expect(result.fileResponses[0].fullName).to.equal('CustomerDataKit'); }); + it('should use retrieve phase ordering when operation is retrieve', async () => { + const pipeline = new TransportPipeline(); + const retrieveResponse: FileResponse = { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: 'force-app/main/default/compute/MyApp/main.py', + }; + // deploy: before-metadata → retrieve: after-metadata (flipped by createMockTransport) + const transport = createMockTransport('connectApi', 'before-metadata', ['ApexClass'], retrieveResponse ? [] : []); + // override retrieve to return responses + transport.retrieve = async (): Promise => ({ fileResponses: [retrieveResponse] }); + pipeline.registerTransport(transport); + + const groups = [{ transport, components: [createMockComponent('ApexClass', 'MyClass')] }]; + + // for deploy, this transport is before-metadata → runAfterMetadata should skip it + const deployResult = await pipeline.runAfterMetadata(mockContext, groups, 'deploy'); + expect(deployResult.fileResponses).to.have.lengthOf(0); + + // for retrieve, this transport is after-metadata → runAfterMetadata should run it + const retrieveResult = await pipeline.runAfterMetadata(mockContext, groups, 'retrieve'); + expect(retrieveResult.fileResponses).to.have.lengthOf(1); + expect(retrieveResult.fileResponses[0].fullName).to.equal('MyApp'); + }); + + it('should produce correct explain plan for retrieve operation', () => { + const pipeline = new TransportPipeline(); + // deploy: before-metadata → retrieve: after-metadata + pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + + const apexClass = createMockComponent('ApexClass', 'MyClass'); + const customObject = createMockComponent('CustomObject', 'MyObject__c'); + + const deployPlan = pipeline.explain([apexClass, customObject], 'deploy'); + const retrievePlan = pipeline.explain([apexClass, customObject], 'retrieve'); + + // deploy: connectApi is before-metadata + expect(deployPlan.phases[0].phase).to.equal('before-metadata'); + expect(deployPlan.phases[0].label).to.equal('Mock connectApi'); + + // retrieve: connectApi is after-metadata (phases are flipped) + expect(retrievePlan.phases[0].phase).to.equal('metadata-api'); + expect(retrievePlan.phases[1].phase).to.equal('after-metadata'); + expect(retrievePlan.phases[1].label).to.equal('Mock connectApi'); + }); + it('should collect async handles from transports', async () => { const pipeline = new TransportPipeline(); const asyncTransport: TransportProvider = { From cf3b9b0486fb557a465c7e789f4610f9da8d4145 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Wed, 7 Oct 2026 14:15:30 -0600 Subject: [PATCH 08/21] refactor: coordinate transports outside metadata api --- src/client/metadataApiRetrieve.ts | 27 +------- src/client/metadataTransfer.ts | 11 ++++ src/client/transports/index.ts | 1 + src/client/transports/transportCoordinator.ts | 64 +++++++++++++++++++ src/client/transports/transportPipeline.ts | 34 +++++----- src/client/types.ts | 6 -- src/collections/componentSet.ts | 32 ++++------ test/client/metadataTransfer.test.ts | 22 +++++-- 8 files changed, 123 insertions(+), 74 deletions(-) create mode 100644 src/client/transports/transportCoordinator.ts diff --git a/src/client/metadataApiRetrieve.ts b/src/client/metadataApiRetrieve.ts index e0322647d..fbcb34f17 100644 --- a/src/client/metadataApiRetrieve.ts +++ b/src/client/metadataApiRetrieve.ts @@ -38,8 +38,7 @@ import { import { extract } from './retrieveExtract'; import { getPackageOptions } from './retrieveExtract'; import { MetadataApiRetrieveOptions } from './types'; -import { AsyncTransportHandle, TransportContext } from './transports/types'; -import { TransportPipeline, TransportGroup } from './transports/transportPipeline'; +import { AsyncTransportHandle } from './transports/types'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); @@ -152,16 +151,6 @@ export class MetadataApiRetrieve extends MetadataTransfer< MetadataApiRetrieveOptions > { public static DEFAULT_OPTIONS: Partial = { merge: false }; - /** @internal */ - public transportFileResponses: FileResponse[] = []; - /** @internal */ - public transportAsyncHandles: AsyncTransportHandle[] = []; - /** @internal */ - public pendingAfterMetadata?: { - pipeline: TransportPipeline; - transports: TransportGroup[]; - context: TransportContext; - }; private readonly options: MetadataApiRetrieveOptions; private orgId?: string; @@ -241,17 +230,6 @@ export class MetadataApiRetrieve extends MetadataTransfer< })); } } - if (result.status === RequestStatus.Succeeded && this.pendingAfterMetadata) { - try { - const { pipeline, transports, context } = this.pendingAfterMetadata; - const afterResults = await pipeline.runAfterMetadata(context, transports, 'retrieve'); - this.transportFileResponses.push(...afterResults.fileResponses); - this.transportAsyncHandles.push(...afterResults.asyncHandles); - } catch (err) { - this.logger.warn(`After-metadata transport failed during retrieve: ${(err as Error).message}`); - } - } - componentSet ??= new ComponentSet(undefined, this.options.registry); const retrieveResult = new RetrieveResult( @@ -261,9 +239,6 @@ export class MetadataApiRetrieve extends MetadataTransfer< partialDeleteFileResponses, this.options.registry ); - if (this.transportFileResponses.length > 0 || this.transportAsyncHandles.length > 0) { - retrieveResult.addTransportResults(this.transportFileResponses, this.transportAsyncHandles); - } if (!isMdapiRetrieve && !this.options.suppressEvents) { // This should only be done when retrieving source format since retrieving // mdapi format has no conversion or events/hooks diff --git a/src/client/metadataTransfer.ts b/src/client/metadataTransfer.ts index 5dae27e76..3adec9088 100644 --- a/src/client/metadataTransfer.ts +++ b/src/client/metadataTransfer.ts @@ -54,6 +54,7 @@ export abstract class MetadataTransfer< protected mdapiTempDir?: string; private transferId: Options['id']; private event = new EventEmitter(); + private readonly resultProcessors: Array<(result: Result, status: Status) => Promise> = []; private usernameOrConnection: string | Connection; private apiVersion?: string; private consecutiveErrorRetries = 0; @@ -148,6 +149,11 @@ export abstract class MetadataTransfer< } const result = await this.post(completedMdapiStatus); + for (const processor of this.resultProcessors) { + // Processors extend the operation result before it is returned to callers. + // eslint-disable-next-line no-await-in-loop + await processor(result, completedMdapiStatus); + } if (completedMdapiStatus.status === RequestStatus.Canceled) { this.event.emit('cancel', completedMdapiStatus); } else { @@ -201,6 +207,11 @@ export abstract class MetadataTransfer< this.event.on('finish', subscriber); } + /** @internal Register work that must complete before pollStatus returns its result. */ + public addResultProcessor(processor: (result: Result, status: Status) => Promise): void { + this.resultProcessors.push(processor); + } + public onCancel(subscriber: (result: Status | undefined) => void): void { this.event.on('cancel', subscriber); } diff --git a/src/client/transports/index.ts b/src/client/transports/index.ts index 2b612e575..04dba78b3 100644 --- a/src/client/transports/index.ts +++ b/src/client/transports/index.ts @@ -31,3 +31,4 @@ export { TransportPlanPhase, TransportPipelineResult, } from './transportPipeline'; +export { TransportCoordinator } from './transportCoordinator'; diff --git a/src/client/transports/transportCoordinator.ts b/src/client/transports/transportCoordinator.ts new file mode 100644 index 000000000..6d9ff6b0d --- /dev/null +++ b/src/client/transports/transportCoordinator.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RequestStatus } from '../types'; +import { DeployResult } from '../metadataApiDeploy'; +import { RetrieveResult } from '../metadataApiRetrieve'; +import { TransportContext } from './types'; +import { TransportGroup, TransportPipeline, TransportPipelineResult } from './transportPipeline'; + +type TransportOperation = 'deploy' | 'retrieve'; +type TransportResult = DeployResult | RetrieveResult; + +export class TransportCoordinator { + private beforeMetadataResults: TransportPipelineResult = { fileResponses: [], asyncHandles: [] }; + + public constructor( + private readonly pipeline: TransportPipeline, + private readonly groups: TransportGroup[], + private readonly context: TransportContext, + private readonly operation: TransportOperation + ) {} + + public async runBeforeMetadata(): Promise { + const result = await this.pipeline.runBeforeMetadata(this.context, this.groups, this.operation); + this.beforeMetadataResults = result; + } + + public async processResult(result: TransportResult, status: RequestStatus): Promise { + if (status !== RequestStatus.Succeeded) return; + + if (this.operation === 'retrieve' && result instanceof RetrieveResult) { + const extractedByKey = new Map( + result.components + .getSourceComponents() + .toArray() + .map((component) => [`${component.type.name}#${component.fullName}`, component]) + ); + for (const group of this.groups) { + group.components = group.components.map( + (component) => extractedByKey.get(`${component.type.name}#${component.fullName}`) ?? component + ); + } + } + + const afterMetadataResults = await this.pipeline.runAfterMetadata(this.context, this.groups, this.operation); + result.addTransportResults( + [...this.beforeMetadataResults.fileResponses, ...afterMetadataResults.fileResponses], + [...this.beforeMetadataResults.asyncHandles, ...afterMetadataResults.asyncHandles] + ); + } +} diff --git a/src/client/transports/transportPipeline.ts b/src/client/transports/transportPipeline.ts index 79296cf65..650b2ed21 100644 --- a/src/client/transports/transportPipeline.ts +++ b/src/client/transports/transportPipeline.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Logger } from '@salesforce/core'; +import { Logger, SfError } from '@salesforce/core'; import { SourceComponent } from '../../resolve/sourceComponent'; import { FileResponse } from '../types'; import { AsyncTransportHandle, TransportContext, TransportPhase, TransportProvider, TransportResult } from './types'; @@ -57,13 +57,12 @@ export class TransportPipeline { this.transports.push(transport); } - public groupByTransport(components: SourceComponent[], skipTypes?: string[]): GroupedComponents { - const skip = skipTypes ? new Set(skipTypes) : undefined; + public groupByTransport(components: SourceComponent[]): GroupedComponents { const metadataApi: SourceComponent[] = []; const transportMap = new Map(); for (const component of components) { - const transport = this.findTransportFor(component, skip); + const transport = this.findTransportFor(component); if (transport) { let group = transportMap.get(transport.name); if (!group) { @@ -83,12 +82,8 @@ export class TransportPipeline { }; } - public explain( - components: SourceComponent[], - operation: 'deploy' | 'retrieve' = 'deploy', - skipTypes?: string[] - ): TransportPlan { - const { metadataApi, transports } = this.groupByTransport(components, skipTypes); + public explain(components: SourceComponent[], operation: 'deploy' | 'retrieve' = 'deploy'): TransportPlan { + const { metadataApi, transports } = this.groupByTransport(components); const phases: TransportPlanPhase[] = []; const beforeMetadata = transports.filter((g) => g.transport.phase[operation] === 'before-metadata'); @@ -142,16 +137,21 @@ export class TransportPipeline { return this.runTransportGroups(afterMetadata, context, operation); } - public hasTransports(components: SourceComponent[], skipTypes?: string[]): boolean { - const skip = skipTypes ? new Set(skipTypes) : undefined; - return components.some((c) => this.findTransportFor(c, skip) !== undefined); + public hasTransports(components: SourceComponent[]): boolean { + return components.some((component) => this.findTransportFor(component) !== undefined); } - private findTransportFor(component: SourceComponent, skipTypes?: Set): TransportProvider | undefined { - if (skipTypes?.has(component.type.name)) { - return undefined; + private findTransportFor(component: SourceComponent): TransportProvider | undefined { + const transportName = component.type.strategies?.transport; + if (!transportName) return undefined; + const transport = this.transports.find((candidate) => candidate.name === transportName); + if (!transport) { + throw new SfError( + `No transport provider is registered for '${transportName}' required by ${component.type.name}.`, + 'MissingTransportProvider' + ); } - return this.transports.find((t) => t.handles(component)); + return transport; } private async runTransportGroups( diff --git a/src/client/types.ts b/src/client/types.ts index a12198550..300922211 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -417,10 +417,4 @@ export type MetadataApiRetrieveOptions = MetadataTransferOptions & RetrieveOptions & { registry?: RegistryAccess; botVersionFilters?: BotVersionFilter[]; - /** - * Metadata type names whose transports should be skipped (e.g., `['PlatformComputeApp']`). - * Components of these types will still be retrieved via the Metadata API but their - * secondary transport (Connect API, presigned URL, etc.) will not run. - */ - skipTransports?: string[]; }; diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index fe6aa310e..13a987ea4 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -31,6 +31,7 @@ import { MetadataApiDeploy, MetadataApiDeployOptions } from '../client/metadataA import { MetadataApiRetrieve } from '../client/metadataApiRetrieve'; import type { MetadataApiRetrieveOptions } from '../client/types'; import { TransportPipeline } from '../client/transports/transportPipeline'; +import { TransportCoordinator } from '../client/transports/transportCoordinator'; import { XML_DECL, XML_NS_KEY, XML_NS_URL } from '../common/constants'; import { SourceComponent } from '../resolve/sourceComponent'; import { MetadataResolver } from '../resolve/metadataResolver'; @@ -400,7 +401,7 @@ export class ComponentSet extends LazyCollection { } const pipeline = this.transportPipeline; - if (pipeline?.hasTransports(toDeploy, options.skipTransports)) { + if (pipeline?.hasTransports(toDeploy)) { return this.deployWithPipeline(options, toDeploy, pipeline); } @@ -445,8 +446,8 @@ export class ComponentSet extends LazyCollection { const pipeline = this.transportPipeline; const toRetrieve = Array.from(this.getSourceComponents()); - if (pipeline?.hasTransports(toRetrieve, options.skipTransports)) { - return this.retrieveWithPipeline(operationOptions, toRetrieve, pipeline, options.skipTransports); + if (pipeline?.hasTransports(toRetrieve)) { + return this.retrieveWithPipeline(operationOptions, toRetrieve, pipeline); } const mdapiRetrieve = new MetadataApiRetrieve(operationOptions); @@ -756,7 +757,7 @@ export class ComponentSet extends LazyCollection { components: SourceComponent[], pipeline: TransportPipeline ): Promise { - const { transports } = pipeline.groupByTransport(components, options.skipTransports); + const { transports } = pipeline.groupByTransport(components); const connection = typeof options.usernameOrConnection === 'string' @@ -770,7 +771,8 @@ export class ComponentSet extends LazyCollection { orgId: connection.getAuthInfoFields().orgId ?? '', }; - const beforeResults = await pipeline.runBeforeMetadata(transportContext, transports); + const coordinator = new TransportCoordinator(pipeline, transports, transportContext, 'deploy'); + await coordinator.runBeforeMetadata(); const operationOptions = Object.assign({}, options, { components: this, @@ -779,24 +781,18 @@ export class ComponentSet extends LazyCollection { }); const mdapiDeploy = new MetadataApiDeploy(operationOptions); + mdapiDeploy.addResultProcessor((result, status) => coordinator.processResult(result, status.status)); await mdapiDeploy.start(); - // store transport results and pending after-metadata groups on the deploy - // so they can be merged into DeployResult after polling completes - mdapiDeploy.transportFileResponses = beforeResults.fileResponses; - mdapiDeploy.transportAsyncHandles = beforeResults.asyncHandles; - mdapiDeploy.pendingAfterMetadata = { pipeline, transports, context: transportContext }; - return mdapiDeploy; } private async retrieveWithPipeline( operationOptions: MetadataApiRetrieveOptions, components: SourceComponent[], - pipeline: TransportPipeline, - skipTransports?: string[] + pipeline: TransportPipeline ): Promise { - const { transports } = pipeline.groupByTransport(components, skipTransports); + const { transports } = pipeline.groupByTransport(components); const connection = typeof operationOptions.usernameOrConnection === 'string' @@ -812,13 +808,11 @@ export class ComponentSet extends LazyCollection { orgId: connection.getAuthInfoFields().orgId ?? '', }; - const beforeResults = await pipeline.runBeforeMetadata(transportContext, transports, 'retrieve'); + const coordinator = new TransportCoordinator(pipeline, transports, transportContext, 'retrieve'); + await coordinator.runBeforeMetadata(); const mdapiRetrieve = new MetadataApiRetrieve(operationOptions); - - mdapiRetrieve.transportFileResponses = beforeResults.fileResponses; - mdapiRetrieve.transportAsyncHandles = beforeResults.asyncHandles; - mdapiRetrieve.pendingAfterMetadata = { pipeline, transports, context: transportContext }; + mdapiRetrieve.addResultProcessor((result, status) => coordinator.processResult(result, status.status)); await mdapiRetrieve.start(); return mdapiRetrieve; diff --git a/test/client/metadataTransfer.test.ts b/test/client/metadataTransfer.test.ts index 664db83fe..4cfa006b8 100644 --- a/test/client/metadataTransfer.test.ts +++ b/test/client/metadataTransfer.test.ts @@ -48,22 +48,19 @@ describe('MetadataTransfer', () => { post: $$.SANDBOX.stub().returns({ id: '1' }), cancel: $$.SANDBOX.stub().returns(true), }; - + public async checkStatus(): Promise { return this.lifecycle.checkStatus(); } - public async cancel(): Promise { this.canceled = this.lifecycle.cancel(); } - protected async pre(): Promise<{ id: string }> { return this.lifecycle.pre(); } - protected async post(): Promise { return this.lifecycle.post(); } @@ -204,6 +201,19 @@ describe('MetadataTransfer', () => { expect(listenerStub.callCount).to.equal(1); }); + it('should await result processors before returning the result', async () => { + const calls: string[] = []; + operation.addResultProcessor(async () => { + await sleep(1); + calls.push('processor'); + }); + + await operation.pollStatus(); + calls.push('returned'); + + expect(calls).to.deep.equal(['processor', 'returned']); + }); + it('should exit and fire "cancel" event when done = true and request status is "Canceled"', async () => { const { checkStatus } = operation.lifecycle; checkStatus.resolves({ status: RequestStatus.Canceled, done: true }); @@ -225,7 +235,7 @@ describe('MetadataTransfer', () => { callOrder.push('firstCall2'); return { done: false }; }); - + checkStatus.onSecondCall().callsFake(async () => { callOrder.push('secondCall1'); return { done: true }; @@ -243,7 +253,7 @@ describe('MetadataTransfer', () => { // until the timeout is exceeded. const { checkStatus } = operation.lifecycle; let callCount = 0; - + checkStatus.callsFake(async () => { callCount += 1; if (callCount > 22) { From b7eae9f3fdb96d728d4fffa21557ea3dde02cfe3 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 24 Sep 2026 16:03:53 -0600 Subject: [PATCH 09/21] feat: add ConnectApiTransport for Heroku Compute (PlatformComputeApp) Implements the concrete transport for deploying/retrieving PlatformComputeApp metadata via the Salesforce Connect API. Adds PlatformComputeApp to the metadata registry, tar.gz bundle packaging/unpacking, and wires the transport into DeployPipeline.withBuiltinTransports(). --- package.json | 3 + src/client/index.ts | 5 + src/client/transports/computeSourceBundle.ts | 170 +++++++++++++++ src/client/transports/connectApiTransport.ts | 163 ++++++++++++++ src/client/transports/index.ts | 2 + src/client/transports/transportPipeline.ts | 7 + src/collections/componentSet.ts | 8 +- src/index.ts | 5 + src/registry/metadataRegistry.json | 20 +- .../transports/computeSourceBundle.test.ts | 172 +++++++++++++++ .../transports/connectApiTransport.test.ts | 200 ++++++++++++++++++ yarn.lock | 96 ++++++++- 12 files changed, 844 insertions(+), 7 deletions(-) create mode 100644 src/client/transports/computeSourceBundle.ts create mode 100644 src/client/transports/connectApiTransport.ts create mode 100644 test/client/transports/computeSourceBundle.test.ts create mode 100644 test/client/transports/connectApiTransport.test.ts diff --git a/package.json b/package.json index 95fc57d6c..a0ac3f8ff 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@salesforce/types": "^1.6.0", "fast-levenshtein": "^3.0.0", "fast-xml-parser": "^5.7.3", + "form-data": "^4.0.6", "got": "^11.8.6", "graceful-fs": "^4.2.11", "ignore": "^5.3.2", @@ -38,6 +39,7 @@ "mime": "2.6.0", "minimatch": "^9.0.9", "proxy-agent": "^6.5.0", + "tar-stream": "^3.2.1", "yaml": "^2.9.0" }, "devDependencies": { @@ -53,6 +55,7 @@ "@types/mocha": "^10.0.10", "@types/node": "^18", "@types/sinon": "^10.0.20", + "@types/tar-stream": "^3.1.4", "deep-equal-in-any-order": "^1.1.19", "esbuild": "^0.28.0", "eslint": "^10.4.0", diff --git a/src/client/index.ts b/src/client/index.ts index 97ebbf64f..7a6ca7e10 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -36,6 +36,11 @@ export { TransportPlan, TransportPlanPhase, TransportPipelineResult, + ConnectApiTransport, + packageComputeBundle, + unpackComputeBundle, + PackagedBundle, + UnpackedBundle, } from './transports'; export { ComponentDeployment, diff --git a/src/client/transports/computeSourceBundle.ts b/src/client/transports/computeSourceBundle.ts new file mode 100644 index 000000000..7729374d1 --- /dev/null +++ b/src/client/transports/computeSourceBundle.ts @@ -0,0 +1,170 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { createGzip, gunzipSync } from 'node:zlib'; +import { buffer as streamToBuffer } from 'node:stream/consumers'; +import tarStream from 'tar-stream'; +import { Logger, SfError } from '@salesforce/core'; +import { ForceIgnore } from '../../resolve/forceIgnore'; + +const logger = Logger.childFromRoot('computeSourceBundle'); + +export type PackagedBundle = { + buffer: Buffer; + fileCount: number; +}; + +export type UnpackedBundle = { + fileCount: number; +}; + +// Bundle shape: deployment-bundle.tar.gz containing api-spec.yaml plus a +// nested source.tar.gz with the app's source files. +export async function packageComputeBundle(appDir: string, name: string): Promise { + const apiSpecPath = join(appDir, 'api-spec.yaml'); + try { + await access(apiSpecPath); + } catch { + throw new SfError(`PlatformComputeApp '${name}' is missing required api-spec.yaml in ${appDir}`, 'MissingApiSpec'); + } + + const metaFileName = `${name}.compute-meta.xml`; + const excluded = new Set(['api-spec.yaml', metaFileName, 'project.toml', '.forceignore']); + const forceIgnore = ForceIgnore.findAndCreate(appDir); + + const allFiles = await listFilesRecursive(appDir); + const sourceRelativePaths = allFiles.filter((f) => !excluded.has(f) && forceIgnore.accepts(join(appDir, f))); + logger.debug( + 'packaging %s: %d of %d files after exclusions/.forceignore', + appDir, + sourceRelativePaths.length, + allFiles.length + ); + + const sourceEntries = await Promise.all( + sourceRelativePaths.map(async (relativePath) => ({ + name: relativePath, + data: await readFile(join(appDir, relativePath)), + })) + ); + const sourceTarGz = await tarGzip(sourceEntries); + const apiSpec = await readFile(apiSpecPath); + + const buffer = await tarGzip([ + { name: 'api-spec.yaml', data: apiSpec }, + { name: 'source.tar.gz', data: sourceTarGz }, + ]); + logger.debug('packaged %s into %d bytes (%d files)', appDir, buffer.length, sourceEntries.length + 1); + + return { buffer, fileCount: sourceEntries.length + 1 }; +} + +export async function unpackComputeBundle(buffer: Buffer, appDir: string): Promise { + const outer = await untarGzip(buffer); + const apiSpec = outer['api-spec.yaml']; + const sourceTarGz = outer['source.tar.gz']; + if (!apiSpec || !sourceTarGz) { + const missing = [!apiSpec && 'api-spec.yaml', !sourceTarGz && 'source.tar.gz'].filter(Boolean).join(', '); + throw new Error(`Malformed compute source bundle: missing ${missing}`); + } + + await writeFile(join(appDir, 'api-spec.yaml'), apiSpec); + + const sourceEntries = await untarGzip(sourceTarGz); + const resolvedAppDir = resolve(appDir); + await Promise.all( + Object.entries(sourceEntries).map(async ([relativePath, data]) => { + const filePath = resolve(appDir, relativePath); + // tar-slip guard: reject entries that escape appDir + if (filePath !== resolvedAppDir && !filePath.startsWith(resolvedAppDir + sep)) { + throw new Error(`Malformed compute source bundle: unsafe entry path "${relativePath}"`); + } + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, data); + }) + ); + + return { fileCount: Object.keys(sourceEntries).length + 1 }; +} + +function untarGzip(buffer: Buffer): Promise> { + return new Promise((resolveEntries, reject) => { + let decompressed: Buffer; + try { + decompressed = gunzipSync(buffer); + } catch { + reject(new Error('Malformed compute source bundle: not a valid gzip archive')); + return; + } + + const entries: Record = {}; + const extract = tarStream.extract(); + extract.on('entry', (header, stream, next) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: unknown) => chunks.push(chunk as Buffer)); + stream.on('end', () => { + entries[header.name] = Buffer.concat(chunks); + next(); + }); + stream.on('error', reject); + stream.resume(); + }); + extract.on('finish', () => resolveEntries(entries)); + extract.on('error', () => reject(new Error('Malformed compute source bundle: not a valid tar archive'))); + extract.end(decompressed); + }); +} + +async function tarGzip(entries: ReadonlyArray<{ name: string; data: Buffer }>): Promise { + const pack = tarStream.pack(); + + // Pipe BEFORE writing entries to avoid deadlock: pack is a Readable with + // a 16KB highWaterMark — without a consumer attached, backpressure stalls + // the entry callbacks once buffered bytes exceed the threshold. + const gzippedBufferPromise = streamToBuffer(pack.pipe(createGzip())); + + await entries.reduce( + (previous, { name, data }) => + previous.then( + async () => + new Promise((resolveEntry, reject) => { + pack.entry({ name, size: data.length }, data, (err) => (err ? reject(err) : resolveEntry())); + }) + ), + Promise.resolve() + ); + pack.finalize(); + return gzippedBufferPromise; +} + +async function listFilesRecursive(dir: string, base = dir): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const nested = await Promise.all( + entries.map(async (entry): Promise => { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + return listFilesRecursive(full, base); + } + if (entry.isFile()) { + return [relative(base, full).split(sep).join('/')]; + } + return []; + }) + ); + return nested.flat(); +} diff --git a/src/client/transports/connectApiTransport.ts b/src/client/transports/connectApiTransport.ts new file mode 100644 index 000000000..27c472e9e --- /dev/null +++ b/src/client/transports/connectApiTransport.ts @@ -0,0 +1,163 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { dirname } from 'node:path'; +import FormData from 'form-data'; +import { Logger, SfError } from '@salesforce/core'; +import type { Connection } from '@salesforce/core'; +import { SourceComponent } from '../../resolve/sourceComponent'; +import { ComponentStatus, FileResponse } from '../types'; +import { TransportContext, TransportDescription, TransportProvider, TransportResult } from './types'; +import { packageComputeBundle, unpackComputeBundle } from './computeSourceBundle'; + +type ComputeSourceUploadResponse = { + platformComputeId: string; + buildId: string; + sourceBlobSize: number; + uploadedAt: string; +}; + +const MIN_API_VERSION = '68.0'; +const ENDPOINT = '/connect/compute/source'; + +function resolveAppDir(component: SourceComponent): string { + if (component.content) { + return component.content; + } + if (component.xml) { + return dirname(component.xml); + } + throw new SfError(`PlatformComputeApp '${component.fullName}' has no content or xml path`, 'MissingComponentPath'); +} + +function validateApiVersion(connection: Connection): void { + const version = connection.version; + if (!version || parseFloat(version) < parseFloat(MIN_API_VERSION)) { + throw new SfError( + `PlatformComputeApp requires API version ${MIN_API_VERSION} or later (current: ${version ?? 'unknown'})`, + 'InsufficientApiVersion' + ); + } +} + +async function uploadSource( + connection: Connection, + name: string, + bundle: Buffer +): Promise { + const form = new FormData(); + form.append('name', name); + form.append('bundle', bundle, { + filename: 'deployment-bundle.tar.gz', + contentType: 'application/gzip', + }); + + try { + return await connection.request({ + method: 'POST', + url: ENDPOINT, + body: form, + headers: form.getHeaders(), + }); + } catch (err) { + throw new SfError(`Connect API upload failed for '${name}': ${(err as Error).message}`, 'ConnectApiUploadError'); + } +} + +// jsforce decodes to UTF-8 by default — lossy for gzip bytes. +// encoding: 'binary' (latin1) preserves byte values 1:1. +async function downloadSource(connection: Connection, appIdOrName: string): Promise { + try { + const body = await connection.request( + { method: 'GET', url: `/connect/compute/${encodeURIComponent(appIdOrName)}/source` }, + { encoding: 'binary' } + ); + return Buffer.from(body, 'binary'); + } catch (err) { + throw new SfError( + `Connect API download failed for '${appIdOrName}': ${(err as Error).message}`, + 'ConnectApiDownloadError' + ); + } +} + +export class ConnectApiTransport implements TransportProvider { + public readonly name = 'connectApi'; + public readonly phase = { deploy: 'before-metadata' as const, retrieve: 'after-metadata' as const }; + private readonly logger = Logger.childFromRoot('ConnectApiTransport'); + + // eslint-disable-next-line class-methods-use-this + public describe(): TransportDescription { + return { label: 'Heroku Compute (Connect API)', endpoint: ENDPOINT }; + } + + // eslint-disable-next-line class-methods-use-this + public handles(component: SourceComponent): boolean { + return component.type.strategies?.transport === 'connectApi' || component.type.name === 'PlatformComputeApp'; + } + + public async deploy(context: TransportContext): Promise { + validateApiVersion(context.connection); + const fileResponses: FileResponse[] = []; + + for (const component of context.components) { + const appDir = resolveAppDir(component); + this.logger.debug('deploying compute source for %s from %s', component.fullName, appDir); + + // eslint-disable-next-line no-await-in-loop + const bundle = await packageComputeBundle(appDir, component.fullName); + this.logger.debug('packaged %s: %d bytes, %d files', component.fullName, bundle.buffer.length, bundle.fileCount); + + // eslint-disable-next-line no-await-in-loop + const result = await uploadSource(context.connection, component.fullName, bundle.buffer); + this.logger.debug('uploaded %s: buildId=%s', component.fullName, result.buildId); + + fileResponses.push({ + fullName: component.fullName, + type: component.type.name, + state: ComponentStatus.Changed, + filePath: appDir, + }); + } + + return { fileResponses }; + } + + public async retrieve(context: TransportContext): Promise { + validateApiVersion(context.connection); + const fileResponses: FileResponse[] = []; + + for (const component of context.components) { + const appDir = resolveAppDir(component); + this.logger.debug('retrieving compute source for %s into %s', component.fullName, appDir); + + // eslint-disable-next-line no-await-in-loop + const buffer = await downloadSource(context.connection, component.fullName); + // eslint-disable-next-line no-await-in-loop + const unpacked = await unpackComputeBundle(buffer, appDir); + this.logger.debug('unpacked %s: %d files into %s', component.fullName, unpacked.fileCount, appDir); + + fileResponses.push({ + fullName: component.fullName, + type: component.type.name, + state: ComponentStatus.Changed, + filePath: appDir, + }); + } + + return { fileResponses }; + } +} diff --git a/src/client/transports/index.ts b/src/client/transports/index.ts index 04dba78b3..35f088e2d 100644 --- a/src/client/transports/index.ts +++ b/src/client/transports/index.ts @@ -32,3 +32,5 @@ export { TransportPipelineResult, } from './transportPipeline'; export { TransportCoordinator } from './transportCoordinator'; +export { ConnectApiTransport } from './connectApiTransport'; +export { packageComputeBundle, unpackComputeBundle, PackagedBundle, UnpackedBundle } from './computeSourceBundle'; diff --git a/src/client/transports/transportPipeline.ts b/src/client/transports/transportPipeline.ts index 650b2ed21..a5d931a90 100644 --- a/src/client/transports/transportPipeline.ts +++ b/src/client/transports/transportPipeline.ts @@ -18,6 +18,7 @@ import { Logger, SfError } from '@salesforce/core'; import { SourceComponent } from '../../resolve/sourceComponent'; import { FileResponse } from '../types'; import { AsyncTransportHandle, TransportContext, TransportPhase, TransportProvider, TransportResult } from './types'; +import { ConnectApiTransport } from './connectApiTransport'; export type TransportGroup = { transport: TransportProvider; @@ -53,6 +54,12 @@ export class TransportPipeline { this.logger = Logger.childFromRoot('TransportPipeline'); } + public static withBuiltinTransports(): TransportPipeline { + const pipeline = new TransportPipeline(); + pipeline.registerTransport(new ConnectApiTransport()); + return pipeline; + } + public registerTransport(transport: TransportProvider): void { this.transports.push(transport); } diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index 13a987ea4..4559439b2 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -400,8 +400,8 @@ export class ComponentSet extends LazyCollection { ); } - const pipeline = this.transportPipeline; - if (pipeline?.hasTransports(toDeploy)) { + const pipeline = this.transportPipeline ?? TransportPipeline.withBuiltinTransports(); + if (pipeline.hasTransports(toDeploy)) { return this.deployWithPipeline(options, toDeploy, pipeline); } @@ -444,9 +444,9 @@ export class ComponentSet extends LazyCollection { ); } - const pipeline = this.transportPipeline; + const pipeline = this.transportPipeline ?? TransportPipeline.withBuiltinTransports(); const toRetrieve = Array.from(this.getSourceComponents()); - if (pipeline?.hasTransports(toRetrieve)) { + if (pipeline.hasTransports(toRetrieve)) { return this.retrieveWithPipeline(operationOptions, toRetrieve, pipeline); } diff --git a/src/index.ts b/src/index.ts index be766aced..038631031 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,11 @@ export { TransportPlan, TransportPlanPhase, TransportPipelineResult, + ConnectApiTransport, + packageComputeBundle, + unpackComputeBundle, + PackagedBundle, + UnpackedBundle, ComponentDeployment, ComponentRetrieval, ComponentDiagnostic, diff --git a/src/registry/metadataRegistry.json b/src/registry/metadataRegistry.json index 57bb33529..35e8bd50b 100644 --- a/src/registry/metadataRegistry.json +++ b/src/registry/metadataRegistry.json @@ -65,6 +65,7 @@ "mktDataSources": "datasource", "objectTranslations": "customobjecttranslation", "objects": "customobject", + "platformComputeApps": "platformcomputeapp", "restrictionRules": "restrictionrule", "siteDotComSites": "sitedotcom", "sites": "customsite", @@ -607,7 +608,8 @@ "cnfgItemTypeRelationDef": "cnfgitemtyperelationdef", "cnfgMgmtRelationTypeDef": "cnfgmgmtrelationtypedef", "meetingPlaybookDefinition": "meetingplaybookdefinition", - "idpConfiguration": "idpconfiguration" + "idpConfiguration": "idpconfiguration", + "compute": "platformcomputeapp" }, "types": { "accesscontrolpolicy": { @@ -790,7 +792,9 @@ "inFolder": false, "strictDirectoryName": true, "versionSeparator": "#", - "strategies": { "adapter": "bundle" } + "strategies": { + "adapter": "bundle" + } }, "aiagentscorerdefinition": { "directoryName": "aiAgentScorerDefinitions", @@ -5422,6 +5426,18 @@ "adapter": "bundle" }, "supportsPartialDelete": true + }, + "platformcomputeapp": { + "directoryName": "platformComputeApps", + "id": "platformcomputeapp", + "inFolder": false, + "name": "PlatformComputeApp", + "strategies": { + "adapter": "bundle", + "transport": "connectApi" + }, + "strictDirectoryName": true, + "suffix": "compute" } } } diff --git a/test/client/transports/computeSourceBundle.test.ts b/test/client/transports/computeSourceBundle.test.ts new file mode 100644 index 000000000..03f3f2036 --- /dev/null +++ b/test/client/transports/computeSourceBundle.test.ts @@ -0,0 +1,172 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mkdir, readFile, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createGzip } from 'node:zlib'; +import { buffer as streamToBuffer } from 'node:stream/consumers'; +import { expect } from 'chai'; +import tarStream from 'tar-stream'; +import { packageComputeBundle, unpackComputeBundle } from '../../../src'; + +describe('computeSourceBundle', () => { + let tempDir: string; + let appDir: string; + + beforeEach(async () => { + tempDir = join(tmpdir(), `sdr-bundle-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + appDir = join(tempDir, 'force-app', 'platformComputeApps', 'MyApp'); + await mkdir(appDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe('packageComputeBundle', () => { + it('should package app directory into a bundle', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'openapi: 3.0.0\ninfo:\n title: test'); + await writeFile(join(appDir, 'MyApp.compute-meta.xml'), ''); + await writeFile(join(appDir, 'main.py'), 'print("hello")'); + await mkdir(join(appDir, 'lib'), { recursive: true }); + await writeFile(join(appDir, 'lib', 'utils.py'), 'def helper(): pass'); + + const result = await packageComputeBundle(appDir, 'MyApp'); + + expect(result.buffer).to.be.instanceOf(Buffer); + expect(result.buffer.length).to.be.greaterThan(0); + // api-spec.yaml counts as +1 in the outer bundle, source files are main.py + lib/utils.py = 2 + // fileCount = source entries + 1 (api-spec) + expect(result.fileCount).to.equal(3); + }); + + it('should exclude meta XML, api-spec, project.toml, and .forceignore from source tar', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'openapi: 3.0.0'); + await writeFile(join(appDir, 'MyApp.compute-meta.xml'), ''); + await writeFile(join(appDir, 'project.toml'), '[project]\nname = "test"'); + await writeFile(join(appDir, '.forceignore'), '*.log'); + await writeFile(join(appDir, 'main.py'), 'print("hello")'); + + const result = await packageComputeBundle(appDir, 'MyApp'); + + // Only main.py is in source + api-spec = 2 + expect(result.fileCount).to.equal(2); + }); + + it('should respect .forceignore patterns', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'openapi: 3.0.0'); + await writeFile(join(appDir, 'MyApp.compute-meta.xml'), ''); + await writeFile(join(appDir, '.forceignore'), '*.log\n__pycache__/'); + await writeFile(join(appDir, 'main.py'), 'print("hello")'); + await writeFile(join(appDir, 'debug.log'), 'log entry'); + await mkdir(join(appDir, '__pycache__'), { recursive: true }); + await writeFile(join(appDir, '__pycache__', 'main.cpython.pyc'), 'bytecode'); + + const result = await packageComputeBundle(appDir, 'MyApp'); + + // Only main.py is in source + api-spec = 2 + expect(result.fileCount).to.equal(2); + }); + }); + + describe('unpackComputeBundle', () => { + it('should round-trip pack and unpack', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'openapi: 3.0.0\ninfo:\n title: test'); + await writeFile(join(appDir, 'MyApp.compute-meta.xml'), ''); + await writeFile(join(appDir, 'main.py'), 'print("hello")'); + await mkdir(join(appDir, 'lib'), { recursive: true }); + await writeFile(join(appDir, 'lib', 'utils.py'), 'def helper(): pass'); + + const packed = await packageComputeBundle(appDir, 'MyApp'); + + const outputDir = join(tempDir, 'output'); + await mkdir(outputDir, { recursive: true }); + + const unpacked = await unpackComputeBundle(packed.buffer, outputDir); + + expect(unpacked.fileCount).to.be.greaterThan(0); + + const apiSpec = await readFile(join(outputDir, 'api-spec.yaml'), 'utf8'); + expect(apiSpec).to.include('openapi: 3.0.0'); + + const mainPy = await readFile(join(outputDir, 'main.py'), 'utf8'); + expect(mainPy).to.equal('print("hello")'); + + const utilsPy = await readFile(join(outputDir, 'lib', 'utils.py'), 'utf8'); + expect(utilsPy).to.equal('def helper(): pass'); + }); + + it('should throw on malformed bundle (not gzip)', async () => { + try { + await unpackComputeBundle(Buffer.from('not a gzip'), join(tempDir, 'out')); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('not a valid gzip archive'); + } + }); + + it('should throw on bundle missing required entries', async () => { + const pack = tarStream.pack(); + const bufPromise = streamToBuffer(pack.pipe(createGzip())); + pack.entry({ name: 'random.txt' }, 'data', () => { + pack.finalize(); + }); + const buf = await bufPromise; + + try { + await unpackComputeBundle(buf, join(tempDir, 'out')); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('missing api-spec.yaml, source.tar.gz'); + } + }); + + it('should reject tar-slip entries', async () => { + const innerPack = tarStream.pack(); + const innerBufPromise = streamToBuffer(innerPack.pipe(createGzip())); + innerPack.entry({ name: '../../../etc/passwd' }, 'malicious', () => { + innerPack.finalize(); + }); + const innerBuf = await innerBufPromise; + + // Outer bundle + const outerPack = tarStream.pack(); + const outerBufPromise = streamToBuffer(outerPack.pipe(createGzip())); + await new Promise((resolve, reject) => { + outerPack.entry({ name: 'api-spec.yaml' }, 'openapi: 3.0.0', (err) => { + if (err) reject(err); + outerPack.entry({ name: 'source.tar.gz' }, innerBuf, (err2) => { + if (err2) reject(err2); + outerPack.finalize(); + resolve(); + }); + }); + }); + const outerBuf = await outerBufPromise; + + const outputDir = join(tempDir, 'tar-slip-test'); + await mkdir(outputDir, { recursive: true }); + + try { + await unpackComputeBundle(outerBuf, outputDir); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('unsafe entry path'); + } + }); + }); +}); diff --git a/test/client/transports/connectApiTransport.test.ts b/test/client/transports/connectApiTransport.test.ts new file mode 100644 index 000000000..d10eeda12 --- /dev/null +++ b/test/client/transports/connectApiTransport.test.ts @@ -0,0 +1,200 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import sinon from 'sinon'; +import { Connection, SfProject } from '@salesforce/core'; +import { ConnectApiTransport, TransportPipeline, RegistryAccess, SourceComponent, TransportContext } from '../../../src'; + +const registryAccess = new RegistryAccess(); + +function createComputeComponent(fullName: string, appDir: string): SourceComponent { + const type = registryAccess.getTypeByName('PlatformComputeApp'); + return new SourceComponent({ name: fullName, type, content: appDir, xml: `${appDir}/${fullName}.compute-meta.xml` }); +} + +function createMockContext(overrides: Partial = {}): TransportContext { + const connection = { + version: '68.0', + request: sinon.stub(), + } as unknown as Connection; + + const project = { + getPath: () => '/mock/project', + } as unknown as SfProject; + + return { + components: [], + connection, + project, + orgId: '00Dxx0000000000', + ...overrides, + }; +} + +describe('ConnectApiTransport', () => { + const transport = new ConnectApiTransport(); + + describe('metadata', () => { + it('should have correct name and phase', () => { + expect(transport.name).to.equal('connectApi'); + expect(transport.phase).to.deep.equal({ + deploy: 'before-metadata', + retrieve: 'after-metadata', + }); + }); + + it('should describe itself correctly', () => { + const desc = transport.describe(); + expect(desc.label).to.equal('Heroku Compute (Connect API)'); + expect(desc.endpoint).to.equal('/connect/compute/source'); + }); + }); + + describe('handles', () => { + it('should handle PlatformComputeApp by type name', () => { + const component = createComputeComponent('MyApp', '/mock/app'); + expect(transport.handles(component)).to.be.true; + }); + + it('should handle components with connectApi transport strategy', () => { + const type = { + ...registryAccess.getTypeByName('ApexClass'), + strategies: { adapter: 'default' as const, transport: 'connectApi' as const }, + }; + const component = new SourceComponent({ name: 'Test', type }); + expect(transport.handles(component)).to.be.true; + }); + + it('should not handle standard types', () => { + const type = registryAccess.getTypeByName('ApexClass'); + const component = new SourceComponent({ name: 'MyClass', type }); + expect(transport.handles(component)).to.be.false; + }); + }); + + describe('API version validation', () => { + it('should throw when API version is below minimum', async () => { + const context = createMockContext({ + connection: { version: '67.0', request: sinon.stub() } as unknown as Connection, + components: [createComputeComponent('MyApp', '/mock/app')], + }); + + try { + await transport.deploy(context); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('API version 68.0'); + expect((err as Error).message).to.include('current: 67.0'); + } + }); + + it('should not throw when API version meets minimum', async () => { + const context = createMockContext({ + connection: { + version: '68.0', + request: sinon + .stub() + .resolves({ platformComputeId: 'id', buildId: 'b1', sourceBlobSize: 100, uploadedAt: 'now' }), + } as unknown as Connection, + components: [createComputeComponent('MyApp', '/mock/app')], + }); + + try { + await transport.deploy(context); + } catch (err) { + expect((err as Error).message).to.not.include('API version'); + } + }); + }); + + describe('deploy', () => { + it('should attempt to package and upload for each component', async () => { + const stub = sinon.stub(); + stub.resolves({ platformComputeId: 'pc1', buildId: 'build1', sourceBlobSize: 500, uploadedAt: '2026-01-01' }); + + const context = createMockContext({ + connection: { version: '68.0', request: stub } as unknown as Connection, + components: [createComputeComponent('MyApp', '/mock/app')], + }); + + // packageComputeBundle will fail because /mock/app doesn't exist on the filesystem + try { + await transport.deploy(context); + } catch { + // expected — no real filesystem + } + }); + }); + + describe('retrieve', () => { + it('should wrap download errors with SfError', async () => { + const stub = sinon.stub(); + stub.rejects(new Error('404 not found')); + + const context = createMockContext({ + connection: { version: '68.0', request: stub } as unknown as Connection, + components: [createComputeComponent('MyApp', '/mock/app')], + }); + + try { + await transport.retrieve(context); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Connect API download failed'); + expect((err as Error).message).to.include('404 not found'); + } + }); + }); + + describe('resolveAppDir', () => { + it('should prefer content over xml dirname', () => { + const component = createComputeComponent('MyApp', '/project/force-app/platformComputeApps/MyApp'); + expect(component.content).to.equal('/project/force-app/platformComputeApps/MyApp'); + }); + + it('should throw for component with no content or xml', () => { + const type = registryAccess.getTypeByName('PlatformComputeApp'); + const component = new SourceComponent({ name: 'NoPath', type }); + + const context = createMockContext({ + components: [component], + }); + + return transport.deploy(context).then( + () => expect.fail('should have thrown'), + (err: unknown) => { + expect((err as Error).message).to.include('no content or xml path'); + } + ); + }); + }); + + describe('withBuiltinTransports integration', () => { + it('should auto-register ConnectApiTransport in pipeline', () => { + const pipeline = TransportPipeline.withBuiltinTransports(); + const component = createComputeComponent('MyApp', '/mock/app'); + expect(pipeline.hasTransports([component])).to.be.true; + }); + + it('should not match standard types via builtin transports', () => { + const pipeline = TransportPipeline.withBuiltinTransports(); + const type = registryAccess.getTypeByName('ApexClass'); + const component = new SourceComponent({ name: 'MyClass', type }); + expect(pipeline.hasTransports([component])).to.be.false; + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index ab218ca1f..4929862a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1161,6 +1161,13 @@ resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz#bf2e02a3dbd4aecaf95942ecd99b7402e03fad5e" integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA== +"@types/tar-stream@^3.1.4": + version "3.1.4" + resolved "https://registry.yarnpkg.com/@types/tar-stream/-/tar-stream-3.1.4.tgz#7eca21004291f8d4803437fe20cc01b0744782a8" + integrity sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg== + dependencies: + "@types/node" "*" + "@types/unist@*": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" @@ -1555,6 +1562,11 @@ atomic-sleep@^1.0.0: resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== +b4a@^1.6.4, b4a@^1.8.1: + version "1.9.0" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.9.0.tgz#6336313b6e76b299c397afa68676ec6ebc788845" + integrity sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ== + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -1565,6 +1577,43 @@ balanced-match@^4.0.2: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== +bare-events@^2.5.4, bare-events@^2.7.0: + version "2.9.2" + resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.9.2.tgz#01d377b64c3c7167b28b52da7add3221aecf2d7f" + integrity sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA== + +bare-fs@^4.5.5: + version "4.8.1" + resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.8.1.tgz#1a946560b45844dc37120c00330eae7c8a1c44cf" + integrity sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg== + dependencies: + bare-events "^2.5.4" + bare-path "^3.0.0" + bare-stream "^2.6.4" + bare-url "^2.2.2" + fast-fifo "^1.3.2" + +bare-path@^3.0.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.1.2.tgz#67261bc74a9ba0105ba139b8638f218c09e8d82e" + integrity sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw== + +bare-stream@^2.6.4: + version "2.13.4" + resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.13.4.tgz#61d448a268d1efd992103aae6bd729332f278ac0" + integrity sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA== + dependencies: + b4a "^1.8.1" + streamx "^2.25.0" + teex "^1.0.1" + +bare-url@^2.2.2: + version "2.5.4" + resolved "https://registry.yarnpkg.com/bare-url/-/bare-url-2.5.4.tgz#10ab50f39b50335e2aa7672d5941043dbfc7749e" + integrity sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g== + dependencies: + bare-path "^3.0.0" + base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -2540,6 +2589,13 @@ event-target-shim@^5.0.0: resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== +events-universal@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/events-universal/-/events-universal-1.0.1.tgz#b56a84fd611b6610e0a2d0f09f80fdf931e2dfe6" + integrity sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw== + dependencies: + bare-events "^2.7.0" + events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" @@ -2590,6 +2646,11 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== +fast-fifo@^1.2.0, fast-fifo@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" + integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== + fast-glob@^3.2.11, fast-glob@^3.2.7, fast-glob@^3.3.2: version "3.3.3" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" @@ -2767,7 +2828,7 @@ foreground-child@^3.1.0, foreground-child@^3.3.0: cross-spawn "^7.0.6" signal-exit "^4.0.1" -form-data@^4.0.4, form-data@^4.0.5: +form-data@^4.0.4, form-data@^4.0.5, form-data@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== @@ -5173,6 +5234,15 @@ stable-hash-x@^0.2.0: resolved "https://registry.yarnpkg.com/stable-hash-x/-/stable-hash-x-0.2.0.tgz#dfd76bfa5d839a7470125c6a6b3c8b22061793e9" integrity sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ== +streamx@^2.12.5, streamx@^2.15.0, streamx@^2.25.0: + version "2.28.1" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.28.1.tgz#376cd42a089505a69bec0efdb5189aa45586141f" + integrity sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA== + dependencies: + events-universal "^1.0.0" + fast-fifo "^1.3.2" + text-decoder "^1.1.0" + "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -5304,6 +5374,23 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +tar-stream@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.2.1.tgz#952d72f7aba68ce5cb802ef2e0b19f1bd989140d" + integrity sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ== + dependencies: + b4a "^1.6.4" + bare-fs "^4.5.5" + fast-fifo "^1.2.0" + streamx "^2.15.0" + +teex@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/teex/-/teex-1.0.1.tgz#b8fa7245ef8e8effa8078281946c85ab780a0b12" + integrity sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg== + dependencies: + streamx "^2.12.5" + test-exclude@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" @@ -5313,6 +5400,13 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +text-decoder@^1.1.0: + version "1.2.7" + resolved "https://registry.yarnpkg.com/text-decoder/-/text-decoder-1.2.7.tgz#5d073a9a74b9c0a9d28dfadcab96b604af57d8ba" + integrity sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ== + dependencies: + b4a "^1.6.4" + text-extensions@^1.0.0: version "1.9.0" resolved "https://registry.yarnpkg.com/text-extensions/-/text-extensions-1.9.0.tgz#1853e45fee39c945ce6f6c36b2d659b5aabc2a26" From ac55ee164886e6acdf6d9dd47116124228fd1185 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Wed, 30 Sep 2026 10:56:49 -0600 Subject: [PATCH 10/21] refactor: lazy transport pipeline discovery and improved test coverage ComponentSet now lazily auto-discovers builtin transports only when transport-eligible types (e.g. PlatformComputeApp) are present, avoiding pipeline/logger overhead for the 99% case with no transport components. Replaces the eager withBuiltinTransports() fallback on every deploy/retrieve. Also fixes the no-op deploy test (now stubs packageComputeBundle and asserts on Connect API calls) and adds ComponentSet auto-discovery tests. --- src/collections/componentSet.ts | 33 ++++++-- .../transports/connectApiTransport.test.ts | 80 +++++++++++++++++-- .../transports/transportIntegration.test.ts | 31 +++++++ 3 files changed, 131 insertions(+), 13 deletions(-) diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index 4559439b2..3d846e89a 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -91,7 +91,8 @@ export class ComponentSet extends LazyCollection { public fullName?: string; public forceIgnoredPaths?: Set; public botVersionFilters?: Array<{ botName: string; versionFilter: 'all' | 'highest' | number }>; - public transportPipeline?: TransportPipeline; + private cachedTransportPipeline?: TransportPipeline; + private transportPipelineResolved = false; private logger: Logger; private readonly registry: RegistryAccess; // all components stored here, regardless of what manifest they belong to @@ -149,6 +150,21 @@ export class ComponentSet extends LazyCollection { return this.destructiveComponents[DestructiveChangesType.POST]; } + public get transportPipeline(): TransportPipeline | undefined { + if (!this.cachedTransportPipeline && !this.transportPipelineResolved) { + if (this.hasTransportEligibleTypes()) { + this.cachedTransportPipeline = TransportPipeline.withBuiltinTransports(); + } + this.transportPipelineResolved = true; + } + return this.cachedTransportPipeline; + } + + public set transportPipeline(pipeline: TransportPipeline | undefined) { + this.cachedTransportPipeline = pipeline; + this.transportPipelineResolved = true; + } + /** * Resolve metadata components from a file or directory path in a file system. * @@ -400,8 +416,8 @@ export class ComponentSet extends LazyCollection { ); } - const pipeline = this.transportPipeline ?? TransportPipeline.withBuiltinTransports(); - if (pipeline.hasTransports(toDeploy)) { + const pipeline = this.transportPipeline; + if (pipeline?.hasTransports(toDeploy)) { return this.deployWithPipeline(options, toDeploy, pipeline); } @@ -444,9 +460,9 @@ export class ComponentSet extends LazyCollection { ); } - const pipeline = this.transportPipeline ?? TransportPipeline.withBuiltinTransports(); + const pipeline = this.transportPipeline; const toRetrieve = Array.from(this.getSourceComponents()); - if (pipeline.hasTransports(toRetrieve)) { + if (pipeline?.hasTransports(toRetrieve)) { return this.retrieveWithPipeline(operationOptions, toRetrieve, pipeline); } @@ -818,6 +834,13 @@ export class ComponentSet extends LazyCollection { return mdapiRetrieve; } + private hasTransportEligibleTypes(): boolean { + for (const comp of this.getSourceComponents()) { + if (comp.type.strategies?.transport) return true; + } + return false; + } + /** * Returns an API version to use as the value of the `version` field * in a manifest (package.xml) for MDAPI calls in the following order diff --git a/test/client/transports/connectApiTransport.test.ts b/test/client/transports/connectApiTransport.test.ts index d10eeda12..9fe82aa76 100644 --- a/test/client/transports/connectApiTransport.test.ts +++ b/test/client/transports/connectApiTransport.test.ts @@ -17,7 +17,14 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { Connection, SfProject } from '@salesforce/core'; -import { ConnectApiTransport, TransportPipeline, RegistryAccess, SourceComponent, TransportContext } from '../../../src'; +import { + ConnectApiTransport, + TransportPipeline, + RegistryAccess, + SourceComponent, + TransportContext, +} from '../../../src'; +import * as computeSourceBundle from '../../../src/client/transports/computeSourceBundle'; const registryAccess = new RegistryAccess(); @@ -122,20 +129,77 @@ describe('ConnectApiTransport', () => { }); describe('deploy', () => { - it('should attempt to package and upload for each component', async () => { - const stub = sinon.stub(); - stub.resolves({ platformComputeId: 'pc1', buildId: 'build1', sourceBlobSize: 500, uploadedAt: '2026-01-01' }); + let packageStub: sinon.SinonStub; + + beforeEach(() => { + packageStub = sinon.stub(computeSourceBundle, 'packageComputeBundle'); + }); + + afterEach(() => { + packageStub.restore(); + }); + + it('should package and upload each component via Connect API', async () => { + packageStub.resolves({ buffer: Buffer.from('fake-bundle'), fileCount: 3 }); + + const requestStub = sinon.stub(); + requestStub.resolves({ + platformComputeId: 'pc1', + buildId: 'build1', + sourceBlobSize: 500, + uploadedAt: '2026-01-01', + }); const context = createMockContext({ - connection: { version: '68.0', request: stub } as unknown as Connection, + connection: { version: '68.0', request: requestStub } as unknown as Connection, + components: [createComputeComponent('MyApp', '/mock/app')], + }); + + const result = await transport.deploy(context); + + expect(packageStub.calledOnce).to.be.true; + expect(packageStub.firstCall.args[0]).to.equal('/mock/app'); + expect(requestStub.calledOnce).to.be.true; + expect(requestStub.firstCall.args[0]).to.have.property('method', 'POST'); + expect(result.fileResponses).to.have.lengthOf(1); + expect(result.fileResponses[0]).to.include({ fullName: 'MyApp', type: 'PlatformComputeApp' }); + }); + + it('should deploy multiple components in sequence', async () => { + packageStub.resolves({ buffer: Buffer.from('fake'), fileCount: 1 }); + + const requestStub = sinon.stub(); + requestStub.resolves({ platformComputeId: 'pc1', buildId: 'b1', sourceBlobSize: 100, uploadedAt: 'now' }); + + const context = createMockContext({ + connection: { version: '68.0', request: requestStub } as unknown as Connection, + components: [createComputeComponent('App1', '/mock/app1'), createComputeComponent('App2', '/mock/app2')], + }); + + const result = await transport.deploy(context); + + expect(packageStub.calledTwice).to.be.true; + expect(requestStub.calledTwice).to.be.true; + expect(result.fileResponses).to.have.lengthOf(2); + }); + + it('should wrap upload errors with SfError', async () => { + packageStub.resolves({ buffer: Buffer.from('fake'), fileCount: 1 }); + + const requestStub = sinon.stub(); + requestStub.rejects(new Error('500 server error')); + + const context = createMockContext({ + connection: { version: '68.0', request: requestStub } as unknown as Connection, components: [createComputeComponent('MyApp', '/mock/app')], }); - // packageComputeBundle will fail because /mock/app doesn't exist on the filesystem try { await transport.deploy(context); - } catch { - // expected — no real filesystem + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Connect API upload failed'); + expect((err as Error).message).to.include('500 server error'); } }); }); diff --git a/test/client/transports/transportIntegration.test.ts b/test/client/transports/transportIntegration.test.ts index 7073ebc69..72f3b6dd7 100644 --- a/test/client/transports/transportIntegration.test.ts +++ b/test/client/transports/transportIntegration.test.ts @@ -239,6 +239,37 @@ describe('Transport integration', () => { }); }); + describe('ComponentSet auto-discovery', () => { + it('should auto-discover builtin transports for PlatformComputeApp', () => { + const computeType = registryAccess.getTypeByName('PlatformComputeApp'); + const component = new SourceComponent({ name: 'MyApp', type: computeType, content: '/mock/app' }); + const cs = new ComponentSet([component]); + + expect(cs.transportPipeline).to.not.be.undefined; + expect(cs.transportPipeline!.hasTransports([component])).to.be.true; + }); + + it('should not create a pipeline when no transport-eligible types exist', () => { + const apexType = registryAccess.getTypeByName('ApexClass'); + const component = new SourceComponent({ name: 'MyClass', type: apexType }); + const cs = new ComponentSet([component]); + + expect(cs.transportPipeline).to.be.undefined; + }); + + it('should allow explicit pipeline to override auto-discovery', () => { + const computeType = registryAccess.getTypeByName('PlatformComputeApp'); + const component = new SourceComponent({ name: 'MyApp', type: computeType, content: '/mock/app' }); + const cs = new ComponentSet([component]); + + const customPipeline = new TransportPipeline(); + cs.transportPipeline = customPipeline; + + expect(cs.transportPipeline).to.equal(customPipeline); + expect(cs.transportPipeline.hasTransports([component])).to.be.false; + }); + }); + describe('RetrieveResult deduplication', () => { it('should deduplicate identical transport responses', () => { const result = new RetrieveResult(createRetrieveStatus(), new ComponentSet()); From 5df5fec511cedd234a89f2c048a3de4a78dfebed Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Wed, 30 Sep 2026 11:02:18 -0600 Subject: [PATCH 11/21] fix: invalidate transport pipeline cache when transport-eligible component added Prevents stale cache when components are added after first getter access. Also fixes the weak API version validation test. --- src/collections/componentSet.ts | 4 ++++ .../transports/connectApiTransport.test.ts | 22 +++++++++---------- .../transports/transportIntegration.test.ts | 14 ++++++++++++ 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index 3d846e89a..badfcf1e5 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -623,6 +623,10 @@ export class ComponentSet extends LazyCollection { } this.manifestComponents.get(key)?.set(srcKey, component); } + + if (component.type.strategies?.transport && this.transportPipelineResolved && !this.cachedTransportPipeline) { + this.transportPipelineResolved = false; + } } /** diff --git a/test/client/transports/connectApiTransport.test.ts b/test/client/transports/connectApiTransport.test.ts index 9fe82aa76..477ba20c7 100644 --- a/test/client/transports/connectApiTransport.test.ts +++ b/test/client/transports/connectApiTransport.test.ts @@ -110,21 +110,21 @@ describe('ConnectApiTransport', () => { }); it('should not throw when API version meets minimum', async () => { + const stub = sinon.stub(computeSourceBundle, 'packageComputeBundle'); + stub.resolves({ buffer: Buffer.from('fake'), fileCount: 1 }); + + const requestStub = sinon.stub(); + requestStub.resolves({ platformComputeId: 'id', buildId: 'b1', sourceBlobSize: 100, uploadedAt: 'now' }); + const context = createMockContext({ - connection: { - version: '68.0', - request: sinon - .stub() - .resolves({ platformComputeId: 'id', buildId: 'b1', sourceBlobSize: 100, uploadedAt: 'now' }), - } as unknown as Connection, + connection: { version: '68.0', request: requestStub } as unknown as Connection, components: [createComputeComponent('MyApp', '/mock/app')], }); - try { - await transport.deploy(context); - } catch (err) { - expect((err as Error).message).to.not.include('API version'); - } + const result = await transport.deploy(context); + expect(result.fileResponses).to.have.lengthOf(1); + + stub.restore(); }); }); diff --git a/test/client/transports/transportIntegration.test.ts b/test/client/transports/transportIntegration.test.ts index 72f3b6dd7..606a36268 100644 --- a/test/client/transports/transportIntegration.test.ts +++ b/test/client/transports/transportIntegration.test.ts @@ -257,6 +257,20 @@ describe('Transport integration', () => { expect(cs.transportPipeline).to.be.undefined; }); + it('should re-evaluate when transport-eligible component is added after first access', () => { + const apexType = registryAccess.getTypeByName('ApexClass'); + const cs = new ComponentSet([new SourceComponent({ name: 'MyClass', type: apexType })]); + + expect(cs.transportPipeline).to.be.undefined; + + const computeType = registryAccess.getTypeByName('PlatformComputeApp'); + const computeComp = new SourceComponent({ name: 'MyApp', type: computeType, content: '/mock/app' }); + cs.add(computeComp); + + expect(cs.transportPipeline).to.not.be.undefined; + expect(cs.transportPipeline!.hasTransports([computeComp])).to.be.true; + }); + it('should allow explicit pipeline to override auto-discovery', () => { const computeType = registryAccess.getTypeByName('PlatformComputeApp'); const component = new SourceComponent({ name: 'MyApp', type: computeType, content: '/mock/app' }); From a551da04bde9af4b93af9e154dd11a4c6ffd5c20 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 6 Oct 2026 12:00:58 -0600 Subject: [PATCH 12/21] refactor: rename ConnectApiTransport to HerokuComputeTransport Address review feedback: rename to be honest about 1:1 mapping with PlatformComputeApp. Add assertComputeComponent type guard for tighter input validation. Replace cache in ComponentSet with fresh derivation. Collect per-component errors without short-circuiting, attach partial results on SfError.data. Constructor injection for TransportPipeline. --- src/client/index.ts | 2 +- src/client/transports/connectApiTransport.ts | 163 --------------- .../transports/herokuComputeTransport.ts | 196 ++++++++++++++++++ src/client/transports/index.ts | 2 +- src/client/transports/transportPipeline.ts | 11 +- src/collections/componentSet.ts | 21 +- src/index.ts | 2 +- src/registry/metadataRegistry.json | 2 +- src/registry/types.ts | 2 +- ...test.ts => herokuComputeTransport.test.ts} | 79 +++++-- 10 files changed, 273 insertions(+), 207 deletions(-) delete mode 100644 src/client/transports/connectApiTransport.ts create mode 100644 src/client/transports/herokuComputeTransport.ts rename test/client/transports/{connectApiTransport.test.ts => herokuComputeTransport.test.ts} (72%) diff --git a/src/client/index.ts b/src/client/index.ts index 7a6ca7e10..98cbdaeff 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -36,7 +36,7 @@ export { TransportPlan, TransportPlanPhase, TransportPipelineResult, - ConnectApiTransport, + HerokuComputeTransport, packageComputeBundle, unpackComputeBundle, PackagedBundle, diff --git a/src/client/transports/connectApiTransport.ts b/src/client/transports/connectApiTransport.ts deleted file mode 100644 index 27c472e9e..000000000 --- a/src/client/transports/connectApiTransport.ts +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright 2026, Salesforce, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { dirname } from 'node:path'; -import FormData from 'form-data'; -import { Logger, SfError } from '@salesforce/core'; -import type { Connection } from '@salesforce/core'; -import { SourceComponent } from '../../resolve/sourceComponent'; -import { ComponentStatus, FileResponse } from '../types'; -import { TransportContext, TransportDescription, TransportProvider, TransportResult } from './types'; -import { packageComputeBundle, unpackComputeBundle } from './computeSourceBundle'; - -type ComputeSourceUploadResponse = { - platformComputeId: string; - buildId: string; - sourceBlobSize: number; - uploadedAt: string; -}; - -const MIN_API_VERSION = '68.0'; -const ENDPOINT = '/connect/compute/source'; - -function resolveAppDir(component: SourceComponent): string { - if (component.content) { - return component.content; - } - if (component.xml) { - return dirname(component.xml); - } - throw new SfError(`PlatformComputeApp '${component.fullName}' has no content or xml path`, 'MissingComponentPath'); -} - -function validateApiVersion(connection: Connection): void { - const version = connection.version; - if (!version || parseFloat(version) < parseFloat(MIN_API_VERSION)) { - throw new SfError( - `PlatformComputeApp requires API version ${MIN_API_VERSION} or later (current: ${version ?? 'unknown'})`, - 'InsufficientApiVersion' - ); - } -} - -async function uploadSource( - connection: Connection, - name: string, - bundle: Buffer -): Promise { - const form = new FormData(); - form.append('name', name); - form.append('bundle', bundle, { - filename: 'deployment-bundle.tar.gz', - contentType: 'application/gzip', - }); - - try { - return await connection.request({ - method: 'POST', - url: ENDPOINT, - body: form, - headers: form.getHeaders(), - }); - } catch (err) { - throw new SfError(`Connect API upload failed for '${name}': ${(err as Error).message}`, 'ConnectApiUploadError'); - } -} - -// jsforce decodes to UTF-8 by default — lossy for gzip bytes. -// encoding: 'binary' (latin1) preserves byte values 1:1. -async function downloadSource(connection: Connection, appIdOrName: string): Promise { - try { - const body = await connection.request( - { method: 'GET', url: `/connect/compute/${encodeURIComponent(appIdOrName)}/source` }, - { encoding: 'binary' } - ); - return Buffer.from(body, 'binary'); - } catch (err) { - throw new SfError( - `Connect API download failed for '${appIdOrName}': ${(err as Error).message}`, - 'ConnectApiDownloadError' - ); - } -} - -export class ConnectApiTransport implements TransportProvider { - public readonly name = 'connectApi'; - public readonly phase = { deploy: 'before-metadata' as const, retrieve: 'after-metadata' as const }; - private readonly logger = Logger.childFromRoot('ConnectApiTransport'); - - // eslint-disable-next-line class-methods-use-this - public describe(): TransportDescription { - return { label: 'Heroku Compute (Connect API)', endpoint: ENDPOINT }; - } - - // eslint-disable-next-line class-methods-use-this - public handles(component: SourceComponent): boolean { - return component.type.strategies?.transport === 'connectApi' || component.type.name === 'PlatformComputeApp'; - } - - public async deploy(context: TransportContext): Promise { - validateApiVersion(context.connection); - const fileResponses: FileResponse[] = []; - - for (const component of context.components) { - const appDir = resolveAppDir(component); - this.logger.debug('deploying compute source for %s from %s', component.fullName, appDir); - - // eslint-disable-next-line no-await-in-loop - const bundle = await packageComputeBundle(appDir, component.fullName); - this.logger.debug('packaged %s: %d bytes, %d files', component.fullName, bundle.buffer.length, bundle.fileCount); - - // eslint-disable-next-line no-await-in-loop - const result = await uploadSource(context.connection, component.fullName, bundle.buffer); - this.logger.debug('uploaded %s: buildId=%s', component.fullName, result.buildId); - - fileResponses.push({ - fullName: component.fullName, - type: component.type.name, - state: ComponentStatus.Changed, - filePath: appDir, - }); - } - - return { fileResponses }; - } - - public async retrieve(context: TransportContext): Promise { - validateApiVersion(context.connection); - const fileResponses: FileResponse[] = []; - - for (const component of context.components) { - const appDir = resolveAppDir(component); - this.logger.debug('retrieving compute source for %s into %s', component.fullName, appDir); - - // eslint-disable-next-line no-await-in-loop - const buffer = await downloadSource(context.connection, component.fullName); - // eslint-disable-next-line no-await-in-loop - const unpacked = await unpackComputeBundle(buffer, appDir); - this.logger.debug('unpacked %s: %d files into %s', component.fullName, unpacked.fileCount, appDir); - - fileResponses.push({ - fullName: component.fullName, - type: component.type.name, - state: ComponentStatus.Changed, - filePath: appDir, - }); - } - - return { fileResponses }; - } -} diff --git a/src/client/transports/herokuComputeTransport.ts b/src/client/transports/herokuComputeTransport.ts new file mode 100644 index 000000000..4f19f983a --- /dev/null +++ b/src/client/transports/herokuComputeTransport.ts @@ -0,0 +1,196 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import FormData from 'form-data'; +import { Logger, SfError } from '@salesforce/core'; +import type { Connection } from '@salesforce/core'; +import { SourceComponent } from '../../resolve/sourceComponent'; +import { ComponentStatus, FileResponse } from '../types'; +import { TransportContext, TransportDescription, TransportProvider, TransportResult } from './types'; +import { packageComputeBundle, unpackComputeBundle } from './computeSourceBundle'; + +type ComputeSourceUploadResponse = { + platformComputeId: string; + buildId: string; + sourceBlobSize: number; + uploadedAt: string; +}; + +const MIN_API_VERSION = '68.0'; +const ENDPOINT = '/connect/compute/source'; + +function validateApiVersion(connection: Connection): void { + const version = connection.version; + if (!version || parseFloat(version) < parseFloat(MIN_API_VERSION)) { + throw new SfError( + `PlatformComputeApp requires API version ${MIN_API_VERSION} or later (current: ${version ?? 'unknown'})`, + 'InsufficientApiVersion' + ); + } +} + +async function uploadSource( + connection: Connection, + name: string, + bundle: Buffer +): Promise { + const form = new FormData(); + form.append('name', name); + form.append('bundle', bundle, { + filename: 'deployment-bundle.tar.gz', + contentType: 'application/gzip', + }); + + return connection.request({ + method: 'POST', + url: ENDPOINT, + body: form, + headers: form.getHeaders(), + }); +} + +// jsforce decodes to UTF-8 by default — lossy for gzip bytes. +// encoding: 'binary' (latin1) preserves byte values 1:1. +async function downloadSource(connection: Connection, appIdOrName: string): Promise { + const body = await connection.request( + { method: 'GET', url: `/connect/compute/${encodeURIComponent(appIdOrName)}/source` }, + { encoding: 'binary' } + ); + return Buffer.from(body, 'binary'); +} + +export class HerokuComputeTransport implements TransportProvider { + public readonly name = 'herokuCompute'; + public readonly phase = { deploy: 'before-metadata' as const, retrieve: 'after-metadata' as const }; + private readonly logger = Logger.childFromRoot('HerokuComputeTransport'); + + // eslint-disable-next-line class-methods-use-this + public describe(): TransportDescription { + return { label: 'Heroku Compute', endpoint: ENDPOINT }; + } + + // eslint-disable-next-line class-methods-use-this + public handles(component: SourceComponent): boolean { + return component.type.strategies?.transport === 'herokuCompute' || component.type.name === 'PlatformComputeApp'; + } + + public async deploy(context: TransportContext): Promise { + validateApiVersion(context.connection); + const fileResponses: FileResponse[] = []; + + for (const component of context.components) { + try { + this.assertComputeComponent(component); + this.logger.debug('deploying compute source for %s from %s', component.fullName, component.content); + + // eslint-disable-next-line no-await-in-loop + const bundle = await packageComputeBundle(component.content, component.fullName); + this.logger.debug( + 'packaged %s: %d bytes, %d files', + component.fullName, + bundle.buffer.length, + bundle.fileCount + ); + + // eslint-disable-next-line no-await-in-loop + const result = await uploadSource(context.connection, component.fullName, bundle.buffer); + this.logger.debug('uploaded %s: buildId=%s', component.fullName, result.buildId); + + fileResponses.push({ + fullName: component.fullName, + type: component.type.name, + state: ComponentStatus.Changed, + filePath: component.content, + }); + } catch (err) { + fileResponses.push({ + fullName: component.fullName, + type: component.type.name, + state: ComponentStatus.Failed, + filePath: component.content ?? component.xml ?? '', + error: `Heroku Compute upload failed for '${component.fullName}': ${(err as Error).message}`, + problemType: 'Error', + }); + } + } + + return this.checkForFailures(fileResponses, 'upload'); + } + + public async retrieve(context: TransportContext): Promise { + validateApiVersion(context.connection); + const fileResponses: FileResponse[] = []; + + for (const component of context.components) { + try { + this.assertComputeComponent(component); + this.logger.debug('retrieving compute source for %s into %s', component.fullName, component.content); + + // eslint-disable-next-line no-await-in-loop + const buffer = await downloadSource(context.connection, component.fullName); + // eslint-disable-next-line no-await-in-loop + const unpacked = await unpackComputeBundle(buffer, component.content); + this.logger.debug('unpacked %s: %d files into %s', component.fullName, unpacked.fileCount, component.content); + + fileResponses.push({ + fullName: component.fullName, + type: component.type.name, + state: ComponentStatus.Changed, + filePath: component.content, + }); + } catch (err) { + fileResponses.push({ + fullName: component.fullName, + type: component.type.name, + state: ComponentStatus.Failed, + filePath: component.content ?? component.xml ?? '', + error: `Heroku Compute download failed for '${component.fullName}': ${(err as Error).message}`, + problemType: 'Error', + }); + } + } + + return this.checkForFailures(fileResponses, 'download'); + } + + // eslint-disable-next-line class-methods-use-this + private assertComputeComponent( + component: SourceComponent + ): asserts component is SourceComponent & { content: string } { + if (!component.content) { + throw new SfError( + `HerokuComputeTransport requires a content path for '${component.fullName}'`, + 'MissingComponentContent' + ); + } + } + + // eslint-disable-next-line class-methods-use-this + private checkForFailures(fileResponses: FileResponse[], operation: string): TransportResult { + const failures = fileResponses.filter((r) => r.state === ComponentStatus.Failed); + if (failures.length > 0) { + const names = failures.map((f) => f.fullName).join(', '); + const error = new SfError( + `Heroku Compute ${operation} failed for: ${names}. ${failures.length} of ${fileResponses.length} component(s) failed.`, + `HerokuCompute${operation.charAt(0).toUpperCase() + operation.slice(1)}Error` + ); + error.data = fileResponses; + throw error; + } + + return { fileResponses }; + } +} diff --git a/src/client/transports/index.ts b/src/client/transports/index.ts index 35f088e2d..a22d26d17 100644 --- a/src/client/transports/index.ts +++ b/src/client/transports/index.ts @@ -32,5 +32,5 @@ export { TransportPipelineResult, } from './transportPipeline'; export { TransportCoordinator } from './transportCoordinator'; -export { ConnectApiTransport } from './connectApiTransport'; +export { HerokuComputeTransport } from './herokuComputeTransport'; export { packageComputeBundle, unpackComputeBundle, PackagedBundle, UnpackedBundle } from './computeSourceBundle'; diff --git a/src/client/transports/transportPipeline.ts b/src/client/transports/transportPipeline.ts index a5d931a90..228a4c33b 100644 --- a/src/client/transports/transportPipeline.ts +++ b/src/client/transports/transportPipeline.ts @@ -18,7 +18,7 @@ import { Logger, SfError } from '@salesforce/core'; import { SourceComponent } from '../../resolve/sourceComponent'; import { FileResponse } from '../types'; import { AsyncTransportHandle, TransportContext, TransportPhase, TransportProvider, TransportResult } from './types'; -import { ConnectApiTransport } from './connectApiTransport'; +import { HerokuComputeTransport } from './herokuComputeTransport'; export type TransportGroup = { transport: TransportProvider; @@ -50,14 +50,15 @@ export class TransportPipeline { private readonly transports: TransportProvider[] = []; private readonly logger: Logger; - public constructor() { + public constructor(transports?: TransportProvider[]) { this.logger = Logger.childFromRoot('TransportPipeline'); + if (transports) { + this.transports.push(...transports); + } } public static withBuiltinTransports(): TransportPipeline { - const pipeline = new TransportPipeline(); - pipeline.registerTransport(new ConnectApiTransport()); - return pipeline; + return new TransportPipeline([new HerokuComputeTransport()]); } public registerTransport(transport: TransportProvider): void { diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index badfcf1e5..677bac1a3 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -91,8 +91,8 @@ export class ComponentSet extends LazyCollection { public fullName?: string; public forceIgnoredPaths?: Set; public botVersionFilters?: Array<{ botName: string; versionFilter: 'all' | 'highest' | number }>; - private cachedTransportPipeline?: TransportPipeline; - private transportPipelineResolved = false; + private manualTransportPipeline?: TransportPipeline; + private hasManualPipeline = false; private logger: Logger; private readonly registry: RegistryAccess; // all components stored here, regardless of what manifest they belong to @@ -151,18 +151,13 @@ export class ComponentSet extends LazyCollection { } public get transportPipeline(): TransportPipeline | undefined { - if (!this.cachedTransportPipeline && !this.transportPipelineResolved) { - if (this.hasTransportEligibleTypes()) { - this.cachedTransportPipeline = TransportPipeline.withBuiltinTransports(); - } - this.transportPipelineResolved = true; - } - return this.cachedTransportPipeline; + if (this.hasManualPipeline) return this.manualTransportPipeline; + return this.hasTransportEligibleTypes() ? TransportPipeline.withBuiltinTransports() : undefined; } public set transportPipeline(pipeline: TransportPipeline | undefined) { - this.cachedTransportPipeline = pipeline; - this.transportPipelineResolved = true; + this.manualTransportPipeline = pipeline; + this.hasManualPipeline = true; } /** @@ -623,10 +618,6 @@ export class ComponentSet extends LazyCollection { } this.manifestComponents.get(key)?.set(srcKey, component); } - - if (component.type.strategies?.transport && this.transportPipelineResolved && !this.cachedTransportPipeline) { - this.transportPipelineResolved = false; - } } /** diff --git a/src/index.ts b/src/index.ts index 038631031..98b55bea6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,7 +37,7 @@ export { TransportPlan, TransportPlanPhase, TransportPipelineResult, - ConnectApiTransport, + HerokuComputeTransport, packageComputeBundle, unpackComputeBundle, PackagedBundle, diff --git a/src/registry/metadataRegistry.json b/src/registry/metadataRegistry.json index 35e8bd50b..55795de95 100644 --- a/src/registry/metadataRegistry.json +++ b/src/registry/metadataRegistry.json @@ -5434,7 +5434,7 @@ "name": "PlatformComputeApp", "strategies": { "adapter": "bundle", - "transport": "connectApi" + "transport": "herokuCompute" }, "strictDirectoryName": true, "suffix": "compute" diff --git a/src/registry/types.ts b/src/registry/types.ts index b95922ca7..773f9f8ea 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -173,7 +173,7 @@ export type MetadataType = { | 'uiBundle'; decomposition?: 'topLevel' | 'folderPerType'; recomposition?: 'startEmpty'; - transport?: 'connectApi' | 'presignedUrl' | 'datakitApi'; + transport?: 'herokuCompute' | 'presignedUrl' | 'datakitApi'; }; }; diff --git a/test/client/transports/connectApiTransport.test.ts b/test/client/transports/herokuComputeTransport.test.ts similarity index 72% rename from test/client/transports/connectApiTransport.test.ts rename to test/client/transports/herokuComputeTransport.test.ts index 477ba20c7..57edda1c7 100644 --- a/test/client/transports/connectApiTransport.test.ts +++ b/test/client/transports/herokuComputeTransport.test.ts @@ -16,9 +16,10 @@ import { expect } from 'chai'; import sinon from 'sinon'; -import { Connection, SfProject } from '@salesforce/core'; +import { Connection, SfError, SfProject } from '@salesforce/core'; import { - ConnectApiTransport, + FileResponse, + HerokuComputeTransport, TransportPipeline, RegistryAccess, SourceComponent, @@ -52,12 +53,12 @@ function createMockContext(overrides: Partial = {}): Transport }; } -describe('ConnectApiTransport', () => { - const transport = new ConnectApiTransport(); +describe('HerokuComputeTransport', () => { + const transport = new HerokuComputeTransport(); describe('metadata', () => { it('should have correct name and phase', () => { - expect(transport.name).to.equal('connectApi'); + expect(transport.name).to.equal('herokuCompute'); expect(transport.phase).to.deep.equal({ deploy: 'before-metadata', retrieve: 'after-metadata', @@ -66,7 +67,7 @@ describe('ConnectApiTransport', () => { it('should describe itself correctly', () => { const desc = transport.describe(); - expect(desc.label).to.equal('Heroku Compute (Connect API)'); + expect(desc.label).to.equal('Heroku Compute'); expect(desc.endpoint).to.equal('/connect/compute/source'); }); }); @@ -77,10 +78,10 @@ describe('ConnectApiTransport', () => { expect(transport.handles(component)).to.be.true; }); - it('should handle components with connectApi transport strategy', () => { + it('should handle components with herokuCompute transport strategy', () => { const type = { ...registryAccess.getTypeByName('ApexClass'), - strategies: { adapter: 'default' as const, transport: 'connectApi' as const }, + strategies: { adapter: 'default' as const, transport: 'herokuCompute' as const }, }; const component = new SourceComponent({ name: 'Test', type }); expect(transport.handles(component)).to.be.true; @@ -183,7 +184,37 @@ describe('ConnectApiTransport', () => { expect(result.fileResponses).to.have.lengthOf(2); }); - it('should wrap upload errors with SfError', async () => { + it('should process all components and throw aggregate error when one fails', async () => { + packageStub.resolves({ buffer: Buffer.from('fake'), fileCount: 1 }); + + const requestStub = sinon.stub(); + requestStub + .onFirstCall() + .resolves({ platformComputeId: 'pc1', buildId: 'b1', sourceBlobSize: 100, uploadedAt: 'now' }); + requestStub.onSecondCall().rejects(new Error('500 server error')); + + const context = createMockContext({ + connection: { version: '68.0', request: requestStub } as unknown as Connection, + components: [createComputeComponent('App1', '/mock/app1'), createComputeComponent('App2', '/mock/app2')], + }); + + try { + await transport.deploy(context); + expect.fail('should have thrown'); + } catch (err) { + const sfErr = err as SfError; + expect(sfErr.message).to.include('Heroku Compute upload failed'); + expect(sfErr.message).to.include('App2'); + expect(sfErr.message).to.include('1 of 2'); + // partial results attached to error.data + const responses = sfErr.data as FileResponse[]; + expect(responses).to.have.lengthOf(2); + expect(responses[0]).to.include({ fullName: 'App1', state: 'Changed' }); + expect(responses[1]).to.include({ fullName: 'App2', state: 'Failed' }); + } + }); + + it('should throw with error details when single component fails', async () => { packageStub.resolves({ buffer: Buffer.from('fake'), fileCount: 1 }); const requestStub = sinon.stub(); @@ -198,14 +229,14 @@ describe('ConnectApiTransport', () => { await transport.deploy(context); expect.fail('should have thrown'); } catch (err) { - expect((err as Error).message).to.include('Connect API upload failed'); - expect((err as Error).message).to.include('500 server error'); + expect((err as Error).message).to.include('Heroku Compute upload failed'); + expect((err as Error).message).to.include('MyApp'); } }); }); describe('retrieve', () => { - it('should wrap download errors with SfError', async () => { + it('should throw with error details on download failure', async () => { const stub = sinon.stub(); stub.rejects(new Error('404 not found')); @@ -218,19 +249,23 @@ describe('ConnectApiTransport', () => { await transport.retrieve(context); expect.fail('should have thrown'); } catch (err) { - expect((err as Error).message).to.include('Connect API download failed'); - expect((err as Error).message).to.include('404 not found'); + const sfErr = err as SfError; + expect(sfErr.message).to.include('Heroku Compute download failed'); + expect(sfErr.message).to.include('MyApp'); + const responses = sfErr.data as FileResponse[]; + expect(responses).to.have.lengthOf(1); + expect(responses[0]).to.include({ fullName: 'MyApp', state: 'Failed' }); } }); }); - describe('resolveAppDir', () => { - it('should prefer content over xml dirname', () => { + describe('component validation', () => { + it('should accept components with a content path', () => { const component = createComputeComponent('MyApp', '/project/force-app/platformComputeApps/MyApp'); expect(component.content).to.equal('/project/force-app/platformComputeApps/MyApp'); }); - it('should throw for component with no content or xml', () => { + it('should collect failure for component with no content path', () => { const type = registryAccess.getTypeByName('PlatformComputeApp'); const component = new SourceComponent({ name: 'NoPath', type }); @@ -241,14 +276,20 @@ describe('ConnectApiTransport', () => { return transport.deploy(context).then( () => expect.fail('should have thrown'), (err: unknown) => { - expect((err as Error).message).to.include('no content or xml path'); + const sfErr = err as SfError; + expect(sfErr.message).to.include('Heroku Compute upload failed'); + expect(sfErr.message).to.include('NoPath'); + const responses = sfErr.data as FileResponse[]; + expect(responses).to.have.lengthOf(1); + expect(responses[0].state).to.equal('Failed'); + expect((responses[0] as { error: string }).error).to.include('requires a content path'); } ); }); }); describe('withBuiltinTransports integration', () => { - it('should auto-register ConnectApiTransport in pipeline', () => { + it('should auto-register HerokuComputeTransport in pipeline', () => { const pipeline = TransportPipeline.withBuiltinTransports(); const component = createComputeComponent('MyApp', '/mock/app'); expect(pipeline.hasTransports([component])).to.be.true; From 8284d7d749c14b22b7ad88878d40ae7cf68e1362 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Tue, 6 Oct 2026 12:12:59 -0600 Subject: [PATCH 13/21] refactor: remove registerTransport, use constructor injection in tests New transports are added in SDR directly via constructor, not registered at runtime by external plugins. Updated all test files to pass transports via constructor instead of registerTransport(). --- src/client/transports/transportPipeline.ts | 4 -- .../transports/transportIntegration.test.ts | 26 ++++----- .../transports/transportPipeline.test.ts | 57 ++++++++----------- 3 files changed, 35 insertions(+), 52 deletions(-) diff --git a/src/client/transports/transportPipeline.ts b/src/client/transports/transportPipeline.ts index 228a4c33b..f9e3c69df 100644 --- a/src/client/transports/transportPipeline.ts +++ b/src/client/transports/transportPipeline.ts @@ -61,10 +61,6 @@ export class TransportPipeline { return new TransportPipeline([new HerokuComputeTransport()]); } - public registerTransport(transport: TransportProvider): void { - this.transports.push(transport); - } - public groupByTransport(components: SourceComponent[]): GroupedComponents { const metadataApi: SourceComponent[] = []; const transportMap = new Map(); diff --git a/test/client/transports/transportIntegration.test.ts b/test/client/transports/transportIntegration.test.ts index 606a36268..85b5bde6d 100644 --- a/test/client/transports/transportIntegration.test.ts +++ b/test/client/transports/transportIntegration.test.ts @@ -114,8 +114,7 @@ describe('Transport integration', () => { retrieveSpy, }); - const pipeline = new TransportPipeline(); - pipeline.registerTransport(transport); + const pipeline = new TransportPipeline([transport]); const groups = [{ transport, components: [createMockComponent('CustomObject', 'MyObject__c')] }]; @@ -136,8 +135,7 @@ describe('Transport integration', () => { retrieveSpy, }); - const pipeline = new TransportPipeline(); - pipeline.registerTransport(transport); + const pipeline = new TransportPipeline([transport]); const groups = [{ transport, components: [createMockComponent('ApexClass', 'MyClass')] }]; @@ -158,8 +156,7 @@ describe('Transport integration', () => { shouldThrow: 'retrieve', }); - const pipeline = new TransportPipeline(); - pipeline.registerTransport(transport); + const pipeline = new TransportPipeline([transport]); const groups = [{ transport, components: [createMockComponent('ApexClass', 'MyClass')] }]; @@ -180,8 +177,7 @@ describe('Transport integration', () => { shouldThrow: 'deploy', }); - const pipeline = new TransportPipeline(); - pipeline.registerTransport(transport); + const pipeline = new TransportPipeline([transport]); const groups = [{ transport, components: [createMockComponent('ApexClass', 'MyClass')] }]; @@ -196,15 +192,14 @@ describe('Transport integration', () => { describe('skipTransports with retrieve', () => { it('should exclude skipped types from retrieve explain plan', () => { - const pipeline = new TransportPipeline(); - pipeline.registerTransport( + const pipeline = new TransportPipeline([ createMockTransport({ name: 'connectApi', deployPhase: 'before-metadata', retrievePhase: 'after-metadata', typeNames: ['ApexClass'], - }) - ); + }), + ]); const apexClass = createMockComponent('ApexClass', 'MyClass'); @@ -217,15 +212,14 @@ describe('Transport integration', () => { }); it('should exclude skipped types from groupByTransport but keep in metadataApi', () => { - const pipeline = new TransportPipeline(); - pipeline.registerTransport( + const pipeline = new TransportPipeline([ createMockTransport({ name: 'connectApi', deployPhase: 'before-metadata', retrievePhase: 'after-metadata', typeNames: ['ApexClass', 'ApexTrigger'], - }) - ); + }), + ]); const apexClass = createMockComponent('ApexClass', 'MyClass'); const apexTrigger = createMockComponent('ApexTrigger', 'MyTrigger'); diff --git a/test/client/transports/transportPipeline.test.ts b/test/client/transports/transportPipeline.test.ts index 53346ad1e..a46468219 100644 --- a/test/client/transports/transportPipeline.test.ts +++ b/test/client/transports/transportPipeline.test.ts @@ -76,9 +76,8 @@ describe('TransportPipeline', () => { }); it('should group components by transport when transports are registered', () => { - const pipeline = new TransportPipeline(); const mockTransport = createMockTransport('connectApi', 'before-metadata', ['ApexClass']); - pipeline.registerTransport(mockTransport); + const pipeline = new TransportPipeline([mockTransport]); const apexClass = createMockComponent('ApexClass', 'MyClass'); const customObject = createMockComponent('CustomObject', 'MyObject__c'); @@ -95,9 +94,10 @@ describe('TransportPipeline', () => { }); it('should handle multiple transports', () => { - const pipeline = new TransportPipeline(); - pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); - pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); + const pipeline = new TransportPipeline([ + createMockTransport('connectApi', 'before-metadata', ['ApexClass']), + createMockTransport('datakitApi', 'after-metadata', ['CustomObject']), + ]); const apexClass = createMockComponent('ApexClass', 'MyClass'); const customObject = createMockComponent('CustomObject', 'MyObject__c'); @@ -125,8 +125,7 @@ describe('TransportPipeline', () => { }); it('should return false when no components match a transport', () => { - const pipeline = new TransportPipeline(); - pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['CustomObject'])); + const pipeline = new TransportPipeline([createMockTransport('connectApi', 'before-metadata', ['CustomObject'])]); const apexClass = createMockComponent('ApexClass', 'MyClass'); @@ -134,8 +133,7 @@ describe('TransportPipeline', () => { }); it('should return true when a component matches a transport', () => { - const pipeline = new TransportPipeline(); - pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + const pipeline = new TransportPipeline([createMockTransport('connectApi', 'before-metadata', ['ApexClass'])]); const apexClass = createMockComponent('ApexClass', 'MyClass'); @@ -143,8 +141,7 @@ describe('TransportPipeline', () => { }); it('should return false when matching type is in skipTypes', () => { - const pipeline = new TransportPipeline(); - pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + const pipeline = new TransportPipeline([createMockTransport('connectApi', 'before-metadata', ['ApexClass'])]); const apexClass = createMockComponent('ApexClass', 'MyClass'); @@ -154,9 +151,10 @@ describe('TransportPipeline', () => { describe('skipTypes', () => { it('should exclude skipped types from transport groups', () => { - const pipeline = new TransportPipeline(); - pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); - pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); + const pipeline = new TransportPipeline([ + createMockTransport('connectApi', 'before-metadata', ['ApexClass']), + createMockTransport('datakitApi', 'after-metadata', ['CustomObject']), + ]); const apexClass = createMockComponent('ApexClass', 'MyClass'); const customObject = createMockComponent('CustomObject', 'MyObject__c'); @@ -171,9 +169,10 @@ describe('TransportPipeline', () => { }); it('should exclude skipped types from explain plan', () => { - const pipeline = new TransportPipeline(); - pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); - pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); + const pipeline = new TransportPipeline([ + createMockTransport('connectApi', 'before-metadata', ['ApexClass']), + createMockTransport('datakitApi', 'after-metadata', ['CustomObject']), + ]); const apexClass = createMockComponent('ApexClass', 'MyClass'); const customObject = createMockComponent('CustomObject', 'MyObject__c'); @@ -189,9 +188,10 @@ describe('TransportPipeline', () => { describe('explain', () => { it('should produce a plan with correct phase ordering', () => { - const pipeline = new TransportPipeline(); - pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); - pipeline.registerTransport(createMockTransport('datakitApi', 'after-metadata', ['CustomObject'])); + const pipeline = new TransportPipeline([ + createMockTransport('connectApi', 'before-metadata', ['ApexClass']), + createMockTransport('datakitApi', 'after-metadata', ['CustomObject']), + ]); const apexClass = createMockComponent('ApexClass', 'MyClass'); const customObject = createMockComponent('CustomObject', 'MyObject__c'); @@ -228,7 +228,6 @@ describe('TransportPipeline', () => { }; it('should run only before-metadata transports in runBeforeMetadata', async () => { - const pipeline = new TransportPipeline(); const beforeResponse: FileResponse = { fullName: 'MyApp', type: 'PlatformComputeApp', @@ -237,8 +236,7 @@ describe('TransportPipeline', () => { }; const beforeTransport = createMockTransport('connectApi', 'before-metadata', ['ApexClass'], [beforeResponse]); const afterTransport = createMockTransport('datakitApi', 'after-metadata', ['CustomObject']); - pipeline.registerTransport(beforeTransport); - pipeline.registerTransport(afterTransport); + const pipeline = new TransportPipeline([beforeTransport, afterTransport]); const groups = [ { transport: beforeTransport, components: [createMockComponent('ApexClass', 'MyClass')] }, @@ -253,7 +251,6 @@ describe('TransportPipeline', () => { }); it('should run only after-metadata transports in runAfterMetadata', async () => { - const pipeline = new TransportPipeline(); const afterResponse: FileResponse = { fullName: 'CustomerDataKit', type: 'DataPackageDefinition', @@ -262,8 +259,7 @@ describe('TransportPipeline', () => { }; const beforeTransport = createMockTransport('connectApi', 'before-metadata', ['ApexClass']); const afterTransport = createMockTransport('datakitApi', 'after-metadata', ['CustomObject'], [afterResponse]); - pipeline.registerTransport(beforeTransport); - pipeline.registerTransport(afterTransport); + const pipeline = new TransportPipeline([beforeTransport, afterTransport]); const groups = [ { transport: beforeTransport, components: [createMockComponent('ApexClass', 'MyClass')] }, @@ -277,7 +273,6 @@ describe('TransportPipeline', () => { }); it('should use retrieve phase ordering when operation is retrieve', async () => { - const pipeline = new TransportPipeline(); const retrieveResponse: FileResponse = { fullName: 'MyApp', type: 'PlatformComputeApp', @@ -288,7 +283,7 @@ describe('TransportPipeline', () => { const transport = createMockTransport('connectApi', 'before-metadata', ['ApexClass'], retrieveResponse ? [] : []); // override retrieve to return responses transport.retrieve = async (): Promise => ({ fileResponses: [retrieveResponse] }); - pipeline.registerTransport(transport); + const pipeline = new TransportPipeline([transport]); const groups = [{ transport, components: [createMockComponent('ApexClass', 'MyClass')] }]; @@ -303,9 +298,8 @@ describe('TransportPipeline', () => { }); it('should produce correct explain plan for retrieve operation', () => { - const pipeline = new TransportPipeline(); // deploy: before-metadata → retrieve: after-metadata - pipeline.registerTransport(createMockTransport('connectApi', 'before-metadata', ['ApexClass'])); + const pipeline = new TransportPipeline([createMockTransport('connectApi', 'before-metadata', ['ApexClass'])]); const apexClass = createMockComponent('ApexClass', 'MyClass'); const customObject = createMockComponent('CustomObject', 'MyObject__c'); @@ -324,7 +318,6 @@ describe('TransportPipeline', () => { }); it('should collect async handles from transports', async () => { - const pipeline = new TransportPipeline(); const asyncTransport: TransportProvider = { name: 'connectApi', phase: { deploy: 'before-metadata', retrieve: 'after-metadata' }, @@ -345,7 +338,7 @@ describe('TransportPipeline', () => { }), retrieve: async () => ({ fileResponses: [] }), }; - pipeline.registerTransport(asyncTransport); + const pipeline = new TransportPipeline([asyncTransport]); const groups = [{ transport: asyncTransport, components: [createMockComponent('ApexClass', 'MyClass')] }]; From bd880f38962cda13af1a00f82045f3ee436ff572 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Wed, 7 Oct 2026 11:06:08 -0600 Subject: [PATCH 14/21] feat: transport lifecycle events, retrieve flow fix, and improved error messages Add TransportStageEvent lifecycle emissions so CLI commands can display transport progress. Fix retrieve for components without local source (-m, --manifest) by falling back to manifestComponents and computing content paths via DEFAULT_PACKAGE_ROOT_SFDX. Ensure unpackComputeBundle creates the target directory before writing. Include per-component error details in checkForFailures messages. --- src/client/index.ts | 1 + src/client/transports/computeSourceBundle.ts | 1 + .../transports/herokuComputeTransport.ts | 25 +++++---- src/client/transports/index.ts | 1 + src/client/transports/transportPipeline.ts | 52 ++++++++++++++++--- src/collections/componentSet.ts | 31 ++++++++++- src/index.ts | 1 + .../transports/herokuComputeTransport.test.ts | 3 +- 8 files changed, 96 insertions(+), 19 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 98cbdaeff..27ba3427b 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -36,6 +36,7 @@ export { TransportPlan, TransportPlanPhase, TransportPipelineResult, + TransportStageEvent, HerokuComputeTransport, packageComputeBundle, unpackComputeBundle, diff --git a/src/client/transports/computeSourceBundle.ts b/src/client/transports/computeSourceBundle.ts index 7729374d1..5eaf35ac1 100644 --- a/src/client/transports/computeSourceBundle.ts +++ b/src/client/transports/computeSourceBundle.ts @@ -83,6 +83,7 @@ export async function unpackComputeBundle(buffer: Buffer, appDir: string): Promi throw new Error(`Malformed compute source bundle: missing ${missing}`); } + await mkdir(appDir, { recursive: true }); await writeFile(join(appDir, 'api-spec.yaml'), apiSpec); const sourceEntries = await untarGzip(sourceTarGz); diff --git a/src/client/transports/herokuComputeTransport.ts b/src/client/transports/herokuComputeTransport.ts index 4f19f983a..de12b9f6f 100644 --- a/src/client/transports/herokuComputeTransport.ts +++ b/src/client/transports/herokuComputeTransport.ts @@ -14,9 +14,11 @@ * limitations under the License. */ +import { join } from 'node:path'; import FormData from 'form-data'; import { Logger, SfError } from '@salesforce/core'; -import type { Connection } from '@salesforce/core'; +import type { Connection, SfProject } from '@salesforce/core'; +import { DEFAULT_PACKAGE_ROOT_SFDX } from '../../common/constants'; import { SourceComponent } from '../../resolve/sourceComponent'; import { ComponentStatus, FileResponse } from '../types'; import { TransportContext, TransportDescription, TransportProvider, TransportResult } from './types'; @@ -32,6 +34,11 @@ type ComputeSourceUploadResponse = { const MIN_API_VERSION = '68.0'; const ENDPOINT = '/connect/compute/source'; +function resolveContentPath(component: SourceComponent, project: SfProject): string { + const packageDir = project.getDefaultPackage().fullPath; + return join(packageDir, DEFAULT_PACKAGE_ROOT_SFDX, component.type.directoryName, component.fullName); +} + function validateApiVersion(connection: Connection): void { const version = connection.version; if (!version || parseFloat(version) < parseFloat(MIN_API_VERSION)) { @@ -57,7 +64,7 @@ async function uploadSource( return connection.request({ method: 'POST', url: ENDPOINT, - body: form, + body: form.getBuffer(), headers: form.getHeaders(), }); } @@ -136,20 +143,20 @@ export class HerokuComputeTransport implements TransportProvider { for (const component of context.components) { try { - this.assertComputeComponent(component); - this.logger.debug('retrieving compute source for %s into %s', component.fullName, component.content); + const contentPath = component.content ?? resolveContentPath(component, context.project); + this.logger.debug('retrieving compute source for %s into %s', component.fullName, contentPath); // eslint-disable-next-line no-await-in-loop const buffer = await downloadSource(context.connection, component.fullName); // eslint-disable-next-line no-await-in-loop - const unpacked = await unpackComputeBundle(buffer, component.content); - this.logger.debug('unpacked %s: %d files into %s', component.fullName, unpacked.fileCount, component.content); + const unpacked = await unpackComputeBundle(buffer, contentPath); + this.logger.debug('unpacked %s: %d files into %s', component.fullName, unpacked.fileCount, contentPath); fileResponses.push({ fullName: component.fullName, type: component.type.name, state: ComponentStatus.Changed, - filePath: component.content, + filePath: contentPath, }); } catch (err) { fileResponses.push({ @@ -182,9 +189,9 @@ export class HerokuComputeTransport implements TransportProvider { private checkForFailures(fileResponses: FileResponse[], operation: string): TransportResult { const failures = fileResponses.filter((r) => r.state === ComponentStatus.Failed); if (failures.length > 0) { - const names = failures.map((f) => f.fullName).join(', '); + const details = failures.map((f) => f.error ?? f.fullName).join('; '); const error = new SfError( - `Heroku Compute ${operation} failed for: ${names}. ${failures.length} of ${fileResponses.length} component(s) failed.`, + `Heroku Compute ${operation} failed: ${details}`, `HerokuCompute${operation.charAt(0).toUpperCase() + operation.slice(1)}Error` ); error.data = fileResponses; diff --git a/src/client/transports/index.ts b/src/client/transports/index.ts index a22d26d17..e37ba62ca 100644 --- a/src/client/transports/index.ts +++ b/src/client/transports/index.ts @@ -30,6 +30,7 @@ export { TransportPlan, TransportPlanPhase, TransportPipelineResult, + TransportStageEvent, } from './transportPipeline'; export { TransportCoordinator } from './transportCoordinator'; export { HerokuComputeTransport } from './herokuComputeTransport'; diff --git a/src/client/transports/transportPipeline.ts b/src/client/transports/transportPipeline.ts index f9e3c69df..045c6e9d1 100644 --- a/src/client/transports/transportPipeline.ts +++ b/src/client/transports/transportPipeline.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Logger, SfError } from '@salesforce/core'; +import { Lifecycle, Logger, SfError } from '@salesforce/core'; import { SourceComponent } from '../../resolve/sourceComponent'; import { FileResponse } from '../types'; import { AsyncTransportHandle, TransportContext, TransportPhase, TransportProvider, TransportResult } from './types'; @@ -46,6 +46,16 @@ export type TransportPipelineResult = { asyncHandles: AsyncTransportHandle[]; }; +export type TransportStageEvent = { + transportName: string; + label: string; + operation: 'deploy' | 'retrieve'; + stage: 'start' | 'complete' | 'error'; + componentCount: number; + componentNames: string[]; + errorMessage?: string; +}; + export class TransportPipeline { private readonly transports: TransportProvider[] = []; private readonly logger: Logger; @@ -165,22 +175,52 @@ export class TransportPipeline { ): Promise { const allFileResponses: FileResponse[] = []; const allAsyncHandles: AsyncTransportHandle[] = []; + const lifecycle = Lifecycle.getInstance(); for (const group of groups) { this.logger.debug( `Running transport '${group.transport.name}' (${operation}) for ${group.components.length} component(s)` ); + const desc = group.transport.describe(); + const componentNames = group.components.map((c) => c.fullName); + const eventBase: Omit = { + transportName: group.transport.name, + label: desc.label, + operation, + componentCount: group.components.length, + componentNames, + }; + + // eslint-disable-next-line no-await-in-loop + await lifecycle.emit('transportStage', { ...eventBase, stage: 'start' }); + const transportContext: TransportContext = { ...context, components: group.components, }; - // eslint-disable-next-line no-await-in-loop - const result: TransportResult = await group.transport[operation](transportContext); - allFileResponses.push(...result.fileResponses); - if (result.asyncResult) { - allAsyncHandles.push(result.asyncResult); + try { + // eslint-disable-next-line no-await-in-loop + const result: TransportResult = await group.transport[operation](transportContext); + allFileResponses.push(...result.fileResponses); + if (result.asyncResult) { + allAsyncHandles.push(result.asyncResult); + } + // eslint-disable-next-line no-await-in-loop + await lifecycle.emit('transportStage', { ...eventBase, stage: 'complete' }); + } catch (err) { + try { + // eslint-disable-next-line no-await-in-loop + await lifecycle.emit('transportStage', { + ...eventBase, + stage: 'error', + errorMessage: (err as Error).message, + }); + } catch { + /* don't mask the transport error */ + } + throw err; } } diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index 677bac1a3..e5024fb36 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -457,8 +457,13 @@ export class ComponentSet extends LazyCollection { const pipeline = this.transportPipeline; const toRetrieve = Array.from(this.getSourceComponents()); - if (pipeline?.hasTransports(toRetrieve)) { - return this.retrieveWithPipeline(operationOptions, toRetrieve, pipeline); + // For retrieve, also include manifest-only components (e.g., -m flag + // where the component doesn't exist locally yet) so the transport + // pipeline can handle them. + const allComponents = toRetrieve.length > 0 ? toRetrieve : this.getManifestSourceComponents(); + if (pipeline?.hasTransports(allComponents)) { + return this.retrieveWithPipeline(operationOptions, allComponents, pipeline); + } } const mdapiRetrieve = new MetadataApiRetrieve(operationOptions); @@ -829,10 +834,32 @@ export class ComponentSet extends LazyCollection { return mdapiRetrieve; } + private getManifestSourceComponents(): SourceComponent[] { + const seen = new Set(); + const results: SourceComponent[] = []; + for (const [, members] of this.manifestComponents) { + for (const [, comp] of members) { + const key = `${comp.type.name}#${comp.fullName}`; + if (!seen.has(key)) { + seen.add(key); + results.push(comp); + } + } + } + return results; + } + private hasTransportEligibleTypes(): boolean { for (const comp of this.getSourceComponents()) { if (comp.type.strategies?.transport) return true; } + // Also check manifestComponents for components without local source + // (e.g., -m retrieves where the component doesn't exist on disk yet) + for (const [, members] of this.manifestComponents) { + for (const [, comp] of members) { + if (comp.type.strategies?.transport) return true; + } + } return false; } diff --git a/src/index.ts b/src/index.ts index 98b55bea6..2f2494795 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,7 @@ export { TransportPlan, TransportPlanPhase, TransportPipelineResult, + TransportStageEvent, HerokuComputeTransport, packageComputeBundle, unpackComputeBundle, diff --git a/test/client/transports/herokuComputeTransport.test.ts b/test/client/transports/herokuComputeTransport.test.ts index 57edda1c7..0a55c29fa 100644 --- a/test/client/transports/herokuComputeTransport.test.ts +++ b/test/client/transports/herokuComputeTransport.test.ts @@ -204,8 +204,7 @@ describe('HerokuComputeTransport', () => { } catch (err) { const sfErr = err as SfError; expect(sfErr.message).to.include('Heroku Compute upload failed'); - expect(sfErr.message).to.include('App2'); - expect(sfErr.message).to.include('1 of 2'); + expect(sfErr.message).to.include('500 server error'); // partial results attached to error.data const responses = sfErr.data as FileResponse[]; expect(responses).to.have.lengthOf(2); From 79e4285b11656a931675e4273fab7038110519a9 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Wed, 7 Oct 2026 14:21:03 -0600 Subject: [PATCH 15/21] refactor: require registry transport routes --- src/client/index.ts | 1 - .../transports/herokuComputeTransport.ts | 8 +- src/client/transports/index.ts | 1 - src/client/transports/transportPipeline.ts | 46 ++++++++--- src/client/transports/types.ts | 12 +-- src/collections/componentSet.ts | 1 - src/index.ts | 1 - src/registry/types.ts | 2 +- .../transports/herokuComputeTransport.test.ts | 24 +++--- .../transports/transportIntegration.test.ts | 81 +++++-------------- .../transports/transportPipeline.test.ts | 72 +++-------------- 11 files changed, 88 insertions(+), 161 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 27ba3427b..f7d9cf4a3 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -28,7 +28,6 @@ export { TransportResult, AsyncTransportHandle, TransportStatus, - TransportPhase, TransportDescription, TransportPipeline, TransportGroup, diff --git a/src/client/transports/herokuComputeTransport.ts b/src/client/transports/herokuComputeTransport.ts index de12b9f6f..94e8a289a 100644 --- a/src/client/transports/herokuComputeTransport.ts +++ b/src/client/transports/herokuComputeTransport.ts @@ -81,7 +81,6 @@ async function downloadSource(connection: Connection, appIdOrName: string): Prom export class HerokuComputeTransport implements TransportProvider { public readonly name = 'herokuCompute'; - public readonly phase = { deploy: 'before-metadata' as const, retrieve: 'after-metadata' as const }; private readonly logger = Logger.childFromRoot('HerokuComputeTransport'); // eslint-disable-next-line class-methods-use-this @@ -89,12 +88,11 @@ export class HerokuComputeTransport implements TransportProvider { return { label: 'Heroku Compute', endpoint: ENDPOINT }; } - // eslint-disable-next-line class-methods-use-this public handles(component: SourceComponent): boolean { - return component.type.strategies?.transport === 'herokuCompute' || component.type.name === 'PlatformComputeApp'; + return component.type.strategies?.transport === this.name; } - public async deploy(context: TransportContext): Promise { + public async beforeDeploy(context: TransportContext): Promise { validateApiVersion(context.connection); const fileResponses: FileResponse[] = []; @@ -137,7 +135,7 @@ export class HerokuComputeTransport implements TransportProvider { return this.checkForFailures(fileResponses, 'upload'); } - public async retrieve(context: TransportContext): Promise { + public async afterRetrieve(context: TransportContext): Promise { validateApiVersion(context.connection); const fileResponses: FileResponse[] = []; diff --git a/src/client/transports/index.ts b/src/client/transports/index.ts index e37ba62ca..cf85ec283 100644 --- a/src/client/transports/index.ts +++ b/src/client/transports/index.ts @@ -20,7 +20,6 @@ export { TransportResult, AsyncTransportHandle, TransportStatus, - TransportPhase, TransportDescription, } from './types'; export { diff --git a/src/client/transports/transportPipeline.ts b/src/client/transports/transportPipeline.ts index 045c6e9d1..81014d2f5 100644 --- a/src/client/transports/transportPipeline.ts +++ b/src/client/transports/transportPipeline.ts @@ -17,7 +17,7 @@ import { Lifecycle, Logger, SfError } from '@salesforce/core'; import { SourceComponent } from '../../resolve/sourceComponent'; import { FileResponse } from '../types'; -import { AsyncTransportHandle, TransportContext, TransportPhase, TransportProvider, TransportResult } from './types'; +import { AsyncTransportHandle, TransportContext, TransportProvider, TransportResult } from './types'; import { HerokuComputeTransport } from './herokuComputeTransport'; export type TransportGroup = { @@ -35,7 +35,7 @@ export type TransportPlan = { }; export type TransportPlanPhase = { - phase: TransportPhase | 'metadata-api'; + phase: 'before-metadata' | 'metadata-api' | 'after-metadata'; label: string; endpoint: string; components: Array<{ fullName: string; type: string }>; @@ -100,8 +100,8 @@ export class TransportPipeline { const { metadataApi, transports } = this.groupByTransport(components); const phases: TransportPlanPhase[] = []; - const beforeMetadata = transports.filter((g) => g.transport.phase[operation] === 'before-metadata'); - const afterMetadata = transports.filter((g) => g.transport.phase[operation] === 'after-metadata'); + const beforeMetadata = transports.filter((group) => getStep(group.transport, operation, 'before') !== undefined); + const afterMetadata = transports.filter((group) => getStep(group.transport, operation, 'after') !== undefined); for (const group of beforeMetadata) { const desc = group.transport.describe(); @@ -138,8 +138,7 @@ export class TransportPipeline { groups: TransportGroup[], operation: 'deploy' | 'retrieve' = 'deploy' ): Promise { - const beforeMetadata = groups.filter((g) => g.transport.phase[operation] === 'before-metadata'); - return this.runTransportGroups(beforeMetadata, context, operation); + return this.runTransportGroups(groups, context, operation, 'before'); } public async runAfterMetadata( @@ -147,8 +146,7 @@ export class TransportPipeline { groups: TransportGroup[], operation: 'deploy' | 'retrieve' = 'deploy' ): Promise { - const afterMetadata = groups.filter((g) => g.transport.phase[operation] === 'after-metadata'); - return this.runTransportGroups(afterMetadata, context, operation); + return this.runTransportGroups(groups, context, operation, 'after'); } public hasTransports(components: SourceComponent[]): boolean { @@ -171,13 +169,16 @@ export class TransportPipeline { private async runTransportGroups( groups: TransportGroup[], context: TransportContext, - operation: 'deploy' | 'retrieve' + operation: 'deploy' | 'retrieve', + timing: 'before' | 'after' ): Promise { const allFileResponses: FileResponse[] = []; const allAsyncHandles: AsyncTransportHandle[] = []; const lifecycle = Lifecycle.getInstance(); for (const group of groups) { + const step = getStep(group.transport, operation, timing); + if (!step) continue; this.logger.debug( `Running transport '${group.transport.name}' (${operation}) for ${group.components.length} component(s)` ); @@ -202,7 +203,7 @@ export class TransportPipeline { try { // eslint-disable-next-line no-await-in-loop - const result: TransportResult = await group.transport[operation](transportContext); + const result: TransportResult = await step(transportContext); allFileResponses.push(...result.fileResponses); if (result.asyncResult) { allAsyncHandles.push(result.asyncResult); @@ -227,3 +228,28 @@ export class TransportPipeline { return { fileResponses: allFileResponses, asyncHandles: allAsyncHandles }; } } + +function getStep( + transport: TransportProvider, + operation: 'deploy' | 'retrieve', + timing: 'before' | 'after' +): ((context: TransportContext) => Promise) | undefined { + if (operation === 'deploy' && timing === 'before') { + return transport.beforeDeploy + ? async (context): Promise => transport.beforeDeploy!(context) + : undefined; + } + if (operation === 'deploy') { + return transport.afterDeploy + ? async (context): Promise => transport.afterDeploy!(context) + : undefined; + } + if (timing === 'before') { + return transport.beforeRetrieve + ? async (context): Promise => transport.beforeRetrieve!(context) + : undefined; + } + return transport.afterRetrieve + ? async (context): Promise => transport.afterRetrieve!(context) + : undefined; +} diff --git a/src/client/transports/types.ts b/src/client/transports/types.ts index c9a8bb3aa..43c433cd7 100644 --- a/src/client/transports/types.ts +++ b/src/client/transports/types.ts @@ -18,8 +18,6 @@ import { Connection, SfProject } from '@salesforce/core'; import { SourceComponent } from '../../resolve/sourceComponent'; import { FileResponse } from '../types'; -export type TransportPhase = 'before-metadata' | 'after-metadata'; - export type TransportStatus = 'Pending' | 'InProgress' | 'Succeeded' | 'Failed' | 'Unknown'; export type TransportDescription = { @@ -48,12 +46,10 @@ export type AsyncTransportHandle = { export type TransportProvider = { readonly name: string; - readonly phase: { - deploy: TransportPhase; - retrieve: TransportPhase; - }; describe(): TransportDescription; handles(component: SourceComponent): boolean; - deploy(context: TransportContext): Promise; - retrieve(context: TransportContext): Promise; + beforeDeploy?(context: TransportContext): Promise; + afterDeploy?(context: TransportContext): Promise; + beforeRetrieve?(context: TransportContext): Promise; + afterRetrieve?(context: TransportContext): Promise; }; diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index e5024fb36..98be9c6af 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -464,7 +464,6 @@ export class ComponentSet extends LazyCollection { if (pipeline?.hasTransports(allComponents)) { return this.retrieveWithPipeline(operationOptions, allComponents, pipeline); } - } const mdapiRetrieve = new MetadataApiRetrieve(operationOptions); await mdapiRetrieve.start(); diff --git a/src/index.ts b/src/index.ts index 2f2494795..3c9ec8bcf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,6 @@ export { TransportResult, AsyncTransportHandle, TransportStatus, - TransportPhase, TransportDescription, TransportPipeline, TransportGroup, diff --git a/src/registry/types.ts b/src/registry/types.ts index 773f9f8ea..8ed7884b8 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -173,7 +173,7 @@ export type MetadataType = { | 'uiBundle'; decomposition?: 'topLevel' | 'folderPerType'; recomposition?: 'startEmpty'; - transport?: 'herokuCompute' | 'presignedUrl' | 'datakitApi'; + transport?: 'herokuCompute'; }; }; diff --git a/test/client/transports/herokuComputeTransport.test.ts b/test/client/transports/herokuComputeTransport.test.ts index 0a55c29fa..e5394868d 100644 --- a/test/client/transports/herokuComputeTransport.test.ts +++ b/test/client/transports/herokuComputeTransport.test.ts @@ -57,12 +57,10 @@ describe('HerokuComputeTransport', () => { const transport = new HerokuComputeTransport(); describe('metadata', () => { - it('should have correct name and phase', () => { + it('should have the Heroku Compute provider name', () => { expect(transport.name).to.equal('herokuCompute'); - expect(transport.phase).to.deep.equal({ - deploy: 'before-metadata', - retrieve: 'after-metadata', - }); + expect(typeof transport.beforeDeploy).to.equal('function'); + expect(typeof transport.afterRetrieve).to.equal('function'); }); it('should describe itself correctly', () => { @@ -102,7 +100,7 @@ describe('HerokuComputeTransport', () => { }); try { - await transport.deploy(context); + await transport.beforeDeploy(context); expect.fail('should have thrown'); } catch (err) { expect((err as Error).message).to.include('API version 68.0'); @@ -122,7 +120,7 @@ describe('HerokuComputeTransport', () => { components: [createComputeComponent('MyApp', '/mock/app')], }); - const result = await transport.deploy(context); + const result = await transport.beforeDeploy(context); expect(result.fileResponses).to.have.lengthOf(1); stub.restore(); @@ -156,7 +154,7 @@ describe('HerokuComputeTransport', () => { components: [createComputeComponent('MyApp', '/mock/app')], }); - const result = await transport.deploy(context); + const result = await transport.beforeDeploy(context); expect(packageStub.calledOnce).to.be.true; expect(packageStub.firstCall.args[0]).to.equal('/mock/app'); @@ -177,7 +175,7 @@ describe('HerokuComputeTransport', () => { components: [createComputeComponent('App1', '/mock/app1'), createComputeComponent('App2', '/mock/app2')], }); - const result = await transport.deploy(context); + const result = await transport.beforeDeploy(context); expect(packageStub.calledTwice).to.be.true; expect(requestStub.calledTwice).to.be.true; @@ -199,7 +197,7 @@ describe('HerokuComputeTransport', () => { }); try { - await transport.deploy(context); + await transport.beforeDeploy(context); expect.fail('should have thrown'); } catch (err) { const sfErr = err as SfError; @@ -225,7 +223,7 @@ describe('HerokuComputeTransport', () => { }); try { - await transport.deploy(context); + await transport.beforeDeploy(context); expect.fail('should have thrown'); } catch (err) { expect((err as Error).message).to.include('Heroku Compute upload failed'); @@ -245,7 +243,7 @@ describe('HerokuComputeTransport', () => { }); try { - await transport.retrieve(context); + await transport.afterRetrieve(context); expect.fail('should have thrown'); } catch (err) { const sfErr = err as SfError; @@ -272,7 +270,7 @@ describe('HerokuComputeTransport', () => { components: [component], }); - return transport.deploy(context).then( + return transport.beforeDeploy(context).then( () => expect.fail('should have thrown'), (err: unknown) => { const sfErr = err as SfError; diff --git a/test/client/transports/transportIntegration.test.ts b/test/client/transports/transportIntegration.test.ts index 85b5bde6d..e1f9c3b19 100644 --- a/test/client/transports/transportIntegration.test.ts +++ b/test/client/transports/transportIntegration.test.ts @@ -28,7 +28,6 @@ import { SourceComponent, TransportContext, TransportDescription, - TransportPhase, TransportPipeline, TransportProvider, TransportResult, @@ -38,8 +37,8 @@ const registryAccess = new RegistryAccess(); function createMockTransport(overrides: { name: string; - deployPhase: TransportPhase; - retrievePhase: TransportPhase; + deployPhase: 'before-metadata' | 'after-metadata'; + retrievePhase: 'before-metadata' | 'after-metadata'; typeNames: string[]; deployResponses?: FileResponse[]; retrieveResponses?: FileResponse[]; @@ -47,30 +46,37 @@ function createMockTransport(overrides: { retrieveSpy?: sinon.SinonSpy; shouldThrow?: 'deploy' | 'retrieve'; }): TransportProvider { - return { + const transport: TransportProvider = { name: overrides.name, - phase: { deploy: overrides.deployPhase, retrieve: overrides.retrievePhase }, describe(): TransportDescription { return { label: `Mock ${overrides.name}`, endpoint: `https://example.com/${overrides.name}` }; }, handles(component: SourceComponent): boolean { return overrides.typeNames.includes(component.type.name); }, - async deploy(ctx: TransportContext): Promise { - overrides.deploySpy?.(ctx); - if (overrides.shouldThrow === 'deploy') throw new Error('deploy transport failed'); - return { fileResponses: overrides.deployResponses ?? [] }; - }, - async retrieve(ctx: TransportContext): Promise { - overrides.retrieveSpy?.(ctx); - if (overrides.shouldThrow === 'retrieve') throw new Error('retrieve transport failed'); - return { fileResponses: overrides.retrieveResponses ?? [] }; - }, }; + const deploy = async (ctx: TransportContext): Promise => { + overrides.deploySpy?.(ctx); + if (overrides.shouldThrow === 'deploy') throw new Error('deploy transport failed'); + return { fileResponses: overrides.deployResponses ?? [] }; + }; + const retrieve = async (ctx: TransportContext): Promise => { + overrides.retrieveSpy?.(ctx); + if (overrides.shouldThrow === 'retrieve') throw new Error('retrieve transport failed'); + return { fileResponses: overrides.retrieveResponses ?? [] }; + }; + if (overrides.deployPhase === 'before-metadata') transport.beforeDeploy = deploy; + else transport.afterDeploy = deploy; + if (overrides.retrievePhase === 'before-metadata') transport.beforeRetrieve = retrieve; + else transport.afterRetrieve = retrieve; + return transport; } function createMockComponent(typeName: string, fullName: string): SourceComponent { - const type = registryAccess.getTypeByName(typeName); + const type = { + ...registryAccess.getTypeByName(typeName), + strategies: { adapter: 'default' as const, transport: 'herokuCompute' as const }, + }; return new SourceComponent({ name: fullName, type }); } @@ -190,49 +196,6 @@ describe('Transport integration', () => { }); }); - describe('skipTransports with retrieve', () => { - it('should exclude skipped types from retrieve explain plan', () => { - const pipeline = new TransportPipeline([ - createMockTransport({ - name: 'connectApi', - deployPhase: 'before-metadata', - retrievePhase: 'after-metadata', - typeNames: ['ApexClass'], - }), - ]); - - const apexClass = createMockComponent('ApexClass', 'MyClass'); - - const planWithout = pipeline.explain([apexClass], 'retrieve'); - const planWith = pipeline.explain([apexClass], 'retrieve', ['ApexClass']); - - expect(planWithout.phases).to.have.lengthOf(2); - expect(planWith.phases).to.have.lengthOf(1); - expect(planWith.phases[0].phase).to.equal('metadata-api'); - }); - - it('should exclude skipped types from groupByTransport but keep in metadataApi', () => { - const pipeline = new TransportPipeline([ - createMockTransport({ - name: 'connectApi', - deployPhase: 'before-metadata', - retrievePhase: 'after-metadata', - typeNames: ['ApexClass', 'ApexTrigger'], - }), - ]); - - const apexClass = createMockComponent('ApexClass', 'MyClass'); - const apexTrigger = createMockComponent('ApexTrigger', 'MyTrigger'); - - const result = pipeline.groupByTransport([apexClass, apexTrigger], ['ApexClass']); - - expect(result.metadataApi).to.have.lengthOf(2); - expect(result.transports).to.have.lengthOf(1); - expect(result.transports[0].components).to.have.lengthOf(1); - expect(result.transports[0].components[0].type.name).to.equal('ApexTrigger'); - }); - }); - describe('ComponentSet auto-discovery', () => { it('should auto-discover builtin transports for PlatformComputeApp', () => { const computeType = registryAccess.getTypeByName('PlatformComputeApp'); diff --git a/test/client/transports/transportPipeline.test.ts b/test/client/transports/transportPipeline.test.ts index a46468219..f9ad4b450 100644 --- a/test/client/transports/transportPipeline.test.ts +++ b/test/client/transports/transportPipeline.test.ts @@ -25,7 +25,6 @@ import { SourceComponent, TransportContext, TransportDescription, - TransportPhase, TransportProvider, TransportResult, } from '../../../src'; @@ -34,31 +33,29 @@ const registryAccess = new RegistryAccess(); function createMockTransport( name: string, - deployPhase: TransportPhase, + timing: 'before' | 'after', typeNames: string[], fileResponses: FileResponse[] = [] ): TransportProvider { - const retrievePhase: TransportPhase = deployPhase === 'before-metadata' ? 'after-metadata' : 'before-metadata'; - return { + const transport: TransportProvider = { name, - phase: { deploy: deployPhase, retrieve: retrievePhase }, describe(): TransportDescription { return { label: `Mock ${name}`, endpoint: `https://example.com/${name}` }; }, handles(component: SourceComponent): boolean { return typeNames.includes(component.type.name); }, - async deploy(): Promise { - return { fileResponses }; - }, - async retrieve(): Promise { - return { fileResponses: [] }; - }, }; + if (timing === 'before') transport.beforeDeploy = async (): Promise => ({ fileResponses }); + else transport.afterDeploy = async (): Promise => ({ fileResponses }); + return transport; } function createMockComponent(typeName: string, fullName: string): SourceComponent { - const type = registryAccess.getTypeByName(typeName); + const type = { + ...registryAccess.getTypeByName(typeName), + strategies: { adapter: 'default' as const, transport: 'herokuCompute' as const }, + }; return new SourceComponent({ name: fullName, type }); } @@ -139,51 +136,6 @@ describe('TransportPipeline', () => { expect(pipeline.hasTransports([apexClass])).to.be.true; }); - - it('should return false when matching type is in skipTypes', () => { - const pipeline = new TransportPipeline([createMockTransport('connectApi', 'before-metadata', ['ApexClass'])]); - - const apexClass = createMockComponent('ApexClass', 'MyClass'); - - expect(pipeline.hasTransports([apexClass], ['ApexClass'])).to.be.false; - }); - }); - - describe('skipTypes', () => { - it('should exclude skipped types from transport groups', () => { - const pipeline = new TransportPipeline([ - createMockTransport('connectApi', 'before-metadata', ['ApexClass']), - createMockTransport('datakitApi', 'after-metadata', ['CustomObject']), - ]); - - const apexClass = createMockComponent('ApexClass', 'MyClass'); - const customObject = createMockComponent('CustomObject', 'MyObject__c'); - - const result = pipeline.groupByTransport([apexClass, customObject], ['ApexClass']); - - // all components still go through metadata api - expect(result.metadataApi).to.have.lengthOf(2); - // only CustomObject transport remains - expect(result.transports).to.have.lengthOf(1); - expect(result.transports[0].transport.name).to.equal('datakitApi'); - }); - - it('should exclude skipped types from explain plan', () => { - const pipeline = new TransportPipeline([ - createMockTransport('connectApi', 'before-metadata', ['ApexClass']), - createMockTransport('datakitApi', 'after-metadata', ['CustomObject']), - ]); - - const apexClass = createMockComponent('ApexClass', 'MyClass'); - const customObject = createMockComponent('CustomObject', 'MyObject__c'); - - const plan = pipeline.explain([apexClass, customObject], 'deploy', ['ApexClass']); - - // before-metadata phase for connectApi is gone, only metadata-api + after-metadata remain - expect(plan.phases).to.have.lengthOf(2); - expect(plan.phases[0].phase).to.equal('metadata-api'); - expect(plan.phases[1].phase).to.equal('after-metadata'); - }); }); describe('explain', () => { @@ -282,7 +234,7 @@ describe('TransportPipeline', () => { // deploy: before-metadata → retrieve: after-metadata (flipped by createMockTransport) const transport = createMockTransport('connectApi', 'before-metadata', ['ApexClass'], retrieveResponse ? [] : []); // override retrieve to return responses - transport.retrieve = async (): Promise => ({ fileResponses: [retrieveResponse] }); + transport.afterRetrieve = async (): Promise => ({ fileResponses: [retrieveResponse] }); const pipeline = new TransportPipeline([transport]); const groups = [{ transport, components: [createMockComponent('ApexClass', 'MyClass')] }]; @@ -320,10 +272,9 @@ describe('TransportPipeline', () => { it('should collect async handles from transports', async () => { const asyncTransport: TransportProvider = { name: 'connectApi', - phase: { deploy: 'before-metadata', retrieve: 'after-metadata' }, describe: () => ({ label: 'Connect API', endpoint: '/connect/compute' }), handles: () => true, - deploy: async () => ({ + beforeDeploy: async () => ({ fileResponses: [], asyncResult: { transportName: 'connectApi', @@ -336,7 +287,6 @@ describe('TransportPipeline', () => { }), }, }), - retrieve: async () => ({ fileResponses: [] }), }; const pipeline = new TransportPipeline([asyncTransport]); From dbbbcd9969c3773be5c97bc651e7f92dcaa9b585 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Wed, 7 Oct 2026 14:41:21 -0600 Subject: [PATCH 16/21] refactor: keep transport internals private --- src/client/index.ts | 21 +--- src/client/metadataApiDeploy.ts | 29 +++-- src/client/metadataApiRetrieve.ts | 3 +- src/client/transports/index.ts | 21 +--- src/index.ts | 17 --- .../transports/computeSourceBundle.test.ts | 2 +- .../transports/deployResultTransport.test.ts | 10 +- .../transports/herokuComputeTransport.test.ts | 12 +- .../retrieveResultTransport.test.ts | 2 +- .../transports/transportIntegration.test.ts | 8 +- .../transports/transportPipeline.test.ts | 111 +++++++----------- 11 files changed, 83 insertions(+), 153 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index f7d9cf4a3..e3246f689 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -22,26 +22,7 @@ export { ScopedPostDeploy, } from './metadataApiDeploy'; export { MetadataApiRetrieve, RetrieveResult, ScopedPostRetrieve, ScopedPreRetrieve } from './metadataApiRetrieve'; -export { - TransportProvider, - TransportContext, - TransportResult, - AsyncTransportHandle, - TransportStatus, - TransportDescription, - TransportPipeline, - TransportGroup, - GroupedComponents, - TransportPlan, - TransportPlanPhase, - TransportPipelineResult, - TransportStageEvent, - HerokuComputeTransport, - packageComputeBundle, - unpackComputeBundle, - PackagedBundle, - UnpackedBundle, -} from './transports'; +export type { TransportStageEvent } from './transports'; export { ComponentDeployment, ComponentRetrieval, diff --git a/src/client/metadataApiDeploy.ts b/src/client/metadataApiDeploy.ts index d08b82798..79c83083a 100644 --- a/src/client/metadataApiDeploy.ts +++ b/src/client/metadataApiDeploy.ts @@ -28,6 +28,7 @@ import { ReplacementEvent } from '../convert/types'; import { MetadataConverter } from '../convert'; import { ComponentSet } from '../collections'; import { MetadataTransfer, MetadataTransferOptions } from './metadataTransfer'; +import { AsyncTransportHandle } from './transports/types'; import { AsyncResult, ComponentStatus, @@ -56,6 +57,8 @@ const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sd // TODO: (NEXT MAJOR) this should just be a readonly object and not a class. export class DeployResult implements MetadataTransferResult { private fileResponses?: FileResponse[]; + private transportFileResponses: FileResponse[] = []; + private asyncTransportHandles: AsyncTransportHandle[] = []; public constructor( public readonly response: MetadataApiDeployStatus, @@ -69,19 +72,29 @@ export class DeployResult implements MetadataTransferResult { if (!this.fileResponses) { this.fileResponses = [ ...new Map( - (this.components - ? buildFileResponsesFromComponentSet(this.components)(this.response) - : buildFileResponses(this.response) - ).map((v) => { - const base = `${v.type}#${v.fullName}#${v.filePath ?? ''}#${v.state}`; - const key = 'error' in v ? `${base}#${v.error}#${String(v.lineNumber)}#${String(v.columnNumber)}` : base; - return [key, v] as const; - }) + // MDAPI and required transport steps can report the same source file. + // A serialized structural key preserves one response without adding a dependency. + [ + ...(this.components + ? buildFileResponsesFromComponentSet(this.components)(this.response) + : buildFileResponses(this.response)), + ...this.transportFileResponses, + ].map((v) => [JSON.stringify(v), v]) ).values(), ]; } return this.fileResponses; } + + public addTransportResults(fileResponses: FileResponse[], asyncHandles: AsyncTransportHandle[] = []): void { + this.transportFileResponses.push(...fileResponses); + this.asyncTransportHandles.push(...asyncHandles); + this.fileResponses = undefined; + } + + public getAsyncTransportHandles(): AsyncTransportHandle[] { + return this.asyncTransportHandles; + } } export type MetadataApiDeployOptions = { diff --git a/src/client/metadataApiRetrieve.ts b/src/client/metadataApiRetrieve.ts index fbcb34f17..3a463a095 100644 --- a/src/client/metadataApiRetrieve.ts +++ b/src/client/metadataApiRetrieve.ts @@ -128,7 +128,8 @@ export class RetrieveResult implements MetadataTransferResult { responses.push(...this.transportFileResponses); - // deduplicate by serializing each response + // MDAPI extraction and required transport retrieval can report the same source file. + // A serialized structural key preserves one response without adding a dependency. this.fileResponses = [...new Map(responses.map((v) => [JSON.stringify(v), v])).values()]; return this.fileResponses; diff --git a/src/client/transports/index.ts b/src/client/transports/index.ts index cf85ec283..ed0f752ac 100644 --- a/src/client/transports/index.ts +++ b/src/client/transports/index.ts @@ -14,23 +14,4 @@ * limitations under the License. */ -export { - TransportProvider, - TransportContext, - TransportResult, - AsyncTransportHandle, - TransportStatus, - TransportDescription, -} from './types'; -export { - TransportPipeline, - TransportGroup, - GroupedComponents, - TransportPlan, - TransportPlanPhase, - TransportPipelineResult, - TransportStageEvent, -} from './transportPipeline'; -export { TransportCoordinator } from './transportCoordinator'; -export { HerokuComputeTransport } from './herokuComputeTransport'; -export { packageComputeBundle, unpackComputeBundle, PackagedBundle, UnpackedBundle } from './computeSourceBundle'; +export type { TransportStageEvent } from './transportPipeline'; diff --git a/src/index.ts b/src/index.ts index 3c9ec8bcf..a3fae0641 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,24 +24,7 @@ export { MetadataApiRetrieve, MetadataApiRetrieveOptions, RetrieveResult, - TransportProvider, - TransportContext, - TransportResult, - AsyncTransportHandle, - TransportStatus, - TransportDescription, - TransportPipeline, - TransportGroup, - GroupedComponents, - TransportPlan, - TransportPlanPhase, - TransportPipelineResult, TransportStageEvent, - HerokuComputeTransport, - packageComputeBundle, - unpackComputeBundle, - PackagedBundle, - UnpackedBundle, ComponentDeployment, ComponentRetrieval, ComponentDiagnostic, diff --git a/test/client/transports/computeSourceBundle.test.ts b/test/client/transports/computeSourceBundle.test.ts index 03f3f2036..11f6daf97 100644 --- a/test/client/transports/computeSourceBundle.test.ts +++ b/test/client/transports/computeSourceBundle.test.ts @@ -21,7 +21,7 @@ import { createGzip } from 'node:zlib'; import { buffer as streamToBuffer } from 'node:stream/consumers'; import { expect } from 'chai'; import tarStream from 'tar-stream'; -import { packageComputeBundle, unpackComputeBundle } from '../../../src'; +import { packageComputeBundle, unpackComputeBundle } from '../../../src/client/transports/computeSourceBundle'; describe('computeSourceBundle', () => { let tempDir: string; diff --git a/test/client/transports/deployResultTransport.test.ts b/test/client/transports/deployResultTransport.test.ts index e5bf1c576..f80fa23f1 100644 --- a/test/client/transports/deployResultTransport.test.ts +++ b/test/client/transports/deployResultTransport.test.ts @@ -15,14 +15,8 @@ */ import { expect } from 'chai'; -import { - AsyncTransportHandle, - ComponentStatus, - DeployResult, - FileResponse, - MetadataApiDeployStatus, - RequestStatus, -} from '../../../src'; +import { ComponentStatus, DeployResult, FileResponse, MetadataApiDeployStatus, RequestStatus } from '../../../src'; +import { AsyncTransportHandle } from '../../../src/client/transports/types'; function createDeployStatus(overrides: Partial = {}): MetadataApiDeployStatus { return { diff --git a/test/client/transports/herokuComputeTransport.test.ts b/test/client/transports/herokuComputeTransport.test.ts index e5394868d..a4ff9a66a 100644 --- a/test/client/transports/herokuComputeTransport.test.ts +++ b/test/client/transports/herokuComputeTransport.test.ts @@ -17,14 +17,10 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { Connection, SfError, SfProject } from '@salesforce/core'; -import { - FileResponse, - HerokuComputeTransport, - TransportPipeline, - RegistryAccess, - SourceComponent, - TransportContext, -} from '../../../src'; +import { FileResponse, RegistryAccess, SourceComponent } from '../../../src'; +import { HerokuComputeTransport } from '../../../src/client/transports/herokuComputeTransport'; +import { TransportPipeline } from '../../../src/client/transports/transportPipeline'; +import { TransportContext } from '../../../src/client/transports/types'; import * as computeSourceBundle from '../../../src/client/transports/computeSourceBundle'; const registryAccess = new RegistryAccess(); diff --git a/test/client/transports/retrieveResultTransport.test.ts b/test/client/transports/retrieveResultTransport.test.ts index 3ce8c2049..31dbcd7c2 100644 --- a/test/client/transports/retrieveResultTransport.test.ts +++ b/test/client/transports/retrieveResultTransport.test.ts @@ -16,7 +16,6 @@ import { expect } from 'chai'; import { - AsyncTransportHandle, ComponentSet, ComponentStatus, FileResponse, @@ -24,6 +23,7 @@ import { RequestStatus, RetrieveResult, } from '../../../src'; +import { AsyncTransportHandle } from '../../../src/client/transports/types'; function createRetrieveStatus(overrides: Partial = {}): MetadataApiRetrieveStatus { return { diff --git a/test/client/transports/transportIntegration.test.ts b/test/client/transports/transportIntegration.test.ts index e1f9c3b19..0c100ec4f 100644 --- a/test/client/transports/transportIntegration.test.ts +++ b/test/client/transports/transportIntegration.test.ts @@ -26,12 +26,14 @@ import { RequestStatus, RetrieveResult, SourceComponent, +} from '../../../src'; +import { TransportPipeline } from '../../../src/client/transports/transportPipeline'; +import { TransportContext, TransportDescription, - TransportPipeline, TransportProvider, TransportResult, -} from '../../../src'; +} from '../../../src/client/transports/types'; const registryAccess = new RegistryAccess(); @@ -237,7 +239,7 @@ describe('Transport integration', () => { cs.transportPipeline = customPipeline; expect(cs.transportPipeline).to.equal(customPipeline); - expect(cs.transportPipeline.hasTransports([component])).to.be.false; + expect(() => cs.transportPipeline?.hasTransports([component])).to.throw('No transport provider is registered'); }); }); diff --git a/test/client/transports/transportPipeline.test.ts b/test/client/transports/transportPipeline.test.ts index f9ad4b450..e99d9b67e 100644 --- a/test/client/transports/transportPipeline.test.ts +++ b/test/client/transports/transportPipeline.test.ts @@ -16,52 +16,43 @@ import { expect } from 'chai'; import { Connection, SfProject } from '@salesforce/core'; +import { ComponentStatus, FileResponse, RegistryAccess, SourceComponent } from '../../../src'; +import { TransportPipeline } from '../../../src/client/transports/transportPipeline'; import { AsyncTransportHandle, - ComponentStatus, - TransportPipeline, - FileResponse, - RegistryAccess, - SourceComponent, TransportContext, TransportDescription, TransportProvider, TransportResult, -} from '../../../src'; +} from '../../../src/client/transports/types'; const registryAccess = new RegistryAccess(); function createMockTransport( name: string, - timing: 'before' | 'after', - typeNames: string[], + timing: 'before' | 'after' | 'before-metadata' | 'after-metadata', fileResponses: FileResponse[] = [] ): TransportProvider { const transport: TransportProvider = { - name, + name: 'herokuCompute', describe(): TransportDescription { return { label: `Mock ${name}`, endpoint: `https://example.com/${name}` }; }, - handles(component: SourceComponent): boolean { - return typeNames.includes(component.type.name); - }, + handles: () => true, }; - if (timing === 'before') transport.beforeDeploy = async (): Promise => ({ fileResponses }); - else transport.afterDeploy = async (): Promise => ({ fileResponses }); + if (timing === 'before' || timing === 'before-metadata') { + transport.beforeDeploy = async (): Promise => ({ fileResponses }); + } else transport.afterDeploy = async (): Promise => ({ fileResponses }); return transport; } function createMockComponent(typeName: string, fullName: string): SourceComponent { - const type = { - ...registryAccess.getTypeByName(typeName), - strategies: { adapter: 'default' as const, transport: 'herokuCompute' as const }, - }; - return new SourceComponent({ name: fullName, type }); + return new SourceComponent({ name: fullName, type: registryAccess.getTypeByName(typeName) }); } describe('TransportPipeline', () => { describe('groupByTransport', () => { - it('should put all components in metadataApi when no transports registered', () => { + it('should put registry-unconfigured components in metadataApi when no transports are registered', () => { const pipeline = new TransportPipeline(); const apexClass = createMockComponent('ApexClass', 'MyClass'); const customObject = createMockComponent('CustomObject', 'MyObject__c'); @@ -73,56 +64,48 @@ describe('TransportPipeline', () => { }); it('should group components by transport when transports are registered', () => { - const mockTransport = createMockTransport('connectApi', 'before-metadata', ['ApexClass']); + const mockTransport = createMockTransport('herokuCompute', 'before-metadata'); const pipeline = new TransportPipeline([mockTransport]); - const apexClass = createMockComponent('ApexClass', 'MyClass'); + const apexClass = createMockComponent('PlatformComputeApp', 'MyApp'); const customObject = createMockComponent('CustomObject', 'MyObject__c'); const result = pipeline.groupByTransport([apexClass, customObject]); // all components go through metadata api expect(result.metadataApi).to.have.lengthOf(2); - // ApexClass also goes through transport + // Configured components also go through their registry transport. expect(result.transports).to.have.lengthOf(1); - expect(result.transports[0].transport.name).to.equal('connectApi'); + expect(result.transports[0].transport.name).to.equal('herokuCompute'); expect(result.transports[0].components).to.have.lengthOf(1); - expect(result.transports[0].components[0].fullName).to.equal('MyClass'); + expect(result.transports[0].components[0].fullName).to.equal('MyApp'); }); - it('should handle multiple transports', () => { - const pipeline = new TransportPipeline([ - createMockTransport('connectApi', 'before-metadata', ['ApexClass']), - createMockTransport('datakitApi', 'after-metadata', ['CustomObject']), - ]); + it('should group all configured components for the registry transport', () => { + const pipeline = new TransportPipeline([createMockTransport('herokuCompute', 'before-metadata')]); - const apexClass = createMockComponent('ApexClass', 'MyClass'); - const customObject = createMockComponent('CustomObject', 'MyObject__c'); + const apexClass = createMockComponent('PlatformComputeApp', 'MyApp'); + const customObject = createMockComponent('PlatformComputeApp', 'OtherApp'); const apexTrigger = createMockComponent('ApexTrigger', 'MyTrigger'); const result = pipeline.groupByTransport([apexClass, customObject, apexTrigger]); expect(result.metadataApi).to.have.lengthOf(3); - expect(result.transports).to.have.lengthOf(2); - - const connectGroup = result.transports.find((t) => t.transport.name === 'connectApi'); - const datakitGroup = result.transports.find((t) => t.transport.name === 'datakitApi'); - - expect(connectGroup?.components).to.have.lengthOf(1); - expect(datakitGroup?.components).to.have.lengthOf(1); + expect(result.transports).to.have.lengthOf(1); + expect(result.transports[0].components).to.have.lengthOf(2); }); }); describe('hasTransports', () => { - it('should return false when no transports registered', () => { + it('should throw when a registry-configured transport provider is missing', () => { const pipeline = new TransportPipeline(); - const apexClass = createMockComponent('ApexClass', 'MyClass'); + const app = createMockComponent('PlatformComputeApp', 'MyApp'); - expect(pipeline.hasTransports([apexClass])).to.be.false; + expect(() => pipeline.hasTransports([app])).to.throw('No transport provider is registered'); }); it('should return false when no components match a transport', () => { - const pipeline = new TransportPipeline([createMockTransport('connectApi', 'before-metadata', ['CustomObject'])]); + const pipeline = new TransportPipeline([createMockTransport('herokuCompute', 'before-metadata')]); const apexClass = createMockComponent('ApexClass', 'MyClass'); @@ -130,9 +113,9 @@ describe('TransportPipeline', () => { }); it('should return true when a component matches a transport', () => { - const pipeline = new TransportPipeline([createMockTransport('connectApi', 'before-metadata', ['ApexClass'])]); + const pipeline = new TransportPipeline([createMockTransport('herokuCompute', 'before-metadata')]); - const apexClass = createMockComponent('ApexClass', 'MyClass'); + const apexClass = createMockComponent('PlatformComputeApp', 'MyApp'); expect(pipeline.hasTransports([apexClass])).to.be.true; }); @@ -140,24 +123,19 @@ describe('TransportPipeline', () => { describe('explain', () => { it('should produce a plan with correct phase ordering', () => { - const pipeline = new TransportPipeline([ - createMockTransport('connectApi', 'before-metadata', ['ApexClass']), - createMockTransport('datakitApi', 'after-metadata', ['CustomObject']), - ]); + const pipeline = new TransportPipeline([createMockTransport('herokuCompute', 'before-metadata')]); - const apexClass = createMockComponent('ApexClass', 'MyClass'); + const apexClass = createMockComponent('PlatformComputeApp', 'MyApp'); const customObject = createMockComponent('CustomObject', 'MyObject__c'); const apexTrigger = createMockComponent('ApexTrigger', 'MyTrigger'); const plan = pipeline.explain([apexClass, customObject, apexTrigger]); - expect(plan.phases).to.have.lengthOf(3); + expect(plan.phases).to.have.lengthOf(2); expect(plan.phases[0].phase).to.equal('before-metadata'); - expect(plan.phases[0].label).to.equal('Mock connectApi'); + expect(plan.phases[0].label).to.equal('Mock herokuCompute'); expect(plan.phases[1].phase).to.equal('metadata-api'); expect(plan.phases[1].components).to.have.lengthOf(3); - expect(plan.phases[2].phase).to.equal('after-metadata'); - expect(plan.phases[2].label).to.equal('Mock datakitApi'); }); it('should produce metadata-only plan when no transports match', () => { @@ -186,8 +164,8 @@ describe('TransportPipeline', () => { state: ComponentStatus.Changed, filePath: 'force-app/main/default/compute/MyApp/main.py', }; - const beforeTransport = createMockTransport('connectApi', 'before-metadata', ['ApexClass'], [beforeResponse]); - const afterTransport = createMockTransport('datakitApi', 'after-metadata', ['CustomObject']); + const beforeTransport = createMockTransport('connectApi', 'before-metadata', [beforeResponse]); + const afterTransport = createMockTransport('datakitApi', 'after-metadata'); const pipeline = new TransportPipeline([beforeTransport, afterTransport]); const groups = [ @@ -209,8 +187,8 @@ describe('TransportPipeline', () => { state: ComponentStatus.Changed, filePath: 'force-app/main/default/datapkg/CustomerDataKit.json', }; - const beforeTransport = createMockTransport('connectApi', 'before-metadata', ['ApexClass']); - const afterTransport = createMockTransport('datakitApi', 'after-metadata', ['CustomObject'], [afterResponse]); + const beforeTransport = createMockTransport('connectApi', 'before-metadata'); + const afterTransport = createMockTransport('datakitApi', 'after-metadata', [afterResponse]); const pipeline = new TransportPipeline([beforeTransport, afterTransport]); const groups = [ @@ -232,7 +210,7 @@ describe('TransportPipeline', () => { filePath: 'force-app/main/default/compute/MyApp/main.py', }; // deploy: before-metadata → retrieve: after-metadata (flipped by createMockTransport) - const transport = createMockTransport('connectApi', 'before-metadata', ['ApexClass'], retrieveResponse ? [] : []); + const transport = createMockTransport('connectApi', 'before-metadata'); // override retrieve to return responses transport.afterRetrieve = async (): Promise => ({ fileResponses: [retrieveResponse] }); const pipeline = new TransportPipeline([transport]); @@ -250,23 +228,24 @@ describe('TransportPipeline', () => { }); it('should produce correct explain plan for retrieve operation', () => { - // deploy: before-metadata → retrieve: after-metadata - const pipeline = new TransportPipeline([createMockTransport('connectApi', 'before-metadata', ['ApexClass'])]); + const transport = createMockTransport('herokuCompute', 'before-metadata'); + transport.afterRetrieve = async (): Promise => ({ fileResponses: [] }); + const pipeline = new TransportPipeline([transport]); - const apexClass = createMockComponent('ApexClass', 'MyClass'); + const apexClass = createMockComponent('PlatformComputeApp', 'MyApp'); const customObject = createMockComponent('CustomObject', 'MyObject__c'); const deployPlan = pipeline.explain([apexClass, customObject], 'deploy'); const retrievePlan = pipeline.explain([apexClass, customObject], 'retrieve'); - // deploy: connectApi is before-metadata + // deploy: herokuCompute is before metadata. expect(deployPlan.phases[0].phase).to.equal('before-metadata'); - expect(deployPlan.phases[0].label).to.equal('Mock connectApi'); + expect(deployPlan.phases[0].label).to.equal('Mock herokuCompute'); - // retrieve: connectApi is after-metadata (phases are flipped) + // retrieve: herokuCompute is after metadata. expect(retrievePlan.phases[0].phase).to.equal('metadata-api'); expect(retrievePlan.phases[1].phase).to.equal('after-metadata'); - expect(retrievePlan.phases[1].label).to.equal('Mock connectApi'); + expect(retrievePlan.phases[1].label).to.equal('Mock herokuCompute'); }); it('should collect async handles from transports', async () => { From dd0f0f0eb756d22c8c38fa841809714e4496f09d Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Wed, 7 Oct 2026 14:54:09 -0600 Subject: [PATCH 17/21] refactor: constrain transport names --- src/client/transports/transportPipeline.ts | 3 ++- src/client/transports/types.ts | 5 +++-- src/registry/types.ts | 4 +++- test/client/transports/deployResultTransport.test.ts | 6 +++--- test/client/transports/retrieveResultTransport.test.ts | 6 +++--- test/client/transports/transportIntegration.test.ts | 10 +++------- test/client/transports/transportPipeline.test.ts | 8 ++++---- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/client/transports/transportPipeline.ts b/src/client/transports/transportPipeline.ts index 81014d2f5..fe920a845 100644 --- a/src/client/transports/transportPipeline.ts +++ b/src/client/transports/transportPipeline.ts @@ -15,6 +15,7 @@ */ import { Lifecycle, Logger, SfError } from '@salesforce/core'; +import { TransportName } from '../../registry/types'; import { SourceComponent } from '../../resolve/sourceComponent'; import { FileResponse } from '../types'; import { AsyncTransportHandle, TransportContext, TransportProvider, TransportResult } from './types'; @@ -47,7 +48,7 @@ export type TransportPipelineResult = { }; export type TransportStageEvent = { - transportName: string; + transportName: TransportName; label: string; operation: 'deploy' | 'retrieve'; stage: 'start' | 'complete' | 'error'; diff --git a/src/client/transports/types.ts b/src/client/transports/types.ts index 43c433cd7..7fb2fb8bb 100644 --- a/src/client/transports/types.ts +++ b/src/client/transports/types.ts @@ -15,6 +15,7 @@ */ import { Connection, SfProject } from '@salesforce/core'; +import { TransportName } from '../../registry/types'; import { SourceComponent } from '../../resolve/sourceComponent'; import { FileResponse } from '../types'; @@ -38,14 +39,14 @@ export type TransportResult = { }; export type AsyncTransportHandle = { - transportName: string; + transportName: TransportName; status: TransportStatus; message?: string; checkStatus(): Promise; }; export type TransportProvider = { - readonly name: string; + readonly name: TransportName; describe(): TransportDescription; handles(component: SourceComponent): boolean; beforeDeploy?(context: TransportContext): Promise; diff --git a/src/registry/types.ts b/src/registry/types.ts index 8ed7884b8..12477c721 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -173,10 +173,12 @@ export type MetadataType = { | 'uiBundle'; decomposition?: 'topLevel' | 'folderPerType'; recomposition?: 'startEmpty'; - transport?: 'herokuCompute'; + transport?: TransportName; }; }; +export type TransportName = 'herokuCompute'; + /** * Mapping of metadata type ids -> Metadata type definitions. */ diff --git a/test/client/transports/deployResultTransport.test.ts b/test/client/transports/deployResultTransport.test.ts index f80fa23f1..c8abbf6b9 100644 --- a/test/client/transports/deployResultTransport.test.ts +++ b/test/client/transports/deployResultTransport.test.ts @@ -109,11 +109,11 @@ describe('DeployResult transport integration', () => { const result = new DeployResult(createDeployStatus()); const asyncHandle: AsyncTransportHandle = { - transportName: 'connectApi', + transportName: 'herokuCompute', status: 'Pending', message: 'Deploy extension pending', checkStatus: async (): Promise => ({ - transportName: 'connectApi', + transportName: 'herokuCompute', status: 'Succeeded' as const, checkStatus: async (): Promise => ({} as unknown as AsyncTransportHandle), }), @@ -123,7 +123,7 @@ describe('DeployResult transport integration', () => { const handles = result.getAsyncTransportHandles(); expect(handles).to.have.lengthOf(1); - expect(handles[0].transportName).to.equal('connectApi'); + expect(handles[0].transportName).to.equal('herokuCompute'); expect(handles[0].status).to.equal('Pending'); }); diff --git a/test/client/transports/retrieveResultTransport.test.ts b/test/client/transports/retrieveResultTransport.test.ts index 31dbcd7c2..2cfcb6317 100644 --- a/test/client/transports/retrieveResultTransport.test.ts +++ b/test/client/transports/retrieveResultTransport.test.ts @@ -67,11 +67,11 @@ describe('RetrieveResult transport integration', () => { const result = new RetrieveResult(createRetrieveStatus(), new ComponentSet()); const asyncHandle: AsyncTransportHandle = { - transportName: 'connectApi', + transportName: 'herokuCompute', status: 'Pending', message: 'Retrieve extension pending', checkStatus: async (): Promise => ({ - transportName: 'connectApi', + transportName: 'herokuCompute', status: 'Succeeded' as const, checkStatus: async (): Promise => ({} as unknown as AsyncTransportHandle), }), @@ -81,7 +81,7 @@ describe('RetrieveResult transport integration', () => { const handles = result.getAsyncTransportHandles(); expect(handles).to.have.lengthOf(1); - expect(handles[0].transportName).to.equal('connectApi'); + expect(handles[0].transportName).to.equal('herokuCompute'); expect(handles[0].status).to.equal('Pending'); }); diff --git a/test/client/transports/transportIntegration.test.ts b/test/client/transports/transportIntegration.test.ts index 0c100ec4f..134bd375e 100644 --- a/test/client/transports/transportIntegration.test.ts +++ b/test/client/transports/transportIntegration.test.ts @@ -38,7 +38,7 @@ import { const registryAccess = new RegistryAccess(); function createMockTransport(overrides: { - name: string; + name?: 'herokuCompute'; deployPhase: 'before-metadata' | 'after-metadata'; retrievePhase: 'before-metadata' | 'after-metadata'; typeNames: string[]; @@ -49,9 +49,9 @@ function createMockTransport(overrides: { shouldThrow?: 'deploy' | 'retrieve'; }): TransportProvider { const transport: TransportProvider = { - name: overrides.name, + name: overrides.name ?? 'herokuCompute', describe(): TransportDescription { - return { label: `Mock ${overrides.name}`, endpoint: `https://example.com/${overrides.name}` }; + return { label: 'Mock transport', endpoint: 'https://example.com/transport' }; }, handles(component: SourceComponent): boolean { return overrides.typeNames.includes(component.type.name); @@ -114,7 +114,6 @@ describe('Transport integration', () => { filePath: 'force-app/main/default/objects/PreRetrieveApp.object-meta.xml', }; const transport = createMockTransport({ - name: 'datakitApi', deployPhase: 'after-metadata', retrievePhase: 'before-metadata', typeNames: ['CustomObject'], @@ -136,7 +135,6 @@ describe('Transport integration', () => { it('should not run after-metadata retrieve transports in runBeforeMetadata', async () => { const retrieveSpy = sinon.spy(); const transport = createMockTransport({ - name: 'connectApi', deployPhase: 'before-metadata', retrievePhase: 'after-metadata', typeNames: ['ApexClass'], @@ -157,7 +155,6 @@ describe('Transport integration', () => { describe('transport failure handling', () => { it('should propagate transport errors from runAfterMetadata for callers to catch', async () => { const transport = createMockTransport({ - name: 'connectApi', deployPhase: 'before-metadata', retrievePhase: 'after-metadata', typeNames: ['ApexClass'], @@ -178,7 +175,6 @@ describe('Transport integration', () => { it('should propagate transport errors from runBeforeMetadata for callers to catch', async () => { const transport = createMockTransport({ - name: 'connectApi', deployPhase: 'before-metadata', retrievePhase: 'after-metadata', typeNames: ['ApexClass'], diff --git a/test/client/transports/transportPipeline.test.ts b/test/client/transports/transportPipeline.test.ts index e99d9b67e..dae42fea2 100644 --- a/test/client/transports/transportPipeline.test.ts +++ b/test/client/transports/transportPipeline.test.ts @@ -250,17 +250,17 @@ describe('TransportPipeline', () => { it('should collect async handles from transports', async () => { const asyncTransport: TransportProvider = { - name: 'connectApi', + name: 'herokuCompute', describe: () => ({ label: 'Connect API', endpoint: '/connect/compute' }), handles: () => true, beforeDeploy: async () => ({ fileResponses: [], asyncResult: { - transportName: 'connectApi', + transportName: 'herokuCompute', status: 'Pending' as const, message: 'Deploy extension pending', checkStatus: async (): Promise => ({ - transportName: 'connectApi', + transportName: 'herokuCompute', status: 'Succeeded' as const, checkStatus: async (): Promise => ({} as unknown as AsyncTransportHandle), }), @@ -274,7 +274,7 @@ describe('TransportPipeline', () => { const result = await pipeline.runBeforeMetadata(mockContext, groups); expect(result.asyncHandles).to.have.lengthOf(1); - expect(result.asyncHandles[0].transportName).to.equal('connectApi'); + expect(result.asyncHandles[0].transportName).to.equal('herokuCompute'); expect(result.asyncHandles[0].status).to.equal('Pending'); }); }); From e380c72e27c1abc1ceb5056fcd696587c66ac9d0 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Wed, 7 Oct 2026 15:05:59 -0600 Subject: [PATCH 18/21] chore: bump core --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a0ac3f8ff..92df60432 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "node": ">=22.0.0" }, "dependencies": { - "@salesforce/core": "^9.2.2", + "@salesforce/core": "^9.3.2", "@salesforce/kit": "^4.0.0", "@salesforce/ts-types": "^3.2.0", "@salesforce/types": "^1.6.0", diff --git a/yarn.lock b/yarn.lock index 4929862a7..77480a76c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -789,10 +789,10 @@ ts-retry-promise "^0.8.1" zod "^4.1.12" -"@salesforce/core@^9.2.2": - version "9.2.2" - resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-9.2.2.tgz#e20561e763fef210c8a9f585a2fea84f7606e63e" - integrity sha512-7D4VqZuCzYbbrW5Yr4ECbVb3+fUa//YG7UMmWGBy3IwI/f65U5NuZ6ZTBOYuC0AM2/LXF97j2jASf5WubQVERg== +"@salesforce/core@^9.3.2": + version "9.3.2" + resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-9.3.2.tgz#deda77122341b6bbeabe5569ecc6f5fa3c0b1dff" + integrity sha512-K9t6otT27v899XOVtTA11LTuzGvYl9LZjrPv7XerCIAs3IEiro7mZJg7Dd1VvJ7sR/4cwGyawP4K8P9FiYLnmg== dependencies: "@jsforce/jsforce-node" "^3.10.24" "@salesforce/kit" "^4.0.0" From 19ca9500cd3c8e048215471850598a306874c996 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 8 Oct 2026 09:18:02 -0600 Subject: [PATCH 19/21] fix: harden transport execution --- src/client/transports/computeSourceBundle.ts | 34 ++- .../transports/herokuComputeTransport.ts | 19 +- src/client/transports/types.ts | 6 +- src/client/types.ts | 2 + src/collections/componentSet.ts | 45 +++- .../transports/computeSourceBundle.test.ts | 116 +++++++++- .../transports/herokuComputeTransport.test.ts | 58 +++++ .../transports/transportIntegration.test.ts | 212 ++++++++++++++++++ 8 files changed, 477 insertions(+), 15 deletions(-) diff --git a/src/client/transports/computeSourceBundle.ts b/src/client/transports/computeSourceBundle.ts index 5eaf35ac1..e6179a8ef 100644 --- a/src/client/transports/computeSourceBundle.ts +++ b/src/client/transports/computeSourceBundle.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname, join, relative, resolve, sep } from 'node:path'; +import { access, lstat, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises'; +import { dirname, join, parse, relative, resolve, sep } from 'node:path'; import { createGzip, gunzipSync } from 'node:zlib'; import { buffer as streamToBuffer } from 'node:stream/consumers'; import tarStream from 'tar-stream'; @@ -83,11 +83,13 @@ export async function unpackComputeBundle(buffer: Buffer, appDir: string): Promi throw new Error(`Malformed compute source bundle: missing ${missing}`); } + const resolvedAppDir = resolve(appDir); + await rejectSymlinks(resolvedAppDir); await mkdir(appDir, { recursive: true }); + await rejectSymlinks(join(resolvedAppDir, 'api-spec.yaml')); await writeFile(join(appDir, 'api-spec.yaml'), apiSpec); const sourceEntries = await untarGzip(sourceTarGz); - const resolvedAppDir = resolve(appDir); await Promise.all( Object.entries(sourceEntries).map(async ([relativePath, data]) => { const filePath = resolve(appDir, relativePath); @@ -95,7 +97,9 @@ export async function unpackComputeBundle(buffer: Buffer, appDir: string): Promi if (filePath !== resolvedAppDir && !filePath.startsWith(resolvedAppDir + sep)) { throw new Error(`Malformed compute source bundle: unsafe entry path "${relativePath}"`); } + await rejectSymlinks(filePath); await mkdir(dirname(filePath), { recursive: true }); + await rejectSymlinks(filePath); await writeFile(filePath, data); }) ); @@ -103,6 +107,30 @@ export async function unpackComputeBundle(buffer: Buffer, appDir: string): Promi return { fileCount: Object.keys(sourceEntries).length + 1 }; } +async function rejectSymlinks(filePath: string): Promise { + let current = resolve(filePath); + const root = parse(current).root; + // The filesystem root is trusted; inspect every component beneath it, including + // ancestors of an already-existing app or output file. + while (current !== root) { + try { + // eslint-disable-next-line no-await-in-loop + const stat = await lstat(current); + if (stat.isSymbolicLink()) { + // macOS exposes /var as a system alias for /private/var. Do not reject + // ordinary output paths in its temporary directory for that alias. + // eslint-disable-next-line no-await-in-loop + if (!(process.platform === 'darwin' && current === '/var' && (await realpath(current)) === '/private/var')) { + throw new Error(`Malformed compute source bundle: symlink in output path "${current}"`); + } + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + current = dirname(current); + } +} + function untarGzip(buffer: Buffer): Promise> { return new Promise((resolveEntries, reject) => { let decompressed: Buffer; diff --git a/src/client/transports/herokuComputeTransport.ts b/src/client/transports/herokuComputeTransport.ts index 94e8a289a..bb3b064a7 100644 --- a/src/client/transports/herokuComputeTransport.ts +++ b/src/client/transports/herokuComputeTransport.ts @@ -141,7 +141,24 @@ export class HerokuComputeTransport implements TransportProvider { for (const component of context.components) { try { - const contentPath = component.content ?? resolveContentPath(component, context.project); + const packageName = context.transportPackageNames?.[`${component.type.name}#${component.fullName}`]; + const packageOutput = packageName + ? context.packageOptions + ?.map((option) => (typeof option === 'string' ? { name: option, outputDir: option } : option)) + .find((option) => option.name === packageName) + : undefined; + if (!component.content && packageName && !packageOutput) { + throw new Error(`No package option found for '${packageName}' (component '${component.fullName}')`); + } + const contentPath = + component.content ?? + (packageOutput || context.output + ? join( + packageOutput?.outputDir ?? packageOutput?.name ?? context.output!, + component.type.directoryName, + component.fullName + ) + : resolveContentPath(component, context.project)); this.logger.debug('retrieving compute source for %s into %s', component.fullName, contentPath); // eslint-disable-next-line no-await-in-loop diff --git a/src/client/transports/types.ts b/src/client/transports/types.ts index 7fb2fb8bb..097bff7f9 100644 --- a/src/client/transports/types.ts +++ b/src/client/transports/types.ts @@ -17,7 +17,7 @@ import { Connection, SfProject } from '@salesforce/core'; import { TransportName } from '../../registry/types'; import { SourceComponent } from '../../resolve/sourceComponent'; -import { FileResponse } from '../types'; +import { FileResponse, PackageOptions } from '../types'; export type TransportStatus = 'Pending' | 'InProgress' | 'Succeeded' | 'Failed' | 'Unknown'; @@ -31,6 +31,10 @@ export type TransportContext = { connection: Connection; project: SfProject; orgId: string; + output?: string; + packageOptions?: PackageOptions; + /** Package name for each manifest-only transport member (`type#fullName`). */ + transportPackageNames?: Record; }; export type TransportResult = { diff --git a/src/client/types.ts b/src/client/types.ts index a12198550..ad7e6fcc7 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -423,4 +423,6 @@ export type MetadataApiRetrieveOptions = MetadataTransferOptions & * secondary transport (Connect API, presigned URL, etc.) will not run. */ skipTransports?: string[]; + /** Explicit package association for manifest-only transport members, keyed by `type#fullName`. */ + transportPackageNames?: Record; }; diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index 98be9c6af..fbbe55049 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -411,7 +411,7 @@ export class ComponentSet extends LazyCollection { ); } - const pipeline = this.transportPipeline; + const pipeline = options.apiOptions?.checkOnly ? undefined : this.transportPipeline; if (pipeline?.hasTransports(toDeploy)) { return this.deployWithPipeline(options, toDeploy, pipeline); } @@ -455,14 +455,26 @@ export class ComponentSet extends LazyCollection { ); } - const pipeline = this.transportPipeline; - const toRetrieve = Array.from(this.getSourceComponents()); - // For retrieve, also include manifest-only components (e.g., -m flag - // where the component doesn't exist locally yet) so the transport - // pipeline can handle them. - const allComponents = toRetrieve.length > 0 ? toRetrieve : this.getManifestSourceComponents(); - if (pipeline?.hasTransports(allComponents)) { - return this.retrieveWithPipeline(operationOptions, allComponents, pipeline); + if (options.format !== 'metadata') { + const sourceComponents = this.getSourceComponents().toArray(); + const sourceMembers = new Set( + sourceComponents.map((component) => `${component.type.name}#${component.fullName}`) + ); + const selected = this.getManifestSourceComponents().filter( + (component) => !sourceMembers.has(`${component.type.name}#${component.fullName}`) + ); + const skipped = new Set(options.skipTransports); + const transportComponents = [...selected, ...sourceComponents].filter( + (component) => !skipped.has(component.type.name) + ); + if (transportComponents.length > 0) { + const pipeline = transportComponents.some((component) => component.type.strategies?.transport) + ? this.transportPipeline + : undefined; + if (pipeline?.hasTransports(transportComponents)) { + return this.retrieveWithPipeline(operationOptions, transportComponents, pipeline); + } + } } const mdapiRetrieve = new MetadataApiRetrieve(operationOptions); @@ -821,6 +833,9 @@ export class ComponentSet extends LazyCollection { connection, project: await SfProject.resolve(this.projectDirectory), orgId: connection.getAuthInfoFields().orgId ?? '', + output: operationOptions.output, + packageOptions: operationOptions.packageOptions, + transportPackageNames: operationOptions.transportPackageNames, }; const coordinator = new TransportCoordinator(pipeline, transports, transportContext, 'retrieve'); @@ -836,6 +851,15 @@ export class ComponentSet extends LazyCollection { private getManifestSourceComponents(): SourceComponent[] { const seen = new Set(); const results: SourceComponent[] = []; + for (const component of this) { + if (!(component instanceof SourceComponent) && component.type.strategies?.transport) { + const key = `${component.type.name}#${component.fullName}`; + if (!seen.has(key)) { + seen.add(key); + results.push(new SourceComponent({ type: component.type, name: component.fullName })); + } + } + } for (const [, members] of this.manifestComponents) { for (const [, comp] of members) { const key = `${comp.type.name}#${comp.fullName}`; @@ -852,6 +876,9 @@ export class ComponentSet extends LazyCollection { for (const comp of this.getSourceComponents()) { if (comp.type.strategies?.transport) return true; } + for (const component of this) { + if (component.type.strategies?.transport) return true; + } // Also check manifestComponents for components without local source // (e.g., -m retrieves where the component doesn't exist on disk yet) for (const [, members] of this.manifestComponents) { diff --git a/test/client/transports/computeSourceBundle.test.ts b/test/client/transports/computeSourceBundle.test.ts index 11f6daf97..8862034ef 100644 --- a/test/client/transports/computeSourceBundle.test.ts +++ b/test/client/transports/computeSourceBundle.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { mkdir, readFile, writeFile, rm } from 'node:fs/promises'; +import { access, mkdir, readFile, writeFile, rm, symlink } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createGzip } from 'node:zlib'; @@ -84,6 +84,120 @@ describe('computeSourceBundle', () => { }); describe('unpackComputeBundle', () => { + it('rejects an existing app and output file below a symlinked ancestor without overwriting outside', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'openapi: 3.0.0'); + await writeFile(join(appDir, 'main.py'), 'new'); + const bundle = await packageComputeBundle(appDir, 'MyApp'); + const outside = join(tempDir, 'outside'); + const outsideApp = join(outside, 'platformComputeApps', 'MyApp'); + await mkdir(outsideApp, { recursive: true }); + await writeFile(join(outsideApp, 'api-spec.yaml'), 'original spec'); + await writeFile(join(outsideApp, 'main.py'), 'original source'); + const outputLink = join(tempDir, 'output-link'); + await symlink(outside, outputLink); + + let error: Error | undefined; + try { + await unpackComputeBundle(bundle.buffer, join(outputLink, 'platformComputeApps', 'MyApp')); + } catch (err) { + error = err as Error; + } + expect(error?.message).to.include('symlink'); + expect(await readFile(join(outsideApp, 'api-spec.yaml'), 'utf8')).to.equal('original spec'); + expect(await readFile(join(outsideApp, 'main.py'), 'utf8')).to.equal('original source'); + }); + + it('rejects a symlinked app directory without writing outside', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'openapi: 3.0.0'); + await writeFile(join(appDir, 'main.py'), 'safe'); + const bundle = await packageComputeBundle(appDir, 'MyApp'); + const outside = join(tempDir, 'outside'); + await mkdir(outside); + const output = join(tempDir, 'output-link'); + await symlink(outside, output); + + try { + await unpackComputeBundle(bundle.buffer, output); + expect.fail('should reject symlinked output'); + } catch (err) { + expect((err as Error).message).to.include('symlink'); + } + try { + await access(join(outside, 'api-spec.yaml')); + expect.fail('should not write outside'); + } catch (err) { + expect((err as Error).message).to.not.include('should not write outside'); + } + }); + + it('rejects an app directory below a symlinked ancestor without writing outside', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'openapi: 3.0.0'); + await writeFile(join(appDir, 'main.py'), 'safe'); + const bundle = await packageComputeBundle(appDir, 'MyApp'); + const outside = join(tempDir, 'outside'); + await mkdir(outside); + const outputLink = join(tempDir, 'output-link'); + await symlink(outside, outputLink); + const output = join(outputLink, 'platformComputeApps', 'MyApp'); + + try { + await unpackComputeBundle(bundle.buffer, output); + expect.fail('should reject symlinked output ancestor'); + } catch (err) { + expect((err as Error).message).to.include('symlink'); + } + try { + await access(join(outside, 'platformComputeApps', 'MyApp', 'api-spec.yaml')); + expect.fail('should not write outside'); + } catch (err) { + expect((err as Error).message).to.not.include('should not write outside'); + } + }); + + it('rejects an existing symlinked nested source directory without writing outside', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'openapi: 3.0.0'); + await mkdir(join(appDir, 'lib')); + await writeFile(join(appDir, 'lib', 'utils.py'), 'safe'); + const bundle = await packageComputeBundle(appDir, 'MyApp'); + const output = join(tempDir, 'output'); + const outside = join(tempDir, 'outside'); + await mkdir(output); + await mkdir(outside); + await symlink(outside, join(output, 'lib')); + + try { + await unpackComputeBundle(bundle.buffer, output); + expect.fail('should reject symlinked child'); + } catch (err) { + expect((err as Error).message).to.include('symlink'); + } + try { + await access(join(outside, 'utils.py')); + expect.fail('should not write outside'); + } catch (err) { + expect((err as Error).message).to.not.include('should not write outside'); + } + }); + + it('rejects a symlinked api-spec output without writing outside', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'openapi: 3.0.0'); + await writeFile(join(appDir, 'main.py'), 'safe'); + const bundle = await packageComputeBundle(appDir, 'MyApp'); + const output = join(tempDir, 'output'); + const outside = join(tempDir, 'outside-spec.yaml'); + await mkdir(output); + await writeFile(outside, 'original'); + await symlink(outside, join(output, 'api-spec.yaml')); + + try { + await unpackComputeBundle(bundle.buffer, output); + expect.fail('should reject symlinked api-spec'); + } catch (err) { + expect((err as Error).message).to.include('symlink'); + } + expect(await readFile(outside, 'utf8')).to.equal('original'); + }); + it('should round-trip pack and unpack', async () => { await writeFile(join(appDir, 'api-spec.yaml'), 'openapi: 3.0.0\ninfo:\n title: test'); await writeFile(join(appDir, 'MyApp.compute-meta.xml'), ''); diff --git a/test/client/transports/herokuComputeTransport.test.ts b/test/client/transports/herokuComputeTransport.test.ts index a4ff9a66a..bf03dcbf9 100644 --- a/test/client/transports/herokuComputeTransport.test.ts +++ b/test/client/transports/herokuComputeTransport.test.ts @@ -229,6 +229,64 @@ describe('HerokuComputeTransport', () => { }); describe('retrieve', () => { + it('routes manifest-only members to their associated package output directories', async () => { + const unpack = sinon.stub(computeSourceBundle, 'unpackComputeBundle').resolves({ fileCount: 2 }); + try { + const request = sinon.stub().resolves(Buffer.from('bundle').toString('binary')); + const type = registryAccess.getTypeByName('PlatformComputeApp'); + const context = createMockContext({ + output: '/unpackaged', + packageOptions: [ + { name: 'FirstPackage', outputDir: '/packages/first' }, + { name: 'SecondPackage', outputDir: '/packages/second' }, + ], + transportPackageNames: { + 'PlatformComputeApp#FirstApp': 'FirstPackage', + 'PlatformComputeApp#SecondApp': 'SecondPackage', + }, + connection: { version: '68.0', request } as unknown as Connection, + components: [ + new SourceComponent({ name: 'FirstApp', type }), + new SourceComponent({ name: 'SecondApp', type }), + ], + }); + + await transport.afterRetrieve(context); + + expect(unpack.firstCall.args[1]).to.equal('/packages/first/platformComputeApps/FirstApp'); + expect(unpack.secondCall.args[1]).to.equal('/packages/second/platformComputeApps/SecondApp'); + } finally { + unpack.restore(); + } + }); + + it('uses explicit output for manifest-only content but preserves existing content paths', async () => { + const unpack = sinon.stub(computeSourceBundle, 'unpackComputeBundle').resolves({ fileCount: 2 }); + try { + const request = sinon.stub().resolves(Buffer.from('bundle').toString('binary')); + const type = registryAccess.getTypeByName('PlatformComputeApp'); + const context = createMockContext({ + output: '/custom/output', + connection: { version: '68.0', request } as unknown as Connection, + components: [ + new SourceComponent({ name: 'RemoteApp', type }), + createComputeComponent('LocalApp', '/local/app'), + ], + }); + + const result = await transport.afterRetrieve(context); + + expect(unpack.firstCall.args[1]).to.equal('/custom/output/platformComputeApps/RemoteApp'); + expect(unpack.secondCall.args[1]).to.equal('/local/app'); + expect(result.fileResponses.map((response) => response.filePath)).to.deep.equal([ + '/custom/output/platformComputeApps/RemoteApp', + '/local/app', + ]); + } finally { + unpack.restore(); + } + }); + it('should throw with error details on download failure', async () => { const stub = sinon.stub(); stub.rejects(new Error('404 not found')); diff --git a/test/client/transports/transportIntegration.test.ts b/test/client/transports/transportIntegration.test.ts index 134bd375e..bb4d439f3 100644 --- a/test/client/transports/transportIntegration.test.ts +++ b/test/client/transports/transportIntegration.test.ts @@ -28,6 +28,8 @@ import { SourceComponent, } from '../../../src'; import { TransportPipeline } from '../../../src/client/transports/transportPipeline'; +import { MetadataApiRetrieve } from '../../../src/client/metadataApiRetrieve'; +import { MetadataApiDeploy } from '../../../src/client/metadataApiDeploy'; import { TransportContext, TransportDescription, @@ -239,6 +241,216 @@ describe('Transport integration', () => { }); }); + describe('ComponentSet transport selection', () => { + const connection = { getAuthInfoFields: () => ({ orgId: '00D' }) } as Connection; + const project = {} as SfProject; + + beforeEach(() => { + sinon.stub(SfProject, 'resolve').resolves(project); + sinon.stub(MetadataApiRetrieve.prototype, 'start').resolves({ id: 'retrieve' }); + sinon.stub(MetadataApiDeploy.prototype, 'start').resolves({ id: 'deploy' }); + }); + + it('preserves both source locations for the same metadata member when grouping', async () => { + const type = registryAccess.getTypeByName('PlatformComputeApp'); + const first = new SourceComponent({ name: 'SharedApp', type, content: '/first/SharedApp' }); + const second = new SourceComponent({ name: 'SharedApp', type, content: '/second/SharedApp' }); + const set = new ComponentSet([{ fullName: 'SharedApp', type }, first, second]); + const pipeline = new TransportPipeline([ + createMockTransport({ + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['PlatformComputeApp'], + }), + ]); + set.transportPipeline = pipeline; + const group = sinon.spy(pipeline, 'groupByTransport'); + + await set.retrieve({ output: '/out', usernameOrConnection: connection }); + + expect(group.firstCall.returnValue.transports[0].components).to.deep.equal([first, second]); + }); + + it('selects both local and manifest-only members and prefers content for duplicates without changing MDAPI members', async () => { + const type = registryAccess.getTypeByName('PlatformComputeApp'); + const local = new SourceComponent({ name: 'LocalApp', type, content: '/local/app' }); + const set = new ComponentSet(); + set.add({ fullName: 'LocalApp', type }); + set.add({ fullName: 'RemoteApp', type }); + set.add(local); + const pipeline = new TransportPipeline([ + createMockTransport({ + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['PlatformComputeApp'], + }), + ]); + set.transportPipeline = pipeline; + const group = sinon.spy(pipeline, 'groupByTransport'); + expect((await set.getObject()).Package.types).to.deep.include({ + name: 'PlatformComputeApp', + members: ['LocalApp', 'RemoteApp'], + }); + + await set.retrieve({ output: '/out', usernameOrConnection: connection }); + + expect(group.calledOnce).to.be.true; + const grouped = group.firstCall.returnValue.transports[0].components; + expect(grouped.map((c) => c.fullName)).to.have.members(['LocalApp', 'RemoteApp']); + expect(grouped).to.have.lengthOf(2); + expect(grouped.find((c) => c.fullName === 'LocalApp')).to.equal(local); + expect(grouped.find((c) => c.fullName === 'RemoteApp')?.content).to.be.undefined; + expect((await set.getObject()).Package.types).to.deep.include({ + name: 'PlatformComputeApp', + members: ['LocalApp', 'RemoteApp'], + }); + }); + + it('skips transport by metadata type while retaining all MDAPI members', async () => { + const type = registryAccess.getTypeByName('PlatformComputeApp'); + const set = new ComponentSet([new SourceComponent({ name: 'MyApp', type, content: '/local/app' })]); + const pipeline = new TransportPipeline([ + createMockTransport({ + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['PlatformComputeApp'], + }), + ]); + set.transportPipeline = pipeline; + const group = sinon.spy(pipeline, 'groupByTransport'); + const run = sinon.spy(pipeline, 'runBeforeMetadata'); + + const result = await set.retrieve({ + output: '/out', + usernameOrConnection: connection, + skipTransports: ['PlatformComputeApp'], + }); + + expect(group.called).to.be.false; + expect(run.called).to.be.false; + expect((await set.getObject()).Package.types).to.deep.include({ name: 'PlatformComputeApp', members: ['MyApp'] }); + expect(result).to.be.instanceOf(MetadataApiRetrieve); + }); + + it('does not activate a skipped provider when other metadata types remain', async () => { + const compute = registryAccess.getTypeByName('PlatformComputeApp'); + const apex = registryAccess.getTypeByName('ApexClass'); + const set = new ComponentSet([ + new SourceComponent({ name: 'MyApp', type: compute }), + new SourceComponent({ name: 'MyClass', type: apex }), + ]); + const pipeline = new TransportPipeline(); + set.transportPipeline = pipeline; + const group = sinon.spy(pipeline, 'groupByTransport'); + await set.retrieve({ output: '/out', usernameOrConnection: connection, skipTransports: ['PlatformComputeApp'] }); + expect(group.called).to.be.false; + expect((await set.getObject()).Package.types).to.deep.include({ name: 'PlatformComputeApp', members: ['MyApp'] }); + }); + + it('does not construct a builtin pipeline for skipped transport types alongside regular metadata', async () => { + const compute = registryAccess.getTypeByName('PlatformComputeApp'); + const apex = registryAccess.getTypeByName('ApexClass'); + const set = new ComponentSet([ + new SourceComponent({ name: 'MyApp', type: compute }), + new SourceComponent({ name: 'MyClass', type: apex }), + ]); + const builtin = sinon.spy(TransportPipeline, 'withBuiltinTransports'); + + await set.retrieve({ output: '/out', usernameOrConnection: connection, skipTransports: ['PlatformComputeApp'] }); + + expect(builtin.called).to.be.false; + }); + + it('does not run a secondary transport for metadata format', async () => { + const type = registryAccess.getTypeByName('PlatformComputeApp'); + const set = new ComponentSet([new SourceComponent({ name: 'MyApp', type, content: '/local/app' })]); + const pipeline = new TransportPipeline(); + set.transportPipeline = pipeline; + await set.retrieve({ output: '/out', format: 'metadata', usernameOrConnection: connection }); + expect((await set.getObject()).Package.types).to.deep.include({ name: 'PlatformComputeApp', members: ['MyApp'] }); + }); + + it('does not run a secondary transport for checkOnly but still starts MDAPI with that option', async () => { + const type = registryAccess.getTypeByName('PlatformComputeApp'); + const set = new ComponentSet([new SourceComponent({ name: 'MyApp', type, content: '/local/app' })]); + const pipeline = new TransportPipeline(); + set.transportPipeline = pipeline; + const result = await set.deploy({ usernameOrConnection: connection, apiOptions: { checkOnly: true } }); + expect(result).to.be.instanceOf(MetadataApiDeploy); + expect((result as unknown as { options: { apiOptions: { checkOnly: boolean } } }).options.apiOptions.checkOnly).to + .be.true; + }); + + it('passes explicit output as the source fallback for manifest-only retrieve', async () => { + const type = registryAccess.getTypeByName('PlatformComputeApp'); + const set = new ComponentSet(); + set.add({ fullName: 'RemoteApp', type }); + const pipeline = new TransportPipeline([ + createMockTransport({ + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['PlatformComputeApp'], + }), + ]); + set.transportPipeline = pipeline; + const run = sinon.spy(pipeline, 'runBeforeMetadata'); + expect((await set.getObject()).Package.types).to.deep.include({ + name: 'PlatformComputeApp', + members: ['RemoteApp'], + }); + await set.retrieve({ output: '/custom/output', usernameOrConnection: connection }); + expect((run.firstCall.args[0] as TransportContext & { output?: string }).output).to.equal('/custom/output'); + expect(run.firstCall.args[0].components[0].content).to.be.undefined; + }); + + it('passes package association through the retrieve transport context', async () => { + const type = registryAccess.getTypeByName('PlatformComputeApp'); + const set = new ComponentSet([{ fullName: 'RemoteApp', type }]); + const pipeline = new TransportPipeline([ + createMockTransport({ + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['PlatformComputeApp'], + }), + ]); + set.transportPipeline = pipeline; + const run = sinon.spy(pipeline, 'runBeforeMetadata'); + const packageOptions = [{ name: 'NamedPackage', outputDir: '/package/output' }]; + const transportPackageNames = { 'PlatformComputeApp#RemoteApp': 'NamedPackage' }; + + await set.retrieve({ + output: '/unpackaged', + packageOptions, + transportPackageNames, + usernameOrConnection: connection, + }); + + expect(run.firstCall.args[0].packageOptions).to.equal(packageOptions); + expect(run.firstCall.args[0].transportPackageNames).to.equal(transportPackageNames); + }); + + it('selects manifest-only members supplied to the constructor', async () => { + const type = registryAccess.getTypeByName('PlatformComputeApp'); + const set = new ComponentSet([{ fullName: 'ConstructorApp', type }]); + const pipeline = new TransportPipeline([ + createMockTransport({ + deployPhase: 'before-metadata', + retrievePhase: 'after-metadata', + typeNames: ['PlatformComputeApp'], + }), + ]); + set.transportPipeline = pipeline; + const group = sinon.spy(pipeline, 'groupByTransport'); + + await set.retrieve({ output: '/custom/output', usernameOrConnection: connection }); + + expect(group.calledOnce).to.be.true; + expect(group.firstCall.args[0].map((component: SourceComponent) => component.fullName)).to.deep.equal([ + 'ConstructorApp', + ]); + }); + }); + describe('RetrieveResult deduplication', () => { it('should deduplicate identical transport responses', () => { const result = new RetrieveResult(createRetrieveStatus(), new ComponentSet()); From 3f1b7415aa5f6b4401ce48caa4e1af214e7d3171 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 8 Oct 2026 13:18:59 -0600 Subject: [PATCH 20/21] fix: bound transport resources --- src/client/metadataTransfer.ts | 2 +- src/client/transports/computeSourceBundle.ts | 139 ++++++++--- .../transports/herokuComputeTransport.ts | 9 +- src/client/transports/transportCoordinator.ts | 13 +- src/collections/componentSet.ts | 10 + test/client/metadataTransfer.test.ts | 18 +- .../transports/computeSourceBundle.test.ts | 217 +++++++++++++++++- .../transports/herokuComputeTransport.test.ts | 20 ++ .../transports/transportIntegration.test.ts | 142 +++++++++++- 9 files changed, 533 insertions(+), 37 deletions(-) diff --git a/src/client/metadataTransfer.ts b/src/client/metadataTransfer.ts index 3adec9088..d8eb9d090 100644 --- a/src/client/metadataTransfer.ts +++ b/src/client/metadataTransfer.ts @@ -182,7 +182,7 @@ export abstract class MetadataTransfer< // this keeps SfError data for failures in post deploy/retrieve. error.setData({ id: this.id, - causeErrorData: error.data, + causeErrorData: err.data as AnyJson, }); error.actions = err.actions; diff --git a/src/client/transports/computeSourceBundle.ts b/src/client/transports/computeSourceBundle.ts index e6179a8ef..b7275ae25 100644 --- a/src/client/transports/computeSourceBundle.ts +++ b/src/client/transports/computeSourceBundle.ts @@ -24,6 +24,14 @@ import { ForceIgnore } from '../../resolve/forceIgnore'; const logger = Logger.childFromRoot('computeSourceBundle'); +// Compute source bundles are app source, not dependency caches or compiled artifacts. +// Cap both gzip layers and the tar entries retained in memory when unpacking. +export const MAX_COMPRESSED_BYTES = 40 * 1024 * 1024; +const MAX_TAR_BYTES = 64 * 1024 * 1024; +const MAX_ENTRY_BYTES = 16 * 1024 * 1024; +const MAX_TOTAL_ENTRY_BYTES = 40 * 1024 * 1024; +const MAX_ENTRIES = 1000; + export type PackagedBundle = { buffer: Buffer; fileCount: number; @@ -47,14 +55,30 @@ export async function packageComputeBundle(appDir: string, name: string): Promis const excluded = new Set(['api-spec.yaml', metaFileName, 'project.toml', '.forceignore']); const forceIgnore = ForceIgnore.findAndCreate(appDir); - const allFiles = await listFilesRecursive(appDir); - const sourceRelativePaths = allFiles.filter((f) => !excluded.has(f) && forceIgnore.accepts(join(appDir, f))); - logger.debug( - 'packaging %s: %d of %d files after exclusions/.forceignore', + const sourceRelativePaths = await listFilesRecursive( appDir, - sourceRelativePaths.length, - allFiles.length + (file) => !excluded.has(file) && forceIgnore.accepts(join(appDir, file)) ); + logger.debug('packaging %s: %d files after exclusions/.forceignore', appDir, sourceRelativePaths.length); + + const apiSpec = await readFile(apiSpecPath); + if (apiSpec.length > MAX_ENTRY_BYTES) { + throw new Error(`Compute source bundle: api-spec.yaml entry size limit (${MAX_ENTRY_BYTES} bytes) exceeded`); + } + let sourceBytes = 0; + for (const relativePath of sourceRelativePaths) { + // eslint-disable-next-line no-await-in-loop + const { size } = await lstat(join(appDir, relativePath)); + if (size > MAX_ENTRY_BYTES) { + throw new Error( + `Compute source bundle: entry size limit (${MAX_ENTRY_BYTES} bytes) exceeded for ${relativePath}` + ); + } + sourceBytes += size; + if (sourceBytes > MAX_TOTAL_ENTRY_BYTES) { + throw new Error(`Compute source bundle: total size limit (${MAX_TOTAL_ENTRY_BYTES} bytes) exceeded`); + } + } const sourceEntries = await Promise.all( sourceRelativePaths.map(async (relativePath) => ({ @@ -62,20 +86,35 @@ export async function packageComputeBundle(appDir: string, name: string): Promis data: await readFile(join(appDir, relativePath)), })) ); + // The source may change between stat and read; check actual bytes before assembling. + if (sourceEntries.some(({ data }) => data.length > MAX_ENTRY_BYTES)) { + throw new Error(`Compute source bundle: entry size limit (${MAX_ENTRY_BYTES} bytes) exceeded`); + } + if (sourceEntries.reduce((total, { data }) => total + data.length, 0) > MAX_TOTAL_ENTRY_BYTES) { + throw new Error(`Compute source bundle: total size limit (${MAX_TOTAL_ENTRY_BYTES} bytes) exceeded`); + } const sourceTarGz = await tarGzip(sourceEntries); - const apiSpec = await readFile(apiSpecPath); + if (sourceTarGz.length > MAX_COMPRESSED_BYTES) { + throw new Error(`Compute source bundle: source.tar.gz entry size limit (${MAX_COMPRESSED_BYTES} bytes) exceeded`); + } + if (apiSpec.length + sourceTarGz.length > MAX_TOTAL_ENTRY_BYTES) { + throw new Error(`Compute source bundle: outer total size limit (${MAX_TOTAL_ENTRY_BYTES} bytes) exceeded`); + } const buffer = await tarGzip([ { name: 'api-spec.yaml', data: apiSpec }, { name: 'source.tar.gz', data: sourceTarGz }, ]); + if (buffer.length > MAX_COMPRESSED_BYTES) { + throw new Error(`Compute source bundle: compressed size limit (${MAX_COMPRESSED_BYTES} bytes) exceeded`); + } logger.debug('packaged %s into %d bytes (%d files)', appDir, buffer.length, sourceEntries.length + 1); return { buffer, fileCount: sourceEntries.length + 1 }; } export async function unpackComputeBundle(buffer: Buffer, appDir: string): Promise { - const outer = await untarGzip(buffer); + const outer = await untarGzip(buffer, true); const apiSpec = outer['api-spec.yaml']; const sourceTarGz = outer['source.tar.gz']; if (!apiSpec || !sourceTarGz) { @@ -89,7 +128,7 @@ export async function unpackComputeBundle(buffer: Buffer, appDir: string): Promi await rejectSymlinks(join(resolvedAppDir, 'api-spec.yaml')); await writeFile(join(appDir, 'api-spec.yaml'), apiSpec); - const sourceEntries = await untarGzip(sourceTarGz); + const sourceEntries = await untarGzip(sourceTarGz, false); await Promise.all( Object.entries(sourceEntries).map(async ([relativePath, data]) => { const filePath = resolve(appDir, relativePath); @@ -131,21 +170,61 @@ async function rejectSymlinks(filePath: string): Promise { } } -function untarGzip(buffer: Buffer): Promise> { +function untarGzip(buffer: Buffer, outer: boolean): Promise> { return new Promise((resolveEntries, reject) => { + if (buffer.length > MAX_COMPRESSED_BYTES) { + reject( + new Error(`Malformed compute source bundle: compressed size limit (${MAX_COMPRESSED_BYTES} bytes) exceeded`) + ); + return; + } let decompressed: Buffer; try { - decompressed = gunzipSync(buffer); - } catch { - reject(new Error('Malformed compute source bundle: not a valid gzip archive')); + decompressed = gunzipSync(buffer, { maxOutputLength: MAX_TAR_BYTES }); + } catch (err) { + reject( + new Error( + (err as NodeJS.ErrnoException).code === 'ERR_BUFFER_TOO_LARGE' + ? `Malformed compute source bundle: decompressed size limit (${MAX_TAR_BYTES} bytes) exceeded` + : 'Malformed compute source bundle: not a valid gzip archive' + ) + ); return; } const entries: Record = {}; const extract = tarStream.extract(); + let entryCount = 0; + let totalBytes = 0; extract.on('entry', (header, stream, next) => { + entryCount++; + if (entryCount > MAX_ENTRIES) { + reject(new Error(`Malformed compute source bundle: entry count limit (${MAX_ENTRIES}) exceeded`)); + extract.destroy(); + return; + } + const entryLimit = outer && header.name === 'source.tar.gz' ? MAX_COMPRESSED_BYTES : MAX_ENTRY_BYTES; + if (header.size > entryLimit || totalBytes + header.size > MAX_TOTAL_ENTRY_BYTES) { + reject( + new Error( + `Malformed compute source bundle: entry size limit (${entryLimit} bytes) or total size limit (${MAX_TOTAL_ENTRY_BYTES} bytes) exceeded` + ) + ); + extract.destroy(); + return; + } const chunks: Buffer[] = []; - stream.on('data', (chunk: unknown) => chunks.push(chunk as Buffer)); + stream.on('data', (chunk: unknown) => { + totalBytes += (chunk as Buffer).length; + if (totalBytes > MAX_TOTAL_ENTRY_BYTES) { + reject( + new Error(`Malformed compute source bundle: total size limit (${MAX_TOTAL_ENTRY_BYTES} bytes) exceeded`) + ); + extract.destroy(); + return; + } + chunks.push(chunk as Buffer); + }); stream.on('end', () => { entries[header.name] = Buffer.concat(chunks); next(); @@ -181,19 +260,27 @@ async function tarGzip(entries: ReadonlyArray<{ name: string; data: Buffer }>): return gzippedBufferPromise; } -async function listFilesRecursive(dir: string, base = dir): Promise { - const entries = await readdir(dir, { withFileTypes: true }); - const nested = await Promise.all( - entries.map(async (entry): Promise => { +async function listFilesRecursive(base: string, accepts: (file: string) => boolean): Promise { + const files: string[] = []; + async function walk(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; const full = join(dir, entry.name); if (entry.isDirectory()) { - return listFilesRecursive(full, base); - } - if (entry.isFile()) { - return [relative(base, full).split(sep).join('/')]; + // eslint-disable-next-line no-await-in-loop + await walk(full); + } else if (entry.isFile()) { + const file = relative(base, full).split(sep).join('/'); + if (accepts(file)) { + if (files.length === MAX_ENTRIES) { + throw new Error(`Compute source bundle: entry count limit (${MAX_ENTRIES}) exceeded`); + } + files.push(file); + } } - return []; - }) - ); - return nested.flat(); + } + } + await walk(base); + return files; } diff --git a/src/client/transports/herokuComputeTransport.ts b/src/client/transports/herokuComputeTransport.ts index bb3b064a7..cc291db62 100644 --- a/src/client/transports/herokuComputeTransport.ts +++ b/src/client/transports/herokuComputeTransport.ts @@ -22,7 +22,7 @@ import { DEFAULT_PACKAGE_ROOT_SFDX } from '../../common/constants'; import { SourceComponent } from '../../resolve/sourceComponent'; import { ComponentStatus, FileResponse } from '../types'; import { TransportContext, TransportDescription, TransportProvider, TransportResult } from './types'; -import { packageComputeBundle, unpackComputeBundle } from './computeSourceBundle'; +import { MAX_COMPRESSED_BYTES, packageComputeBundle, unpackComputeBundle } from './computeSourceBundle'; type ComputeSourceUploadResponse = { platformComputeId: string; @@ -70,12 +70,17 @@ async function uploadSource( } // jsforce decodes to UTF-8 by default — lossy for gzip bytes. -// encoding: 'binary' (latin1) preserves byte values 1:1. +// encoding: 'binary' (latin1) preserves byte values 1:1. Connection.request +// buffers the complete response; this guard prevents a second oversized Buffer +// allocation, while unpackComputeBundle limits the archive after download. async function downloadSource(connection: Connection, appIdOrName: string): Promise { const body = await connection.request( { method: 'GET', url: `/connect/compute/${encodeURIComponent(appIdOrName)}/source` }, { encoding: 'binary' } ); + if (body.length > MAX_COMPRESSED_BYTES) { + throw new Error(`Compute source bundle: compressed size limit (${MAX_COMPRESSED_BYTES} bytes) exceeded`); + } return Buffer.from(body, 'binary'); } diff --git a/src/client/transports/transportCoordinator.ts b/src/client/transports/transportCoordinator.ts index 6d9ff6b0d..a56c374f5 100644 --- a/src/client/transports/transportCoordinator.ts +++ b/src/client/transports/transportCoordinator.ts @@ -25,6 +25,8 @@ type TransportResult = DeployResult | RetrieveResult; export class TransportCoordinator { private beforeMetadataResults: TransportPipelineResult = { fileResponses: [], asyncHandles: [] }; + private readonly beforeAdded = new WeakSet(); + private readonly afterStarted = new WeakSet(); public constructor( private readonly pipeline: TransportPipeline, @@ -39,7 +41,13 @@ export class TransportCoordinator { } public async processResult(result: TransportResult, status: RequestStatus): Promise { + if (!this.beforeAdded.has(result)) { + this.beforeAdded.add(result); + result.addTransportResults(this.beforeMetadataResults.fileResponses, this.beforeMetadataResults.asyncHandles); + } if (status !== RequestStatus.Succeeded) return; + if (this.afterStarted.has(result)) return; + this.afterStarted.add(result); if (this.operation === 'retrieve' && result instanceof RetrieveResult) { const extractedByKey = new Map( @@ -56,9 +64,6 @@ export class TransportCoordinator { } const afterMetadataResults = await this.pipeline.runAfterMetadata(this.context, this.groups, this.operation); - result.addTransportResults( - [...this.beforeMetadataResults.fileResponses, ...afterMetadataResults.fileResponses], - [...this.beforeMetadataResults.asyncHandles, ...afterMetadataResults.asyncHandles] - ); + result.addTransportResults(afterMetadataResults.fileResponses, afterMetadataResults.asyncHandles); } } diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index fbbe55049..9ff75dead 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -790,6 +790,9 @@ export class ComponentSet extends LazyCollection { typeof options.usernameOrConnection === 'string' ? await Connection.create({ authInfo: await AuthInfo.create({ username: options.usernameOrConnection }) }) : options.usernameOrConnection; + if (typeof options.usernameOrConnection === 'string' && this.apiVersion && this.apiVersion !== connection.version) { + connection.setApiVersion(this.apiVersion); + } const transportContext = { components, @@ -827,6 +830,13 @@ export class ComponentSet extends LazyCollection { authInfo: await AuthInfo.create({ username: operationOptions.usernameOrConnection }), }) : operationOptions.usernameOrConnection; + if ( + typeof operationOptions.usernameOrConnection === 'string' && + this.apiVersion && + this.apiVersion !== connection.version + ) { + connection.setApiVersion(this.apiVersion); + } const transportContext = { components, diff --git a/test/client/metadataTransfer.test.ts b/test/client/metadataTransfer.test.ts index 4cfa006b8..9a226938a 100644 --- a/test/client/metadataTransfer.test.ts +++ b/test/client/metadataTransfer.test.ts @@ -15,7 +15,7 @@ */ import { fail } from 'node:assert'; import { SinonStub } from 'sinon'; -import { AuthInfo, Connection, Messages } from '@salesforce/core'; +import { AuthInfo, Connection, Messages, SfError } from '@salesforce/core'; import { assert, expect } from 'chai'; import { Duration, @@ -214,6 +214,22 @@ describe('MetadataTransfer', () => { expect(calls).to.deep.equal(['processor', 'returned']); }); + it('preserves result processor SfError data in the wrapped error', async () => { + const cause = new SfError('processor failed', 'ProcessorError'); + cause.setData({ fileResponses: [{ fullName: 'MyApp' }] }); + operation.addResultProcessor(async () => { + throw cause; + }); + + try { + await operation.pollStatus(); + fail('should have thrown an error'); + } catch (error) { + assert(error instanceof SfError); + expect(error.data).to.deep.equal({ id: undefined, causeErrorData: cause.data }); + } + }); + it('should exit and fire "cancel" event when done = true and request status is "Canceled"', async () => { const { checkStatus } = operation.lifecycle; checkStatus.resolves({ status: RequestStatus.Canceled, done: true }); diff --git a/test/client/transports/computeSourceBundle.test.ts b/test/client/transports/computeSourceBundle.test.ts index 8862034ef..c2039f616 100644 --- a/test/client/transports/computeSourceBundle.test.ts +++ b/test/client/transports/computeSourceBundle.test.ts @@ -14,15 +14,30 @@ * limitations under the License. */ -import { access, mkdir, readFile, writeFile, rm, symlink } from 'node:fs/promises'; +import { access, chmod, mkdir, readFile, writeFile, rm, symlink } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { createGzip } from 'node:zlib'; +import { createGzip, gzipSync } from 'node:zlib'; import { buffer as streamToBuffer } from 'node:stream/consumers'; +import { promises as fsPromises } from 'node:fs'; import { expect } from 'chai'; +import sinon from 'sinon'; import tarStream from 'tar-stream'; import { packageComputeBundle, unpackComputeBundle } from '../../../src/client/transports/computeSourceBundle'; +async function archive(entries: Array<{ name: string; data: Buffer }>): Promise { + const pack = tarStream.pack(); + const output = streamToBuffer(pack.pipe(createGzip())); + for (const entry of entries) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve, reject) => + pack.entry({ name: entry.name }, entry.data, (err) => (err ? reject(err) : resolve())) + ); + } + pack.finalize(); + return output; +} + describe('computeSourceBundle', () => { let tempDir: string; let appDir: string; @@ -38,6 +53,116 @@ describe('computeSourceBundle', () => { }); describe('packageComputeBundle', () => { + it('stops walking other directories once the source entry cap is exceeded', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'spec'); + const first = join(appDir, 'a'); + const later = join(appDir, 'z'); + await mkdir(first); + await mkdir(later); + await Promise.all(Array.from({ length: 1001 }, (_, i) => writeFile(join(first, `${i}.py`), 'x'))); + const readdir = sinon.spy(fsPromises, 'readdir'); + try { + let error: Error | undefined; + try { + await packageComputeBundle(appDir, 'MyApp'); + } catch (err) { + error = err as Error; + } + expect(error?.message).to.include('entry count limit'); + expect(readdir.getCalls().some(({ args }) => args[0] === later)).to.be.false; + } finally { + readdir.restore(); + } + }); + + it('rejects source entry count beyond the unpack limit', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'spec'); + await Promise.all(Array.from({ length: 1001 }, (_, i) => writeFile(join(appDir, `${i}.py`), 'x'))); + try { + await packageComputeBundle(appDir, 'MyApp'); + expect.fail('should reject count'); + } catch (err) { + expect((err as Error).message).to.include('entry count limit'); + } + }); + + it('accepts a source entry exactly at the per-entry limit', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'spec'); + await writeFile(join(appDir, 'large.py'), Buffer.alloc(16 * 1024 * 1024)); + const bundle = await packageComputeBundle(appDir, 'MyApp'); + await unpackComputeBundle(bundle.buffer, join(tempDir, 'boundary-output')); + expect(bundle.fileCount).to.equal(2); + }); + + it('rejects a source entry above the per-entry limit', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'spec'); + await writeFile(join(appDir, 'large.py'), Buffer.alloc(16 * 1024 * 1024 + 1)); + try { + await packageComputeBundle(appDir, 'MyApp'); + expect.fail('should reject size'); + } catch (err) { + expect((err as Error).message).to.include('entry size limit'); + } + }); + + it('rejects aggregate source entry bytes above the unpack limit', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'spec'); + await Promise.all([0, 1, 2].map((i) => writeFile(join(appDir, `${i}.py`), Buffer.alloc(14 * 1024 * 1024)))); + try { + await packageComputeBundle(appDir, 'MyApp'); + expect.fail('should reject size'); + } catch (err) { + expect((err as Error).message).to.include('total size limit'); + } + }); + + it('rejects an outer api-spec entry above the unpack per-entry limit', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), Buffer.alloc(16 * 1024 * 1024 + 1)); + try { + await packageComputeBundle(appDir, 'MyApp'); + expect.fail('should reject outer entry size'); + } catch (err) { + expect((err as Error).message).to.include('api-spec.yaml entry size limit'); + } + }); + + it('does not traverse dot-directories even when nested', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'spec'); + await mkdir(join(appDir, 'lib', '.hidden'), { recursive: true }); + await writeFile(join(appDir, 'lib', '.hidden', 'secret'), 'secret'); + await chmod(join(appDir, 'lib', '.hidden'), 0); + try { + const bundle = await packageComputeBundle(appDir, 'MyApp'); + expect(bundle.fileCount).to.equal(1); + } finally { + await chmod(join(appDir, 'lib', '.hidden'), 0o700); + } + }); + + it('excludes dotfiles, VCS trees, and secrets without .forceignore', async () => { + await writeFile(join(appDir, 'api-spec.yaml'), 'openapi: 3.0.0'); + await writeFile(join(appDir, 'main.py'), 'safe'); + await writeFile(join(appDir, '.env'), 'SECRET=1'); + await mkdir(join(appDir, '.git', 'objects'), { recursive: true }); + await writeFile(join(appDir, '.git', 'objects', 'secret'), 'secret'); + await mkdir(join(appDir, 'lib', '.hidden'), { recursive: true }); + await writeFile(join(appDir, 'lib', '.hidden', 'secret'), 'secret'); + + const bundle = await packageComputeBundle(appDir, 'MyApp'); + expect(bundle.fileCount).to.equal(2); + const output = join(tempDir, 'safe-output'); + await unpackComputeBundle(bundle.buffer, output); + expect(await readFile(join(output, 'main.py'), 'utf8')).to.equal('safe'); + for (const path of ['.env', '.git/objects/secret', 'lib/.hidden/secret']) { + try { + // eslint-disable-next-line no-await-in-loop + await access(join(output, path)); + expect.fail(`${path} should not be bundled`); + } catch (err) { + expect((err as NodeJS.ErrnoException).code).to.equal('ENOENT'); + } + } + }); it('should package app directory into a bundle', async () => { await writeFile(join(appDir, 'api-spec.yaml'), 'openapi: 3.0.0\ninfo:\n title: test'); await writeFile(join(appDir, 'MyApp.compute-meta.xml'), ''); @@ -84,6 +209,94 @@ describe('computeSourceBundle', () => { }); describe('unpackComputeBundle', () => { + it('rejects an oversized compressed download buffer', async () => { + try { + await unpackComputeBundle(Buffer.alloc(40 * 1024 * 1024 + 1), join(tempDir, 'out')); + expect.fail('should reject size'); + } catch (err) { + expect((err as Error).message).to.include('size limit'); + } + }); + + it('rejects oversized decompressed outer gzip as a size limit', async () => { + try { + await unpackComputeBundle(gzipSync(Buffer.alloc(64 * 1024 * 1024 + 1)), join(tempDir, 'out')); + expect.fail('should reject size'); + } catch (err) { + expect((err as Error).message).to.include('size limit'); + } + }); + + it('rejects oversized decompressed inner gzip as a size limit', async () => { + const bundle = await archive([ + { name: 'api-spec.yaml', data: Buffer.from('spec') }, + { name: 'source.tar.gz', data: gzipSync(Buffer.alloc(64 * 1024 * 1024 + 1)) }, + ]); + try { + await unpackComputeBundle(bundle, join(tempDir, 'out')); + expect.fail('should reject size'); + } catch (err) { + expect((err as Error).message).to.include('size limit'); + } + }); + + it('rejects an oversized tar entry', async () => { + const inner = await archive([{ name: 'huge.py', data: Buffer.alloc(16 * 1024 * 1024 + 1) }]); + const outer = await archive([ + { name: 'api-spec.yaml', data: Buffer.from('spec') }, + { name: 'source.tar.gz', data: inner }, + ]); + try { + await unpackComputeBundle(outer, join(tempDir, 'out')); + expect.fail('should reject size'); + } catch (err) { + expect((err as Error).message).to.include('size limit'); + } + }); + + it('applies the source entry size limit even to a file named source.tar.gz', async () => { + const inner = await archive([{ name: 'source.tar.gz', data: Buffer.alloc(16 * 1024 * 1024 + 1) }]); + const outer = await archive([ + { name: 'api-spec.yaml', data: Buffer.from('spec') }, + { name: 'source.tar.gz', data: inner }, + ]); + try { + await unpackComputeBundle(outer, join(tempDir, 'out')); + expect.fail('should reject size'); + } catch (err) { + expect((err as Error).message).to.include('size limit'); + } + }); + + it('rejects aggregate tar entry bytes above the limit', async () => { + const inner = await archive([0, 1, 2].map((i) => ({ name: `${i}.py`, data: Buffer.alloc(16 * 1024 * 1024) }))); + const outer = await archive([ + { name: 'api-spec.yaml', data: Buffer.from('spec') }, + { name: 'source.tar.gz', data: inner }, + ]); + try { + await unpackComputeBundle(outer, join(tempDir, 'out')); + expect.fail('should reject size'); + } catch (err) { + expect((err as Error).message).to.include('size limit'); + } + }); + + it('rejects an excessive inner tar entry count', async () => { + const inner = await archive( + Array.from({ length: 1001 }, (_, i) => ({ name: `${i}.py`, data: Buffer.from('x') })) + ); + const outer = await archive([ + { name: 'api-spec.yaml', data: Buffer.from('spec') }, + { name: 'source.tar.gz', data: inner }, + ]); + try { + await unpackComputeBundle(outer, join(tempDir, 'out')); + expect.fail('should reject count'); + } catch (err) { + expect((err as Error).message).to.include('entry count limit'); + } + }); it('rejects an existing app and output file below a symlinked ancestor without overwriting outside', async () => { await writeFile(join(appDir, 'api-spec.yaml'), 'openapi: 3.0.0'); await writeFile(join(appDir, 'main.py'), 'new'); diff --git a/test/client/transports/herokuComputeTransport.test.ts b/test/client/transports/herokuComputeTransport.test.ts index bf03dcbf9..75c6d0214 100644 --- a/test/client/transports/herokuComputeTransport.test.ts +++ b/test/client/transports/herokuComputeTransport.test.ts @@ -229,6 +229,26 @@ describe('HerokuComputeTransport', () => { }); describe('retrieve', () => { + it('rejects an oversized response before converting the binary string to a Buffer', async () => { + const request = sinon.stub().resolves('x'.repeat(40 * 1024 * 1024 + 1)); + const unpack = sinon.spy(computeSourceBundle, 'unpackComputeBundle'); + try { + const context = createMockContext({ + connection: { version: '68.0', request } as unknown as Connection, + components: [createComputeComponent('MyApp', '/mock/app')], + }); + try { + await transport.afterRetrieve(context); + expect.fail('should reject response'); + } catch (err) { + expect((err as Error).message).to.include('compressed size limit'); + expect(unpack.called).to.be.false; + } + } finally { + unpack.restore(); + } + }); + it('routes manifest-only members to their associated package output directories', async () => { const unpack = sinon.stub(computeSourceBundle, 'unpackComputeBundle').resolves({ fileCount: 2 }); try { diff --git a/test/client/transports/transportIntegration.test.ts b/test/client/transports/transportIntegration.test.ts index bb4d439f3..c27561211 100644 --- a/test/client/transports/transportIntegration.test.ts +++ b/test/client/transports/transportIntegration.test.ts @@ -16,11 +16,13 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; -import { Connection, SfProject } from '@salesforce/core'; +import { AuthInfo, Connection, SfProject } from '@salesforce/core'; import { ComponentSet, ComponentStatus, + DeployResult, FileResponse, + MetadataApiDeployStatus, MetadataApiRetrieveStatus, RegistryAccess, RequestStatus, @@ -28,6 +30,7 @@ import { SourceComponent, } from '../../../src'; import { TransportPipeline } from '../../../src/client/transports/transportPipeline'; +import { TransportCoordinator } from '../../../src/client/transports/transportCoordinator'; import { MetadataApiRetrieve } from '../../../src/client/metadataApiRetrieve'; import { MetadataApiDeploy } from '../../../src/client/metadataApiDeploy'; import { @@ -96,6 +99,30 @@ function createRetrieveStatus(overrides: Partial = {} }; } +function createDeployStatus(status: RequestStatus): MetadataApiDeployStatus { + return { + id: '0Af000000000000', + status, + success: false, + done: true, + checkOnly: false, + createdBy: '005000000000000', + createdByName: 'Test User', + createdDate: '2026-01-01T00:00:00.000Z', + details: {}, + ignoreWarnings: false, + lastModifiedDate: '2026-01-01T00:00:00.000Z', + numberComponentErrors: 0, + numberComponentsDeployed: 0, + numberComponentsTotal: 0, + numberTestErrors: 0, + numberTestsCompleted: 0, + numberTestsTotal: 0, + runTestsEnabled: false, + rollbackOnError: true, + }; +} + const mockContext: TransportContext = { components: [], connection: {} as Connection, @@ -106,6 +133,90 @@ const mockContext: TransportContext = { describe('Transport integration', () => { afterEach(() => sinon.restore()); + for (const status of [RequestStatus.Failed, RequestStatus.Canceled]) { + it(`retains before-deploy results on ${status} without running after-metadata transports`, async () => { + const response: FileResponse = { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: '/app/main.py', + }; + const handle = { + transportName: 'herokuCompute' as const, + status: 'Pending' as const, + checkStatus: async () => handle, + }; + const pipeline = new TransportPipeline(); + sinon.stub(pipeline, 'runBeforeMetadata').resolves({ fileResponses: [response], asyncHandles: [handle] }); + const after = sinon.stub(pipeline, 'runAfterMetadata').resolves({ fileResponses: [], asyncHandles: [] }); + const coordinator = new TransportCoordinator(pipeline, [], mockContext, 'deploy'); + await coordinator.runBeforeMetadata(); + const result = new DeployResult(createDeployStatus(status)); + + await coordinator.processResult(result, status); + + expect(result.getFileResponses()).to.deep.include(response); + expect(result.getAsyncTransportHandles()).to.deep.equal([handle]); + expect(after.called).to.be.false; + }); + } + + for (const status of [RequestStatus.Failed, RequestStatus.Succeeded]) { + it(`processes the same ${status} result only once`, async () => { + const response: FileResponse = { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: '/app/main.py', + }; + const handle = { + transportName: 'herokuCompute' as const, + status: 'Pending' as const, + checkStatus: async () => handle, + }; + const pipeline = new TransportPipeline(); + sinon.stub(pipeline, 'runBeforeMetadata').resolves({ fileResponses: [response], asyncHandles: [handle] }); + const after = sinon.stub(pipeline, 'runAfterMetadata').resolves({ fileResponses: [], asyncHandles: [] }); + const coordinator = new TransportCoordinator(pipeline, [], mockContext, 'deploy'); + await coordinator.runBeforeMetadata(); + const result = new DeployResult(createDeployStatus(status)); + + await coordinator.processResult(result, status); + await coordinator.processResult(result, status); + + expect(result.getAsyncTransportHandles()).to.deep.equal([handle]); + expect(result.getFileResponses()).to.deep.include(response); + expect(after.callCount).to.equal(status === RequestStatus.Succeeded ? 1 : 0); + }); + } + + it('allows a failed status to transition to success without repeating before results', async () => { + const response: FileResponse = { + fullName: 'MyApp', + type: 'PlatformComputeApp', + state: ComponentStatus.Changed, + filePath: '/app/main.py', + }; + const handle = { + transportName: 'herokuCompute' as const, + status: 'Pending' as const, + checkStatus: async () => handle, + }; + const pipeline = new TransportPipeline(); + sinon.stub(pipeline, 'runBeforeMetadata').resolves({ fileResponses: [response], asyncHandles: [handle] }); + const after = sinon.stub(pipeline, 'runAfterMetadata').resolves({ fileResponses: [], asyncHandles: [] }); + const coordinator = new TransportCoordinator(pipeline, [], mockContext, 'deploy'); + await coordinator.runBeforeMetadata(); + const result = new DeployResult(createDeployStatus(RequestStatus.Failed)); + + await coordinator.processResult(result, RequestStatus.Failed); + await coordinator.processResult(result, RequestStatus.Succeeded); + await coordinator.processResult(result, RequestStatus.Succeeded); + + expect(result.getAsyncTransportHandles()).to.deep.equal([handle]); + expect(after.calledOnce).to.be.true; + }); + describe('retrieve before-metadata transports', () => { it('should run before-metadata retrieve transports via runBeforeMetadata', async () => { const retrieveSpy = sinon.spy(); @@ -251,6 +362,35 @@ describe('Transport integration', () => { sinon.stub(MetadataApiDeploy.prototype, 'start').resolves({ id: 'deploy' }); }); + for (const operation of ['deploy', 'retrieve'] as const) { + it(`sets the username connection API version before the ${operation} provider runs`, async () => { + const type = registryAccess.getTypeByName('PlatformComputeApp'); + const component = new SourceComponent({ name: 'MyApp', type, content: '/local/app' }); + const set = new ComponentSet([component]); + set.apiVersion = '69.0'; + const builtConnection = { + version: '68.0', + setApiVersion(version: string): void { + builtConnection.version = version; + }, + getAuthInfoFields: () => ({ orgId: '00D' }), + } as unknown as Connection; + sinon.stub(AuthInfo, 'create').resolves({} as AuthInfo); + sinon.stub(Connection, 'create').resolves(builtConnection); + const provider = createMockTransport({ + deployPhase: 'before-metadata', + retrievePhase: 'before-metadata', + typeNames: ['PlatformComputeApp'], + deploySpy: sinon.spy((ctx: TransportContext) => expect(ctx.connection.version).to.equal('69.0')), + retrieveSpy: sinon.spy((ctx: TransportContext) => expect(ctx.connection.version).to.equal('69.0')), + }); + set.transportPipeline = new TransportPipeline([provider]); + + if (operation === 'deploy') await set.deploy({ usernameOrConnection: 'test@example.com' }); + else await set.retrieve({ usernameOrConnection: 'test@example.com', output: '/out' }); + }); + } + it('preserves both source locations for the same metadata member when grouping', async () => { const type = registryAccess.getTypeByName('PlatformComputeApp'); const first = new SourceComponent({ name: 'SharedApp', type, content: '/first/SharedApp' }); From cd3971e14ed42c6448c96ed9b8be952668dfbeac Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Thu, 8 Oct 2026 13:39:09 -0600 Subject: [PATCH 21/21] test: support Windows transport paths --- test/client/transports/herokuComputeTransport.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/client/transports/herokuComputeTransport.test.ts b/test/client/transports/herokuComputeTransport.test.ts index 75c6d0214..07a102680 100644 --- a/test/client/transports/herokuComputeTransport.test.ts +++ b/test/client/transports/herokuComputeTransport.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { join } from 'node:path'; import { expect } from 'chai'; import sinon from 'sinon'; import { Connection, SfError, SfProject } from '@salesforce/core'; @@ -273,8 +274,8 @@ describe('HerokuComputeTransport', () => { await transport.afterRetrieve(context); - expect(unpack.firstCall.args[1]).to.equal('/packages/first/platformComputeApps/FirstApp'); - expect(unpack.secondCall.args[1]).to.equal('/packages/second/platformComputeApps/SecondApp'); + expect(unpack.firstCall.args[1]).to.equal(join('/packages/first', 'platformComputeApps', 'FirstApp')); + expect(unpack.secondCall.args[1]).to.equal(join('/packages/second', 'platformComputeApps', 'SecondApp')); } finally { unpack.restore(); } @@ -296,10 +297,10 @@ describe('HerokuComputeTransport', () => { const result = await transport.afterRetrieve(context); - expect(unpack.firstCall.args[1]).to.equal('/custom/output/platformComputeApps/RemoteApp'); + expect(unpack.firstCall.args[1]).to.equal(join('/custom/output', 'platformComputeApps', 'RemoteApp')); expect(unpack.secondCall.args[1]).to.equal('/local/app'); expect(result.fileResponses.map((response) => response.filePath)).to.deep.equal([ - '/custom/output/platformComputeApps/RemoteApp', + join('/custom/output', 'platformComputeApps', 'RemoteApp'), '/local/app', ]); } finally {