diff --git a/.changeset/align-wait-deploy-wait.md b/.changeset/align-wait-deploy-wait.md new file mode 100644 index 00000000..49a9c53c --- /dev/null +++ b/.changeset/align-wait-deploy-wait.md @@ -0,0 +1,7 @@ +--- +'@salesforce/b2c-tooling-sdk': minor +'@salesforce/b2c-cli': minor +'@salesforce/b2c-dx-docs': patch +--- + +Add `--wait` flag to `mrt bundle deploy` to poll until deployment completes, and align all SDK wait functions (`waitForJob`, `waitForEnv`) to a consistent pattern with structured `onPoll` callbacks, seconds-based options, and injectable `sleep` for testing. diff --git a/docs/cli/jobs.md b/docs/cli/jobs.md index cee1fa22..fd6244da 100644 --- a/docs/cli/jobs.md +++ b/docs/cli/jobs.md @@ -64,6 +64,7 @@ In addition to [global flags](./index#global-flags): |------|-------------|---------| | `--wait`, `-w` | Wait for job to complete | `false` | | `--timeout`, `-t` | Timeout in seconds when waiting | No timeout | +| `--poll-interval` | Polling interval in seconds when using `--wait` | `3` | | `--param`, `-P` | Job parameter in format "name=value" (repeatable) | | | `--body`, `-B` | Raw JSON request body (for system jobs with non-standard schemas) | | | `--no-wait-running` | Do not wait for running job to finish before starting | `false` | diff --git a/docs/cli/mrt.md b/docs/cli/mrt.md index b4c72f0c..d3953373 100644 --- a/docs/cli/mrt.md +++ b/docs/cli/mrt.md @@ -284,6 +284,8 @@ b2c mrt env create prod -p my-storefront --name "Production" \ | `--enable-source-maps` | Enable source maps | | `--proxy` | Proxy configuration in format `path=host` (repeatable) | | `--wait`, `-w` | Wait for the environment to be ready before returning | +| `--poll-interval` | Polling interval in seconds when using `--wait` | `10` | +| `--timeout` | Maximum time to wait in seconds when using `--wait` (`0` for no timeout) | `600` | ### b2c mrt env get @@ -454,6 +456,9 @@ b2c mrt bundle deploy -p my-storefront --build-dir ./dist # Deploy existing bundle by ID b2c mrt bundle deploy 12345 -p my-storefront -e production + +# Deploy and wait for completion +b2c mrt bundle deploy -p my-storefront -e staging --wait ``` **Flags:** @@ -465,6 +470,9 @@ b2c mrt bundle deploy 12345 -p my-storefront -e production | `--ssr-shared` | Shared file patterns | `static/**/*,client/**/*` | | `--node-version`, `-n` | Node.js version for SSR | `22.x` | | `--ssr-param` | SSR parameters (key=value) | | +| `--wait`, `-w` | Wait for the deployment to complete before returning | `false` | +| `--poll-interval` | Polling interval in seconds when using `--wait` | `30` | +| `--timeout` | Maximum time to wait in seconds when using `--wait` (`0` for no timeout) | `600` | ### b2c mrt bundle list diff --git a/packages/b2c-cli/src/commands/content/export.ts b/packages/b2c-cli/src/commands/content/export.ts index 83737be7..e3c04f3b 100644 --- a/packages/b2c-cli/src/commands/content/export.ts +++ b/packages/b2c-cli/src/commands/content/export.ts @@ -116,7 +116,7 @@ export default class ContentExport extends JobCommand { this.requireOAuthCredentials(); } - const waitOptions = flags.timeout ? {timeout: flags.timeout * 1000} : undefined; + const waitOptions = flags.timeout ? {timeoutSeconds: flags.timeout} : undefined; if (flags['dry-run']) { const {library} = await this.operations.fetchContentLibrary(this.instance, libraryId, { diff --git a/packages/b2c-cli/src/commands/content/list.ts b/packages/b2c-cli/src/commands/content/list.ts index 6a672cd1..4b3a93c0 100644 --- a/packages/b2c-cli/src/commands/content/list.ts +++ b/packages/b2c-cli/src/commands/content/list.ts @@ -98,7 +98,7 @@ export default class ContentList extends JobCommand { this.requireOAuthCredentials(); } - const waitOptions = flags.timeout ? {timeout: flags.timeout * 1000} : undefined; + const waitOptions = flags.timeout ? {timeoutSeconds: flags.timeout} : undefined; const instance = flags['library-file'] ? (null as unknown as typeof this.instance) : this.instance; diff --git a/packages/b2c-cli/src/commands/job/export.ts b/packages/b2c-cli/src/commands/job/export.ts index 5e49a150..40f70a0e 100644 --- a/packages/b2c-cli/src/commands/job/export.ts +++ b/packages/b2c-cli/src/commands/job/export.ts @@ -182,14 +182,13 @@ export default class JobExport extends JobCommand { this.log(t('commands.job.export.dataUnits', 'Data units: {{dataUnits}}', {dataUnits: JSON.stringify(dataUnits)})); const waitOptions: WaitForJobOptions = { - timeout: timeout ? timeout * 1000 : undefined, - onProgress: (exec, elapsed) => { + timeoutSeconds: timeout, + onPoll: (info) => { if (!this.jsonEnabled()) { - const elapsedSec = Math.floor(elapsed / 1000); this.log( t('commands.job.export.progress', ' Status: {{status}} ({{elapsed}}s elapsed)', { - status: exec.execution_status, - elapsed: elapsedSec.toString(), + status: info.status, + elapsed: String(info.elapsedSeconds), }), ); } diff --git a/packages/b2c-cli/src/commands/job/import.ts b/packages/b2c-cli/src/commands/job/import.ts index a9685795..ea01f484 100644 --- a/packages/b2c-cli/src/commands/job/import.ts +++ b/packages/b2c-cli/src/commands/job/import.ts @@ -117,14 +117,13 @@ export default class JobImport extends JobCommand { const result = await this.operations.siteArchiveImport(this.instance, importTarget, { keepArchive, waitOptions: { - timeout: timeout ? timeout * 1000 : undefined, - onProgress: (exec, elapsed) => { + timeoutSeconds: timeout, + onPoll: (info) => { if (!this.jsonEnabled()) { - const elapsedSec = Math.floor(elapsed / 1000); this.log( t('commands.job.import.progress', ' Status: {{status}} ({{elapsed}}s elapsed)', { - status: exec.execution_status, - elapsed: elapsedSec.toString(), + status: info.status, + elapsed: String(info.elapsedSeconds), }), ); } diff --git a/packages/b2c-cli/src/commands/job/run.ts b/packages/b2c-cli/src/commands/job/run.ts index 00b71bf2..09ca7b52 100644 --- a/packages/b2c-cli/src/commands/job/run.ts +++ b/packages/b2c-cli/src/commands/job/run.ts @@ -49,6 +49,11 @@ export default class JobRun extends JobCommand { char: 't', description: 'Timeout in seconds when waiting (default: no timeout)', }), + 'poll-interval': Flags.integer({ + description: 'Polling interval in seconds when using --wait', + default: 3, + dependsOn: ['wait'], + }), param: Flags.string({ char: 'P', description: 'Job parameter in format "name=value" (use -P multiple times for multiple params)', @@ -80,7 +85,15 @@ export default class JobRun extends JobCommand { this.requireOAuthCredentials(); const {jobId} = this.args; - const {wait, timeout, param, body, 'no-wait-running': noWaitRunning, 'show-log': showLog} = this.flags; + const { + wait, + timeout, + 'poll-interval': pollInterval, + param, + body, + 'no-wait-running': noWaitRunning, + 'show-log': showLog, + } = this.flags; // Parse parameters or body const parameters = this.parseParameters(param || []); @@ -138,6 +151,7 @@ export default class JobRun extends JobCommand { jobId, executionId: execution.id!, timeout, + pollInterval, showLog, context, }); @@ -216,22 +230,23 @@ export default class JobRun extends JobCommand { jobId: string; executionId: string; timeout: number | undefined; + pollInterval: number | undefined; showLog: boolean; context: B2COperationContext; }): Promise { - const {jobId, executionId, timeout, showLog, context} = options; + const {jobId, executionId, timeout, pollInterval, showLog, context} = options; this.log(t('commands.job.run.waiting', 'Waiting for job to complete...')); try { const execution = await this.operations.waitForJob(this.instance, jobId, executionId, { - timeout: timeout ? timeout * 1000 : undefined, - onProgress: (exec, elapsed) => { + timeoutSeconds: timeout, + pollIntervalSeconds: pollInterval, + onPoll: (info) => { if (!this.jsonEnabled()) { - const elapsedSec = Math.floor(elapsed / 1000); this.log( t('commands.job.run.progress', ' Status: {{status}} ({{elapsed}}s elapsed)', { - status: exec.execution_status, - elapsed: elapsedSec.toString(), + status: info.status, + elapsed: String(info.elapsedSeconds), }), ); } diff --git a/packages/b2c-cli/src/commands/job/wait.ts b/packages/b2c-cli/src/commands/job/wait.ts index f8c994b1..6e43410c 100644 --- a/packages/b2c-cli/src/commands/job/wait.ts +++ b/packages/b2c-cli/src/commands/job/wait.ts @@ -68,15 +68,14 @@ export default class JobWait extends JobCommand { try { const execution = await this.operations.waitForJob(this.instance, jobId, executionId, { - timeout: timeout ? timeout * 1000 : undefined, - pollInterval: pollInterval * 1000, - onProgress: (exec, elapsed) => { + timeoutSeconds: timeout, + pollIntervalSeconds: pollInterval, + onPoll: (info) => { if (!this.jsonEnabled()) { - const elapsedSec = Math.floor(elapsed / 1000); this.log( t('commands.job.wait.progress', ' Status: {{status}} ({{elapsed}}s elapsed)', { - status: exec.execution_status, - elapsed: elapsedSec.toString(), + status: info.status, + elapsed: String(info.elapsedSeconds), }), ); } diff --git a/packages/b2c-cli/src/commands/mrt/bundle/deploy.ts b/packages/b2c-cli/src/commands/mrt/bundle/deploy.ts index c195089a..f768af31 100644 --- a/packages/b2c-cli/src/commands/mrt/bundle/deploy.ts +++ b/packages/b2c-cli/src/commands/mrt/bundle/deploy.ts @@ -8,9 +8,11 @@ import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; import { pushBundle, createDeployment, + waitForEnv, DEFAULT_SSR_PARAMETERS, type PushResult, type CreateDeploymentResult, + type MrtEnvironment, } from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../i18n/index.js'; @@ -53,7 +55,7 @@ function parseSsrParams(params: string[]): Record { return result; } -type DeployResult = CreateDeploymentResult | PushResult; +type DeployResult = CreateDeploymentResult | MrtEnvironment | PushResult; /** * Deploy a bundle to Managed Runtime. @@ -86,6 +88,7 @@ export default class MrtBundleDeploy extends MrtCommand '<%= config.bin %> <%= command.id %> --project my-storefront --node-version 20.x', '<%= config.bin %> <%= command.id %> --project my-storefront --ssr-param SSRProxyPath=/api', '<%= config.bin %> <%= command.id %> 12345 --project my-storefront --environment staging', + '<%= config.bin %> <%= command.id %> 12345 --project my-storefront --environment staging --wait', ]; static flags = { @@ -116,6 +119,27 @@ export default class MrtBundleDeploy extends MrtCommand multiple: true, default: [], }), + wait: Flags.boolean({ + char: 'w', + description: 'Wait for the deployment to complete before returning', + default: false, + }), + 'poll-interval': Flags.integer({ + description: 'Polling interval in seconds when using --wait', + default: 30, + dependsOn: ['wait'], + }), + timeout: Flags.integer({ + description: 'Maximum time to wait in seconds when using --wait (0 for no timeout)', + default: 600, + dependsOn: ['wait'], + }), + }; + + protected operations = { + pushBundle, + createDeployment, + waitForEnv, }; async run(): Promise { @@ -132,7 +156,7 @@ export default class MrtBundleDeploy extends MrtCommand /** * Deploy an existing bundle to an environment. */ - private async deployExistingBundle(bundleId: number): Promise { + private async deployExistingBundle(bundleId: number): Promise { const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; if (!project) { @@ -153,7 +177,7 @@ export default class MrtBundleDeploy extends MrtCommand ); try { - const result = await createDeployment( + const result = await this.operations.createDeployment( { projectSlug: project, targetSlug: environment, @@ -174,12 +198,18 @@ export default class MrtBundleDeploy extends MrtCommand }, ), ); - this.log( - t( - 'commands.mrt.bundle.deploy.note', - 'Note: Deployments are asynchronous. Use "b2c mrt env get" or the Runtime Admin dashboard to check status.', - ), - ); + if (!this.flags.wait) { + this.log( + t( + 'commands.mrt.bundle.deploy.note', + 'Note: Deployments are asynchronous. Use "b2c mrt env get" or the Runtime Admin dashboard to check status.', + ), + ); + } + } + + if (this.flags.wait) { + return this.waitForDeployment(project, environment); } return result; @@ -198,7 +228,7 @@ export default class MrtBundleDeploy extends MrtCommand /** * Push a local build to create a new bundle. */ - private async pushLocalBuild(): Promise { + private async pushLocalBuild(): Promise { const {mrtProject: project, mrtEnvironment: target} = this.resolvedConfig.values; const {message} = this.flags; @@ -227,7 +257,7 @@ export default class MrtBundleDeploy extends MrtCommand } try { - const result = await pushBundle( + const result = await this.operations.pushBundle( { projectSlug: project, target, @@ -256,6 +286,14 @@ export default class MrtBundleDeploy extends MrtCommand ), ); + if (this.flags.wait) { + if (!target) { + this.warn('--wait was specified but no environment target was provided. Skipping wait.'); + return result; + } + return this.waitForDeployment(project, target); + } + return result; } catch (error) { if (error instanceof Error) { @@ -264,4 +302,46 @@ export default class MrtBundleDeploy extends MrtCommand throw error; } } + + /** + * Wait for a deployment to complete by polling the environment state. + */ + private async waitForDeployment(project: string, environment: string): Promise { + this.log( + t('commands.mrt.bundle.deploy.waiting', 'Waiting for deployment to complete on {{environment}}...', { + environment, + }), + ); + + const envResult = await this.operations.waitForEnv( + { + projectSlug: project, + slug: environment, + origin: this.resolvedConfig.values.mrtOrigin, + pollIntervalSeconds: this.flags['poll-interval'], + timeoutSeconds: this.flags.timeout, + onPoll: (info) => { + if (!this.jsonEnabled()) { + this.log( + t('commands.mrt.bundle.deploy.state', '[{{elapsed}}s] State: {{state}}', { + elapsed: String(info.elapsedSeconds), + state: info.state, + }), + ); + } + }, + }, + this.getMrtAuth(), + ); + + if (!this.jsonEnabled()) { + this.log( + t('commands.mrt.bundle.deploy.deployComplete', 'Deployment complete. Environment is {{state}}.', { + state: envResult.state ?? 'unknown', + }), + ); + } + + return envResult; + } } diff --git a/packages/b2c-cli/src/commands/mrt/env/create.ts b/packages/b2c-cli/src/commands/mrt/env/create.ts index d6d3420b..d0189d60 100644 --- a/packages/b2c-cli/src/commands/mrt/env/create.ts +++ b/packages/b2c-cli/src/commands/mrt/env/create.ts @@ -194,6 +194,16 @@ export default class MrtEnvCreate extends MrtCommand { description: 'Wait for the environment to be ready before returning', default: false, }), + 'poll-interval': Flags.integer({ + description: 'Polling interval in seconds when using --wait', + default: 10, + dependsOn: ['wait'], + }), + timeout: Flags.integer({ + description: 'Maximum time to wait in seconds when using --wait (0 for no timeout)', + default: 600, + dependsOn: ['wait'], + }), }; protected operations = { @@ -222,6 +232,8 @@ export default class MrtEnvCreate extends MrtCommand { 'enable-source-maps': enableSourceMaps, proxy: proxyStrings, wait, + 'poll-interval': pollInterval, + timeout, } = this.flags; // Default name to slug if not provided @@ -257,19 +269,19 @@ export default class MrtEnvCreate extends MrtCommand { if (wait) { this.log(t('commands.mrt.env.create.waiting', 'Waiting for environment "{{slug}}" to be ready...', {slug})); - const waitStartTime = Date.now(); result = await this.operations.waitForEnv( { projectSlug: project, slug, origin: this.resolvedConfig.values.mrtOrigin, - onPoll: (env) => { + pollIntervalSeconds: pollInterval, + timeoutSeconds: timeout, + onPoll: (info) => { if (!this.jsonEnabled()) { - const elapsed = Math.round((Date.now() - waitStartTime) / 1000); this.log( t('commands.mrt.env.create.state', '[{{elapsed}}s] State: {{state}}', { - elapsed: String(elapsed), - state: env.state ?? 'unknown', + elapsed: String(info.elapsedSeconds), + state: info.state, }), ); } diff --git a/packages/b2c-cli/src/commands/sites/cartridges/add.ts b/packages/b2c-cli/src/commands/sites/cartridges/add.ts index d219fc2e..17f48ea7 100644 --- a/packages/b2c-cli/src/commands/sites/cartridges/add.ts +++ b/packages/b2c-cli/src/commands/sites/cartridges/add.ts @@ -82,13 +82,12 @@ export default class SitesCartridgesAdd extends InstanceCommand { + onPoll: (info) => { if (!this.jsonEnabled()) { - const elapsedSec = Math.floor(elapsed / 1000); this.log( t('commands.sites.cartridges.jobProgress', ' Status: {{status}} ({{elapsed}}s elapsed)', { - status: exec.execution_status, - elapsed: elapsedSec.toString(), + status: info.status, + elapsed: String(info.elapsedSeconds), }), ); } diff --git a/packages/b2c-cli/src/commands/sites/cartridges/remove.ts b/packages/b2c-cli/src/commands/sites/cartridges/remove.ts index d10dc2fd..beb00675 100644 --- a/packages/b2c-cli/src/commands/sites/cartridges/remove.ts +++ b/packages/b2c-cli/src/commands/sites/cartridges/remove.ts @@ -53,13 +53,12 @@ export default class SitesCartridgesRemove extends InstanceCommand { + onPoll: (info) => { if (!this.jsonEnabled()) { - const elapsedSec = Math.floor(elapsed / 1000); this.log( t('commands.sites.cartridges.jobProgress', ' Status: {{status}} ({{elapsed}}s elapsed)', { - status: exec.execution_status, - elapsed: elapsedSec.toString(), + status: info.status, + elapsed: String(info.elapsedSeconds), }), ); } diff --git a/packages/b2c-cli/src/commands/sites/cartridges/set.ts b/packages/b2c-cli/src/commands/sites/cartridges/set.ts index e2820571..b0592cfe 100644 --- a/packages/b2c-cli/src/commands/sites/cartridges/set.ts +++ b/packages/b2c-cli/src/commands/sites/cartridges/set.ts @@ -53,13 +53,12 @@ export default class SitesCartridgesSet extends InstanceCommand { + onPoll: (info) => { if (!this.jsonEnabled()) { - const elapsedSec = Math.floor(elapsed / 1000); this.log( t('commands.sites.cartridges.jobProgress', ' Status: {{status}} ({{elapsed}}s elapsed)', { - status: exec.execution_status, - elapsed: elapsedSec.toString(), + status: info.status, + elapsed: String(info.elapsedSeconds), }), ); } diff --git a/packages/b2c-cli/test/commands/content/export.test.ts b/packages/b2c-cli/test/commands/content/export.test.ts index 20d29a49..fb79a0a4 100644 --- a/packages/b2c-cli/test/commands/content/export.test.ts +++ b/packages/b2c-cli/test/commands/content/export.test.ts @@ -131,7 +131,7 @@ describe('content export', () => { expect(args[4].offline).to.equal(true); expect(args[4].libraryFile).to.equal('./library.xml'); expect(args[4].keepOrphans).to.equal(true); - expect(args[4].waitOptions).to.deep.equal({timeout: 120_000}); + expect(args[4].waitOptions).to.deep.equal({timeoutSeconds: 120}); }); it('calls exportContent with correct arguments', async () => { diff --git a/packages/b2c-cli/test/commands/mrt/bundle/deploy.test.ts b/packages/b2c-cli/test/commands/mrt/bundle/deploy.test.ts index e669139d..ea31b894 100644 --- a/packages/b2c-cli/test/commands/mrt/bundle/deploy.test.ts +++ b/packages/b2c-cli/test/commands/mrt/bundle/deploy.test.ts @@ -71,6 +71,7 @@ describe('mrt bundle deploy', () => { 'node-version': '20.x', 'ssr-param': ['SSRProxyPath=/api', 'Foo=bar'], message: 'Test push', + wait: false, }, {}, ); @@ -90,22 +91,7 @@ describe('mrt bundle deploy', () => { projectSlug: 'my-project', target: 'staging', } as any); - - // Inject the stub via operations property - (command as any).run = async function () { - this.requireMrtCredentials(); - const result = await pushStub({ - projectSlug: 'my-project', - target: 'staging', - message: 'Test push', - buildDirectory: 'dist', - ssrOnly: ['ssr.js'], - ssrShared: ['static/**/*'], - ssrParameters: {SSRProxyPath: '/api', Foo: 'bar', SSRFunctionNodeVersion: '20.x'}, - origin: 'https://example.com', - }); - return result; - }; + command.operations = {...command.operations, pushBundle: pushStub}; const result = await command.run(); @@ -182,7 +168,7 @@ describe('mrt bundle deploy', () => { it('calls createDeployment with bundleId and returns result', async () => { const command = createCommand(); - stubParse(command, {project: 'my-project', environment: 'staging'}, {bundleId: 12_345}); + stubParse(command, {project: 'my-project', environment: 'staging', wait: false}, {bundleId: 12_345}); await command.init(); stubCommonAuth(command); @@ -193,23 +179,11 @@ describe('mrt bundle deploy', () => { .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}})); const deployStub = sinon.stub().resolves({ - id: 999, - bundle_id: 12_345, - target: 'staging', + bundleId: 12_345, + targetSlug: 'staging', status: 'pending', } as any); - - // Mock the private method by overriding run - (command as any).run = async function () { - this.requireMrtCredentials(); - const result = await deployStub({ - projectSlug: 'my-project', - targetSlug: 'staging', - bundleId: 12_345, - origin: 'https://example.com', - }); - return result; - }; + command.operations = {...command.operations, createDeployment: deployStub}; const result = await command.run(); @@ -218,7 +192,175 @@ describe('mrt bundle deploy', () => { expect(input.projectSlug).to.equal('my-project'); expect(input.targetSlug).to.equal('staging'); expect(input.bundleId).to.equal(12_345); - expect(result.bundle_id).to.equal(12_345); + expect(result.bundleId).to.equal(12_345); + }); + }); + + describe('--wait flag', () => { + it('calls waitForEnv after deploying existing bundle', async () => { + const command = createCommand(); + + stubParse( + command, + {project: 'my-project', environment: 'staging', wait: true, 'poll-interval': 10, timeout: 600}, + {bundleId: 12_345}, + ); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'log').returns(void 0); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}})); + + const deployStub = sinon.stub().resolves({bundleId: 12_345, targetSlug: 'staging', status: 'pending'} as any); + const waitStub = sinon.stub().resolves({slug: 'staging', state: 'ACTIVE', name: 'staging'} as any); + command.operations = {...command.operations, createDeployment: deployStub, waitForEnv: waitStub}; + + const result = await command.run(); + + expect(deployStub.calledOnce).to.equal(true); + expect(waitStub.calledOnce).to.equal(true); + expect(result.state).to.equal('ACTIVE'); + }); + + it('calls waitForEnv after push with environment', async () => { + const command = createCommand(); + + stubParse( + command, + { + project: 'my-project', + environment: 'staging', + wait: true, + 'poll-interval': 10, + timeout: 600, + 'build-dir': 'build', + 'ssr-only': 'ssr.js', + 'ssr-shared': 'static/**/*', + 'ssr-param': [], + }, + {}, + ); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'log').returns(void 0); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}})); + + const pushStub = sinon.stub().resolves({ + bundleId: 123, + deployed: true, + message: 'auto', + projectSlug: 'my-project', + target: 'staging', + } as any); + const waitStub = sinon.stub().resolves({slug: 'staging', state: 'ACTIVE', name: 'staging'} as any); + command.operations = {...command.operations, pushBundle: pushStub, waitForEnv: waitStub}; + + const result = await command.run(); + + expect(pushStub.calledOnce).to.equal(true); + expect(waitStub.calledOnce).to.equal(true); + expect(result.state).to.equal('ACTIVE'); + }); + + it('skips waitForEnv when push has no target', async () => { + const command = createCommand(); + + stubParse( + command, + { + project: 'my-project', + wait: true, + 'poll-interval': 10, + timeout: 600, + 'build-dir': 'build', + 'ssr-only': 'ssr.js', + 'ssr-shared': 'static/**/*', + 'ssr-param': [], + }, + {}, + ); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'jsonEnabled').returns(false); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'warn').returns(void 0); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: undefined}})); + + const pushStub = sinon.stub().resolves({ + bundleId: 123, + deployed: false, + message: 'auto', + projectSlug: 'my-project', + } as any); + const waitStub = sinon.stub().resolves({} as any); + command.operations = {...command.operations, pushBundle: pushStub, waitForEnv: waitStub}; + + const result = await command.run(); + + expect(pushStub.calledOnce).to.equal(true); + expect(waitStub.notCalled).to.equal(true); + expect(result.bundleId).to.equal(123); + }); + + it('does not call waitForEnv when --wait is not set', async () => { + const command = createCommand(); + + stubParse(command, {project: 'my-project', environment: 'staging', wait: false}, {bundleId: 12_345}); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'log').returns(void 0); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}})); + + const deployStub = sinon.stub().resolves({bundleId: 12_345, targetSlug: 'staging', status: 'pending'} as any); + const waitStub = sinon.stub().resolves({} as any); + command.operations = {...command.operations, createDeployment: deployStub, waitForEnv: waitStub}; + + await command.run(); + + expect(waitStub.notCalled).to.equal(true); + }); + + it('propagates waitForEnv errors', async () => { + const command = createCommand(); + + stubParse( + command, + {project: 'my-project', environment: 'staging', wait: true, 'poll-interval': 10, timeout: 600}, + {bundleId: 12_345}, + ); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'log').returns(void 0); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}})); + + const deployStub = sinon.stub().resolves({bundleId: 12_345, targetSlug: 'staging', status: 'pending'} as any); + const waitStub = sinon.stub().rejects(new Error('Environment publish failed')); + command.operations = {...command.operations, createDeployment: deployStub, waitForEnv: waitStub}; + + try { + await command.run(); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('publish failed'); + } }); }); }); diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index e179ab2a..f14a9723 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -223,6 +223,7 @@ export type { JobExecutionParameter, ExecuteJobOptions, WaitForJobOptions, + WaitForJobPollInfo, SearchJobExecutionsOptions, JobExecutionSearchResult, SiteArchiveImportOptions, diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts index d65c6b3e..8f19d36f 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts @@ -85,6 +85,7 @@ export type { JobExecutionParameter, ExecuteJobOptions, WaitForJobOptions, + WaitForJobPollInfo, SearchJobExecutionsOptions, JobExecutionSearchResult, } from './run.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/run.ts b/packages/b2c-tooling-sdk/src/operations/jobs/run.ts index d6a1cba6..aebe3a26 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/run.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/run.ts @@ -48,16 +48,32 @@ export interface ExecuteJobOptions { waitForRunning?: boolean; } +/** + * Poll info passed to the onPoll callback during job waiting. + */ +export interface WaitForJobPollInfo { + /** Job ID being waited on. */ + jobId: string; + /** Execution ID being waited on. */ + executionId: string; + /** Seconds elapsed since waiting started. */ + elapsedSeconds: number; + /** Current execution status (e.g., 'running', 'pending', 'finished'). */ + status: string; +} + /** * Options for waiting on a job. */ export interface WaitForJobOptions { - /** Polling interval in milliseconds (default: 3000) */ - pollInterval?: number; - /** Maximum time to wait in milliseconds (default: no limit) */ - timeout?: number; - /** Callback for progress updates */ - onProgress?: (execution: JobExecution, elapsedMs: number) => void; + /** Polling interval in seconds (default: 3). */ + pollIntervalSeconds?: number; + /** Maximum time to wait in seconds (default: no limit, 0 = no timeout). */ + timeoutSeconds?: number; + /** Callback invoked on each poll with current status. */ + onPoll?: (info: WaitForJobPollInfo) => void; + /** Custom sleep function for testing. */ + sleep?: (ms: number) => Promise; } /** @@ -194,10 +210,10 @@ export async function getJobExecution( * // Simple wait * const result = await waitForJob(instance, 'my-job', 'exec-123'); * - * // With progress callback + * // With poll callback * const result = await waitForJob(instance, 'my-job', 'exec-123', { - * onProgress: (exec, elapsed) => { - * console.log(`Status: ${exec.execution_status}, elapsed: ${elapsed}ms`); + * onPoll: (info) => { + * console.log(`Status: ${info.status} (${info.elapsedSeconds}s elapsed)`); * } * }); * ``` @@ -209,25 +225,28 @@ export async function waitForJob( options: WaitForJobOptions = {}, ): Promise { const logger = getLogger(); - const {pollInterval = 3000, timeout, onProgress} = options; + const {pollIntervalSeconds = 3, timeoutSeconds = 0, onPoll} = options; + const sleepFn = options.sleep ?? defaultSleep; const startTime = Date.now(); + const pollIntervalMs = pollIntervalSeconds * 1000; + const timeoutMs = timeoutSeconds * 1000; let ticks = 0; + await sleepFn(pollIntervalMs); + while (true) { - await sleep(pollInterval); + const elapsedSeconds = Math.round((Date.now() - startTime) / 1000); - const elapsed = Date.now() - startTime; - if (timeout && elapsed > timeout) { + if (timeoutSeconds > 0 && Date.now() - startTime > timeoutMs) { throw new Error(`Timeout waiting for job ${jobId} execution ${executionId}`); } const execution = await getJobExecution(instance, jobId, executionId); + const currentStatus = execution.execution_status ?? 'unknown'; - // Call progress callback - if (onProgress) { - onProgress(execution, elapsed); - } + logger.trace({jobId, executionId, elapsedSeconds, status: currentStatus}, '[Jobs] Job poll'); + onPoll?.({jobId, executionId, elapsedSeconds, status: currentStatus}); // Check for terminal states if (execution.execution_status === 'aborted' || execution.exit_status?.code === 'ERROR') { @@ -247,12 +266,13 @@ export async function waitForJob( // Log periodic updates if (ticks % 5 === 0) { logger.debug( - {jobId, executionId, status: execution.execution_status, elapsed: elapsed / 1000}, - `Waiting for job ${jobId} to finish (${(elapsed / 1000).toFixed(0)}s elapsed)...`, + {jobId, executionId, status: currentStatus, elapsed: elapsedSeconds}, + `Waiting for job ${jobId} to finish (${elapsedSeconds}s elapsed)...`, ); } ticks++; + await sleepFn(pollIntervalMs); } } @@ -464,9 +484,8 @@ export async function getJobLog(instance: B2CInstance, execution: JobExecution): return new TextDecoder().decode(content); } -/** - * Helper function for sleeping. - */ -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +async function defaultSleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); } diff --git a/packages/b2c-tooling-sdk/src/operations/mrt/env.ts b/packages/b2c-tooling-sdk/src/operations/mrt/env.ts index 49b85f26..0c96dfcd 100644 --- a/packages/b2c-tooling-sdk/src/operations/mrt/env.ts +++ b/packages/b2c-tooling-sdk/src/operations/mrt/env.ts @@ -353,26 +353,49 @@ export async function getEnv(options: GetEnvOptions, auth: AuthStrategy): Promis */ const TERMINAL_STATES: MrtEnvironmentState[] = ['ACTIVE', 'CREATE_FAILED', 'PUBLISH_FAILED']; +/** + * Poll info passed to the onPoll callback during environment waiting. + */ +export interface WaitForEnvPollInfo { + /** Environment slug. */ + slug: string; + /** Seconds elapsed since waiting started. */ + elapsedSeconds: number; + /** Current environment state (e.g., 'PUBLISH_IN_PROGRESS', 'ACTIVE'). */ + state: string; +} + /** * Options for waiting for an MRT environment to be ready. */ export interface WaitForEnvOptions extends GetEnvOptions { /** - * Polling interval in milliseconds. - * @default 10000 + * Polling interval in seconds. + * @default 10 */ - pollInterval?: number; + pollIntervalSeconds?: number; /** - * Maximum time to wait in milliseconds. - * @default 2700000 (45 minutes) + * Maximum time to wait in seconds (0 for no timeout). + * @default 2700 (45 minutes) */ - timeout?: number; + timeoutSeconds?: number; /** - * Optional callback called on each poll with the current environment state. + * Optional callback invoked on each poll with current status. */ - onPoll?: (env: MrtEnvironment) => void; + onPoll?: (info: WaitForEnvPollInfo) => void; + + /** + * Custom sleep function for testing. + */ + sleep?: (ms: number) => Promise; +} + +async function defaultSleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); } /** @@ -404,8 +427,8 @@ export interface WaitForEnvOptions extends GetEnvOptions { * const readyEnv = await waitForEnv({ * projectSlug: 'my-storefront', * slug: 'staging', - * timeout: 60000, // 1 minute - * onPoll: (e) => console.log(`State: ${e.state}`) + * timeoutSeconds: 60, + * onPoll: (info) => console.log(`[${info.elapsedSeconds}s] State: ${info.state}`) * }, auth); * * if (readyEnv.state === 'ACTIVE') { @@ -415,19 +438,30 @@ export interface WaitForEnvOptions extends GetEnvOptions { */ export async function waitForEnv(options: WaitForEnvOptions, auth: AuthStrategy): Promise { const logger = getLogger(); - const {projectSlug, slug, pollInterval = 10000, timeout = 2700000, onPoll, origin} = options; - - logger.debug({projectSlug, slug, pollInterval, timeout}, '[MRT] Waiting for environment'); + const {projectSlug, slug, pollIntervalSeconds = 10, timeoutSeconds = 2700, onPoll, origin} = options; + const sleepFn = options.sleep ?? defaultSleep; const startTime = Date.now(); + const pollIntervalMs = pollIntervalSeconds * 1000; + const timeoutMs = timeoutSeconds * 1000; - while (Date.now() - startTime < timeout) { - const env = await getEnv({projectSlug, slug, origin}, auth); + logger.debug({projectSlug, slug, pollIntervalSeconds, timeoutSeconds}, '[MRT] Waiting for environment'); + + await sleepFn(pollIntervalMs); + + while (true) { + const elapsedSeconds = Math.round((Date.now() - startTime) / 1000); - if (onPoll) { - onPoll(env); + if (timeoutSeconds > 0 && Date.now() - startTime > timeoutMs) { + throw new Error(`Timeout waiting for environment "${slug}" after ${timeoutSeconds}s`); } + const env = await getEnv({projectSlug, slug, origin}, auth); + const currentState = (env.state as string) ?? 'unknown'; + + logger.trace({slug, elapsedSeconds, state: currentState}, '[MRT] Environment poll'); + onPoll?.({slug, elapsedSeconds, state: currentState}); + if (env.state && TERMINAL_STATES.includes(env.state as MrtEnvironmentState)) { if (env.state === 'CREATE_FAILED') { throw new Error(`Environment creation failed`); @@ -439,12 +473,8 @@ export async function waitForEnv(options: WaitForEnvOptions, auth: AuthStrategy) return env; } - logger.debug({slug, state: env.state, elapsed: Date.now() - startTime}, '[MRT] Environment still in progress'); - - await new Promise((resolve) => setTimeout(resolve, pollInterval)); + await sleepFn(pollIntervalMs); } - - throw new Error(`Timeout waiting for environment "${slug}" to be ready after ${timeout}ms`); } /** diff --git a/packages/b2c-tooling-sdk/src/operations/mrt/index.ts b/packages/b2c-tooling-sdk/src/operations/mrt/index.ts index 06eba432..0d858e66 100644 --- a/packages/b2c-tooling-sdk/src/operations/mrt/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/mrt/index.ts @@ -79,6 +79,7 @@ export type { DeleteEnvOptions, GetEnvOptions, WaitForEnvOptions, + WaitForEnvPollInfo, ListEnvsOptions, ListEnvsResult, UpdateEnvOptions, diff --git a/packages/b2c-tooling-sdk/test/operations/content/export.test.ts b/packages/b2c-tooling-sdk/test/operations/content/export.test.ts index 83e4f887..290f4534 100644 --- a/packages/b2c-tooling-sdk/test/operations/content/export.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/content/export.test.ts @@ -25,7 +25,7 @@ const WEBDAV_BASE = `https://${TEST_HOST}/on/demandware.servlet/webdav/Sites`; const OCAPI_BASE = `https://${TEST_HOST}/s/-/dw/data/v25_6`; /** Short poll interval for fast tests */ -const FAST_WAIT_OPTIONS = {pollInterval: 10}; +const FAST_WAIT_OPTIONS = {pollIntervalSeconds: 1, sleep: () => Promise.resolve()}; /** * Library XML with attributes on elements so xml2js produces diff --git a/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts b/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts index 2ba95d29..42d7d3ea 100644 --- a/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts @@ -25,8 +25,8 @@ const TEST_HOST = 'test.demandware.net'; const WEBDAV_BASE = `https://${TEST_HOST}/on/demandware.servlet/webdav/Sites`; const OCAPI_BASE = `https://${TEST_HOST}/s/-/dw/data/v25_6`; -// Use short poll interval for fast tests (default is 3000ms) -const FAST_WAIT_OPTIONS = {pollInterval: 10}; +// Use short poll interval for fast tests +const FAST_WAIT_OPTIONS = {pollIntervalSeconds: 1, sleep: () => Promise.resolve()}; describe('operations/jobs/site-archive', () => { const server = setupServer(); diff --git a/packages/b2c-tooling-sdk/test/operations/mrt/env.test.ts b/packages/b2c-tooling-sdk/test/operations/mrt/env.test.ts index 347fbcc6..b3aad3b0 100644 --- a/packages/b2c-tooling-sdk/test/operations/mrt/env.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/mrt/env.test.ts @@ -207,7 +207,9 @@ describe('operations/mrt/env', () => { }); describe('waitForEnv', () => { - it('should return immediately when environment is ACTIVE', async () => { + const instantSleep = () => Promise.resolve(); + + it('should return when environment is ACTIVE', async () => { server.use( http.get(`${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/`, () => { return HttpResponse.json({ @@ -222,7 +224,8 @@ describe('operations/mrt/env', () => { { projectSlug: 'my-project', slug: 'staging', - pollInterval: 10, + pollIntervalSeconds: 1, + sleep: instantSleep, }, auth, ); @@ -230,9 +233,7 @@ describe('operations/mrt/env', () => { expect(result.state).to.equal('ACTIVE'); }); - it('should poll until environment becomes ACTIVE', async function () { - this.timeout(5000); - + it('should poll until environment becomes ACTIVE', async () => { let callCount = 0; server.use( @@ -256,7 +257,8 @@ describe('operations/mrt/env', () => { { projectSlug: 'my-project', slug: 'staging', - pollInterval: 100, + pollIntervalSeconds: 1, + sleep: instantSleep, }, auth, ); @@ -265,9 +267,7 @@ describe('operations/mrt/env', () => { expect(callCount).to.be.greaterThanOrEqual(3); }); - it('should call onPoll callback', async function () { - this.timeout(5000); - + it('should call onPoll callback with structured info', async () => { const pollUpdates: any[] = []; server.use( @@ -284,9 +284,10 @@ describe('operations/mrt/env', () => { { projectSlug: 'my-project', slug: 'staging', - pollInterval: 100, - onPoll: (env) => { - pollUpdates.push(env); + pollIntervalSeconds: 1, + sleep: instantSleep, + onPoll: (info) => { + pollUpdates.push(info); }, }, auth, @@ -294,12 +295,12 @@ describe('operations/mrt/env', () => { expect(result.state).to.equal('ACTIVE'); expect(pollUpdates.length).to.be.greaterThan(0); - expect(pollUpdates[0].slug).to.equal('staging'); + expect(pollUpdates[0]).to.have.property('slug', 'staging'); + expect(pollUpdates[0]).to.have.property('elapsedSeconds').that.is.a('number'); + expect(pollUpdates[0]).to.have.property('state', 'ACTIVE'); }); - it('should timeout after specified duration', async function () { - this.timeout(5000); - + it('should timeout after specified duration', async () => { server.use( http.get(`${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/`, () => { return HttpResponse.json({ @@ -316,8 +317,9 @@ describe('operations/mrt/env', () => { { projectSlug: 'my-project', slug: 'staging', - pollInterval: 100, - timeout: 500, + pollIntervalSeconds: 1, + timeoutSeconds: 1, + sleep: instantSleep, }, auth, ); @@ -344,7 +346,8 @@ describe('operations/mrt/env', () => { { projectSlug: 'my-project', slug: 'staging', - pollInterval: 10, + pollIntervalSeconds: 1, + sleep: instantSleep, }, auth, ); @@ -353,5 +356,33 @@ describe('operations/mrt/env', () => { expect(error.message).to.include('creation failed'); } }); + + it('should throw error when environment enters PUBLISH_FAILED state', async () => { + server.use( + http.get(`${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/`, () => { + return HttpResponse.json({ + slug: 'staging', + state: 'PUBLISH_FAILED', + }); + }), + ); + + const auth = new MockAuthStrategy(); + + try { + await waitForEnv( + { + projectSlug: 'my-project', + slug: 'staging', + pollIntervalSeconds: 1, + sleep: instantSleep, + }, + auth, + ); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('publish failed'); + } + }); }); });