Skip to content

Commit e790dfa

Browse files
authored
feat: add --wait flag to sandbox clone create (#249)
* feat: add --wait flag to sandbox clone create and fix config.bin template Add --wait, --poll-interval, and --timeout flags to `sandbox clone create` to poll until the clone reaches COMPLETED or FAILED, matching `sandbox create`. Fix the status hint message which was outputting raw `<%= config.bin %>` instead of the resolved binary name. * chore: change changeset to patch * fix: resolve lint errors in SDK wait-for-clone
1 parent 070bf6b commit e790dfa

7 files changed

Lines changed: 506 additions & 14 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@salesforce/b2c-cli': patch
3+
'@salesforce/b2c-tooling-sdk': patch
4+
---
5+
6+
Add `--wait` flag to `sandbox clone create` command to poll until the clone completes, matching the behavior of `sandbox create --wait`. Also fixes the status check hint to display the correct command name instead of a raw template string.

packages/b2c-cli/src/commands/sandbox/clone/create.ts

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
*/
66
import {Args, Flags, Errors} from '@oclif/core';
77
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
8-
import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk';
8+
import {
9+
getApiErrorMessage,
10+
waitForClone,
11+
ClonePollingTimeoutError,
12+
ClonePollingError,
13+
CloneFailedError,
14+
} from '@salesforce/b2c-tooling-sdk';
915
import {t} from '../../../i18n/index.js';
1016

1117
/**
@@ -30,6 +36,8 @@ export default class CloneCreate extends OdsCommand<typeof CloneCreate> {
3036
'<%= config.bin %> <%= command.id %> <sandboxId> --target-profile large',
3137
'<%= config.bin %> <%= command.id %> <sandboxId> --ttl 48',
3238
'<%= config.bin %> <%= command.id %> <sandboxId> --target-profile large --ttl 48 --emails dev@example.com,qa@example.com',
39+
'<%= config.bin %> <%= command.id %> <sandboxId> --wait',
40+
'<%= config.bin %> <%= command.id %> <sandboxId> --wait --poll-interval 15',
3341
];
3442

3543
static flags = {
@@ -49,11 +57,26 @@ export default class CloneCreate extends OdsCommand<typeof CloneCreate> {
4957
required: false,
5058
default: 24,
5159
}),
60+
wait: Flags.boolean({
61+
char: 'w',
62+
description: 'Wait for the clone to complete before returning',
63+
default: false,
64+
}),
65+
'poll-interval': Flags.integer({
66+
description: 'Polling interval in seconds when using --wait',
67+
default: 10,
68+
dependsOn: ['wait'],
69+
}),
70+
timeout: Flags.integer({
71+
description: 'Maximum time to wait in seconds when using --wait (0 for no timeout)',
72+
default: 1800,
73+
dependsOn: ['wait'],
74+
}),
5275
};
5376

5477
async run(): Promise<{cloneId?: string}> {
5578
const {sandboxId: rawSandboxId} = this.args;
56-
const {'target-profile': targetProfile, emails, ttl} = this.flags;
79+
const {'target-profile': targetProfile, emails, ttl, wait, 'poll-interval': pollInterval, timeout} = this.flags;
5780

5881
// Validate TTL
5982
if (ttl > 0 && ttl < 24) {
@@ -103,19 +126,65 @@ export default class CloneCreate extends OdsCommand<typeof CloneCreate> {
103126

104127
const cloneId = result.data.data?.cloneId;
105128

106-
if (this.jsonEnabled()) {
107-
return {cloneId};
129+
if (!this.jsonEnabled()) {
130+
this.log(t('commands.clone.create.success', '✓ Sandbox clone creation started successfully'));
131+
this.log(t('commands.clone.create.cloneId', 'Clone ID: {{cloneId}}', {cloneId}));
108132
}
109133

110-
this.log(t('commands.clone.create.success', '✓ Sandbox clone creation started successfully'));
111-
this.log(t('commands.clone.create.cloneId', 'Clone ID: {{cloneId}}', {cloneId}));
112-
this.log(
113-
t(
114-
'commands.clone.create.checkStatus',
115-
'\nTo check the clone status, run:\n <%= config.bin %> ods clone get {{sandboxId}} {{cloneId}}',
116-
{sandboxId, cloneId},
117-
),
118-
);
134+
if (wait && cloneId) {
135+
this.log(t('commands.clone.create.waiting', 'Waiting for clone to complete...'));
136+
137+
try {
138+
await waitForClone(this.odsClient, {
139+
sandboxId,
140+
cloneId,
141+
pollIntervalSeconds: pollInterval,
142+
timeoutSeconds: timeout,
143+
onPoll: ({elapsedSeconds, status, progressPercentage}) => {
144+
const progress = progressPercentage === undefined ? '' : ` (${progressPercentage}%)`;
145+
this.logger.info(
146+
{sandboxId, cloneId, elapsed: elapsedSeconds, status},
147+
`[${elapsedSeconds}s] Status: ${status}${progress}`,
148+
);
149+
},
150+
});
151+
} catch (error) {
152+
if (error instanceof ClonePollingTimeoutError) {
153+
this.error(
154+
t('commands.clone.create.timeout', 'Timeout waiting for clone after {{seconds}} seconds', {
155+
seconds: String(error.timeoutSeconds),
156+
}),
157+
);
158+
}
159+
160+
if (error instanceof CloneFailedError) {
161+
this.error(t('commands.clone.create.failed', 'Clone operation failed'));
162+
}
163+
164+
if (error instanceof ClonePollingError) {
165+
this.error(
166+
t('commands.clone.create.pollError', 'Failed to fetch clone status: {{message}}', {
167+
message: error.message,
168+
}),
169+
);
170+
}
171+
172+
throw error;
173+
}
174+
175+
if (!this.jsonEnabled()) {
176+
this.log(t('commands.clone.create.completed', '✓ Clone completed successfully'));
177+
}
178+
} else if (!this.jsonEnabled()) {
179+
const bin = this.config.bin;
180+
this.log(
181+
t(
182+
'commands.clone.create.checkStatus',
183+
'\nTo check the clone status, run:\n {{bin}} sandbox clone get {{sandboxId}} {{cloneId}}',
184+
{bin, sandboxId, cloneId},
185+
),
186+
);
187+
}
119188

120189
return {cloneId};
121190
}

packages/b2c-cli/test/commands/sandbox/clone/create.test.ts

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {runSilent} from '../../../helpers/test-setup.js';
1313
function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void {
1414
Object.defineProperty(command, 'config', {
1515
value: {
16+
bin: 'b2c',
1617
findConfigFile: () => ({
1718
read: () => ({'sandbox-api-host': sandboxApiHost}),
1819
}),
@@ -231,7 +232,7 @@ describe('sandbox clone create', () => {
231232
it('should display formatted output in non-JSON mode', async () => {
232233
const command = new CloneCreate(['test-sandbox-id'], {} as any);
233234
(command as any).args = {sandboxId: 'test-sandbox-id'};
234-
(command as any).flags = {'target-profile': 'medium', ttl: 24};
235+
(command as any).flags = {'target-profile': 'medium', ttl: 24, wait: false, 'poll-interval': 10, timeout: 1800};
235236
stubJsonEnabled(command, false);
236237
stubCommandConfigAndLogger(command);
237238
stubResolveSandboxId(command, async (id) => id);
@@ -256,6 +257,9 @@ describe('sandbox clone create', () => {
256257
expect(combinedLogs).to.include('Clone ID');
257258
expect(combinedLogs).to.include(mockCloneId);
258259
expect(combinedLogs).to.include('started successfully');
260+
// Verify the config.bin template is resolved, not raw EJS
261+
expect(combinedLogs).to.not.include('<%= config.bin %>');
262+
expect(combinedLogs).to.include('b2c sandbox clone get');
259263
});
260264

261265
it('should pass emails to API when provided', async () => {
@@ -313,6 +317,129 @@ describe('sandbox clone create', () => {
313317
});
314318
});
315319

320+
describe('wait functionality', () => {
321+
it('should have wait flag', () => {
322+
expect(CloneCreate.flags).to.have.property('wait');
323+
expect(CloneCreate.flags.wait.default).to.be.false;
324+
});
325+
326+
it('should have poll-interval flag that depends on wait', () => {
327+
expect(CloneCreate.flags).to.have.property('poll-interval');
328+
expect(CloneCreate.flags['poll-interval'].default).to.equal(10);
329+
expect(CloneCreate.flags['poll-interval'].dependsOn).to.deep.equal(['wait']);
330+
});
331+
332+
it('should have timeout flag that depends on wait', () => {
333+
expect(CloneCreate.flags).to.have.property('timeout');
334+
expect(CloneCreate.flags.timeout.default).to.equal(1800);
335+
expect(CloneCreate.flags.timeout.dependsOn).to.deep.equal(['wait']);
336+
});
337+
338+
it('should poll until clone completes when --wait is used', async () => {
339+
const command = new CloneCreate(['test-sandbox-id'], {} as any);
340+
(command as any).args = {sandboxId: 'test-sandbox-id'};
341+
(command as any).flags = {ttl: 24, wait: true, 'poll-interval': 0, timeout: 5};
342+
stubJsonEnabled(command, true);
343+
stubCommandConfigAndLogger(command);
344+
stubResolveSandboxId(command, async (id) => id);
345+
346+
const mockCloneId = 'aaaa-001-1642780893121';
347+
let getCalls = 0;
348+
349+
Object.defineProperty(command, 'odsClient', {
350+
value: {
351+
POST: async () => ({
352+
data: {data: {cloneId: mockCloneId}},
353+
response: new Response(),
354+
}),
355+
async GET() {
356+
getCalls++;
357+
const status = getCalls >= 2 ? 'COMPLETED' : 'IN_PROGRESS';
358+
return {
359+
data: {data: {status, cloneId: mockCloneId, progressPercentage: getCalls >= 2 ? 100 : 50}},
360+
response: new Response(),
361+
};
362+
},
363+
},
364+
configurable: true,
365+
});
366+
367+
const result = await command.run();
368+
369+
expect(result.cloneId).to.equal(mockCloneId);
370+
expect(getCalls).to.be.greaterThanOrEqual(2);
371+
});
372+
373+
it('should error when clone fails during wait', async () => {
374+
const command = new CloneCreate(['test-sandbox-id'], {} as any);
375+
(command as any).args = {sandboxId: 'test-sandbox-id'};
376+
(command as any).flags = {ttl: 24, wait: true, 'poll-interval': 0, timeout: 5};
377+
stubJsonEnabled(command, true);
378+
stubCommandConfigAndLogger(command);
379+
makeCommandThrowOnError(command);
380+
stubResolveSandboxId(command, async (id) => id);
381+
382+
Object.defineProperty(command, 'odsClient', {
383+
value: {
384+
POST: async () => ({
385+
data: {data: {cloneId: 'test-clone-id'}},
386+
response: new Response(),
387+
}),
388+
GET: async () => ({
389+
data: {data: {status: 'FAILED', cloneId: 'test-clone-id'}},
390+
response: new Response(),
391+
}),
392+
},
393+
configurable: true,
394+
});
395+
396+
try {
397+
await command.run();
398+
expect.fail('Should have thrown');
399+
} catch (error: unknown) {
400+
expect((error as Error).message).to.include('failed');
401+
}
402+
});
403+
404+
it('should timeout if clone never completes', async () => {
405+
const command = new CloneCreate(['test-sandbox-id'], {} as any);
406+
(command as any).args = {sandboxId: 'test-sandbox-id'};
407+
(command as any).flags = {ttl: 24, wait: true, 'poll-interval': 0, timeout: 1};
408+
stubJsonEnabled(command, true);
409+
stubCommandConfigAndLogger(command);
410+
makeCommandThrowOnError(command);
411+
stubResolveSandboxId(command, async (id) => id);
412+
413+
const clock = sinon.useFakeTimers({now: 0});
414+
415+
Object.defineProperty(command, 'odsClient', {
416+
value: {
417+
POST: async () => ({
418+
data: {data: {cloneId: 'test-clone-id'}},
419+
response: new Response(),
420+
}),
421+
GET: async () => ({
422+
data: {data: {status: 'IN_PROGRESS', cloneId: 'test-clone-id'}},
423+
response: new Response(),
424+
}),
425+
},
426+
configurable: true,
427+
});
428+
429+
const promise = command.run();
430+
await clock.tickAsync(2000);
431+
432+
try {
433+
await promise;
434+
expect.fail('Expected timeout');
435+
} catch (error: any) {
436+
expect(error.message).to.include('Timeout waiting for clone');
437+
} finally {
438+
clock.restore();
439+
}
440+
});
441+
});
442+
316443
describe('error handling', () => {
317444
it('should throw error when API call fails', async () => {
318445
const command = new CloneCreate(['test-sandbox-id'], {} as any);

packages/b2c-tooling-sdk/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,9 +255,14 @@ export {
255255
SandboxPollingTimeoutError,
256256
SandboxPollingError,
257257
SandboxTerminalStateError,
258+
waitForClone,
259+
ClonePollingTimeoutError,
260+
ClonePollingError,
261+
CloneFailedError,
258262
} from './operations/ods/index.js';
259263

260264
export type {SandboxState, WaitForSandboxOptions, WaitForSandboxPollInfo} from './operations/ods/index.js';
265+
export type {CloneState, WaitForCloneOptions, WaitForClonePollInfo} from './operations/ods/index.js';
261266

262267
// Operations - CIP
263268
export {

packages/b2c-tooling-sdk/src/operations/ods/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,7 @@ export {
2525
} from './wait-for-sandbox.js';
2626

2727
export type {SandboxState, WaitForSandboxOptions, WaitForSandboxPollInfo} from './wait-for-sandbox.js';
28+
29+
export {waitForClone, ClonePollingTimeoutError, ClonePollingError, CloneFailedError} from './wait-for-clone.js';
30+
31+
export type {CloneState, WaitForCloneOptions, WaitForClonePollInfo} from './wait-for-clone.js';

0 commit comments

Comments
 (0)