Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/e2e-shell-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: E2E Shell Tests

on:
schedule:
- cron: '0 3 * * *' # Run at 3 AM UTC daily
workflow_dispatch:

jobs:
e2e-shell-tests:
runs-on: ubuntu-latest
environment: e2e-dev
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '24'

- name: Check for required secrets and vars
id: check-secrets
env:
SFCC_CLIENT_ID: ${{ vars.SFCC_CLIENT_ID }}
SFCC_CLIENT_SECRET: ${{ secrets.SFCC_CLIENT_SECRET }}
TEST_REALM: ${{ vars.TEST_REALM }}
SFCC_ACCOUNT_MANAGER_HOST: ${{ vars.SFCC_ACCOUNT_MANAGER_HOST }}
SFCC_SANDBOX_API_HOST: ${{ vars.SFCC_SANDBOX_API_HOST }}
SFCC_SHORTCODE: ${{ vars.SFCC_SHORTCODE }}
run: |
missing=""
[ -z "$SFCC_CLIENT_ID" ] && missing="$missing SFCC_CLIENT_ID"
[ -z "$SFCC_CLIENT_SECRET" ] && missing="$missing SFCC_CLIENT_SECRET"
[ -z "$TEST_REALM" ] && missing="$missing TEST_REALM"
[ -z "$SFCC_ACCOUNT_MANAGER_HOST" ] && missing="$missing SFCC_ACCOUNT_MANAGER_HOST"
[ -z "$SFCC_SANDBOX_API_HOST" ] && missing="$missing SFCC_SANDBOX_API_HOST"
[ -z "$SFCC_SHORTCODE" ] && missing="$missing SFCC_SHORTCODE"

if [ -z "$missing" ]; then
echo "has-secrets=true" >> $GITHUB_OUTPUT
else
echo "has-secrets=false" >> $GITHUB_OUTPUT
echo "E2E shell tests skipped - missing required variables:$missing" >> $GITHUB_STEP_SUMMARY
fi

- name: Setup pnpm
if: steps.check-secrets.outputs.has-secrets == 'true'
uses: pnpm/action-setup@v4
with:
version: 10.17.1

- name: Get pnpm store directory
if: steps.check-secrets.outputs.has-secrets == 'true'
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV

- name: Setup pnpm cache
if: steps.check-secrets.outputs.has-secrets == 'true'
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-

- name: Install dependencies
if: steps.check-secrets.outputs.has-secrets == 'true'
run: pnpm install --frozen-lockfile

- name: Build packages
if: steps.check-secrets.outputs.has-secrets == 'true'
run: pnpm -r run build

- name: Run E2E Shell Tests
if: steps.check-secrets.outputs.has-secrets == 'true'
env:
SFCC_CLIENT_ID: ${{ vars.SFCC_CLIENT_ID }}
SFCC_CLIENT_SECRET: ${{ secrets.SFCC_CLIENT_SECRET }}
SFCC_ACCOUNT_MANAGER_HOST: ${{ vars.SFCC_ACCOUNT_MANAGER_HOST }}
SFCC_SANDBOX_API_HOST: ${{ vars.SFCC_SANDBOX_API_HOST }}
SFCC_SHORTCODE: ${{ vars.SFCC_SHORTCODE }}
TEST_REALM: ${{ vars.TEST_REALM }}
run: |
echo "Running E2E shell tests with realm: ${TEST_REALM}"
cd packages/b2c-cli
./test/functional/e2e_cli_test.sh
17 changes: 16 additions & 1 deletion docs/cli/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ 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 |
| `--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` |
| `--show-log` | Show job log on failure | `true` |

Note: `--param` and `--body` are mutually exclusive.

### Examples

```bash
Expand All @@ -42,13 +45,25 @@ b2c job run my-custom-job --wait
# Execute with timeout
b2c job run my-custom-job --wait --timeout 600

# Execute with parameters
# Execute with parameters (standard jobs)
b2c job run my-custom-job -P "SiteScope={\"all_storefront_sites\":true}" -P OtherParam=value

# Output as JSON
b2c job run my-custom-job --wait --json
```

### System Jobs with Custom Request Bodies

Some system jobs (like search indexing) use non-standard request schemas that don't follow the `parameters` array format. Use `--body` to provide a raw JSON request body:

```bash
# Run search index job for specific sites
b2c job run sfcc-search-index-product-full-update --wait --body '{"site_scope":["RefArch","SiteGenesis"]}'

# Run search index job for a single site
b2c job run sfcc-search-index-product-full-update --wait --body '{"site_scope":["RefArch"]}'
```

### Authentication

This command requires OAuth authentication with OCAPI permissions for the `/jobs` resource.
Expand Down
24 changes: 13 additions & 11 deletions docs/cli/slas.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,17 +102,18 @@ b2c slas client create [CLIENTID] --tenant-id <TENANT_ID> --channels <CHANNELS>

### Flags

