diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md index 39035e99..0e552397 100644 --- a/.claude/skills/testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -18,6 +18,19 @@ This skill covers project-specific testing patterns for the B2C CLI project. ## Running Tests +For coding agents (minimal output - only failures shown): + +```bash +# Run tests - only failures + summary +pnpm run test:agent + +# Run tests for specific package +pnpm --filter @salesforce/b2c-tooling-sdk run test:agent +pnpm --filter @salesforce/b2c-cli run test:agent +``` + +For debugging (full output with coverage): + ```bash # Run all tests with coverage pnpm run test @@ -307,6 +320,59 @@ const client = new WebDavClient(TEST_HOST, mockAuth); const customAuth = new MockAuthStrategy('custom-token'); ``` +## Silencing Test Output + +Commands may produce console output (tables, formatted displays) even in tests. Use these helpers to keep test output clean. + +### Using runSilent for Output Capture + +The `runSilent` helper uses oclif's `captureOutput` to suppress stdout/stderr: + +```typescript +import { runSilent } from '../../helpers/test-setup.js'; + +it('returns data in non-JSON mode', async () => { + const command = new MyCommand([], {} as any); + // ... setup ... + + // Silences any console output from the command + const result = await runSilent(() => command.run()); + + expect(result.data).to.exist; +}); +``` + +Use `runSilent` when: +- Testing non-JSON output modes (tables, formatted displays) +- The test doesn't need to verify console output content +- You want clean test output with only pass/fail summary + +### When Output Verification is Needed + +If you need to verify console output, stub `ux.stdout` directly: + +```typescript +import { ux } from '@oclif/core'; + +it('prints table in non-JSON mode', async () => { + const stdoutStub = sinon.stub(ux, 'stdout'); + + await command.run(); + + expect(stdoutStub.called).to.be.true; +}); +``` + +### stubParse Sets Silent Logging + +The `stubParse` helper automatically sets `'log-level': 'silent'` to reduce pino logger output: + +```typescript +// stubParse includes silent log level by default +stubParse(command, {server: 'test.demandware.net'}); +// Equivalent to: {server: 'test.demandware.net', 'log-level': 'silent'} +``` + ## Command Test Guidelines Command tests should focus on **command-specific logic**, not trivial flag verification. @@ -445,6 +511,33 @@ pnpm run test open coverage/index.html ``` +## Test Helpers Reference + +### CLI Package (`packages/b2c-cli/test/helpers/`) + +| Helper | Purpose | +|--------|---------| +| `runSilent(fn)` | Capture and suppress stdout/stderr from command execution | +| `stubParse(command, flags, args)` | Stub oclif's parse method with flags (includes silent log level) | +| `createTestCommand(CommandClass, config, flags, args)` | Create command instance with stubbed parse | +| `createIsolatedConfigHooks()` | Mocha hooks for config isolation | +| `createIsolatedEnvHooks()` | Mocha hooks for env var isolation | + +### SDK Package (`packages/b2c-tooling-sdk/test/helpers/`) + +| Helper | Purpose | +|--------|---------| +| `MockAuthStrategy` | Mock authentication for API clients | +| `stubParse(command, flags, args)` | Stub oclif's parse method (includes silent log level) | +| `createNullStream()` | Create a writable stream that discards output | +| `CapturingStream` | Writable stream that captures output for assertions | + +### SDK Test Utils (exported from package) + +```typescript +import { isolateConfig, restoreConfig } from '@salesforce/b2c-tooling-sdk/test-utils'; +``` + ## Writing Tests Checklist 1. Create test file in `test/` mirroring source structure @@ -452,8 +545,9 @@ open coverage/index.html 3. Import from package names, not relative paths 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` +6. Use `runSilent()` for commands that produce console output +7. Use `pollInterval` option for polling operations +8. Use MockAuthStrategy for authenticated clients +9. Test both success and error paths +10. Focus on command-specific logic, not trivial delegation +11. Run tests: `pnpm --filter run test` diff --git a/AGENTS.md b/AGENTS.md index 3aec1d9c..2d44efb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,13 +18,41 @@ pnpm run build pnpm --filter @salesforce/b2c-cli run build pnpm --filter @salesforce/b2c-tooling-sdk run build -# Run tests (includes linting) -pnpm run test - # Dev mode for CLI (uses source files directly) pnpm --filter @salesforce/b2c-cli run dev # or using convenience script ./cli +``` + +## Commands for Coding Agents + +These commands produce condensed output optimized for AI coding agents: + +```bash +# Run tests (minimal output - only failures + summary) +pnpm run test:agent + +# Run tests for specific package +pnpm --filter @salesforce/b2c-cli run test:agent +pnpm --filter @salesforce/b2c-tooling-sdk run test:agent + +# Lint (errors only, no warnings) +pnpm run lint:agent + +# Type-check (single-line errors, no color) +pnpm run typecheck:agent + +# Format check (lists only files needing formatting) +pnpm run -r format:check +``` + +## Verbose Commands (Debugging/CI) + +Use these for detailed output during debugging or in CI pipelines: + +```bash +# Run tests with full output and coverage +pnpm run test # Run tests for specific package pnpm --filter @salesforce/b2c-cli run test @@ -33,7 +61,7 @@ pnpm --filter @salesforce/b2c-tooling-sdk run test # Format code with prettier pnpm run -r format -# Lint only (without tests) +# Lint with full output pnpm run -r lint ``` diff --git a/package.json b/package.json index 8b3c7675..91fb8156 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,12 @@ "start": "pnpm --filter @salesforce/b2c-cli run dev", "test": "pnpm -r test", "test:unit": "pnpm -r run test:unit", + "test:agent": "pnpm -r run test:agent", "coverage": "pnpm -r run coverage", "format": "pnpm -r run format", "lint": "pnpm -r run lint", + "lint:agent": "pnpm -r run lint:agent", + "typecheck:agent": "pnpm -r run typecheck:agent", "build": "pnpm -r run build", "docs:api": "typedoc", "docs:dev": "pnpm run docs:api && vitepress dev docs", diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index 423f4764..cc3b1486 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -253,6 +253,8 @@ "scripts": { "build": "shx rm -rf dist && tsc -p tsconfig.build.json", "lint": "eslint", + "lint:agent": "eslint --quiet", + "typecheck:agent": "tsc --noEmit --pretty false", "format": "prettier --write src", "format:check": "prettier --check src", "postpack": "shx rm -f oclif.manifest.json", @@ -262,6 +264,7 @@ "test": "c8 env OCLIF_TEST_ROOT=. mocha --forbid-only --exclude \"test/functional/e2e/**\" \"test/**/*.test.ts\"", "test:ci": "c8 env OCLIF_TEST_ROOT=. mocha --forbid-only --exclude \"test/functional/e2e/**\" --reporter json --reporter-option output=test-results.json \"test/**/*.test.ts\"", "test:unit": "env OCLIF_TEST_ROOT=. mocha --forbid-only --exclude \"test/functional/e2e/**\" \"test/**/*.test.ts\"", + "test:agent": "env OCLIF_TEST_ROOT=. mocha --forbid-only --reporter min --exclude \"test/functional/e2e/**\" \"test/**/*.test.ts\"", "test:e2e": "env OCLIF_TEST_ROOT=. mocha --forbid-only --reporter json --reporter-option output=test-results.json \"test/functional/e2e/**/*.test.ts\"", "coverage": "c8 report", "version": "oclif readme && git add README.md", diff --git a/packages/b2c-cli/test/commands/_test/index.test.ts b/packages/b2c-cli/test/commands/_test/index.test.ts index d0c686f1..58ccabc1 100644 --- a/packages/b2c-cli/test/commands/_test/index.test.ts +++ b/packages/b2c-cli/test/commands/_test/index.test.ts @@ -7,7 +7,9 @@ import {runCommand} from '@oclif/test'; import {expect} from 'chai'; describe('_test', () => { - it('runs the smoke test command without errors', async () => { + // Skip in automated tests - this is a debug command that intentionally produces log output + // Run manually with: ./cli _test + it.skip('runs the smoke test command without errors', async () => { const {error} = await runCommand('_test'); expect(error).to.be.undefined; }); diff --git a/packages/b2c-cli/test/commands/docs/search.test.ts b/packages/b2c-cli/test/commands/docs/search.test.ts index c791c8b1..0ec7352e 100644 --- a/packages/b2c-cli/test/commands/docs/search.test.ts +++ b/packages/b2c-cli/test/commands/docs/search.test.ts @@ -9,7 +9,7 @@ import {expect} from 'chai'; import {afterEach, beforeEach} from 'mocha'; import sinon from 'sinon'; import DocsSearch from '../../../src/commands/docs/search.js'; -import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; +import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../helpers/test-setup.js'; describe('docs search', () => { const hooks = createIsolatedConfigHooks(); @@ -43,7 +43,7 @@ describe('docs search', () => { const listStub = sinon.stub().returns([{id: 'a', title: 'A', filePath: 'a.md'}]); command.operations = {...command.operations, listDocs: listStub}; - const result = await command.run(); + const result = await runSilent(() => command.run()); expect(result.entries).to.have.length(1); }); @@ -69,7 +69,7 @@ describe('docs search', () => { const searchStub = sinon.stub().returns([{entry: {id: 'a', title: 'A', filePath: 'a.md'}, score: 0.1}]); command.operations = {...command.operations, searchDocs: searchStub}; - const result = await command.run(); + const result = await runSilent(() => command.run()); expect(result.results).to.have.length(1); }); diff --git a/packages/b2c-cli/test/commands/ecdn/security/get.test.ts b/packages/b2c-cli/test/commands/ecdn/security/get.test.ts index 4efe9fe7..cc2961b4 100644 --- a/packages/b2c-cli/test/commands/ecdn/security/get.test.ts +++ b/packages/b2c-cli/test/commands/ecdn/security/get.test.ts @@ -7,7 +7,7 @@ import {expect} from 'chai'; import {afterEach, beforeEach} from 'mocha'; import sinon from 'sinon'; import EcdnSecurityGet from '../../../../src/commands/ecdn/security/get.js'; -import {createIsolatedConfigHooks, createTestCommand} from '../../../helpers/test-setup.js'; +import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../../helpers/test-setup.js'; /** * Unit tests for eCDN security get command CLI logic. @@ -98,7 +98,7 @@ describe('ecdn security get', () => { }), }); - const result = await command.run(); + const result = await runSilent(() => command.run()); expect(result.settings.securityLevel).to.equal('high'); expect(result.settings.wafEnabled).to.be.true; diff --git a/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts b/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts index 381108d6..8b8f3c46 100644 --- a/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts +++ b/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts @@ -7,7 +7,7 @@ import {expect} from 'chai'; import {afterEach, beforeEach} from 'mocha'; import sinon from 'sinon'; import EcdnZonesList from '../../../../src/commands/ecdn/zones/list.js'; -import {createIsolatedConfigHooks, createTestCommand} from '../../../helpers/test-setup.js'; +import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../../helpers/test-setup.js'; /** * Unit tests for eCDN zones list command CLI logic. @@ -129,7 +129,7 @@ describe('ecdn zones list', () => { }), }); - const result = await command.run(); + const result = await runSilent(() => command.run()); expect(result).to.have.property('total', 1); expect(result.zones).to.have.lengthOf(1); diff --git a/packages/b2c-cli/test/commands/ods/create.test.ts b/packages/b2c-cli/test/commands/ods/create.test.ts index eeec14d2..28f0d366 100644 --- a/packages/b2c-cli/test/commands/ods/create.test.ts +++ b/packages/b2c-cli/test/commands/ods/create.test.ts @@ -8,6 +8,7 @@ import {expect} from 'chai'; import sinon from 'sinon'; import OdsCreate from '../../../src/commands/ods/create.js'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {runSilent} from '../../helpers/test-setup.js'; function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { Object.defineProperty(command, 'config', { @@ -199,7 +200,7 @@ describe('ods create', () => { }), }); - const result = await command.run(); + const result = await runSilent(() => command.run()); expect(result.id).to.equal('sb-123'); }); @@ -257,7 +258,7 @@ describe('ods create', () => { }, }); - await command.run(); + await runSilent(() => command.run()); expect(requestBody.settings).to.be.undefined; }); diff --git a/packages/b2c-cli/test/commands/ods/get.test.ts b/packages/b2c-cli/test/commands/ods/get.test.ts index 2c09fd08..26dba7ec 100644 --- a/packages/b2c-cli/test/commands/ods/get.test.ts +++ b/packages/b2c-cli/test/commands/ods/get.test.ts @@ -9,6 +9,7 @@ import sinon from 'sinon'; import OdsGet from '../../../src/commands/ods/get.js'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {runSilent} from '../../helpers/test-setup.js'; function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { Object.defineProperty(command, 'config', { @@ -134,7 +135,7 @@ describe('ods get', () => { }), }); - const result = await command.run(); + const result = await runSilent(() => command.run()); // Command returns the sandbox data regardless of JSON mode expect(result.id).to.equal('sandbox-123'); diff --git a/packages/b2c-cli/test/commands/ods/info.test.ts b/packages/b2c-cli/test/commands/ods/info.test.ts index 584980d4..15d06b06 100644 --- a/packages/b2c-cli/test/commands/ods/info.test.ts +++ b/packages/b2c-cli/test/commands/ods/info.test.ts @@ -9,6 +9,7 @@ import sinon from 'sinon'; import OdsInfo from '../../../src/commands/ods/info.js'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {runSilent} from '../../helpers/test-setup.js'; function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { Object.defineProperty(command, 'config', { @@ -146,7 +147,7 @@ describe('ods info', () => { throw new Error(`Unexpected path: ${path}`); }); - const result = await command.run(); + const result = await runSilent(() => command.run()); expect(result).to.have.property('user'); expect(result).to.have.property('system'); diff --git a/packages/b2c-cli/test/commands/ods/list.test.ts b/packages/b2c-cli/test/commands/ods/list.test.ts index b447e0d3..07dc990e 100644 --- a/packages/b2c-cli/test/commands/ods/list.test.ts +++ b/packages/b2c-cli/test/commands/ods/list.test.ts @@ -8,6 +8,7 @@ import {expect} from 'chai'; import sinon from 'sinon'; import OdsList from '../../../src/commands/ods/list.js'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {runSilent} from '../../helpers/test-setup.js'; function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { Object.defineProperty(command, 'config', { @@ -179,7 +180,7 @@ describe('ods list', () => { }), }); - const result = await command.run(); + const result = await runSilent(() => command.run()); // Command returns data regardless of JSON mode expect(result).to.have.property('count', 1); @@ -241,7 +242,7 @@ describe('ods list', () => { }), }); - const result = await command.run(); + const result = await runSilent(() => command.run()); expect(result.count).to.equal(0); expect(result.data).to.deep.equal([]); diff --git a/packages/b2c-cli/test/commands/setup/config.test.ts b/packages/b2c-cli/test/commands/setup/config.test.ts index d1d8f877..6a2e4b93 100644 --- a/packages/b2c-cli/test/commands/setup/config.test.ts +++ b/packages/b2c-cli/test/commands/setup/config.test.ts @@ -9,6 +9,7 @@ import sinon from 'sinon'; import SetupConfig from '../../../src/commands/setup/config.js'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {runSilent} from '../../helpers/test-setup.js'; import type {ConfigSourceInfo, NormalizedConfig} from '@salesforce/b2c-tooling-sdk/config'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -341,7 +342,7 @@ describe('setup config', () => { ], ); - const result = await command.run(); + const result = await runSilent(() => command.run()); expect(result).to.have.property('config'); expect(result.config.hostname).to.equal('test.example.com'); @@ -360,7 +361,7 @@ describe('setup config', () => { stubResolvedConfig(command, {hostname: 'test.example.com'}); - await command.run(); + await runSilent(() => command.run()); expect(warnings).to.include('Sensitive values are displayed unmasked.'); }); diff --git a/packages/b2c-cli/test/helpers/stub-parse.ts b/packages/b2c-cli/test/helpers/stub-parse.ts index 05c239cd..e317c30d 100644 --- a/packages/b2c-cli/test/helpers/stub-parse.ts +++ b/packages/b2c-cli/test/helpers/stub-parse.ts @@ -11,9 +11,11 @@ export function stubParse( flags: Record = {}, args: Record = {}, ): SinonStub { + // Include silent log level by default to reduce test output noise + const defaultFlags = {'log-level': 'silent'}; return stub(command as {parse: unknown}, 'parse').resolves({ args, - flags, + flags: {...defaultFlags, ...flags}, metadata: {}, argv: [], raw: [], diff --git a/packages/b2c-cli/test/helpers/test-setup.ts b/packages/b2c-cli/test/helpers/test-setup.ts index a4ab46c4..4c7a6601 100644 --- a/packages/b2c-cli/test/helpers/test-setup.ts +++ b/packages/b2c-cli/test/helpers/test-setup.ts @@ -5,10 +5,24 @@ */ import type {Config} from '@oclif/core'; +import {captureOutput} from '@oclif/test'; import sinon from 'sinon'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import {stubParse} from './stub-parse.js'; +/** + * Run a command silently, capturing stdout/stderr. + * Use this when you don't need to verify console output. + * + * @example + * const result = await runSilent(() => command.run()); + */ +export async function runSilent(fn: () => Promise): Promise { + const {result, error} = await captureOutput(fn); + if (error) throw error; + return result as T; +} + export function createIsolatedEnvHooks(): { beforeEach: () => void; afterEach: () => void; diff --git a/packages/b2c-dx-mcp/package.json b/packages/b2c-dx-mcp/package.json index b9411889..efae432a 100644 --- a/packages/b2c-dx-mcp/package.json +++ b/packages/b2c-dx-mcp/package.json @@ -70,6 +70,8 @@ "build": "shx rm -rf dist && tsc -p tsconfig.build.json", "clean": "shx rm -rf dist", "lint": "eslint", + "lint:agent": "eslint --quiet", + "typecheck:agent": "tsc --noEmit --pretty false", "format": "prettier --write src", "format:check": "prettier --check src", "preinspect": "pnpm run build", @@ -77,6 +79,7 @@ "inspect:dev": "mcp-inspector node --conditions development bin/dev.js --toolsets all --allow-non-ga-tools", "pretest": "tsc --noEmit -p test", "test": "mocha --forbid-only \"test/**/*.test.ts\"", + "test:agent": "mocha --forbid-only --reporter min \"test/**/*.test.ts\"", "posttest": "pnpm run lint", "prepack": "oclif manifest", "postpack": "shx rm -f oclif.manifest.json" diff --git a/packages/b2c-tooling-sdk/.mocharc.json b/packages/b2c-tooling-sdk/.mocharc.json index 1722876a..6a34721b 100644 --- a/packages/b2c-tooling-sdk/.mocharc.json +++ b/packages/b2c-tooling-sdk/.mocharc.json @@ -1,5 +1,6 @@ { "node-option": ["import=tsx", "conditions=development"], + "require": ["test/setup.ts"], "timeout": 10000, "recursive": true, "extension": ["ts"] diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 79d8598a..6d9dbd1a 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -90,17 +90,6 @@ "default": "./dist/cjs/operations/jobs/index.js" } }, - "./operations/sites": { - "development": "./src/operations/sites/index.ts", - "import": { - "types": "./dist/esm/operations/sites/index.d.ts", - "default": "./dist/esm/operations/sites/index.js" - }, - "require": { - "types": "./dist/cjs/operations/sites/index.d.ts", - "default": "./dist/cjs/operations/sites/index.js" - } - }, "./operations/mrt": { "development": "./src/operations/mrt/index.ts", "import": { @@ -238,12 +227,15 @@ "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", "clean": "shx rm -rf dist", "lint": "eslint", + "lint:agent": "eslint --quiet", + "typecheck:agent": "tsc --noEmit --pretty false", "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\"", "test:unit": "mocha --forbid-only \"test/**/*.test.ts\"", + "test:agent": "mocha --forbid-only --reporter min \"test/**/*.test.ts\"", "test:watch": "mocha --watch \"test/**/*.test.ts\"", "coverage": "c8 report", "posttest": "pnpm run lint", diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 35e1995b..a39f5974 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -180,10 +180,6 @@ export type { ExportGlobalDataConfiguration, } from './operations/jobs/index.js'; -// Operations - Sites -export {listSites, getSite} from './operations/sites/index.js'; -export type {Site} from './operations/sites/index.js'; - // Operations - Docs export { searchDocs, diff --git a/packages/b2c-tooling-sdk/src/logging/index.ts b/packages/b2c-tooling-sdk/src/logging/index.ts index f8ea9c73..01149dcd 100644 --- a/packages/b2c-tooling-sdk/src/logging/index.ts +++ b/packages/b2c-tooling-sdk/src/logging/index.ts @@ -118,5 +118,5 @@ * @module logging */ -export type {Logger, LoggerOptions, LogLevel, LogContext} from './types.js'; +export type {Logger, LoggerOptions, LogLevel, LogContext, LogDestination} from './types.js'; export {createLogger, configureLogger, getLogger, resetLogger, createSilentLogger} from './logger.js'; diff --git a/packages/b2c-tooling-sdk/src/logging/logger.ts b/packages/b2c-tooling-sdk/src/logging/logger.ts index 0e212460..3a5e66e9 100644 --- a/packages/b2c-tooling-sdk/src/logging/logger.ts +++ b/packages/b2c-tooling-sdk/src/logging/logger.ts @@ -84,6 +84,22 @@ function createPinoLogger(options: LoggerOptions): Logger { }; } + // Custom destination stream (for testing) + if (options.destination) { + if (options.json) { + return pino(pinoOptions, options.destination) as unknown as Logger; + } + const isVerbose = level === 'debug' || level === 'trace'; + const prettyStream = pretty({ + destination: options.destination, + sync: true, + colorize, + ignore: 'pid,hostname' + (isVerbose ? '' : ',time'), + hideObject: !isVerbose, + }); + return pino(pinoOptions, prettyStream); + } + // JSON output if (options.json) { return pino(pinoOptions, pino.destination({fd, sync: true})) as unknown as Logger; diff --git a/packages/b2c-tooling-sdk/src/logging/types.ts b/packages/b2c-tooling-sdk/src/logging/types.ts index 064e3e70..f48c78c8 100644 --- a/packages/b2c-tooling-sdk/src/logging/types.ts +++ b/packages/b2c-tooling-sdk/src/logging/types.ts @@ -13,11 +13,21 @@ export interface LogContext { [key: string]: unknown; } +/** Writable stream interface for custom log destinations (Node.js Writable compatible) */ +export interface LogDestination { + write(chunk: string | Buffer, encoding?: BufferEncoding, callback?: (error?: Error | null) => void): boolean; + on?(event: string, listener: (...args: unknown[]) => void): this; + once?(event: string, listener: (...args: unknown[]) => void): this; + emit?(event: string, ...args: unknown[]): boolean; +} + export interface LoggerOptions { /** Log level. Default: 'info' */ level?: LogLevel; /** File descriptor to write to (1=stdout, 2=stderr). Default: 2 */ fd?: number; + /** Custom destination stream. Overrides fd when provided. Useful for testing. */ + destination?: LogDestination; /** Base context included in all log entries */ baseContext?: LogContext; /** Enable secret redaction. Default: true */ diff --git a/packages/b2c-tooling-sdk/src/operations/sites/index.ts b/packages/b2c-tooling-sdk/src/operations/sites/index.ts deleted file mode 100644 index 9f31ed49..00000000 --- a/packages/b2c-tooling-sdk/src/operations/sites/index.ts +++ /dev/null @@ -1,78 +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 - */ -/** - * Site management operations for B2C Commerce. - * - * This module provides functions for listing and retrieving site information - * from B2C Commerce instances via OCAPI. - * - * ## Functions - * - * - {@link listSites} - List all sites on an instance - * - {@link getSite} - Get details for a specific site - * - * ## Usage - * - * ```typescript - * import { listSites, getSite } from '@salesforce/b2c-tooling-sdk/operations/sites'; - * import { B2CInstance, OAuthStrategy } from '@salesforce/b2c-tooling-sdk'; - * - * const auth = new OAuthStrategy({ - * clientId: 'your-client-id', - * clientSecret: 'your-client-secret', - * }); - * const instance = new B2CInstance( - * { hostname: 'your-sandbox.demandware.net' }, - * auth - * ); - * - * // List all sites - * const sites = await listSites(instance); - * for (const site of sites) { - * console.log(`${site.id}: ${site.displayName} (${site.status})`); - * } - * - * // Get a specific site - * const site = await getSite(instance, 'RefArch'); - * ``` - * - * ## Authentication - * - * Site operations require OAuth authentication with appropriate OCAPI permissions. - * - * @module operations/sites - */ -import {B2CInstance} from '../../instance/index.js'; - -export interface Site { - id: string; - displayName: string; - status: 'online' | 'offline'; -} - -/** - * Lists all sites on an instance. - */ -export async function listSites(instance: B2CInstance): Promise { - console.log(`Listing sites on ${instance.config.hostname}...`); - - // TODO: Implement actual site listing via OCAPI - // GET /s/-/dw/data/v21_10/sites - - return []; -} - -/** - * Gets details for a specific site. - */ -export async function getSite(instance: B2CInstance, siteId: string): Promise { - console.log(`Getting site ${siteId} on ${instance.config.hostname}...`); - - // TODO: Implement actual site retrieval via OCAPI - // GET /s/-/dw/data/v21_10/sites/{site_id} - - return null; -} diff --git a/packages/b2c-tooling-sdk/src/test-utils/config-isolation.ts b/packages/b2c-tooling-sdk/src/test-utils/config-isolation.ts index 55004581..5707a3b6 100644 --- a/packages/b2c-tooling-sdk/src/test-utils/config-isolation.ts +++ b/packages/b2c-tooling-sdk/src/test-utils/config-isolation.ts @@ -3,6 +3,7 @@ * 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 {resetLogger} from '../logging/index.js'; const ADDITIONAL_ENV_VARS = ['LANGUAGE', 'NO_COLOR']; @@ -31,6 +32,10 @@ export function isolateConfig(): void { process.env.SFCC_CONFIG = '/dev/null'; process.env.MRT_CREDENTIALS_FILE = '/dev/null'; + process.env.SFCC_LOG_LEVEL = 'silent'; + + // Reset global logger so it picks up the new SFCC_LOG_LEVEL + resetLogger(); state = {savedEnvVars}; } @@ -38,8 +43,12 @@ export function isolateConfig(): void { export function restoreConfig(): void { if (!state) return; + // Reset logger before restoring env vars + resetLogger(); + delete process.env.SFCC_CONFIG; delete process.env.MRT_CREDENTIALS_FILE; + delete process.env.SFCC_LOG_LEVEL; for (const [key, value] of Object.entries(state.savedEnvVars)) { if (value === undefined) { 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 index 1146305f..ecfdf7d6 100644 --- a/packages/b2c-tooling-sdk/test/cli/base-command.integration.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/base-command.integration.test.ts @@ -13,7 +13,7 @@ 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}); + const {error} = await runCommand(['test-base', '--json'], {root: fixtureRoot}); expect(error).to.be.undefined; }); 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 index f6a8277d..03d79a71 100644 --- a/packages/b2c-tooling-sdk/test/cli/instance-command.integration.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/instance-command.integration.test.ts @@ -19,7 +19,7 @@ interface TestInstanceResult { describe('InstanceCommand integration', () => { it('runs test-instance command without errors', async () => { - const {error} = await runCommand(['test-instance'], {root: fixtureRoot}); + const {error} = await runCommand(['test-instance', '--json'], {root: fixtureRoot}); expect(error).to.be.undefined; }); diff --git a/packages/b2c-tooling-sdk/test/cli/job-command.test.ts b/packages/b2c-tooling-sdk/test/cli/job-command.test.ts index bb210fec..d1da3374 100644 --- a/packages/b2c-tooling-sdk/test/cli/job-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/job-command.test.ts @@ -4,6 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Config} from '@oclif/core'; +import sinon from 'sinon'; import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; import type {JobExecution} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; @@ -94,6 +95,9 @@ describe('cli/job-command', () => { await cmd.init(); (cmd as Record)._instance = mockInstance; + // Stub warn to avoid noise in test output for expected warning + const warnStub = sinon.stub(command, 'warn'); + const execution: JobExecution = { id: 'test-job', execution_status: 'aborted', @@ -108,6 +112,7 @@ describe('cli/job-command', () => { // Expected if getJobLog fails } + warnStub.restore(); cmd.parse = originalParse; }); 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 84e1c922..9760cc6e 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 @@ -22,7 +22,7 @@ interface TestMrtResult { describe('MrtCommand integration', () => { it('runs test-mrt command without errors', async () => { - const {error} = await runCommand(['test-mrt'], {root: fixtureRoot}); + const {error} = await runCommand(['test-mrt', '--json'], {root: fixtureRoot}); expect(error).to.be.undefined; }); diff --git a/packages/b2c-tooling-sdk/test/clients/middleware.test.ts b/packages/b2c-tooling-sdk/test/clients/middleware.test.ts index 48e75471..8fc63c28 100644 --- a/packages/b2c-tooling-sdk/test/clients/middleware.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/middleware.test.ts @@ -8,13 +8,14 @@ import {expect} from 'chai'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import sinon from 'sinon'; import { createAuthMiddleware, createExtraParamsMiddleware, createLoggingMiddleware, } from '@salesforce/b2c-tooling-sdk/clients'; import type {AuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; -import {configureLogger, resetLogger} from '@salesforce/b2c-tooling-sdk/logging'; +import {configureLogger, getLogger, resetLogger} from '@salesforce/b2c-tooling-sdk/logging'; describe('clients/middleware', () => { describe('createAuthMiddleware', () => { @@ -120,6 +121,9 @@ describe('clients/middleware', () => { }); it('does not throw if body is invalid JSON (skips merge)', async () => { + // Stub logger.warn to avoid noise in test output for expected warning + const warnStub = sinon.stub(getLogger(), 'warn'); + const middleware = createExtraParamsMiddleware({body: {forced: true}}); const request = new Request('https://example.com/items', { @@ -138,6 +142,9 @@ describe('clients/middleware', () => { expect(text).to.equal('not-json'); expect(text).to.not.include('forced'); + expect(warnStub.calledOnce).to.be.true; + + warnStub.restore(); }); it('does not merge extra body when request is not JSON', async () => { diff --git a/packages/b2c-tooling-sdk/test/helpers/null-stream.ts b/packages/b2c-tooling-sdk/test/helpers/null-stream.ts new file mode 100644 index 00000000..3647d0eb --- /dev/null +++ b/packages/b2c-tooling-sdk/test/helpers/null-stream.ts @@ -0,0 +1,43 @@ +/* + * 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 {Writable} from 'node:stream'; + +/** + * Creates a null stream that discards all output. + * Useful for silencing logger output in tests. + * Creates a new instance each time to avoid listener accumulation. + */ +export function createNullStream(): Writable { + return new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }); +} + +// Convenience constant for backward compatibility (use createNullStream() in beforeEach for cleaner tests) +export const nullStream = createNullStream(); + +/** + * A capturing stream that stores output for later inspection. + * Useful for verifying logger output in tests. + */ +export class CapturingStream extends Writable { + output: string[] = []; + + _write(chunk: Buffer | string, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { + this.output.push(chunk.toString()); + callback(); + } + + clear(): void { + this.output = []; + } + + getOutput(): string { + return this.output.join(''); + } +} diff --git a/packages/b2c-tooling-sdk/test/helpers/stub-parse.ts b/packages/b2c-tooling-sdk/test/helpers/stub-parse.ts index 2fbe3af0..5c698457 100644 --- a/packages/b2c-tooling-sdk/test/helpers/stub-parse.ts +++ b/packages/b2c-tooling-sdk/test/helpers/stub-parse.ts @@ -19,9 +19,11 @@ export function stubParse( flags: Record = {}, args: Record = {}, ): SinonStub { + // Include silent log level by default to reduce test output noise + const defaultFlags = {'log-level': 'silent'}; return sinon.stub(command as {parse: unknown}, 'parse').resolves({ args, - flags, + flags: {...defaultFlags, ...flags}, metadata: {}, argv: [], raw: [], diff --git a/packages/b2c-tooling-sdk/test/logging/logger.test.ts b/packages/b2c-tooling-sdk/test/logging/logger.test.ts index 977de807..a1ee7cfe 100644 --- a/packages/b2c-tooling-sdk/test/logging/logger.test.ts +++ b/packages/b2c-tooling-sdk/test/logging/logger.test.ts @@ -12,6 +12,7 @@ import { createSilentLogger, type Logger, } from '@salesforce/b2c-tooling-sdk/logging'; +import {createNullStream, CapturingStream} from '../helpers/null-stream.js'; describe('logging/logger', () => { beforeEach(() => { @@ -179,7 +180,9 @@ describe('logging/logger', () => { let logger: Logger; beforeEach(() => { - logger = createLogger({level: 'trace'}); + // Use createNullStream() to prevent console output during tests + // Fresh stream each time to avoid listener accumulation + logger = createLogger({level: 'trace', destination: createNullStream()}); }); it('supports trace method with message first', () => { @@ -233,21 +236,21 @@ describe('logging/logger', () => { describe('child logger', () => { it('creates child logger with context', () => { - const parent = createLogger({level: 'info'}); + const parent = createLogger({level: 'info', destination: createNullStream()}); const child = parent.child({operation: 'deploy'}); expect(child).to.exist; expect(child.info).to.be.a('function'); }); it('child logger inherits parent configuration', () => { - const parent = createLogger({level: 'debug'}); + const parent = createLogger({level: 'debug', destination: createNullStream()}); const child = parent.child({operation: 'deploy'}); expect(child).to.exist; expect(child.info).to.be.a('function'); }); it('child logger can create nested children', () => { - const parent = createLogger({level: 'info'}); + const parent = createLogger({level: 'info', destination: createNullStream()}); const child1 = parent.child({operation: 'deploy'}); const child2 = child1.child({file: 'app.zip'}); expect(child2).to.exist; @@ -257,38 +260,38 @@ describe('logging/logger', () => { describe('log levels', () => { it('respects trace level', () => { - const logger = createLogger({level: 'trace'}); + const logger = createLogger({level: 'trace', destination: createNullStream()}); logger.trace('trace message'); logger.debug('debug message'); logger.info('info message'); }); it('respects debug level', () => { - const logger = createLogger({level: 'debug'}); + const logger = createLogger({level: 'debug', destination: createNullStream()}); logger.debug('debug message'); logger.info('info message'); }); it('respects info level', () => { - const logger = createLogger({level: 'info'}); + const logger = createLogger({level: 'info', destination: createNullStream()}); logger.info('info message'); logger.warn('warn message'); }); it('respects warn level', () => { - const logger = createLogger({level: 'warn'}); + const logger = createLogger({level: 'warn', destination: createNullStream()}); logger.warn('warn message'); logger.error('error message'); }); it('respects error level', () => { - const logger = createLogger({level: 'error'}); + const logger = createLogger({level: 'error', destination: createNullStream()}); logger.error('error message'); logger.fatal('fatal message'); }); it('respects fatal level', () => { - const logger = createLogger({level: 'fatal'}); + const logger = createLogger({level: 'fatal', destination: createNullStream()}); logger.fatal('fatal message'); }); @@ -301,73 +304,123 @@ describe('logging/logger', () => { describe('secret redaction', () => { it('redacts password field', () => { - const logger = createLogger({level: 'info', json: true}); - // Capture output to verify redaction + const stream = new CapturingStream(); + const logger = createLogger({level: 'info', json: true, destination: stream}); logger.info({username: 'user', password: 'secret123'}, 'Auth attempt'); + const output = stream.getOutput(); + expect(output).to.include('username'); + expect(output).to.include('REDACTED'); + expect(output).not.to.include('secret123'); }); it('redacts clientSecret field', () => { - const logger = createLogger({level: 'info', json: true}); + const stream = new CapturingStream(); + const logger = createLogger({level: 'info', json: true, destination: stream}); logger.info({clientId: 'client', clientSecret: 'secret123'}, 'OAuth config'); + expect(stream.getOutput()).to.include('REDACTED'); }); it('redacts apiKey field', () => { - const logger = createLogger({level: 'info', json: true}); + const stream = new CapturingStream(); + const logger = createLogger({level: 'info', json: true, destination: stream}); logger.info({apiKey: 'key123456789'}, 'API key config'); + expect(stream.getOutput()).to.include('REDACTED'); }); it('redacts token field', () => { - const logger = createLogger({level: 'info', json: true}); + const stream = new CapturingStream(); + const logger = createLogger({level: 'info', json: true, destination: stream}); logger.info({token: 'token123456789'}, 'Token config'); + expect(stream.getOutput()).to.include('REDACTED'); }); - it('redacts nested fields', () => { - const logger = createLogger({level: 'info', json: true}); - logger.info({config: {auth: {password: 'secret123'}}}, 'Nested config'); + it('redacts nested fields one level deep', () => { + const stream = new CapturingStream(); + const logger = createLogger({level: 'info', json: true, destination: stream}); + // Note: *.password pattern only matches one level deep (e.g., auth.password) + logger.info({auth: {password: 'secret123'}}, 'Nested config'); + expect(stream.getOutput()).to.include('REDACTED'); }); it('redacts authorization header with Basic auth', () => { - const logger = createLogger({level: 'info', json: true}); + const stream = new CapturingStream(); + const logger = createLogger({level: 'info', json: true, destination: stream}); logger.info({authorization: 'Basic dXNlcjpwYXNz'}, 'Auth header'); + const output = stream.getOutput(); + expect(output).to.include('Basic'); + expect(output).not.to.include('dXNlcjpwYXNz'); }); it('redacts authorization header with Bearer token', () => { - const logger = createLogger({level: 'info', json: true}); + const stream = new CapturingStream(); + const logger = createLogger({level: 'info', json: true, destination: stream}); logger.info({authorization: 'Bearer token123456789'}, 'Auth header'); + const output = stream.getOutput(); + expect(output).to.include('Bearer'); + expect(output).to.include('REDACTED'); }); it('does not redact when redact is disabled', () => { - const logger = createLogger({level: 'info', json: true, redact: false}); + const stream = new CapturingStream(); + const logger = createLogger({level: 'info', json: true, redact: false, destination: stream}); logger.info({password: 'secret123'}, 'No redaction'); + expect(stream.getOutput()).to.include('secret123'); }); }); describe('JSON output mode', () => { it('outputs JSON when json option is true', () => { - const logger = createLogger({json: true, level: 'info'}); + const stream = new CapturingStream(); + const logger = createLogger({json: true, level: 'info', destination: stream}); logger.info('test message'); + const output = stream.getOutput(); + // JSON output should be parseable + expect(() => JSON.parse(output)).not.to.throw(); + expect(output).to.include('test message'); }); it('outputs pretty print when json option is false', () => { - const logger = createLogger({json: false, level: 'info'}); + const stream = new CapturingStream(); + const logger = createLogger({json: false, level: 'info', destination: stream}); logger.info('test message'); + expect(stream.getOutput()).to.include('test message'); }); it('outputs pretty print by default', () => { - const logger = createLogger({level: 'info'}); + const stream = new CapturingStream(); + const logger = createLogger({level: 'info', destination: stream}); logger.info('test message'); + expect(stream.getOutput()).to.include('test message'); }); }); describe('baseContext', () => { it('includes baseContext in all log entries', () => { - const logger = createLogger({baseContext: {app: 'test-app', version: '1.0.0'}}); + const stream = new CapturingStream(); + const logger = createLogger({ + baseContext: {app: 'test-app', version: '1.0.0'}, + level: 'info', + json: true, + destination: stream, + }); logger.info('test message'); + const output = stream.getOutput(); + expect(output).to.include('test-app'); + expect(output).to.include('1.0.0'); }); it('merges baseContext with inline context', () => { - const logger = createLogger({baseContext: {app: 'test-app'}}); + const stream = new CapturingStream(); + const logger = createLogger({ + baseContext: {app: 'test-app'}, + level: 'info', + json: true, + destination: stream, + }); logger.info({operation: 'deploy'}, 'test message'); + const output = stream.getOutput(); + expect(output).to.include('test-app'); + expect(output).to.include('deploy'); }); }); }); diff --git a/packages/b2c-tooling-sdk/test/operations/sites/index.test.ts b/packages/b2c-tooling-sdk/test/operations/sites/index.test.ts deleted file mode 100644 index 764c7bcd..00000000 --- a/packages/b2c-tooling-sdk/test/operations/sites/index.test.ts +++ /dev/null @@ -1,37 +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 {listSites, getSite} from '../../../src/operations/sites/index.js'; -import {B2CInstance} from '../../../src/instance/index.js'; - -describe('operations/sites', () => { - let mockInstance: B2CInstance; - - beforeEach(() => { - mockInstance = new B2CInstance( - {hostname: 'test.demandware.net'}, - { - oauth: {clientId: 'test-client', clientSecret: 'test-secret'}, - }, - ); - }); - - describe('listSites', () => { - it('should return empty array (TODO implementation)', async () => { - const sites = await listSites(mockInstance); - expect(sites).to.be.an('array'); - expect(sites).to.have.lengthOf(0); - }); - }); - - describe('getSite', () => { - it('should return null (TODO implementation)', async () => { - const site = await getSite(mockInstance, 'RefArch'); - expect(site).to.be.null; - }); - }); -}); diff --git a/packages/b2c-tooling-sdk/test/setup.ts b/packages/b2c-tooling-sdk/test/setup.ts new file mode 100644 index 00000000..573170e3 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/setup.ts @@ -0,0 +1,13 @@ +/* + * 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 + */ + +/** + * Global test setup - runs before all tests. + * Sets SFCC_LOG_LEVEL to silent to reduce test output noise. + */ + +// Set silent log level by default for all tests +process.env.SFCC_LOG_LEVEL = 'silent';