Skip to content

Commit be1a8e5

Browse files
committed
test: add consecutiveErrors NUTs
1 parent e1ab5e2 commit be1a8e5

1 file changed

Lines changed: 227 additions & 0 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/*
2+
* Copyright 2026, Salesforce, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { fileURLToPath } from 'node:url';
18+
import * as path from 'node:path';
19+
import { expect } from 'chai';
20+
import sinon from 'sinon';
21+
import { SourceTestkit } from '@salesforce/source-testkit';
22+
import { AuthInfo, Connection, Org } from '@salesforce/core';
23+
import DeployMetadata from '../../../src/commands/project/deploy/start.js';
24+
25+
/**
26+
* Tests for verifying that deploy commands properly throw errors when
27+
* consecutive polling errors exceed the configured limit.
28+
*
29+
* see https://github.com/forcedotcom/source-deploy-retrieve/pull/1663
30+
*/
31+
describe('Deploy Consecutive Errors NUT', () => {
32+
let testkit: SourceTestkit;
33+
let orgUsername: string;
34+
35+
const sinonSandbox = sinon.createSandbox();
36+
37+
// Use a low retry limit to avoid needing many mocked responses
38+
const ERROR_RETRY_LIMIT = 5;
39+
40+
before(async () => {
41+
// Set the environment variable to reduce retry limit for testing
42+
process.env.SF_METADATA_POLL_ERROR_RETRY_LIMIT = String(ERROR_RETRY_LIMIT);
43+
44+
testkit = await SourceTestkit.create({
45+
nut: fileURLToPath(import.meta.url),
46+
repository: 'https://github.com/trailheadapps/dreamhouse-lwc.git',
47+
});
48+
orgUsername = testkit.username;
49+
});
50+
51+
after(async () => {
52+
delete process.env.SF_METADATA_POLL_ERROR_RETRY_LIMIT;
53+
await testkit?.clean();
54+
});
55+
56+
afterEach(() => {
57+
sinonSandbox.restore();
58+
});
59+
60+
/**
61+
* Creates a stubbed connection that will throw errors during checkDeployStatus calls.
62+
* The connection is injected by stubbing Org.create to return an org with our stubbed connection.
63+
*
64+
* @param username - The org username to stub
65+
* @param errorMessage - The error message to throw during checkDeployStatus
66+
* @returns The stubbed connection for additional configuration
67+
*/
68+
const stubConnectionWithDeployStatusErrors = async (
69+
username: string,
70+
errorMessage: string
71+
): Promise<{ connection: Connection; checkDeployStatusStub: sinon.SinonStub }> => {
72+
const connection = await Connection.create({
73+
authInfo: await AuthInfo.create({ username }),
74+
});
75+
76+
// Stub checkDeployStatus to throw a retryable error
77+
const checkDeployStatusStub = sinonSandbox
78+
.stub(connection.metadata, 'checkDeployStatus')
79+
.rejects(new Error(errorMessage));
80+
81+
// Save original Org.create function to call in the fake
82+
const orgCreateFn = Org.create.bind(Org);
83+
sinonSandbox.stub(Org, 'create').callsFake(async (opts) => {
84+
const org = (await orgCreateFn(opts)) as Org;
85+
// @ts-expect-error re-assigning a private property
86+
org.connection = connection;
87+
return org;
88+
});
89+
90+
return { connection, checkDeployStatusStub };
91+
};
92+
93+
it('should throw error when consecutive retryable errors exceed the limit (socket hang up)', async () => {
94+
const retryableError = 'socket hang up';
95+
96+
await stubConnectionWithDeployStatusErrors(orgUsername, retryableError);
97+
98+
try {
99+
await DeployMetadata.run([
100+
'--source-dir',
101+
path.join(testkit.projectDir, 'force-app'),
102+
'-o',
103+
orgUsername,
104+
'--wait',
105+
'1', // Short wait since we expect it to fail quickly
106+
]);
107+
expect.fail('Expected command to throw consecutive error from SDR');
108+
} catch (error) {
109+
const err = error as Error;
110+
expect(err.message).to.include('Exceeded maximum of 5 consecutive retryable errors. Last error: socket hang up');
111+
}
112+
});
113+
114+
it('should throw error when consecutive retryable errors exceed the limit (ECONNRESET)', async () => {
115+
const retryableError = 'ECONNRESET';
116+
117+
await stubConnectionWithDeployStatusErrors(orgUsername, retryableError);
118+
119+
try {
120+
await DeployMetadata.run([
121+
'--source-dir',
122+
path.join(testkit.projectDir, 'force-app'),
123+
'-o',
124+
orgUsername,
125+
'--wait',
126+
'1',
127+
]);
128+
expect.fail('Expected command to throw consecutive error from SDR');
129+
} catch (error) {
130+
const err = error as Error;
131+
expect(err.message).to.include('Exceeded maximum of 5 consecutive retryable errors. Last error: ECONNRESET');
132+
}
133+
});
134+
135+
it('should throw error when consecutive retryable errors exceed the limit (ETIMEDOUT)', async () => {
136+
const retryableError = 'ETIMEDOUT';
137+
138+
await stubConnectionWithDeployStatusErrors(orgUsername, retryableError);
139+
140+
try {
141+
await DeployMetadata.run([
142+
'--source-dir',
143+
path.join(testkit.projectDir, 'force-app'),
144+
'-o',
145+
orgUsername,
146+
'--wait',
147+
'1',
148+
]);
149+
expect.fail('Expected command to throw consecutive error from SDR');
150+
} catch (error) {
151+
const err = error as Error;
152+
expect(err.message).to.include('Exceeded maximum of 5 consecutive retryable errors. Last error: ETIMEDOUT');
153+
}
154+
});
155+
156+
it('should throw error when consecutive retryable errors exceed the limit (ERROR_HTTP_503)', async () => {
157+
const retryableError = 'ERROR_HTTP_503';
158+
159+
await stubConnectionWithDeployStatusErrors(orgUsername, retryableError);
160+
161+
try {
162+
await DeployMetadata.run([
163+
'--source-dir',
164+
path.join(testkit.projectDir, 'force-app'),
165+
'-o',
166+
orgUsername,
167+
'--wait',
168+
'1',
169+
]);
170+
expect.fail('Expected command to throw consecutive error from SDR');
171+
} catch (error) {
172+
const err = error as Error;
173+
expect(err.message).to.include('Exceeded maximum of 5 consecutive retryable errors. Last error: ERROR_HTTP_503');
174+
}
175+
});
176+
177+
it.only('should continue polling and succeed when errors occur but do not exceed the limit', async () => {
178+
const retryableError = 'socket hang up';
179+
const { connection, checkDeployStatusStub } = await stubConnectionWithDeployStatusErrors(
180+
orgUsername,
181+
retryableError
182+
);
183+
184+
// Make the stub throw errors for the first few calls, then succeed
185+
// Number of errors is less than the limit, so it should recover
186+
const errorsToThrow = ERROR_RETRY_LIMIT - 2; // Below the limit
187+
let callCount = 0;
188+
189+
checkDeployStatusStub.restore();
190+
sinonSandbox.stub(connection.metadata, 'checkDeployStatus').callsFake(async () => {
191+
callCount++;
192+
if (callCount <= errorsToThrow) {
193+
throw new Error(retryableError);
194+
}
195+
// Return a successful/completed deploy status
196+
return {
197+
id: 'mockDeployId',
198+
done: true,
199+
status: 'Succeeded',
200+
success: true,
201+
numberComponentsDeployed: 1,
202+
numberComponentsTotal: 1,
203+
numberComponentErrors: 0,
204+
details: {},
205+
} as unknown as ReturnType<Connection['metadata']['checkDeployStatus']>;
206+
});
207+
208+
// This should succeed because errors don't exceed the limit
209+
const result = await DeployMetadata.run([
210+
'--source-dir',
211+
path.join(testkit.projectDir, 'force-app'),
212+
'-o',
213+
orgUsername,
214+
'--wait',
215+
'5',
216+
'--json',
217+
]);
218+
219+
expect(result.id).to.equal('mockDeployId');
220+
expect(result.status).to.equal('Succeeded');
221+
expect(result.numberComponentsTotal).to.equal(1);
222+
expect(result.numberComponentsDeployed).to.equal(1);
223+
expect(result.numberComponentErrors).to.equal(0);
224+
// The deploy should eventually succeed after recovering from errors
225+
expect(callCount).to.be.greaterThan(errorsToThrow);
226+
});
227+
});

0 commit comments

Comments
 (0)