From aafef919c3bda455c452c3c2353b94dbc5f598b4 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 14 Jan 2026 17:51:24 -0500 Subject: [PATCH 01/10] Fix test isolation, fast polling, and cleanup low-value command tests Test Isolation: - Add `credentialsFile` parameter to ResolveConfigOptions and LoadConfigOptions - Update MobifySource to use credentialsFile when provided (overrides ~/.mobify) - Add --credentials-file flag with MRT_CREDENTIALS_FILE env var to MrtCommand - Add config-isolation.ts helper to clear SFCC_*/MRT_* env vars for tests Fast Polling Tests: - Use short pollInterval for site-archive tests instead of default 3000ms - Tests now complete in milliseconds instead of seconds Command Test Cleanup: - Delete cartridge-command.test.ts (100% trivial delegation tests) - Simplify base-command.test.ts (keep getExtraParams, catch tests) - Simplify instance-command.test.ts (keep requireX, context tests) - Simplify mrt-command.test.ts (keep requireMrtCredentials only) - Simplify oauth-command.test.ts (keep parseAuthMethods, requireOAuthCredentials) - Simplify ods-command.test.ts (keep odsClient lazy init tests) Documentation: - Update testing skill with config isolation, pollInterval patterns - Add command test guidelines (what to test vs avoid) - Remove general knowledge content (basic Mocha/Chai patterns) --- .claude/skills/testing/SKILL.md | 373 +++++++----------- packages/b2c-tooling-sdk/package.json | 2 +- packages/b2c-tooling-sdk/src/cli/config.ts | 3 + .../b2c-tooling-sdk/src/cli/mrt-command.ts | 6 + .../src/config/sources/mobify-source.ts | 3 +- packages/b2c-tooling-sdk/src/config/types.ts | 2 + .../test/cli/base-command.test.ts | 319 --------------- .../test/cli/cartridge-command.test.ts | 281 ------------- .../test/cli/instance-command.test.ts | 118 ------ .../test/cli/mrt-command.test.ts | 207 +--------- .../test/cli/oauth-command.test.ts | 257 +----------- .../test/cli/ods-command.test.ts | 104 ----- .../test/config/resolved-config.test.ts | 24 +- .../test/helpers/config-isolation.ts | 89 +++++ .../test/operations/jobs/site-archive.test.ts | 42 +- pnpm-lock.yaml | 31 ++ 16 files changed, 329 insertions(+), 1532 deletions(-) delete mode 100644 packages/b2c-tooling-sdk/test/cli/cartridge-command.test.ts create mode 100644 packages/b2c-tooling-sdk/test/helpers/config-isolation.ts diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md index 98c602e9..11d128a7 100644 --- a/.claude/skills/testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -5,7 +5,7 @@ description: Writing tests for the B2C CLI project using Mocha, Chai, and MSW # Testing -This skill covers writing tests for the B2C CLI project using Mocha, Chai, and MSW. +This skill covers project-specific testing patterns for the B2C CLI project. ## Test Framework Stack @@ -38,7 +38,7 @@ pnpm --filter @salesforce/b2c-tooling-sdk run test:watch ## Test Organization -Tests mirror the source directory structure: +Tests mirror the source directory structure with `.test.ts` suffix: ``` packages/b2c-tooling-sdk/ @@ -50,8 +50,6 @@ packages/b2c-tooling-sdk/ └── webdav.test.ts ``` -Use `.test.ts` suffix for test files. - ## Import Patterns Always use package exports, not relative paths: @@ -67,6 +65,89 @@ import { WebDavClient } from '../../src/clients/webdav.js'; This ensures tests use the same export paths as consumers. +## Config Isolation + +Tests that check for "missing credentials" or "no config" scenarios need isolation from the developer's real configuration files (`~/.mobify`, `dw.json`) and environment variables. + +### Using Config Isolation Helpers + +```typescript +import { isolateConfig, restoreConfig } from '../helpers/config-isolation.js'; + +describe('config-dependent tests', () => { + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + restoreConfig(); + }); + + it('handles missing credentials', async () => { + // Test now runs without reading real ~/.mobify or SFCC_* env vars + }); +}); +``` + +The helpers: +- Clear all `SFCC_*` and `MRT_*` environment variables +- Clear other config-affecting vars (`LANGUAGE`, `NO_COLOR`) +- Must call `restoreConfig()` in afterEach to restore original state + +### For SDK Unit Tests (bypass config sources) + +When testing `resolveConfig` directly without file system: + +```typescript +import { resolveConfig } from '@salesforce/b2c-tooling-sdk/config'; + +const config = resolveConfig({}, { + replaceDefaultSources: true, + sources: [] // No file-based sources +}); +``` + +### For MRT Credential Isolation + +Use the `credentialsFile` option to override the default `~/.mobify` path: + +```typescript +import { resolveConfig } from '@salesforce/b2c-tooling-sdk/config'; + +// Point to non-existent file for isolation +const config = resolveConfig({}, { + credentialsFile: '/dev/null' +}); +``` + +In CLI command tests, mock the `credentials-file` flag: + +```typescript +cmd.parse = (async () => ({ + args: {}, + flags: {'credentials-file': '/dev/null'}, // Isolates from real ~/.mobify + metadata: {}, +})) as typeof cmd.parse; +``` + +## Polling Tests (Avoid Fake Timers) + +**Do not use fake timers with MSW.** MSW v2 uses microtasks internally, and fake timers prevent MSW's promises from resolving. + +Instead, use the `pollInterval` option for fast tests: + +```typescript +// Good - use short poll interval +const result = await siteArchiveImport(mockInstance, siteDir, { + archiveName: 'test-import', + waitOptions: { pollInterval: 10 } // 10ms instead of default 3000ms +}); + +// Bad - fake timers break MSW +import FakeTimers from '@sinonjs/fake-timers'; +const clock = FakeTimers.install(); // DON'T DO THIS with MSW +``` + ## HTTP Mocking with MSW ### Basic Setup @@ -115,7 +196,6 @@ describe('WebDavClient', () => { ); await client.mkcol('Cartridges/v1'); - // If no error thrown, test passes }); }); ``` @@ -156,37 +236,6 @@ it('sends correct headers', async () => { expect(requests).to.have.length(1); expect(requests[0].method).to.equal('PUT'); expect(requests[0].headers.get('Authorization')).to.equal('Bearer test-token'); - expect(requests[0].body).to.equal('content'); -}); -``` - -### Mocking Different HTTP Methods - -```typescript -// GET request -http.get(`${BASE_URL}/api/items`, () => { - return HttpResponse.json({ items: [{ id: '1' }] }); -}); - -// POST request with body inspection -http.post(`${BASE_URL}/api/items`, async ({ request }) => { - const body = await request.json(); - return HttpResponse.json({ id: '123', ...body }, { status: 201 }); -}); - -// PUT request -http.put(`${BASE_URL}/api/items/:id`, ({ params }) => { - return HttpResponse.json({ id: params.id, updated: true }); -}); - -// DELETE request -http.delete(`${BASE_URL}/api/items/:id`, () => { - return new HttpResponse(null, { status: 204 }); -}); - -// Match any method -http.all(`${BASE_URL}/*`, ({ request }) => { - // Handle based on request.method }); ``` @@ -196,10 +245,7 @@ http.all(`${BASE_URL}/*`, ({ request }) => { it('handles 404 errors', async () => { server.use( http.get(`${BASE_URL}/api/items/:id`, () => { - return HttpResponse.json( - { error: 'Not found' }, - { status: 404 } - ); + return HttpResponse.json({ error: 'Not found' }, { status: 404 }); }), ); @@ -262,91 +308,69 @@ const client = new WebDavClient(TEST_HOST, mockAuth); const customAuth = new MockAuthStrategy('custom-token'); ``` -## Testing Operations +## Command Test Guidelines -Operations tests verify higher-level business logic: +Command tests should focus on **command-specific logic**, not trivial flag verification. -```typescript -import { expect } from 'chai'; -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import { uploadBundle } from '@salesforce/b2c-tooling-sdk/operations/mrt'; -import { createMrtClient } from '@salesforce/b2c-tooling-sdk/clients'; -import { MockAuthStrategy } from '../helpers/mock-auth.js'; +### Good Command Tests -const server = setupServer(); - -describe('uploadBundle', () => { - const testBundle = { - message: 'Test bundle', - encoding: 'base64', - data: 'dGVzdC1kYXRh', - }; - - before(() => server.listen({ onUnhandledRequest: 'error' })); - afterEach(() => server.resetHandlers()); - after(() => server.close()); - - it('uploads bundle and returns result', async () => { - let receivedBody: unknown; - - server.use( - http.post('https://cloud.commercecloud.com/api/projects/:slug/builds/', async ({ request, params }) => { - receivedBody = await request.json(); - return HttpResponse.json({ - bundle_id: 123, - message: 'Bundle created', - }); - }), - ); +Test behavior that is specific to the command class: - const auth = new MockAuthStrategy(); - const client = createMrtClient({}, auth); - - const result = await uploadBundle(client, 'my-project', testBundle); +```typescript +describe('requireMrtCredentials', () => { + it('throws error when no credentials', async () => { + cmd.parse = (async () => ({ + args: {}, + flags: {'credentials-file': '/dev/null'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + let errorCalled = false; + cmd.error = () => { + errorCalled = true; + throw new Error('Expected error'); + }; + + try { + command.testRequireMrtCredentials(); + } catch { + // Expected + } - expect(result.bundleId).to.equal(123); - expect(receivedBody).to.deep.include({ message: 'Test bundle' }); + expect(errorCalled).to.be.true; }); }); ``` -## Testing Pure Logic +### Low-Value Tests to Avoid -For functions without HTTP calls: +Do not write tests that just verify flag values equal mocked values: ```typescript -import { expect } from 'chai'; -import { checkAvailableAuthMethods } from '@salesforce/b2c-tooling-sdk/auth'; - -describe('checkAvailableAuthMethods', () => { - it('returns client-credentials when credentials provided', () => { - const result = checkAvailableAuthMethods({ - clientId: 'test-client', - clientSecret: 'test-secret', - }); - - expect(result.available).to.include('client-credentials'); - }); - - it('returns unavailable with reason when secret missing', () => { - const result = checkAvailableAuthMethods( - { clientId: 'test-client' }, - ['client-credentials'] - ); - - expect(result.available).to.have.length(0); - expect(result.unavailable[0]).to.deep.equal({ - method: 'client-credentials', - reason: 'clientSecret is required', - }); - }); +// BAD - tests nothing (just verifies JavaScript assignment works) +it('handles server flag', async () => { + cmd.parse = (async () => ({ + flags: {server: 'test.demandware.net'}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags.server).to.equal('test.demandware.net'); // Trivial! }); ``` -## Testing CLI Commands +### What to Test in Commands -Use `@oclif/test` for CLI command tests: +| Test | Keep | +|------|------| +| `requireX` error handling | Yes - verifies error messages | +| `parseAuthMethods` logic | Yes - transforms/filters input | +| Lazy client initialization | Yes - verifies caching behavior | +| Context creation | Yes - assembles operation metadata | +| Flag value equals mocked value | No - tests nothing | +| Delegation to resolvedConfig | No - tested in SDK unit tests | + +## Testing CLI Commands with oclif ```typescript import { runCommand } from '@oclif/test'; @@ -360,15 +384,11 @@ describe('ods list', () => { }); ``` -## End-to-End Tests +## E2E Tests -E2E tests run against real infrastructure: +E2E tests run against real infrastructure and are skipped without credentials: ```typescript -import { expect } from 'chai'; -import { execa } from 'execa'; -import path from 'node:path'; - describe('ODS Lifecycle E2E', function () { this.timeout(360_000); // 6 minutes @@ -398,123 +418,17 @@ describe('ODS Lifecycle E2E', function () { ]); expect(result.exitCode).to.equal(0); - const response = JSON.parse(result.stdout); - expect(response.id).to.be.a('string'); - }); -}); -``` - -## Test Structure Patterns - -### Describe/It Nesting - -```typescript -describe('WebDavClient', () => { - describe('mkcol', () => { - it('creates directory on success', async () => { }); - it('throws on 403 forbidden', async () => { }); - it('handles nested paths', async () => { }); - }); - - describe('put', () => { - it('uploads file content', async () => { }); - it('sets correct content-type', async () => { }); - }); -}); -``` - -### Setup/Teardown - -```typescript -describe('Feature', () => { - let sharedResource: Resource; - - before(() => { - // Once before all tests in this describe - }); - - after(() => { - // Once after all tests in this describe - }); - - beforeEach(() => { - // Before each test - sharedResource = new Resource(); - }); - - afterEach(() => { - // After each test - sharedResource.cleanup(); }); }); ``` -## Chai Assertions - -Common assertion patterns: - -```typescript -// Equality -expect(value).to.equal('expected'); -expect(obj).to.deep.equal({ key: 'value' }); - -// Truthiness -expect(value).to.be.true; -expect(value).to.be.false; -expect(value).to.be.undefined; -expect(value).to.be.null; - -// Arrays -expect(arr).to.have.length(3); -expect(arr).to.include('item'); -expect(arr).to.deep.include({ id: '1' }); - -// Objects -expect(obj).to.have.property('key'); -expect(obj).to.have.property('key', 'value'); -expect(obj).to.deep.include({ subset: 'props' }); - -// Strings -expect(str).to.include('substring'); -expect(str).to.match(/pattern/); - -// Errors -expect(() => fn()).to.throw(); -expect(() => fn()).to.throw('message'); -expect(() => fn()).to.throw(ErrorType); - -// Async errors -try { - await asyncFn(); - expect.fail('Should have thrown'); -} catch (error) { - expect(error.message).to.include('expected'); -} -``` - ## Coverage -Coverage is configured in `.c8rc.json`: - -```json -{ - "all": true, - "src": ["src"], - "exclude": ["src/clients/*.generated.ts", "test/**"], - "reporter": ["text", "text-summary", "html", "lcov"], - "check-coverage": true, - "lines": 5, - "functions": 5, - "branches": 5, - "statements": 5 -} -``` - -View coverage report: +Coverage is configured in `.c8rc.json`. View the HTML report after running tests: ```bash pnpm run test -# Then open coverage/index.html +open coverage/index.html ``` ## Writing Tests Checklist @@ -522,9 +436,10 @@ pnpm run test 1. Create test file in `test/` mirroring source structure 2. Use `.test.ts` suffix 3. Import from package names, not relative paths -4. Set up MSW server for HTTP tests -5. Use MockAuthStrategy for authenticated clients -6. Test both success and error paths -7. Use request capture to verify HTTP call details -8. Clean up handlers with `afterEach(() => server.resetHandlers())` -9. Run tests: `pnpm --filter run test` +4. Set up MSW server for HTTP tests (avoid fake timers) +5. Use `isolateConfig()`/`restoreConfig()` for config-dependent tests +6. Use `pollInterval` option for polling operations +7. Use MockAuthStrategy for authenticated clients +8. Test both success and error paths +9. Focus on command-specific logic, not trivial delegation +10. Run tests: `pnpm --filter run test` diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 070dc5b8..6e27727d 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -244,9 +244,9 @@ }, "dependencies": { "archiver": "^7.0.1", - "fuse.js": "^7.0.0", "chokidar": "^5.0.0", "cliui": "^9.0.1", + "fuse.js": "^7.0.0", "glob": "^13.0.0", "i18next": "^25.6.3", "jszip": "^3.10.1", diff --git a/packages/b2c-tooling-sdk/src/cli/config.ts b/packages/b2c-tooling-sdk/src/cli/config.ts index a3f0abec..6b521c09 100644 --- a/packages/b2c-tooling-sdk/src/cli/config.ts +++ b/packages/b2c-tooling-sdk/src/cli/config.ts @@ -44,6 +44,8 @@ export interface LoadConfigOptions { configPath?: string; /** Cloud origin for MRT ~/.mobify lookup (e.g., https://cloud-staging.mobify.com) */ cloudOrigin?: string; + /** Path to custom MRT credentials file (overrides default ~/.mobify) */ + credentialsFile?: string; } /** @@ -110,6 +112,7 @@ export function loadConfig( configPath: options.configPath, hostnameProtection: true, cloudOrigin: options.cloudOrigin, + credentialsFile: options.credentialsFile, sourcesBefore: pluginSources.before, sourcesAfter: pluginSources.after, }); diff --git a/packages/b2c-tooling-sdk/src/cli/mrt-command.ts b/packages/b2c-tooling-sdk/src/cli/mrt-command.ts index 95702efc..79441f92 100644 --- a/packages/b2c-tooling-sdk/src/cli/mrt-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/mrt-command.ts @@ -55,15 +55,21 @@ export abstract class MrtCommand extends BaseCommand = { diff --git a/packages/b2c-tooling-sdk/src/config/sources/mobify-source.ts b/packages/b2c-tooling-sdk/src/config/sources/mobify-source.ts index d711285c..8ddb2553 100644 --- a/packages/b2c-tooling-sdk/src/config/sources/mobify-source.ts +++ b/packages/b2c-tooling-sdk/src/config/sources/mobify-source.ts @@ -41,7 +41,8 @@ export class MobifySource implements ConfigSource { private lastPath?: string; load(options: ResolveConfigOptions): NormalizedConfig | undefined { - const mobifyPath = this.getMobifyPath(options.cloudOrigin); + // Use explicit credentialsFile if provided, otherwise use default path + const mobifyPath = options.credentialsFile ?? this.getMobifyPath(options.cloudOrigin); this.lastPath = mobifyPath; if (!fs.existsSync(mobifyPath)) { diff --git a/packages/b2c-tooling-sdk/src/config/types.ts b/packages/b2c-tooling-sdk/src/config/types.ts index c91736dc..82ea45a1 100644 --- a/packages/b2c-tooling-sdk/src/config/types.ts +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -123,6 +123,8 @@ export interface ResolveConfigOptions { hostnameProtection?: boolean; /** Cloud origin for ~/.mobify lookup (MRT) */ cloudOrigin?: string; + /** Path to custom MRT credentials file (overrides default ~/.mobify) */ + credentialsFile?: string; /** * Custom sources to add BEFORE default sources (higher priority). diff --git a/packages/b2c-tooling-sdk/test/cli/base-command.test.ts b/packages/b2c-tooling-sdk/test/cli/base-command.test.ts index be88137c..01b5aa7c 100644 --- a/packages/b2c-tooling-sdk/test/cli/base-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/base-command.test.ts @@ -57,272 +57,6 @@ describe('cli/base-command', () => { command = new TestBaseCommand([], config); }); - describe('init', () => { - it('initializes command with default flags', async () => { - // Mock parse method - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - - expect(cmd.flags).to.be.an('object'); - expect(cmd.args).to.be.an('object'); - expect(cmd.resolvedConfig).to.be.an('object'); - expect(cmd.logger).to.exist; - expect(cmd.logger.info).to.be.a('function'); - - cmd.parse = originalParse; - }); - - it('handles log-level flag', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'log-level': 'debug'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.logger).to.exist; - expect(cmd.logger.info).to.be.a('function'); - - cmd.parse = originalParse; - }); - - it('handles debug flag', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {debug: true}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.logger).to.exist; - expect(cmd.logger.info).to.be.a('function'); - - cmd.parse = originalParse; - }); - - it('handles json flag', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {json: true}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags.json).to.be.true; - - cmd.parse = originalParse; - }); - - it('handles lang flag', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {lang: 'de'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags.lang).to.equal('de'); - - cmd.parse = originalParse; - }); - - it('handles config flag', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {config: '/custom/dw.json'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags.config).to.equal('/custom/dw.json'); - - cmd.parse = originalParse; - }); - - it('handles instance flag', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {instance: 'test-instance'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags.instance).to.equal('test-instance'); - - cmd.parse = originalParse; - }); - }); - - describe('configureLogging', () => { - it('configures logger with default level', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.logger).to.exist; - expect(cmd.logger.info).to.be.a('function'); - - cmd.parse = originalParse; - }); - - it('configures logger with log-level flag', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'log-level': 'warn'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.logger).to.exist; - expect(cmd.logger.info).to.be.a('function'); - - cmd.parse = originalParse; - }); - - it('configures logger with debug flag', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {debug: true}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.logger).to.exist; - expect(cmd.logger.info).to.be.a('function'); - - cmd.parse = originalParse; - }); - }); - - describe('log', () => { - it('logs message using logger', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - let loggedMessage = ''; - const originalInfo = cmd.logger.info.bind(cmd.logger); - cmd.logger.info = ((message: string) => { - loggedMessage = message; - }) as typeof cmd.logger.info; - - cmd.log('Test message'); - expect(loggedMessage).to.equal('Test message'); - - cmd.logger.info = originalInfo; - cmd.parse = originalParse; - }); - - it('logs message with args', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - let loggedMessage = ''; - const originalInfo = cmd.logger.info.bind(cmd.logger); - cmd.logger.info = ((message: string) => { - loggedMessage = message; - }) as typeof cmd.logger.info; - - cmd.log('Test message', 'arg1', 'arg2'); - expect(loggedMessage).to.include('Test message'); - expect(loggedMessage).to.include('arg1'); - expect(loggedMessage).to.include('arg2'); - - cmd.logger.info = originalInfo; - cmd.parse = originalParse; - }); - }); - - describe('warn', () => { - it('warns with string message', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - let warnedMessage = ''; - const originalWarn = cmd.logger.warn.bind(cmd.logger); - cmd.logger.warn = ((msg: string) => { - warnedMessage = msg; - }) as typeof cmd.logger.warn; - - const result = cmd.warn('Warning message'); - expect(result).to.equal('Warning message'); - expect(warnedMessage).to.equal('Warning message'); - - cmd.logger.warn = originalWarn; - cmd.parse = originalParse; - }); - - it('warns with Error object', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - let warnedMessage = ''; - const originalWarn = cmd.logger.warn.bind(cmd.logger); - cmd.logger.warn = ((msg: string) => { - warnedMessage = msg; - }) as typeof cmd.logger.warn; - - const error = new Error('Error message'); - const result = cmd.warn(error); - expect(result).to.equal(error); - expect(warnedMessage).to.equal('Error message'); - - cmd.logger.warn = originalWarn; - cmd.parse = originalParse; - }); - }); - describe('getExtraParams', () => { it('returns undefined when no extra params', async () => { const cmd = command as MockableBaseCommand; @@ -451,59 +185,6 @@ describe('cli/base-command', () => { }); }); - describe('baseCommandTest', () => { - it('logs test message', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - let loggedMessage = ''; - const originalInfo = cmd.logger.info.bind(cmd.logger); - cmd.logger.info = ((message: string) => { - loggedMessage = message; - }) as typeof cmd.logger.info; - - cmd.baseCommandTest(); - expect(loggedMessage).to.include('BaseCommand initialized'); - - cmd.logger.info = originalInfo; - cmd.parse = originalParse; - }); - }); - - describe('shouldColorize', () => { - it('respects NO_COLOR environment variable', async () => { - const originalNoColor = process.env.NO_COLOR; - process.env.NO_COLOR = '1'; - - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - // shouldColorize is private, so we test it indirectly through configureLogging - // The logger will be configured with colorize=false when NO_COLOR is set - expect(cmd.logger).to.exist; - - if (originalNoColor !== undefined) { - process.env.NO_COLOR = originalNoColor; - } else { - delete process.env.NO_COLOR; - } - - cmd.parse = originalParse; - }); - }); - describe('catch', () => { it('handles errors with exit code', async () => { const cmd = command as MockableBaseCommand; diff --git a/packages/b2c-tooling-sdk/test/cli/cartridge-command.test.ts b/packages/b2c-tooling-sdk/test/cli/cartridge-command.test.ts deleted file mode 100644 index c78fcdc5..00000000 --- a/packages/b2c-tooling-sdk/test/cli/cartridge-command.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * SPDX-License-Identifier: Apache-2 - * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 - */ -import {expect} from 'chai'; -import {Config} from '@oclif/core'; -import {CartridgeCommand} from '@salesforce/b2c-tooling-sdk/cli'; - -// Create a test command class -class TestCartridgeCommand extends CartridgeCommand { - static id = 'test:cartridge'; - static description = 'Test cartridge command'; - - async run(): Promise { - // Test implementation - } - - // Expose protected methods for testing - public testCartridgePath() { - return this.cartridgePath; - } - - public testCartridgeOptions() { - return this.cartridgeOptions; - } - - public testFindCartridgesWithProviders(directory?: string, options?: {include?: string[]; exclude?: string[]}) { - return this.findCartridgesWithProviders(directory, options); - } -} - -// Type for mocking command properties in tests -type MockableCartridgeCommand = TestCartridgeCommand & { - parse: () => Promise<{ - args: Record; - flags: Record; - metadata: Record; - }>; - flags: Record; - args: Record; - resolvedConfig: Record; -}; - -describe('cli/cartridge-command', () => { - let config: Config; - let command: TestCartridgeCommand; - - beforeEach(async () => { - config = await Config.load(); - command = new TestCartridgeCommand([], config); - }); - - describe('init', () => { - it('initializes command with cartridge flags', async () => { - const cmd = command as MockableCartridgeCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {cartridgePath: '.'}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags).to.be.an('object'); - expect(cmd.args).to.be.an('object'); - - cmd.parse = originalParse; - }); - - it('handles cartridgePath argument', async () => { - const cmd = command as MockableCartridgeCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {cartridgePath: '/custom/path'}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.args.cartridgePath).to.equal('/custom/path'); - - cmd.parse = originalParse; - }); - - it('uses default cartridgePath when not specified', async () => { - const cmd = command as MockableCartridgeCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {cartridgePath: '.'}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.args.cartridgePath).to.equal('.'); - - cmd.parse = originalParse; - }); - - it('handles cartridge flag', async () => { - const cmd = command as MockableCartridgeCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {cartridgePath: '.'}, - flags: {cartridge: ['cart1', 'cart2']}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags.cartridge).to.be.an('array'); - - cmd.parse = originalParse; - }); - - it('handles exclude-cartridge flag', async () => { - const cmd = command as MockableCartridgeCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {cartridgePath: '.'}, - flags: {'exclude-cartridge': ['cart1', 'cart2']}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags['exclude-cartridge']).to.be.an('array'); - - cmd.parse = originalParse; - }); - }); - - describe('cartridgePath', () => { - it('returns default path when not specified', async () => { - const cmd = command as MockableCartridgeCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {cartridgePath: '.'}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const path = command.testCartridgePath(); - expect(path).to.equal('.'); - - cmd.parse = originalParse; - }); - - it('returns custom path from args', async () => { - const cmd = command as MockableCartridgeCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {cartridgePath: '/custom/path'}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const path = command.testCartridgePath(); - expect(path).to.equal('/custom/path'); - - cmd.parse = originalParse; - }); - }); - - describe('cartridgeOptions', () => { - it('returns empty options when no flags', async () => { - const cmd = command as MockableCartridgeCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {cartridgePath: '.'}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const options = command.testCartridgeOptions(); - expect(options.include).to.be.undefined; - expect(options.exclude).to.be.undefined; - - cmd.parse = originalParse; - }); - - it('returns include options from flag', async () => { - const cmd = command as MockableCartridgeCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {cartridgePath: '.'}, - flags: {cartridge: ['cart1', 'cart2']}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const options = command.testCartridgeOptions(); - expect(options.include).to.be.an('array'); - expect(options.include).to.include('cart1'); - expect(options.include).to.include('cart2'); - - cmd.parse = originalParse; - }); - - it('returns exclude options from flag', async () => { - const cmd = command as MockableCartridgeCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {cartridgePath: '.'}, - flags: {'exclude-cartridge': ['cart1', 'cart2']}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const options = command.testCartridgeOptions(); - expect(options.exclude).to.be.an('array'); - expect(options.exclude).to.include('cart1'); - expect(options.exclude).to.include('cart2'); - - cmd.parse = originalParse; - }); - }); - - describe('findCartridgesWithProviders', () => { - it('returns default cartridges when no providers', async () => { - const cmd = command as MockableCartridgeCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {cartridgePath: '.'}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - // This will use the actual findCartridges function which may not find cartridges - // in the test environment, so we just verify it doesn't throw - try { - await command.testFindCartridgesWithProviders(); - } catch { - // Expected if no cartridges found - } - - cmd.parse = originalParse; - }); - - it('accepts custom directory', async () => { - const cmd = command as MockableCartridgeCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {cartridgePath: '.'}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - try { - await command.testFindCartridgesWithProviders('/custom/dir'); - } catch { - // Expected if no cartridges found - } - - cmd.parse = originalParse; - }); - - it('accepts custom options', async () => { - const cmd = command as MockableCartridgeCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {cartridgePath: '.'}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - try { - await command.testFindCartridgesWithProviders(undefined, {include: ['cart1']}); - } catch { - // Expected if no cartridges found - } - - cmd.parse = originalParse; - }); - }); -}); diff --git a/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts b/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts index bb3b331a..475f0bd4 100644 --- a/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts @@ -18,10 +18,6 @@ class TestInstanceCommand extends InstanceCommand { } // Expose protected methods for testing - public testHasWebDavCredentials() { - return this.hasWebDavCredentials(); - } - public testRequireServer() { return this.requireServer(); } @@ -73,120 +69,6 @@ describe('cli/instance-command', () => { command = new TestInstanceCommand([], config); }); - describe('init', () => { - it('initializes command with instance flags', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags).to.be.an('object'); - expect(cmd.resolvedConfig).to.be.an('object'); - - cmd.parse = originalParse; - }); - - it('handles server flag', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {server: 'test.demandware.net'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags.server).to.equal('test.demandware.net'); - - cmd.parse = originalParse; - }); - - it('handles code-version flag', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'code-version': 'v1'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags['code-version']).to.equal('v1'); - - cmd.parse = originalParse; - }); - - it('handles username and password flags', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {username: 'test-user', password: 'test-pass'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags.username).to.equal('test-user'); - expect(cmd.flags.password).to.equal('test-pass'); - - cmd.parse = originalParse; - }); - }); - - describe('hasWebDavCredentials', () => { - it('returns false when no credentials', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const hasCreds = command.testHasWebDavCredentials(); - expect(hasCreds).to.be.false; - - cmd.parse = originalParse; - }); - - it('returns true when username and password are set', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {username: 'user', password: 'pass'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const hasCreds = command.testHasWebDavCredentials(); - expect(hasCreds).to.be.true; - - cmd.parse = originalParse; - }); - - it('returns true when clientId is set', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'client-id': 'test-client'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const hasCreds = command.testHasWebDavCredentials(); - expect(hasCreds).to.be.true; - - cmd.parse = originalParse; - }); - }); - describe('requireServer', () => { it('throws error when no server', async () => { const cmd = command as MockableInstanceCommand; diff --git a/packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts b/packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts index 1d6dacf3..2bd50ed2 100644 --- a/packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts @@ -17,21 +17,9 @@ class TestMrtCommand extends MrtCommand { } // Expose protected methods for testing - public testGetMrtAuth() { - return this.getMrtAuth(); - } - - public testHasMrtCredentials() { - return this.hasMrtCredentials(); - } - public testRequireMrtCredentials() { return this.requireMrtCredentials(); } - - public testCreateMrtClient(project: {org: string; project: string; env: string}) { - return this.createMrtClient(project); - } } // Type for mocking command properties in tests @@ -56,168 +44,13 @@ describe('cli/mrt-command', () => { command = new TestMrtCommand([], config); }); - describe('init', () => { - it('initializes command with MRT flags', async () => { - const cmd = command as MockableMrtCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags).to.be.an('object'); - expect(cmd.resolvedConfig).to.be.an('object'); - - cmd.parse = originalParse; - }); - - it('handles api-key flag', async () => { - const cmd = command as MockableMrtCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'api-key': 'test-api-key'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags['api-key']).to.equal('test-api-key'); - - cmd.parse = originalParse; - }); - - it('handles project flag', async () => { - const cmd = command as MockableMrtCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {project: 'test-project'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags.project).to.equal('test-project'); - - cmd.parse = originalParse; - }); - - it('handles environment flag', async () => { - const cmd = command as MockableMrtCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {environment: 'staging'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags.environment).to.equal('staging'); - - cmd.parse = originalParse; - }); - - it('handles cloud-origin flag', async () => { - const cmd = command as MockableMrtCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'cloud-origin': 'https://cloud-staging.mobify.com'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags['cloud-origin']).to.equal('https://cloud-staging.mobify.com'); - - cmd.parse = originalParse; - }); - }); - - describe('getMrtAuth', () => { - it('throws error when no API key', async () => { - const cmd = command as MockableMrtCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - try { - command.testGetMrtAuth(); - expect.fail('Should have thrown'); - } catch (error) { - expect(error).to.be.an('error'); - // Error message may be translated, so just check it's an error about MRT/API key - const message = (error as Error).message.toLowerCase(); - expect(message).to.satisfy( - (msg: string) => msg.includes('mrt') || msg.includes('api') || msg.includes('schlüssel'), - ); - } - - cmd.parse = originalParse; - }); - - it('returns ApiKeyStrategy when API key is set', async () => { - const cmd = command as MockableMrtCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'api-key': 'test-api-key'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const auth = command.testGetMrtAuth(); - expect(auth).to.be.an('object'); - - cmd.parse = originalParse; - }); - }); - - describe('hasMrtCredentials', () => { - it('returns false when no API key', async () => { - const cmd = command as MockableMrtCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const hasCreds = command.testHasMrtCredentials(); - expect(hasCreds).to.be.false; - - cmd.parse = originalParse; - }); - - it('returns true when API key is set', async () => { - const cmd = command as MockableMrtCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'api-key': 'test-api-key'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const hasCreds = command.testHasMrtCredentials(); - expect(hasCreds).to.be.true; - - cmd.parse = originalParse; - }); - }); - describe('requireMrtCredentials', () => { it('throws error when no credentials', async () => { const cmd = command as MockableMrtCommand; const originalParse = cmd.parse.bind(command); cmd.parse = (async () => ({ args: {}, - flags: {}, + flags: {'credentials-file': '/dev/null'}, // Use non-existent credentials file metadata: {}, })) as typeof cmd.parse; @@ -259,42 +92,4 @@ describe('cli/mrt-command', () => { cmd.parse = originalParse; }); }); - - describe('createMrtClient', () => { - it('throws error when no credentials', async () => { - const cmd = command as MockableMrtCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - try { - command.testCreateMrtClient({org: 'test-org', project: 'test-project', env: 'staging'}); - expect.fail('Should have thrown'); - } catch (error) { - expect(error).to.be.an('error'); - } - - cmd.parse = originalParse; - }); - - it('creates MRT client when credentials available', async () => { - const cmd = command as MockableMrtCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'api-key': 'test-api-key'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const client = command.testCreateMrtClient({org: 'test-org', project: 'test-project', env: 'staging'}); - expect(client).to.be.an('object'); - - cmd.parse = originalParse; - }); - }); }); diff --git a/packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts b/packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts index 7ce68d94..5d833a77 100644 --- a/packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts @@ -21,22 +21,6 @@ class TestOAuthCommand extends OAuthCommand { return this.parseAuthMethods(); } - public testAccountManagerHost() { - return this.accountManagerHost; - } - - public testGetOAuthStrategy() { - return this.getOAuthStrategy(); - } - - public testHasOAuthCredentials() { - return this.hasOAuthCredentials(); - } - - public testHasFullOAuthCredentials() { - return this.hasFullOAuthCredentials(); - } - public testRequireOAuthCredentials() { return this.requireOAuthCredentials(); } @@ -46,10 +30,10 @@ class TestOAuthCommand extends OAuthCommand { type MockableOAuthCommand = TestOAuthCommand & { parse: () => Promise<{ args: Record; - flags: Record; + flags: Record; metadata: Record; }>; - flags: Record; + flags: Record; args: Record; resolvedConfig: Record; error?: (message: string, options?: {exit?: number}) => never; @@ -64,84 +48,6 @@ describe('cli/oauth-command', () => { command = new TestOAuthCommand([], config); }); - describe('init', () => { - it('initializes command with OAuth flags', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags).to.be.an('object'); - expect(cmd.resolvedConfig).to.be.an('object'); - - cmd.parse = originalParse; - }); - - it('handles client-id flag', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'client-id': 'test-client-id'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags['client-id']).to.equal('test-client-id'); - - cmd.parse = originalParse; - }); - - it('handles client-secret flag', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'client-secret': 'test-secret'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags['client-secret']).to.equal('test-secret'); - - cmd.parse = originalParse; - }); - - it('handles scope flag', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {scope: ['mail', 'roles']}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags.scope).to.be.an('array'); - - cmd.parse = originalParse; - }); - - it('handles account-manager-host flag', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'account-manager-host': 'custom.example.com'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags['account-manager-host']).to.equal('custom.example.com'); - - cmd.parse = originalParse; - }); - }); - describe('parseAuthMethods', () => { it('returns undefined when no auth methods specified', async () => { const cmd = command as MockableOAuthCommand; @@ -194,163 +100,6 @@ describe('cli/oauth-command', () => { }); }); - describe('accountManagerHost', () => { - it('returns default account manager host', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const host = command.testAccountManagerHost(); - expect(host).to.be.a('string'); - expect(host.length).to.be.greaterThan(0); - - cmd.parse = originalParse; - }); - - it('returns custom account manager host from flag', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'account-manager-host': 'custom.example.com'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const host = command.testAccountManagerHost(); - expect(host).to.equal('custom.example.com'); - - cmd.parse = originalParse; - }); - }); - - describe('getOAuthStrategy', () => { - it('throws error when no credentials available', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - try { - command.testGetOAuthStrategy(); - expect.fail('Should have thrown'); - } catch (error) { - expect(error).to.be.an('error'); - } - - cmd.parse = originalParse; - }); - - it('returns OAuthStrategy when client credentials available', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'client-id': 'test-client', 'client-secret': 'test-secret'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const strategy = command.testGetOAuthStrategy(); - expect(strategy).to.be.an('object'); - - cmd.parse = originalParse; - }); - - it('returns ImplicitOAuthStrategy when only clientId available', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'client-id': 'test-client', 'auth-methods': ['implicit']}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const strategy = command.testGetOAuthStrategy(); - expect(strategy).to.be.an('object'); - - cmd.parse = originalParse; - }); - }); - - describe('hasOAuthCredentials', () => { - it('returns false when no clientId', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const hasCreds = command.testHasOAuthCredentials(); - expect(hasCreds).to.be.false; - - cmd.parse = originalParse; - }); - - it('returns true when clientId is set', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'client-id': 'test-client'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const hasCreds = command.testHasOAuthCredentials(); - expect(hasCreds).to.be.true; - - cmd.parse = originalParse; - }); - }); - - describe('hasFullOAuthCredentials', () => { - it('returns false when only clientId', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'client-id': 'test-client'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const hasFull = command.testHasFullOAuthCredentials(); - expect(hasFull).to.be.false; - - cmd.parse = originalParse; - }); - - it('returns true when both clientId and clientSecret', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'client-id': 'test-client', 'client-secret': 'test-secret'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const hasFull = command.testHasFullOAuthCredentials(); - expect(hasFull).to.be.true; - - cmd.parse = originalParse; - }); - }); - describe('requireOAuthCredentials', () => { it('throws error when no credentials', async () => { const cmd = command as MockableOAuthCommand; @@ -363,7 +112,7 @@ describe('cli/oauth-command', () => { await cmd.init(); let errorCalled = false; - const originalError = cmd.error.bind(command); + const originalError = cmd.error?.bind(command); cmd.error = () => { errorCalled = true; throw new Error('Expected error'); diff --git a/packages/b2c-tooling-sdk/test/cli/ods-command.test.ts b/packages/b2c-tooling-sdk/test/cli/ods-command.test.ts index 8f633f2f..c505ecca 100644 --- a/packages/b2c-tooling-sdk/test/cli/ods-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/ods-command.test.ts @@ -17,10 +17,6 @@ class TestOdsCommand extends OdsCommand { } // Expose protected methods for testing - public testOdsHost() { - return this.odsHost; - } - public testOdsClient() { return this.odsClient; } @@ -47,90 +43,6 @@ describe('cli/ods-command', () => { command = new TestOdsCommand([], config); }); - describe('init', () => { - it('initializes command with ODS flags', async () => { - const cmd = command as MockableOdsCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags).to.be.an('object'); - expect(cmd.resolvedConfig).to.be.an('object'); - - cmd.parse = originalParse; - }); - - it('handles sandbox-api-host flag', async () => { - const cmd = command as MockableOdsCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'sandbox-api-host': 'custom.example.com'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - expect(cmd.flags['sandbox-api-host']).to.equal('custom.example.com'); - - cmd.parse = originalParse; - }); - - it('uses default sandbox-api-host when not specified', async () => { - const cmd = command as MockableOdsCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const host = command.testOdsHost(); - expect(host).to.be.a('string'); - expect(host.length).to.be.greaterThan(0); - - cmd.parse = originalParse; - }); - }); - - describe('odsHost', () => { - it('returns default host when not specified', async () => { - const cmd = command as MockableOdsCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const host = command.testOdsHost(); - expect(host).to.be.a('string'); - - cmd.parse = originalParse; - }); - - it('returns custom host from flag', async () => { - const cmd = command as MockableOdsCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'sandbox-api-host': 'custom.example.com'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const host = command.testOdsHost(); - expect(host).to.equal('custom.example.com'); - - cmd.parse = originalParse; - }); - }); - describe('odsClient', () => { it('throws error when no OAuth credentials', async () => { const cmd = command as MockableOdsCommand; @@ -153,22 +65,6 @@ describe('cli/ods-command', () => { cmd.parse = originalParse; }); - it('creates ODS client when OAuth credentials available', async () => { - const cmd = command as MockableOdsCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'client-id': 'test-client', 'client-secret': 'test-secret'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - const client = command.testOdsClient(); - expect(client).to.be.an('object'); - - cmd.parse = originalParse; - }); - it('creates ODS client lazily', async () => { const cmd = command as MockableOdsCommand; const originalParse = cmd.parse.bind(command); diff --git a/packages/b2c-tooling-sdk/test/config/resolved-config.test.ts b/packages/b2c-tooling-sdk/test/config/resolved-config.test.ts index acfe2c18..93fdcf7e 100644 --- a/packages/b2c-tooling-sdk/test/config/resolved-config.test.ts +++ b/packages/b2c-tooling-sdk/test/config/resolved-config.test.ts @@ -16,7 +16,7 @@ describe('config/resolved-config', () => { }); it('hasB2CInstanceConfig returns false when hostname is missing', () => { - const config = resolveConfig({}); + const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); expect(config.hasB2CInstanceConfig()).to.be.false; }); @@ -26,7 +26,7 @@ describe('config/resolved-config', () => { }); it('hasMrtConfig returns false when mrtApiKey is missing', () => { - const config = resolveConfig({}); + const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); expect(config.hasMrtConfig()).to.be.false; }); @@ -36,7 +36,7 @@ describe('config/resolved-config', () => { }); it('hasOAuthConfig returns false when clientId is missing', () => { - const config = resolveConfig({}); + const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); expect(config.hasOAuthConfig()).to.be.false; }); @@ -46,12 +46,12 @@ describe('config/resolved-config', () => { }); it('hasBasicAuthConfig returns false when username is missing', () => { - const config = resolveConfig({password: 'pass'}); + const config = resolveConfig({password: 'pass'}, {replaceDefaultSources: true, sources: []}); expect(config.hasBasicAuthConfig()).to.be.false; }); it('hasBasicAuthConfig returns false when password is missing', () => { - const config = resolveConfig({username: 'user'}); + const config = resolveConfig({username: 'user'}, {replaceDefaultSources: true, sources: []}); expect(config.hasBasicAuthConfig()).to.be.false; }); }); @@ -67,7 +67,7 @@ describe('config/resolved-config', () => { }); it('throws error when hostname is missing', () => { - const config = resolveConfig({}); + const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); expect(() => config.createB2CInstance()).to.throw('B2C instance requires hostname'); }); }); @@ -82,12 +82,12 @@ describe('config/resolved-config', () => { }); it('throws error when username is missing', () => { - const config = resolveConfig({password: 'pass'}); + const config = resolveConfig({password: 'pass'}, {replaceDefaultSources: true, sources: []}); expect(() => config.createBasicAuth()).to.throw('Basic auth requires username and password'); }); it('throws error when password is missing', () => { - const config = resolveConfig({username: 'user'}); + const config = resolveConfig({username: 'user'}, {replaceDefaultSources: true, sources: []}); expect(() => config.createBasicAuth()).to.throw('Basic auth requires username and password'); }); }); @@ -102,7 +102,7 @@ describe('config/resolved-config', () => { }); it('throws error when clientId is missing', () => { - const config = resolveConfig({}); + const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); expect(() => config.createOAuth()).to.throw('OAuth requires clientId'); }); @@ -123,7 +123,7 @@ describe('config/resolved-config', () => { }); it('throws error when mrtApiKey is missing', () => { - const config = resolveConfig({}); + const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); expect(() => config.createMrtAuth()).to.throw('MRT auth requires mrtApiKey'); }); }); @@ -146,7 +146,7 @@ describe('config/resolved-config', () => { }); it('throws error when no auth is available', () => { - const config = resolveConfig({}); + const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); expect(() => config.createWebDavAuth()).to.throw( 'WebDAV auth requires basic auth (username/password) or OAuth (clientId)', ); @@ -171,7 +171,7 @@ describe('config/resolved-config', () => { }); it('throws error when mrtApiKey is missing', () => { - const config = resolveConfig({}); + const config = resolveConfig({}, {replaceDefaultSources: true, sources: []}); expect(() => config.createMrtClient({org: 'test-org', project: 'test-project'})).to.throw( 'MRT auth requires mrtApiKey', ); diff --git a/packages/b2c-tooling-sdk/test/helpers/config-isolation.ts b/packages/b2c-tooling-sdk/test/helpers/config-isolation.ts new file mode 100644 index 00000000..3e3525f4 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/helpers/config-isolation.ts @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Test helper for isolating tests from host environment configuration. + * + * This helper clears environment variables that affect config loading: + * - All SFCC_* env vars (CLI flags) + * - All MRT_* env vars (MRT credentials file path) + * - Additional vars that affect output (LANGUAGE, NO_COLOR) + * + * @example + * ```typescript + * import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; + * + * describe('my-test', () => { + * beforeEach(() => { + * isolateConfig(); + * }); + * + * afterEach(() => { + * restoreConfig(); + * }); + * + * // ... tests run in isolated environment + * }); + * ``` + */ + +/** Additional non-SFCC env vars that affect config loading */ +const ADDITIONAL_ENV_VARS = ['LANGUAGE', 'NO_COLOR']; + +interface IsolationState { + savedEnvVars: Record; +} + +let state: IsolationState | null = null; + +/** + * Isolates tests from host environment configuration. + * + * Must be called in beforeEach() and paired with restoreConfig() in afterEach(). + * + * @throws Error if called without first calling restoreConfig() + */ +export function isolateConfig(): void { + if (state) throw new Error('isolateConfig() called without cleanup - call restoreConfig() first'); + + const savedEnvVars: Record = {}; + + // Clear all SFCC_* AND MRT_* env vars + for (const key of Object.keys(process.env)) { + if (key.startsWith('SFCC_') || key.startsWith('MRT_')) { + savedEnvVars[key] = process.env[key]; + delete process.env[key]; + } + } + + // Clear additional non-SFCC vars that affect config + for (const key of ADDITIONAL_ENV_VARS) { + savedEnvVars[key] = process.env[key]; + delete process.env[key]; + } + + state = {savedEnvVars}; +} + +/** + * Restores the host environment after test isolation. + * + * Must be called in afterEach() after isolateConfig() was called in beforeEach(). + * Safe to call even if isolateConfig() was not called (no-op). + */ +export function restoreConfig(): void { + if (!state) return; + + // Restore env vars + for (const [key, value] of Object.entries(state.savedEnvVars)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + state = null; +} 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 b3af750e..1d0942ff 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 @@ -24,6 +24,9 @@ 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}; + describe('operations/jobs/site-archive', () => { const server = setupServer(); let mockInstance: any; @@ -106,6 +109,7 @@ describe('operations/jobs/site-archive', () => { const result = await siteArchiveImport(mockInstance, siteDir, { archiveName: 'test-import', + waitOptions: FAST_WAIT_OPTIONS, }); expect(result.execution.id).to.equal('exec-1'); @@ -153,7 +157,9 @@ describe('operations/jobs/site-archive', () => { }), ); - const result = await siteArchiveImport(mockInstance, zipPath); + const result = await siteArchiveImport(mockInstance, zipPath, { + waitOptions: FAST_WAIT_OPTIONS, + }); expect(result.execution.id).to.equal('exec-2'); expect(uploadedZip).to.not.be.null; @@ -185,6 +191,7 @@ describe('operations/jobs/site-archive', () => { const result = await siteArchiveImport(mockInstance, zipBuffer, { archiveName: 'buffer-import', + waitOptions: FAST_WAIT_OPTIONS, }); expect(result.execution.id).to.equal('exec-3'); @@ -210,7 +217,13 @@ describe('operations/jobs/site-archive', () => { }), ); - const result = await siteArchiveImport(mockInstance, {remoteFilename: 'existing-archive.zip'}); + const result = await siteArchiveImport( + mockInstance, + {remoteFilename: 'existing-archive.zip'}, + { + waitOptions: FAST_WAIT_OPTIONS, + }, + ); expect(result.execution.id).to.equal('exec-4'); expect(result.archiveFilename).to.equal('existing-archive.zip'); @@ -248,6 +261,7 @@ describe('operations/jobs/site-archive', () => { const result = await siteArchiveImport(mockInstance, zipPath, { keepArchive: true, + waitOptions: FAST_WAIT_OPTIONS, }); expect(result.archiveKept).to.be.true; @@ -291,7 +305,9 @@ describe('operations/jobs/site-archive', () => { ); try { - await siteArchiveImport(mockInstance, zipPath); + await siteArchiveImport(mockInstance, zipPath, { + waitOptions: FAST_WAIT_OPTIONS, + }); expect.fail('Should have thrown JobExecutionError'); } catch (error: any) { expect(error.name).to.equal('JobExecutionError'); @@ -333,7 +349,9 @@ describe('operations/jobs/site-archive', () => { }), ); - const result = await siteArchiveExportToPath(mockInstance, {global_data: {meta_data: true}}, exportPath); + const result = await siteArchiveExportToPath(mockInstance, {global_data: {meta_data: true}}, exportPath, { + waitOptions: FAST_WAIT_OPTIONS, + }); expect(result.execution.id).to.equal('export-1'); expect(result.localPath).to.equal(exportPath); @@ -371,7 +389,11 @@ describe('operations/jobs/site-archive', () => { }), ); - const result = await siteArchiveExport(mockInstance, {global_data: {meta_data: true}}); + const result = await siteArchiveExport( + mockInstance, + {global_data: {meta_data: true}}, + {waitOptions: FAST_WAIT_OPTIONS}, + ); expect(result.execution.id).to.equal('export-2'); expect(result.data).to.be.instanceOf(Buffer); @@ -399,7 +421,9 @@ describe('operations/jobs/site-archive', () => { ); try { - await siteArchiveExportToPath(mockInstance, {}, exportPath); + await siteArchiveExportToPath(mockInstance, {}, exportPath, { + waitOptions: FAST_WAIT_OPTIONS, + }); expect.fail('Should have thrown JobExecutionError'); } catch (error: any) { expect(error.name).to.equal('JobExecutionError'); @@ -436,7 +460,11 @@ describe('operations/jobs/site-archive', () => { }), ); - const result = await siteArchiveExport(mockInstance, {global_data: {meta_data: true}}); + const result = await siteArchiveExport( + mockInstance, + {global_data: {meta_data: true}}, + {waitOptions: FAST_WAIT_OPTIONS}, + ); expect(result.archiveFilename).to.match(/\d{8}T\d{9}Z_export\.zip/); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80477e3c..27523324 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,6 +300,9 @@ importers: '@salesforce/dev-config': specifier: ^4.3.2 version: 4.3.2 + '@sinonjs/fake-timers': + specifier: ^15.1.0 + version: 15.1.0 '@tony.ganchev/eslint-plugin-header': specifier: ^3.1.11 version: 3.1.11(eslint@9.39.1) @@ -315,6 +318,9 @@ importers: '@types/node': specifier: ^18.19.130 version: 18.19.130 + '@types/sinonjs__fake-timers': + specifier: ^15.0.1 + version: 15.0.1 '@types/xml2js': specifier: ^0.4.14 version: 0.4.14 @@ -1986,6 +1992,12 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@15.1.0': + resolution: {integrity: sha512-cqfapCxwTGsrR80FEgOoPsTonoefMBY7dnUEbQ+GRcved0jvkJLzvX6F4WtN+HBqbPX/SiFsIRUp+IrCW/2I2w==} + '@smithy/abort-controller@4.2.5': resolution: {integrity: sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==} engines: {node: '>=18.0.0'} @@ -2293,6 +2305,9 @@ packages: '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + '@types/sinonjs__fake-timers@15.0.1': + resolution: {integrity: sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -5527,6 +5542,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + type-detect@4.1.0: resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} engines: {node: '>=4'} @@ -7967,6 +7986,14 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@15.1.0': + dependencies: + '@sinonjs/commons': 3.0.1 + '@smithy/abort-controller@4.2.5': dependencies: '@smithy/types': 4.9.0 @@ -8401,6 +8428,8 @@ snapshots: dependencies: '@types/node': 18.19.130 + '@types/sinonjs__fake-timers@15.0.1': {} + '@types/statuses@2.0.6': {} '@types/unist@3.0.3': {} @@ -11972,6 +12001,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-detect@4.0.8: {} + type-detect@4.1.0: {} type-fest@0.20.2: {} From a155bc7136da6bda346666dcde66f081631efbe0 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 14 Jan 2026 17:56:00 -0500 Subject: [PATCH 02/10] update lockfile --- pnpm-lock.yaml | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27523324..80477e3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,9 +300,6 @@ importers: '@salesforce/dev-config': specifier: ^4.3.2 version: 4.3.2 - '@sinonjs/fake-timers': - specifier: ^15.1.0 - version: 15.1.0 '@tony.ganchev/eslint-plugin-header': specifier: ^3.1.11 version: 3.1.11(eslint@9.39.1) @@ -318,9 +315,6 @@ importers: '@types/node': specifier: ^18.19.130 version: 18.19.130 - '@types/sinonjs__fake-timers': - specifier: ^15.0.1 - version: 15.0.1 '@types/xml2js': specifier: ^0.4.14 version: 0.4.14 @@ -1992,12 +1986,6 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@sinonjs/commons@3.0.1': - resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - - '@sinonjs/fake-timers@15.1.0': - resolution: {integrity: sha512-cqfapCxwTGsrR80FEgOoPsTonoefMBY7dnUEbQ+GRcved0jvkJLzvX6F4WtN+HBqbPX/SiFsIRUp+IrCW/2I2w==} - '@smithy/abort-controller@4.2.5': resolution: {integrity: sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==} engines: {node: '>=18.0.0'} @@ -2305,9 +2293,6 @@ packages: '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} - '@types/sinonjs__fake-timers@15.0.1': - resolution: {integrity: sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==} - '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -5542,10 +5527,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} - type-detect@4.1.0: resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} engines: {node: '>=4'} @@ -7986,14 +7967,6 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@sinonjs/commons@3.0.1': - dependencies: - type-detect: 4.0.8 - - '@sinonjs/fake-timers@15.1.0': - dependencies: - '@sinonjs/commons': 3.0.1 - '@smithy/abort-controller@4.2.5': dependencies: '@smithy/types': 4.9.0 @@ -8428,8 +8401,6 @@ snapshots: dependencies: '@types/node': 18.19.130 - '@types/sinonjs__fake-timers@15.0.1': {} - '@types/statuses@2.0.6': {} '@types/unist@3.0.3': {} @@ -12001,8 +11972,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-detect@4.0.8: {} - type-detect@4.1.0: {} type-fest@0.20.2: {} From 9091e4c3ea6d2bca18f0fac1b3b842c1fdd5ddd3 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 14 Jan 2026 18:27:21 -0500 Subject: [PATCH 03/10] Remove upward search from dw.json loading, complete test isolation - Change loadDwJson() default to look only at ./dw.json (no parent search) - Update DwJsonSource to use same logic, remove findDwJson import - Keep findDwJson() exported for users who need explicit upward search - Set SFCC_CONFIG=/dev/null and MRT_CREDENTIALS_FILE=/dev/null in isolateConfig() - Update test to verify new behavior (no upward search) --- packages/b2c-tooling-sdk/src/config/dw-json.ts | 16 +++++++++++----- .../src/config/sources/dw-json-source.ts | 7 ++++--- .../b2c-tooling-sdk/test/config/sources.test.ts | 6 ++++-- .../test/helpers/config-isolation.ts | 11 ++++++++++- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/b2c-tooling-sdk/src/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index 391f0125..ef70a1a7 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -149,16 +149,21 @@ function selectConfig(json: DwJsonMultiConfig, instanceName?: string): DwJsonCon /** * Loads configuration from a dw.json file. * - * Searches upward from the current directory (or specified startDir) for a dw.json file. - * Supports both single-config and multi-config formats. + * If an explicit path is provided, uses that file. Otherwise, looks for dw.json + * in the startDir (or cwd). Does NOT search parent directories. + * + * Use `findDwJson()` if you need to search upward through parent directories. * * @param options - Loading options * @returns The parsed config, or undefined if no dw.json found * * @example - * // Auto-find dw.json + * // Load from ./dw.json (current directory) * const config = loadDwJson(); * + * // Load from specific directory + * const config = loadDwJson({ startDir: '/path/to/project' }); + * * // Use named instance * const config = loadDwJson({ instance: 'staging' }); * @@ -166,9 +171,10 @@ function selectConfig(json: DwJsonMultiConfig, instanceName?: string): DwJsonCon * const config = loadDwJson({ path: './config/dw.json' }); */ export function loadDwJson(options: LoadDwJsonOptions = {}): DwJsonConfig | undefined { - const dwJsonPath = options.path || findDwJson(options.startDir); + // If explicit path provided, use it. Otherwise default to ./dw.json (no upward search) + const dwJsonPath = options.path ?? path.join(options.startDir || process.cwd(), 'dw.json'); - if (!dwJsonPath || !fs.existsSync(dwJsonPath)) { + if (!fs.existsSync(dwJsonPath)) { return undefined; } diff --git a/packages/b2c-tooling-sdk/src/config/sources/dw-json-source.ts b/packages/b2c-tooling-sdk/src/config/sources/dw-json-source.ts index 3fa65576..2fa07ea9 100644 --- a/packages/b2c-tooling-sdk/src/config/sources/dw-json-source.ts +++ b/packages/b2c-tooling-sdk/src/config/sources/dw-json-source.ts @@ -8,7 +8,8 @@ * * @internal This module is internal to the SDK. Use ConfigResolver instead. */ -import {loadDwJson, findDwJson} from '../dw-json.js'; +import * as path from 'node:path'; +import {loadDwJson} from '../dw-json.js'; import {mapDwJsonToNormalizedConfig} from '../mapping.js'; import type {ConfigSource, NormalizedConfig, ResolveConfigOptions} from '../types.js'; @@ -33,8 +34,8 @@ export class DwJsonSource implements ConfigSource { return undefined; } - // Track the path for diagnostics - this.lastPath = options.configPath || findDwJson(options.startDir); + // Track the path for diagnostics - use explicit path or default location + this.lastPath = options.configPath ?? path.join(options.startDir || process.cwd(), 'dw.json'); return mapDwJsonToNormalizedConfig(dwConfig); } diff --git a/packages/b2c-tooling-sdk/test/config/sources.test.ts b/packages/b2c-tooling-sdk/test/config/sources.test.ts index 85df2d9c..501df536 100644 --- a/packages/b2c-tooling-sdk/test/config/sources.test.ts +++ b/packages/b2c-tooling-sdk/test/config/sources.test.ts @@ -46,7 +46,7 @@ describe('config/sources', () => { expect(config.codeVersion).to.equal('v1'); }); - it('loads config from dw.json in parent directory', () => { + it('does NOT load config from dw.json in parent directory (no upward search)', () => { const subDir = path.join(tempDir, 'subdir'); fs.mkdirSync(subDir); const dwJsonPath = path.join(tempDir, 'dw.json'); @@ -57,11 +57,13 @@ describe('config/sources', () => { }), ); + // Change to subdirectory - should NOT find parent's dw.json process.chdir(subDir); const resolver = new ConfigResolver(); const {config} = resolver.resolve(); - expect(config.hostname).to.equal('parent.demandware.net'); + // Parent dw.json should NOT be found (no upward search) + expect(config.hostname).to.be.undefined; }); it('handles OAuth credentials from dw.json', () => { diff --git a/packages/b2c-tooling-sdk/test/helpers/config-isolation.ts b/packages/b2c-tooling-sdk/test/helpers/config-isolation.ts index 3e3525f4..14333912 100644 --- a/packages/b2c-tooling-sdk/test/helpers/config-isolation.ts +++ b/packages/b2c-tooling-sdk/test/helpers/config-isolation.ts @@ -64,6 +64,11 @@ export function isolateConfig(): void { delete process.env[key]; } + // SET isolation env vars - oclif will pick these up during flag parsing + // /dev/null exists but is empty (JSON.parse fails), so config sources find nothing + process.env.SFCC_CONFIG = '/dev/null'; + process.env.MRT_CREDENTIALS_FILE = '/dev/null'; + state = {savedEnvVars}; } @@ -76,7 +81,11 @@ export function isolateConfig(): void { export function restoreConfig(): void { if (!state) return; - // Restore env vars + // Remove isolation env vars we set + delete process.env.SFCC_CONFIG; + delete process.env.MRT_CREDENTIALS_FILE; + + // Restore original env vars for (const [key, value] of Object.entries(state.savedEnvVars)) { if (value === undefined) { delete process.env[key]; From 2cf7273cc72552abd063250a79788aff188935af Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 14 Jan 2026 18:33:08 -0500 Subject: [PATCH 04/10] Wire up isolateConfig to SDK command tests Add isolateConfig()/restoreConfig() to command test files to ensure tests are isolated from developer's environment variables (SFCC_*, MRT_*). Files updated: - test/cli/base-command.test.ts - test/cli/instance-command.test.ts - test/cli/mrt-command.test.ts - test/cli/oauth-command.test.ts - test/cli/ods-command.test.ts --- packages/b2c-tooling-sdk/test/cli/base-command.test.ts | 6 ++++++ packages/b2c-tooling-sdk/test/cli/instance-command.test.ts | 6 ++++++ packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts | 6 ++++++ packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts | 6 ++++++ packages/b2c-tooling-sdk/test/cli/ods-command.test.ts | 6 ++++++ 5 files changed, 30 insertions(+) diff --git a/packages/b2c-tooling-sdk/test/cli/base-command.test.ts b/packages/b2c-tooling-sdk/test/cli/base-command.test.ts index 01b5aa7c..a789373d 100644 --- a/packages/b2c-tooling-sdk/test/cli/base-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/base-command.test.ts @@ -6,6 +6,7 @@ import {expect} from 'chai'; import {Config} from '@oclif/core'; import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; // Create a concrete test command class class TestBaseCommand extends BaseCommand { @@ -53,10 +54,15 @@ describe('cli/base-command', () => { let command: TestBaseCommand; beforeEach(async () => { + isolateConfig(); config = await Config.load(); command = new TestBaseCommand([], config); }); + afterEach(() => { + restoreConfig(); + }); + describe('getExtraParams', () => { it('returns undefined when no extra params', async () => { const cmd = command as MockableBaseCommand; diff --git a/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts b/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts index 475f0bd4..20d7d782 100644 --- a/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts @@ -7,6 +7,7 @@ import {expect} from 'chai'; import {Config} from '@oclif/core'; import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; import type {B2COperationContext, B2COperationResult, B2COperationType} from '@salesforce/b2c-tooling-sdk/cli'; +import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; // Create a test command class class TestInstanceCommand extends InstanceCommand { @@ -65,10 +66,15 @@ describe('cli/instance-command', () => { let command: TestInstanceCommand; beforeEach(async () => { + isolateConfig(); config = await Config.load(); command = new TestInstanceCommand([], config); }); + afterEach(() => { + restoreConfig(); + }); + describe('requireServer', () => { it('throws error when no server', async () => { const cmd = command as MockableInstanceCommand; diff --git a/packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts b/packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts index 2bd50ed2..d6882980 100644 --- a/packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts @@ -6,6 +6,7 @@ import {expect} from 'chai'; import {Config} from '@oclif/core'; import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; // Create a test command class class TestMrtCommand extends MrtCommand { @@ -40,10 +41,15 @@ describe('cli/mrt-command', () => { let command: TestMrtCommand; beforeEach(async () => { + isolateConfig(); config = await Config.load(); command = new TestMrtCommand([], config); }); + afterEach(() => { + restoreConfig(); + }); + describe('requireMrtCredentials', () => { it('throws error when no credentials', async () => { const cmd = command as MockableMrtCommand; diff --git a/packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts b/packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts index 5d833a77..61bdcb42 100644 --- a/packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts @@ -6,6 +6,7 @@ import {expect} from 'chai'; import {Config} from '@oclif/core'; import {OAuthCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; // Create a test command class class TestOAuthCommand extends OAuthCommand { @@ -44,10 +45,15 @@ describe('cli/oauth-command', () => { let command: TestOAuthCommand; beforeEach(async () => { + isolateConfig(); config = await Config.load(); command = new TestOAuthCommand([], config); }); + afterEach(() => { + restoreConfig(); + }); + describe('parseAuthMethods', () => { it('returns undefined when no auth methods specified', async () => { const cmd = command as MockableOAuthCommand; diff --git a/packages/b2c-tooling-sdk/test/cli/ods-command.test.ts b/packages/b2c-tooling-sdk/test/cli/ods-command.test.ts index c505ecca..6d6f2e96 100644 --- a/packages/b2c-tooling-sdk/test/cli/ods-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/ods-command.test.ts @@ -6,6 +6,7 @@ import {expect} from 'chai'; import {Config} from '@oclif/core'; import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; // Create a test command class class TestOdsCommand extends OdsCommand { @@ -39,10 +40,15 @@ describe('cli/ods-command', () => { let command: TestOdsCommand; beforeEach(async () => { + isolateConfig(); config = await Config.load(); command = new TestOdsCommand([], config); }); + afterEach(() => { + restoreConfig(); + }); + describe('odsClient', () => { it('throws error when no OAuth credentials', async () => { const cmd = command as MockableOdsCommand; From 94a825dd3a0eef076d4632360e0e601aa6d6be9f Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 14 Jan 2026 18:40:03 -0500 Subject: [PATCH 05/10] timing issues in tests; don't sleep is poll is 0 --- packages/b2c-cli/src/commands/ods/create.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/b2c-cli/src/commands/ods/create.ts b/packages/b2c-cli/src/commands/ods/create.ts index a558676c..39e0d7ad 100644 --- a/packages/b2c-cli/src/commands/ods/create.ts +++ b/packages/b2c-cli/src/commands/ods/create.ts @@ -254,7 +254,10 @@ export default class OdsCreate extends OdsCommand { 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); + // Skip delay when pollInterval is 0 (test mode) + if (pollIntervalSeconds > 0) { + await this.sleep(2000); + } while (true) { // Check for timeout From 21a8bf0f85846a84cd95b7e4e229206ca7a494ae Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 14 Jan 2026 18:42:15 -0500 Subject: [PATCH 06/10] initial wait from poll interval --- packages/b2c-cli/src/commands/ods/create.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/b2c-cli/src/commands/ods/create.ts b/packages/b2c-cli/src/commands/ods/create.ts index 39e0d7ad..f7529196 100644 --- a/packages/b2c-cli/src/commands/ods/create.ts +++ b/packages/b2c-cli/src/commands/ods/create.ts @@ -254,10 +254,7 @@ export default class OdsCreate extends OdsCommand { 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 - // Skip delay when pollInterval is 0 (test mode) - if (pollIntervalSeconds > 0) { - await this.sleep(2000); - } + await this.sleep(pollIntervalMs); while (true) { // Check for timeout From 0e0b537f0ad1e18eb5bafcbcf1026ba64f74fb78 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 14 Jan 2026 19:28:52 -0500 Subject: [PATCH 07/10] Improve command test patterns with Sinon and integration tests - Add sinon, @types/sinon, @oclif/test as SDK dev dependencies - Create stubParse helper for cleaner parse method mocking - Refactor 5 command test files to use Sinon instead of manual mocking - Add test fixture (test/fixtures/test-cli/) for integration testing - Add base-command.integration.test.ts with runCommand() tests - Update testing skill docs with new patterns The stubParse helper eliminates the brittle MockableXxxCommand type casting pattern. Integration tests exercise full command lifecycle through the oclif test utilities. --- .claude/skills/testing/SKILL.md | 68 +++--- packages/b2c-tooling-sdk/package.json | 3 + .../test/cli/base-command.integration.test.ts | 59 +++++ .../test/cli/base-command.test.ts | 196 ++++------------ .../test/cli/instance-command.test.ts | 217 ++++-------------- .../test/cli/mrt-command.test.ts | 53 +---- .../test/cli/oauth-command.test.ts | 99 ++------ .../test/cli/ods-command.test.ts | 39 +--- .../test/fixtures/test-cli/package.json | 12 + .../test-cli/src/commands/test-base.js | 34 +++ .../test/helpers/stub-parse.ts | 30 +++ pnpm-lock.yaml | 66 ++++++ 12 files changed, 375 insertions(+), 501 deletions(-) create mode 100644 packages/b2c-tooling-sdk/test/cli/base-command.integration.test.ts create mode 100644 packages/b2c-tooling-sdk/test/fixtures/test-cli/package.json create mode 100644 packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-base.js create mode 100644 packages/b2c-tooling-sdk/test/helpers/stub-parse.ts diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md index 11d128a7..39035e99 100644 --- a/.claude/skills/testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -12,6 +12,7 @@ This skill covers project-specific testing patterns for the B2C CLI project. - **Test Runner**: Mocha - **Assertions**: Chai (property-based) - **HTTP Mocking**: MSW (Mock Service Worker) +- **Stubbing/Mocking**: Sinon - **Code Coverage**: c8 - **TypeScript**: tsx (native execution without compilation) @@ -120,14 +121,12 @@ const config = resolveConfig({}, { }); ``` -In CLI command tests, mock the `credentials-file` flag: +In CLI command tests, use the `stubParse` helper with the `credentials-file` flag: ```typescript -cmd.parse = (async () => ({ - args: {}, - flags: {'credentials-file': '/dev/null'}, // Isolates from real ~/.mobify - metadata: {}, -})) as typeof cmd.parse; +import { stubParse } from '../helpers/stub-parse.js'; + +stubParse(command, {'credentials-file': '/dev/null'}); // Isolates from real ~/.mobify ``` ## Polling Tests (Avoid Fake Timers) @@ -312,25 +311,26 @@ const customAuth = new MockAuthStrategy('custom-token'); Command tests should focus on **command-specific logic**, not trivial flag verification. -### Good Command Tests +### Using the stubParse Helper -Test behavior that is specific to the command class: +Use the `stubParse` helper from `test/helpers/stub-parse.js` to stub oclif's parse method. This handles the type casting needed for oclif's protected `parse` method: ```typescript -describe('requireMrtCredentials', () => { +import sinon from 'sinon'; +import { stubParse } from '../helpers/stub-parse.js'; +import { isolateConfig, restoreConfig } from '../helpers/config-isolation.js'; + +describe('cli/mrt-command', () => { + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + it('throws error when no credentials', async () => { - cmd.parse = (async () => ({ - args: {}, - flags: {'credentials-file': '/dev/null'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - let errorCalled = false; - cmd.error = () => { - errorCalled = true; - throw new Error('Expected error'); - }; + stubParse(command, {'credentials-file': '/dev/null'}); + await command.init(); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); try { command.testRequireMrtCredentials(); @@ -338,7 +338,7 @@ describe('requireMrtCredentials', () => { // Expected } - expect(errorCalled).to.be.true; + expect(errorStub.called).to.be.true; }); }); ``` @@ -350,12 +350,10 @@ Do not write tests that just verify flag values equal mocked values: ```typescript // BAD - tests nothing (just verifies JavaScript assignment works) it('handles server flag', async () => { - cmd.parse = (async () => ({ - flags: {server: 'test.demandware.net'}, - })) as typeof cmd.parse; + stubParse(command, {server: 'test.demandware.net'}); - await cmd.init(); - expect(cmd.flags.server).to.equal('test.demandware.net'); // Trivial! + await command.init(); + expect(command.flags.server).to.equal('test.demandware.net'); // Trivial! }); ``` @@ -372,6 +370,10 @@ it('handles server flag', async () => { ## Testing CLI Commands with oclif +### Integration Tests with runCommand + +Use `@oclif/test`'s `runCommand()` for integration-style tests: + ```typescript import { runCommand } from '@oclif/test'; import { expect } from 'chai'; @@ -384,6 +386,18 @@ describe('ods list', () => { }); ``` +### SDK Base Command Integration Tests + +The SDK includes a test fixture at `test/fixtures/test-cli/` for integration testing base command behavior. See `test/cli/base-command.integration.test.ts` for examples. + +### When to Use Each Approach + +| Approach | Use For | +|----------|---------| +| Unit tests with `stubParse` | Testing protected method logic in isolation | +| Integration tests with fixture | Testing full command lifecycle, flag parsing | +| `runCommand()` in b2c-cli | Testing actual CLI commands | + ## E2E Tests E2E tests run against real infrastructure and are skipped without credentials: diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 6e27727d..e151c99f 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -210,12 +210,14 @@ "@eslint/compat": "^1", "@oclif/core": "^4", "@oclif/prettier-config": "^0.2.1", + "@oclif/test": "^4.1.14", "@salesforce/dev-config": "^4.3.2", "@tony.ganchev/eslint-plugin-header": "^3.1.11", "@types/archiver": "^7.0.0", "@types/chai": "^4.3.20", "@types/mocha": "^10.0.10", "@types/node": "^18.19.130", + "@types/sinon": "^21.0.0", "@types/xml2js": "^0.4.14", "c8": "^10.1.3", "chai": "^4.5.0", @@ -227,6 +229,7 @@ "openapi-typescript": "^7.10.1", "prettier": "^3.6.2", "shx": "^0.3.3", + "sinon": "^21.0.1", "tsx": "^4.20.6", "typescript": "^5", "typescript-eslint": "^8" diff --git a/packages/b2c-tooling-sdk/test/cli/base-command.integration.test.ts b/packages/b2c-tooling-sdk/test/cli/base-command.integration.test.ts new file mode 100644 index 00000000..1146305f --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/base-command.integration.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import {runCommand} from '@oclif/test'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixtureRoot = path.join(__dirname, '../fixtures/test-cli'); + +describe('BaseCommand integration', () => { + it('runs test-base command without errors', async () => { + const {error} = await runCommand(['test-base'], {root: fixtureRoot}); + expect(error).to.be.undefined; + }); + + it('handles --extra-query flag', async () => { + const {error, result} = await runCommand<{extraParams?: Record}>( + ['test-base', '--extra-query', '{"debug":"true"}', '--json'], + {root: fixtureRoot}, + ); + + expect(error).to.be.undefined; + expect(result?.extraParams?.query).to.deep.equal({debug: 'true'}); + }); + + it('handles --extra-body flag', async () => { + const {error, result} = await runCommand<{extraParams?: Record}>( + ['test-base', '--extra-body', '{"_internal":true}', '--json'], + {root: fixtureRoot}, + ); + + expect(error).to.be.undefined; + expect(result?.extraParams?.body).to.deep.equal({_internal: true}); + }); + + it('handles both --extra-query and --extra-body flags', async () => { + const {error, result} = await runCommand<{extraParams?: Record}>( + ['test-base', '--extra-query', '{"debug":"true"}', '--extra-body', '{"_internal":true}', '--json'], + {root: fixtureRoot}, + ); + + expect(error).to.be.undefined; + expect(result?.extraParams?.query).to.deep.equal({debug: 'true'}); + expect(result?.extraParams?.body).to.deep.equal({_internal: true}); + }); + + it('returns undefined extraParams when no extra flags provided', async () => { + const {error, result} = await runCommand<{extraParams?: Record}>(['test-base', '--json'], { + root: fixtureRoot, + }); + + expect(error).to.be.undefined; + expect(result?.extraParams).to.be.undefined; + }); +}); diff --git a/packages/b2c-tooling-sdk/test/cli/base-command.test.ts b/packages/b2c-tooling-sdk/test/cli/base-command.test.ts index a789373d..a081d7f2 100644 --- a/packages/b2c-tooling-sdk/test/cli/base-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/base-command.test.ts @@ -4,9 +4,11 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {expect} from 'chai'; +import sinon from 'sinon'; import {Config} from '@oclif/core'; import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; +import {stubParse} from '../helpers/stub-parse.js'; // Create a concrete test command class class TestBaseCommand extends BaseCommand { @@ -27,28 +29,6 @@ class TestBaseCommand extends BaseCommand { } } -// Type for mocking command properties in tests -type MockableBaseCommand = TestBaseCommand & { - parse: () => Promise<{ - args: Record; - flags: Record; - metadata: Record; - }>; - flags: Record; - args: Record; - resolvedConfig: Record; - logger: { - info: ((message: string, context?: Record) => void) & - ((context: Record, message: string) => void); - warn: ((message: string, context?: Record) => void) & - ((context: Record, message: string) => void); - error: ((message: string, context?: Record) => void) & - ((context: Record, message: string) => void); - debug: ((message: string, context?: Record) => void) & - ((context: Record, message: string) => void); - }; -}; - describe('cli/base-command', () => { let config: Config; let command: TestBaseCommand; @@ -60,94 +40,53 @@ describe('cli/base-command', () => { }); afterEach(() => { + sinon.restore(); restoreConfig(); }); describe('getExtraParams', () => { it('returns undefined when no extra params', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command); + + await command.init(); const params = command.testGetExtraParams(); expect(params).to.be.undefined; - - cmd.parse = originalParse; }); it('parses extra-query flag', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'extra-query': '{"debug":"true"}'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command, {'extra-query': '{"debug":"true"}'}); + + await command.init(); const params = command.testGetExtraParams(); expect(params?.query).to.deep.equal({debug: 'true'}); - - cmd.parse = originalParse; }); it('parses extra-body flag', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'extra-body': '{"_internal":true}'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command, {'extra-body': '{"_internal":true}'}); + + await command.init(); const params = command.testGetExtraParams(); expect(params?.body).to.deep.equal({_internal: true}); - - cmd.parse = originalParse; }); it('parses both extra-query and extra-body', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: { - 'extra-query': '{"debug":"true"}', - 'extra-body': '{"_internal":true}', - }, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command, { + 'extra-query': '{"debug":"true"}', + 'extra-body': '{"_internal":true}', + }); + + await command.init(); const params = command.testGetExtraParams(); expect(params?.query).to.deep.equal({debug: 'true'}); expect(params?.body).to.deep.equal({_internal: true}); - - cmd.parse = originalParse; }); it('throws error for invalid JSON in extra-query', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'extra-query': 'invalid-json'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - let errorCalled = false; - const originalError = cmd.error.bind(command); - cmd.error = () => { - errorCalled = true; - throw new Error('Expected error'); - }; + stubParse(command, {'extra-query': 'invalid-json'}); + + await command.init(); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); try { command.testGetExtraParams(); @@ -155,28 +94,15 @@ describe('cli/base-command', () => { // Expected } - expect(errorCalled).to.be.true; - - cmd.error = originalError; - cmd.parse = originalParse; + expect(errorStub.called).to.be.true; }); it('throws error for invalid JSON in extra-body', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'extra-body': 'invalid-json'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - let errorCalled = false; - const originalError = cmd.error.bind(command); - cmd.error = () => { - errorCalled = true; - throw new Error('Expected error'); - }; + stubParse(command, {'extra-body': 'invalid-json'}); + + await command.init(); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); try { command.testGetExtraParams(); @@ -184,30 +110,17 @@ describe('cli/base-command', () => { // Expected } - expect(errorCalled).to.be.true; - - cmd.error = originalError; - cmd.parse = originalParse; + expect(errorStub.called).to.be.true; }); }); describe('catch', () => { it('handles errors with exit code', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {json: false}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - let errorCalled = false; - const originalError = cmd.error.bind(command); - cmd.error = () => { - errorCalled = true; - throw new Error('Expected error'); - }; + stubParse(command, {json: false}); + + await command.init(); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); const error = new Error('Test error') as Error & {exitCode?: number}; error.exitCode = 2; @@ -218,40 +131,24 @@ describe('cli/base-command', () => { // Expected } - expect(errorCalled).to.be.true; - - cmd.error = originalError; - cmd.parse = originalParse; + expect(errorStub.called).to.be.true; }); it('outputs JSON error in JSON mode', async () => { - const cmd = command as MockableBaseCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {json: true}, - metadata: {}, - })) as typeof cmd.parse; + stubParse(command, {json: true}); - await cmd.init(); + await command.init(); // Mock jsonEnabled to return true - const originalJsonEnabled = cmd.jsonEnabled?.bind(command); - cmd.jsonEnabled = () => true; + sinon.stub(command, 'jsonEnabled').returns(true); let writtenData = ''; - const originalWrite = process.stderr.write.bind(process.stderr); - process.stderr.write = (chunk: string | Buffer) => { + sinon.stub(process.stderr, 'write').callsFake((chunk: string | Uint8Array) => { writtenData += chunk.toString(); return true; - }; + }); - const originalExit = process.exit.bind(process); - let exitCode: number | undefined; - process.exit = (code?: number) => { - exitCode = code; - throw new Error('Exit called'); - }; + const exitStub = sinon.stub(process, 'exit').throws(new Error('Exit called')); const error = new Error('Test error'); @@ -264,14 +161,9 @@ describe('cli/base-command', () => { // In JSON mode, error should be written to stderr as JSON expect(writtenData).to.include('error'); expect(writtenData).to.include('Test error'); - expect(exitCode).to.equal(1); + expect(exitStub.calledWith(1)).to.be.true; - process.stderr.write = originalWrite; - process.exit = originalExit; - if (originalJsonEnabled) { - cmd.jsonEnabled = originalJsonEnabled; - } - cmd.parse = originalParse; + // Cleanup handled by sinon.restore() in afterEach }); }); }); diff --git a/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts b/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts index 20d7d782..4f332652 100644 --- a/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts @@ -4,10 +4,12 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {expect} from 'chai'; +import sinon from 'sinon'; import {Config} from '@oclif/core'; import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; import type {B2COperationContext, B2COperationResult, B2COperationType} from '@salesforce/b2c-tooling-sdk/cli'; import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; +import {stubParse} from '../helpers/stub-parse.js'; // Create a test command class class TestInstanceCommand extends InstanceCommand { @@ -48,19 +50,6 @@ class TestInstanceCommand extends InstanceCommand { } } -// Type for mocking command properties in tests -type MockableInstanceCommand = TestInstanceCommand & { - parse: () => Promise<{ - args: Record; - flags: Record; - metadata: Record; - }>; - flags: Record; - args: Record; - resolvedConfig: Record; - error: (message: string) => never; -}; - describe('cli/instance-command', () => { let config: Config; let command: TestInstanceCommand; @@ -72,26 +61,17 @@ describe('cli/instance-command', () => { }); afterEach(() => { + sinon.restore(); restoreConfig(); }); describe('requireServer', () => { it('throws error when no server', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - let errorCalled = false; - const originalError = cmd.error.bind(command); - cmd.error = () => { - errorCalled = true; - throw new Error('Expected error'); - }; + stubParse(command); + + await command.init(); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); try { command.testRequireServer(); @@ -99,46 +79,25 @@ describe('cli/instance-command', () => { // Expected } - expect(errorCalled).to.be.true; - - cmd.error = originalError; - cmd.parse = originalParse; + expect(errorStub.called).to.be.true; }); it('does not throw when server is set', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {server: 'test.demandware.net'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command, {server: 'test.demandware.net'}); + + await command.init(); // Should not throw command.testRequireServer(); - - cmd.parse = originalParse; }); }); describe('requireCodeVersion', () => { it('throws error when no code version', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - let errorCalled = false; - const originalError = cmd.error.bind(command); - cmd.error = () => { - errorCalled = true; - throw new Error('Expected error'); - }; + stubParse(command); + + await command.init(); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); try { command.testRequireCodeVersion(); @@ -146,46 +105,25 @@ describe('cli/instance-command', () => { // Expected } - expect(errorCalled).to.be.true; - - cmd.error = originalError; - cmd.parse = originalParse; + expect(errorStub.called).to.be.true; }); it('does not throw when code version is set', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'code-version': 'v1'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command, {'code-version': 'v1'}); + + await command.init(); // Should not throw command.testRequireCodeVersion(); - - cmd.parse = originalParse; }); }); describe('requireWebDavCredentials', () => { it('throws error when no credentials', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - let errorCalled = false; - const originalError = cmd.error.bind(command); - cmd.error = () => { - errorCalled = true; - throw new Error('Expected error'); - }; + stubParse(command); + + await command.init(); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); try { command.testRequireWebDavCredentials(); @@ -193,141 +131,82 @@ describe('cli/instance-command', () => { // Expected } - expect(errorCalled).to.be.true; - - cmd.error = originalError; - cmd.parse = originalParse; + expect(errorStub.called).to.be.true; }); it('does not throw when credentials are set', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {username: 'user', password: 'pass'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command, {username: 'user', password: 'pass'}); + + await command.init(); // Should not throw command.testRequireWebDavCredentials(); - - cmd.parse = originalParse; }); }); describe('instance', () => { it('throws error when no server', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command); + + await command.init(); try { command.testInstance(); expect.fail('Should have thrown'); } catch (error) { expect(error).to.be.an('error'); } - - cmd.parse = originalParse; }); it('creates instance when server is set', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {server: 'test.demandware.net', 'client-id': 'test-client'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command, {server: 'test.demandware.net', 'client-id': 'test-client'}); + + await command.init(); const instance = command.testInstance(); expect(instance).to.be.an('object'); - - cmd.parse = originalParse; }); it('creates instance lazily', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {server: 'test.demandware.net', 'client-id': 'test-client'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command, {server: 'test.demandware.net', 'client-id': 'test-client'}); + + await command.init(); const instance1 = command.testInstance(); const instance2 = command.testInstance(); // Should return same instance expect(instance1).to.equal(instance2); - - cmd.parse = originalParse; }); }); describe('createContext', () => { it('creates operation context', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {server: 'test.demandware.net', 'client-id': 'test-client'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command, {server: 'test.demandware.net', 'client-id': 'test-client'}); + + await command.init(); const context = command.testCreateContext('job:run', {jobId: 'test-job'}); expect(context.operationType).to.equal('job:run'); expect(context.metadata.jobId).to.equal('test-job'); expect(context.instance).to.be.an('object'); - - cmd.parse = originalParse; }); }); describe('runBeforeHooks', () => { it('returns empty result when no lifecycle runner', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {server: 'test.demandware.net', 'client-id': 'test-client'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command, {server: 'test.demandware.net', 'client-id': 'test-client'}); + + await command.init(); const context = command.testCreateContext('job:run', {}); const result = await command.testRunBeforeHooks(context); expect(result).to.deep.equal({}); - - cmd.parse = originalParse; }); }); describe('runAfterHooks', () => { it('does nothing when no lifecycle runner', async () => { - const cmd = command as MockableInstanceCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {server: 'test.demandware.net', 'client-id': 'test-client'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command, {server: 'test.demandware.net', 'client-id': 'test-client'}); + + await command.init(); const context = command.testCreateContext('job:run', {}); const result = {success: true, duration: 100}; // Should not throw await command.testRunAfterHooks(context, result); - - cmd.parse = originalParse; }); }); }); diff --git a/packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts b/packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts index d6882980..4d01b1bd 100644 --- a/packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts @@ -4,9 +4,11 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {expect} from 'chai'; +import sinon from 'sinon'; import {Config} from '@oclif/core'; import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; +import {stubParse} from '../helpers/stub-parse.js'; // Create a test command class class TestMrtCommand extends MrtCommand { @@ -23,19 +25,6 @@ class TestMrtCommand extends MrtCommand { } } -// Type for mocking command properties in tests -type MockableMrtCommand = TestMrtCommand & { - parse: () => Promise<{ - args: Record; - flags: Record; - metadata: Record; - }>; - flags: Record; - args: Record; - resolvedConfig: Record; - error?: (message: string, options?: {exit?: number}) => never; -}; - describe('cli/mrt-command', () => { let config: Config; let command: TestMrtCommand; @@ -47,26 +36,17 @@ describe('cli/mrt-command', () => { }); afterEach(() => { + sinon.restore(); restoreConfig(); }); describe('requireMrtCredentials', () => { it('throws error when no credentials', async () => { - const cmd = command as MockableMrtCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'credentials-file': '/dev/null'}, // Use non-existent credentials file - metadata: {}, - })) as typeof cmd.parse; + stubParse(command, {'credentials-file': '/dev/null'}); // Use non-existent credentials file + + await command.init(); - await cmd.init(); - let errorCalled = false; - const originalError = cmd.error?.bind(command); - cmd.error = () => { - errorCalled = true; - throw new Error('Expected error'); - }; + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); try { command.testRequireMrtCredentials(); @@ -74,28 +54,15 @@ describe('cli/mrt-command', () => { // Expected } - expect(errorCalled).to.be.true; - - if (originalError) { - cmd.error = originalError; - } - cmd.parse = originalParse; + expect(errorStub.called).to.be.true; }); it('does not throw when API key is set', async () => { - const cmd = command as MockableMrtCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'api-key': 'test-api-key'}, - metadata: {}, - })) as typeof cmd.parse; + stubParse(command, {'api-key': 'test-api-key'}); - await cmd.init(); + await command.init(); // Should not throw command.testRequireMrtCredentials(); - - cmd.parse = originalParse; }); }); }); diff --git a/packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts b/packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts index 61bdcb42..7cb8d7c3 100644 --- a/packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts @@ -4,9 +4,11 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {expect} from 'chai'; +import sinon from 'sinon'; import {Config} from '@oclif/core'; import {OAuthCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; +import {stubParse} from '../helpers/stub-parse.js'; // Create a test command class class TestOAuthCommand extends OAuthCommand { @@ -27,19 +29,6 @@ class TestOAuthCommand extends OAuthCommand { } } -// Type for mocking command properties in tests -type MockableOAuthCommand = TestOAuthCommand & { - parse: () => Promise<{ - args: Record; - flags: Record; - metadata: Record; - }>; - flags: Record; - args: Record; - resolvedConfig: Record; - error?: (message: string, options?: {exit?: number}) => never; -}; - describe('cli/oauth-command', () => { let config: Config; let command: TestOAuthCommand; @@ -51,78 +40,45 @@ describe('cli/oauth-command', () => { }); afterEach(() => { + sinon.restore(); restoreConfig(); }); describe('parseAuthMethods', () => { it('returns undefined when no auth methods specified', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command); + + await command.init(); const methods = command.testParseAuthMethods(); expect(methods).to.be.undefined; - - cmd.parse = originalParse; }); it('parses valid auth methods', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'auth-methods': ['client-credentials', 'implicit']}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command, {'auth-methods': ['client-credentials', 'implicit']}); + + await command.init(); const methods = command.testParseAuthMethods(); expect(methods).to.include('client-credentials'); expect(methods).to.include('implicit'); - - cmd.parse = originalParse; }); it('filters out invalid auth methods', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'auth-methods': ['client-credentials', 'invalid', 'basic']}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command, {'auth-methods': ['client-credentials', 'invalid', 'basic']}); + + await command.init(); const methods = command.testParseAuthMethods(); expect(methods).to.include('client-credentials'); expect(methods).to.not.include('invalid'); - - cmd.parse = originalParse; }); }); describe('requireOAuthCredentials', () => { it('throws error when no credentials', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); - let errorCalled = false; - const originalError = cmd.error?.bind(command); - cmd.error = () => { - errorCalled = true; - throw new Error('Expected error'); - }; + stubParse(command); + + await command.init(); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); try { command.testRequireOAuthCredentials(); @@ -130,28 +86,15 @@ describe('cli/oauth-command', () => { // Expected } - expect(errorCalled).to.be.true; - - if (originalError) { - cmd.error = originalError; - } - cmd.parse = originalParse; + expect(errorStub.called).to.be.true; }); it('does not throw when clientId is set', async () => { - const cmd = command as MockableOAuthCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'client-id': 'test-client'}, - metadata: {}, - })) as typeof cmd.parse; - - await cmd.init(); + stubParse(command, {'client-id': 'test-client'}); + + await command.init(); // Should not throw command.testRequireOAuthCredentials(); - - cmd.parse = originalParse; }); }); }); diff --git a/packages/b2c-tooling-sdk/test/cli/ods-command.test.ts b/packages/b2c-tooling-sdk/test/cli/ods-command.test.ts index 6d6f2e96..c258cdee 100644 --- a/packages/b2c-tooling-sdk/test/cli/ods-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/ods-command.test.ts @@ -4,9 +4,11 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {expect} from 'chai'; +import sinon from 'sinon'; import {Config} from '@oclif/core'; import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; +import {stubParse} from '../helpers/stub-parse.js'; // Create a test command class class TestOdsCommand extends OdsCommand { @@ -23,18 +25,6 @@ class TestOdsCommand extends OdsCommand { } } -// Type for mocking command properties in tests -type MockableOdsCommand = TestOdsCommand & { - parse: () => Promise<{ - args: Record; - flags: Record; - metadata: Record; - }>; - flags: Record; - args: Record; - resolvedConfig: Record; -}; - describe('cli/ods-command', () => { let config: Config; let command: TestOdsCommand; @@ -46,20 +36,15 @@ describe('cli/ods-command', () => { }); afterEach(() => { + sinon.restore(); restoreConfig(); }); describe('odsClient', () => { it('throws error when no OAuth credentials', async () => { - const cmd = command as MockableOdsCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {}, - metadata: {}, - })) as typeof cmd.parse; + stubParse(command); - await cmd.init(); + await command.init(); try { // Accessing odsClient getter will try to create client command.testOdsClient(); @@ -67,26 +52,16 @@ describe('cli/ods-command', () => { } catch (error) { expect(error).to.be.an('error'); } - - cmd.parse = originalParse; }); it('creates ODS client lazily', async () => { - const cmd = command as MockableOdsCommand; - const originalParse = cmd.parse.bind(command); - cmd.parse = (async () => ({ - args: {}, - flags: {'client-id': 'test-client', 'client-secret': 'test-secret'}, - metadata: {}, - })) as typeof cmd.parse; + stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); - await cmd.init(); + await command.init(); const client1 = command.testOdsClient(); const client2 = command.testOdsClient(); // Should return same instance expect(client1).to.equal(client2); - - cmd.parse = originalParse; }); }); }); diff --git a/packages/b2c-tooling-sdk/test/fixtures/test-cli/package.json b/packages/b2c-tooling-sdk/test/fixtures/test-cli/package.json new file mode 100644 index 00000000..43e6d94b --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/test-cli/package.json @@ -0,0 +1,12 @@ +{ + "name": "test-cli", + "version": "0.0.0", + "private": true, + "type": "module", + "oclif": { + "commands": "./src/commands" + }, + "exports": { + "./package.json": "./package.json" + } +} diff --git a/packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-base.js b/packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-base.js new file mode 100644 index 00000000..4375c533 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-base.js @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +// Use relative import since this fixture is inside the SDK's test folder +import {BaseCommand} from '../../../../../src/cli/index.js'; + +/** + * Test command that extends BaseCommand for integration testing. + * Exercises base command features like extra params parsing. + */ +export default class TestBase extends BaseCommand { + static id = 'test-base'; + static description = 'Test command for BaseCommand integration testing'; + static enableJsonFlag = true; + + async run() { + const extraParams = this.getExtraParams(); + + if (this.jsonEnabled()) { + return {extraParams}; + } + + if (extraParams) { + this.log('Extra query params: ' + JSON.stringify(extraParams.query)); + this.log('Extra body params: ' + JSON.stringify(extraParams.body)); + } else { + this.log('No extra params'); + } + + return {extraParams}; + } +} diff --git a/packages/b2c-tooling-sdk/test/helpers/stub-parse.ts b/packages/b2c-tooling-sdk/test/helpers/stub-parse.ts new file mode 100644 index 00000000..2fbe3af0 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/helpers/stub-parse.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import sinon, {type SinonStub} from 'sinon'; + +/** + * Helper to stub the parse method on oclif commands. + * The parse method is protected in oclif's Command class, so we need to cast. + * + * @param command - The command instance to stub + * @param flags - The flags to return from parse + * @param args - The args to return from parse + * @returns The sinon stub for the parse method + */ +export function stubParse( + command: unknown, + flags: Record = {}, + args: Record = {}, +): SinonStub { + return sinon.stub(command as {parse: unknown}, 'parse').resolves({ + args, + flags, + metadata: {}, + argv: [], + raw: [], + nonExistentFlags: {}, + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80477e3c..839fd5bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -297,6 +297,9 @@ importers: '@oclif/prettier-config': specifier: ^0.2.1 version: 0.2.1 + '@oclif/test': + specifier: ^4.1.14 + version: 4.1.14(@oclif/core@4.8.0) '@salesforce/dev-config': specifier: ^4.3.2 version: 4.3.2 @@ -315,6 +318,9 @@ importers: '@types/node': specifier: ^18.19.130 version: 18.19.130 + '@types/sinon': + specifier: ^21.0.0 + version: 21.0.0 '@types/xml2js': specifier: ^0.4.14 version: 0.4.14 @@ -348,6 +354,9 @@ importers: shx: specifier: ^0.3.3 version: 0.3.4 + sinon: + specifier: ^21.0.1 + version: 21.0.1 tsx: specifier: ^4.20.6 version: 4.20.6 @@ -1986,6 +1995,15 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@15.1.0': + resolution: {integrity: sha512-cqfapCxwTGsrR80FEgOoPsTonoefMBY7dnUEbQ+GRcved0jvkJLzvX6F4WtN+HBqbPX/SiFsIRUp+IrCW/2I2w==} + + '@sinonjs/samsam@8.0.3': + resolution: {integrity: sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==} + '@smithy/abort-controller@4.2.5': resolution: {integrity: sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==} engines: {node: '>=18.0.0'} @@ -2293,6 +2311,12 @@ packages: '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + '@types/sinon@21.0.0': + resolution: {integrity: sha512-+oHKZ0lTI+WVLxx1IbJDNmReQaIsQJjN2e7UUrJHEeByG7bFeKJYsv1E75JxTQ9QKJDp21bAa/0W2Xo4srsDnw==} + + '@types/sinonjs__fake-timers@15.0.1': + resolution: {integrity: sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -3161,6 +3185,10 @@ packages: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -5258,6 +5286,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sinon@21.0.1: + resolution: {integrity: sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==} + skin-tone@2.0.0: resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} engines: {node: '>=8'} @@ -5527,6 +5558,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + type-detect@4.1.0: resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} engines: {node: '>=4'} @@ -7967,6 +8002,19 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@15.1.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@sinonjs/samsam@8.0.3': + dependencies: + '@sinonjs/commons': 3.0.1 + type-detect: 4.1.0 + '@smithy/abort-controller@4.2.5': dependencies: '@smithy/types': 4.9.0 @@ -8401,6 +8449,12 @@ snapshots: dependencies: '@types/node': 18.19.130 + '@types/sinon@21.0.0': + dependencies: + '@types/sinonjs__fake-timers': 15.0.1 + + '@types/sinonjs__fake-timers@15.0.1': {} + '@types/statuses@2.0.6': {} '@types/unist@3.0.3': {} @@ -9307,6 +9361,8 @@ snapshots: diff@5.2.0: {} + diff@8.0.3: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -11672,6 +11728,14 @@ snapshots: signal-exit@4.1.0: {} + sinon@21.0.1: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 15.1.0 + '@sinonjs/samsam': 8.0.3 + diff: 8.0.3 + supports-color: 7.2.0 + skin-tone@2.0.0: dependencies: unicode-emoji-modifier-base: 1.0.0 @@ -11972,6 +12036,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-detect@4.0.8: {} + type-detect@4.1.0: {} type-fest@0.20.2: {} From 09e3f9da0820b6b260606fec2d0463d17664bc7d Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 14 Jan 2026 19:43:14 -0500 Subject: [PATCH 08/10] Restore CartridgeCommand unit tests with improved coverage - Add cartridge-command.test.ts with tests for cartridgePath, cartridgeOptions, provider runner init, and findCartridgesWithProviders - Use stubParse helper with server mock for instance-dependent tests - Improves cartridge-command.ts coverage from 19% to 91% --- .../test/cli/cartridge-command.test.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 packages/b2c-tooling-sdk/test/cli/cartridge-command.test.ts diff --git a/packages/b2c-tooling-sdk/test/cli/cartridge-command.test.ts b/packages/b2c-tooling-sdk/test/cli/cartridge-command.test.ts new file mode 100644 index 00000000..d1f91952 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/cartridge-command.test.ts @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import {CartridgeCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {stubParse} from '../helpers/stub-parse.js'; +import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; + +class TestCartridgeCommand extends CartridgeCommand { + static id = 'test:cartridge'; + async run(): Promise {} + + // Expose protected for testing + public get testCartridgePath() { + return this.cartridgePath; + } + public get testCartridgeOptions() { + return this.cartridgeOptions; + } + public get testCartridgeProviderRunner() { + return this.cartridgeProviderRunner; + } + public testFindCartridgesWithProviders(dir?: string) { + return this.findCartridgesWithProviders(dir); + } +} + +describe('cli/cartridge-command', () => { + let config: Config; + let command: TestCartridgeCommand; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + command = new TestCartridgeCommand([], config); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + describe('cartridgePath', () => { + it('returns cartridgePath from args', async () => { + stubParse(command, {}, {cartridgePath: '/path/to/cartridges'}); + await command.init(); + expect(command.testCartridgePath).to.equal('/path/to/cartridges'); + }); + + it('defaults to current directory', async () => { + stubParse(command, {}, {cartridgePath: '.'}); + await command.init(); + expect(command.testCartridgePath).to.equal('.'); + }); + }); + + describe('cartridgeOptions', () => { + it('returns include/exclude from flags', async () => { + stubParse( + command, + { + cartridge: ['app_storefront', 'app_custom'], + 'exclude-cartridge': ['bm_extensions'], + }, + {cartridgePath: '.'}, + ); + await command.init(); + + const options = command.testCartridgeOptions; + expect(options.include).to.deep.equal(['app_storefront', 'app_custom']); + expect(options.exclude).to.deep.equal(['bm_extensions']); + }); + + it('returns undefined for unset include/exclude', async () => { + stubParse(command, {}, {cartridgePath: '.'}); + await command.init(); + + const options = command.testCartridgeOptions; + expect(options.include).to.be.undefined; + expect(options.exclude).to.be.undefined; + }); + }); + + describe('collectCartridgeProviders', () => { + it('creates CartridgeProviderRunner during init', async () => { + stubParse(command, {}, {cartridgePath: '.'}); + await command.init(); + // Runner is created even with no plugins + expect(command.testCartridgeProviderRunner).to.exist; + }); + }); + + describe('findCartridgesWithProviders', () => { + it('returns empty array when no cartridges found', async () => { + stubParse(command, {server: 'test.demandware.net'}, {cartridgePath: '.'}); + await command.init(); + + // With no .project files in cwd, returns empty array + const cartridges = await command.testFindCartridgesWithProviders(); + expect(cartridges).to.be.an('array'); + }); + + it('uses custom directory when provided', async () => { + stubParse(command, {server: 'test.demandware.net'}, {cartridgePath: '/default/path'}); + await command.init(); + + // Should not throw when using a custom directory + const cartridges = await command.testFindCartridgesWithProviders('/tmp'); + expect(cartridges).to.be.an('array'); + }); + }); +}); From 5773981f9b46d69c7c2ec3fbc9b449de07c9cdae Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 14 Jan 2026 20:17:11 -0500 Subject: [PATCH 09/10] Add InstanceCommand and MrtCommand integration tests Integration tests provide API contract validation beyond code coverage: - Catch flag definition errors (wrong type, missing env var) - Validate baseFlags inheritance works correctly - Exercise full oclif command lifecycle (discovery, parse, init, run) - Test commands the way consumers actually use them New fixtures: - test-instance.js: Tests server, instance flags and hasServer check - test-mrt.js: Tests api-key, project, environment, cloud-origin flags New integration tests: - instance-command.integration.test.ts (5 tests) - mrt-command.integration.test.ts (8 tests) Total integration tests: 18 (BaseCommand + InstanceCommand + MrtCommand) --- .../cli/instance-command.integration.test.ts | 66 ++++++++++++ .../test/cli/mrt-command.integration.test.ts | 101 ++++++++++++++++++ .../test-cli/src/commands/test-instance.js | 42 ++++++++ .../test-cli/src/commands/test-mrt.js | 40 +++++++ 4 files changed, 249 insertions(+) create mode 100644 packages/b2c-tooling-sdk/test/cli/instance-command.integration.test.ts create mode 100644 packages/b2c-tooling-sdk/test/cli/mrt-command.integration.test.ts create mode 100644 packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-instance.js create mode 100644 packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-mrt.js diff --git a/packages/b2c-tooling-sdk/test/cli/instance-command.integration.test.ts b/packages/b2c-tooling-sdk/test/cli/instance-command.integration.test.ts new file mode 100644 index 00000000..f6a8277d --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/instance-command.integration.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import {runCommand} from '@oclif/test'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixtureRoot = path.join(__dirname, '../fixtures/test-cli'); + +interface TestInstanceResult { + server?: string; + hasServer: boolean; + instance?: string; +} + +describe('InstanceCommand integration', () => { + it('runs test-instance command without errors', async () => { + const {error} = await runCommand(['test-instance'], {root: fixtureRoot}); + expect(error).to.be.undefined; + }); + + it('handles --server flag', async () => { + const {error, result} = await runCommand( + ['test-instance', '--server', 'test.demandware.net', '--json'], + {root: fixtureRoot}, + ); + + expect(error).to.be.undefined; + expect(result?.server).to.equal('test.demandware.net'); + expect(result?.hasServer).to.be.true; + }); + + it('reports hasServer false when no server provided', async () => { + const {error, result} = await runCommand(['test-instance', '--json'], {root: fixtureRoot}); + + expect(error).to.be.undefined; + expect(result?.hasServer).to.be.false; + expect(result?.server).to.be.undefined; + }); + + it('creates instance when server is provided', async () => { + const {error, result} = await runCommand( + ['test-instance', '--server', 'test.demandware.net', '--json'], + {root: fixtureRoot}, + ); + + expect(error).to.be.undefined; + expect(result?.instance).to.equal('test.demandware.net'); + }); + + it('handles --instance flag for config selection', async () => { + // The --instance flag is for selecting a named instance from dw.json + // Without a dw.json, it just sets the flag value + const {error, result} = await runCommand(['test-instance', '--instance', 'staging', '--json'], { + root: fixtureRoot, + }); + + expect(error).to.be.undefined; + // Instance flag is for config selection, not server name + expect(result?.hasServer).to.be.false; + }); +}); diff --git a/packages/b2c-tooling-sdk/test/cli/mrt-command.integration.test.ts b/packages/b2c-tooling-sdk/test/cli/mrt-command.integration.test.ts new file mode 100644 index 00000000..053c6e48 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/mrt-command.integration.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import {runCommand} from '@oclif/test'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixtureRoot = path.join(__dirname, '../fixtures/test-cli'); + +interface TestMrtResult { + hasApiKey: boolean; + project?: string; + environment?: string; + cloudOrigin?: string; + credentialsFile?: string; + hasMrtCredentials: boolean; +} + +describe('MrtCommand integration', () => { + it('runs test-mrt command without errors', async () => { + const {error} = await runCommand(['test-mrt'], {root: fixtureRoot}); + expect(error).to.be.undefined; + }); + + it('handles --api-key flag', async () => { + const {error, result} = await runCommand(['test-mrt', '--api-key', 'test-api-key-123', '--json'], { + root: fixtureRoot, + }); + + expect(error).to.be.undefined; + expect(result?.hasApiKey).to.be.true; + expect(result?.hasMrtCredentials).to.be.true; + }); + + it('handles --project flag', async () => { + const {error, result} = await runCommand(['test-mrt', '--project', 'my-project', '--json'], { + root: fixtureRoot, + }); + + expect(error).to.be.undefined; + expect(result?.project).to.equal('my-project'); + }); + + it('handles --environment flag', async () => { + const {error, result} = await runCommand(['test-mrt', '--environment', 'staging', '--json'], { + root: fixtureRoot, + }); + + expect(error).to.be.undefined; + expect(result?.environment).to.equal('staging'); + }); + + it('handles --cloud-origin flag', async () => { + const {error, result} = await runCommand( + ['test-mrt', '--cloud-origin', 'https://custom.mobify.com', '--json'], + {root: fixtureRoot}, + ); + + expect(error).to.be.undefined; + expect(result?.cloudOrigin).to.equal('https://custom.mobify.com'); + }); + + it('handles --credentials-file flag', async () => { + const {error, result} = await runCommand( + ['test-mrt', '--credentials-file', '/custom/path/.mobify', '--json'], + {root: fixtureRoot}, + ); + + expect(error).to.be.undefined; + expect(result?.credentialsFile).to.equal('/custom/path/.mobify'); + }); + + it('reports hasMrtCredentials false when no api-key provided', async () => { + // Use --credentials-file to isolate from developer's ~/.mobify + const {error, result} = await runCommand( + ['test-mrt', '--credentials-file', '/dev/null', '--json'], + {root: fixtureRoot}, + ); + + expect(error).to.be.undefined; + expect(result?.hasApiKey).to.be.false; + expect(result?.hasMrtCredentials).to.be.false; + }); + + it('handles multiple flags together', async () => { + const {error, result} = await runCommand( + ['test-mrt', '--api-key', 'key123', '--project', 'proj', '--environment', 'prod', '--json'], + {root: fixtureRoot}, + ); + + expect(error).to.be.undefined; + expect(result?.hasApiKey).to.be.true; + expect(result?.project).to.equal('proj'); + expect(result?.environment).to.equal('prod'); + expect(result?.hasMrtCredentials).to.be.true; + }); +}); diff --git a/packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-instance.js b/packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-instance.js new file mode 100644 index 00000000..9225ae41 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-instance.js @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +// Use relative import since this fixture is inside the SDK's test folder +import {InstanceCommand} from '../../../../../src/cli/index.js'; + +/** + * Test command that extends InstanceCommand for integration testing. + * Exercises instance command features like server flag and instance getter. + */ +export default class TestInstance extends InstanceCommand { + static id = 'test-instance'; + static description = 'Test command for InstanceCommand integration testing'; + static enableJsonFlag = true; + + async run() { + // Check server via resolvedConfig (no hasServer method on InstanceCommand) + const hasServer = Boolean(this.resolvedConfig.hostname); + + // Return server/instance info without requiring server (for testing flags work) + const result = { + server: this.resolvedConfig.hostname, + hasServer, + }; + + // Only access instance if server is provided (avoids requireServer error) + if (hasServer) { + result.instance = this.instance.config.hostname; + } + + if (this.jsonEnabled()) { + return result; + } + + this.log('Server: ' + (result.server || 'not set')); + this.log('Has server: ' + result.hasServer); + + return result; + } +} diff --git a/packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-mrt.js b/packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-mrt.js new file mode 100644 index 00000000..a4ee93e1 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/test-cli/src/commands/test-mrt.js @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +// Use relative import since this fixture is inside the SDK's test folder +import {MrtCommand} from '../../../../../src/cli/index.js'; + +/** + * Test command that extends MrtCommand for integration testing. + * Exercises MRT command features like api-key, project, and environment flags. + */ +export default class TestMrt extends MrtCommand { + static id = 'test-mrt'; + static description = 'Test command for MrtCommand integration testing'; + static enableJsonFlag = true; + + async run() { + const result = { + // Mask API key for security (just show if present) + hasApiKey: Boolean(this.flags['api-key']), + project: this.flags.project, + environment: this.flags.environment, + cloudOrigin: this.flags['cloud-origin'], + credentialsFile: this.flags['credentials-file'], + hasMrtCredentials: this.hasMrtCredentials(), + }; + + if (this.jsonEnabled()) { + return result; + } + + this.log('Has API key: ' + result.hasApiKey); + this.log('Project: ' + (result.project || 'not set')); + this.log('Environment: ' + (result.environment || 'not set')); + this.log('Has MRT credentials: ' + result.hasMrtCredentials); + + return result; + } +} From c7057b124ce217653daa18b56fdc632b2b43ff1e Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 14 Jan 2026 20:28:27 -0500 Subject: [PATCH 10/10] prettier updates --- packages/b2c-tooling-sdk/.c8rc.json | 7 +- packages/b2c-tooling-sdk/.prettierignore | 4 ++ packages/b2c-tooling-sdk/README.md | 64 +++++++++---------- packages/b2c-tooling-sdk/package.json | 4 +- .../test/cli/mrt-command.integration.test.ts | 7 +- 5 files changed, 42 insertions(+), 44 deletions(-) diff --git a/packages/b2c-tooling-sdk/.c8rc.json b/packages/b2c-tooling-sdk/.c8rc.json index 96aaa560..1dd6b074 100644 --- a/packages/b2c-tooling-sdk/.c8rc.json +++ b/packages/b2c-tooling-sdk/.c8rc.json @@ -1,12 +1,7 @@ { "all": true, "src": ["src"], - "exclude": [ - "src/clients/*.generated.ts", - "test/**", - "**/*.d.ts", - "src/**/*types.ts" - ], + "exclude": ["src/clients/*.generated.ts", "test/**", "**/*.d.ts", "src/**/*types.ts"], "reporter": ["text", "text-summary", "html", "lcov"], "report-dir": "coverage", "check-coverage": true, diff --git a/packages/b2c-tooling-sdk/.prettierignore b/packages/b2c-tooling-sdk/.prettierignore index c61f4a4a..ff73a284 100644 --- a/packages/b2c-tooling-sdk/.prettierignore +++ b/packages/b2c-tooling-sdk/.prettierignore @@ -1 +1,5 @@ *.generated.ts +dist +coverage +data +specs diff --git a/packages/b2c-tooling-sdk/README.md b/packages/b2c-tooling-sdk/README.md index 53719e41..53509ae9 100644 --- a/packages/b2c-tooling-sdk/README.md +++ b/packages/b2c-tooling-sdk/README.md @@ -20,7 +20,7 @@ npm install @salesforce/b2c-tooling-sdk Use `resolveConfig()` to load configuration from project files (dw.json) and create a B2C instance: ```typescript -import { resolveConfig } from '@salesforce/b2c-tooling-sdk/config'; +import {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; // Load configuration, override secrets from environment const config = resolveConfig({ @@ -36,8 +36,8 @@ await instance.webdav.mkcol('Cartridges/v1'); await instance.webdav.put('Cartridges/v1/app.zip', zipBuffer); // Use typed OCAPI client (openapi-fetch) -const { data, error } = await instance.ocapi.GET('/sites', { - params: { query: { select: '(**)' } }, +const {data, error} = await instance.ocapi.GET('/sites', { + params: {query: {select: '(**)'}}, }); ``` @@ -46,16 +46,16 @@ const { data, error } = await instance.ocapi.GET('/sites', { For advanced use cases, you can construct a B2CInstance directly: ```typescript -import { B2CInstance } from '@salesforce/b2c-tooling-sdk'; +import {B2CInstance} from '@salesforce/b2c-tooling-sdk'; const instance = new B2CInstance( - { hostname: 'your-sandbox.demandware.net', codeVersion: 'v1' }, + {hostname: 'your-sandbox.demandware.net', codeVersion: 'v1'}, { oauth: { clientId: 'your-client-id', - clientSecret: 'your-client-secret' - } - } + clientSecret: 'your-client-secret', + }, + }, ); ``` @@ -89,24 +89,24 @@ The OCAPI client uses [openapi-fetch](https://openapi-ts.dev/openapi-fetch/) wit ```typescript // List sites -const { data, error } = await instance.ocapi.GET('/sites', { - params: { query: { select: '(**)' } }, +const {data, error} = await instance.ocapi.GET('/sites', { + params: {query: {select: '(**)'}}, }); // Activate a code version -const { data, error } = await instance.ocapi.PATCH('/code_versions/{code_version_id}', { - params: { path: { code_version_id: 'v1' } }, - body: { active: true }, +const {data, error} = await instance.ocapi.PATCH('/code_versions/{code_version_id}', { + params: {path: {code_version_id: 'v1'}}, + body: {active: true}, }); ``` ### Code Deployment ```typescript -import { findAndDeployCartridges, activateCodeVersion } from '@salesforce/b2c-tooling-sdk/operations/code'; +import {findAndDeployCartridges, activateCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; // Deploy cartridges -await findAndDeployCartridges(instance, './cartridges', { reload: true }); +await findAndDeployCartridges(instance, './cartridges', {reload: true}); // Activate code version await activateCodeVersion(instance, 'v1'); @@ -115,7 +115,7 @@ await activateCodeVersion(instance, 'v1'); ### Job Execution ```typescript -import { executeJob, waitForJob, siteArchiveImport } from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {executeJob, waitForJob, siteArchiveImport} from '@salesforce/b2c-tooling-sdk/operations/jobs'; // Run a job and wait for completion const execution = await executeJob(instance, 'my-job-id'); @@ -129,32 +129,32 @@ await siteArchiveImport(instance, './site-data.zip'); The SDK provides subpath exports for tree-shaking and organization: -| Export | Description | -|--------|-------------| -| `@salesforce/b2c-tooling-sdk` | Main entry point with all exports | -| `@salesforce/b2c-tooling-sdk/config` | Configuration resolution (resolveConfig) | -| `@salesforce/b2c-tooling-sdk/auth` | Authentication strategies (OAuth, Basic, API Key) | -| `@salesforce/b2c-tooling-sdk/instance` | B2CInstance class | -| `@salesforce/b2c-tooling-sdk/clients` | Low-level API clients (WebDAV, OCAPI, SLAS, ODS, MRT) | -| `@salesforce/b2c-tooling-sdk/operations/code` | Code deployment operations | -| `@salesforce/b2c-tooling-sdk/operations/jobs` | Job execution and site import/export | -| `@salesforce/b2c-tooling-sdk/operations/sites` | Site management | -| `@salesforce/b2c-tooling-sdk/discovery` | Workspace type detection (PWA Kit, SFRA, etc.) | -| `@salesforce/b2c-tooling-sdk/cli` | CLI utilities (BaseCommand, table rendering) | -| `@salesforce/b2c-tooling-sdk/logging` | Structured logging utilities | +| Export | Description | +| ---------------------------------------------- | ----------------------------------------------------- | +| `@salesforce/b2c-tooling-sdk` | Main entry point with all exports | +| `@salesforce/b2c-tooling-sdk/config` | Configuration resolution (resolveConfig) | +| `@salesforce/b2c-tooling-sdk/auth` | Authentication strategies (OAuth, Basic, API Key) | +| `@salesforce/b2c-tooling-sdk/instance` | B2CInstance class | +| `@salesforce/b2c-tooling-sdk/clients` | Low-level API clients (WebDAV, OCAPI, SLAS, ODS, MRT) | +| `@salesforce/b2c-tooling-sdk/operations/code` | Code deployment operations | +| `@salesforce/b2c-tooling-sdk/operations/jobs` | Job execution and site import/export | +| `@salesforce/b2c-tooling-sdk/operations/sites` | Site management | +| `@salesforce/b2c-tooling-sdk/discovery` | Workspace type detection (PWA Kit, SFRA, etc.) | +| `@salesforce/b2c-tooling-sdk/cli` | CLI utilities (BaseCommand, table rendering) | +| `@salesforce/b2c-tooling-sdk/logging` | Structured logging utilities | ## Logging Configure logging for debugging HTTP requests: ```typescript -import { configureLogger } from '@salesforce/b2c-tooling-sdk/logging'; +import {configureLogger} from '@salesforce/b2c-tooling-sdk/logging'; // Enable debug logging (shows HTTP request summaries) -configureLogger({ level: 'debug' }); +configureLogger({level: 'debug'}); // Enable trace logging (shows full request/response with headers and bodies) -configureLogger({ level: 'trace' }); +configureLogger({level: 'trace'}); ``` ## Documentation diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index e151c99f..4a171bb4 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -195,8 +195,8 @@ "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", "clean": "shx rm -rf dist", "lint": "eslint", - "format": "prettier --write src scripts", - "format:check": "prettier --check src scripts", + "format": "prettier --write .", + "format:check": "prettier --check .", "pretest": "tsc --noEmit -p test", "test": "c8 mocha --forbid-only \"test/**/*.test.ts\"", "test:ci": "c8 mocha --forbid-only --reporter json --reporter-option output=test-results.json \"test/**/*.test.ts\"", diff --git a/packages/b2c-tooling-sdk/test/cli/mrt-command.integration.test.ts b/packages/b2c-tooling-sdk/test/cli/mrt-command.integration.test.ts index 053c6e48..84e1c922 100644 --- a/packages/b2c-tooling-sdk/test/cli/mrt-command.integration.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/mrt-command.integration.test.ts @@ -76,10 +76,9 @@ describe('MrtCommand integration', () => { it('reports hasMrtCredentials false when no api-key provided', async () => { // Use --credentials-file to isolate from developer's ~/.mobify - const {error, result} = await runCommand( - ['test-mrt', '--credentials-file', '/dev/null', '--json'], - {root: fixtureRoot}, - ); + const {error, result} = await runCommand(['test-mrt', '--credentials-file', '/dev/null', '--json'], { + root: fixtureRoot, + }); expect(error).to.be.undefined; expect(result?.hasApiKey).to.be.false;