| Flag | Description | Required |
|------|-------------|----------|
| `--tenant-id` | SLAS tenant ID (organization ID) | Yes |
| `--channels` | Site IDs/channels (comma-separated) | Yes |
| `--redirect-uri` | Redirect URIs (comma-separated) | Yes |
| `--name` | Display name for the client | No |
| `--scopes` | OAuth scopes for the client (comma-separated) | No |
| `--default-scopes` | Use default shopper scopes | No |
| `--callback-uri` | Callback URIs for passwordless login | No |
| `--secret` | Client secret (generated if omitted) | No |
| `--public` | Create a public client (default is private) | No |
| Flag | Description | Default |
|------|-------------|---------|
| `--tenant-id` | SLAS tenant ID (organization ID) | Required |
| `--channels` | Site IDs/channels (comma-separated) | Required |
| `--redirect-uri` | Redirect URIs (comma-separated) | Required |
| `--name` | Display name for the client | Auto-generated |
| `--scopes` | OAuth scopes for the client (comma-separated) | |
| `--default-scopes` | Use default shopper scopes | `false` |
| `--callback-uri` | Callback URIs for passwordless login | |
| `--secret` | Client secret (generated if omitted) | Auto-generated |
| `--public` | Create a public client (default is private) | `false` |
| `--[no-]create-tenant` | Automatically create tenant if it doesn't exist | `true` |

### Examples

Expand Down Expand Up @@ -150,6 +151,7 @@ b2c slas client create --tenant-id abcd_123 \
- If `--secret` is not provided for a private client, one will be generated
- The generated secret is only shown once during creation
- Use `--default-scopes` for common shopper API access scopes
- By default, the tenant is automatically created if it doesn't exist. Use `--no-create-tenant` to disable this behavior if you prefer to manage tenants separately

---

Expand Down
23 changes: 20 additions & 3 deletions packages/b2c-cli/src/commands/job/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default class JobRun extends JobCommand<typeof JobRun> {
'<%= config.bin %> <%= command.id %> my-custom-job --wait',
String.raw`<%= config.bin %> <%= command.id %> my-custom-job -P "SiteScope={\"all_storefront_sites\":true}" -P OtherParam=value`,
'<%= config.bin %> <%= command.id %> my-custom-job --wait --timeout 600',
String.raw`<%= config.bin %> <%= command.id %> sfcc-search-index-product-full-update --body '{"site_scope":{"named_sites":["RefArch"]}}'`,
];

static flags = {
Expand All @@ -48,6 +49,12 @@ export default class JobRun extends JobCommand<typeof JobRun> {
description: 'Job parameter in format "name=value" (use -P multiple times for multiple params)',
multiple: true,
multipleNonGreedy: true,
exclusive: ['body'],
}),
body: Flags.string({
char: 'B',
description: 'Raw JSON request body (for system jobs with non-standard schemas)',
exclusive: ['param'],
}),
'no-wait-running': Flags.boolean({
description: 'Do not wait for running job to finish before starting',
Expand All @@ -63,10 +70,11 @@ export default class JobRun extends JobCommand<typeof JobRun> {
this.requireOAuthCredentials();

const {jobId} = this.args;
const {wait, timeout, param, 'no-wait-running': noWaitRunning, 'show-log': showLog} = this.flags;
const {wait, timeout, param, body, 'no-wait-running': noWaitRunning, 'show-log': showLog} = this.flags;

// Parse parameters
// Parse parameters or body
const parameters = this.parseParameters(param || []);
const rawBody = body ? this.parseBody(body) : undefined;

this.log(
t('commands.job.run.executing', 'Executing job {{jobId}} on {{hostname}}...', {
Expand All @@ -78,7 +86,8 @@ export default class JobRun extends JobCommand<typeof JobRun> {
let execution: JobExecution;
try {
execution = await executeJob(this.instance, jobId, {
parameters,
parameters: rawBody ? undefined : parameters,
body: rawBody,
waitForRunning: !noWaitRunning,
});
} catch (error) {
Expand Down Expand Up @@ -147,6 +156,14 @@ export default class JobRun extends JobCommand<typeof JobRun> {
return execution;
}

private parseBody(body: string): Record<string, unknown> {
try {
return JSON.parse(body) as Record<string, unknown>;
} catch {
this.error(t('commands.job.run.invalidBody', 'Invalid JSON body: {{body}}', {body}));
}
}

private parseParameters(params: string[]): Array<{name: string; value: string}> {
return params.map((p) => {
const eqIndex = p.indexOf('=');
Expand Down
15 changes: 7 additions & 8 deletions packages/b2c-cli/src/commands/ods/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export default class OdsCreate extends OdsCommand<typeof OdsCreate> {
let sandbox = result.data.data;

this.log('');
this.log(t('commands.ods.create.success', 'Sandbox created successfully!'));
this.logger.info({sandboxId: sandbox.id}, t('commands.ods.create.success', 'Sandbox created successfully'));

if (wait && sandbox.id) {
this.log('');
Expand Down Expand Up @@ -256,6 +256,9 @@ export default class OdsCreate extends OdsCommand<typeof OdsCreate> {

this.log(t('commands.ods.create.waiting', 'Waiting for sandbox to be ready...'));

// Initial delay before first poll to allow the sandbox to be registered in the API
await this.sleep(2000);

while (true) {
// Check for timeout
if (timeoutSeconds > 0 && Date.now() - startTime > timeoutMs) {
Expand Down Expand Up @@ -286,12 +289,8 @@ export default class OdsCreate extends OdsCommand<typeof OdsCreate> {

// Log current state on each poll
const elapsed = Math.round((Date.now() - startTime) / 1000);
this.log(
t('commands.ods.create.stateChange', '[{{elapsed}}s] State: {{state}}', {
elapsed: String(elapsed),
state: currentState || 'unknown',
}),
);
const state = currentState || 'unknown';
this.logger.info({sandboxId, elapsed, state}, `[${elapsed}s] State: ${state}`);

// Check for terminal states
if (currentState && TERMINAL_STATES.has(currentState)) {
Expand All @@ -306,7 +305,7 @@ export default class OdsCreate extends OdsCommand<typeof OdsCreate> {
}
case 'started': {
this.log('');
this.log(t('commands.ods.create.ready', 'Sandbox is now ready!'));
this.logger.info({sandboxId}, t('commands.ods.create.ready', 'Sandbox is now ready'));
break;
}
}
Expand Down
11 changes: 11 additions & 0 deletions packages/b2c-cli/src/commands/slas/client/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ export default class SlasClientCreate extends SlasClientCommand<typeof SlasClien
description: 'Create a public client (default is private)',
default: false,
}),
'create-tenant': Flags.boolean({
description: 'Automatically create tenant if it does not exist',
default: true,
allowNo: true,
}),
};

async run(): Promise<ClientOutput> {
Expand All @@ -113,6 +118,7 @@ export default class SlasClientCreate extends SlasClientCommand<typeof SlasClien
'callback-uri': callbackUri,
secret,
public: isPublic,
'create-tenant': createTenant,
} = this.flags;

// Validate that either --scopes or --default-scopes is provided
Expand Down Expand Up @@ -140,6 +146,11 @@ export default class SlasClientCreate extends SlasClientCommand<typeof SlasClien

const slasClient = this.getSlasClient();

// Ensure tenant exists before creating client (if enabled)
if (createTenant) {
await this.ensureTenantExists(slasClient, tenantId);
}

// Build body - secret should only be included for private clients
const body: Record<string, unknown> = {
clientId,
Expand Down
67 changes: 67 additions & 0 deletions packages/b2c-cli/src/utils/slas/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,73 @@ export abstract class SlasClientCommand<T extends typeof Command> extends OAuthC
}),
};

/**
* Ensure tenant exists, creating it if necessary.
* This is required before creating SLAS clients.
*/
protected async ensureTenantExists(slasClient: SlasClient, tenantId: string): Promise<void> {
// Try to get the tenant first
const {error, response} = await slasClient.GET('/tenants/{tenantId}', {
params: {
path: {tenantId},
},
});

// If tenant exists, we're done
if (!error) {
return;
}

// Check if this is a "tenant not found" error (SLAS returns 400 with TenantNotFoundException)
const isTenantNotFound =
response.status === 404 ||
(response.status === 400 &&
typeof error === 'object' &&
error !== null &&
'exception_name' in error &&
(error as {exception_name?: string}).exception_name === 'TenantNotFoundException');

// If it's not a tenant-not-found error, something else went wrong
if (!isTenantNotFound) {
this.error(
t('commands.slas.client.create.tenantError', 'Failed to check tenant: {{message}}', {
message: formatApiError(error),
}),
);
}

// Tenant doesn't exist, create it with placeholder values
if (!this.jsonEnabled()) {
this.log(t('commands.slas.client.create.creatingTenant', 'Creating SLAS tenant {{tenantId}}...', {tenantId}));
}

const {error: createError} = await slasClient.PUT('/tenants/{tenantId}', {
params: {
path: {tenantId},
},
body: {
tenantId,
merchantName: 'B2C CLI Tenant',
description: 'Auto-created by b2c-cli',
contact: 'B2C CLI',
emailAddress: 'noreply@example.com',
phoneNo: '+1 000-000-0000',
},
});

if (createError) {
this.error(
t('commands.slas.client.create.tenantCreateError', 'Failed to create tenant: {{message}}', {
message: formatApiError(createError),
}),
);
}

if (!this.jsonEnabled()) {
this.log(t('commands.slas.client.create.tenantCreated', 'SLAS tenant created successfully.'));
}
}

/**
* Get the SLAS client, ensuring short code is configured.
*/
Expand Down
Loading
Loading