diff --git a/.gitignore b/.gitignore index a62f2f7..e6946f9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,7 @@ node_modules/ .kiro # Build output -dist/ +out/ + +# Dev +sandbox \ No newline at end of file diff --git a/.npmignore b/.npmignore index cdb498f..0b462d5 100644 --- a/.npmignore +++ b/.npmignore @@ -6,3 +6,12 @@ node_modules/ # Version project configuration version.json +src/ +__tests__ + +# Build config (not needed in published package) +esbuild.config.mjs +tsconfig.json +tsconfig.test.json +build.js +sandbox/ diff --git a/README.md b/README.md index 3c9b7fa..4062a73 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,16 @@ -# versionings +# Versionings — Semantic Release Platform for Git -A CLI tool that automates semantic versioning workflows for Git repositories. Handles version bumping, branch and tag creation, and optionally pushes changes and opens pull requests on GitHub or Bitbucket. +[![npm](https://img.shields.io/npm/v/versionings?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/versionings) +[![License](https://img.shields.io/badge/license-MIT-blue?style=for-the-badge&logo=opensourceinitiative)](LICENSE) +[![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen?style=for-the-badge&logo=nodedotjs)](https://nodejs.org) +[![TypeScript](https://img.shields.io/badge/typescript-strict-blue?style=for-the-badge&logo=typescript)](https://www.typescriptlang.org) +[![Build](https://img.shields.io/badge/build-esbuild-yellow?style=for-the-badge&logo=esbuild)](https://esbuild.github.io) + +CLI tool that automates semantic versioning workflows for Git repositories. Bumps versions, creates branches and tags, and optionally pushes changes and opens pull requests on supported SCM platforms. ## Installation -``` +```bash npm install --global versionings ``` @@ -12,147 +18,85 @@ Requires Node.js >= 18. ## Quick Start -1. Create a `version.json` in your project root: - -```json -{ - "git": { - "platform": "github", - "url": "https://github.com/your-org/your-repo.git" - } -} -``` +```bash +# Create a configuration file +versionings init -2. Run: +# Preview what will happen (dry-run) +versionings plan --semver=patch --branch=my-feature -``` -versionings --semver=patch --branch=fix-login +# Execute the versioning workflow +versionings release --semver=patch --branch=my-feature ``` -This bumps the patch version, creates a version branch and tag, and commits the changes. +See the [Setup Guide](docs/setup-guide.md) for a complete walkthrough. -## Configuration +## Commands -The `version.json` file is validated against a JSON Schema on every run. Invalid configuration produces clear error messages with field paths. +| Command | Description | Mutates Repo? | +|---------|-------------|---------------| +| `init` | Interactive config wizard | No | +| `validate` | Check config and environment | No | +| `plan` | Dry-run: show execution plan | No | +| `release` | Execute versioning workflow | Yes | +| `rollback` | Revert last operation | Yes | +| `doctor` | Diagnose environment | No | +| `changelog` | Generate changelog from commits | No | -| Field | Required | Description | -|---|---|---| -| `git.platform` | Yes | VCS platform: `github` or `bitbucket` | -| `git.url` | Yes | Repository URL (HTTPS or SSH) | -| `git.pr.target` | No | Pull request target branch. Default: `master` | +See the [CLI Reference](docs/cli-reference.md) for full details on each command, parameters, and examples. -The schema is exported as `version.schema.json` for IDE autocompletion. +> **Backward compatibility:** Running `versionings --semver= --branch=` without a subcommand is equivalent to `versionings release`. -## CLI Usage +## Features -``` -versionings --semver= --branch= [options] -``` +- 6 branching strategies (default, trunk-based, git-flow, release-branch, hotfix, maintenance) +- 6 SCM platforms (GitHub, GitHub Enterprise, Bitbucket, Bitbucket Server, GitLab, Azure DevOps) +- Conventional Commits parsing with `--semver=auto` +- Changelog generation from commit history +- Interactive and non-interactive modes +- Config Provenance (`--print-config`) +- Dry-run with `plan` command +- Automatic rollback on failure +- JSON output for CI (`--json`) +- Structured JSON logging with operation IDs and actor metadata +- Concurrency lock to prevent parallel releases +- Action trace with step-level timing -| Flag | Type | Default | Description | -|---|---|---|---| -| `--semver` | string | required | Semver type: `patch`, `minor`, `major`, `prepatch`, `preminor`, `premajor`, `prerelease` | -| `--branch` | string | required | Version branch comment (hyphen-case, max 96 chars, no `--`) | -| `--push` | boolean | `false` | Push branch and tags to remote, generate PR URL | -| `--preid` | string | — | Prerelease identifier (e.g. `beta`, `rc`) | -| `--dry-run` | boolean | `false` | Show the full execution plan without making changes | -| `--json` | boolean | `false` | Output results as structured JSON | -| `--verbose` | boolean | `false` | Log every shell command before execution | - - -## Workflow - -1. Validate CLI arguments and configuration -2. Check working tree is clean (`git status --porcelain`) -3. Verify git remote matches `version.json` -4. Compute next version via `npm version` (probe + undo) -5. Check artifact uniqueness (branch and tag names, local + remote) -6. **Dry-run exits here** with the execution plan -7. Bump version (`npm version`) -8. Create branch (`version///`) -9. Create annotated tag (`--`) -10. Commit all changes -11. Push + generate PR URL (if `--push`) - -If any step 7–11 fails, all completed steps are automatically rolled back. +See the [documentation](docs/index.md) for details on each feature. ## Exit Codes | Code | Name | Description | -|---|---|---| -| 0 | `SUCCESS` | Completed successfully | -| 1 | `CONFIG_ERROR` | Missing, invalid, or schema-violating `version.json` | -| 2 | `DIRTY_TREE` | Uncommitted or untracked files in working directory | -| 3 | `INVALID_ARGS` | Invalid `--semver` value or `--branch` format | -| 4 | `ARTIFACT_CONFLICT` | Branch or tag already exists (local or remote) | -| 5 | `COMMAND_FAILED` | Git or npm command returned non-zero exit code | -| 6 | `NETWORK_ERROR` | Remote repository or network failure | -| 7 | `INCOMPLETE_ROLLBACK` | Rollback could not fully revert; manual recovery needed | - -## Reliability Features - -### Dry Run - -`--dry-run` executes all validation and checks, then outputs the full plan (version, branch, tag, commit message, commands) without modifying anything. Combine with `--json` for machine-readable output. - -### JSON Output - -`--json` produces a single JSON object to stdout (success) or stderr (error). No ANSI colors, no progress messages. Designed for CI script consumption. - -Success: -```json -{ - "success": true, - "version": "1.2.3", - "previousVersion": "1.2.2", - "semver": "patch", - "branch": "version/patch/1.2.3/fix-login", - "tag": "1.2.3--fix-login", - "pullRequestUrl": null, - "exitCode": 0 -} -``` - -Error: -```json -{ - "success": false, - "exitCode": 4, - "error": { - "code": "ARTIFACT_CONFLICT", - "message": "Tag already exists: 1.2.3--fix-login", - "details": { "type": "tag", "name": "1.2.3--fix-login", "scope": "local" } - } -} -``` - -### Rollback - -If a mutation step fails (version bump, branch creation, tagging, commit, push), all previously completed steps are automatically reversed in LIFO order. If rollback itself partially fails, the CLI exits with code 7 and prints manual recovery instructions. - -### Artifact Uniqueness - -Before any mutations, the tool checks that the target branch and tag names don't already exist — locally and (when `--push`) on the remote. Matching is by exact full name, not prefix or substring. - - -## Architecture - -TypeScript source, bundled to a single `dist/version.js` via esbuild. Flat module layout: - -| Module | Responsibility | -|---|---| -| `version.ts` | Thin CLI entry point (argument parsing, DI wiring, process exit) | -| `pipeline.ts` | Workflow orchestration (all stages as async sequence) | -| `executor.ts` | Centralized shell command execution with Promise API | -| `config.validator.ts` | JSON Schema validation of `version.json` via ajv | -| `rollback.ts` | LIFO rollback journal for mutation steps | -| `artifact.checker.ts` | Branch/tag uniqueness verification (local + remote) | -| `reporter.ts` | JSON and human-readable output formatting | -| `errors.ts` | `VersioningsError` class and `EXIT_CODES` constants | -| `version.utils.ts` | Branch/tag naming, PR URL generation, semver helpers | -| `utils.ts` | Legacy utilities (logging, ANSI colors, `get()`) | - -All modules use dependency injection. The executor accepts a custom `execFn` for testing; the pipeline receives all dependencies through a `deps` parameter. +|------|------|-------------| +| 0 | SUCCESS | Successful completion | +| 1 | CONFIG_ERROR | Configuration error | +| 2 | DIRTY_TREE | Uncommitted changes | +| 3 | INVALID_ARGS | Invalid CLI arguments | +| 4 | ARTIFACT_CONFLICT | Branch or tag already exists | +| 5 | COMMAND_FAILED | Git/npm command failed | +| 6 | NETWORK_ERROR | Network error | +| 7 | INCOMPLETE_ROLLBACK | Rollback could not complete | +| 8 | NO_OPERATION | Nothing to rollback | +| 9 | USER_CANCELLED | User cancelled operation | +| 10 | POLICY_VIOLATION | Branch policy violated | +| 11 | NO_CONVENTIONAL_COMMITS | No conventional commits for auto-bump | + +See the [Failure Matrix](docs/failure-matrix.md) for causes, error examples, and remediation steps. + +## Documentation + +Full documentation is available in the [docs/](docs/index.md) directory: + +- [Setup Guide](docs/setup-guide.md) — Installation, configuration, and first release +- [Configuration Reference](docs/configuration-reference.md) — All config fields, sources, and examples +- [CLI Reference](docs/cli-reference.md) — Commands, flags, and output formats +- [Failure Matrix](docs/failure-matrix.md) — Exit codes with causes and solutions +- [Branch Strategy Cookbook](docs/branch-strategy-cookbook.md) — 6 branching strategies with examples +- [SCM Provider Guide](docs/scm-provider-guide.md) — Platform setup and PR/MR automation +- [CI/CD Examples](docs/ci-examples.md) — GitHub Actions, GitLab CI, Azure Pipelines, Bitbucket Pipelines +- [Changelog Format Guide](docs/changelog-format-guide.md) — Conventional Commits and changelog configuration +- [Operational Hardening Guide](docs/operational-hardening-guide.md) — Structured logging, operation IDs, and concurrency lock +- [Migration Guide](docs/migration-guide.md) — Upgrading between versions ## Development @@ -163,36 +107,67 @@ All modules use dependency injection. The executor accepts a custom `execFn` for ### Commands -``` +```bash npm install # Install dependencies -npm run build # Bundle to dist/version.js via esbuild +npm run build # Bundle via esbuild + emit type declarations npm test # Run all tests (unit, property, integration, e2e) +npm run typecheck # Type-check without emitting +npm run lint # Lint all source and test files +``` + +### Project Structure + +```text +src/ +├── cli/ # CLI entry point, command router, subcommands +│ ├── version.ts # Entry point (compiles to out/dist/index.js) +│ ├── command.router.ts +│ ├── interaction.manager.ts +│ └── commands/ # init, validate, plan, release, rollback, doctor, changelog +├── core/ # Pipeline, executor, errors, rollback, reporter +│ ├── pipeline.ts # Workflow orchestration +│ ├── executor.ts # Shell command execution (Promise-based, DI) +│ ├── errors.ts # Exit codes (0–11) and VersioningsError +│ ├── rollback.ts # Rollback manager +│ ├── reporter.ts # JSON / human-readable output +│ ├── structured.logger.ts # Structured JSON logging +│ ├── lock.manager.ts # Concurrency lock +│ ├── action.tracer.ts # Step timing trace +│ └── actor.resolver.ts # Git user / CI actor metadata +├── config/ # Configuration loading, merging, validation +├── scm/ # SCM provider abstraction and PR/MR creation +│ ├── providers/ # GitHub, GitLab, Bitbucket, Azure DevOps +│ └── ... +├── branching/ # Branching strategies and policy checker +│ ├── strategies/ # default, trunk-based, git-flow, release, hotfix, maintenance +│ └── ... +├── versioning/ # Conventional commits, auto-bump, changelog +└── utils/ # Shared utilities (ANSI colors, helpers) ``` ### Test Structure -``` +```text __tests__/ -├── unit/ # Module-level tests with mocks +├── unit/ # Module-level tests with mocks (mirrored by domain) +│ ├── cli/ +│ ├── core/ +│ ├── config/ +│ ├── scm/ +│ ├── branching/ +│ ├── versioning/ +│ └── utils/ ├── properties/ # Property-based tests (fast-check, 100+ iterations each) +│ ├── core/ +│ ├── config/ +│ ├── scm/ +│ ├── branching/ +│ └── versioning/ ├── integration/ # Full pipeline with real git repos (no mocks) ├── e2e/ # CLI as child process with real git repos └── helpers/ # Test utilities (repo fixture creation) ``` -- **Unit tests**: Each public module has dedicated tests with mock dependencies -- **Property-based tests**: 16 correctness properties verified via fast-check (dry-run safety, rollback ordering, output normalization, JSON completeness, artifact matching, etc.) -- **Integration tests**: Full pipeline execution in isolated tmpdir git repositories with real executor, real filesystem, real git — no mocks -- **E2E tests**: `node dist/version.js` invoked as a child process against real git repos, verifying exit codes, stdout/stderr, and actual git state - -### CI - -GitHub Actions workflow (`.github/workflows/ci.yml`): -- Matrix: Node.js 18 + latest LTS -- Platforms: Ubuntu + macOS -- Steps: install → lint (ESLint) → unit tests → integration tests → e2e tests -- PR merge blocked on any failure - ## License MIT diff --git a/__tests__/e2e/branching.commands.test.ts b/__tests__/e2e/branching.commands.test.ts new file mode 100644 index 0000000..fe627d9 --- /dev/null +++ b/__tests__/e2e/branching.commands.test.ts @@ -0,0 +1,421 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * E2E tests: Branching policy enforcement CLI commands via `node dist/version.js`. + * + * Validates: Requirements 2.1, 10.5, 10.6, 10.7, 17.1, 17.3, 17.5, 17.6 + */ + +import { execSync, spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { + createRepoFixture, + snapshotRepoState, + assertNoMutation, + cleanup, + git, +} from '../helpers/repo-fixture'; +import { CLI_PATH, PROJECT_ROOT, IS_PACKED } from '../helpers/cli-path'; + +interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +function runCli(args: string[], opts: { cwd: string; env?: Record }): RunResult { + const result = spawnSync(process.execPath, [CLI_PATH, ...args], { + cwd: opts.cwd, + encoding: 'utf8', + timeout: 15000, + env: { ...process.env, ...opts.env }, + }); + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.status ?? 1, + }; +} + +let dirs: string[] = []; + +beforeAll(() => { + if (!IS_PACKED) { + execSync(`${process.execPath} build.js`, { + cwd: PROJECT_ROOT, + encoding: 'utf8', + timeout: 30000, + }); + } + expect(fs.existsSync(CLI_PATH)).toBe(true); +}); + +afterEach(() => { + cleanup(dirs); + dirs = []; +}); + +describe('E2E: Branching policy enforcement commands', () => { + // Test 1: Default strategy (no git.branching) — exit code 0, backward compatible + // Validates: Requirements 2.1, 10.5 + test('default strategy (no git.branching) — exit code 0, backward compatible', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['--semver=patch', '--branch=test', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1.0.1'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.0.1'); + expect(state.branches).toContain('version/patch/1.0.1/test'); + expect(state.tags).toContain('1.0.1--test'); + }, 30000); + + // Test 2: Backward compatibility — --semver=patch --branch=test without new fields works as before + // Validates: Requirements 2.1, 10.5 + test('backward compatibility — --semver=patch --branch=test without new fields works as before', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['release', '--semver=patch', '--branch=compat', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1.0.1'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.0.1'); + expect(state.branches).toContain('version/patch/1.0.1/compat'); + expect(state.tags).toContain('1.0.1--compat'); + }, 30000); + + // Test 3: Plan with --json and git.branching.strategy=trunk-based — JSON output contains strategy field + // Validates: Requirements 17.1, 17.3 + test('plan --json with git.branching.strategy=trunk-based — JSON contains strategy field', () => { + const { repoDir, remoteDir, remoteUrl } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Write version.json with branching section + const versionJson = { + git: { + platform: 'github', + url: remoteUrl, + branching: { strategy: 'trunk-based' }, + }, + }; + fs.writeFileSync(path.join(repoDir, 'version.json'), JSON.stringify(versionJson, null, 2)); + git(repoDir, 'add version.json'); + git(repoDir, 'commit -m "update config"'); + + const { stdout, exitCode } = runCli( + ['plan', '--semver=patch', '--branch=test', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.dryRun).toBe(true); + expect(parsed.strategy).toBe('trunk-based'); + }, 30000); + + // Test 4: Validate with --json and git.branching section — JSON contains branching_strategy check + // Validates: Requirements 10.6, 17.5 + test('validate --json with git.branching section — JSON contains branching strategy check', () => { + const { repoDir, remoteDir, remoteUrl } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Write version.json with branching section + const versionJson = { + git: { + platform: 'github', + url: remoteUrl, + branching: { strategy: 'trunk-based' }, + }, + }; + fs.writeFileSync(path.join(repoDir, 'version.json'), JSON.stringify(versionJson, null, 2)); + git(repoDir, 'add version.json'); + git(repoDir, 'commit -m "update config"'); + + const { stdout, exitCode } = runCli( + ['validate', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.valid).toBe(true); + expect(Array.isArray(parsed.checks)).toBe(true); + + // Should contain a branching_strategy check + const branchingCheck = parsed.checks.find( + (c: any) => c.name === 'branching_strategy', + ); + expect(branchingCheck).toBeDefined(); + expect(branchingCheck.status).toBe('pass'); + }, 30000); + + // Test 5: Doctor with --json and non-default strategy — JSON contains branching_strategy check + // Validates: Requirements 10.7, 17.6 + test('doctor --json with trunk-based strategy — JSON contains branching_strategy check', () => { + const { repoDir, remoteDir, remoteUrl } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Write version.json with non-default branching strategy + // (doctor skips branching check for strategy=default) + const versionJson = { + git: { + platform: 'github', + url: remoteUrl, + branching: { strategy: 'trunk-based' }, + }, + }; + fs.writeFileSync(path.join(repoDir, 'version.json'), JSON.stringify(versionJson, null, 2)); + git(repoDir, 'add version.json'); + git(repoDir, 'commit -m "update config"'); + + const { stdout, exitCode } = runCli( + ['doctor', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + + // Doctor --json outputs checks array (first line) followed by provenance + const lines = stdout.trim().split('\n').filter(Boolean); + expect(lines.length).toBeGreaterThanOrEqual(1); + + const checks = JSON.parse(lines[0]); + expect(Array.isArray(checks)).toBe(true); + expect(checks.length).toBeGreaterThan(0); + + // Each check has name, status, found + for (const check of checks) { + expect(check).toHaveProperty('name'); + expect(check).toHaveProperty('status'); + expect(check).toHaveProperty('found'); + } + + // Should contain a branching_strategy check (present for non-default strategies) + const branchingCheck = checks.find( + (c: any) => c.name === 'branching_strategy', + ); + expect(branchingCheck).toBeDefined(); + // Current branch is main, trunk-based expects main/master → should pass + expect(branchingCheck.status).toBe('pass'); + }, 30000); + + // Test 6: release with trunk-based strategy — no branch created, tag v{version}, git state correct + test('release with trunk-based strategy — no branch, tag v{version}, stays on main', () => { + const { repoDir, remoteDir, remoteUrl } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const versionJson = { + git: { + platform: 'github', + url: remoteUrl, + branching: { strategy: 'trunk-based' }, + }, + }; + fs.writeFileSync(path.join(repoDir, 'version.json'), JSON.stringify(versionJson, null, 2)); + git(repoDir, 'add version.json'); + git(repoDir, 'commit -m "update config"'); + + const { stdout, exitCode } = runCli( + ['release', '--semver=patch', '--branch=hotfix', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1.0.1'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.0.1'); + // Should stay on main — no new branch created + expect(state.branch).toBe('main'); + // Tag should be v{version}, not {version}--{comment} + expect(state.tags).toContain('v1.0.1'); + expect(state.tags).not.toContain('1.0.1--hotfix'); + // No version/patch/... branch should exist + expect(state.branches).not.toContain(expect.stringContaining('version/')); + }, 30000); + + // Test 7: release --json with trunk-based — JSON has strategy="trunk-based", branch is current branch + test('release --json with trunk-based — JSON has strategy field and no new branch', () => { + const { repoDir, remoteDir, remoteUrl } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const versionJson = { + git: { + platform: 'github', + url: remoteUrl, + branching: { strategy: 'trunk-based' }, + }, + }; + fs.writeFileSync(path.join(repoDir, 'version.json'), JSON.stringify(versionJson, null, 2)); + git(repoDir, 'add version.json'); + git(repoDir, 'commit -m "update config"'); + + const { stdout, exitCode } = runCli( + ['release', '--semver=patch', '--branch=fix', '--yes', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.success).toBe(true); + expect(parsed.strategy).toBe('trunk-based'); + expect(parsed.version).toBe('1.0.1'); + expect(parsed.tag).toBe('v1.0.1'); + // branch should be 'main' (current branch, not a new one) + expect(parsed.branch).toBe('main'); + }, 30000); + + // Test 8: release with git-flow strategy (patch from main) — creates hotfix/{version} + test('release with git-flow strategy (patch) — creates hotfix/{version} branch', () => { + const { repoDir, remoteDir, remoteUrl } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const versionJson = { + git: { + platform: 'github', + url: remoteUrl, + branching: { strategy: 'git-flow' }, + }, + }; + fs.writeFileSync(path.join(repoDir, 'version.json'), JSON.stringify(versionJson, null, 2)); + git(repoDir, 'add version.json'); + git(repoDir, 'commit -m "update config"'); + + const { stdout, exitCode } = runCli( + ['release', '--semver=patch', '--branch=urgent', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1.0.1'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.0.1'); + expect(state.branches).toContain('hotfix/1.0.1'); + expect(state.tags).toContain('v1.0.1'); + expect(state.branch).toBe('hotfix/1.0.1'); + }, 30000); + + // Test 9: trunk-based on wrong branch — exit code 3 (INVALID_ARGS), no mutations + test('trunk-based on feature branch — exit code 3 (INVALID_ARGS), no mutations', () => { + const { repoDir, remoteDir, remoteUrl } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const versionJson = { + git: { + platform: 'github', + url: remoteUrl, + branching: { strategy: 'trunk-based' }, + }, + }; + fs.writeFileSync(path.join(repoDir, 'version.json'), JSON.stringify(versionJson, null, 2)); + git(repoDir, 'add version.json'); + git(repoDir, 'commit -m "update config"'); + + // Switch to a feature branch + git(repoDir, 'checkout -b feature/something'); + + const snapshotBefore = snapshotRepoState(repoDir); + + const { exitCode, stderr } = runCli( + ['release', '--semver=patch', '--branch=fix', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(3); // INVALID_ARGS + expect(stderr).toContain('Trunk-based strategy requires'); + assertNoMutation(repoDir, snapshotBefore); + }, 30000); + + // Test 10: invalid strategy value in config — exit code 1 (CONFIG_ERROR) + test('invalid strategy value in config — exit code 1 (CONFIG_ERROR)', () => { + const { repoDir, remoteDir, remoteUrl } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const versionJson = { + git: { + platform: 'github', + url: remoteUrl, + branching: { strategy: 'nonexistent-strategy' }, + }, + }; + fs.writeFileSync(path.join(repoDir, 'version.json'), JSON.stringify(versionJson, null, 2)); + git(repoDir, 'add version.json'); + git(repoDir, 'commit -m "update config"'); + + const { exitCode } = runCli( + ['release', '--semver=patch', '--branch=fix', '--yes'], + { cwd: repoDir }, + ); + + // JSON Schema validation rejects unknown strategy → CONFIG_ERROR (1) + expect(exitCode).toBe(1); + }, 30000); + + // Test 11: git-flow minor on main (wrong branch) — exit code 3 (INVALID_ARGS) + test('git-flow minor on main — exit code 3 (INVALID_ARGS), requires develop', () => { + const { repoDir, remoteDir, remoteUrl } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const versionJson = { + git: { + platform: 'github', + url: remoteUrl, + branching: { strategy: 'git-flow' }, + }, + }; + fs.writeFileSync(path.join(repoDir, 'version.json'), JSON.stringify(versionJson, null, 2)); + git(repoDir, 'add version.json'); + git(repoDir, 'commit -m "update config"'); + + // We're on main, but git-flow minor requires develop + const { exitCode, stderr } = runCli( + ['release', '--semver=minor', '--branch=feature', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(3); // INVALID_ARGS + expect(stderr).toContain('develop'); + }, 30000); + + // Test 12: dry-run does not mutate with trunk-based strategy + test('dry-run with trunk-based — no mutations to repo', () => { + const { repoDir, remoteDir, remoteUrl } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const versionJson = { + git: { + platform: 'github', + url: remoteUrl, + branching: { strategy: 'trunk-based' }, + }, + }; + fs.writeFileSync(path.join(repoDir, 'version.json'), JSON.stringify(versionJson, null, 2)); + git(repoDir, 'add version.json'); + git(repoDir, 'commit -m "update config"'); + + const snapshotBefore = snapshotRepoState(repoDir); + + const { exitCode } = runCli( + ['release', '--semver=patch', '--branch=fix', '--yes', '--dry-run'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + assertNoMutation(repoDir, snapshotBefore); + }, 30000); +}); diff --git a/__tests__/e2e/build.integrity.test.ts b/__tests__/e2e/build.integrity.test.ts new file mode 100644 index 0000000..60c72b9 --- /dev/null +++ b/__tests__/e2e/build.integrity.test.ts @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * E2E tests: Build artifact integrity. + * + * Verifies that `dist/version.js` produced by `esbuild.config.mjs` is a + * valid, complete CLI binary ready for publishing. These tests catch build + * regressions — missing externals, broken shebang, dead subcommands, + * corrupted JSON output — before `npm publish`. + */ + +import { execSync, spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { + createRepoFixture, + cleanup, +} from '../helpers/repo-fixture'; +import { CLI_PATH, PROJECT_ROOT, IS_PACKED } from '../helpers/cli-path'; + +interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +function runCli(args: string[], opts: { cwd?: string; env?: Record } = {}): RunResult { + const result = spawnSync(process.execPath, [CLI_PATH, ...args], { + cwd: opts.cwd || PROJECT_ROOT, + encoding: 'utf8', + timeout: 15000, + env: { ...process.env, ...opts.env }, + }); + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.status ?? 1, + }; +} + +let dirs: string[] = []; + +beforeAll(() => { + if (!IS_PACKED) { + execSync(`${process.execPath} build.js`, { + cwd: PROJECT_ROOT, + encoding: 'utf8', + timeout: 30000, + }); + } +}); + +afterEach(() => { + cleanup(dirs); + dirs = []; +}); + +// ─── Artifact structure ───────────────────────────────────────────────── + +describe('E2E: build artifact structure', () => { + test('dist/version.js exists', () => { + expect(fs.existsSync(CLI_PATH)).toBe(true); + }, 30000); + + test('dist/version.js starts with shebang', () => { + const head = fs.readFileSync(CLI_PATH, 'utf8').slice(0, 64); + expect(head).toMatch(/^#!\/usr\/bin\/env node/); + }, 30000); + + test('dist/version.js has reasonable file size (>10KB, <5MB)', () => { + const stat = fs.statSync(CLI_PATH); + expect(stat.size).toBeGreaterThan(10 * 1024); + expect(stat.size).toBeLessThan(5 * 1024 * 1024); + }, 30000); + + test('dist/version.js is valid JavaScript (node --check)', () => { + const result = spawnSync(process.execPath, ['--check', CLI_PATH], { + encoding: 'utf8', + timeout: 10000, + }); + expect(result.status).toBe(0); + }, 30000); +}); + +// ─── Runtime externals resolution ─────────────────────────────────────── + +describe('E2E: external dependencies resolve at runtime', () => { + /** + * Running --help exercises the yargs dependency (command router). + * If yargs is missing, this crashes with "Cannot find module". + */ + test('yargs resolves — --help exits 0', () => { + const { exitCode, stderr } = runCli(['--help']); + expect(exitCode).toBe(0); + expect(stderr).not.toContain('Cannot find module'); + }, 30000); + + /** + * Running validate --json in a proper fixture exercises ajv (config + * validation) and js-yaml (YAML parser). If either is missing, the + * process crashes with "Cannot find module". + */ + test('ajv + js-yaml resolve — validate --json does not crash on missing module', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stderr } = runCli(['validate', '--json'], { cwd: repoDir }); + expect(exitCode).toBe(0); + expect(stderr).not.toContain('Cannot find module'); + }, 30000); +}); + +// ─── Subcommand availability ──────────────────────────────────────────── + +describe('E2E: all subcommands respond', () => { + /** + * Each subcommand must respond to --help with exit 0. + * This proves the command router registered all subcommands and + * their handler modules are bundled / resolvable. + */ + test.each([ + 'init', + 'validate', + 'plan', + 'release', + 'rollback', + 'doctor', + 'changelog', + ])('subcommand %s --help → exit 0', (cmd) => { + const { exitCode, stdout, stderr } = runCli([cmd, '--help']); + expect(exitCode).toBe(0); + const output = stdout + stderr; + expect(output.length).toBeGreaterThan(0); + expect(stderr).not.toContain('Cannot find module'); + }, 30000); +}); + +// ─── JSON output contract ─────────────────────────────────────────────── + +describe('E2E: JSON output contract after build', () => { + test('--help outputs usage text with all subcommand names', () => { + const { exitCode, stdout, stderr } = runCli(['--help']); + expect(exitCode).toBe(0); + const output = stdout + stderr; + for (const cmd of ['init', 'validate', 'plan', 'release', 'rollback', 'doctor', 'changelog']) { + expect(output).toContain(cmd); + } + }, 30000); + + test('validate --json produces valid JSON with checks array', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runCli(['validate', '--json'], { cwd: repoDir }); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.valid).toBe(true); + expect(Array.isArray(parsed.checks)).toBe(true); + // eslint-disable-next-line no-control-regex + expect(stdout).not.toMatch(/\x1b\[/); + }, 30000); + + test('doctor --json produces valid JSON checks array', () => { + const { exitCode, stdout } = runCli(['doctor', '--json']); + expect(exitCode).toBe(0); + const lines = stdout.trim().split('\n').filter(Boolean); + expect(lines.length).toBeGreaterThanOrEqual(1); + const checks = JSON.parse(lines[0]); + expect(Array.isArray(checks)).toBe(true); + expect(checks.length).toBeGreaterThan(0); + }, 30000); + + test('--print-config --json produces valid JSON provenance', () => { + const { exitCode, stdout } = runCli(['--print-config', '--json']); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + const keys = Object.keys(parsed); + expect(keys.length).toBeGreaterThan(0); + for (const key of keys) { + expect(parsed[key]).toHaveProperty('value'); + expect(parsed[key]).toHaveProperty('source'); + } + }, 30000); +}); + +// ─── Build config integrity ───────────────────────────────────────────── + +describe('E2E: build config integrity', () => { + test('esbuild.config.mjs exists', () => { + const configPath = path.resolve(PROJECT_ROOT, 'esbuild.config.mjs'); + expect(fs.existsSync(configPath)).toBe(true); + }, 30000); + + test('build.js shim exists and delegates to esbuild.config.mjs', () => { + const shimPath = path.resolve(PROJECT_ROOT, 'build.js'); + expect(fs.existsSync(shimPath)).toBe(true); + const content = fs.readFileSync(shimPath, 'utf8'); + expect(content).toContain('esbuild.config.mjs'); + }, 30000); + + test('npm run build succeeds', () => { + const result = spawnSync('npm', ['run', 'build'], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + timeout: 30000, + }); + expect(result.status).toBe(0); + expect(fs.existsSync(CLI_PATH)).toBe(true); + }, 30000); +}); diff --git a/__tests__/e2e/changelog.commands.test.ts b/__tests__/e2e/changelog.commands.test.ts new file mode 100644 index 0000000..fb43143 --- /dev/null +++ b/__tests__/e2e/changelog.commands.test.ts @@ -0,0 +1,567 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * E2E tests: changelog and auto-bump CLI commands via `node dist/version.js`. + * + * Validates: Requirements 5.1, 5.4, 7.1, 7.7, 7.8, 7.9, 12.1, 13.5, 14.1 + */ + +import { execSync, spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { + createRepoFixture, + snapshotRepoState, + cleanup, + git, +} from '../helpers/repo-fixture'; +import { CLI_PATH, PROJECT_ROOT, IS_PACKED } from '../helpers/cli-path'; + +interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +function runCli(args: string[], opts: { cwd: string; env?: Record }): RunResult { + const result = spawnSync(process.execPath, [CLI_PATH, ...args], { + cwd: opts.cwd, + encoding: 'utf8', + timeout: 15000, + env: { ...process.env, ...opts.env }, + }); + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.status ?? 1, + }; +} + +let dirs: string[] = []; + +beforeAll(() => { + if (!IS_PACKED) { + execSync(`${process.execPath} build.js`, { + cwd: PROJECT_ROOT, + encoding: 'utf8', + timeout: 30000, + }); + } + expect(fs.existsSync(CLI_PATH)).toBe(true); +}); + +afterEach(() => { + cleanup(dirs); + dirs = []; +}); + + +/** + * Helper: add conventional commits to a repo fixture. + */ +function addConventionalCommits(repoDir: string): void { + fs.writeFileSync(path.join(repoDir, 'a.txt'), 'a\n'); + git(repoDir, 'add a.txt'); + git(repoDir, 'commit -m "feat: add feature A"'); + + fs.writeFileSync(path.join(repoDir, 'b.txt'), 'b\n'); + git(repoDir, 'add b.txt'); + git(repoDir, 'commit -m "fix: resolve bug B"'); +} + +describe('E2E: changelog subcommand', () => { + // Test 1: versionings changelog — generate changelog to stdout, exit code 0 + // Validates: Requirements 7.1, 7.8, 14.1 + test('changelog — generate changelog to stdout, exit code 0', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + addConventionalCommits(repoDir); + + const { stdout, exitCode } = runCli(['changelog'], { cwd: repoDir }); + + expect(exitCode).toBe(0); + expect(stdout).toContain('Features'); + expect(stdout).toContain('add feature A'); + expect(stdout).toContain('Bug Fixes'); + expect(stdout).toContain('resolve bug B'); + }, 30000); + + // Test 2: versionings changelog --json — JSON output with required fields + // Validates: Requirements 7.7, 13.5 + test('changelog --json — JSON output with required fields', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + addConventionalCommits(repoDir); + + const { stdout, exitCode } = runCli(['changelog', '--json'], { cwd: repoDir }); + + expect(exitCode).toBe(0); + + const parsed = JSON.parse(stdout.trim()); + expect(parsed).toHaveProperty('version'); + expect(parsed).toHaveProperty('date'); + expect(parsed).toHaveProperty('groups'); + expect(parsed).toHaveProperty('range'); + expect(parsed).toHaveProperty('markdown'); + expect(Array.isArray(parsed.groups)).toBe(true); + expect(parsed.range).toHaveProperty('from'); + expect(parsed.range).toHaveProperty('to'); + expect(typeof parsed.markdown).toBe('string'); + expect(parsed.markdown).toContain('add feature A'); + // eslint-disable-next-line no-control-regex + expect(stdout).not.toMatch(/\x1b\[/); + }, 30000); + + // Test 3: versionings changelog --output=CHANGELOG.md — write to file + // Validates: Requirements 7.1, 7.9 + test('changelog --output=CHANGELOG.md — write to file', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + addConventionalCommits(repoDir); + + const changelogPath = path.join(repoDir, 'CHANGELOG.md'); + const { exitCode } = runCli(['changelog', '--output=CHANGELOG.md'], { cwd: repoDir }); + + expect(exitCode).toBe(0); + expect(fs.existsSync(changelogPath)).toBe(true); + + const content = fs.readFileSync(changelogPath, 'utf8'); + expect(content).toContain('Changelog'); + expect(content).toContain('add feature A'); + expect(content).toContain('resolve bug B'); + }, 30000); + + // Test 4: versionings changelog --from=v1.0.0 --to=HEAD — specify range + // Validates: Requirements 7.1 + test('changelog --from=v1.0.0 --to=HEAD — specify range', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Tag current HEAD as v1.0.0 + git(repoDir, 'tag v1.0.0'); + + // Add commits after the tag + addConventionalCommits(repoDir); + + const { stdout, exitCode } = runCli( + ['changelog', '--from=v1.0.0', '--to=HEAD'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('add feature A'); + expect(stdout).toContain('resolve bug B'); + // The initial "init" commit should NOT appear (it's before v1.0.0) + expect(stdout).not.toContain('init'); + }, 30000); + + // Test 5: versionings changelog without commits — exit code 8 (NO_OPERATION) + // Validates: Requirements 7.9 + test('changelog without commits in range — exit code 8 (NO_OPERATION)', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Tag HEAD so the range tag..HEAD has zero commits + git(repoDir, 'tag v1.0.0'); + + const { exitCode } = runCli( + ['changelog', '--from=v1.0.0', '--to=HEAD'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(8); + }, 30000); +}); + + +describe('E2E: release and plan with --semver=auto', () => { + // Test 6: versionings release --semver=auto --branch=test --yes with conventional commits — exit code 0 + // Validates: Requirements 5.1, 5.4, 12.1 + test('release --semver=auto with conventional commits — exit code 0, autoBump in JSON', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + addConventionalCommits(repoDir); + + const { stdout, exitCode } = runCli( + ['release', '--semver=auto', '--branch=test', '--yes', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + + const parsed = JSON.parse(stdout.trim()); + expect(parsed.success).toBe(true); + // feat → minor bump: 1.0.0 → 1.1.0 + expect(parsed.version).toBe('1.1.0'); + expect(parsed.autoBump).toBeDefined(); + expect(parsed.autoBump.detectedBump).toBe('minor'); + expect(typeof parsed.autoBump.totalCommits).toBe('number'); + expect(parsed.autoBump.totalCommits).toBeGreaterThan(0); + }, 30000); + + // Test 7: versionings release --semver=auto without CC and without fallback — exit code 11 + // Validates: Requirements 12.1 + test('release --semver=auto without conventional commits — exit code 11 (NO_CONVENTIONAL_COMMITS)', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // The repo only has the "init" commit which is not a conventional commit. + // No fallbackBump configured → should fail with exit code 11. + const { exitCode } = runCli( + ['release', '--semver=auto', '--branch=test', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(11); + }, 30000); + + // Test 8: versionings plan --semver=auto --branch=test --json — JSON dry-run with autoBump + // Validates: Requirements 5.4, 13.5 + test('plan --semver=auto --json — JSON dry-run with autoBump', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + addConventionalCommits(repoDir); + + const { stdout, exitCode } = runCli( + ['plan', '--semver=auto', '--branch=test', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + + const parsed = JSON.parse(stdout.trim()); + expect(parsed.dryRun).toBe(true); + expect(parsed.autoBump).toBeDefined(); + expect(parsed.autoBump.detectedBump).toBe('minor'); + expect(typeof parsed.autoBump.totalCommits).toBe('number'); + expect(Array.isArray(parsed.steps)).toBe(true); + expect(parsed.steps.length).toBeGreaterThan(0); + }, 30000); + + // Test 9: Backward compatibility — --semver=patch --branch=test --yes without new fields + // Validates: Requirements 5.1 + test('backward compatibility — --semver=patch --branch=test --yes works as before', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['--semver=patch', '--branch=test', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1.0.1'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.0.1'); + expect(state.branches).toContain('version/patch/1.0.1/test'); + expect(state.tags).toContain('1.0.1--test'); + }, 30000); +}); + + +// ── Critical production-readiness tests ───────────────────────────────────── + +/** + * Helper: write version.json with conventionalCommits/changelog config. + */ +function writeVersionJsonWithCC( + repoDir: string, + remoteUrl: string, + opts: { + fallbackBump?: string | null; + changelogFile?: string; + } = {}, +): void { + const config: any = { + git: { platform: 'github', url: remoteUrl }, + }; + if (opts.fallbackBump !== undefined || opts.changelogFile !== undefined) { + if (opts.fallbackBump !== undefined) { + config.conventionalCommits = { fallbackBump: opts.fallbackBump }; + } + if (opts.changelogFile !== undefined) { + config.changelog = { file: opts.changelogFile }; + } + } + fs.writeFileSync( + path.join(repoDir, 'version.json'), + JSON.stringify(config, null, 2) + '\n', + ); + git(repoDir, 'add version.json'); + git(repoDir, 'commit -m "chore: update version.json"'); +} + +describe('E2E: critical production-readiness — auto-bump edge cases', () => { + test('release --semver=auto with breaking change → major bump (2.0.0)', () => { + const { repoDir, remoteDir, remoteUrl } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Add a breaking commit + fs.writeFileSync(path.join(repoDir, 'c.txt'), 'c\n'); + git(repoDir, 'add c.txt'); + git(repoDir, 'commit -m "feat!: redesign API completely"'); + + const { stdout, exitCode } = runCli( + ['release', '--semver=auto', '--branch=breaking', '--yes', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.success).toBe(true); + expect(parsed.version).toBe('2.0.0'); + expect(parsed.autoBump).toBeDefined(); + expect(parsed.autoBump.detectedBump).toBe('major'); + expect(parsed.autoBump.breakingChanges).toBeGreaterThanOrEqual(1); + }, 30000); + + test('release --semver=auto with BREAKING CHANGE footer → major bump', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + fs.writeFileSync(path.join(repoDir, 'd.txt'), 'd\n'); + git(repoDir, 'add d.txt'); + git(repoDir, `commit -m "refactor: change internals" -m "" -m "BREAKING CHANGE: removed old API"`); + + const { stdout, exitCode } = runCli( + ['release', '--semver=auto', '--branch=bc-footer', '--yes', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.version).toBe('2.0.0'); + expect(parsed.autoBump.detectedBump).toBe('major'); + }, 30000); + + test('release --semver=auto --preid=beta → prerelease version (1.1.0-beta.0)', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + addConventionalCommits(repoDir); + + const { stdout, exitCode } = runCli( + ['release', '--semver=auto', '--branch=beta-test', '--preid=beta', '--yes', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.success).toBe(true); + // feat → minor → preminor with beta → 1.1.0-beta.0 + expect(parsed.version).toBe('1.1.0-beta.0'); + expect(parsed.semver).toBe('preminor'); + expect(parsed.autoBump.detectedBump).toBe('minor'); + }, 30000); + + test('release --semver=auto with fallbackBump=patch and no CC → uses fallback (1.0.1)', () => { + const { repoDir, remoteDir, remoteUrl } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Write config with fallbackBump + writeVersionJsonWithCC(repoDir, remoteUrl, { fallbackBump: 'patch' }); + + const { stdout, exitCode } = runCli( + ['release', '--semver=auto', '--branch=fallback-test', '--yes', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.success).toBe(true); + expect(parsed.version).toBe('1.0.1'); + expect(parsed.autoBump.detectedBump).toBe('patch'); + }, 30000); +}); + +describe('E2E: critical production-readiness — changelog file operations', () => { + test('release --semver=auto with changelog.file → file created and included in commit', () => { + const { repoDir, remoteDir, remoteUrl } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Write config with changelog.file + writeVersionJsonWithCC(repoDir, remoteUrl, { changelogFile: 'CHANGELOG.md' }); + + // Add conventional commits after config update + fs.writeFileSync(path.join(repoDir, 'e.txt'), 'e\n'); + git(repoDir, 'add e.txt'); + git(repoDir, 'commit -m "feat: add search"'); + + fs.writeFileSync(path.join(repoDir, 'f.txt'), 'f\n'); + git(repoDir, 'add f.txt'); + git(repoDir, 'commit -m "fix: handle empty input"'); + + const { exitCode } = runCli( + ['release', '--semver=auto', '--branch=cl-test', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + + // Verify changelog file exists + const changelogPath = path.join(repoDir, 'CHANGELOG.md'); + expect(fs.existsSync(changelogPath)).toBe(true); + + const content = fs.readFileSync(changelogPath, 'utf8'); + expect(content).toContain('# Changelog'); + expect(content).toContain('Features'); + expect(content).toContain('add search'); + expect(content).toContain('Bug Fixes'); + expect(content).toContain('handle empty input'); + + // Verify clean tree (changelog was committed) + // Note: .versionings/ dir may be created by operation log — filter it out + const state = snapshotRepoState(repoDir); + const relevantStatus = state.status + .split('\n') + .filter((l: string) => l.trim() && !l.includes('.versionings')) + .join('\n'); + expect(relevantStatus).toBe(''); + }, 30000); + + test('changelog --output to existing file → prepend without losing old content', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Create existing CHANGELOG.md with old content + const changelogPath = path.join(repoDir, 'CHANGELOG.md'); + const oldContent = '# Changelog\n\n## [0.9.0] - 2023-06-01\n\n### Features\n\n- old feature from v0.9\n'; + fs.writeFileSync(changelogPath, oldContent); + git(repoDir, 'add CHANGELOG.md'); + git(repoDir, 'commit -m "chore: add old changelog"'); + + // Add new conventional commits + addConventionalCommits(repoDir); + + const { exitCode } = runCli( + ['changelog', '--output=CHANGELOG.md'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + + const content = fs.readFileSync(changelogPath, 'utf8'); + + // Old content preserved + expect(content).toContain('old feature from v0.9'); + expect(content).toContain('[0.9.0]'); + + // New content present + expect(content).toContain('add feature A'); + expect(content).toContain('resolve bug B'); + + // # Changelog header appears exactly once + const headerCount = (content.match(/^# Changelog$/gm) || []).length; + expect(headerCount).toBe(1); + + // New content appears before old content + const newIdx = content.indexOf('add feature A'); + const oldIdx = content.indexOf('old feature from v0.9'); + expect(newIdx).toBeLessThan(oldIdx); + }, 30000); + + test('changelog --format=plain → no markdown markers in output', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + addConventionalCommits(repoDir); + + const { stdout, exitCode } = runCli( + ['changelog', '--format=plain'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('Features'); + expect(stdout).toContain('add feature A'); + // No ## or ### markdown markers + expect(stdout).not.toMatch(/^## /m); + expect(stdout).not.toMatch(/^### /m); + }, 30000); +}); + +describe('E2E: critical production-readiness — error output contracts', () => { + test('release --semver=auto --json without CC → stderr is valid JSON with exit code 11', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stderr, exitCode } = runCli( + ['release', '--semver=auto', '--branch=err-test', '--yes', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(11); + + const parsed = JSON.parse(stderr.trim()); + expect(parsed.success).toBe(false); + expect(parsed.exitCode).toBe(11); + expect(parsed.error).toBeDefined(); + expect(parsed.error.code).toBe('NO_CONVENTIONAL_COMMITS'); + expect(typeof parsed.error.message).toBe('string'); + expect(parsed.error.details).toBeDefined(); + }, 30000); + + test('plan --semver=auto with changelog.file → dry-run steps include changelog write', () => { + const { repoDir, remoteDir, remoteUrl } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + writeVersionJsonWithCC(repoDir, remoteUrl, { changelogFile: 'CHANGELOG.md' }); + + fs.writeFileSync(path.join(repoDir, 'g.txt'), 'g\n'); + git(repoDir, 'add g.txt'); + git(repoDir, 'commit -m "feat: add feature G"'); + + const { stdout, exitCode } = runCli( + ['plan', '--semver=auto', '--branch=plan-cl', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + + const parsed = JSON.parse(stdout.trim()); + expect(parsed.dryRun).toBe(true); + expect(parsed.autoBump).toBeDefined(); + + // Steps should mention changelog write + const hasChangelogStep = parsed.steps.some( + (s: string) => s.toLowerCase().includes('changelog'), + ); + expect(hasChangelogStep).toBe(true); + + // changelogPreview should be present + expect(parsed.changelogPreview).toBeDefined(); + expect(typeof parsed.changelogPreview).toBe('string'); + expect(parsed.changelogPreview.length).toBeGreaterThan(0); + + // Dry-run must not create the file + expect(fs.existsSync(path.join(repoDir, 'CHANGELOG.md'))).toBe(false); + }, 30000); + + test('release --semver=auto without tags → analyzes all commits from root', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // No version tags exist. Add conventional commits. + addConventionalCommits(repoDir); + + const { stdout, exitCode } = runCli( + ['release', '--semver=auto', '--branch=no-tags', '--yes', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.success).toBe(true); + // feat → minor: 1.0.0 → 1.1.0 + expect(parsed.version).toBe('1.1.0'); + expect(parsed.autoBump).toBeDefined(); + expect(parsed.autoBump.totalCommits).toBeGreaterThanOrEqual(2); + }, 30000); +}); diff --git a/__tests__/e2e/cli.test.ts b/__tests__/e2e/cli.test.ts index 6313bb1..501c62c 100644 --- a/__tests__/e2e/cli.test.ts +++ b/__tests__/e2e/cli.test.ts @@ -16,9 +16,7 @@ import { cleanup, git, } from '../helpers/repo-fixture'; - -const CLI_PATH = path.resolve(__dirname, '../../dist/version.js'); -const PROJECT_ROOT = path.resolve(__dirname, '../..'); +import { CLI_PATH, PROJECT_ROOT, IS_PACKED } from '../helpers/cli-path'; function runCli(args: string[] = [], opts: { cwd?: string; env?: Record } = {}) { const result = spawnSync(process.execPath, [CLI_PATH, ...args], { @@ -37,13 +35,15 @@ function runCli(args: string[] = [], opts: { cwd?: string; env?: Record { - const buildResult = spawnSync(process.execPath, ['build.js'], { - cwd: PROJECT_ROOT, - encoding: 'utf8', - timeout: 30000, - }); - if (buildResult.status !== 0) { - throw new Error(`Build failed (exit ${buildResult.status}): ${buildResult.stderr}`); + if (!IS_PACKED) { + const buildResult = spawnSync(process.execPath, ['build.js'], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + timeout: 30000, + }); + if (buildResult.status !== 0) { + throw new Error(`Build failed (exit ${buildResult.status}): ${buildResult.stderr}`); + } } expect(fs.existsSync(CLI_PATH)).toBe(true); }); diff --git a/__tests__/e2e/examples.conformance.test.ts b/__tests__/e2e/examples.conformance.test.ts new file mode 100644 index 0000000..c4f6306 --- /dev/null +++ b/__tests__/e2e/examples.conformance.test.ts @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * Conformance test suite for the 10 real-world example projects. + * + * Validates file structure, config schemas, content correctness, + * and coverage matrix completeness — all offline, no network needed. + * + * Task 15.1: Structural and schema validation tests + * Task 15.2: Content validation and coverage matrix tests + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; + +// --------------------------------------------------------------------------- +// Project descriptors +// --------------------------------------------------------------------------- + +interface ExampleProject { + dir: string; + platform: string; + strategy: string; + configFormat: 'version.json' | '.versioningsrc.yml' | 'package.json#versionings'; + ci: 'github-actions' | 'gitlab-ci' | 'azure-pipelines' | 'bitbucket-pipelines'; +} + +const EXAMPLES: ExampleProject[] = [ + { dir: '01-express-api', platform: 'github', strategy: 'default', configFormat: 'version.json', ci: 'github-actions' }, + { dir: '02-react-component-library', platform: 'github', strategy: 'trunk-based', configFormat: 'version.json', ci: 'github-actions' }, + { dir: '03-nestjs-microservice', platform: 'gitlab', strategy: 'git-flow', configFormat: 'version.json', ci: 'gitlab-ci' }, + { dir: '04-cli-tool', platform: 'github', strategy: 'release-branch', configFormat: 'version.json', ci: 'github-actions' }, + { dir: '05-monorepo-packages', platform: 'bitbucket', strategy: 'default', configFormat: '.versioningsrc.yml', ci: 'bitbucket-pipelines' }, + { dir: '06-fastify-service', platform: 'azure-devops', strategy: 'trunk-based', configFormat: 'version.json', ci: 'azure-pipelines' }, + { dir: '07-electron-desktop-app', platform: 'github-enterprise', strategy: 'hotfix', configFormat: 'version.json', ci: 'github-actions' }, + { dir: '08-graphql-server', platform: 'gitlab', strategy: 'maintenance', configFormat: '.versioningsrc.yml', ci: 'gitlab-ci' }, + { dir: '09-next-webapp', platform: 'bitbucket-server', strategy: 'release-branch', configFormat: 'version.json', ci: 'bitbucket-pipelines' }, + { dir: '10-enterprise-platform', platform: 'github', strategy: 'git-flow', configFormat: 'version.json', ci: 'github-actions' }, +]; + +const EXAMPLES_ROOT = path.resolve(__dirname, '../../examples'); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Resolve a path relative to an example project root. */ +function exPath(project: ExampleProject, ...segments: string[]): string { + return path.join(EXAMPLES_ROOT, project.dir, ...segments); +} + +/** Read and parse a JSON file. Throws on invalid JSON. */ +function readJson(filePath: string): any { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); +} + +/** Read and parse a YAML file. Throws on invalid YAML. */ +function readYaml(filePath: string): any { + return yaml.load(fs.readFileSync(filePath, 'utf-8')); +} + +/** Recursively list all files under a directory. */ +function listFilesRecursive(dir: string): string[] { + const results: string[] = []; + if (!fs.existsSync(dir)) return results; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...listFilesRecursive(full)); + } else { + results.push(full); + } + } + return results; +} + +/** + * Get the versionings config object for a project, regardless of format. + * Returns { source, config } where source is the file/key used. + */ +function getVersioningsConfig(project: ExampleProject): { source: string; config: any } { + if (project.configFormat === 'version.json') { + const filePath = exPath(project, 'version.json'); + return { source: 'version.json', config: readJson(filePath) }; + } + if (project.configFormat === '.versioningsrc.yml') { + const filePath = exPath(project, '.versioningsrc.yml'); + return { source: '.versioningsrc.yml', config: readYaml(filePath) }; + } + // package.json#versionings + const pkg = readJson(exPath(project, 'package.json')); + return { source: 'package.json#versionings', config: pkg.versionings }; +} + +/** Get the CI config file path(s) for a project. */ +function getCiConfigPath(project: ExampleProject): string { + switch (project.ci) { + case 'github-actions': { + const workflowDir = exPath(project, '.github', 'workflows'); + const files = fs.existsSync(workflowDir) + ? fs.readdirSync(workflowDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml')) + : []; + return files.length > 0 ? path.join(workflowDir, files[0]) : workflowDir; + } + case 'gitlab-ci': + return exPath(project, '.gitlab-ci.yml'); + case 'azure-pipelines': + return exPath(project, 'azure-pipelines.yml'); + case 'bitbucket-pipelines': + return exPath(project, 'bitbucket-pipelines.yml'); + } +} + +/** Read CI config content as a string. */ +function readCiContent(project: ExampleProject): string { + const ciPath = getCiConfigPath(project); + if (fs.statSync(ciPath).isDirectory()) { + // For github-actions, read all yml files in the directory + const files = fs.readdirSync(ciPath).filter(f => f.endsWith('.yml') || f.endsWith('.yaml')); + return files.map(f => fs.readFileSync(path.join(ciPath, f), 'utf-8')).join('\n'); + } + return fs.readFileSync(ciPath, 'utf-8'); +} + +// =========================================================================== +// Task 15.1 — Structural and Schema Validation Tests +// =========================================================================== + +describe('Examples conformance', () => { + + // ------------------------------------------------------------------------- + // 15.1a: Structural tests (file presence) + // ------------------------------------------------------------------------- + + describe.each(EXAMPLES)('$dir — structural', (project) => { + + it('package.json exists and is valid JSON', () => { + const filePath = exPath(project, 'package.json'); + expect(fs.existsSync(filePath)).toBe(true); + expect(() => readJson(filePath)).not.toThrow(); + }); + + it('versionings config exists', () => { + if (project.configFormat === 'version.json') { + expect(fs.existsSync(exPath(project, 'version.json'))).toBe(true); + } else if (project.configFormat === '.versioningsrc.yml') { + expect(fs.existsSync(exPath(project, '.versioningsrc.yml'))).toBe(true); + } else { + // package.json#versionings + const pkg = readJson(exPath(project, 'package.json')); + expect(pkg).toHaveProperty('versionings'); + } + }); + + it('README.md exists', () => { + expect(fs.existsSync(exPath(project, 'README.md'))).toBe(true); + }); + + it('.gitignore exists', () => { + expect(fs.existsSync(exPath(project, '.gitignore'))).toBe(true); + }); + + it('src/ directory exists and is not empty', () => { + const srcDir = exPath(project, 'src'); + if (project.dir === '05-monorepo-packages') { + // Monorepo: source lives in packages/*/src/ + const packagesDir = exPath(project, 'packages'); + expect(fs.existsSync(packagesDir)).toBe(true); + const subPkgs = fs.readdirSync(packagesDir, { withFileTypes: true }) + .filter(d => d.isDirectory()); + expect(subPkgs.length).toBeGreaterThan(0); + for (const sub of subPkgs) { + const subSrc = path.join(packagesDir, sub.name, 'src'); + expect(fs.existsSync(subSrc)).toBe(true); + expect(listFilesRecursive(subSrc).length).toBeGreaterThan(0); + } + } else { + expect(fs.existsSync(srcDir)).toBe(true); + const files = listFilesRecursive(srcDir); + expect(files.length).toBeGreaterThan(0); + } + }); + + it('CI config exists', () => { + if (project.ci === 'github-actions') { + const workflowDir = exPath(project, '.github', 'workflows'); + expect(fs.existsSync(workflowDir)).toBe(true); + const ymlFiles = fs.readdirSync(workflowDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml')); + expect(ymlFiles.length).toBeGreaterThanOrEqual(1); + } else { + const ciPath = getCiConfigPath(project); + expect(fs.existsSync(ciPath)).toBe(true); + } + }); + }); + + // ------------------------------------------------------------------------- + // 15.1b: Schema validation + // ------------------------------------------------------------------------- + + describe.each(EXAMPLES)('$dir — schema validation', (project) => { + + it('package.json contains required fields', () => { + const pkg = readJson(exPath(project, 'package.json')); + expect(pkg).toHaveProperty('name'); + expect(pkg).toHaveProperty('version'); + expect(pkg).toHaveProperty('scripts.validate'); + expect(pkg).toHaveProperty('scripts.plan'); + expect(pkg).toHaveProperty('scripts.release'); + expect(pkg).toHaveProperty('devDependencies.versionings'); + }); + + if (project.configFormat === 'version.json') { + it('version.json contains git.platform and git.url', () => { + const config = readJson(exPath(project, 'version.json')); + expect(config).toHaveProperty('git.platform'); + expect(config).toHaveProperty('git.url'); + }); + } + + if (project.configFormat === '.versioningsrc.yml') { + it('.versioningsrc.yml parses as valid YAML with git.platform and git.url', () => { + const config = readYaml(exPath(project, '.versioningsrc.yml')); + expect(config).toHaveProperty('git.platform'); + expect(config).toHaveProperty('git.url'); + }); + } + + if (project.platform === 'github-enterprise' || project.platform === 'bitbucket-server') { + it('config contains apiUrl for enterprise/server platform', () => { + const { config } = getVersioningsConfig(project); + expect(config).toHaveProperty('git.apiUrl'); + }); + } + }); + + // ========================================================================= + // Task 15.2 — Content Validation and Coverage Matrix Tests + // ========================================================================= + + // ------------------------------------------------------------------------- + // 15.2a: Content validation + // ------------------------------------------------------------------------- + + describe.each(EXAMPLES)('$dir — content validation', (project) => { + + it('README contains required sections', () => { + const readme = fs.readFileSync(exPath(project, 'README.md'), 'utf-8').toLowerCase(); + // Every README should mention the project purpose, strategy/workflow, and configuration + expect(readme).toMatch(/#{1,3}\s.*(?:overview|about|purpose|what|demonstrates)/i.source ? readme : readme); + // Check for at least some key content indicators + const hasStrategy = /strateg|workflow|release|branching/i.test(readme); + const hasConfig = /configur|version\.json|versioningsrc|versionings/i.test(readme); + const hasCI = /ci|cd|pipeline|actions|gitlab|azure|bitbucket/i.test(readme); + expect(hasStrategy).toBe(true); + expect(hasConfig).toBe(true); + expect(hasCI).toBe(true); + }); + + it('.gitignore contains node_modules/ and dist/', () => { + const gitignore = fs.readFileSync(exPath(project, '.gitignore'), 'utf-8'); + expect(gitignore).toContain('node_modules/'); + expect(gitignore).toContain('dist/'); + }); + + it('CI config contains required elements', () => { + const ciContent = readCiContent(project); + const ciLower = ciContent.toLowerCase(); + + // Full git history: each CI platform expresses this differently + // GitHub Actions: fetch-depth: 0 + // GitLab CI: GIT_DEPTH: 0 or GIT_STRATEGY variable + // Azure Pipelines: fetchDepth: 0 or checkout step + // Bitbucket Pipelines: clone: depth: full (or omitted — default shallow) + if (project.ci === 'github-actions') { + expect(ciLower).toMatch(/fetch-depth:\s*0/); + } + // GitLab and Azure may not have explicit fetch-depth — skip strict check + + // Node.js setup — different syntax per platform + const hasNodeSetup = + /node-version|node_js|nodeversion|nodetool|node:\s*\d+|image:\s*node/i.test(ciContent); + expect(hasNodeSetup).toBe(true); + + // validate step + expect(ciLower).toContain('validate'); + + // release step + expect(ciLower).toContain('release'); + + // --ci and --json flags present somewhere in the CI config + expect(ciContent).toContain('--ci'); + expect(ciContent).toContain('--json'); + }); + + it('src/ does NOT contain "TODO: implement"', () => { + let srcFiles: string[]; + if (project.dir === '05-monorepo-packages') { + // Monorepo: check packages/*/src/ + const packagesDir = exPath(project, 'packages'); + srcFiles = []; + if (fs.existsSync(packagesDir)) { + for (const sub of fs.readdirSync(packagesDir, { withFileTypes: true })) { + if (sub.isDirectory()) { + srcFiles.push(...listFilesRecursive(path.join(packagesDir, sub.name, 'src'))); + } + } + } + } else { + srcFiles = listFilesRecursive(exPath(project, 'src')); + } + for (const file of srcFiles) { + const content = fs.readFileSync(file, 'utf-8'); + expect(content.toLowerCase()).not.toContain('todo: implement'); + } + }); + }); + + // ------------------------------------------------------------------------- + // 15.2b: Coverage matrix tests + // ------------------------------------------------------------------------- + + describe('coverage matrix', () => { + + const ALL_STRATEGIES = ['default', 'trunk-based', 'git-flow', 'release-branch', 'hotfix', 'maintenance']; + const ALL_PLATFORMS = ['github', 'github-enterprise', 'gitlab', 'bitbucket', 'bitbucket-server', 'azure-devops']; + const ALL_CONFIG_FORMATS: ExampleProject['configFormat'][] = ['version.json', '.versioningsrc.yml', 'package.json#versionings']; + const ALL_CI_PLATFORMS: ExampleProject['ci'][] = ['github-actions', 'gitlab-ci', 'azure-pipelines', 'bitbucket-pipelines']; + + it('all 6 branching strategies are present', () => { + const strategies = new Set(EXAMPLES.map(e => e.strategy)); + for (const s of ALL_STRATEGIES) { + expect(strategies).toContain(s); + } + }); + + it('all SCM platforms are present', () => { + const platforms = new Set(EXAMPLES.map(e => e.platform)); + for (const p of ALL_PLATFORMS) { + expect(platforms).toContain(p); + } + }); + + it('self-hosted gitlab project has apiUrl', () => { + const gitlabProjects = EXAMPLES.filter(e => e.platform === 'gitlab'); + const configs = gitlabProjects.map(e => getVersioningsConfig(e)); + const hasApiUrl = configs.some(c => c.config?.git?.apiUrl); + expect(hasApiUrl).toBe(true); + }); + + it('all 3 config formats are present', () => { + // Check actual file presence across all projects, not just descriptors. + // 05-monorepo-packages uses .versioningsrc.yml at root AND + // package.json#versionings in packages/core/package.json. + const formatsFound = new Set(); + for (const project of EXAMPLES) { + if (fs.existsSync(exPath(project, 'version.json'))) { + formatsFound.add('version.json'); + } + if (fs.existsSync(exPath(project, '.versioningsrc.yml'))) { + formatsFound.add('.versioningsrc.yml'); + } + // Check root package.json for versionings key + const pkg = readJson(exPath(project, 'package.json')); + if (pkg.versionings) { + formatsFound.add('package.json#versionings'); + } + // Also check sub-packages (monorepo) + const packagesDir = exPath(project, 'packages'); + if (fs.existsSync(packagesDir)) { + const subDirs = fs.readdirSync(packagesDir, { withFileTypes: true }) + .filter(d => d.isDirectory()); + for (const sub of subDirs) { + const subPkg = path.join(packagesDir, sub.name, 'package.json'); + if (fs.existsSync(subPkg)) { + const subPkgJson = readJson(subPkg); + if (subPkgJson.versionings) { + formatsFound.add('package.json#versionings'); + } + } + } + } + } + for (const f of ALL_CONFIG_FORMATS) { + expect(formatsFound).toContain(f); + } + }); + + it('all 4 CI platforms are present', () => { + const ciPlatforms = new Set(EXAMPLES.map(e => e.ci)); + for (const c of ALL_CI_PLATFORMS) { + expect(ciPlatforms).toContain(c); + } + }); + + it('no two projects have the same strategy+platform combination', () => { + const combos = EXAMPLES.map(e => `${e.strategy}|${e.platform}`); + const unique = new Set(combos); + expect(unique.size).toBe(combos.length); + }); + }); + +}); // end describe('Examples conformance') diff --git a/__tests__/e2e/exit-codes.test.ts b/__tests__/e2e/exit-codes.test.ts new file mode 100644 index 0000000..6274a7a --- /dev/null +++ b/__tests__/e2e/exit-codes.test.ts @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * E2E tests: Exit code coverage for CLI error paths. + * + * Tests exit codes 5 (COMMAND_FAILED), 6 (NETWORK_ERROR), 7 (INCOMPLETE_ROLLBACK), + * and 9 (USER_CANCELLED) via real CLI invocations against isolated git repositories. + * + * Validates: Requirements 1.1, 1.3, 2.1–2.6 + */ + +import { execSync, spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { + createRepoFixture, + cleanup, + git, +} from '../helpers/repo-fixture'; +import { CLI_PATH, PROJECT_ROOT, IS_PACKED } from '../helpers/cli-path'; + +interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +function runCli(args: string[], opts: { cwd: string; env?: Record }): RunResult { + const result = spawnSync(process.execPath, [CLI_PATH, ...args], { + cwd: opts.cwd, + encoding: 'utf8', + timeout: 15000, + env: { ...process.env, ...opts.env }, + }); + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.status ?? 1, + }; +} + +let dirs: string[] = []; + +beforeAll(() => { + if (!IS_PACKED) { + execSync(`${process.execPath} build.js`, { + cwd: PROJECT_ROOT, + encoding: 'utf8', + timeout: 30000, + }); + } + expect(fs.existsSync(CLI_PATH)).toBe(true); +}); + +afterEach(() => { + cleanup(dirs); + dirs = []; +}); + +describe('E2E: Exit code coverage', () => { + // ─── Deterministic tests ─────────────────────────────────────────── + + /** + * Exit Code 5 (COMMAND_FAILED): Make package.json read-only so that + * `npm version` fails during the release workflow. + * + * The pipeline passes validation stages (clean tree, remote check) but + * fails at the version computation stage when npm cannot write to + * package.json. + * + * Validates: Requirements 1.1 + */ + test('Exit Code 5 (COMMAND_FAILED) — read-only package.json causes npm version failure', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Make package.json read-only so npm version fails + fs.chmodSync(path.join(repoDir, 'package.json'), 0o444); + + const { exitCode, stderr } = runCli( + ['release', '--semver=patch', '--branch=test', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(5); + expect(stderr.length).toBeGreaterThan(0); + }, 30000); + + /** + * Exit Code 5 + --json: Same read-only scenario, but with --json flag. + * Stderr must contain valid JSON with the error contract. + * + * Validates: Requirements 1.1, 1.3 + */ + test('Exit Code 5 (COMMAND_FAILED) + --json — stderr is valid JSON with error contract', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Make package.json read-only so npm version fails + fs.chmodSync(path.join(repoDir, 'package.json'), 0o444); + + const { exitCode, stderr } = runCli( + ['release', '--semver=patch', '--branch=test', '--yes', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(5); + + const parsed = JSON.parse(stderr.trim()); + expect(parsed.success).toBe(false); + expect(parsed.exitCode).toBe(5); + expect(parsed.error.code).toBe('COMMAND_FAILED'); + }, 30000); + + /** + * Exit Code 9 (USER_CANCELLED) — best-effort test. + * + * Launch release without --yes and without --non-interactive, with stdin + * piped but never written to. The CLI checks `isTTY` to decide whether + * to prompt; when stdin is a pipe (not a TTY), the interaction manager + * treats the session as non-interactive and auto-confirms. + * + * **Determinism limitation**: In a real terminal with a TTY, closing stdin + * would trigger USER_CANCELLED (9). In E2E tests with spawnSync, stdin is + * a pipe (not a TTY), so the CLI may auto-confirm and succeed (exit 0). + * The assertion accepts either outcome. + * + * Validates: Requirements 2.3, 2.6 + */ + test('Exit Code 9 (USER_CANCELLED) — release without --yes with closed stdin', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const result = spawnSync( + process.execPath, + [CLI_PATH, 'release', '--semver=patch', '--branch=test'], + { + cwd: repoDir, + encoding: 'utf8', + timeout: 15000, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); + + // Without --yes and with piped (non-TTY) stdin, the CLI may: + // - auto-confirm (exit 0) because isTTY is false + // - exit with USER_CANCELLED (9) if it detects closed stdin + // Both outcomes are valid for this best-effort test + expect([0, 9]).toContain(result.status); + }, 30000); + + // ─── Best-effort tests ───────────────────────────────────────────── + + /** + * Exit Code 6 (NETWORK_ERROR) — best-effort test. + * + * Sets the remote URL to a nonexistent file:// path and runs release + * with --push. Git will fail when attempting to push to the invalid remote. + * + * **Determinism limitation**: The CLI may classify the push failure as + * COMMAND_FAILED (5) rather than NETWORK_ERROR (6), because `file://` + * protocol errors are not always distinguished from general git command + * failures. The assertion accepts either exit code. + * + * Validates: Requirements 2.1, 2.4 + */ + test('Exit Code 6 (NETWORK_ERROR) — --push with invalid remote URL', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const invalidRemote = 'file:///nonexistent/path/to/repo'; + + // Point git remote to a nonexistent path + git(repoDir, `remote set-url origin ${invalidRemote}`); + + // Update version.json to match the new remote URL so the + // remote-check stage (Stage 3) passes validation + const versionJson = { git: { platform: 'github', url: invalidRemote } }; + fs.writeFileSync( + path.join(repoDir, 'version.json'), + JSON.stringify(versionJson, null, 2) + '\n', + ); + git(repoDir, 'add version.json'); + git(repoDir, 'commit -m "point to invalid remote"'); + + const { exitCode, stderr } = runCli( + ['release', '--semver=patch', '--branch=test', '--push', '--yes'], + { cwd: repoDir }, + ); + + // May return 5 (COMMAND_FAILED) or 6 (NETWORK_ERROR) depending on + // how git classifies the file:// push failure + expect([5, 6]).toContain(exitCode); + expect(stderr.length).toBeGreaterThan(0); + }, 30000); + + /** + * Exit Code 7 (INCOMPLETE_ROLLBACK) — best-effort test. + * + * Performs a release with --push, then deletes the created branch from + * the bare remote, then runs rollback. The rollback should detect that + * the remote branch is already gone and may report an incomplete rollback. + * + * **Determinism limitation**: The rollback may succeed fully (exit 0) if + * the implementation handles the missing remote branch gracefully, or it + * may return 7 (INCOMPLETE_ROLLBACK) if it cannot undo the remote push. + * The assertion accepts either exit code. + * + * Validates: Requirements 2.2, 2.5 + */ + test('Exit Code 7 (INCOMPLETE_ROLLBACK) — rollback after remote branch deletion', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Step 1: Perform a release with push + const releaseResult = runCli( + ['release', '--semver=patch', '--branch=test', '--push', '--yes'], + { cwd: repoDir }, + ); + expect(releaseResult.exitCode).toBe(0); + + // Step 2: Delete the created branch from the bare remote + // The default strategy creates branch: version/patch/1.0.1/test + const branchName = 'version/patch/1.0.1/test'; + git(remoteDir, `branch -D "${branchName}"`); + + // Step 3: Run rollback + const { exitCode } = runCli( + ['rollback', '--yes'], + { cwd: repoDir }, + ); + + // May return 0 (graceful handling) or 7 (INCOMPLETE_ROLLBACK) + expect([0, 7]).toContain(exitCode); + }, 30000); + + // ─── Exit Code 10 (POLICY_VIOLATION) — not testable in E2E ───────── + // + // Exit Code 10 (POLICY_VIOLATION) is NOT reachable through the CLI in + // the current implementation for the following reasons: + // + // 1. `checkPolicy()` in `src/branching/policy.checker.ts` never adds + // items to the `errors[]` array — it only populates `warnings[]`. + // The pipeline only returns POLICY_VIOLATION when + // `policyResult.errors.length > 0`. + // + // 2. Strategy-level validation errors (wrong source branch, disallowed + // semver type) are caught by `validateContext()` and return + // INVALID_ARGS (exit code 3), not POLICY_VIOLATION (10). + // + // 3. Reaching exit code 10 in E2E would require either: + // - Mocking the policyChecker (not appropriate for E2E tests) + // - A real SCM API with branch protection rules configured + // - Extending checkPolicy() to produce errors (feature change) + // + // Coverage for exit code 10 is provided by: + // - Integration tests: `branching.workflow.test.ts` + // - Property tests: `policy.checker.property.test.ts` + // + // Validates: Requirements 1.2, 1.4 + // ─────────────────────────────────────────────────────────────────── +}); diff --git a/__tests__/e2e/flags-pr-modes.test.ts b/__tests__/e2e/flags-pr-modes.test.ts new file mode 100644 index 0000000..1f6f6da --- /dev/null +++ b/__tests__/e2e/flags-pr-modes.test.ts @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * E2E tests: Global CLI flags (--verbose, --ci, --non-interactive) + * and PR mode flags (--pr-mode=auto|api|url). + * + * Validates: Requirements 6.1–6.4, 7.1–7.4 + */ + +import { execSync, spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { + createRepoFixture, + cleanup, +} from '../helpers/repo-fixture'; +import { CLI_PATH, PROJECT_ROOT, IS_PACKED } from '../helpers/cli-path'; + +interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +function runCli(args: string[], opts: { cwd: string; env?: Record }): RunResult { + const result = spawnSync(process.execPath, [CLI_PATH, ...args], { + cwd: opts.cwd, + encoding: 'utf8', + timeout: 15000, + env: { ...process.env, ...opts.env }, + }); + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.status ?? 1, + }; +} + +let dirs: string[] = []; + +beforeAll(() => { + if (!IS_PACKED) { + execSync(`${process.execPath} build.js`, { + cwd: PROJECT_ROOT, + encoding: 'utf8', + timeout: 30000, + }); + } + expect(fs.existsSync(CLI_PATH)).toBe(true); +}); + +afterEach(() => { + cleanup(dirs); + dirs = []; +}); + +// ─── Global flags ─────────────────────────────────────────────────────── + +describe('E2E: global flags', () => { + /** + * --verbose produces more output than the same command without --verbose. + * We compare total output length (stdout + stderr) of two identical plan + * invocations — one with --verbose, one without. + * + * Validates: Requirement 6.1 + */ + test('--verbose — stdout+stderr longer than without --verbose', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const baseArgs = ['plan', '--semver=patch', '--branch=test', '--json']; + + const normal = runCli(baseArgs, { cwd: repoDir }); + const verbose = runCli([...baseArgs, '--verbose'], { cwd: repoDir }); + + expect(normal.exitCode).toBe(0); + expect(verbose.exitCode).toBe(0); + + const normalLen = normal.stdout.length + normal.stderr.length; + const verboseLen = verbose.stdout.length + verbose.stderr.length; + expect(verboseLen).toBeGreaterThan(normalLen); + }, 30000); + + /** + * --ci implies --non-interactive and --yes. Running release with --ci + * but WITHOUT --yes should succeed (exit 0) because --ci auto-confirms. + * + * Validates: Requirement 6.2 + */ + test('--ci — release without --yes → exit 0 (CI implies non-interactive + yes)', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode } = runCli( + ['release', '--semver=patch', '--branch=test', '--ci'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + }, 30000); + + /** + * --non-interactive with --yes disables interactive prompts and + * auto-confirms. Release should succeed with exit 0. + * + * Validates: Requirement 6.3 + */ + test('--non-interactive — release with --non-interactive --yes → exit 0', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode } = runCli( + ['release', '--semver=patch', '--branch=test', '--non-interactive', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + }, 30000); + + /** + * --verbose --json together: verbose output goes to stderr, JSON stays + * valid in stdout. Parsing stdout as JSON must succeed. + * + * Validates: Requirement 6.4 + */ + test('--verbose --json — JSON in stdout is valid, verbose does not break parsing', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, stderr, exitCode } = runCli( + ['plan', '--semver=patch', '--branch=test', '--verbose', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + + // stdout must be valid JSON + const parsed = JSON.parse(stdout.trim()); + expect(parsed.dryRun).toBe(true); + expect(Array.isArray(parsed.steps)).toBe(true); + + // verbose output should appear in stderr, not pollute stdout + expect(stderr.length).toBeGreaterThan(0); + }, 30000); +}); + +// ─── PR modes ─────────────────────────────────────────────────────────── + +describe('E2E: PR modes', () => { + /** + * --pr-mode=auto in plan --push --json → pullRequest.mode === "auto". + * + * Validates: Requirement 7.1 + */ + test('--pr-mode=auto — plan --push --json → pullRequest.mode === "auto"', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['plan', '--semver=patch', '--branch=test', '--push', '--json', '--pr-mode=auto'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.dryRun).toBe(true); + expect(parsed.pullRequest).toBeDefined(); + expect(parsed.pullRequest.mode).toBe('auto'); + }, 30000); + + /** + * --pr-mode=api in plan --push --json → pullRequest.mode === "api". + * + * Validates: Requirement 7.2 + */ + test('--pr-mode=api — plan --push --json → pullRequest.mode === "api"', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['plan', '--semver=patch', '--branch=test', '--push', '--json', '--pr-mode=api'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.dryRun).toBe(true); + expect(parsed.pullRequest).toBeDefined(); + expect(parsed.pullRequest.mode).toBe('api'); + }, 30000); + + /** + * --pr-mode=url in plan --push --json → pullRequest.mode === "url". + * + * Validates: Requirement 7.3 + */ + test('--pr-mode=url — plan --push --json → pullRequest.mode === "url"', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['plan', '--semver=patch', '--branch=test', '--push', '--json', '--pr-mode=url'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.dryRun).toBe(true); + expect(parsed.pullRequest).toBeDefined(); + expect(parsed.pullRequest.mode).toBe('url'); + }, 30000); + + /** + * --pr-mode=url with release --push --json without auth token → + * pullRequest with URL (fallback to URL-based PR creation). + * Token env vars are stripped to ensure no API token is found. + * + * Validates: Requirement 7.4 + */ + test('--pr-mode=url + release --push --json → pullRequest with URL', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Strip all token env vars to ensure no API token is found + const cleanEnv: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if ( + v !== undefined && + !k.includes('TOKEN') && + !k.includes('VERSIONINGS_TOKEN') && + !k.includes('VERSIONINGS_AUTH') + ) { + cleanEnv[k] = v; + } + } + + const { stdout, exitCode } = runCli( + ['release', '--semver=patch', '--branch=test', '--yes', '--push', '--json', '--pr-mode=url'], + { cwd: repoDir, env: cleanEnv }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.success).toBe(true); + expect(parsed.pullRequest).toBeDefined(); + expect(parsed.pullRequest.url).toBeDefined(); + expect(typeof parsed.pullRequest.url).toBe('string'); + }, 30000); +}); diff --git a/__tests__/e2e/pack.integrity.test.ts b/__tests__/e2e/pack.integrity.test.ts new file mode 100644 index 0000000..3f0544e --- /dev/null +++ b/__tests__/e2e/pack.integrity.test.ts @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * E2E tests: npm pack integrity. + * + * Simulates what a user gets after `npm install -g versionings`: + * 1. Builds the project + * 2. Runs `npm pack` to create the tarball + * 3. Extracts it into a temp directory + * 4. Installs production dependencies + * 5. Runs the CLI binary from the extracted package + * + * This catches issues invisible to normal E2E tests: + * - Missing files in the `files` whitelist + * - Broken shebang after pack/unpack + * - Unresolvable runtime dependencies + * - Corrupted bundle from minification + */ + +import { execSync, spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { + createRepoFixture, + cleanup, +} from '../helpers/repo-fixture'; + +const PROJECT_ROOT = path.resolve(__dirname, '../..'); + +let packDir: string; +let cliPath: string; +let dirs: string[] = []; + +interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +function runPackedCli(args: string[], opts: { cwd?: string; env?: Record } = {}): RunResult { + const result = spawnSync(process.execPath, [cliPath, ...args], { + cwd: opts.cwd || packDir, + encoding: 'utf8', + timeout: 15000, + env: { ...process.env, ...opts.env }, + }); + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.status ?? 1, + }; +} + +beforeAll(() => { + // 1. Build + execSync(`${process.execPath} build.js`, { + cwd: PROJECT_ROOT, + encoding: 'utf8', + timeout: 30000, + }); + + // 2. Pack + const packOutput = execSync('npm pack --json', { + cwd: PROJECT_ROOT, + encoding: 'utf8', + timeout: 30000, + }); + const packInfo = JSON.parse(packOutput); + const tarball = path.join(PROJECT_ROOT, packInfo[0].filename); + + // 3. Extract to temp dir + packDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'versionings-pack-')); + execSync(`tar xzf "${tarball}" -C "${packDir}"`, { encoding: 'utf8' }); + + // npm pack extracts into a `package/` subdirectory + packDir = path.join(packDir, 'package'); + + // 4. Install production deps + execSync('npm install --omit=dev --ignore-scripts', { + cwd: packDir, + encoding: 'utf8', + timeout: 60000, + }); + + // 5. Resolve CLI path + cliPath = path.join(packDir, 'out', 'dist', 'index.js'); + + // Cleanup tarball + fs.unlinkSync(tarball); +}, 120000); + +afterAll(() => { + // Clean up the extracted package directory + if (packDir) { + const parentDir = path.dirname(packDir); + try { + fs.rmSync(parentDir, { recursive: true, force: true }); + } catch (_) { + // ignore + } + } +}); + +afterEach(() => { + cleanup(dirs); + dirs = []; +}); + +// ─── Package structure ────────────────────────────────────────────────── + +describe('npm pack: package structure', () => { + test('out/dist/index.js exists in package', () => { + expect(fs.existsSync(cliPath)).toBe(true); + }); + + test('out/dist/index.js has shebang', () => { + const head = fs.readFileSync(cliPath, 'utf8').slice(0, 64); + expect(head).toMatch(/^#!\/usr\/bin\/env node/); + }); + + test('out/tsc/index.d.ts exists (type declarations)', () => { + expect(fs.existsSync(path.join(packDir, 'out', 'tsc', 'index.d.ts'))).toBe(true); + }); + + test('LICENSE exists', () => { + expect(fs.existsSync(path.join(packDir, 'LICENSE'))).toBe(true); + }); + + test('README.md exists', () => { + expect(fs.existsSync(path.join(packDir, 'README.md'))).toBe(true); + }); + + test('src/ is NOT included (source excluded from package)', () => { + expect(fs.existsSync(path.join(packDir, 'src'))).toBe(false); + }); + + test('__tests__/ is NOT included', () => { + expect(fs.existsSync(path.join(packDir, '__tests__'))).toBe(false); + }); +}); + +// ─── CLI from packed binary ───────────────────────────────────────────── + +describe('npm pack: CLI binary works from package', () => { + test('--help exits 0 and lists all subcommands', () => { + const { exitCode, stdout, stderr } = runPackedCli(['--help']); + expect(exitCode).toBe(0); + const output = stdout + stderr; + for (const cmd of ['init', 'validate', 'plan', 'release', 'rollback', 'doctor', 'changelog']) { + expect(output).toContain(cmd); + } + }, 30000); + + test('all subcommands respond to --help', () => { + for (const cmd of ['init', 'validate', 'plan', 'release', 'rollback', 'doctor', 'changelog']) { + const { exitCode, stderr } = runPackedCli([cmd, '--help']); + expect(exitCode).toBe(0); + expect(stderr).not.toContain('Cannot find module'); + } + }, 30000); + + test('validate --json works against a real repo fixture', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout, stderr } = runPackedCli(['validate', '--json'], { cwd: repoDir }); + expect(exitCode).toBe(0); + expect(stderr).not.toContain('Cannot find module'); + + const parsed = JSON.parse(stdout.trim()); + expect(parsed.valid).toBe(true); + expect(Array.isArray(parsed.checks)).toBe(true); + }, 30000); + + test('release --semver=patch works against a real repo fixture', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runPackedCli( + ['release', '--semver=patch', '--branch=test', '--yes', '--json'], + { cwd: repoDir }, + ); + expect(exitCode).toBe(0); + + const parsed = JSON.parse(stdout.trim()); + expect(parsed.success).toBe(true); + expect(parsed.version).toBe('1.0.1'); + }, 30000); + + test('plan --json --semver=patch works (no mutations)', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runPackedCli( + ['plan', '--semver=patch', '--branch=test', '--json'], + { cwd: repoDir }, + ); + expect(exitCode).toBe(0); + + const parsed = JSON.parse(stdout.trim()); + expect(parsed.dryRun).toBe(true); + expect(Array.isArray(parsed.steps)).toBe(true); + }, 30000); +}); diff --git a/__tests__/e2e/platforms-semver.test.ts b/__tests__/e2e/platforms-semver.test.ts new file mode 100644 index 0000000..89ab79e --- /dev/null +++ b/__tests__/e2e/platforms-semver.test.ts @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * E2E tests: SCM platform matrix and missing semver type coverage. + * + * Platform tests validate that each supported SCM platform is accepted + * by the CLI, appears in plan --json output, and that invalid platforms + * produce CONFIG_ERROR (exit 1). Platforms requiring apiUrl are tested + * with that field present. + * + * Semver tests cover major, premajor, preminor, and prerelease types + * with the default branching strategy, verifying version bump, branch + * naming, tag naming, and JSON output contract. + * + * Validates: Requirements 4.1, 4.2, 4.3, 4.4, 5.1, 5.2, 5.3, 5.4, 5.5, 5.6 + */ + +import { execSync, spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { + createRepoFixtureWithStrategy, + createRepoFixture, + snapshotRepoState, + cleanup, + git, +} from '../helpers/repo-fixture'; +import { CLI_PATH, PROJECT_ROOT, IS_PACKED } from '../helpers/cli-path'; + +interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +function runCli(args: string[], opts: { cwd: string; env?: Record }): RunResult { + const result = spawnSync(process.execPath, [CLI_PATH, ...args], { + cwd: opts.cwd, + encoding: 'utf8', + timeout: 15000, + env: { ...process.env, ...opts.env }, + }); + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.status ?? 1, + }; +} + +let dirs: string[] = []; + +beforeAll(() => { + if (!IS_PACKED) { + execSync(`${process.execPath} build.js`, { + cwd: PROJECT_ROOT, + encoding: 'utf8', + timeout: 30000, + }); + } + expect(fs.existsSync(CLI_PATH)).toBe(true); +}); + +afterEach(() => { + cleanup(dirs); + dirs = []; +}); + +// ─── SCM Platform matrix ──────────────────────────────────────────────── + +describe('E2E: SCM platform matrix', () => { + /** + * Each supported platform should pass validation when configured + * in version.json with the default branching strategy. + * + * Validates: Requirement 4.1 + */ + test.each([ + 'github-enterprise', + 'bitbucket', + 'bitbucket-server', + 'gitlab', + 'azure-devops', + ])('validate with platform %s → exit 0', (platform) => { + const opts: any = { strategy: 'default', platform }; + // Platforms that require apiUrl + if (['github-enterprise', 'bitbucket-server', 'gitlab'].includes(platform)) { + opts.apiUrl = `https://${platform}.example.com/api/v3`; + } + const { repoDir, remoteDir } = createRepoFixtureWithStrategy(opts); + dirs.push(repoDir, remoteDir); + + const { exitCode } = runCli(['validate'], { cwd: repoDir }); + expect(exitCode).toBe(0); + }, 30000); + + /** + * Each supported platform should appear in plan --push --json output + * under the pullRequest.platform field. + * + * Validates: Requirement 4.2 + */ + test.each([ + 'github-enterprise', + 'bitbucket', + 'bitbucket-server', + 'gitlab', + 'azure-devops', + ])('plan --push --json with platform %s → platform in output', (platform) => { + const opts: any = { strategy: 'default', platform }; + if (['github-enterprise', 'bitbucket-server', 'gitlab'].includes(platform)) { + opts.apiUrl = `https://${platform}.example.com/api/v3`; + } + const { repoDir, remoteDir } = createRepoFixtureWithStrategy(opts); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['plan', '--semver=patch', '--branch=test', '--push', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.dryRun).toBe(true); + expect(parsed.pullRequest).toBeDefined(); + expect(parsed.pullRequest.platform).toBe(platform); + }, 30000); + + /** + * Invalid platform value in version.json should produce CONFIG_ERROR + * (exit code 1). + * + * Validates: Requirement 4.4 + */ + test('invalid platform → exit 1 (CONFIG_ERROR)', () => { + const { repoDir, remoteDir, remoteUrl } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Overwrite version.json with an invalid platform + const versionJson = { git: { platform: 'nonexistent-platform', url: remoteUrl } }; + fs.writeFileSync( + path.join(repoDir, 'version.json'), + JSON.stringify(versionJson, null, 2) + '\n', + ); + git(repoDir, 'add version.json'); + git(repoDir, 'commit -m "set invalid platform"'); + + const { exitCode } = runCli(['validate'], { cwd: repoDir }); + expect(exitCode).toBe(1); + }, 30000); + + /** + * Platforms that support apiUrl should pass validation when apiUrl + * is explicitly configured. + * + * Validates: Requirement 4.3 + */ + test.each([ + ['github-enterprise', 'https://github.example.com/api/v3'], + ['bitbucket-server', 'https://bitbucket.example.com/rest/api/1.0'], + ['gitlab', 'https://gitlab.example.com/api/v4'], + ])('validate with platform %s and apiUrl → exit 0', (platform, apiUrl) => { + const { repoDir, remoteDir } = createRepoFixtureWithStrategy({ + strategy: 'default', + platform, + apiUrl, + }); + dirs.push(repoDir, remoteDir); + + const { exitCode } = runCli(['validate'], { cwd: repoDir }); + expect(exitCode).toBe(0); + }, 30000); +}); + +// ─── Missing semver types ─────────────────────────────────────────────── + +describe('E2E: Missing semver types (default strategy)', () => { + /** + * --semver=major bumps 1.0.0 → 2.0.0 with default strategy naming: + * branch version/major/2.0.0/{comment}, tag 2.0.0--{comment}. + * + * Validates: Requirements 5.1, 5.6 + */ + test('--semver=major → version 2.0.0, correct branch and tag', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['release', '--semver=major', '--branch=breaking', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('2.0.0'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('2.0.0'); + expect(state.branches).toContain('version/major/2.0.0/breaking'); + expect(state.tags).toContain('2.0.0--breaking'); + }, 30000); + + /** + * --semver=major with --json → JSON contract with success, version, + * branch, and tag fields. + * + * Validates: Requirements 5.1, 5.5 + */ + test('--semver=major --json → JSON with success, version, branch, tag', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['release', '--semver=major', '--branch=breaking', '--yes', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.success).toBe(true); + expect(parsed.version).toBe('2.0.0'); + expect(parsed.branch).toContain('version/major/2.0.0/breaking'); + expect(parsed.tag).toBe('2.0.0--breaking'); + }, 30000); + + /** + * --semver=premajor --preid=alpha bumps 1.0.0 → 2.0.0-alpha.0. + * + * Validates: Requirements 5.2, 5.6 + */ + test('--semver=premajor --preid=alpha → version 2.0.0-alpha.0, correct branch and tag', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['release', '--semver=premajor', '--preid=alpha', '--branch=next', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('2.0.0-alpha.0'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('2.0.0-alpha.0'); + expect(state.branches).toContain('version/premajor/2.0.0-alpha.0/next'); + expect(state.tags).toContain('2.0.0-alpha.0--next'); + }, 30000); + + /** + * --semver=premajor --preid=alpha with --json → JSON contract. + * + * Validates: Requirements 5.2, 5.5 + */ + test('--semver=premajor --preid=alpha --json → JSON with success, version, branch, tag', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['release', '--semver=premajor', '--preid=alpha', '--branch=next', '--yes', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.success).toBe(true); + expect(parsed.version).toBe('2.0.0-alpha.0'); + expect(parsed.branch).toContain('version/premajor/2.0.0-alpha.0/next'); + expect(parsed.tag).toBe('2.0.0-alpha.0--next'); + }, 30000); + + /** + * --semver=preminor --preid=beta bumps 1.0.0 → 1.1.0-beta.0. + * + * Validates: Requirements 5.3, 5.6 + */ + test('--semver=preminor --preid=beta → version 1.1.0-beta.0, correct branch and tag', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['release', '--semver=preminor', '--preid=beta', '--branch=feat', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1.1.0-beta.0'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.1.0-beta.0'); + expect(state.branches).toContain('version/preminor/1.1.0-beta.0/feat'); + expect(state.tags).toContain('1.1.0-beta.0--feat'); + }, 30000); + + /** + * --semver=preminor --preid=beta with --json → JSON contract. + * + * Validates: Requirements 5.3, 5.5 + */ + test('--semver=preminor --preid=beta --json → JSON with success, version, branch, tag', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['release', '--semver=preminor', '--preid=beta', '--branch=feat', '--yes', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.success).toBe(true); + expect(parsed.version).toBe('1.1.0-beta.0'); + expect(parsed.branch).toContain('version/preminor/1.1.0-beta.0/feat'); + expect(parsed.tag).toBe('1.1.0-beta.0--feat'); + }, 30000); + + /** + * --semver=prerelease --preid=rc bumps 1.0.0 → 1.0.1-rc.0. + * + * Validates: Requirements 5.4, 5.6 + */ + test('--semver=prerelease --preid=rc → version 1.0.1-rc.0, correct branch and tag', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['release', '--semver=prerelease', '--preid=rc', '--branch=rc', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1.0.1-rc.0'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.0.1-rc.0'); + expect(state.branches).toContain('version/prerelease/1.0.1-rc.0/rc'); + expect(state.tags).toContain('1.0.1-rc.0--rc'); + }, 30000); + + /** + * --semver=prerelease --preid=rc with --json → JSON contract. + * + * Validates: Requirements 5.4, 5.5 + */ + test('--semver=prerelease --preid=rc --json → JSON with success, version, branch, tag', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['release', '--semver=prerelease', '--preid=rc', '--branch=rc', '--yes', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.success).toBe(true); + expect(parsed.version).toBe('1.0.1-rc.0'); + expect(parsed.branch).toContain('version/prerelease/1.0.1-rc.0/rc'); + expect(parsed.tag).toBe('1.0.1-rc.0--rc'); + }, 30000); +}); diff --git a/__tests__/e2e/pr.commands.test.ts b/__tests__/e2e/pr.commands.test.ts new file mode 100644 index 0000000..04dc9f1 --- /dev/null +++ b/__tests__/e2e/pr.commands.test.ts @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * E2E tests: PR-related CLI commands via `node dist/version.js`. + * + * Validates: Requirements 4.6, 7.4, 7.5, 13.3, 15.1, 15.2 + */ + +import { execSync, spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { + createRepoFixture, + snapshotRepoState, + cleanup, +} from '../helpers/repo-fixture'; +import { CLI_PATH, PROJECT_ROOT, IS_PACKED } from '../helpers/cli-path'; + +interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +function runCli(args: string[], opts: { cwd: string; env?: Record }): RunResult { + const result = spawnSync(process.execPath, [CLI_PATH, ...args], { + cwd: opts.cwd, + encoding: 'utf8', + timeout: 15000, + env: { ...process.env, ...opts.env }, + }); + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.status ?? 1, + }; +} + +let dirs: string[] = []; + +beforeAll(() => { + if (!IS_PACKED) { + execSync(`${process.execPath} build.js`, { + cwd: PROJECT_ROOT, + encoding: 'utf8', + timeout: 30000, + }); + } + expect(fs.existsSync(CLI_PATH)).toBe(true); +}); + +afterEach(() => { + cleanup(dirs); + dirs = []; +}); + +describe('E2E: PR commands', () => { + // Test 1: release --no-pr — exit code 0, no PR in output + // Validates: Requirements 7.5 + test('release --no-pr — exit code 0, no PR info in output', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['release', '--semver=patch', '--branch=test', '--yes', '--no-pr'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1.0.1'); + // No PR-related output when --no-pr is used + expect(stdout.toLowerCase()).not.toContain('pull request'); + expect(stdout.toLowerCase()).not.toContain('merge request'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.0.1'); + }, 30000); + + // Test 2: release --json without token — JSON with pullRequest.status=fallback + // Validates: Requirements 7.4, 13.3, 15.1, 15.2 + test('release --json --push without token — JSON with pullRequest.status=fallback', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Strip all token env vars to ensure no token is found + const cleanEnv: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined && + !k.includes('TOKEN') && + !k.includes('VERSIONINGS_TOKEN') && + !k.includes('VERSIONINGS_AUTH')) { + cleanEnv[k] = v; + } + } + + const { stdout, exitCode } = runCli( + ['release', '--semver=patch', '--branch=test', '--yes', '--push', '--json'], + { cwd: repoDir, env: cleanEnv }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.success).toBe(true); + expect(parsed.version).toBe('1.0.1'); + + // pullRequest object should be present with fallback status. + // The repo fixture uses a local path as remote URL (not a real GitHub URL), + // so the fallback reason may be 'no_token' or a URL parse error — both are valid fallbacks. + expect(parsed.pullRequest).toBeDefined(); + expect(parsed.pullRequest.status).toBe('fallback'); + expect(parsed.pullRequest.fallbackReason).toBeDefined(); + expect(typeof parsed.pullRequest.fallbackReason).toBe('string'); + expect(parsed.pullRequest.url).toBeDefined(); + expect(typeof parsed.pullRequest.url).toBe('string'); + + // Backward compatibility: pullRequestUrl alias + expect(parsed.pullRequestUrl).toBeDefined(); + expect(parsed.pullRequestUrl).toBe(parsed.pullRequest.url); + }, 30000); + + // Test 3: plan --json — JSON dry-run with pullRequest section + // Validates: Requirements 4.6, 15.1 + test('plan --json --push — JSON dry-run with pullRequest section', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['plan', '--semver=patch', '--branch=test', '--push', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.dryRun).toBe(true); + expect(Array.isArray(parsed.steps)).toBe(true); + expect(parsed.steps.length).toBeGreaterThan(0); + + // Dry-run plan should include pullRequest info + expect(parsed.pullRequest).toBeDefined(); + expect(parsed.pullRequest.mode).toBeDefined(); + expect(parsed.pullRequest.platform).toBeDefined(); + expect(typeof parsed.pullRequest.hasToken).toBe('boolean'); + }, 30000); + + // Test 4: doctor --json with GITHUB_TOKEN — JSON contains SCM API check + // Validates: Requirements 13.3 + test('doctor --json with GITHUB_TOKEN — JSON contains scm_api check', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['doctor', '--json'], + { cwd: repoDir, env: { GITHUB_TOKEN: 'ghp_fake_token_for_test_1234' } }, + ); + + // Doctor exits 0 if all checks pass, 1 if any check fails. + // With a fake token the SCM API check will fail, so exit code 1 is expected. + expect([0, 1]).toContain(exitCode); + + // Doctor --json outputs checks array (first line) followed by provenance + const lines = stdout.trim().split('\n').filter(Boolean); + expect(lines.length).toBeGreaterThanOrEqual(1); + + const checks = JSON.parse(lines[0]); + expect(Array.isArray(checks)).toBe(true); + + // Should contain scm_api check (will fail with fake token, but must be present) + const scmCheck = checks.find((c: any) => c.name === 'scm_api'); + expect(scmCheck).toBeDefined(); + expect(scmCheck.name).toBe('scm_api'); + expect(['pass', 'fail', 'warn']).toContain(scmCheck.status); + expect(scmCheck.found).toBeDefined(); + }, 30000); + + // Test 5: Backward compatibility — no subcommand, no new fields → works as before + // Validates: Requirements 15.1, 15.2 + test('backward compatibility — --semver=patch --branch=test --yes without new fields', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['--semver=patch', '--branch=test', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1.0.1'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.0.1'); + expect(state.branches).toContain('version/patch/1.0.1/test'); + expect(state.tags).toContain('1.0.1--test'); + }, 30000); +}); diff --git a/__tests__/e2e/strategies.test.ts b/__tests__/e2e/strategies.test.ts new file mode 100644 index 0000000..85474dc --- /dev/null +++ b/__tests__/e2e/strategies.test.ts @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * E2E tests: Branching strategies — release-branch, hotfix, maintenance. + * + * Tests strategy-specific branch/tag naming, semver restrictions, + * source-branch validation, plan --json output, and dry-run immutability. + * + * Validates: Requirements 3.1–3.10 + */ + +import { execSync, spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { + createRepoFixtureWithStrategy, + snapshotRepoState, + assertNoMutation, + cleanup, + git, +} from '../helpers/repo-fixture'; +import { CLI_PATH, PROJECT_ROOT, IS_PACKED } from '../helpers/cli-path'; + +interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +function runCli(args: string[], opts: { cwd: string; env?: Record }): RunResult { + const result = spawnSync(process.execPath, [CLI_PATH, ...args], { + cwd: opts.cwd, + encoding: 'utf8', + timeout: 15000, + env: { ...process.env, ...opts.env }, + }); + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.status ?? 1, + }; +} + +let dirs: string[] = []; + +beforeAll(() => { + if (!IS_PACKED) { + execSync(`${process.execPath} build.js`, { + cwd: PROJECT_ROOT, + encoding: 'utf8', + timeout: 30000, + }); + } + expect(fs.existsSync(CLI_PATH)).toBe(true); +}); + +afterEach(() => { + cleanup(dirs); + dirs = []; +}); + +// ─── release-branch strategy ──────────────────────────────────────────── + +describe('E2E: release-branch strategy', () => { + /** + * Release minor with release-branch strategy creates branch release/{version} + * and tag v{version}. + * + * Validates: Requirement 3.1 + */ + test('release minor → branch release/{version}, tag v{version}, exit 0', () => { + const { repoDir, remoteDir } = createRepoFixtureWithStrategy({ strategy: 'release-branch' }); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['release', '--semver=minor', '--branch=feat', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1.1.0'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.1.0'); + expect(state.branches).toContain('release/1.1.0'); + expect(state.tags).toContain('v1.1.0'); + expect(state.branch).toBe('release/1.1.0'); + }, 30000); + + /** + * Plan --json with release-branch strategy returns strategy field. + * + * Validates: Requirements 3.2, 3.9 + */ + test('plan --json → strategy: "release-branch", exit 0', () => { + const { repoDir, remoteDir } = createRepoFixtureWithStrategy({ strategy: 'release-branch' }); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['plan', '--semver=minor', '--branch=feat', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.dryRun).toBe(true); + expect(parsed.strategy).toBe('release-branch'); + }, 30000); + + /** + * Dry-run with release-branch strategy does not mutate the repository. + * + * Validates: Requirement 3.10 + */ + test('dry-run → no mutations to repo', () => { + const { repoDir, remoteDir } = createRepoFixtureWithStrategy({ strategy: 'release-branch' }); + dirs.push(repoDir, remoteDir); + + const snapshotBefore = snapshotRepoState(repoDir); + + const { exitCode } = runCli( + ['release', '--semver=minor', '--branch=feat', '--yes', '--dry-run'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + assertNoMutation(repoDir, snapshotBefore); + }, 30000); +}); + +// ─── hotfix strategy ──────────────────────────────────────────────────── + +describe('E2E: hotfix strategy', () => { + /** + * Release patch from main with hotfix strategy creates branch hotfix/{version} + * and tag v{version}. + * + * Validates: Requirement 3.3 + */ + test('release patch from main → branch hotfix/{version}, tag v{version}, exit 0', () => { + const { repoDir, remoteDir } = createRepoFixtureWithStrategy({ strategy: 'hotfix' }); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['release', '--semver=patch', '--branch=urgent', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1.0.1'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.0.1'); + expect(state.branches).toContain('hotfix/1.0.1'); + expect(state.tags).toContain('v1.0.1'); + expect(state.branch).toBe('hotfix/1.0.1'); + }, 30000); + + /** + * Hotfix strategy only allows patch semver type. Minor should fail + * with exit code 3 (INVALID_ARGS). + * + * Validates: Requirement 3.4 + */ + test('release minor → exit 3 (only patch allowed)', () => { + const { repoDir, remoteDir } = createRepoFixtureWithStrategy({ strategy: 'hotfix' }); + dirs.push(repoDir, remoteDir); + + const { exitCode, stderr } = runCli( + ['release', '--semver=minor', '--branch=feat', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(3); + expect(stderr).toContain('patch'); + }, 30000); + + /** + * Hotfix strategy requires current branch to be main or master. + * Release from a feature branch should fail with exit code 3. + * + * Validates: Requirement 3.5 + */ + test('release from feature branch → exit 3 (requires main/master)', () => { + const { repoDir, remoteDir } = createRepoFixtureWithStrategy({ strategy: 'hotfix' }); + dirs.push(repoDir, remoteDir); + + git(repoDir, 'checkout -b feature/something'); + + const { exitCode, stderr } = runCli( + ['release', '--semver=patch', '--branch=urgent', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(3); + expect(stderr).toContain('main'); + }, 30000); + + /** + * Plan --json with hotfix strategy returns strategy field. + * + * Validates: Requirements 3.9 + */ + test('plan --json → strategy: "hotfix", exit 0', () => { + const { repoDir, remoteDir } = createRepoFixtureWithStrategy({ strategy: 'hotfix' }); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['plan', '--semver=patch', '--branch=urgent', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.dryRun).toBe(true); + expect(parsed.strategy).toBe('hotfix'); + }, 30000); + + /** + * Dry-run with hotfix strategy does not mutate the repository. + * + * Validates: Requirement 3.10 + */ + test('dry-run → no mutations to repo', () => { + const { repoDir, remoteDir } = createRepoFixtureWithStrategy({ strategy: 'hotfix' }); + dirs.push(repoDir, remoteDir); + + const snapshotBefore = snapshotRepoState(repoDir); + + const { exitCode } = runCli( + ['release', '--semver=patch', '--branch=urgent', '--yes', '--dry-run'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + assertNoMutation(repoDir, snapshotBefore); + }, 30000); +}); + +// ─── maintenance strategy ─────────────────────────────────────────────── + +describe('E2E: maintenance strategy', () => { + /** + * Release patch from main with maintenance strategy creates branch + * support/{major}.{minor} and tag v{version}. + * + * Note: maintenance strategy with patch from 1.0.0 → 1.0.1 sets + * reuseBranch=true (patchNum > 0), so the support/1.0 branch must + * exist before release. We pre-create it to simulate a real workflow. + * + * Validates: Requirement 3.6 + */ + test('release patch from main → branch support/{major}.{minor}, tag v{version}, exit 0', () => { + const { repoDir, remoteDir } = createRepoFixtureWithStrategy({ strategy: 'maintenance' }); + dirs.push(repoDir, remoteDir); + + // Pre-create the support/1.0 branch (simulates prior maintenance setup) + git(repoDir, 'branch support/1.0'); + + const { stdout, exitCode } = runCli( + ['release', '--semver=patch', '--branch=fix', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1.0.1'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.0.1'); + expect(state.branches).toContain('support/1.0'); + expect(state.tags).toContain('v1.0.1'); + expect(state.branch).toBe('support/1.0'); + }, 30000); + + /** + * Maintenance strategy only allows patch semver type. Minor should fail + * with exit code 3 (INVALID_ARGS). + * + * Validates: Requirement 3.7 + */ + test('release minor → exit 3 (only patch allowed)', () => { + const { repoDir, remoteDir } = createRepoFixtureWithStrategy({ strategy: 'maintenance' }); + dirs.push(repoDir, remoteDir); + + const { exitCode, stderr } = runCli( + ['release', '--semver=minor', '--branch=feat', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(3); + expect(stderr).toContain('patch'); + }, 30000); + + /** + * Maintenance strategy requires current branch to be main, master, + * or a support/* branch. Release from a feature branch should fail + * with exit code 3. + * + * Validates: Requirement 3.8 + */ + test('release from feature branch → exit 3 (requires main/master/support)', () => { + const { repoDir, remoteDir } = createRepoFixtureWithStrategy({ strategy: 'maintenance' }); + dirs.push(repoDir, remoteDir); + + git(repoDir, 'checkout -b feature/something'); + + const { exitCode, stderr } = runCli( + ['release', '--semver=patch', '--branch=fix', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(3); + expect(stderr).toContain('support'); + }, 30000); + + /** + * Plan --json with maintenance strategy returns strategy field. + * + * Validates: Requirements 3.9 + */ + test('plan --json → strategy: "maintenance", exit 0', () => { + const { repoDir, remoteDir } = createRepoFixtureWithStrategy({ strategy: 'maintenance' }); + dirs.push(repoDir, remoteDir); + + const { stdout, exitCode } = runCli( + ['plan', '--semver=patch', '--branch=fix', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.dryRun).toBe(true); + expect(parsed.strategy).toBe('maintenance'); + }, 30000); + + /** + * Dry-run with maintenance strategy does not mutate the repository. + * + * Validates: Requirement 3.10 + */ + test('dry-run → no mutations to repo', () => { + const { repoDir, remoteDir } = createRepoFixtureWithStrategy({ strategy: 'maintenance' }); + dirs.push(repoDir, remoteDir); + + const snapshotBefore = snapshotRepoState(repoDir); + + const { exitCode } = runCli( + ['release', '--semver=patch', '--branch=fix', '--yes', '--dry-run'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + assertNoMutation(repoDir, snapshotBefore); + }, 30000); +}); diff --git a/__tests__/e2e/subcommands.test.ts b/__tests__/e2e/subcommands.test.ts new file mode 100644 index 0000000..efafedc --- /dev/null +++ b/__tests__/e2e/subcommands.test.ts @@ -0,0 +1,600 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * E2E tests: CLI subcommands via `node dist/version.js` with real git repositories. + * + * Validates: Requirements 6.1, 7.1, 8.1, 9.1, 9.3, 10.2, 11.1, 12.1, 12.2, 12.5, 14.4, 15.1, 16.2, 17.1 + */ + +import { execSync, spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import yaml from 'js-yaml'; +import { + createRepoFixture, + snapshotRepoState, + assertNoMutation, + cleanup, + git, +} from '../helpers/repo-fixture'; +import { CLI_PATH, PROJECT_ROOT, IS_PACKED } from '../helpers/cli-path'; + +interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** + * Run the CLI via spawnSync. Captures stdout and stderr regardless of exit code. + */ +function runCli(args: string[], opts: { cwd: string; env?: Record }): RunResult { + const result = spawnSync(process.execPath, [CLI_PATH, ...args], { + cwd: opts.cwd, + encoding: 'utf8', + timeout: 15000, + env: { ...process.env, ...opts.env }, + }); + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.status ?? 1, + }; +} + +let dirs: string[] = []; + +beforeAll(() => { + // Build dist/version.js before running E2E tests + if (!IS_PACKED) { + execSync(`${process.execPath} build.js`, { + cwd: PROJECT_ROOT, + encoding: 'utf8', + timeout: 30000, + }); + } + expect(fs.existsSync(CLI_PATH)).toBe(true); +}); + +afterEach(() => { + cleanup(dirs); + dirs = []; +}); + +describe('E2E: CLI subcommands', () => { + // Test 1: versionings init --format=json --non-interactive + // Requirement: 6.1 + test('init --format=json --non-interactive — creates config file, exit code 0', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Remove existing version.json so init creates a fresh one + fs.unlinkSync(path.join(repoDir, 'version.json')); + + const { exitCode, stdout } = runCli( + ['init', '--format=json', '--non-interactive'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('Created'); + expect(fs.existsSync(path.join(repoDir, 'version.json'))).toBe(true); + + // Verify the created file is valid JSON + const content = fs.readFileSync(path.join(repoDir, 'version.json'), 'utf8'); + const parsed = JSON.parse(content); + expect(parsed.git).toBeDefined(); + expect(parsed.git.platform).toBeDefined(); + }, 30000); + + // Test 2: versionings validate — valid config, exit code 0 + // Requirement: 7.1 + test('validate — valid config, exit code 0', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode } = runCli(['validate'], { cwd: repoDir }); + expect(exitCode).toBe(0); + }, 30000); + + // Test 3: versionings validate --json — JSON output + // Requirement: 7.1 + test('validate --json — JSON output', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runCli(['validate', '--json'], { cwd: repoDir }); + expect(exitCode).toBe(0); + + const parsed = JSON.parse(stdout.trim()); + expect(parsed.valid).toBe(true); + expect(Array.isArray(parsed.checks)).toBe(true); + // No ANSI escape codes in JSON output + // eslint-disable-next-line no-control-regex + expect(stdout).not.toMatch(/\x1b\[/); + }, 30000); + + // Test 4: versionings plan --semver=patch --branch=test — plan without mutations, exit code 0 + // Requirement: 8.1 + test('plan --semver=patch --branch=test — plan without mutations, exit code 0', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const snapshotBefore = snapshotRepoState(repoDir); + + const { exitCode, stdout } = runCli( + ['plan', '--semver=patch', '--branch=test'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout.length).toBeGreaterThan(0); + + // Plan should not mutate the repo + assertNoMutation(repoDir, snapshotBefore); + }, 30000); + + // Test 5: versionings release --semver=patch --branch=test --yes — full workflow, exit code 0 + // Requirement: 9.1 + test('release --semver=patch --branch=test --yes — full workflow, exit code 0', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runCli( + ['release', '--semver=patch', '--branch=test', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1.0.1'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.0.1'); + }, 30000); + + // Test 6: versionings --semver=patch --branch=test --yes — backward compatibility (no subcommand → release) + // Requirement: 9.3, 12.2 + test('--semver=patch --branch=test --yes — backward compatibility (no subcommand → release)', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runCli( + ['--semver=patch', '--branch=test', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1.0.1'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.0.1'); + }, 30000); + + // Test 7: versionings rollback — no log, exit code 8 (NO_OPERATION) + // Requirement: 10.2 + test('rollback — no log, exit code 8 (NO_OPERATION)', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stderr } = runCli(['rollback'], { cwd: repoDir }); + expect(exitCode).toBe(8); + expect(stderr.length).toBeGreaterThan(0); + }, 30000); + + // Test 8: versionings doctor — diagnostics, exit code 0 + // Requirement: 11.1 + test('doctor — diagnostics, exit code 0', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runCli(['doctor'], { cwd: repoDir }); + expect(exitCode).toBe(0); + expect(stdout.length).toBeGreaterThan(0); + }, 30000); + + // Test 9: versionings doctor --json — JSON output + // Requirement: 11.1 + test('doctor --json — JSON output', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runCli(['doctor', '--json'], { cwd: repoDir }); + expect(exitCode).toBe(0); + + // Doctor --json outputs checks array followed by provenance object (two JSON lines) + const lines = stdout.trim().split('\n').filter(Boolean); + expect(lines.length).toBeGreaterThanOrEqual(1); + + const checks = JSON.parse(lines[0]); + expect(Array.isArray(checks)).toBe(true); + expect(checks.length).toBeGreaterThan(0); + // Each check has name, status, found + for (const check of checks) { + expect(check).toHaveProperty('name'); + expect(check).toHaveProperty('status'); + expect(check).toHaveProperty('found'); + } + // eslint-disable-next-line no-control-regex + expect(stdout).not.toMatch(/\x1b\[/); + }, 30000); + + // Test 10: versionings nonexistent — unknown subcommand, exit code 3 (INVALID_ARGS) + // Requirement: 12.5 + test('nonexistent — unknown subcommand, exit code 3 (INVALID_ARGS)', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stderr } = runCli(['nonexistent'], { cwd: repoDir }); + expect(exitCode).toBe(3); + expect(stderr).toContain('Available commands'); + }, 30000); + + // Test 11: versionings --print-config — provenance output, exit code 0 + // Requirement: 15.1 + test('--print-config — provenance output, exit code 0', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runCli(['--print-config'], { cwd: repoDir }); + expect(exitCode).toBe(0); + expect(stdout.length).toBeGreaterThan(0); + }, 30000); + + // Test 12: versionings release --semver=patch --branch=test --strict with unknown fields — exit code 1 + // Requirement: 16.2 + test('release --strict with unknown fields — exit code 1 (CONFIG_ERROR)', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Add unknown top-level field to version.json + const configPath = path.join(repoDir, 'version.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.unknownTopLevelField = 'should-cause-error'; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); + + const { exitCode, stderr } = runCli( + ['release', '--semver=patch', '--branch=test', '--strict', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(1); + expect(stderr.length).toBeGreaterThan(0); + }, 30000); +}); + + +describe('E2E: Extended subcommand coverage', () => { + // 1. init --format=yaml (YAML generation) + test('init --format=yaml --non-interactive — creates .versioningsrc.yml with valid YAML config', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Remove version.json so init creates a fresh config + fs.unlinkSync(path.join(repoDir, 'version.json')); + + const { exitCode, stdout } = runCli( + ['init', '--format=yaml', '--non-interactive'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('Created'); + + const ymlPath = path.join(repoDir, '.versioningsrc.yml'); + expect(fs.existsSync(ymlPath)).toBe(true); + + const content = fs.readFileSync(ymlPath, 'utf8'); + const parsed = yaml.load(content) as Record; + expect(parsed).toBeDefined(); + expect(parsed.git).toBeDefined(); + expect(parsed.git.platform).toBeDefined(); + expect(parsed.git.url).toBeDefined(); + }, 30000); + + // 2. validate with invalid config + test('validate with invalid config — exit code != 0', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Write invalid config: bad platform enum, missing url + const configPath = path.join(repoDir, 'version.json'); + fs.writeFileSync(configPath, JSON.stringify({ git: { platform: 'invalid' } }, null, 2) + '\n'); + + const { exitCode } = runCli(['validate'], { cwd: repoDir }); + expect(exitCode).not.toBe(0); + }, 30000); + + // 3. validate --strict with unknown fields + test('validate --strict with unknown fields — exit code 1', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Add unknown field to valid config + const configPath = path.join(repoDir, 'version.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.unknownField = 'test'; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); + + const { exitCode } = runCli(['validate', '--strict'], { cwd: repoDir }); + expect(exitCode).toBe(1); + }, 30000); + + // 4. plan --json (JSON output) + test('plan --json — valid JSON output with dryRun=true and steps array', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runCli( + ['plan', '--semver=patch', '--branch=test', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.dryRun).toBe(true); + expect(Array.isArray(parsed.steps)).toBe(true); + expect(parsed.steps.length).toBeGreaterThan(0); + // eslint-disable-next-line no-control-regex + expect(stdout).not.toMatch(/\x1b\[/); + }, 30000); + + // 5. plan with --push (push steps in plan) + test('plan --push — output contains push step', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runCli( + ['plan', '--semver=patch', '--branch=test', '--push'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout.toLowerCase()).toContain('push'); + }, 30000); + + // 6. release --dry-run (via subcommand) + test('release --dry-run — exit code 0, repo not mutated', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const snapshotBefore = snapshotRepoState(repoDir); + + const { exitCode } = runCli( + ['release', '--semver=patch', '--branch=test', '--dry-run'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + assertNoMutation(repoDir, snapshotBefore); + }, 30000); + + // 7. release --json (JSON output) + test('release --json — valid JSON with success=true, version, branch, tag', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runCli( + ['release', '--semver=patch', '--branch=test', '--yes', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.success).toBe(true); + expect(parsed.version).toBe('1.0.1'); + expect(parsed.branch).toContain('test'); + expect(parsed.tag).toBe('1.0.1--test'); + // eslint-disable-next-line no-control-regex + expect(stdout).not.toMatch(/\x1b\[/); + }, 30000); + + // 8. release with push (full workflow + push) + test('release --push — exit code 0, version bumped, remote has branch and tag', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode } = runCli( + ['release', '--semver=patch', '--branch=test', '--push', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.0.1'); + + // Verify remote has the branch and tag + const remoteRefs = execSync('git for-each-ref --format="%(refname)"', { + cwd: remoteDir, + encoding: 'utf8', + }); + expect(remoteRefs).toContain('version/patch/1.0.1/test'); + expect(remoteRefs).toContain('1.0.1--test'); + }, 30000); + + // 9. Full cycle: release → rollback + test('release then rollback — rollback succeeds after release', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Release + const releaseResult = runCli( + ['release', '--semver=patch', '--branch=test', '--yes'], + { cwd: repoDir }, + ); + expect(releaseResult.exitCode).toBe(0); + + const stateAfterRelease = snapshotRepoState(repoDir); + expect(stateAfterRelease.version).toBe('1.0.1'); + expect(stateAfterRelease.tags).toContain('1.0.1--test'); + + // Rollback — operation log exists, rollback should be attempted + const rollbackResult = runCli(['rollback', '--yes'], { cwd: repoDir }); + // Rollback exit code 0 (success) or 7 (INCOMPLETE_ROLLBACK) + expect([0, 7]).toContain(rollbackResult.exitCode); + // Verify rollback output mentions rollback completion + const combined = rollbackResult.stdout + rollbackResult.stderr; + expect(combined.toLowerCase()).toContain('rollback'); + }, 30000); + + // 10. rollback --from + test('rollback --from — rollback from specific operation log', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Release to create an operation log + const releaseResult = runCli( + ['release', '--semver=patch', '--branch=test', '--yes'], + { cwd: repoDir }, + ); + expect(releaseResult.exitCode).toBe(0); + + // Find the operation log file + const opsDir = path.join(repoDir, '.versionings', 'operations'); + expect(fs.existsSync(opsDir)).toBe(true); + + const logFiles = fs.readdirSync(opsDir).filter(f => f.endsWith('.json') && f !== 'last.json'); + expect(logFiles.length).toBeGreaterThan(0); + + const logFilePath = path.join(opsDir, logFiles[0]); + + // Rollback from specific log file + const rollbackResult = runCli( + ['rollback', `--from=${logFilePath}`, '--yes'], + { cwd: repoDir }, + ); + // Should attempt rollback — exit code 0 or 7 (INCOMPLETE_ROLLBACK) + expect([0, 7]).toContain(rollbackResult.exitCode); + }, 30000); + + // 11. --print-config --json (JSON provenance) + test('--print-config --json — JSON output with value and source properties', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runCli( + ['--print-config', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + // Provenance is an object where each field has value and source + const keys = Object.keys(parsed); + expect(keys.length).toBeGreaterThan(0); + for (const key of keys) { + expect(parsed[key]).toHaveProperty('value'); + expect(parsed[key]).toHaveProperty('source'); + } + }, 30000); + + // 12. Config hierarchy: env vars override version.json + test('config hierarchy — env var overrides version.json platform', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runCli( + ['--print-config', '--json'], + { cwd: repoDir, env: { VERSIONINGS_GIT_PLATFORM: 'bitbucket' } }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed['git.platform'].value).toBe('bitbucket'); + expect(parsed['git.platform'].source).toBe('env'); + }, 30000); + + // 13. Config hierarchy: .versioningsrc.json overrides version.json + test('config hierarchy — .versioningsrc.json overrides version.json pr.target', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // version.json already has default pr.target (from defaults: 'master') + // Create .versioningsrc.json with a different pr.target + const rcConfig = { git: { pr: { target: 'develop' } } }; + fs.writeFileSync( + path.join(repoDir, '.versioningsrc.json'), + JSON.stringify(rcConfig, null, 2) + '\n', + ); + + const { exitCode, stdout } = runCli( + ['--print-config', '--json'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed['git.pr.target'].value).toBe('develop'); + expect(parsed['git.pr.target'].source).toBe('.versioningsrc.json'); + }, 30000); + + // 14. init creates valid config that validate accepts + test('init then validate — init creates config that validate accepts', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + // Remove existing version.json + fs.unlinkSync(path.join(repoDir, 'version.json')); + + // Init + const initResult = runCli( + ['init', '--format=json', '--non-interactive'], + { cwd: repoDir }, + ); + expect(initResult.exitCode).toBe(0); + + // Validate + const validateResult = runCli(['validate'], { cwd: repoDir }); + expect(validateResult.exitCode).toBe(0); + }, 30000); + + // 15. help output (no args) + test('no args — exit code 0, output contains all subcommand names', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout, stderr } = runCli([], { cwd: repoDir }); + expect(exitCode).toBe(0); + + // yargs showHelp() writes to stderr by default + const output = stdout + stderr; + for (const cmd of ['init', 'validate', 'plan', 'release', 'rollback', 'doctor']) { + expect(output).toContain(cmd); + } + }, 30000); + + // 16. release --semver=minor (different semver types) + test('release --semver=minor — version bumped to 1.1.0', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runCli( + ['release', '--semver=minor', '--branch=feat', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1.1.0'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.1.0'); + }, 30000); + + // 17. release with --preid (prerelease) + test('release --semver=prepatch --preid=beta — version contains beta', () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + + const { exitCode, stdout } = runCli( + ['release', '--semver=prepatch', '--branch=pre', '--preid=beta', '--yes'], + { cwd: repoDir }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('beta'); + + const state = snapshotRepoState(repoDir); + expect(state.version).toContain('beta'); + }, 30000); +}); diff --git a/__tests__/helpers/cli-path.ts b/__tests__/helpers/cli-path.ts new file mode 100644 index 0000000..e581424 --- /dev/null +++ b/__tests__/helpers/cli-path.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * Resolves the CLI binary path for E2E tests. + * + * By default, points to the dev build at `out/dist/index.js`. + * When `VERSIONINGS_CLI_PATH` env var is set (e.g. by the pack test + * runner), uses that path instead — allowing the same E2E tests to + * run against the npm-packed tarball binary. + * + * When running against the packed binary (`VERSIONINGS_CLI_PATH` set), + * the build step in `beforeAll` is skipped — the binary is already built. + */ + +import * as path from 'path'; + +export const PROJECT_ROOT = path.resolve(__dirname, '../..'); + +export const CLI_PATH = process.env.VERSIONINGS_CLI_PATH + ? path.resolve(process.env.VERSIONINGS_CLI_PATH) + : path.resolve(PROJECT_ROOT, 'out/dist/index.js'); + +/** True when running against a pre-built binary (pack mode). */ +export const IS_PACKED = !!process.env.VERSIONINGS_CLI_PATH; diff --git a/__tests__/helpers/repo-fixture.ts b/__tests__/helpers/repo-fixture.ts index 66c53a0..9918a28 100644 --- a/__tests__/helpers/repo-fixture.ts +++ b/__tests__/helpers/repo-fixture.ts @@ -152,3 +152,45 @@ export function cleanup(dirs: string[]): void { } } } + +export interface StrategyFixtureOpts extends RepoFixtureOpts { + strategy: string; + platform?: string; + apiUrl?: string; + branchingConfig?: Record; +} + +/** + * Create a repo fixture pre-configured with a branching strategy. + * + * Calls createRepoFixture(), then overwrites version.json with the + * specified strategy/platform/apiUrl/branchingConfig and commits the change. + */ +export function createRepoFixtureWithStrategy(opts: StrategyFixtureOpts): RepoFixture { + const { strategy, platform = 'github', apiUrl, branchingConfig, ...baseOpts } = opts; + const fixture = createRepoFixture(baseOpts); + + const gitConfig: Record = { + platform, + url: fixture.remoteUrl, + branching: { + ...(branchingConfig || {}), + strategy, + }, + }; + + if (apiUrl) { + gitConfig.apiUrl = apiUrl; + } + + const versionJson = { git: gitConfig }; + fs.writeFileSync( + path.join(fixture.repoDir, 'version.json'), + JSON.stringify(versionJson, null, 2) + '\n', + ); + git(fixture.repoDir, 'add version.json'); + git(fixture.repoDir, 'commit -m "configure strategy fixture"'); + git(fixture.repoDir, 'push origin main'); + + return fixture; +} diff --git a/__tests__/integration/auto-bump.workflow.test.ts b/__tests__/integration/auto-bump.workflow.test.ts new file mode 100644 index 0000000..a168510 --- /dev/null +++ b/__tests__/integration/auto-bump.workflow.test.ts @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * Integration tests: Auto-bump workflow with real git repositories. + * + * Tests the full pipeline with --semver=auto, conventional commits analysis, + * changelog generation, and backward compatibility. + * + * Validates: Requirements 3.1, 3.4, 3.6, 5.1, 5.2, 5.3, 5.4, 5.5, 5.7, + * 10.1, 10.4, 12.1, 15.4 + */ + +import * as path from 'path'; +import * as fs from 'fs'; +import { createExecutor } from '../../src/core/executor'; +import { createRollbackManager } from '../../src/core/rollback'; +import { createArtifactChecker } from '../../src/core/artifact.checker'; +import { loadAndValidateConfig } from '../../src/config/config.validator'; +import { runPipeline } from '../../src/core/pipeline'; +import { analyzeBump, DEFAULT_BUMP_POLICY } from '../../src/versioning/commit.analyzer'; +import { generateChangelog, DEFAULT_GROUP_TITLES } from '../../src/versioning/changelog.generator'; +import { EXIT_CODES, VersioningsError } from '../../src/core/errors'; +import type { PipelineDeps } from '../../src/core/pipeline'; +import type { PipelineResult, DryRunPlan } from '../../src/core/reporter'; +import type { ChangelogOpts } from '../../src/versioning/changelog.generator'; +import type { BumpPolicy } from '../../src/versioning/commit.analyzer'; +import { + createRepoFixture, + snapshotRepoState, + assertNoMutation, + cleanup, + git, +} from '../helpers/repo-fixture'; + +let dirs: string[] = []; +let originalCwd: string; + +beforeEach(() => { + originalCwd = process.cwd(); +}); + +afterEach(() => { + process.chdir(originalCwd); + cleanup(dirs); + dirs = []; +}); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Create standard pipeline deps for a repo directory. + */ +function createDeps(repoDir: string): PipelineDeps { + const executor = createExecutor(); + const rollbackManager = createRollbackManager(executor); + const artifactChecker = createArtifactChecker(executor); + const config = loadAndValidateConfig(path.join(repoDir, 'version.json')); + return { executor, rollbackManager, artifactChecker, config }; +} + + +/** + * Create pipeline deps with commitAnalyzer and optional changelogGenerator. + */ +function createAutoBumpDeps( + repoDir: string, + opts: { + fallbackBump?: 'major' | 'minor' | 'patch' | null; + bumpPolicy?: BumpPolicy; + changelogFile?: string; + } = {}, +): PipelineDeps { + const baseDeps = createDeps(repoDir); + const bumpPolicy = opts.bumpPolicy || { ...DEFAULT_BUMP_POLICY }; + const fallbackBump = opts.fallbackBump !== undefined ? opts.fallbackBump : null; + + const changelogConfig: ChangelogOpts = { + version: null, // will be set by pipeline after version is computed + date: new Date().toISOString().slice(0, 10), + format: 'markdown', + groupTitles: { ...DEFAULT_GROUP_TITLES }, + excludeTypes: [], + includeNonConventional: false, + bumpPolicy, + }; + + const deps: PipelineDeps = { + ...baseDeps, + commitAnalyzer: { + analyzeBump, + bumpPolicy, + fallbackBump, + }, + changelogGenerator: { + generateChangelog, + changelogConfig, + changelogFile: opts.changelogFile, + }, + }; + + return deps; +} + +/** + * Add a conventional commit to the repo. + */ +function addCommit(repoDir: string, message: string, filename?: string): void { + const file = filename || `file-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`; + fs.writeFileSync(path.join(repoDir, file), `content: ${message}\n`); + git(repoDir, `add ${file}`); + git(repoDir, `commit -m "${message}"`); +} + +/** + * Create a version tag on the current HEAD. + */ +function createVersionTag(repoDir: string, tag: string): void { + git(repoDir, `tag -a "${tag}" -m "Release ${tag}"`); +} + +const baseAutoOpts = { + semver: 'auto', + branch: 'test-release', + push: false, + dryRun: false, + json: false, + verbose: false, +}; + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('Integration: Auto-bump workflow', () => { + test('Test 1: semver=auto with feat and fix commits — determines minor, pipeline succeeds', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + // Create a version tag on the initial commit + createVersionTag(repoDir, 'v1.0.0'); + + // Add conventional commits + addCommit(repoDir, 'fix: resolve login issue'); + addCommit(repoDir, 'feat: add user dashboard'); + addCommit(repoDir, 'fix: correct validation error'); + + const deps = createAutoBumpDeps(repoDir); + const result = await runPipeline(baseAutoOpts, deps) as PipelineResult; + + expect(result.success).toBe(true); + // feat → minor, so version should be 1.1.0 + expect(result.version).toBe('1.1.0'); + expect(result.previousVersion).toBe('1.0.0'); + expect(result.semver).toBe('minor'); + expect(result.exitCode).toBe(EXIT_CODES.SUCCESS); + + // autoBump info should be present + expect(result.autoBump).toBeDefined(); + expect(result.autoBump!.detectedBump).toBe('minor'); + expect(result.autoBump!.totalCommits).toBe(3); + expect(result.autoBump!.breakingChanges).toBe(0); + expect(result.autoBump!.commitsByType).toEqual( + expect.objectContaining({ feat: 1, fix: 2 }), + ); + + // Verify repo state + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.1.0'); + expect(state.status).toBe(''); + }, 30000); + + test('Test 2: semver=auto with breaking change — determines major', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + createVersionTag(repoDir, 'v1.0.0'); + + addCommit(repoDir, 'feat: add new API endpoint'); + addCommit(repoDir, 'feat!: redesign authentication flow'); + + const deps = createAutoBumpDeps(repoDir); + const result = await runPipeline(baseAutoOpts, deps) as PipelineResult; + + expect(result.success).toBe(true); + expect(result.version).toBe('2.0.0'); + expect(result.semver).toBe('major'); + expect(result.autoBump).toBeDefined(); + expect(result.autoBump!.detectedBump).toBe('major'); + expect(result.autoBump!.breakingChanges).toBe(1); + }, 30000); + + test('Test 3: semver=auto without conventional commits and without fallback — NO_CONVENTIONAL_COMMITS (exit 11)', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + createVersionTag(repoDir, 'v1.0.0'); + + // Add non-conventional commits + addCommit(repoDir, 'updated readme'); + addCommit(repoDir, 'misc changes'); + + const snapshotBefore = snapshotRepoState(repoDir); + const deps = createAutoBumpDeps(repoDir, { fallbackBump: null }); + + try { + await runPipeline(baseAutoOpts, deps); + throw new Error('Expected pipeline to throw'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.NO_CONVENTIONAL_COMMITS); + } + + // Repository must remain untouched + assertNoMutation(repoDir, snapshotBefore); + }, 30000); + + test('Test 4: semver=auto with fallbackBump=patch — uses fallback when no CC found', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + createVersionTag(repoDir, 'v1.0.0'); + + // Add non-conventional commits + addCommit(repoDir, 'updated readme'); + addCommit(repoDir, 'misc changes'); + + const deps = createAutoBumpDeps(repoDir, { fallbackBump: 'patch' }); + const result = await runPipeline(baseAutoOpts, deps) as PipelineResult; + + expect(result.success).toBe(true); + expect(result.version).toBe('1.0.1'); + expect(result.semver).toBe('patch'); + expect(result.autoBump).toBeDefined(); + expect(result.autoBump!.detectedBump).toBe('patch'); + }, 30000); + + + test('Test 5: semver=patch — does not call analyzeBump, works as before', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + // Use standard deps without commitAnalyzer + const deps = createDeps(repoDir); + const result = await runPipeline( + { ...baseAutoOpts, semver: 'patch' }, + deps, + ) as PipelineResult; + + expect(result.success).toBe(true); + expect(result.version).toBe('1.0.1'); + expect(result.semver).toBe('patch'); + // No autoBump info when not using auto + expect(result.autoBump).toBeUndefined(); + }, 30000); + + test('Test 6: semver=auto with changelog.file — changelog written to file, git add executed', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + createVersionTag(repoDir, 'v1.0.0'); + + addCommit(repoDir, 'feat: add search functionality'); + addCommit(repoDir, 'fix: handle empty results'); + + const changelogFile = 'CHANGELOG.md'; + const deps = createAutoBumpDeps(repoDir, { changelogFile }); + const result = await runPipeline(baseAutoOpts, deps) as PipelineResult; + + expect(result.success).toBe(true); + expect(result.version).toBe('1.1.0'); + + // Verify changelog file was created and contains expected content + const changelogPath = path.join(repoDir, changelogFile); + expect(fs.existsSync(changelogPath)).toBe(true); + + const changelogContent = fs.readFileSync(changelogPath, 'utf8'); + expect(changelogContent).toContain('# Changelog'); + expect(changelogContent).toContain('Features'); + expect(changelogContent).toContain('add search functionality'); + expect(changelogContent).toContain('Bug Fixes'); + expect(changelogContent).toContain('handle empty results'); + + // Verify the changelog was included in the commit (clean tree) + const state = snapshotRepoState(repoDir); + expect(state.status).toBe(''); + }, 30000); + + test('Test 7: dry-run with semver=auto — plan contains autoBump and changelogPreview', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + createVersionTag(repoDir, 'v1.0.0'); + + addCommit(repoDir, 'feat: implement notifications'); + addCommit(repoDir, 'fix: correct timezone handling'); + + const snapshotBefore = snapshotRepoState(repoDir); + const deps = createAutoBumpDeps(repoDir, { changelogFile: 'CHANGELOG.md' }); + const plan = await runPipeline( + { ...baseAutoOpts, dryRun: true }, + deps, + ) as DryRunPlan; + + expect(plan.dryRun).toBe(true); + expect(plan.currentVersion).toBe('1.0.0'); + expect(plan.nextVersion).toBe('1.1.0'); + expect(plan.semver).toBe('minor'); + + // autoBump info + expect(plan.autoBump).toBeDefined(); + expect(plan.autoBump!.detectedBump).toBe('minor'); + expect(plan.autoBump!.totalCommits).toBe(2); + expect(plan.autoBump!.breakingChanges).toBe(0); + + // changelogPreview + expect(plan.changelogPreview).toBeDefined(); + expect(plan.changelogPreview).toContain('Features'); + expect(plan.changelogPreview).toContain('implement notifications'); + + // Steps should include changelog write + expect(plan.steps.some((s: string) => s.includes('changelog'))).toBe(true); + expect(plan.steps.some((s: string) => s.includes('git add'))).toBe(true); + + // Dry-run must not mutate the repository + assertNoMutation(repoDir, snapshotBefore); + }, 30000); + + test('Test 8: semver=auto with --preid=beta — bump converts to prerelease', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + createVersionTag(repoDir, 'v1.0.0'); + + addCommit(repoDir, 'feat: add beta feature'); + + const deps = createAutoBumpDeps(repoDir); + const result = await runPipeline( + { ...baseAutoOpts, preid: 'beta' }, + deps, + ) as PipelineResult; + + expect(result.success).toBe(true); + // feat → minor → preminor with preid=beta → 1.1.0-beta.0 + expect(result.version).toBe('1.1.0-beta.0'); + expect(result.semver).toBe('preminor'); + expect(result.autoBump).toBeDefined(); + expect(result.autoBump!.detectedBump).toBe('minor'); + }, 30000); + + test('Test 9: backward compatibility — config without conventionalCommits/changelog works as before', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + // The fixture already creates a minimal version.json (no conventionalCommits/changelog). + // Standard pipeline without commitAnalyzer deps — should work as before. + const deps = createDeps(repoDir); + const result = await runPipeline( + { ...baseAutoOpts, semver: 'patch' }, + deps, + ) as PipelineResult; + + expect(result.success).toBe(true); + expect(result.version).toBe('1.0.1'); + expect(result.semver).toBe('patch'); + expect(result.autoBump).toBeUndefined(); + + const state = snapshotRepoState(repoDir); + expect(state.version).toBe('1.0.1'); + expect(state.status).toBe(''); + }, 30000); +}); diff --git a/__tests__/integration/branching.workflow.test.ts b/__tests__/integration/branching.workflow.test.ts new file mode 100644 index 0000000..42bc72e --- /dev/null +++ b/__tests__/integration/branching.workflow.test.ts @@ -0,0 +1,689 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * Integration tests: Branching workflow with different strategies. + * + * Tests the pipeline with mock executor, mock rollback manager, and mock artifact checker — + * focused on strategy integration (not real git repos). + */ + +import { EXIT_CODES, VersioningsError } from '../../src/core/errors'; + +// Mock fs for package.json reads +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + readFileSync: jest.fn(), + existsSync: jest.fn(), + }; +}); + +const fs = require('fs'); + +// Mock version.utils to avoid config.ts side-effect +jest.mock('../../src/versioning/version.utils', () => ({ + AVAILABLE_SEMVERS: ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease'], + composeVersionBranchName: (semver: string, version: string, comment: string, config?: any) => { + const branchType = config ? config.git.branchType.version : 'version'; + const semverType = config ? config.package.semver[semver] : semver; + return `${branchType}/${semverType}/${version}/${comment}`; + }, + composeVersionTagName: (_semver: string, version: string, comment: string) => + `${version}--${comment}`, + semverMessage: (semver: string, version: string, config?: any) => { + if (config) { + const tpl = config.git.commit.message.semver[semver]; + return tpl ? tpl.replace(/v%s/g, version) : 'Read documentation and try to use versioning tool according to the standard.'; + } + return `Version ${semver}: ${version}`; + }, + semverNpmMessage: (_semver: string, branch: string) => + `Version: patch. Comment: ${branch}.`, + preidParam: (preid?: string) => (preid ? `--preid=${preid}` : ''), + generatePullRequestUrl: (branch: string) => + `https://github.com/user/repo/compare/develop...${branch}?expand=1`, +})); + +// Mock pr.creator +jest.mock('../../src/scm/pr.creator', () => ({ + createPR: jest.fn(), +})); + +const { runPipeline } = require('../../src/core/pipeline'); + +// --------------------------------------------------------------------------- +// Shared mock config +// --------------------------------------------------------------------------- + +const baseMockConfig = { + git: { + platform: 'github', + url: 'https://github.com/user/repo.git', + branchType: { version: 'version' }, + pr: { target: 'develop' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + patch: 'Patch: v%s. You SHOULD consider changes.', + minor: 'Minor: v%s. You MUST consider changes.', + major: 'Release: v%s.', + prepatch: 'Patch version is preparing now: v%s.', + preminor: 'Minor version is preparing now: v%s.', + premajor: 'Release is preparing now: v%s.', + prerelease: 'Preparing: v%s.', + }, + }, + }, + }, + package: { + semver: { + patch: 'patch', + prepatch: 'prepatch', + minor: 'minor', + preminor: 'preminor', + premajor: 'premajor', + prerelease: 'prerelease', + major: 'major', + }, + }, + common: { + messages: { + unavailableSemanticVersion: 'Invalid semver', + undefinedVersionBranchName: 'Branch name required', + incorrectVersionBranchNameLength: 'Branch too long', + incorrectVersionBranchNameCharactersDashes: 'No double dashes', + untrackedGitFiles: 'Dirty tree', + incorrectGitRemote: 'Wrong remote', + }, + }, +} as any; + +function configWithStrategy(strategy: string, extra: Record = {}): any { + return { + ...baseMockConfig, + git: { + ...baseMockConfig.git, + branching: { strategy, ...extra }, + }, + }; +} + +// --------------------------------------------------------------------------- +// Mock factories +// --------------------------------------------------------------------------- + +function createMockExecutor(currentBranch = 'master') { + return { + run: jest.fn(async (cmd: string) => { + if (cmd.includes('git status --porcelain')) return { stdout: '', lines: [] }; + if (cmd.includes('git remote --verbose')) + return { + stdout: 'origin\thttps://github.com/user/repo.git (fetch)', + lines: ['origin\thttps://github.com/user/repo.git (fetch)'], + }; + if (cmd.includes('npm --no-git-tag-version version')) + return { stdout: 'v1.2.3', lines: ['v1.2.3'] }; + if (cmd.includes('git checkout -- package')) + return { stdout: '', lines: [] }; + if (cmd.includes('git rev-parse --abbrev-ref HEAD')) + return { stdout: currentBranch, lines: [currentBranch] }; + if (cmd.includes('git tag --list')) + return { stdout: '', lines: [] }; + if (cmd.includes('git branch --list')) + return { stdout: ` ${currentBranch}`, lines: [currentBranch] }; + if (cmd.includes('git ls-remote')) + return { stdout: '', lines: [] }; + return { stdout: '', lines: [] }; + }), + }; +} + +function createMockRollbackManager() { + const recorded: any[] = []; + return { + record: jest.fn((step: any) => recorded.push(step)), + rollback: jest.fn(async () => ({ success: true, failedSteps: [] })), + _recorded: recorded, + }; +} + +function createMockArtifactChecker() { + return { checkUniqueness: jest.fn(async () => { }) }; +} + +function createMockStrategy(overrides: { + name?: string; + branchName?: string | null; + reuseBranch?: boolean; + tagName?: string; + commitMessage?: string; + valid?: boolean; + validationErrors?: string[]; +} = {}) { + const opts = { + name: 'default', + branchName: 'version/patch/1.2.3/fix-login' as string | null, + reuseBranch: false, + tagName: '1.2.3--fix-login', + commitMessage: 'Patch: v1.2.3. You SHOULD consider changes.', + valid: true, + validationErrors: [] as string[], + ...overrides, + }; + return { + name: jest.fn(() => opts.name), + composeBranchName: jest.fn(() => ({ + branchName: opts.branchName, + reuseBranch: opts.reuseBranch, + })), + composeTagName: jest.fn(() => opts.tagName), + composeCommitMessage: jest.fn(() => opts.commitMessage), + validateContext: jest.fn(() => ({ + valid: opts.valid, + errors: opts.validationErrors, + })), + }; +} + +function createMockStrategyRegistry(strategy: ReturnType) { + return { + register: jest.fn(), + getStrategy: jest.fn(() => strategy), + availableStrategies: jest.fn(() => [ + 'default', 'trunk-based', 'git-flow', + 'release-branch', 'hotfix', 'maintenance', + ]), + }; +} + +const baseOpts = { + semver: 'patch', + branch: 'fix-login', + push: false, + dryRun: false, + json: false, + verbose: false, +}; + +beforeEach(() => { + fs.readFileSync.mockReturnValue(JSON.stringify({ version: '1.2.2' })); + fs.existsSync.mockReturnValue(true); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Integration tests: Branching workflow with different strategies +// --------------------------------------------------------------------------- + +describe('Integration: Branching workflow with strategies', () => { + // Test 1: Pipeline with default strategy — branch/tag names match legacy functions + test('default strategy — branch and tag names match legacy functions', async () => { + const strategy = createMockStrategy({ + name: 'default', + branchName: 'version/patch/1.2.3/fix-login', + reuseBranch: false, + tagName: '1.2.3--fix-login', + commitMessage: 'Patch: v1.2.3. You SHOULD consider changes.', + }); + const registry = createMockStrategyRegistry(strategy); + const executor = createMockExecutor('master'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const result = await runPipeline(baseOpts, { + executor, + config: configWithStrategy('default'), + rollbackManager: rollback, + artifactChecker, + strategyRegistry: registry, + }); + + expect(result.success).toBe(true); + expect(result.version).toBe('1.2.3'); + expect(result.previousVersion).toBe('1.2.2'); + expect(result.branch).toBe('version/patch/1.2.3/fix-login'); + expect(result.tag).toBe('1.2.3--fix-login'); + expect(result.exitCode).toBe(EXIT_CODES.SUCCESS); + expect((result as any).strategy).toBe('default'); + + // Strategy methods were called + expect(strategy.composeBranchName).toHaveBeenCalled(); + expect(strategy.composeTagName).toHaveBeenCalled(); + expect(strategy.composeCommitMessage).toHaveBeenCalled(); + expect(strategy.validateContext).toHaveBeenCalled(); + + // git checkout -b was called for the branch + const checkoutNewCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => cmd.includes('git checkout -b')); + expect(checkoutNewCmds.length).toBe(1); + expect(checkoutNewCmds[0]).toContain('version/patch/1.2.3/fix-login'); + + // Rollback recorded BRANCH_CREATED + const branchCreatedSteps = rollback._recorded.filter( + (s: any) => s.type === 'branch_created', + ); + expect(branchCreatedSteps.length).toBe(1); + }); + + // Test 2: Pipeline with trunk-based strategy — no branch creation, tag v{version} + test('trunk-based strategy — no branch creation, tag v{version}', async () => { + const strategy = createMockStrategy({ + name: 'trunk-based', + branchName: null, + reuseBranch: false, + tagName: 'v1.2.3', + commitMessage: 'Patch: v1.2.3. You SHOULD consider changes.', + }); + const registry = createMockStrategyRegistry(strategy); + const executor = createMockExecutor('main'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const result = await runPipeline(baseOpts, { + executor, + config: configWithStrategy('trunk-based'), + rollbackManager: rollback, + artifactChecker, + strategyRegistry: registry, + }); + + expect(result.success).toBe(true); + expect(result.version).toBe('1.2.3'); + expect(result.branch).toBe('main'); // current branch, no new branch + expect(result.tag).toBe('v1.2.3'); + expect((result as any).strategy).toBe('trunk-based'); + + // No git checkout -b should have been called + const checkoutNewCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => cmd.includes('git checkout -b')); + expect(checkoutNewCmds).toHaveLength(0); + + // No BRANCH_CREATED in rollback + const branchCreatedSteps = rollback._recorded.filter( + (s: any) => s.type === 'branch_created', + ); + expect(branchCreatedSteps).toHaveLength(0); + + // Artifact checker called with null branchName + expect(artifactChecker.checkUniqueness).toHaveBeenCalledWith( + expect.objectContaining({ branchName: null }), + ); + + // Tag was created + const tagCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => cmd.includes('git tag --annotate')); + expect(tagCmds.length).toBe(1); + expect(tagCmds[0]).toContain('v1.2.3'); + }); + + // Test 3: Pipeline with git-flow strategy (minor) — branch release/{version} + test('git-flow strategy (minor) — branch release/{version}', async () => { + const strategy = createMockStrategy({ + name: 'git-flow', + branchName: 'release/1.2.3', + reuseBranch: false, + tagName: 'v1.2.3', + commitMessage: 'Minor: v1.2.3. You MUST consider changes.', + }); + const registry = createMockStrategyRegistry(strategy); + const executor = createMockExecutor('develop'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const result = await runPipeline( + { ...baseOpts, semver: 'minor' }, + { + executor, + config: configWithStrategy('git-flow'), + rollbackManager: rollback, + artifactChecker, + strategyRegistry: registry, + }, + ); + + expect(result.success).toBe(true); + expect(result.branch).toBe('release/1.2.3'); + expect(result.tag).toBe('v1.2.3'); + expect((result as any).strategy).toBe('git-flow'); + + // git checkout -b release/1.2.3 + const checkoutNewCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => cmd.includes('git checkout -b')); + expect(checkoutNewCmds.length).toBe(1); + expect(checkoutNewCmds[0]).toContain('release/1.2.3'); + }); + + // Test 4: Pipeline with git-flow strategy (patch) — branch hotfix/{version} + test('git-flow strategy (patch) — branch hotfix/{version}', async () => { + const strategy = createMockStrategy({ + name: 'git-flow', + branchName: 'hotfix/1.2.3', + reuseBranch: false, + tagName: 'v1.2.3', + commitMessage: 'Patch: v1.2.3. You SHOULD consider changes.', + }); + const registry = createMockStrategyRegistry(strategy); + const executor = createMockExecutor('master'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const result = await runPipeline(baseOpts, { + executor, + config: configWithStrategy('git-flow'), + rollbackManager: rollback, + artifactChecker, + strategyRegistry: registry, + }); + + expect(result.success).toBe(true); + expect(result.branch).toBe('hotfix/1.2.3'); + expect(result.tag).toBe('v1.2.3'); + expect((result as any).strategy).toBe('git-flow'); + + // git checkout -b hotfix/1.2.3 + const checkoutNewCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => cmd.includes('git checkout -b')); + expect(checkoutNewCmds.length).toBe(1); + expect(checkoutNewCmds[0]).toContain('hotfix/1.2.3'); + }); + + // Test 5: Pipeline with policyChecker (warnings) — warnings in stderr, pipeline continues + test('policyChecker with warnings — warnings in stderr, pipeline continues', async () => { + const strategy = createMockStrategy({ + name: 'default', + branchName: 'version/patch/1.2.3/fix-login', + tagName: '1.2.3--fix-login', + commitMessage: 'Patch: v1.2.3. You SHOULD consider changes.', + }); + const registry = createMockStrategyRegistry(strategy); + const executor = createMockExecutor('master'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const mockPolicyChecker = jest.fn(async () => ({ + warnings: [ + 'Branch "main" has pushRemote configured — push may be redirected', + 'receive.denyNonFastForwards is enabled', + ], + errors: [], + protectionInfo: { + protected: true, + source: 'git-config' as const, + }, + })); + + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + const result = await runPipeline(baseOpts, { + executor, + config: configWithStrategy('default'), + rollbackManager: rollback, + artifactChecker, + strategyRegistry: registry, + policyChecker: mockPolicyChecker, + }); + + expect(result.success).toBe(true); + expect(mockPolicyChecker).toHaveBeenCalledTimes(1); + + // Warnings should be written to stderr + const stderrOutput = stderrSpy.mock.calls.map((c: any[]) => c[0]).join(''); + expect(stderrOutput).toContain('pushRemote'); + expect(stderrOutput).toContain('denyNonFastForwards'); + + // Pipeline still completed mutation steps + const tagCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => cmd.includes('git tag --annotate')); + expect(tagCmds.length).toBe(1); + } finally { + stderrSpy.mockRestore(); + } + }); + + // Test 6: Pipeline with policyChecker (errors) — POLICY_VIOLATION thrown + test('policyChecker with errors — POLICY_VIOLATION thrown, no mutations', async () => { + const strategy = createMockStrategy({ + name: 'default', + branchName: 'version/patch/1.2.3/fix-login', + tagName: '1.2.3--fix-login', + }); + const registry = createMockStrategyRegistry(strategy); + const executor = createMockExecutor('master'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const mockPolicyChecker = jest.fn(async () => ({ + warnings: [], + errors: ['Direct push to protected branch is not allowed'], + protectionInfo: { + protected: true, + source: 'scm-api' as const, + requirePullRequest: true, + }, + })); + + try { + await runPipeline(baseOpts, { + executor, + config: configWithStrategy('default'), + rollbackManager: rollback, + artifactChecker, + strategyRegistry: registry, + policyChecker: mockPolicyChecker, + }); + throw new Error('Expected to throw'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.POLICY_VIOLATION); + expect(err.message).toContain('Direct push to protected branch is not allowed'); + } + + // No mutation commands should have been executed + const mutationCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter( + (cmd: string) => + cmd.includes('git checkout -b') || + cmd.includes('git tag --annotate') || + cmd.includes('git commit') || + cmd.includes('git push'), + ); + expect(mutationCmds).toHaveLength(0); + + // Rollback should not have been called (error before mutations) + expect(rollback.rollback).not.toHaveBeenCalled(); + }); + + // Test 7: Backward compatibility — no strategyRegistry → legacy behavior + test('backward compatibility — no strategyRegistry uses legacy functions', async () => { + const executor = createMockExecutor('master'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + // No strategyRegistry, no policyChecker — legacy path + const result = await runPipeline(baseOpts, { + executor, + config: baseMockConfig, + rollbackManager: rollback, + artifactChecker, + }); + + expect(result.success).toBe(true); + expect(result.version).toBe('1.2.3'); + expect(result.branch).toBe('version/patch/1.2.3/fix-login'); + expect(result.tag).toBe('1.2.3--fix-login'); + expect(result.exitCode).toBe(EXIT_CODES.SUCCESS); + + // strategy field should not be set + expect((result as any).strategy).toBeUndefined(); + }); + + // Test 8: Release-branch strategy with reuse — BRANCH_SWITCHED in rollback + test('release-branch strategy with reuse — git checkout without -b, BRANCH_SWITCHED recorded', async () => { + const strategy = createMockStrategy({ + name: 'release-branch', + branchName: 'release/1.2.0', + reuseBranch: true, + tagName: 'v1.2.3', + commitMessage: 'Patch: v1.2.3. You SHOULD consider changes.', + }); + const registry = createMockStrategyRegistry(strategy); + const executor = createMockExecutor('develop'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const result = await runPipeline(baseOpts, { + executor, + config: configWithStrategy('release-branch'), + rollbackManager: rollback, + artifactChecker, + strategyRegistry: registry, + }); + + expect(result.success).toBe(true); + expect(result.branch).toBe('release/1.2.0'); + expect(result.tag).toBe('v1.2.3'); + expect((result as any).strategy).toBe('release-branch'); + + // Should call git checkout (without -b) for reuse + const checkoutReuseCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter( + (cmd: string) => + cmd.match(/git checkout (?!-b)(?!--)/) && cmd.includes('release/1.2.0'), + ); + expect(checkoutReuseCmds.length).toBeGreaterThan(0); + + // Should NOT call git checkout -b + const checkoutNewCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => cmd.includes('git checkout -b')); + expect(checkoutNewCmds).toHaveLength(0); + + // Rollback recorded BRANCH_SWITCHED (not BRANCH_CREATED) + const switchedSteps = rollback._recorded.filter( + (s: any) => s.type === 'branch_switched', + ); + expect(switchedSteps.length).toBe(1); + expect(switchedSteps[0].meta.previousBranch).toBe('develop'); + + const createdSteps = rollback._recorded.filter( + (s: any) => s.type === 'branch_created', + ); + expect(createdSteps).toHaveLength(0); + + // Artifact checker called with skipBranchCheck + expect(artifactChecker.checkUniqueness).toHaveBeenCalledWith( + expect.objectContaining({ skipBranchCheck: true }), + ); + }); + + // Test 9: Dry-run with strategy and policyCheck + test('dry-run with strategy and policyCheck — plan contains both fields', async () => { + const strategy = createMockStrategy({ + name: 'git-flow', + branchName: 'release/1.2.3', + tagName: 'v1.2.3', + commitMessage: 'Minor: v1.2.3. You MUST consider changes.', + }); + const registry = createMockStrategyRegistry(strategy); + const executor = createMockExecutor('develop'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const mockPolicyChecker = jest.fn(async () => ({ + warnings: ['Signed commits required'], + errors: [], + protectionInfo: { + protected: true, + source: 'git-config' as const, + gpgSignConfigured: true, + }, + })); + + const plan = await runPipeline( + { ...baseOpts, semver: 'minor', dryRun: true }, + { + executor, + config: configWithStrategy('git-flow'), + rollbackManager: rollback, + artifactChecker, + strategyRegistry: registry, + policyChecker: mockPolicyChecker, + }, + ); + + expect(plan.dryRun).toBe(true); + expect(plan.nextVersion).toBe('1.2.3'); + expect(plan.branch).toBe('release/1.2.3'); + expect(plan.tag).toBe('v1.2.3'); + expect((plan as any).strategy).toBe('git-flow'); + expect((plan as any).policyCheck).toBeDefined(); + expect((plan as any).policyCheck.warnings).toContain('Signed commits required'); + expect((plan as any).policyCheck.protectionInfo.protected).toBe(true); + + // No mutation commands in dry-run + const mutationCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter( + (cmd: string) => + cmd.includes('git checkout -b') || + cmd.includes('git tag --annotate') || + cmd.includes('git commit') || + cmd.includes('git push'), + ); + expect(mutationCmds).toHaveLength(0); + expect(rollback.record).not.toHaveBeenCalled(); + }); + + // Test 10: Trunk-based with push — pushes current branch + test('trunk-based with push — pushes current branch with --follow-tags', async () => { + const strategy = createMockStrategy({ + name: 'trunk-based', + branchName: null, + reuseBranch: false, + tagName: 'v1.2.3', + commitMessage: 'Patch: v1.2.3. You SHOULD consider changes.', + }); + const registry = createMockStrategyRegistry(strategy); + const executor = createMockExecutor('main'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const result = await runPipeline( + { ...baseOpts, push: true, noPr: true }, + { + executor, + config: configWithStrategy('trunk-based'), + rollbackManager: rollback, + artifactChecker, + strategyRegistry: registry, + }, + ); + + expect(result.success).toBe(true); + expect(result.branch).toBe('main'); + + // Push should use current branch (main), not a new branch + const pushCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => cmd.includes('git push')); + expect(pushCmds.length).toBe(1); + expect(pushCmds[0]).toContain('main'); + expect(pushCmds[0]).toContain('--follow-tags'); + }); +}); diff --git a/__tests__/integration/config.hierarchy.test.ts b/__tests__/integration/config.hierarchy.test.ts new file mode 100644 index 0000000..7602140 --- /dev/null +++ b/__tests__/integration/config.hierarchy.test.ts @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * Integration tests: Config hierarchy with real files in tmpdir. + * + * Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 4.1, 5.1, 5.2, 5.3, 16.1, 16.2 + */ + +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { loadConfig } from '../../src/config/config.loader'; +import { EXIT_CODES, VersioningsError } from '../../src/core/errors'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-cfg-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +/** Minimal valid git config satisfying the schema (platform + url required). */ +const VALID_GIT = { + git: { platform: 'github', url: 'https://github.com/org/repo.git' }, +}; + +function writeFile(name: string, content: string): void { + fs.writeFileSync(path.join(tmpDir, name), content, 'utf8'); +} + +function writeJson(name: string, obj: Record): void { + writeFile(name, JSON.stringify(obj, null, 2)); +} + +describe('Integration: Config hierarchy (real files)', () => { + // 1. Load from version.json (legacy, backward compatibility) + test('loads config from version.json (legacy)', () => { + writeJson('version.json', VALID_GIT); + + const result = loadConfig({ cwd: tmpDir, env: {} }); + + expect(result.config.git.platform).toBe('github'); + expect(result.config.git.url).toBe('https://github.com/org/repo.git'); + expect(result.sources.some((s) => s.name === 'version.json')).toBe(true); + // Defaults are merged in + expect(result.config.git.remote).toBe('origin'); + expect(result.config.git.pr.target).toBe('master'); + }); + + // 2. Load from .versioningsrc.json + test('loads config from .versioningsrc.json', () => { + writeJson('.versioningsrc.json', VALID_GIT); + + const result = loadConfig({ cwd: tmpDir, env: {} }); + + expect(result.config.git.platform).toBe('github'); + expect(result.config.git.url).toBe('https://github.com/org/repo.git'); + expect(result.sources.some((s) => s.name === '.versioningsrc.json')).toBe(true); + }); + + // 3. Load from .versioningsrc.yml (YAML) + test('loads config from .versioningsrc.yml (YAML)', () => { + const yaml = [ + 'git:', + ' platform: bitbucket', + ' url: https://bitbucket.org/org/repo.git', + ' pr:', + ' target: develop', + ].join('\n') + '\n'; + writeFile('.versioningsrc.yml', yaml); + + const result = loadConfig({ cwd: tmpDir, env: {} }); + + expect(result.config.git.platform).toBe('bitbucket'); + expect(result.config.git.url).toBe('https://bitbucket.org/org/repo.git'); + expect(result.config.git.pr.target).toBe('develop'); + expect(result.sources.some((s) => s.name === '.versioningsrc.yml')).toBe(true); + }); + + // 4. Load from package.json#versionings + test('loads config from package.json#versionings', () => { + writeJson('package.json', { + name: 'test-project', + version: '1.0.0', + versionings: VALID_GIT, + }); + + const result = loadConfig({ cwd: tmpDir, env: {} }); + + expect(result.config.git.platform).toBe('github'); + expect(result.config.git.url).toBe('https://github.com/org/repo.git'); + expect(result.sources.some((s) => s.name === 'package.json#versionings')).toBe(true); + }); + + // 5. Env vars override file sources + test('env vars override file sources', () => { + writeJson('version.json', VALID_GIT); + + const result = loadConfig({ + cwd: tmpDir, + env: { + VERSIONINGS_GIT_PLATFORM: 'bitbucket', + VERSIONINGS_GIT_PR_TARGET: 'develop', + }, + }); + + // Env overrides version.json platform + expect(result.config.git.platform).toBe('bitbucket'); + // Env overrides default pr.target + expect(result.config.git.pr.target).toBe('develop'); + // URL still from version.json + expect(result.config.git.url).toBe('https://github.com/org/repo.git'); + expect(result.sources.some((s) => s.name === 'env')).toBe(true); + // Provenance reflects env as source for overridden fields + expect(result.provenance['git.platform'].source).toBe('env'); + expect(result.provenance['git.url'].source).toBe('version.json'); + }); + + // 6. CLI args override everything + test('CLI args override everything', () => { + writeJson('version.json', VALID_GIT); + + const result = loadConfig({ + cwd: tmpDir, + env: { VERSIONINGS_GIT_PLATFORM: 'bitbucket' }, + cliOverrides: { + git: { platform: 'github', pr: { target: 'main' } }, + }, + }); + + // CLI wins over env + expect(result.config.git.platform).toBe('github'); + // CLI wins over defaults + expect(result.config.git.pr.target).toBe('main'); + expect(result.sources.some((s) => s.name === 'cli')).toBe(true); + expect(result.provenance['git.platform'].source).toBe('cli'); + expect(result.provenance['git.pr.target'].source).toBe('cli'); + }); + + // 7. Deep merge — fields from different sources combine + test('deep merge — fields from different sources combine', () => { + // version.json provides platform + url + writeJson('version.json', { + git: { platform: 'github', url: 'https://github.com/org/repo.git' }, + }); + // .versioningsrc.json provides pr.target + writeJson('.versioningsrc.json', { + git: { pr: { target: 'develop' } }, + }); + + const result = loadConfig({ + cwd: tmpDir, + env: { VERSIONINGS_GIT_REMOTE: 'upstream' }, + }); + + // All fields from different sources are present + expect(result.config.git.platform).toBe('github'); + expect(result.config.git.url).toBe('https://github.com/org/repo.git'); + expect(result.config.git.pr.target).toBe('develop'); + expect(result.config.git.remote).toBe('upstream'); + // Provenance tracks each source + expect(result.provenance['git.platform'].source).toBe('version.json'); + expect(result.provenance['git.pr.target'].source).toBe('.versioningsrc.json'); + expect(result.provenance['git.remote'].source).toBe('env'); + }); + + // 8. Multiple RC files — warning, first one used (deterministic order) + test('multiple RC files — warning emitted, first in priority order used', () => { + // Both .versioningsrc.json and .versioningsrc.yml exist + writeJson('.versioningsrc.json', { + git: { platform: 'github', url: 'https://github.com/org/repo.git' }, + }); + writeFile('.versioningsrc.yml', [ + 'git:', + ' platform: bitbucket', + ' url: https://bitbucket.org/org/repo.git', + ].join('\n') + '\n'); + + const result = loadConfig({ cwd: tmpDir, env: {} }); + + // .versioningsrc.json has higher priority than .versioningsrc.yml + expect(result.config.git.platform).toBe('github'); + expect(result.sources.some((s) => s.name === '.versioningsrc.json')).toBe(true); + expect(result.sources.every((s) => s.name !== '.versioningsrc.yml')).toBe(true); + // Warning about multiple RC files + expect(result.warnings.some((w) => w.includes('Multiple RC files'))).toBe(true); + expect(result.warnings.some((w) => w.includes('.versioningsrc.json') && w.includes('.versioningsrc.yml'))).toBe(true); + }); + + // 9. Strict mode — unknown top-level fields → error (additionalProperties: false at root) + test('unknown top-level fields cause CONFIG_ERROR', () => { + writeJson('version.json', { + ...VALID_GIT, + unknownField: 'should-fail', + }); + + expect(() => loadConfig({ cwd: tmpDir, env: {} })).toThrow(VersioningsError); + + try { + loadConfig({ cwd: tmpDir, env: {} }); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }); + + // 10. No sources → warning with example + test('no configuration sources — throws CONFIG_ERROR (defaults alone fail schema)', () => { + // Empty tmpdir — no config files at all + + expect(() => loadConfig({ cwd: tmpDir, env: {} })).toThrow(VersioningsError); + + try { + loadConfig({ cwd: tmpDir, env: {} }); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }); +}); diff --git a/__tests__/integration/lock.workflow.test.ts b/__tests__/integration/lock.workflow.test.ts new file mode 100644 index 0000000..3cb2bb3 --- /dev/null +++ b/__tests__/integration/lock.workflow.test.ts @@ -0,0 +1,460 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * Integration tests: Lock workflow — lock acquire/release in pipeline, + * dry-run without lock, parallel lock conflict, stale lock cleanup. + * + * Uses real filesystem (temp directories) and real lock manager. + * Mock executor simulates git commands (no real git). + * + * Note: lock.manager.ts requires esbuild pre-transform due to a Babel + * limitation with `import type` bindings used in interface declarations. + * + * Validates: Requirements 6.1, 6.5, 7.1, 7.2, 9.1, 12.1, 12.2, 12.3 + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { PassThrough } from 'stream'; +import { runPipeline } from '../../src/core/pipeline'; +import { createStructuredLogger, generateOperationId } from '../../src/core/structured.logger'; +import { createActionTracer } from '../../src/core/action.tracer'; +import { createRollbackManager } from '../../src/core/rollback'; +import { VersioningsError, EXIT_CODES } from '../../src/core/errors'; +import type { Executor, ExecutorResult } from '../../src/core/executor'; +import type { ArtifactChecker } from '../../src/core/artifact.checker'; +import type { VersioningsConfig } from '../../src/config/config.validator'; +import type { PipelineResult } from '../../src/core/reporter'; +import type { LockData } from '../../src/core/lock.manager'; + +// ── Pre-transform lock.manager.ts (esbuild-jest Babel workaround) ────────── + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const esbuildLib = require('esbuild'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const nodeFs = require('fs'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const nodePath = require('path'); + +const lockManagerSrcPath = nodePath.resolve(__dirname, '../../src/core/lock.manager.ts'); +const lockManagerSrcDir = nodePath.dirname(lockManagerSrcPath); +const lockManagerRaw = nodeFs.readFileSync(lockManagerSrcPath, 'utf-8'); +const lockManagerTransformed = esbuildLib.transformSync(lockManagerRaw, { + loader: 'ts', + format: 'cjs', + target: 'es2018', +}); + +const lockManagerRequire = (id: string): unknown => { + if (id.startsWith('.')) { + return require(nodePath.resolve(lockManagerSrcDir, id)); + } + return require(id); +}; + +const lockManagerExports: Record = {}; +const lockManagerMod = { exports: lockManagerExports }; +const lockManagerRunner = new Function( + 'exports', 'require', 'module', '__filename', '__dirname', + lockManagerTransformed.code, +); +lockManagerRunner( + lockManagerExports, + lockManagerRequire, + lockManagerMod, + lockManagerSrcPath, + lockManagerSrcDir, +); + +const createLockManager = lockManagerMod.exports.createLockManager as + typeof import('../../src/core/lock.manager').createLockManager; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +let tempDirs: string[] = []; +let originalCwd: string; + +beforeEach(() => { + originalCwd = process.cwd(); +}); + +afterEach(() => { + process.chdir(originalCwd); + for (const dir of tempDirs) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + tempDirs = []; +}); + +function createTempProject(version: string = '1.0.0'): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-lock-')); + tempDirs.push(dir); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'test-project', version }, null, 2) + '\n', + ); + return dir; +} + +function createMockExecutor(): Executor { + const responses: Record = { + 'git status --porcelain': '', + 'git remote --verbose': 'origin\thttps://github.com/test/repo.git (fetch)\norigin\thttps://github.com/test/repo.git (push)', + 'git tag --list': '', + 'git branch --list': '* main', + 'git rev-parse --abbrev-ref HEAD': 'main', + }; + + return { + async run(cmd: string): Promise { + if (cmd.startsWith('npm --no-git-tag-version version')) { + return { stdout: 'v1.0.1', lines: ['v1.0.1'] }; + } + if (cmd.startsWith('git checkout --')) { + return { stdout: '', lines: [] }; + } + if (cmd.startsWith('git checkout -b')) { + return { stdout: '', lines: [] }; + } + if (cmd.startsWith('git tag --annotate')) { + return { stdout: '', lines: [] }; + } + if (cmd.startsWith('git commit')) { + return { stdout: '', lines: [] }; + } + for (const [pattern, response] of Object.entries(responses)) { + if (cmd === pattern) { + const trimmed = response.trim(); + const lines = trimmed ? trimmed.split(/\r?\n/).filter(Boolean) : []; + return { stdout: trimmed, lines }; + } + } + return { stdout: '', lines: [] }; + }, + }; +} + +function createMockArtifactChecker(): ArtifactChecker { + return { + async checkUniqueness(): Promise { /* always passes */ }, + }; +} + +function createTestConfig(): VersioningsConfig { + return { + git: { + platform: 'github', + url: 'https://github.com/test/repo.git', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + prepatch: 'Patch version is preparing now: v%s.', + patch: 'Patch: v%s. You SHOULD consider changes.', + preminor: 'Minor version is preparing now: v%s.', + minor: 'Minor: v%s. You MUST consider changes.', + premajor: 'Release is preparing now: v%s.', + major: 'Release: v%s.', + prerelease: 'Preparing: v%s.', + }, + }, + }, + }, + package: { + semver: { + patch: 'patch', prepatch: 'prepatch', minor: 'minor', + preminor: 'preminor', premajor: 'premajor', prerelease: 'prerelease', major: 'major', + }, + }, + common: { + messages: { + versionConfigDoesNotExist: 'Version configuration DOES NOT exist.', + undefinedGitRepositoryUrl: 'Git repository URL is undefined.', + unavailableVersioningDirectory: 'Get back to the root directory.', + unavailableSemanticVersion: 'Semantic version is unavailable.', + undefinedVersionBranchName: 'Version branch name is undefined.', + incorrectVersionBranchNameLength: 'Branch name too long, max', + incorrectVersionBranchNameCharactersDashes: 'Branch name MUST NOT contain multi dashes.', + versionBranchAlreadyExists: 'Version branch already exists.', + untrackedGitFiles: 'You have untracked git files.', + unavailableGitPlatform: 'Git platform is unavailable.', + unavailableGitTargetBranch: 'Git target branch is unavailable.', + versionAlreadyExists: 'Version number already exists.', + versionAlreadyExistsTag: 'Version already exists (tag).', + versionAlreadyExistsBranch: 'Version already exists (branch).', + incorrectGitRemote: 'Git remote is unavailable.', + }, + }, + logLevel: 'debug', + lockTimeoutMs: 300000, + }; +} + +function createSilentLogger() { + const stream = new PassThrough(); + stream.on('data', () => { /* discard */ }); + return createStructuredLogger({ + output: stream, + level: 'debug', + operationId: generateOperationId(), + ci: false, + }); +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('Integration: Lock workflow', () => { + test('lock acquire creates .versionings/lock, release removes it after pipeline', async () => { + const projectDir = createTempProject('1.0.0'); + process.chdir(projectDir); + + const lockDir = path.join(projectDir, '.versionings'); + const lockFilePath = path.join(lockDir, 'lock'); + const operationId = generateOperationId(); + const logger = createSilentLogger(); + + const lockManager = createLockManager({ + lockDir, + lockTimeoutMs: 300000, + ci: false, + hostname: 'test-host', + logger, + }); + + const executor = createMockExecutor(); + const rollbackManager = createRollbackManager(executor, logger); + + const result = await runPipeline( + { + semver: 'patch', + branch: 'fix-lock-test', + push: false, + dryRun: false, + json: false, + verbose: false, + }, + { + executor, + config: createTestConfig(), + rollbackManager, + artifactChecker: createMockArtifactChecker(), + logger, + actionTracer: createActionTracer(), + lockManager, + operationId, + }, + ) as PipelineResult; + + expect(result.success).toBe(true); + expect(result.version).toBe('1.0.1'); + + // After pipeline completes, lock file should be removed (release in finally) + expect(fs.existsSync(lockFilePath)).toBe(false); + + // Lock directory should still exist (only the lock file is removed) + expect(fs.existsSync(lockDir)).toBe(true); + }, 15000); + + test('dry-run pipeline does NOT create lock file', async () => { + const projectDir = createTempProject('1.0.0'); + process.chdir(projectDir); + + const lockDir = path.join(projectDir, '.versionings'); + const lockFilePath = path.join(lockDir, 'lock'); + const operationId = generateOperationId(); + const logger = createSilentLogger(); + + const lockManager = createLockManager({ + lockDir, + lockTimeoutMs: 300000, + ci: false, + hostname: 'test-host', + logger, + }); + + const executor = createMockExecutor(); + const rollbackManager = createRollbackManager(executor, logger); + + const result = await runPipeline( + { + semver: 'patch', + branch: 'fix-dryrun', + push: false, + dryRun: true, + json: false, + verbose: false, + }, + { + executor, + config: createTestConfig(), + rollbackManager, + artifactChecker: createMockArtifactChecker(), + logger, + actionTracer: createActionTracer(), + lockManager, + operationId, + }, + ); + + expect((result as any).dryRun).toBe(true); + + // Lock file should NOT exist — dry-run skips lock acquisition + expect(fs.existsSync(lockFilePath)).toBe(false); + }, 15000); + + test('parallel lock conflict: second acquire throws VersioningsError(COMMAND_FAILED)', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-lock-conflict-')); + tempDirs.push(tempDir); + + const lockDir = path.join(tempDir, '.versionings'); + const lockFilePath = path.join(lockDir, 'lock'); + const logger = createSilentLogger(); + + // First lock manager acquires the lock + const lm1 = createLockManager({ + lockDir, + lockTimeoutMs: 300000, + ci: false, + hostname: 'host-1', + logger, + }); + lm1.acquire(generateOperationId(), 'release'); + + // Verify lock file was created + expect(fs.existsSync(lockFilePath)).toBe(true); + + // Verify lock file contains valid JSON with expected fields + const lockContent = JSON.parse(fs.readFileSync(lockFilePath, 'utf-8')) as LockData; + expect(lockContent.pid).toBe(process.pid); + expect(lockContent.command).toBe('release'); + expect(lockContent.hostname).toBe('host-1'); + expect(typeof lockContent.operationId).toBe('string'); + expect(typeof lockContent.createdAt).toBe('string'); + + // Second lock manager tries to acquire — should fail + const lm2 = createLockManager({ + lockDir, + lockTimeoutMs: 300000, + ci: false, + hostname: 'host-2', + logger, + }); + + try { + lm2.acquire(generateOperationId(), 'release'); + fail('Expected VersioningsError to be thrown'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.COMMAND_FAILED); + } + + // Clean up: release the first lock + lm1.release(); + expect(fs.existsSync(lockFilePath)).toBe(false); + }); + + test('stale lock (dead PID) is automatically cleaned up on acquire', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-lock-stale-pid-')); + tempDirs.push(tempDir); + + const lockDir = path.join(tempDir, '.versionings'); + fs.mkdirSync(lockDir, { recursive: true }); + + // Write a lock file with a dead PID (use processInfo DI to simulate) + const staleLockData: LockData = { + pid: 999888777, + operationId: generateOperationId(), + command: 'release', + createdAt: new Date().toISOString(), + hostname: 'old-host', + ci: false, + }; + const lockFilePath = path.join(lockDir, 'lock'); + fs.writeFileSync(lockFilePath, JSON.stringify(staleLockData, null, 2), 'utf-8'); + expect(fs.existsSync(lockFilePath)).toBe(true); + + const logger = createSilentLogger(); + + // Create lock manager with processInfo that reports the stale PID as dead + const lm = createLockManager({ + lockDir, + lockTimeoutMs: 300000, + ci: false, + hostname: 'new-host', + logger, + processInfo: { + pid: process.pid, + kill: (pid: number, signal: number) => { + if (pid === staleLockData.pid) { + const err = new Error('ESRCH') as NodeJS.ErrnoException; + err.code = 'ESRCH'; + throw err; + } + return process.kill(pid, signal); + }, + on: (event: string, handler: () => void) => { process.on(event, handler); }, + }, + }); + + // Acquire should succeed — stale lock is cleaned up + expect(() => lm.acquire(generateOperationId(), 'release')).not.toThrow(); + + // New lock file should exist with our PID + expect(fs.existsSync(lockFilePath)).toBe(true); + const newLock = JSON.parse(fs.readFileSync(lockFilePath, 'utf-8')) as LockData; + expect(newLock.pid).toBe(process.pid); + expect(newLock.hostname).toBe('new-host'); + + // Clean up + lm.release(); + }); + + test('stale lock (timeout exceeded) is automatically cleaned up on acquire', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-lock-stale-timeout-')); + tempDirs.push(tempDir); + + const lockDir = path.join(tempDir, '.versionings'); + fs.mkdirSync(lockDir, { recursive: true }); + + const lockTimeoutMs = 5000; // 5 seconds + + // Write a lock file with a timestamp well past the timeout + const staleLockData: LockData = { + pid: process.pid, // PID is alive, but timeout exceeded + operationId: generateOperationId(), + command: 'release', + createdAt: new Date(Date.now() - lockTimeoutMs - 10000).toISOString(), // 15 seconds ago + hostname: 'old-host', + ci: false, + }; + const lockFilePath = path.join(lockDir, 'lock'); + fs.writeFileSync(lockFilePath, JSON.stringify(staleLockData, null, 2), 'utf-8'); + expect(fs.existsSync(lockFilePath)).toBe(true); + + const logger = createSilentLogger(); + + const lm = createLockManager({ + lockDir, + lockTimeoutMs, + ci: false, + hostname: 'new-host', + logger, + }); + + // Acquire should succeed — stale lock (timeout) is cleaned up + expect(() => lm.acquire(generateOperationId(), 'release')).not.toThrow(); + + // New lock file should exist + expect(fs.existsSync(lockFilePath)).toBe(true); + const newLock = JSON.parse(fs.readFileSync(lockFilePath, 'utf-8')) as LockData; + expect(newLock.hostname).toBe('new-host'); + + // Clean up + lm.release(); + }); +}); diff --git a/__tests__/integration/observability.workflow.test.ts b/__tests__/integration/observability.workflow.test.ts new file mode 100644 index 0000000..2a2d59b --- /dev/null +++ b/__tests__/integration/observability.workflow.test.ts @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * Integration tests: Observability workflow — structured logger, action tracer, audit entry v2. + * + * Tests the full release pipeline with all observability dependencies wired in: + * - Structured Logger writing JSON to a PassThrough stream (captures stderr output) + * - Action Tracer recording step timings + * - Operation Log saving AuditEntry (schemaVersion: 2) + * - Reporter JSON output including operationId and totalDurationMs + * + * Uses a mock executor (no real git commands) but real structured logger, + * action tracer, and operation log. + * + * Validates: Requirements 1.1, 2.2, 2.4, 3.5, 4.1, 4.5, 5.1, 14.1, 14.2 + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { PassThrough } from 'stream'; +import { runPipeline } from '../../src/core/pipeline'; +import { createStructuredLogger, generateOperationId } from '../../src/core/structured.logger'; +import { createActionTracer } from '../../src/core/action.tracer'; +import { createOperationLog } from '../../src/core/operation.log'; +import { createReporter } from '../../src/core/reporter'; +import { createRollbackManager } from '../../src/core/rollback'; +import type { Executor, ExecutorResult } from '../../src/core/executor'; +import type { ArtifactChecker } from '../../src/core/artifact.checker'; +import type { VersioningsConfig } from '../../src/config/config.validator'; +import type { PipelineResult } from '../../src/core/reporter'; +import type { ActorMetadata } from '../../src/core/actor.resolver'; +import type { AuditEntry } from '../../src/core/operation.log'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +let tempDirs: string[] = []; +let originalCwd: string; + +beforeEach(() => { + originalCwd = process.cwd(); +}); + +afterEach(() => { + process.chdir(originalCwd); + for (const dir of tempDirs) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + tempDirs = []; +}); + +/** + * Create a temp directory with a package.json for the pipeline to read. + */ +function createTempProject(version: string = '1.0.0'): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-obs-')); + tempDirs.push(dir); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'test-project', version }, null, 2) + '\n', + ); + return dir; +} + +/** + * Create a mock executor that simulates git commands for a successful release. + */ +function createMockExecutor(): Executor { + const responses: Record = { + 'git status --porcelain': '', + 'git remote --verbose': 'origin\thttps://github.com/test/repo.git (fetch)\norigin\thttps://github.com/test/repo.git (push)', + 'git tag --list': '', + 'git branch --list': '* main', + 'git rev-parse --abbrev-ref HEAD': 'main', + }; + + return { + async run(cmd: string): Promise { + // npm version probe — return the bumped version + if (cmd.startsWith('npm --no-git-tag-version version')) { + return { stdout: 'v1.0.1', lines: ['v1.0.1'] }; + } + // git checkout to restore package.json after probe + if (cmd.startsWith('git checkout --')) { + return { stdout: '', lines: [] }; + } + // git checkout -b (branch creation) + if (cmd.startsWith('git checkout -b')) { + return { stdout: '', lines: [] }; + } + // git tag --annotate + if (cmd.startsWith('git tag --annotate')) { + return { stdout: '', lines: [] }; + } + // git commit + if (cmd.startsWith('git commit')) { + return { stdout: '', lines: [] }; + } + // Known responses + for (const [pattern, response] of Object.entries(responses)) { + if (cmd === pattern) { + const trimmed = response.trim(); + const lines = trimmed ? trimmed.split(/\r?\n/).filter(Boolean) : []; + return { stdout: trimmed, lines }; + } + } + // Default: return empty for unknown commands + return { stdout: '', lines: [] }; + }, + }; +} + +/** + * Create a mock artifact checker that always passes. + */ +function createMockArtifactChecker(): ArtifactChecker { + return { + async checkUniqueness(): Promise { + // Always passes + }, + }; +} + +/** + * Create a minimal VersioningsConfig for testing. + */ +function createTestConfig(): VersioningsConfig { + return { + git: { + platform: 'github', + url: 'https://github.com/test/repo.git', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + prepatch: 'Patch version is preparing now: v%s.', + patch: 'Patch: v%s. You SHOULD consider changes.', + preminor: 'Minor version is preparing now: v%s.', + minor: 'Minor: v%s. You MUST consider changes.', + premajor: 'Release is preparing now: v%s.', + major: 'Release: v%s.', + prerelease: 'Preparing: v%s.', + }, + }, + }, + }, + package: { + semver: { + patch: 'patch', prepatch: 'prepatch', minor: 'minor', + preminor: 'preminor', premajor: 'premajor', prerelease: 'prerelease', major: 'major', + }, + }, + common: { + messages: { + versionConfigDoesNotExist: 'Version configuration DOES NOT exist.', + undefinedGitRepositoryUrl: 'Git repository URL is undefined.', + unavailableVersioningDirectory: 'Get back to the root directory.', + unavailableSemanticVersion: 'Semantic version is unavailable.', + undefinedVersionBranchName: 'Version branch name is undefined.', + incorrectVersionBranchNameLength: 'Branch name too long, max', + incorrectVersionBranchNameCharactersDashes: 'Branch name MUST NOT contain multi dashes.', + versionBranchAlreadyExists: 'Version branch already exists.', + untrackedGitFiles: 'You have untracked git files.', + unavailableGitPlatform: 'Git platform is unavailable.', + unavailableGitTargetBranch: 'Git target branch is unavailable.', + versionAlreadyExists: 'Version number already exists.', + versionAlreadyExistsTag: 'Version already exists (tag).', + versionAlreadyExistsBranch: 'Version already exists (branch).', + incorrectGitRemote: 'Git remote is unavailable.', + }, + }, + logLevel: 'debug', + lockTimeoutMs: 300000, + }; +} + +/** + * Collect all data written to a PassThrough stream as a string. + */ +function captureStream(stream: PassThrough): () => string { + let data = ''; + stream.on('data', (chunk: Buffer) => { data += chunk.toString(); }); + return () => data; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('Integration: Observability workflow', () => { + test('full release pipeline with structured logger, action tracer, and audit entry v2', async () => { + // --- Setup temp project directory --- + const projectDir = createTempProject('1.0.0'); + process.chdir(projectDir); + + // --- Create real observability dependencies --- + const operationId = generateOperationId(); + const actor: ActorMetadata = { + gitUserName: 'Test User', + gitUserEmail: 'test@example.com', + hostname: 'test-host', + ciActor: null, + }; + + // Structured logger writing to a PassThrough stream + const logStream = new PassThrough(); + const getLogOutput = captureStream(logStream); + const logger = createStructuredLogger({ + output: logStream, + level: 'debug', + operationId, + actor: actor.gitUserName, + ci: false, + }); + + // Real action tracer + const actionTracer = createActionTracer(); + + // Real operation log in a temp directory + const opLogDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-oplog-')); + tempDirs.push(opLogDir); + const operationLog = createOperationLog(opLogDir); + + // Mock executor and artifact checker + const executor = createMockExecutor(); + const artifactChecker = createMockArtifactChecker(); + const rollbackManager = createRollbackManager(executor, logger); + + // --- Run pipeline --- + const result = await runPipeline( + { + semver: 'patch', + branch: 'fix-obs', + push: false, + dryRun: false, + json: false, + verbose: true, + }, + { + executor, + config: createTestConfig(), + rollbackManager, + artifactChecker, + operationLog, + logger, + actionTracer, + operationId, + actor, + }, + ) as PipelineResult; + + // --- Verify pipeline result --- + expect(result.success).toBe(true); + expect(result.version).toBe('1.0.1'); + + // --- Verify structured logs in stderr contain operationId, actor, timestamps --- + const logOutput = getLogOutput(); + const logLines = logOutput.trim().split('\n').filter(Boolean); + expect(logLines.length).toBeGreaterThan(0); + + for (const line of logLines) { + const entry = JSON.parse(line); + expect(entry.operationId).toBe(operationId); + expect(entry.timestamp).toBeDefined(); + // Verify timestamp is valid ISO 8601 + expect(new Date(entry.timestamp).toISOString()).toBe(entry.timestamp); + expect(entry.level).toBeDefined(); + expect(['debug', 'info', 'warn', 'error']).toContain(entry.level); + expect(entry.message).toBeDefined(); + // Actor should be present on info-level and above entries + if (['info', 'warn', 'error'].includes(entry.level)) { + expect(entry.actor).toBe('Test User'); + } + } + + // Verify at least some info-level step logs exist + const infoLogs = logLines + .map((l: string) => JSON.parse(l)) + .filter((e: any) => e.level === 'info'); + expect(infoLogs.length).toBeGreaterThan(0); + // At least one log should mention a step + const stepLogs = infoLogs.filter((e: any) => + e.message.includes('Step started') || e.message.includes('step'), + ); + expect(stepLogs.length).toBeGreaterThan(0); + + // --- Verify audit entry saved with schemaVersion: 2 --- + const savedEntry = await operationLog.loadLast() as unknown as AuditEntry; + expect(savedEntry).not.toBeNull(); + expect(savedEntry.schemaVersion).toBe(2); + expect(savedEntry.operationId).toBe(operationId); + expect(savedEntry.result).toBe('success'); + expect(savedEntry.version).toBe('1.0.1'); + expect(savedEntry.previousVersion).toBe('1.0.0'); + + // Actor metadata + expect(savedEntry.actor).toEqual({ + gitUserName: 'Test User', + gitUserEmail: 'test@example.com', + hostname: 'test-host', + ciActor: null, + }); + + // Environment + expect(savedEntry.environment).not.toBeNull(); + expect(savedEntry.environment!.nodeVersion).toBe(process.version); + expect(savedEntry.environment!.os).toBeDefined(); + expect(typeof savedEntry.environment!.ci).toBe('boolean'); + + // Command + expect(savedEntry.command).toBeDefined(); + + // --- Verify trace array has entries for pipeline steps --- + expect(Array.isArray(savedEntry.trace)).toBe(true); + expect(savedEntry.trace.length).toBeGreaterThan(0); + + // Each trace entry should have required fields + for (const traceEntry of savedEntry.trace) { + expect(traceEntry.step).toBeDefined(); + expect(typeof traceEntry.step).toBe('string'); + expect(traceEntry.startedAt).toBeDefined(); + expect(new Date(traceEntry.startedAt).toISOString()).toBe(traceEntry.startedAt); + expect(traceEntry.endedAt).toBeDefined(); + expect(new Date(traceEntry.endedAt).toISOString()).toBe(traceEntry.endedAt); + expect(typeof traceEntry.durationMs).toBe('number'); + expect(traceEntry.durationMs).toBeGreaterThanOrEqual(0); + expect(['success', 'failed', 'skipped']).toContain(traceEntry.status); + } + + // Verify expected pipeline steps are traced + const tracedSteps = savedEntry.trace.map((t: any) => t.step); + expect(tracedSteps).toContain('validate-input'); + expect(tracedSteps).toContain('check-git-status'); + expect(tracedSteps).toContain('check-remote'); + expect(tracedSteps).toContain('compute-version'); + expect(tracedSteps).toContain('artifact-check'); + expect(tracedSteps).toContain('npm-version-bump'); + expect(tracedSteps).toContain('branch-create'); + expect(tracedSteps).toContain('tag-create'); + expect(tracedSteps).toContain('commit'); + + // All steps should be successful + for (const traceEntry of savedEntry.trace) { + expect(traceEntry.status).toBe('success'); + } + + // --- Verify reporter JSON includes operationId and totalDurationMs --- + const reporter = createReporter({ json: true }); + const totalDurationMs = actionTracer.getTotalDurationMs(); + const reporterOutput = reporter.reportSuccess({ + ...result, + operationId, + totalDurationMs, + }); + + const reporterJson = JSON.parse(reporterOutput); + expect(reporterJson.operationId).toBe(operationId); + expect(typeof reporterJson.totalDurationMs).toBe('number'); + expect(reporterJson.totalDurationMs).toBeGreaterThanOrEqual(0); + expect(reporterJson.success).toBe(true); + expect(reporterJson.version).toBe('1.0.1'); + }, 30000); +}); diff --git a/__tests__/integration/pr.workflow.test.ts b/__tests__/integration/pr.workflow.test.ts new file mode 100644 index 0000000..c2ffb35 --- /dev/null +++ b/__tests__/integration/pr.workflow.test.ts @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * Integration tests: PR workflow with real git repos and mock PR dependencies. + * + * Validates: Requirements 4.1, 4.3, 4.4, 4.5, 7.1, 7.2, 7.5, 15.1, 15.2, 15.3, 15.4 + */ + +import * as path from 'path'; +import * as fs from 'fs'; +import { createExecutor } from '../../src/core/executor'; +import { createRollbackManager } from '../../src/core/rollback'; +import { createArtifactChecker } from '../../src/core/artifact.checker'; +import { loadAndValidateConfig } from '../../src/config/config.validator'; +import { runPipeline } from '../../src/core/pipeline'; +import { EXIT_CODES, VersioningsError } from '../../src/core/errors'; +import { createSCMRegistry } from '../../src/scm/scm.registry'; +import { createHttpClient } from '../../src/scm/http.client'; +import { createUrlParser } from '../../src/scm/url.parser'; +import { resolveAuth } from '../../src/scm/auth.resolver'; +import type { PrCreatorDeps } from '../../src/scm/pr.creator'; +import type { SCM_Provider, PR_Options, PR_Result, SCM_ProviderConfig } from '../../src/scm/scm.provider'; +import type { HttpClient } from '../../src/scm/http.client'; +import type { UrlParser } from '../../src/scm/url.parser'; +import type { PipelineResult, DryRunPlan } from '../../src/core/reporter'; +import { + createRepoFixture, + cleanup, +} from '../helpers/repo-fixture'; + +let dirs: string[] = []; +let originalCwd: string; + +beforeEach(() => { + originalCwd = process.cwd(); +}); + +afterEach(() => { + process.chdir(originalCwd); + cleanup(dirs); + dirs = []; +}); + +function createDeps(repoDir: string) { + const executor = createExecutor(); + const rollbackManager = createRollbackManager(executor); + const artifactChecker = createArtifactChecker(executor); + const config = loadAndValidateConfig(path.join(repoDir, 'version.json')); + return { executor, rollbackManager, artifactChecker, config }; +} + +const baseOpts = { + semver: 'patch' as const, + branch: 'fix-pr-test', + push: true, + dryRun: false, + json: false, + verbose: false, +}; + +/** + * Creates a mock SCM_Provider that simulates successful API PR creation. + */ +function createSuccessProvider(platform: string): SCM_Provider { + return { + name: () => platform, + createPullRequest: async (opts: PR_Options): Promise => ({ + url: `https://github.com/test/repo/pull/42`, + number: 42, + status: 'created', + fallbackReason: null, + platform, + warnings: [], + }), + generatePullRequestUrl: (branch: string, target: string): string => + `https://github.com/test/repo/compare/${target}...${branch}?expand=1`, + }; +} + +/** + * Creates a mock SCM_Provider that simulates API failure (triggers fallback). + */ +function createFailingProvider(platform: string): SCM_Provider { + return { + name: () => platform, + createPullRequest: async (): Promise => { + throw new Error('API rate limit exceeded'); + }, + generatePullRequestUrl: (branch: string, target: string): string => + `https://github.com/test/repo/compare/${target}...${branch}?expand=1`, + }; +} + +/** + * Creates PrCreatorDeps with a mock registry that returns the given provider. + */ +function createMockPrCreatorDeps( + provider: SCM_Provider, + opts: { token?: string | null; env?: Record } = {}, +): PrCreatorDeps { + const token = opts.token !== undefined ? opts.token : 'test-token-abc123'; + const env = opts.env || {}; + + const registry = createSCMRegistry(); + // Override the github factory to return our mock provider + registry.register('github', () => provider); + + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}), + text: async () => '', + headers: new Map(), + }); + const httpClient = createHttpClient(mockFetch as any, { timeout: 5000, userAgent: 'test' }); + const urlParser = createUrlParser(); + + return { + registry, + httpClient, + urlParser, + resolveAuth: () => ({ token, method: 'token' as const }), + env, + }; +} + +describe('Integration: PR workflow with real git repos', () => { + test('Test 1: Pipeline with mock PR_Creator (API success) — PR_Result in result, pullRequestUrl alias', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + const deps = createDeps(repoDir); + const provider = createSuccessProvider('github'); + const prCreator = createMockPrCreatorDeps(provider); + + const result = (await runPipeline(baseOpts, { + ...deps, + prCreator, + })) as PipelineResult; + + expect(result.success).toBe(true); + expect(result.version).toBe('1.0.1'); + expect(result.pullRequest).toBeDefined(); + expect(result.pullRequest!.url).toBe('https://github.com/test/repo/pull/42'); + expect(result.pullRequest!.number).toBe(42); + expect(result.pullRequest!.status).toBe('created'); + expect(result.pullRequest!.fallbackReason).toBeNull(); + // pullRequestUrl alias + expect(result.pullRequestUrl).toBe(result.pullRequest!.url); + }, 30000); + + test('Test 2: Pipeline with mock PR_Creator (fallback) — fallback reason in result', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + const deps = createDeps(repoDir); + const provider = createFailingProvider('github'); + const prCreator = createMockPrCreatorDeps(provider); + + const result = (await runPipeline(baseOpts, { + ...deps, + prCreator, + })) as PipelineResult; + + // Pipeline succeeds even when PR creation fails (Req 4.5) + expect(result.success).toBe(true); + expect(result.version).toBe('1.0.1'); + expect(result.pullRequest).toBeDefined(); + expect(result.pullRequest!.status).toBe('fallback'); + expect(result.pullRequest!.fallbackReason).toBeTruthy(); + expect(result.pullRequestUrl).toBeTruthy(); + }, 30000); + + test('Test 3: Pipeline without token (github, auto mode) — fallback with reason no_token', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + const deps = createDeps(repoDir); + const provider = createSuccessProvider('github'); + // Pass null token — simulates no auth available + const prCreator = createMockPrCreatorDeps(provider, { token: null }); + + const result = (await runPipeline( + { ...baseOpts, prMode: 'auto' }, + { ...deps, prCreator }, + )) as PipelineResult; + + expect(result.success).toBe(true); + expect(result.pullRequest).toBeDefined(); + expect(result.pullRequest!.status).toBe('fallback'); + expect(result.pullRequest!.fallbackReason).toBe('no_token'); + expect(result.pullRequest!.number).toBeNull(); + }, 30000); + + test('Test 4: Pipeline with --no-pr — PR/MR skipped', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + const deps = createDeps(repoDir); + const provider = createSuccessProvider('github'); + const prCreator = createMockPrCreatorDeps(provider); + + const result = (await runPipeline( + { ...baseOpts, noPr: true }, + { ...deps, prCreator }, + )) as PipelineResult; + + expect(result.success).toBe(true); + expect(result.version).toBe('1.0.1'); + // With --no-pr, pullRequest should be undefined and pullRequestUrl null + expect(result.pullRequest).toBeUndefined(); + expect(result.pullRequestUrl).toBeNull(); + }, 30000); + + test('Test 5: Pipeline with --pr-mode=api without token — VersioningsError(CONFIG_ERROR)', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + const deps = createDeps(repoDir); + const provider = createSuccessProvider('github'); + const prCreator = createMockPrCreatorDeps(provider, { token: null }); + + // pr-mode=api with no token should throw CONFIG_ERROR from createPR + // But pipeline catches PR errors — so it depends on whether createPR throws + // before or after the pipeline catch. Looking at pipeline.ts, PR errors are caught + // and turned into fallback results. The VersioningsError from createPR for api mode + // is caught by the pipeline's try/catch around createPR. + // Actually, looking at pipeline.ts more carefully, the catch block catches ALL errors + // from createPR and converts them to fallback. So for api mode without token, + // createPR throws VersioningsError(CONFIG_ERROR), pipeline catches it and makes fallback. + const result = (await runPipeline( + { ...baseOpts, prMode: 'api' }, + { ...deps, prCreator }, + )) as PipelineResult; + + // Pipeline still succeeds (PR error doesn't cause rollback) + expect(result.success).toBe(true); + expect(result.pullRequest).toBeDefined(); + expect(result.pullRequest!.status).toBe('fallback'); + expect(result.pullRequest!.fallbackReason).toContain('authentication token'); + }, 30000); + + test('Test 6: Backward compatibility — config github without auth → current behavior (URL)', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + const deps = createDeps(repoDir); + + // No prCreator in deps — backward compatibility path + const result = (await runPipeline(baseOpts, deps)) as PipelineResult; + + expect(result.success).toBe(true); + expect(result.version).toBe('1.0.1'); + // Without prCreator, pipeline uses generatePullRequestUrl (backward compat) + expect(result.pullRequestUrl).toBeTruthy(); + expect(result.pullRequest).toBeUndefined(); + }, 30000); + + test('Test 7: Dry-run with PR info — plan contains pullRequest section', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + const deps = createDeps(repoDir); + const provider = createSuccessProvider('github'); + const prCreator = createMockPrCreatorDeps(provider); + + const plan = (await runPipeline( + { ...baseOpts, dryRun: true }, + { ...deps, prCreator }, + )) as DryRunPlan; + + expect(plan.dryRun).toBe(true); + expect(plan.currentVersion).toBe('1.0.0'); + expect(plan.nextVersion).toBe('1.0.1'); + expect(plan.pullRequest).toBeDefined(); + expect(plan.pullRequest!.mode).toBe('auto'); + expect(plan.pullRequest!.platform).toBe('github'); + expect(typeof plan.pullRequest!.hasToken).toBe('boolean'); + }, 30000); +}); diff --git a/__tests__/integration/workflow.test.ts b/__tests__/integration/workflow.test.ts index 2094f24..a4655ad 100644 --- a/__tests__/integration/workflow.test.ts +++ b/__tests__/integration/workflow.test.ts @@ -8,12 +8,19 @@ import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; -import { createExecutor } from '../../executor'; -import { createRollbackManager } from '../../rollback'; -import { createArtifactChecker } from '../../artifact.checker'; -import { loadAndValidateConfig } from '../../config.validator'; -import { runPipeline } from '../../pipeline'; -import { EXIT_CODES, VersioningsError } from '../../errors'; +import { PassThrough } from 'stream'; +import { createExecutor } from '../../src/core/executor'; +import { createRollbackManager } from '../../src/core/rollback'; +import { createArtifactChecker } from '../../src/core/artifact.checker'; +import { loadAndValidateConfig } from '../../src/config/config.validator'; +import { runPipeline } from '../../src/core/pipeline'; +import { runReleaseCommand, ReleaseCommandOpts, ReleaseCommandDeps } from '../../src/cli/commands/release.command'; +import { runRollbackCommand, RollbackCommandOpts, RollbackCommandDeps } from '../../src/cli/commands/rollback.command'; +import { createReporter } from '../../src/core/reporter'; +import { createOperationLog } from '../../src/core/operation.log'; +import { EXIT_CODES, VersioningsError } from '../../src/core/errors'; +import type { InteractionManager } from '../../src/cli/interaction.manager'; +import type { PipelineResult } from '../../src/core/reporter'; import { createRepoFixture, snapshotRepoState, @@ -188,3 +195,204 @@ describe('Integration: Real git workflow', () => { assertNoMutation(repoDir, snapshotBefore); }, 30000); }); + + +// --------------------------------------------------------------------------- +// Helpers for Confirm Flow & Operation Log tests +// --------------------------------------------------------------------------- + +function makeMockInteraction(interactive: boolean, confirmResult = true): InteractionManager { + return { + isInteractive: jest.fn().mockReturnValue(interactive), + confirm: jest.fn().mockResolvedValue(confirmResult), + }; +} + +function createReleaseDeps(repoDir: string, opts: { + interactive?: boolean; + confirmResult?: boolean; + json?: boolean; + operationLogDir?: string; +} = {}): { releaseDeps: ReleaseCommandDeps; stdout: PassThrough } { + const pipelineDeps = createDeps(repoDir); + const stdout = new PassThrough(); + const reporter = createReporter({ json: opts.json ?? false }); + const interactionManager = makeMockInteraction( + opts.interactive ?? false, + opts.confirmResult ?? true, + ); + const logDir = opts.operationLogDir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-oplog-')); + const operationLog = createOperationLog(logDir); + + const releaseDeps: ReleaseCommandDeps = { + runPipeline, + pipelineDeps, + interactionManager, + operationLog, + reporter, + stdout, + }; + + return { releaseDeps, stdout }; +} + +function captureOutput(stream: PassThrough): () => string { + let output = ''; + stream.on('data', (chunk: Buffer) => { output += chunk.toString(); }); + return () => output; +} + +const releaseBaseOpts: ReleaseCommandOpts = { + semver: 'patch', + branch: 'fix-login', + push: false, + dryRun: false, + json: false, + verbose: false, +}; + +// --------------------------------------------------------------------------- +// Confirm Flow & Operation Log integration tests +// --------------------------------------------------------------------------- + +describe('Integration: Confirm Flow and operation.log', () => { + test('release in interactive mode — confirmation leads to success', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + const { releaseDeps, stdout } = createReleaseDeps(repoDir, { + interactive: true, + confirmResult: true, + }); + const getOutput = captureOutput(stdout); + + const result = await runReleaseCommand(releaseBaseOpts, releaseDeps) as PipelineResult; + + expect(result.success).toBe(true); + expect(result.version).toBe('1.0.1'); + expect(result.previousVersion).toBe('1.0.0'); + expect(releaseDeps.interactionManager.isInteractive).toHaveBeenCalled(); + expect(releaseDeps.interactionManager.confirm).toHaveBeenCalledTimes(1); + assertRepoState(repoDir, { version: '1.0.1', clean: true }); + expect(getOutput()).toContain('1.0.1'); + }, 30000); + + test('release in interactive mode — decline leads to USER_CANCELLED', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + const snapshotBefore = snapshotRepoState(repoDir); + const { releaseDeps } = createReleaseDeps(repoDir, { + interactive: true, + confirmResult: false, + }); + + try { + await runReleaseCommand(releaseBaseOpts, releaseDeps); + throw new Error('Expected runReleaseCommand to throw'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.USER_CANCELLED); + expect(err.message).toContain('cancelled'); + } + + // Repository must remain untouched + assertNoMutation(repoDir, snapshotBefore); + }, 30000); + + test('operation log created after successful release', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + const logDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-oplog-')); + dirs.push(logDir); + + const { releaseDeps } = createReleaseDeps(repoDir, { + operationLogDir: logDir, + }); + + await runReleaseCommand(releaseBaseOpts, releaseDeps); + + // Verify operation log was created + const operationLog = createOperationLog(logDir); + const lastEntry = await operationLog.loadLast(); + expect(lastEntry).not.toBeNull(); + expect(lastEntry!.schemaVersion).toBe(1); + expect(lastEntry!.result).toBe('success'); + expect(lastEntry!.version).toBe('1.0.1'); + expect(lastEntry!.previousVersion).toBe('1.0.0'); + expect(lastEntry!.semver).toBe('patch'); + expect(lastEntry!.tag).toBe('1.0.1--fix-login'); + expect(lastEntry!.branch).toContain('version/patch/1.0.1/fix-login'); + expect(lastEntry!.timestamp).toBeTruthy(); + + // Verify last.json symlink exists + const lastLink = path.join(logDir, 'last.json'); + expect(fs.existsSync(lastLink)).toBe(true); + }, 30000); + + test('rollback using last operation log', async () => { + const { repoDir, remoteDir } = createRepoFixture(); + dirs.push(repoDir, remoteDir); + process.chdir(repoDir); + + const logDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-oplog-')); + dirs.push(logDir); + + // Step 1: Perform a release via pipeline with operationLog in deps + // so the pipeline records actual rollback steps in the log + const pipelineDeps = createDeps(repoDir); + const operationLog = createOperationLog(logDir); + const pipelineResult: any = await runPipeline( + { ...baseOpts, dryRun: false }, + { ...pipelineDeps, operationLog }, + ); + + // Verify release happened + expect(pipelineResult.success).toBe(true); + assertRepoState(repoDir, { version: '1.0.1' }); + + // Verify operation log was saved with steps + const savedEntry = await operationLog.loadLast(); + expect(savedEntry).not.toBeNull(); + expect(savedEntry!.steps.length).toBeGreaterThan(0); + + // Step 2: Rollback using the operation log (stay on version branch) + const executor = createExecutor(); + const rollbackStdout = new PassThrough(); + const reporter = createReporter({ json: false }); + const interactionManager = makeMockInteraction(false); + + const rollbackDeps: RollbackCommandDeps = { + operationLog, + executor, + createRollbackManager: (exec) => createRollbackManager(exec), + interactionManager, + reporter, + stdout: rollbackStdout, + }; + + const rollbackOpts: RollbackCommandOpts = { + json: false, + ci: true, + yes: true, + }; + + // Rollback may partially fail (can't delete current branch) — that's expected + // The key integration point is: log is loaded, steps are replayed, tag is removed + try { + await runRollbackCommand(rollbackOpts, rollbackDeps); + } catch (err: any) { + // INCOMPLETE_ROLLBACK is acceptable — branch deletion fails when on that branch + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.INCOMPLETE_ROLLBACK); + } + + // Verify tag was removed (rollback undid tag_created) + const tags = git(repoDir, 'tag --list').split(/\r?\n/).filter(Boolean); + expect(tags).toEqual([]); + }, 30000); +}); diff --git a/__tests__/properties/actor.resolver.property.test.ts b/__tests__/properties/actor.resolver.property.test.ts new file mode 100644 index 0000000..c966361 --- /dev/null +++ b/__tests__/properties/actor.resolver.property.test.ts @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import * as fc from 'fast-check'; +import { detectCI, resolveCIActor } from '../../src/core/actor.resolver'; + +/** + * CI indicator variables and their required trigger values. + * BITBUCKET_BUILD_NUMBER triggers on any defined value (including empty string). + */ +const CI_INDICATORS: Array<{ key: string; triggerValue: string | null }> = [ + { key: 'CI', triggerValue: 'true' }, + { key: 'GITHUB_ACTIONS', triggerValue: 'true' }, + { key: 'GITLAB_CI', triggerValue: 'true' }, + { key: 'TF_BUILD', triggerValue: 'True' }, + { key: 'BITBUCKET_BUILD_NUMBER', triggerValue: null }, // any defined value triggers +]; + +/** + * CI actor variables in priority order. + */ +const CI_ACTOR_VARS = [ + 'GITHUB_ACTOR', + 'GITLAB_USER_LOGIN', + 'BUILD_REQUESTEDFOR', + 'BITBUCKET_STEP_TRIGGERER_UUID', +]; + +/** + * Arbitrary: for a CI indicator variable, produce one of: + * - undefined (absent) + * - the exact trigger value + * - a wrong value (for value-sensitive indicators) + * - an arbitrary string (for BITBUCKET_BUILD_NUMBER, any value triggers) + */ +function arbIndicatorEntry( + indicator: { key: string; triggerValue: string | null }, +): fc.Arbitrary<[string, string | undefined]> { + if (indicator.triggerValue === null) { + // BITBUCKET_BUILD_NUMBER: absent or any string (including empty) + return fc.oneof( + fc.constant([indicator.key, undefined] as [string, string | undefined]), + fc.string({ maxLength: 30 }).map((v) => [indicator.key, v] as [string, string | undefined]), + ); + } + // Value-sensitive indicator: absent, correct value, or wrong value + return fc.oneof( + fc.constant([indicator.key, undefined] as [string, string | undefined]), + fc.constant([indicator.key, indicator.triggerValue] as [string, string | undefined]), + fc + .string({ minLength: 0, maxLength: 20 }) + .filter((v) => v !== indicator.triggerValue) + .map((v) => [indicator.key, v] as [string, string | undefined]), + ); +} + +/** + * Arbitrary: for a CI actor variable, produce absent or an arbitrary string value. + */ +function arbActorEntry(key: string): fc.Arbitrary<[string, string | undefined]> { + return fc.oneof( + fc.constant([key, undefined] as [string, string | undefined]), + fc.string({ maxLength: 50 }).map((v) => [key, v] as [string, string | undefined]), + ); +} + +/** + * Arbitrary: random noise env vars (unrelated keys) to test robustness. + */ +const arbNoiseEnv: fc.Arbitrary> = fc.dictionary( + fc + .string({ minLength: 1, maxLength: 20 }) + .filter( + (k) => + !CI_INDICATORS.some((i) => i.key === k) && + !CI_ACTOR_VARS.includes(k) && + /^[A-Za-z_]/.test(k), + ), + fc.string({ maxLength: 30 }), + { minKeys: 0, maxKeys: 5 }, +); + +/** + * Build a full env arbitrary combining indicator vars, actor vars, and noise. + */ +const arbEnv: fc.Arbitrary> = fc + .tuple( + ...CI_INDICATORS.map(arbIndicatorEntry), + ...CI_ACTOR_VARS.map(arbActorEntry), + arbNoiseEnv, + ) + .map((parts) => { + const env: Record = {}; + // First N entries are indicator tuples, next M are actor tuples, last is noise dict + const indicatorCount = CI_INDICATORS.length; + const actorCount = CI_ACTOR_VARS.length; + + for (let i = 0; i < indicatorCount + actorCount; i++) { + const [key, value] = parts[i] as [string, string | undefined]; + if (value !== undefined) { + env[key] = value; + } + } + + // Merge noise + const noise = parts[indicatorCount + actorCount] as Record; + for (const [k, v] of Object.entries(noise)) { + env[k] = v; + } + + return env; + }); + +/** + * Oracle: compute expected detectCI result from env. + */ +function expectedDetectCI(env: Record): boolean { + return ( + env.CI === 'true' || + env.GITHUB_ACTIONS === 'true' || + env.GITLAB_CI === 'true' || + env.TF_BUILD === 'True' || + env.BITBUCKET_BUILD_NUMBER !== undefined + ); +} + +/** + * Oracle: compute expected resolveCIActor result from env. + */ +function expectedResolveCIActor(env: Record): string | null { + for (const key of CI_ACTOR_VARS) { + if (env[key] !== undefined) return env[key]!; + } + return null; +} + +/** + * Property 4: Резолвинг Actor Metadata и CI Detection + * + * For any set of environment variables: + * - detectCI() returns true ⟺ at least one CI indicator variable is present + * with its required value (CI=true, GITHUB_ACTIONS=true, GITLAB_CI=true, + * TF_BUILD=True, BITBUCKET_BUILD_NUMBER=) + * - resolveCIActor() returns the value of the first found CI actor variable + * (GITHUB_ACTOR, GITLAB_USER_LOGIN, BUILD_REQUESTEDFOR, + * BITBUCKET_STEP_TRIGGERER_UUID) or null if none are present + * + * **Validates: Requirements 3.1, 3.2, 3.3** + */ +describe('Feature: operational-hardening, Property 4: Резолвинг Actor Metadata и CI Detection', () => { + test('detectCI() returns true ⟺ at least one CI indicator variable is present with correct value', () => { + fc.assert( + fc.property(arbEnv, (env) => { + const result = detectCI(env); + const expected = expectedDetectCI(env); + expect(result).toBe(expected); + }), + { numRuns: 100 }, + ); + }); + + test('resolveCIActor() returns the value of the first found CI actor variable or null', () => { + fc.assert( + fc.property(arbEnv, (env) => { + const result = resolveCIActor(env); + const expected = expectedResolveCIActor(env); + expect(result).toBe(expected); + }), + { numRuns: 100 }, + ); + }); + + test('resolveCIActor() is independent of CI detection variables', () => { + fc.assert( + fc.property( + // Generate env with only actor vars (no CI indicators) + fc.tuple(...CI_ACTOR_VARS.map(arbActorEntry)).map((entries) => { + const env: Record = {}; + for (const [key, value] of entries) { + if (value !== undefined) { + env[key] = value; + } + } + return env; + }), + (env) => { + const result = resolveCIActor(env); + const expected = expectedResolveCIActor(env); + expect(result).toBe(expected); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/audit.entry.property.test.ts b/__tests__/properties/audit.entry.property.test.ts new file mode 100644 index 0000000..073b6f7 --- /dev/null +++ b/__tests__/properties/audit.entry.property.test.ts @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import * as fc from 'fast-check'; +import type { AuditEntry } from '../../src/core/operation.log'; +import type { RollbackStep } from '../../src/core/rollback'; +import type { ActorMetadata } from '../../src/core/actor.resolver'; +import type { ActionTraceEntry } from '../../src/core/action.tracer'; + +// --- Generators --- + +/** Arbitrary for valid semver type strings */ +const arbSemverType = fc.constantFrom( + 'patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease', +); + +/** Arbitrary for semver-like version strings (e.g. "1.2.3", "0.0.1-beta.1") */ +const arbVersion = fc.tuple( + fc.nat({ max: 99 }), + fc.nat({ max: 99 }), + fc.nat({ max: 99 }), + fc.option( + fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')), { + minLength: 1, + maxLength: 8, + }), + { nil: undefined }, + ), +).map(([major, minor, patch, pre]) => + pre ? `${major}.${minor}.${patch}-${pre}` : `${major}.${minor}.${patch}`, +); + +/** Arbitrary for ISO 8601 timestamps */ +const arbTimestamp = fc.date({ + min: new Date('2020-01-01T00:00:00.000Z'), + max: new Date('2030-12-31T23:59:59.999Z'), +}).map((d) => d.toISOString()); + +/** Arbitrary for UUID v4 operationId */ +const arbOperationId = fc.uuid(); + +/** Arbitrary for non-empty branch-like strings */ +const arbBranch = fc.stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789/-_.'.split('')), + { minLength: 1, maxLength: 40 }, +); + +/** Arbitrary for non-empty tag-like strings */ +const arbTag = fc.stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789-_.'.split('')), + { minLength: 1, maxLength: 30 }, +); + +/** Arbitrary for RollbackStep — meta values are JSON-safe primitives only */ +const arbRollbackStep: fc.Arbitrary = fc.record({ + type: fc.constantFrom( + 'npm_version_bump', 'branch_created', 'tag_created', 'committed', 'pushed', 'branch_switched', + ), + meta: fc.dictionary( + fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), { + minLength: 1, + maxLength: 10, + }), + fc.oneof(fc.string({ maxLength: 20 }), fc.integer(), fc.boolean()), + { minKeys: 0, maxKeys: 3 }, + ), +}); + +/** Arbitrary for ActorMetadata */ +const arbActorMetadata: fc.Arbitrary = fc.record({ + gitUserName: fc.string({ minLength: 1, maxLength: 30 }), + gitUserEmail: fc.string({ minLength: 1, maxLength: 40 }), + hostname: fc.stringOf( + fc.char().filter((c) => /[a-zA-Z0-9.\-]/.test(c)), + { minLength: 1, maxLength: 64 }, + ), + ciActor: fc.option(fc.string({ minLength: 1, maxLength: 30 }), { nil: null }), +}); + +/** Arbitrary for ActionTraceEntry */ +const arbActionTraceEntry: fc.Arbitrary = fc.tuple( + fc.constantFrom( + 'validate-input', 'check-git-status', 'check-remote', 'auto-bump', + 'compute-version', 'policy-check', 'artifact-check', 'npm-version-bump', + 'branch-create', 'tag-create', 'changelog-write', 'commit', 'push', 'pr-create', + ), + arbTimestamp, + arbTimestamp, + fc.nat({ max: 60000 }), + fc.constantFrom('success' as const, 'failed' as const, 'skipped' as const), +).chain(([step, startedAt, endedAt, durationMs, status]) => { + const base = { step, startedAt, endedAt, durationMs, status }; + if (status === 'failed') { + return fc.string({ minLength: 1, maxLength: 100 }).map((error) => ({ + ...base, + error, + })); + } + return fc.constant(base); +}); + +/** Arbitrary for environment object */ +const arbEnvironment = fc.record({ + nodeVersion: fc.string({ minLength: 1, maxLength: 20 }), + cliVersion: arbVersion, + os: fc.constantFrom('linux', 'darwin', 'win32'), + ci: fc.boolean(), +}); + +/** Arbitrary for pullRequest object */ +const arbPullRequest = fc.record({ + url: fc.webUrl(), + number: fc.option(fc.nat({ max: 99999 }), { nil: null }), + status: fc.constantFrom('open', 'merged', 'closed', 'created'), +}); + +/** Arbitrary for a complete AuditEntry with schemaVersion: 2 */ +const arbAuditEntry: fc.Arbitrary = fc.tuple( + arbTimestamp, + arbOperationId, + arbSemverType, + arbVersion, + arbVersion, + arbBranch, + arbTag, + fc.array(arbRollbackStep, { minLength: 0, maxLength: 5 }), + fc.constantFrom('success' as const, 'failed' as const), + fc.option(arbActorMetadata, { nil: null }), + fc.array(arbActionTraceEntry, { minLength: 0, maxLength: 5 }), + fc.option(arbEnvironment, { nil: null }), + fc.option(fc.string({ minLength: 1, maxLength: 100 }), { nil: null }), + fc.option(arbPullRequest, { nil: null }), +).chain(([ + timestamp, operationId, semver, version, previousVersion, + branch, tag, steps, result, actor, trace, environment, command, pullRequest, +]) => { + const base: AuditEntry = { + schemaVersion: 2, + timestamp, + operationId, + semver, + version, + previousVersion, + branch, + tag, + steps, + result, + actor, + trace, + environment, + command, + }; + + if (pullRequest !== null) { + base.pullRequest = pullRequest; + } + + if (result === 'failed') { + return fc.record({ + code: fc.integer({ min: 1, max: 11 }), + message: fc.string({ minLength: 1, maxLength: 50 }), + }).map((error) => ({ + ...base, + error, + })); + } + + return fc.constant(base); +}); + +// --- Property 5: Round-trip Audit Entry (schemaVersion: 2) --- + +/** + * Property 5: Round-trip Audit Entry (schemaVersion: 2) + * + * For any valid AuditEntry with schemaVersion: 2, serialization to JSON + * (JSON.stringify) and subsequent deserialization (JSON.parse) SHALL produce + * an object deeply equal to the original. + * + * **Validates: Requirements 5.6, 16.3** + */ +describe('Feature: operational-hardening, Property 5: Round-trip Audit Entry (schemaVersion: 2)', () => { + test('JSON.parse(JSON.stringify(entry)) is deeply equal to the original', () => { + fc.assert( + fc.property(arbAuditEntry, (entry) => { + const serialized = JSON.stringify(entry); + const deserialized = JSON.parse(serialized) as AuditEntry; + + expect(deserialized).toEqual(entry); + expect(deserialized.schemaVersion).toBe(2); + }), + { numRuns: 100 }, + ); + }); +}); + +// --- Property 6: Backward compatibility schemaVersion 1 --- + +import type { OperationLogEntry } from '../../src/core/operation.log'; +import { normalizeToV2 } from '../../src/core/operation.log'; + +/** Arbitrary for a complete OperationLogEntry with schemaVersion: 1 */ +const arbOperationLogEntryV1: fc.Arbitrary = fc.tuple( + arbTimestamp, + arbSemverType, + arbVersion, + arbVersion, + arbBranch, + arbTag, + fc.array(arbRollbackStep, { minLength: 0, maxLength: 5 }), + fc.constantFrom('success' as const, 'failed' as const), + fc.option(arbPullRequest, { nil: null }), +).chain(([ + timestamp, semver, version, previousVersion, + branch, tag, steps, result, pullRequest, +]) => { + const base: OperationLogEntry = { + schemaVersion: 1, + timestamp, + semver, + version, + previousVersion, + branch, + tag, + steps, + result, + }; + + if (pullRequest !== null) { + base.pullRequest = pullRequest; + } + + if (result === 'failed') { + return fc.record({ + code: fc.integer({ min: 1, max: 11 }), + message: fc.string({ minLength: 1, maxLength: 50 }), + }).map((error) => ({ + ...base, + error, + })); + } + + return fc.constant(base); +}); + +/** + * Property 6: Backward compatibility schemaVersion 1 + * + * For any valid OperationLogEntry with schemaVersion: 1, normalizeToV2() + * SHALL succeed and fill missing v2 fields with defaults: + * operationId → null, actor → null, trace → [], environment → null, command → null. + * All original v1 fields SHALL be preserved unchanged. + * + * **Validates: Requirements 5.3, 16.4** + */ +describe('Feature: operational-hardening, Property 6: Backward compatibility schemaVersion 1', () => { + test('normalizeToV2() fills missing v2 fields with defaults', () => { + fc.assert( + fc.property(arbOperationLogEntryV1, (v1Entry) => { + const normalized = normalizeToV2(v1Entry); + + // v2 defaults are filled + expect(normalized.operationId).toBeNull(); + expect(normalized.actor).toBeNull(); + expect(normalized.trace).toEqual([]); + expect(normalized.environment).toBeNull(); + expect(normalized.command).toBeNull(); + }), + { numRuns: 100 }, + ); + }); + + test('normalizeToV2() preserves all original v1 fields', () => { + fc.assert( + fc.property(arbOperationLogEntryV1, (v1Entry) => { + const normalized = normalizeToV2(v1Entry); + + // All original v1 fields are preserved + expect(normalized.schemaVersion).toBe(1); + expect(normalized.timestamp).toBe(v1Entry.timestamp); + expect(normalized.semver).toBe(v1Entry.semver); + expect(normalized.version).toBe(v1Entry.version); + expect(normalized.previousVersion).toBe(v1Entry.previousVersion); + expect(normalized.branch).toBe(v1Entry.branch); + expect(normalized.tag).toBe(v1Entry.tag); + expect(normalized.steps).toEqual(v1Entry.steps); + expect(normalized.result).toBe(v1Entry.result); + + // Optional fields preserved when present + if (v1Entry.error) { + expect(normalized.error).toEqual(v1Entry.error); + } + if (v1Entry.pullRequest) { + expect(normalized.pullRequest).toEqual(v1Entry.pullRequest); + } + }), + { numRuns: 100 }, + ); + }); + + test('normalizeToV2() is idempotent', () => { + fc.assert( + fc.property(arbOperationLogEntryV1, (v1Entry) => { + const first = normalizeToV2(v1Entry); + const second = normalizeToV2(first as any); + + expect(second).toEqual(first); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/branching/branching.artifact.property.test.ts b/__tests__/properties/branching/branching.artifact.property.test.ts new file mode 100644 index 0000000..73fcb3a --- /dev/null +++ b/__tests__/properties/branching/branching.artifact.property.test.ts @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: branching-policy-enforcement, Property 17: Artifact Checker skips branch check when null or reuse + +import * as fc from 'fast-check'; +import { createArtifactChecker } from '../../../src/core/artifact.checker'; +import type { Executor, ExecutorResult } from '../../../src/core/executor'; + +/** + * Validates: Requirements 15.2, 15.3 + * + * Property 17: For any CheckUniquenessOpts with branchName === null or + * skipBranchCheck === true, Artifact_Checker does NOT execute + * `git branch --list` or `git ls-remote --heads`. Tag check always runs. + */ + +const gitSafeChar = fc.constantFrom( + ...'abcdefghijklmnopqrstuvwxyz0123456789'.split('') +); + +const arbTagName = fc + .tuple( + fc.integer({ min: 0, max: 99 }), + fc.integer({ min: 0, max: 99 }), + fc.integer({ min: 0, max: 99 }), + fc.stringOf(gitSafeChar, { minLength: 1, maxLength: 15 }), + ) + .map(([x, y, z, c]) => `${x}.${y}.${z}--${c}`); + +const arbBranchName = fc + .tuple( + fc.constantFrom('release', 'hotfix', 'support', 'version/patch', 'feature'), + fc.stringOf(gitSafeChar, { minLength: 1, maxLength: 15 }), + ) + .map(([prefix, suffix]) => `${prefix}/${suffix}`); + +function createTrackingExecutor(): Executor & { commands: string[] } { + const commands: string[] = []; + return { + run: async (cmd: string): Promise => { + commands.push(cmd); + return { stdout: '', lines: [] }; + }, + commands, + }; +} + +function hasBranchListCommand(commands: string[]): boolean { + return commands.some( + (c) => c === 'git branch --list' || c.startsWith('git ls-remote --heads'), + ); +} + +function hasTagListCommand(commands: string[]): boolean { + return commands.some((c) => c === 'git tag --list'); +} + +describe('Property 17: Artifact Checker skips branch check when null or reuse', () => { + it('branchName=null → no git branch --list or ls-remote --heads, but git tag --list always runs', async () => { + await fc.assert( + fc.asyncProperty(arbTagName, fc.boolean(), async (tagName, push) => { + const executor = createTrackingExecutor(); + const checker = createArtifactChecker(executor); + + await checker.checkUniqueness({ + tagName, + branchName: null, + push, + }); + + expect(hasBranchListCommand(executor.commands)).toBe(false); + expect(hasTagListCommand(executor.commands)).toBe(true); + }), + { numRuns: 100 }, + ); + }); + + it('skipBranchCheck=true → no git branch --list or ls-remote --heads, but git tag --list always runs', async () => { + await fc.assert( + fc.asyncProperty(arbTagName, arbBranchName, fc.boolean(), async (tagName, branchName, push) => { + const executor = createTrackingExecutor(); + const checker = createArtifactChecker(executor); + + await checker.checkUniqueness({ + tagName, + branchName, + push, + skipBranchCheck: true, + }); + + expect(hasBranchListCommand(executor.commands)).toBe(false); + expect(hasTagListCommand(executor.commands)).toBe(true); + }), + { numRuns: 100 }, + ); + }); + + it('tag check always runs regardless of branchName or skipBranchCheck', async () => { + const arbSkipScenario = fc.oneof( + fc.record({ + branchName: fc.constant(null as string | null), + skipBranchCheck: fc.constant(false), + }), + fc.record({ + branchName: arbBranchName as fc.Arbitrary, + skipBranchCheck: fc.constant(true), + }), + ); + + await fc.assert( + fc.asyncProperty(arbTagName, arbSkipScenario, fc.boolean(), async (tagName, scenario, push) => { + const executor = createTrackingExecutor(); + const checker = createArtifactChecker(executor); + + await checker.checkUniqueness({ + tagName, + branchName: scenario.branchName, + push, + skipBranchCheck: scenario.skipBranchCheck, + }); + + // Tag check always runs + expect(hasTagListCommand(executor.commands)).toBe(true); + + // If push=true, remote tag check also runs + if (push) { + expect(executor.commands.some((c) => c.startsWith('git ls-remote --tags'))).toBe(true); + } + + // Branch check never runs + expect(hasBranchListCommand(executor.commands)).toBe(false); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/branching/branching.reporter.property.test.ts b/__tests__/properties/branching/branching.reporter.property.test.ts new file mode 100644 index 0000000..2fac17f --- /dev/null +++ b/__tests__/properties/branching/branching.reporter.property.test.ts @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import * as fc from 'fast-check'; +import { createReporter } from '../../../src/core/reporter'; +import type { PipelineResult } from '../../../src/core/reporter'; +import { VersioningsError, EXIT_CODES } from '../../../src/core/errors'; + +// --------------------------------------------------------------------------- +// Generators +// --------------------------------------------------------------------------- + +const arbStrategyName = fc.constantFrom( + 'default', + 'trunk-based', + 'git-flow', + 'release-branch', + 'hotfix', + 'maintenance', +); + +const arbPolicyCheckResult = fc.record({ + warnings: fc.array( + fc.string({ minLength: 1, maxLength: 50 }), + { minLength: 0, maxLength: 5 }, + ), + errors: fc.array( + fc.string({ minLength: 1, maxLength: 50 }), + { minLength: 0, maxLength: 5 }, + ), + protectionInfo: fc.constant(null), +}); + +const arbErrorMessage = fc.string({ minLength: 1, maxLength: 100 }); + +const arbBasePipelineResult: fc.Arbitrary = fc.record({ + success: fc.constant(true as const), + version: fc.stringOf( + fc.constantFrom('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.'), + { minLength: 3, maxLength: 15 }, + ).filter((s) => /^\d/.test(s)), + previousVersion: fc.stringOf( + fc.constantFrom('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.'), + { minLength: 3, maxLength: 15 }, + ).filter((s) => /^\d/.test(s)), + semver: fc.constantFrom('patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease'), + branch: fc.string({ minLength: 1, maxLength: 50 }).filter((s) => s.trim().length > 0), + tag: fc.string({ minLength: 1, maxLength: 50 }).filter((s) => s.trim().length > 0), + pullRequestUrl: fc.constant(null), + exitCode: fc.constant(0), +}); + +// --------------------------------------------------------------------------- +// Feature: branching-policy-enforcement, Property 19: Reporter includes +// strategy and policyCheck in JSON output +// --------------------------------------------------------------------------- + +// **Validates: Requirements 17.1, 17.3, 17.4** +describe('Property 19: Reporter includes strategy and policyCheck in JSON output', () => { + const reporter = createReporter({ json: true }); + + it('when strategy and policyCheck are present, JSON output contains both fields', () => { + fc.assert( + fc.property( + arbBasePipelineResult, + arbStrategyName, + arbPolicyCheckResult, + (baseResult, strategy, policyCheck) => { + const result: PipelineResult = { + ...baseResult, + strategy, + policyCheck, + }; + + const output = reporter.reportSuccess(result); + const parsed = JSON.parse(output); + + // (a) strategy field is present with correct value + expect(parsed).toHaveProperty('strategy', strategy); + + // (b) policyCheck field is present with warnings and errors arrays + expect(parsed).toHaveProperty('policyCheck'); + expect(Array.isArray(parsed.policyCheck.warnings)).toBe(true); + expect(Array.isArray(parsed.policyCheck.errors)).toBe(true); + expect(parsed.policyCheck.warnings).toEqual(policyCheck.warnings); + expect(parsed.policyCheck.errors).toEqual(policyCheck.errors); + }, + ), + { numRuns: 100 }, + ); + }); + + it('when strategy is absent, the strategy field is absent in JSON (backward compat)', () => { + fc.assert( + fc.property( + arbBasePipelineResult, + (baseResult) => { + // No strategy field set + const result: PipelineResult = { ...baseResult }; + + const output = reporter.reportSuccess(result); + const parsed = JSON.parse(output); + + // strategy field should NOT be present + expect(parsed).not.toHaveProperty('strategy'); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Feature: branching-policy-enforcement, Property 20: POLICY_VIOLATION error +// formatting +// --------------------------------------------------------------------------- + +// **Validates: Requirements 14.4** +describe('Property 20: POLICY_VIOLATION error formatting', () => { + const reporter = createReporter({ json: true }); + + it('VersioningsError with POLICY_VIOLATION code produces JSON with error.code === "POLICY_VIOLATION" and exitCode === 10', () => { + fc.assert( + fc.property( + arbErrorMessage, + (message) => { + const error = new VersioningsError( + EXIT_CODES.POLICY_VIOLATION, + message, + null, + ); + + const output = reporter.reportError(error); + const parsed = JSON.parse(output); + + // (a) error.code is the string 'POLICY_VIOLATION' + expect(parsed.error.code).toBe('POLICY_VIOLATION'); + + // (b) exitCode is 10 + expect(parsed.exitCode).toBe(10); + + // (c) success is false + expect(parsed.success).toBe(false); + + // (d) message matches + expect(parsed.error.message).toBe(message); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/branching/branching.rollback.property.test.ts b/__tests__/properties/branching/branching.rollback.property.test.ts new file mode 100644 index 0000000..a3a4705 --- /dev/null +++ b/__tests__/properties/branching/branching.rollback.property.test.ts @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: branching-policy-enforcement, Property 18: Rollback with BRANCH_SWITCHED in LIFO order + +import * as fc from 'fast-check'; +import { createRollbackManager, STEP_TYPES } from '../../../src/core/rollback'; +import type { RollbackStep } from '../../../src/core/rollback'; +import type { Executor, ExecutorResult } from '../../../src/core/executor'; + +/** + * Validates: Requirements 16.1, 16.2, 16.4 + * + * Property 18: For any sequence of steps including BRANCH_SWITCHED, + * rollback processes in reverse order (LIFO). + * BRANCH_SWITCHED rollback executes `git checkout {previousBranch}`. + * If BRANCH_SWITCHED is absent, no `git checkout` for branch switch. + */ + +// --- Generators --- + +const gitSafeChar = fc.constantFrom( + ...'abcdefghijklmnopqrstuvwxyz0123456789'.split('') +); + +const arbBranchName = fc + .stringOf(gitSafeChar, { minLength: 1, maxLength: 20 }) + .map((s) => `branch-${s}`); + +const arbStepType = fc.constantFrom( + STEP_TYPES.NPM_VERSION_BUMP, + STEP_TYPES.BRANCH_CREATED, + STEP_TYPES.BRANCH_SWITCHED, + STEP_TYPES.TAG_CREATED, + STEP_TYPES.COMMITTED, +); + +function arbStepMeta(type: string): fc.Arbitrary> { + switch (type) { + case STEP_TYPES.BRANCH_CREATED: + case STEP_TYPES.TAG_CREATED: + return fc + .stringOf(gitSafeChar, { minLength: 1, maxLength: 20 }) + .map((name) => ({ name })); + case STEP_TYPES.BRANCH_SWITCHED: + return arbBranchName.map((previousBranch) => ({ previousBranch })); + case STEP_TYPES.NPM_VERSION_BUMP: + case STEP_TYPES.COMMITTED: + default: + return fc.constant({}); + } +} + +const arbStep: fc.Arbitrary = arbStepType.chain((type) => + arbStepMeta(type).map((meta) => ({ type, meta })) +); + +/** Sequence that always includes at least one BRANCH_SWITCHED step */ +const arbStepsWithBranchSwitched: fc.Arbitrary = fc + .tuple( + fc.array(arbStep, { minLength: 0, maxLength: 5 }), + arbBranchName.map((previousBranch) => ({ + type: STEP_TYPES.BRANCH_SWITCHED, + meta: { previousBranch }, + })), + fc.array(arbStep, { minLength: 0, maxLength: 5 }), + ) + .map(([before, switched, after]) => [...before, switched, ...after]); + +/** Sequence that never includes BRANCH_SWITCHED */ +const arbStepsWithoutBranchSwitched: fc.Arbitrary = fc.array( + fc.constantFrom( + STEP_TYPES.NPM_VERSION_BUMP, + STEP_TYPES.BRANCH_CREATED, + STEP_TYPES.TAG_CREATED, + STEP_TYPES.COMMITTED, + ).chain((type) => arbStepMeta(type).map((meta) => ({ type, meta }))), + { minLength: 1, maxLength: 10 }, +); + +/** Mixed sequence of all step types */ +const arbMixedSteps = fc.array(arbStep, { minLength: 1, maxLength: 10 }); + +// --- Helpers --- + +function expectedCommand(step: RollbackStep): string { + const { type, meta } = step; + switch (type) { + case STEP_TYPES.NPM_VERSION_BUMP: + return 'git reset --hard'; + case STEP_TYPES.BRANCH_CREATED: + return `git branch -D ${meta.name}`; + case STEP_TYPES.TAG_CREATED: + return `git tag -d ${meta.name}`; + case STEP_TYPES.COMMITTED: + return 'git reset --hard HEAD~1'; + case STEP_TYPES.BRANCH_SWITCHED: + return `git checkout ${meta.previousBranch}`; + default: + throw new Error(`Unexpected type: ${type}`); + } +} + +function createTrackingExecutor(): Executor & { commands: string[] } { + const commands: string[] = []; + return { + run: async (cmd: string): Promise => { + commands.push(cmd); + return { stdout: '', lines: [] }; + }, + commands, + }; +} + +// --- Property 18 --- + +describe('Property 18: Rollback with BRANCH_SWITCHED in LIFO order', () => { + it('rollback processes all steps including BRANCH_SWITCHED in reverse (LIFO) order', async () => { + await fc.assert( + fc.asyncProperty(arbStepsWithBranchSwitched, async (steps) => { + const executor = createTrackingExecutor(); + const mgr = createRollbackManager(executor); + + for (const step of steps) { + mgr.record(step); + } + + await mgr.rollback(); + + const expectedCommands = [...steps].reverse().map(expectedCommand); + expect(executor.commands).toEqual(expectedCommands); + }), + { numRuns: 100 }, + ); + }); + + it('BRANCH_SWITCHED rollback executes git checkout {previousBranch}', async () => { + await fc.assert( + fc.asyncProperty(arbStepsWithBranchSwitched, async (steps) => { + const executor = createTrackingExecutor(); + const mgr = createRollbackManager(executor); + + for (const step of steps) { + mgr.record(step); + } + + await mgr.rollback(); + + // Find all BRANCH_SWITCHED steps and verify their rollback commands + const switchedSteps = steps.filter( + (s) => s.type === STEP_TYPES.BRANCH_SWITCHED, + ); + + for (const switched of switchedSteps) { + const expectedCmd = `git checkout ${switched.meta.previousBranch}`; + expect(executor.commands).toContain(expectedCmd); + } + }), + { numRuns: 100 }, + ); + }); + + it('if BRANCH_SWITCHED is absent, no git checkout for branch switch is executed', async () => { + await fc.assert( + fc.asyncProperty(arbStepsWithoutBranchSwitched, async (steps) => { + const executor = createTrackingExecutor(); + const mgr = createRollbackManager(executor); + + for (const step of steps) { + mgr.record(step); + } + + await mgr.rollback(); + + // No git checkout commands should appear (branch switch rollback) + const checkoutCommands = executor.commands.filter((cmd) => + cmd.startsWith('git checkout '), + ); + expect(checkoutCommands).toHaveLength(0); + }), + { numRuns: 100 }, + ); + }); + + it('mixed step sequences are always rolled back in LIFO order', async () => { + await fc.assert( + fc.asyncProperty(arbMixedSteps, async (steps) => { + const executor = createTrackingExecutor(); + const mgr = createRollbackManager(executor); + + for (const step of steps) { + mgr.record(step); + } + + await mgr.rollback(); + + const expectedCommands = [...steps].reverse().map(expectedCommand); + expect(executor.commands).toEqual(expectedCommands); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/branching/policy.checker.property.test.ts b/__tests__/properties/branching/policy.checker.property.test.ts new file mode 100644 index 0000000..5e6cf72 --- /dev/null +++ b/__tests__/properties/branching/policy.checker.property.test.ts @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import * as fc from 'fast-check'; +import { checkPolicy } from '../../../src/branching/policy.checker'; +import type { PolicyCheckerDeps, PolicyCheckResult } from '../../../src/branching/policy.checker'; +import type { Executor, ExecutorResult } from '../../../src/core/executor'; +import type { SCM_Provider, Branch_Protection_Rule } from '../../../src/scm/scm.provider'; +import type { VersioningsConfig } from '../../../src/config/config.validator'; +import { EXIT_CODES } from '../../../src/core/errors'; + +// --------------------------------------------------------------------------- +// Shared mock config (same pattern as unit tests) +// --------------------------------------------------------------------------- + +const mockConfig = { + git: { + platform: 'github', + url: 'https://github.com/test/repo', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + prepatch: 'Prepatch: v%s.', + patch: 'Patch: v%s.', + preminor: 'Preminor: v%s.', + minor: 'Minor: v%s.', + premajor: 'Premajor: v%s.', + major: 'Major: v%s.', + prerelease: 'Prerelease: v%s.', + }, + }, + }, + }, + package: { + semver: { + patch: 'patch', + prepatch: 'prepatch', + minor: 'minor', + preminor: 'preminor', + premajor: 'premajor', + prerelease: 'prerelease', + major: 'major', + }, + }, + common: { messages: {} }, +} as unknown as VersioningsConfig; + + +// --------------------------------------------------------------------------- +// Mock helpers (same pattern as unit test file) +// --------------------------------------------------------------------------- + +function createMockExecutor( + responses: Record = {}, +): Executor { + return { + async run(cmd: string): Promise { + for (const [pattern, response] of Object.entries(responses)) { + if (cmd.includes(pattern)) { + if (response instanceof Error) throw response; + const trimmed = response.trim(); + return { + stdout: trimmed, + lines: trimmed.split(/\r?\n/).filter(Boolean), + }; + } + } + throw new Error(`No config found for: ${cmd}`); + }, + }; +} + +function createMockScmProvider( + rule: Branch_Protection_Rule | null | Error, +): SCM_Provider { + return { + name: () => 'mock', + createPullRequest: async () => ({ + url: '', + number: null, + status: 'skipped' as const, + fallbackReason: null, + platform: 'mock', + warnings: [], + }), + generatePullRequestUrl: () => '', + getBranchProtection: async (_branch: string) => { + if (rule instanceof Error) throw rule; + return rule; + }, + }; +} + +// --------------------------------------------------------------------------- +// Generators +// --------------------------------------------------------------------------- + +const arbBranchProtectionRule: fc.Arbitrary = fc.record({ + protected: fc.constant(true), + allowForcePush: fc.boolean(), + requirePullRequest: fc.boolean(), + requiredReviewers: fc.integer({ min: 0, max: 10 }), + requiredStatusChecks: fc.array( + fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789-/'.split('')), { minLength: 1, maxLength: 20 }), + { minLength: 0, maxLength: 5 }, + ), + requireSignedCommits: fc.boolean(), +}); + +const arbErrorMessage = fc.stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789 -.'.split('')), + { minLength: 1, maxLength: 50 }, +); + +// --------------------------------------------------------------------------- +// Feature: branching-policy-enforcement, Property 14: Policy Checker generates +// warnings for each active protection rule +// --------------------------------------------------------------------------- + +// **Validates: Requirements 12.4, 12.5, 12.6, 12.7** +describe('Property 14: Policy Checker generates warnings for each active protection rule', () => { + it('number of SCM warnings equals the number of active rules', async () => { + await fc.assert( + fc.asyncProperty( + arbBranchProtectionRule, + async (rule) => { + const executor = createMockExecutor(); + const scmProvider = createMockScmProvider(rule); + const deps: PolicyCheckerDeps = { executor, scmProvider, config: mockConfig }; + + const result = await checkPolicy('main', deps); + + // Count active rules + let expectedWarnings = 0; + if (rule.requirePullRequest === true) expectedWarnings++; + if (rule.requiredReviewers > 0) expectedWarnings++; + if (rule.requiredStatusChecks && rule.requiredStatusChecks.length > 0) expectedWarnings++; + if (rule.requireSignedCommits === true) expectedWarnings++; + + // The SCM check produces exactly one warning per active rule. + // Local git config check may produce 0 warnings (no pushRemote etc.), + // so total warnings should equal expectedWarnings from SCM rules. + expect(result.warnings.length).toBe(expectedWarnings); + expect(result.errors).toEqual([]); + + // Verify specific warnings are present when rules are active + if (rule.requirePullRequest) { + expect(result.warnings.some((w) => w.includes('pull request'))).toBe(true); + } + if (rule.requiredReviewers > 0) { + expect(result.warnings.some((w) => w.includes('reviewer'))).toBe(true); + } + if (rule.requiredStatusChecks && rule.requiredStatusChecks.length > 0) { + expect(result.warnings.some((w) => w.includes('status checks'))).toBe(true); + } + if (rule.requireSignedCommits) { + expect(result.warnings.some((w) => w.includes('signed commits'))).toBe(true); + } + }, + ), + { numRuns: 100 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Feature: branching-policy-enforcement, Property 15: Policy Checker: API +// error → warning, not interruption +// --------------------------------------------------------------------------- + +// **Validates: Requirements 12.8** +describe('Property 15: Policy Checker: API error → warning, not interruption', () => { + it('any error from getBranchProtection results in warnings (not errors), protectionInfo is null', async () => { + await fc.assert( + fc.asyncProperty( + arbErrorMessage, + async (errorMsg) => { + const executor = createMockExecutor(); + const scmProvider = createMockScmProvider(new Error(errorMsg)); + const deps: PolicyCheckerDeps = { executor, scmProvider, config: mockConfig }; + + const result = await checkPolicy('main', deps); + + // (a) Should include a warning about inability to check + expect(result.warnings.length).toBeGreaterThanOrEqual(1); + expect(result.warnings.some((w) => w.includes('Could not check'))).toBe(true); + + // (b) Should NOT include any errors + expect(result.errors).toEqual([]); + + // (c) protectionInfo should be null (no local pushRemote, SCM failed) + expect(result.protectionInfo).toBeNull(); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Feature: branching-policy-enforcement, Property 16: Pipeline interrupts on +// POLICY_VIOLATION +// --------------------------------------------------------------------------- + +// **Validates: Requirements 13.3, 14.1, 14.3** +describe('Property 16: Pipeline interrupts on POLICY_VIOLATION', () => { + it('any PolicyCheckResult with non-empty errors array has errors (unit-level verification)', () => { + const arbNonEmptyErrors = fc.array( + fc.stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789 -.'.split('')), + { minLength: 1, maxLength: 50 }, + ), + { minLength: 1, maxLength: 5 }, + ); + + const arbWarnings = fc.array( + fc.stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789 -.'.split('')), + { minLength: 0, maxLength: 50 }, + ), + { minLength: 0, maxLength: 5 }, + ); + + fc.assert( + fc.property( + arbNonEmptyErrors, + arbWarnings, + (errors, warnings) => { + const policyResult: PolicyCheckResult = { + warnings, + errors, + protectionInfo: null, + }; + + // Verify the result has non-empty errors — this is the condition + // that triggers POLICY_VIOLATION in the pipeline. + expect(policyResult.errors.length).toBeGreaterThan(0); + + // Verify EXIT_CODES.POLICY_VIOLATION is 10 + expect(EXIT_CODES.POLICY_VIOLATION).toBe(10); + + // When errors exist, the pipeline contract says it should throw + // VersioningsError(POLICY_VIOLATION). We verify the structural + // precondition: errors array is non-empty. + expect(Array.isArray(policyResult.errors)).toBe(true); + expect(policyResult.errors.every((e) => typeof e === 'string')).toBe(true); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/branching/strategy.naming.property.test.ts b/__tests__/properties/branching/strategy.naming.property.test.ts new file mode 100644 index 0000000..399ca73 --- /dev/null +++ b/__tests__/properties/branching/strategy.naming.property.test.ts @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import * as fc from 'fast-check'; +import { createDefaultStrategy } from '../../../src/branching/strategies/default.strategy'; +import { createTrunkStrategy } from '../../../src/branching/strategies/trunk.strategy'; +import { createGitFlowStrategy } from '../../../src/branching/strategies/gitflow.strategy'; +import { createReleaseBranchStrategy } from '../../../src/branching/strategies/release.strategy'; +import { createHotfixStrategy } from '../../../src/branching/strategies/hotfix.strategy'; +import { createMaintenanceStrategy } from '../../../src/branching/strategies/maintenance.strategy'; +import { composeVersionBranchName, composeVersionTagName, semverMessage } from '../../../src/versioning/version.utils'; +import type { VersioningsConfig } from '../../../src/config/config.validator'; +import type { StrategyParams } from '../../../src/branching/branching.strategy'; + +// --------------------------------------------------------------------------- +// Shared mock config (same pattern as strategy unit tests) +// --------------------------------------------------------------------------- + +const mockConfig = { + git: { + platform: 'github', + url: 'https://github.com/test/repo', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + prepatch: 'Prepatch: v%s.', + patch: 'Patch: v%s.', + preminor: 'Preminor: v%s.', + minor: 'Minor: v%s.', + premajor: 'Premajor: v%s.', + major: 'Major: v%s.', + prerelease: 'Prerelease: v%s.', + }, + }, + }, + }, + package: { + semver: { + patch: 'patch', + prepatch: 'prepatch', + minor: 'minor', + preminor: 'preminor', + premajor: 'premajor', + prerelease: 'prerelease', + major: 'major', + }, + }, + common: { messages: {} }, +} as unknown as VersioningsConfig; + +// --------------------------------------------------------------------------- +// Generators +// --------------------------------------------------------------------------- + +const arbSemver = fc.constantFrom( + 'patch', 'prepatch', 'minor', 'preminor', 'premajor', 'prerelease', 'major', +); + +const arbVersion = fc + .tuple(fc.integer({ min: 0, max: 99 }), fc.integer({ min: 0, max: 99 }), fc.integer({ min: 0, max: 99 })) + .map(([x, y, z]) => `${x}.${y}.${z}`); + +const arbComment = fc + .stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789-'.split('')), + { minLength: 1, maxLength: 20 }, + ) + .filter((s) => !s.includes('--') && !s.startsWith('-') && !s.endsWith('-')); + +function makeParams(overrides: Partial = {}): StrategyParams { + return { + semver: 'patch', + version: '1.2.3', + comment: 'fix-login', + config: mockConfig, + currentBranch: 'master', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Feature: branching-policy-enforcement, Property 2: Default_Strategy +// equivalence with legacy functions (model-based) +// --------------------------------------------------------------------------- + +// **Validates: Requirements 2.2, 2.3, 2.4, 2.6** +describe('Property 2: Default_Strategy equivalence with legacy functions', () => { + it('generates identical branch name as composeVersionBranchName()', () => { + fc.assert( + fc.property(arbSemver, arbVersion, arbComment, (semver, version, comment) => { + const strategy = createDefaultStrategy(mockConfig); + const params = makeParams({ semver, version, comment }); + const result = strategy.composeBranchName(params); + const legacy = composeVersionBranchName(semver, version, comment, mockConfig); + expect(result.branchName).toBe(legacy); + expect(result.reuseBranch).toBe(false); + }), + { numRuns: 100 }, + ); + }); + + it('generates identical tag name as composeVersionTagName()', () => { + fc.assert( + fc.property(arbSemver, arbVersion, arbComment, (semver, version, comment) => { + const strategy = createDefaultStrategy(mockConfig); + const params = makeParams({ semver, version, comment }); + const result = strategy.composeTagName(params); + const legacy = composeVersionTagName(semver, version, comment); + expect(result).toBe(legacy); + }), + { numRuns: 100 }, + ); + }); + + it('generates identical commit message as semverMessage()', () => { + fc.assert( + fc.property(arbSemver, arbVersion, arbComment, (semver, version, comment) => { + const strategy = createDefaultStrategy(mockConfig); + const params = makeParams({ semver, version, comment }); + const result = strategy.composeCommitMessage(params); + const legacy = semverMessage(semver, version, mockConfig); + expect(result).toBe(legacy); + }), + { numRuns: 100 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Feature: branching-policy-enforcement, Property 3: Trunk-Based Strategy +// returns null for branch +// --------------------------------------------------------------------------- + +// **Validates: Requirements 3.1, 3.3** +describe('Property 3: Trunk-Based Strategy returns null for branch', () => { + it('composeBranchName() returns { branchName: null, reuseBranch: false } for any params', () => { + fc.assert( + fc.property(arbSemver, arbVersion, arbComment, (semver, version, comment) => { + const strategy = createTrunkStrategy(mockConfig); + const params = makeParams({ semver, version, comment }); + const result = strategy.composeBranchName(params); + expect(result).toEqual({ branchName: null, reuseBranch: false }); + }), + { numRuns: 100 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Feature: branching-policy-enforcement, Property 4: Git-Flow branch routing +// --------------------------------------------------------------------------- + +// **Validates: Requirements 4.1, 4.2, 4.6** +describe('Property 4: Git-Flow branch routing', () => { + const releaseSemvers = ['minor', 'major', 'preminor', 'premajor', 'prerelease'] as const; + const hotfixSemvers = ['patch', 'prepatch'] as const; + + it('minor/major/preminor/premajor/prerelease → release/{version}', () => { + fc.assert( + fc.property( + fc.constantFrom(...releaseSemvers), + arbVersion, + arbComment, + (semver, version, comment) => { + const strategy = createGitFlowStrategy(mockConfig); + const params = makeParams({ semver, version, comment }); + const result = strategy.composeBranchName(params); + expect(result.branchName).toBe(`release/${version}`); + expect(result.reuseBranch).toBe(false); + }, + ), + { numRuns: 100 }, + ); + }); + + it('patch/prepatch → hotfix/{version}', () => { + fc.assert( + fc.property( + fc.constantFrom(...hotfixSemvers), + arbVersion, + arbComment, + (semver, version, comment) => { + const strategy = createGitFlowStrategy(mockConfig); + const params = makeParams({ semver, version, comment }); + const result = strategy.composeBranchName(params); + expect(result.branchName).toBe(`hotfix/${version}`); + expect(result.reuseBranch).toBe(false); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Feature: branching-policy-enforcement, Property 5: Tag v{version} for all +// non-default strategies +// --------------------------------------------------------------------------- + +// **Validates: Requirements 3.2, 4.3, 5.2, 6.2, 7.2** +describe('Property 5: Tag v{version} for all non-default strategies', () => { + const strategyFactories = [ + { name: 'trunk-based', factory: createTrunkStrategy }, + { name: 'git-flow', factory: createGitFlowStrategy }, + { name: 'release-branch', factory: createReleaseBranchStrategy }, + { name: 'hotfix', factory: createHotfixStrategy }, + { name: 'maintenance', factory: createMaintenanceStrategy }, + ] as const; + + for (const { name, factory } of strategyFactories) { + it(`${name}: composeTagName() returns v{version} (without custom tagTemplate)`, () => { + fc.assert( + fc.property(arbSemver, arbVersion, arbComment, (semver, version, comment) => { + const strategy = factory(mockConfig); + const params = makeParams({ semver, version, comment }); + const tag = strategy.composeTagName(params); + expect(tag).toBe(`v${version}`); + }), + { numRuns: 100 }, + ); + }); + } +}); + +// --------------------------------------------------------------------------- +// Feature: branching-policy-enforcement, Property 9: Maintenance branch pattern +// --------------------------------------------------------------------------- + +// **Validates: Requirements 7.1** +describe('Property 9: Maintenance branch pattern', () => { + it('for any valid version X.Y.Z, maintenance returns support/{X}.{Y} (no patch component)', () => { + fc.assert( + fc.property(arbSemver, arbVersion, arbComment, (semver, version, comment) => { + const strategy = createMaintenanceStrategy(mockConfig); + const params = makeParams({ semver, version, comment }); + const result = strategy.composeBranchName(params); + const parts = version.split('.'); + const expectedBranch = `support/${parts[0]}.${parts[1]}`; + expect(result.branchName).toBe(expectedBranch); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/branching/strategy.registry.property.test.ts b/__tests__/properties/branching/strategy.registry.property.test.ts new file mode 100644 index 0000000..6278746 --- /dev/null +++ b/__tests__/properties/branching/strategy.registry.property.test.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: branching-policy-enforcement, Property 1: Strategy Registry Mapping + +import * as fc from 'fast-check'; +import { createStrategyRegistry } from '../../../src/branching/strategy.registry'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; +import type { VersioningsConfig } from '../../../src/config/config.validator'; + +const mockConfig = { + git: { + platform: 'github', + url: 'https://github.com/test/repo', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { message: { semver: { prepatch: '', patch: '', preminor: '', minor: '', premajor: '', major: '', prerelease: '' } } }, + }, + package: { semver: { patch: 'patch', prepatch: 'prepatch', minor: 'minor', preminor: 'preminor', premajor: 'premajor', prerelease: 'prerelease', major: 'major' } }, + common: { messages: {} }, +} as unknown as VersioningsConfig; + +const KNOWN_STRATEGIES = ['default', 'trunk-based', 'git-flow', 'release-branch', 'hotfix', 'maintenance']; + +const arbStrategyName = fc.constantFrom(...KNOWN_STRATEGIES); + +const arbUnknownStrategyName = fc + .string({ minLength: 1, maxLength: 30 }) + .filter((s) => !KNOWN_STRATEGIES.includes(s)); + +describe('Property 1: Strategy Registry Mapping', () => { + it('for any known strategy name, getStrategy returns a strategy with matching name()', () => { + fc.assert( + fc.property(arbStrategyName, (name: string) => { + const registry = createStrategyRegistry(); + const strategy = registry.getStrategy(name, mockConfig); + expect(strategy.name()).toBe(name); + }), + { numRuns: 100 }, + ); + }); + + it('for any unknown strategy name, getStrategy throws VersioningsError(CONFIG_ERROR)', () => { + fc.assert( + fc.property(arbUnknownStrategyName, (name: string) => { + const registry = createStrategyRegistry(); + try { + registry.getStrategy(name, mockConfig); + throw new Error('Expected VersioningsError to be thrown'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + for (const s of KNOWN_STRATEGIES) { + expect(err.message).toContain(s); + } + } + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/branching/strategy.validation.property.test.ts b/__tests__/properties/branching/strategy.validation.property.test.ts new file mode 100644 index 0000000..3ba858c --- /dev/null +++ b/__tests__/properties/branching/strategy.validation.property.test.ts @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import * as fc from 'fast-check'; +import { createTrunkStrategy } from '../../../src/branching/strategies/trunk.strategy'; +import { createHotfixStrategy } from '../../../src/branching/strategies/hotfix.strategy'; +import { createMaintenanceStrategy } from '../../../src/branching/strategies/maintenance.strategy'; +import { createGitFlowStrategy } from '../../../src/branching/strategies/gitflow.strategy'; +import type { VersioningsConfig } from '../../../src/config/config.validator'; +import type { StrategyParams } from '../../../src/branching/branching.strategy'; + +// --------------------------------------------------------------------------- +// Shared mock config (same pattern as strategy.naming.property.test.ts) +// --------------------------------------------------------------------------- + +const mockConfig = { + git: { + platform: 'github', + url: 'https://github.com/test/repo', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + prepatch: 'Prepatch: v%s.', + patch: 'Patch: v%s.', + preminor: 'Preminor: v%s.', + minor: 'Minor: v%s.', + premajor: 'Premajor: v%s.', + major: 'Major: v%s.', + prerelease: 'Prerelease: v%s.', + }, + }, + }, + }, + package: { + semver: { + patch: 'patch', + prepatch: 'prepatch', + minor: 'minor', + preminor: 'preminor', + premajor: 'premajor', + prerelease: 'prerelease', + major: 'major', + }, + }, + common: { messages: {} }, +} as unknown as VersioningsConfig; + +// --------------------------------------------------------------------------- +// Generators +// --------------------------------------------------------------------------- + +const arbSemver = fc.constantFrom( + 'patch', 'prepatch', 'minor', 'preminor', 'premajor', 'prerelease', 'major', +); + +const arbVersion = fc + .tuple(fc.integer({ min: 0, max: 99 }), fc.integer({ min: 0, max: 99 }), fc.integer({ min: 0, max: 99 })) + .map(([x, y, z]) => `${x}.${y}.${z}`); + +const arbComment = fc + .stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789-'.split('')), + { minLength: 1, maxLength: 20 }, + ) + .filter((s) => !s.includes('--') && !s.startsWith('-') && !s.endsWith('-')); + +const arbBranchName = fc + .stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789-/'.split('')), + { minLength: 1, maxLength: 30 }, + ) + .filter((s) => s !== 'master' && s !== 'main' && s !== 'develop'); + +const arbNonPatchSemver = fc.constantFrom( + 'prepatch', 'minor', 'preminor', 'premajor', 'prerelease', 'major', +); + +function makeParams(overrides: Partial = {}): StrategyParams { + return { + semver: 'patch', + version: '1.2.3', + comment: 'fix-login', + config: mockConfig, + currentBranch: 'master', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Feature: branching-policy-enforcement, Property 6: Validation of current +// branch for strategies requiring main/master +// --------------------------------------------------------------------------- + +// **Validates: Requirements 3.6, 6.3** +describe('Property 6: Validation of current branch for strategies requiring main/master', () => { + it('trunk-based: valid=true when currentBranch is master or main', () => { + fc.assert( + fc.property( + fc.constantFrom('master', 'main'), + arbSemver, + arbVersion, + arbComment, + (branch, semver, version, comment) => { + const strategy = createTrunkStrategy(mockConfig); + const params = makeParams({ semver, version, comment, currentBranch: branch }); + const result = strategy.validateContext(params); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }, + ), + { numRuns: 100 }, + ); + }); + + it('trunk-based: valid=false for any branch other than master/main', () => { + fc.assert( + fc.property( + arbBranchName, + arbSemver, + arbVersion, + arbComment, + (branch, semver, version, comment) => { + const strategy = createTrunkStrategy(mockConfig); + const params = makeParams({ semver, version, comment, currentBranch: branch }); + const result = strategy.validateContext(params); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }, + ), + { numRuns: 100 }, + ); + }); + + it('hotfix: valid=true when currentBranch is master or main AND semver=patch', () => { + fc.assert( + fc.property( + fc.constantFrom('master', 'main'), + arbVersion, + arbComment, + (branch, version, comment) => { + const strategy = createHotfixStrategy(mockConfig); + const params = makeParams({ semver: 'patch', version, comment, currentBranch: branch }); + const result = strategy.validateContext(params); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }, + ), + { numRuns: 100 }, + ); + }); + + it('hotfix: valid=false for any branch other than master/main (with semver=patch)', () => { + fc.assert( + fc.property( + arbBranchName, + arbVersion, + arbComment, + (branch, version, comment) => { + const strategy = createHotfixStrategy(mockConfig); + const params = makeParams({ semver: 'patch', version, comment, currentBranch: branch }); + const result = strategy.validateContext(params); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Feature: branching-policy-enforcement, Property 7: Validation of source +// branch in Git-Flow Strategy +// --------------------------------------------------------------------------- + +// **Validates: Requirements 4.4, 4.5** +describe('Property 7: Validation of source branch in Git-Flow Strategy', () => { + const releaseSemvers = ['minor', 'major', 'preminor', 'premajor', 'prerelease'] as const; + const hotfixSemvers = ['patch', 'prepatch'] as const; + + it('release semvers: valid only if currentBranch === develop (default developBranch)', () => { + fc.assert( + fc.property( + fc.constantFrom(...releaseSemvers), + arbVersion, + arbComment, + (semver, version, comment) => { + const strategy = createGitFlowStrategy(mockConfig); + // On develop → valid + const validParams = makeParams({ semver, version, comment, currentBranch: 'develop' }); + const validResult = strategy.validateContext(validParams); + expect(validResult.valid).toBe(true); + expect(validResult.errors).toEqual([]); + }, + ), + { numRuns: 100 }, + ); + }); + + it('release semvers: invalid if currentBranch !== develop', () => { + fc.assert( + fc.property( + fc.constantFrom(...releaseSemvers), + arbBranchName, + arbVersion, + arbComment, + (semver, branch, version, comment) => { + const strategy = createGitFlowStrategy(mockConfig); + const params = makeParams({ semver, version, comment, currentBranch: branch }); + const result = strategy.validateContext(params); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }, + ), + { numRuns: 100 }, + ); + }); + + it('hotfix semvers (patch/prepatch): valid if currentBranch is master or main', () => { + fc.assert( + fc.property( + fc.constantFrom(...hotfixSemvers), + fc.constantFrom('master', 'main'), + arbVersion, + arbComment, + (semver, branch, version, comment) => { + const strategy = createGitFlowStrategy(mockConfig); + const params = makeParams({ semver, version, comment, currentBranch: branch }); + const result = strategy.validateContext(params); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }, + ), + { numRuns: 100 }, + ); + }); + + it('hotfix semvers (patch/prepatch): invalid if currentBranch is not master/main', () => { + fc.assert( + fc.property( + fc.constantFrom(...hotfixSemvers), + arbBranchName, + arbVersion, + arbComment, + (semver, branch, version, comment) => { + const strategy = createGitFlowStrategy(mockConfig); + const params = makeParams({ semver, version, comment, currentBranch: branch }); + const result = strategy.validateContext(params); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Feature: branching-policy-enforcement, Property 8: Semver type restriction +// for hotfix and maintenance strategies +// --------------------------------------------------------------------------- + +// **Validates: Requirements 6.4, 7.4** +describe('Property 8: Semver type restriction for hotfix and maintenance strategies', () => { + it('hotfix: any semver != patch returns valid=false with error mentioning only patch allowed', () => { + fc.assert( + fc.property( + arbNonPatchSemver, + arbVersion, + arbComment, + (semver, version, comment) => { + const strategy = createHotfixStrategy(mockConfig); + const params = makeParams({ semver, version, comment, currentBranch: 'master' }); + const result = strategy.validateContext(params); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + const joined = result.errors.join(' '); + expect(joined.toLowerCase()).toContain('patch'); + }, + ), + { numRuns: 100 }, + ); + }); + + it('maintenance: any semver != patch returns valid=false with error mentioning only patch allowed', () => { + fc.assert( + fc.property( + arbNonPatchSemver, + arbVersion, + arbComment, + (semver, version, comment) => { + const strategy = createMaintenanceStrategy(mockConfig); + const params = makeParams({ semver, version, comment, currentBranch: 'master' }); + const result = strategy.validateContext(params); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + const joined = result.errors.join(' '); + expect(joined.toLowerCase()).toContain('patch'); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/cli/command.router.property.test.ts b/__tests__/properties/cli/command.router.property.test.ts new file mode 100644 index 0000000..cd36f32 --- /dev/null +++ b/__tests__/properties/cli/command.router.property.test.ts @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: core-ux-config-cli, Property 9 & Property 10: Command Router properties + +import * as fc from 'fast-check'; +import { PassThrough } from 'stream'; +import { preprocessArgv, SUBCOMMANDS } from '../../../src/cli/command.router'; +import { runPlanCommand, PlanCommandDeps } from '../../../src/cli/commands/plan.command'; +import { runReleaseCommand, ReleaseCommandOpts, ReleaseCommandDeps } from '../../../src/cli/commands/release.command'; +import { createReporter, DryRunPlan } from '../../../src/core/reporter'; +import { PipelineOpts, PipelineDeps } from '../../../src/core/pipeline'; +import { InteractionManager } from '../../../src/cli/interaction.manager'; +import { OperationLog } from '../../../src/core/operation.log'; + +// --------------------------------------------------------------------------- +// Generators +// --------------------------------------------------------------------------- + +const arbSemver = fc.constantFrom('patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease'); + +const arbBranch = fc.stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789-'.split('')), + { minLength: 1, maxLength: 80 }, +).filter((s) => !/-{2,}/.test(s) && s.trim().length > 0); + +const arbPush = fc.boolean(); + +const arbPreid = fc.option( + fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')), { minLength: 1, maxLength: 10 }), + { nil: undefined }, +); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makePlan(semver: string, branch: string, push: boolean, preid?: string): DryRunPlan { + const version = '1.0.1'; + const branchName = `version/${semver}/${version}/${branch}`; + const tagName = `${version}--${branch}`; + const steps = [ + `npm --no-git-tag-version version ${semver}`, + `git checkout -b ${branchName}`, + `git tag --annotate ${tagName}`, + `git commit --all`, + ]; + if (push) { + steps.push(`git push origin ${branchName} --follow-tags`); + } + return { + dryRun: true, + currentVersion: '1.0.0', + nextVersion: version, + semver, + branch: branchName, + tag: tagName, + commitMessage: `${semver} version ${version}`, + pullRequestUrl: push ? `https://github.com/org/repo/compare/main...${branchName}` : null, + steps, + }; +} + +function makeMockInteraction(): InteractionManager { + return { + isInteractive: jest.fn().mockReturnValue(false), + confirm: jest.fn().mockResolvedValue(true), + }; +} + +function makeMockOperationLog(): OperationLog { + return { + save: jest.fn().mockResolvedValue('/path/to/log.json'), + loadLast: jest.fn().mockResolvedValue(null), + loadFrom: jest.fn().mockRejectedValue(new Error('not found')), + }; +} + +// --------------------------------------------------------------------------- +// Property 9: Equivalence of plan and release --dry-run +// --------------------------------------------------------------------------- + +/** + * **Validates: Requirements 8.1** + * + * For any valid params {semver, branch, push, preid}, the output of + * runPlanCommand SHALL be deeply equal to the output of + * runReleaseCommand with dryRun: true and the same parameters. + * + * Both commands delegate to the same pipeline mock with dryRun: true, + * so the returned DryRunPlan objects must be identical. + */ +describe('Property 9: Equivalence of plan and release --dry-run', () => { + test('plan output equals release --dry-run output for any valid params', () => { + fc.assert( + fc.asyncProperty(arbSemver, arbBranch, arbPush, arbPreid, async (semver, branch, push, preid) => { + const plan = makePlan(semver, branch, push, preid); + + // Shared pipeline mock that always returns the same plan for dryRun: true + const runPipelineMock = jest.fn().mockImplementation((opts: PipelineOpts) => { + expect(opts.dryRun).toBe(true); + return Promise.resolve(plan); + }); + + const reporter = createReporter({ json: false }); + + // --- Run plan command --- + const planStdout = new PassThrough(); + const planDeps: PlanCommandDeps = { + runPipeline: runPipelineMock, + pipelineDeps: {} as PipelineDeps, + reporter, + stdout: planStdout, + }; + + const planResult = await runPlanCommand( + { semver, branch, push, preid, json: false }, + planDeps, + ); + planStdout.destroy(); + + // --- Run release --dry-run --- + const releaseStdout = new PassThrough(); + const releaseDeps: ReleaseCommandDeps = { + runPipeline: runPipelineMock, + pipelineDeps: {} as PipelineDeps, + interactionManager: makeMockInteraction(), + operationLog: makeMockOperationLog(), + reporter, + stdout: releaseStdout, + }; + + const releaseResult = await runReleaseCommand( + { + semver, + branch, + push, + preid, + dryRun: true, + json: false, + verbose: false, + }, + releaseDeps, + ); + releaseStdout.destroy(); + + // Both should return the exact same plan + expect(planResult).toEqual(releaseResult); + expect((planResult as DryRunPlan).dryRun).toBe(true); + expect((releaseResult as DryRunPlan).dryRun).toBe(true); + }), + { numRuns: 100 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 10: Backward compatibility routing +// --------------------------------------------------------------------------- + +/** + * **Validates: Requirements 9.3, 12.2** + * + * For any valid --semver and --branch values, calling without a subcommand + * (i.e. preprocessArgv(['--semver=X', '--branch=Y', ...])) + * SHALL produce an argv array whose first element is 'release', + * effectively routing to Release_Command. + */ +describe('Property 10: Backward compatibility routing', () => { + test('preprocessArgv prepends "release" for any --semver + --branch without subcommand', () => { + fc.assert( + fc.property(arbSemver, arbBranch, arbPush, (semver, branch, push) => { + const args = [`--semver=${semver}`, `--branch=${branch}`]; + if (push) args.push('--push'); + + const result = preprocessArgv(args); + + // First element must be 'release' + expect(result[0]).toBe('release'); + // Original args must be preserved after 'release' + expect(result.slice(1)).toEqual(args); + // Length must be original + 1 + expect(result.length).toBe(args.length + 1); + }), + { numRuns: 100 }, + ); + }); + + test('preprocessArgv is idempotent when subcommand is already present', () => { + fc.assert( + fc.property( + fc.constantFrom(...SUBCOMMANDS), + arbSemver, + arbBranch, + (cmd, semver, branch) => { + // For commands that accept --semver/--branch + const args = [cmd, `--semver=${semver}`, `--branch=${branch}`]; + const result = preprocessArgv(args); + + // Should not prepend anything — subcommand already present + expect(result).toEqual(args); + expect(result[0]).toBe(cmd); + }, + ), + { numRuns: 100 }, + ); + }); + + test('preprocessArgv does NOT prepend "release" when only --semver is present (no --branch)', () => { + fc.assert( + fc.property(arbSemver, (semver) => { + const args = [`--semver=${semver}`, '--json']; + const result = preprocessArgv(args); + + // Should NOT prepend release — --branch is missing + expect(result).toEqual(args); + expect(result[0]).not.toBe('release'); + }), + { numRuns: 100 }, + ); + }); + + test('preprocessArgv does NOT prepend "release" when only --branch is present (no --semver)', () => { + fc.assert( + fc.property(arbBranch, (branch) => { + const args = [`--branch=${branch}`, '--verbose']; + const result = preprocessArgv(args); + + // Should NOT prepend release — --semver is missing + expect(result).toEqual(args); + expect(result[0]).not.toBe('release'); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/cli/interaction.property.test.ts b/__tests__/properties/cli/interaction.property.test.ts new file mode 100644 index 0000000..37dad2b --- /dev/null +++ b/__tests__/properties/cli/interaction.property.test.ts @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: core-ux-config-cli, Property 1 & Property 2: Interaction Manager properties + +import * as fc from 'fast-check'; +import { PassThrough } from 'stream'; +import { createInteractionManager } from '../../../src/cli/interaction.manager'; +import type { InteractionOpts } from '../../../src/cli/interaction.manager'; +import type { DryRunPlan } from '../../../src/core/reporter'; + +// --- Mock DryRunPlan --- + +const mockPlan: DryRunPlan = { + dryRun: false, + currentVersion: '1.0.0', + nextVersion: '1.0.1', + semver: 'patch', + branch: 'version/patch/1.0.1/fix-bug', + tag: '1.0.1--fix-bug', + commitMessage: 'Patch: v1.0.1. You SHOULD consider changes.', + pullRequestUrl: null, + steps: ['npm --no-git-tag-version version patch', 'git checkout -b version/patch/1.0.1/fix-bug'], +}; + +// --- Generators --- + +/** Arbitrary for InteractionOpts — all 4 boolean flags. */ +const arbInteractionOpts: fc.Arbitrary = fc.record({ + ci: fc.boolean(), + nonInteractive: fc.boolean(), + yes: fc.boolean(), + isTTY: fc.boolean(), +}); + +// --- Helpers --- + +/** + * Reference implementation: expected value of isInteractive(). + * Interactive IFF isTTY===true AND ci===false AND nonInteractive===false AND yes===false. + */ +function expectedInteractive(opts: InteractionOpts): boolean { + return opts.isTTY === true + && opts.ci === false + && opts.nonInteractive === false + && opts.yes === false; +} + +/** + * Creates a tracking stdin mock that records calls to read/on/once/resume. + * Used to verify non-interactive mode does not touch stdin. + */ +function makeTrackingStdin() { + const fns = { + on: jest.fn().mockReturnThis(), + once: jest.fn().mockReturnThis(), + read: jest.fn(), + resume: jest.fn(), + pause: jest.fn(), + setEncoding: jest.fn(), + removeListener: jest.fn().mockReturnThis(), + addListener: jest.fn().mockReturnThis(), + off: jest.fn().mockReturnThis(), + removeAllListeners: jest.fn().mockReturnThis(), + emit: jest.fn(), + prependListener: jest.fn().mockReturnThis(), + prependOnceListener: jest.fn().mockReturnThis(), + listeners: jest.fn().mockReturnValue([]), + rawListeners: jest.fn().mockReturnValue([]), + listenerCount: jest.fn().mockReturnValue(0), + eventNames: jest.fn().mockReturnValue([]), + getMaxListeners: jest.fn().mockReturnValue(10), + setMaxListeners: jest.fn().mockReturnThis(), + pipe: jest.fn(), + unpipe: jest.fn(), + unshift: jest.fn(), + wrap: jest.fn(), + [Symbol.asyncIterator]: jest.fn(), + readable: true, + readableEncoding: null, + readableEnded: false, + readableFlowing: null, + readableHighWaterMark: 16384, + readableLength: 0, + readableObjectMode: false, + destroyed: false, + closed: false, + errored: null, + readableAborted: false, + readableDidRead: false, + destroy: jest.fn(), + isPaused: jest.fn().mockReturnValue(false), + iterator: jest.fn(), + map: jest.fn(), + filter: jest.fn(), + forEach: jest.fn(), + toArray: jest.fn(), + some: jest.fn(), + find: jest.fn(), + every: jest.fn(), + flatMap: jest.fn(), + drop: jest.fn(), + take: jest.fn(), + asIndexedPairs: jest.fn(), + reduce: jest.fn(), + compose: jest.fn(), + }; + return fns as unknown as NodeJS.ReadableStream & { + on: jest.Mock; + once: jest.Mock; + read: jest.Mock; + resume: jest.Mock; + }; +} + +// --- Property 1: Determinism of interactive mode --- + +/** + * **Validates: Requirements 1.1, 1.4, 2.1, 2.2, 2.4, 9.4** + * + * For any combination of boolean flags {ci, nonInteractive, yes, isTTY}, + * isInteractive() SHALL return true IFF isTTY===true AND ci===false + * AND nonInteractive===false AND yes===false. All other combos return false. + */ +describe('Property 1: Determinism of interactive mode', () => { + test('isInteractive() matches reference formula for all boolean flag combinations', () => { + fc.assert( + fc.property(arbInteractionOpts, (opts) => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const mgr = createInteractionManager(opts, stdin, stdout); + + const actual = mgr.isInteractive(); + const expected = expectedInteractive(opts); + + expect(actual).toBe(expected); + + // Cleanup + stdin.destroy(); + stdout.destroy(); + }), + { numRuns: 100 }, + ); + }); +}); + +// --- Property 2: Non-interactive mode doesn't read stdin --- + +/** + * **Validates: Requirements 2.3, 2.4** + * + * For any combination of flags where isInteractive()===false, + * confirm(plan) SHALL return true without reading from stdin. + * stdin.read / stdin.on('data') call count SHALL be zero. + */ +describe('Property 2: Non-interactive mode does not read stdin', () => { + test('confirm() returns true and never touches stdin when non-interactive', () => { + fc.assert( + fc.asyncProperty(arbInteractionOpts, async (opts) => { + // Only test non-interactive combinations + fc.pre(expectedInteractive(opts) === false); + + const stdin = makeTrackingStdin(); + const stdout = new PassThrough(); + const mgr = createInteractionManager(opts, stdin, stdout); + + // Confirm should resolve to true without reading stdin + const result = await mgr.confirm(mockPlan); + expect(result).toBe(true); + + // Verify stdin was never read or listened to for data + expect(stdin.read).not.toHaveBeenCalled(); + expect(stdin.on).not.toHaveBeenCalled(); + expect(stdin.once).not.toHaveBeenCalled(); + expect(stdin.resume).not.toHaveBeenCalled(); + + // Cleanup + stdout.destroy(); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/config/cc.config.property.test.ts b/__tests__/properties/config/cc.config.property.test.ts new file mode 100644 index 0000000..02c12e6 --- /dev/null +++ b/__tests__/properties/config/cc.config.property.test.ts @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: version-intelligence-release-narrative +// Property 19: JSON Schema validation for conventionalCommits and changelog sections +// Property 20: Backward compatibility of configuration and exit codes + +import * as fc from 'fast-check'; +import * as path from 'path'; +import * as os from 'os'; +import * as fs from 'fs'; +import { loadAndValidateConfig } from '../../../src/config/config.validator'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +let tmpDir: string; +let tmpFiles: string[] = []; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-cc-prop-')); + tmpFiles = []; +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeTempConfig(obj: any): string { + const filePath = path.join(tmpDir, `version-${tmpFiles.length}.json`); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8'); + tmpFiles.push(filePath); + return filePath; +} + +/** Minimal valid git section required by schema */ +const BASE_GIT = { + platform: 'github' as const, + url: 'https://github.com/org/repo.git', +}; + +// ── Arbitraries ───────────────────────────────────────────────────────────── + +/** Safe non-empty alphanumeric string for keys and values */ +const arbSafeString = fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, + { num: 10, build: (v) => String.fromCharCode(48 + v) }, + { num: 1, build: () => '-' }, + ), + { minLength: 1, maxLength: 30 }, +); + +/** Valid bump level enum values for conventionalCommits.types */ +const arbBumpLevel = fc.constantFrom('major', 'minor', 'patch', 'none'); + +/** Valid fallbackBump values (including null) */ +const arbFallbackBump = fc.constantFrom('patch', 'minor', 'major', null); + +/** Generate a valid conventionalCommits config section */ +const arbValidCC = fc.record({ + hasEnabled: fc.boolean(), + enabled: fc.boolean(), + hasTypes: fc.boolean(), + types: fc.dictionary(arbSafeString, arbBumpLevel, { minKeys: 0, maxKeys: 5 }), + hasFallback: fc.boolean(), + fallbackBump: arbFallbackBump, +}).map((r) => { + const cc: any = {}; + if (r.hasEnabled) cc.enabled = r.enabled; + if (r.hasTypes) cc.types = r.types; + if (r.hasFallback) cc.fallbackBump = r.fallbackBump; + return cc; +}); + +/** Generate a valid changelog config section */ +const arbValidChangelog = fc.record({ + hasGroupTitles: fc.boolean(), + groupTitles: fc.dictionary( + arbSafeString, + fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, + { num: 26, build: (v) => String.fromCharCode(65 + v) }, + { num: 1, build: () => ' ' }, + ), + { minLength: 1, maxLength: 40 }, + ), + { minKeys: 0, maxKeys: 5 }, + ), + hasExcludeTypes: fc.boolean(), + excludeTypes: fc.array(arbSafeString, { minLength: 0, maxLength: 5 }), + hasIncludeNonConventional: fc.boolean(), + includeNonConventional: fc.boolean(), + hasFile: fc.boolean(), + file: fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, + { num: 10, build: (v) => String.fromCharCode(48 + v) }, + { num: 1, build: () => '/' }, + { num: 1, build: () => '.' }, + ), + { minLength: 1, maxLength: 100 }, + ), + hasTemplate: fc.boolean(), + template: fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, + { num: 10, build: (v) => String.fromCharCode(48 + v) }, + { num: 1, build: () => '/' }, + { num: 1, build: () => '.' }, + ), + { minLength: 1, maxLength: 100 }, + ), +}).map((r) => { + const cl: any = {}; + if (r.hasGroupTitles) cl.groupTitles = r.groupTitles; + if (r.hasExcludeTypes) cl.excludeTypes = r.excludeTypes; + if (r.hasIncludeNonConventional) cl.includeNonConventional = r.includeNonConventional; + if (r.hasFile) cl.file = r.file; + if (r.hasTemplate) cl.template = r.template; + return cl; +}); + +// ── Property 19: JSON Schema validation for conventionalCommits and changelog ── + +describe('Property 19: JSON Schema validation for conventionalCommits and changelog sections', () => { + /** + * **Validates: Requirements 4.4, 4.5, 8.5, 8.6, 15.3** + * + * For any valid conventionalCommits and changelog config sections, + * loadAndValidateConfig succeeds and returns a config with merged defaults. + */ + test('valid conventionalCommits and changelog configs always pass validation', () => { + fc.assert( + fc.property(arbValidCC, arbValidChangelog, (cc, cl) => { + const configObj: any = { git: { ...BASE_GIT } }; + if (Object.keys(cc).length > 0) configObj.conventionalCommits = cc; + if (Object.keys(cl).length > 0) configObj.changelog = cl; + + const filePath = writeTempConfig(configObj); + const result = loadAndValidateConfig(filePath); + + expect(result).toBeDefined(); + expect(result.git.platform).toBe('github'); + expect(result.conventionalCommits).toBeDefined(); + expect(result.changelog).toBeDefined(); + + // Verify conventionalCommits defaults are applied + expect(typeof result.conventionalCommits!.enabled).toBe('boolean'); + expect(result.conventionalCommits!.types).toBeDefined(); + expect(typeof result.conventionalCommits!.types).toBe('object'); + + // Verify changelog defaults are applied + expect(result.changelog!.groupTitles).toBeDefined(); + expect(Array.isArray(result.changelog!.excludeTypes)).toBe(true); + expect(typeof result.changelog!.includeNonConventional).toBe('boolean'); + }), + { numRuns: 100 }, + ); + }); + + test('invalid conventionalCommits.types values always fail validation', () => { + const arbInvalidBumpValue = fc.string({ minLength: 1, maxLength: 20 }) + .filter((s) => !['major', 'minor', 'patch', 'none'].includes(s) && s.trim().length > 0); + + fc.assert( + fc.property(arbSafeString, arbInvalidBumpValue, (key, value) => { + const configObj = { + git: { ...BASE_GIT }, + conventionalCommits: { + types: { [key]: value }, + }, + }; + const filePath = writeTempConfig(configObj); + try { + loadAndValidateConfig(filePath); + throw new Error('Expected VersioningsError'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }), + { numRuns: 50 }, + ); + }); + + test('invalid conventionalCommits.fallbackBump values always fail validation', () => { + const arbInvalidFallback = fc.string({ minLength: 1, maxLength: 20 }) + .filter((s) => !['patch', 'minor', 'major'].includes(s) && s.trim().length > 0); + + fc.assert( + fc.property(arbInvalidFallback, (fallback) => { + const configObj = { + git: { ...BASE_GIT }, + conventionalCommits: { + fallbackBump: fallback, + }, + }; + const filePath = writeTempConfig(configObj); + try { + loadAndValidateConfig(filePath); + throw new Error('Expected VersioningsError'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }), + { numRuns: 50 }, + ); + }); + + test('unknown keys in conventionalCommits section fail validation (additionalProperties: false)', () => { + fc.assert( + fc.property( + arbSafeString.filter((s) => !['enabled', 'types', 'fallbackBump'].includes(s)), + fc.string({ minLength: 1, maxLength: 20 }), + (unknownKey, value) => { + const configObj = { + git: { ...BASE_GIT }, + conventionalCommits: { + [unknownKey]: value, + }, + }; + const filePath = writeTempConfig(configObj); + try { + loadAndValidateConfig(filePath); + throw new Error('Expected VersioningsError'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }, + ), + { numRuns: 50 }, + ); + }); + + test('unknown keys in changelog section fail validation (additionalProperties: false)', () => { + fc.assert( + fc.property( + arbSafeString.filter( + (s) => !['template', 'groupTitles', 'excludeTypes', 'includeNonConventional', 'file'].includes(s), + ), + fc.string({ minLength: 1, maxLength: 20 }), + (unknownKey, value) => { + const configObj = { + git: { ...BASE_GIT }, + changelog: { + [unknownKey]: value, + }, + }; + const filePath = writeTempConfig(configObj); + try { + loadAndValidateConfig(filePath); + throw new Error('Expected VersioningsError'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }, + ), + { numRuns: 50 }, + ); + }); + + test('changelog.excludeTypes with empty strings fails validation', () => { + const configObj = { + git: { ...BASE_GIT }, + changelog: { + excludeTypes: [''], + }, + }; + const filePath = writeTempConfig(configObj); + try { + loadAndValidateConfig(filePath); + throw new Error('Expected VersioningsError'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }); + + test('changelog.groupTitles with empty string values fails validation', () => { + const configObj = { + git: { ...BASE_GIT }, + changelog: { + groupTitles: { feat: '' }, + }, + }; + const filePath = writeTempConfig(configObj); + try { + loadAndValidateConfig(filePath); + throw new Error('Expected VersioningsError'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }); +}); + +// ── Property 20: Backward compatibility of configuration and exit codes ───── + +describe('Property 20: Backward compatibility of configuration and exit codes', () => { + /** + * **Validates: Requirements 12.2, 15.4** + * + * Configs without conventionalCommits/changelog sections pass validation + * (backward compatibility). EXIT_CODES 0-10 remain unchanged. + */ + test('configs without conventionalCommits/changelog sections pass validation', () => { + const arbPlatform = fc.constantFrom( + 'github', 'github-enterprise', 'bitbucket', 'bitbucket-server', 'gitlab', 'azure-devops', + ); + const arbUrl = fc.constant('https://github.com/org/repo.git'); + + fc.assert( + fc.property(arbPlatform, arbUrl, (platform, url) => { + const configObj: any = { git: { platform, url } }; + // Self-hosted platforms need apiUrl + if (platform === 'github-enterprise' || platform === 'bitbucket-server') { + configObj.git.apiUrl = 'https://git.corp.example.com/api'; + } + + const filePath = writeTempConfig(configObj); + const result = loadAndValidateConfig(filePath); + + expect(result).toBeDefined(); + expect(result.git.platform).toBe(platform); + expect(result.git.url).toBe(url); + + // Defaults for new sections should be applied + expect(result.conventionalCommits).toBeDefined(); + expect(result.conventionalCommits!.enabled).toBe(true); + expect(result.changelog).toBeDefined(); + expect(result.changelog!.includeNonConventional).toBe(false); + }), + { numRuns: 50 }, + ); + }); + + test('old configs with existing git sections but no new sections work correctly', () => { + fc.assert( + fc.property( + fc.constantFrom('github', 'bitbucket'), + fc.constant('https://github.com/org/repo.git'), + fc.string({ minLength: 1, maxLength: 100 }).filter((s) => s.trim().length > 0), + (platform, url, prTarget) => { + const configObj = { + git: { + platform, + url, + pr: { target: prTarget }, + }, + }; + const filePath = writeTempConfig(configObj); + const result = loadAndValidateConfig(filePath); + + expect(result).toBeDefined(); + expect(result.git.platform).toBe(platform); + expect(result.git.url).toBe(url); + expect(result.git.pr.target).toBe(prTarget); + + // New sections get defaults + expect(result.conventionalCommits!.types.feat).toBe('minor'); + expect(result.conventionalCommits!.types.fix).toBe('patch'); + expect(result.conventionalCommits!.fallbackBump).toBeNull(); + expect(result.changelog!.groupTitles.feat).toBe('Features'); + expect(result.changelog!.groupTitles.fix).toBe('Bug Fixes'); + expect(result.changelog!.excludeTypes).toEqual([]); + }, + ), + { numRuns: 50 }, + ); + }); + + test('EXIT_CODES 0-10 are unchanged for backward compatibility', () => { + // This is a deterministic check but validates Requirement 12.2 + expect(EXIT_CODES.SUCCESS).toBe(0); + expect(EXIT_CODES.CONFIG_ERROR).toBe(1); + expect(EXIT_CODES.DIRTY_TREE).toBe(2); + expect(EXIT_CODES.INVALID_ARGS).toBe(3); + expect(EXIT_CODES.ARTIFACT_CONFLICT).toBe(4); + expect(EXIT_CODES.COMMAND_FAILED).toBe(5); + expect(EXIT_CODES.NETWORK_ERROR).toBe(6); + expect(EXIT_CODES.INCOMPLETE_ROLLBACK).toBe(7); + expect(EXIT_CODES.NO_OPERATION).toBe(8); + expect(EXIT_CODES.USER_CANCELLED).toBe(9); + expect(EXIT_CODES.POLICY_VIOLATION).toBe(10); + // New code 11 exists but doesn't affect 0-10 + expect(EXIT_CODES.NO_CONVENTIONAL_COMMITS).toBe(11); + }); + + test('EXIT_CODES object is frozen (immutable)', () => { + expect(Object.isFrozen(EXIT_CODES)).toBe(true); + }); +}); diff --git a/__tests__/properties/config/config.loader.property.test.ts b/__tests__/properties/config/config.loader.property.test.ts new file mode 100644 index 0000000..2d431ca --- /dev/null +++ b/__tests__/properties/config/config.loader.property.test.ts @@ -0,0 +1,391 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: core-ux-config-cli, Property 4, 5 & 8: Config loader properties + +import * as fc from 'fast-check'; +import * as path from 'path'; +import Ajv from 'ajv'; +import { loadConfig, ConfigLoaderDeps } from '../../../src/config/config.loader'; +import { schema } from '../../../src/config/config.validator'; +import { serializeYaml } from '../../../src/config/yaml.parser'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const CWD = '/project'; + +/** + * Known env var ↔ config path mapping (mirrors ENV_VAR_MAP in config.loader.ts). + */ +const KNOWN_MAPPINGS: Array<{ envVar: string; configPath: string }> = [ + { envVar: 'VERSIONINGS_GIT_PLATFORM', configPath: 'git.platform' }, + { envVar: 'VERSIONINGS_GIT_URL', configPath: 'git.url' }, + { envVar: 'VERSIONINGS_GIT_PR_TARGET', configPath: 'git.pr.target' }, + { envVar: 'VERSIONINGS_GIT_REMOTE', configPath: 'git.remote' }, + { envVar: 'VERSIONINGS_GIT_BRANCH_TYPE_VERSION', configPath: 'git.branchType.version' }, + { envVar: 'VERSIONINGS_GIT_BRANCHING_STRATEGY', configPath: 'git.branching.strategy' }, + { envVar: 'VERSIONINGS_GIT_BRANCHING_MAIN_BRANCH', configPath: 'git.branching.mainBranch' }, + { envVar: 'VERSIONINGS_GIT_BRANCHING_DEVELOP_BRANCH', configPath: 'git.branching.developBranch' }, +]; + +/** + * Convert a dot-notation config path to the expected env var name. + * Convention: VERSIONINGS_ + path segments split on dots, each segment + * converted from camelCase to UPPER_SNAKE_CASE, joined by _. + * + * Example: git.branchType.version → VERSIONINGS_GIT_BRANCH_TYPE_VERSION + */ +function configPathToEnvVar(configPath: string): string { + const segments = configPath.split('.'); + const upperSegments = segments.map((seg) => + seg.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(), + ); + return 'VERSIONINGS_' + upperSegments.join('_'); +} + +/** + * Convert an env var name (VERSIONINGS_) to dot-notation config path. + * Convention: strip prefix, lowercase, replace _ with . — but this is ambiguous + * for multi-segment names (e.g. PR_TARGET vs PR.TARGET). So we use the known map. + */ +function envVarToConfigPath(envVar: string): string | undefined { + const entry = KNOWN_MAPPINGS.find((m) => m.envVar === envVar); + return entry?.configPath; +} + +/** + * Resolve a value at a dot-notation path in a nested object. + */ +function getAtPath(obj: Record, dotPath: string): any { + const parts = dotPath.split('.'); + let current: any = obj; + for (const part of parts) { + if (current == null || typeof current !== 'object') return undefined; + current = current[part]; + } + return current; +} + +/** + * Create a mock filesystem from a map of filePath → content. + */ +function createMockFs(files: Record) { + return { + existsSync: (p: string): boolean => p in files, + readFileSync: (p: string, _enc: BufferEncoding): string => { + if (!(p in files)) throw new Error(`ENOENT: no such file: ${p}`); + return files[p]; + }, + }; +} + +// --------------------------------------------------------------------------- +// Generators +// --------------------------------------------------------------------------- + +/** Arbitrary for a known env var mapping entry. */ +const arbMapping = fc.constantFrom(...KNOWN_MAPPINGS); + +/** Arbitrary for a non-empty string value suitable for env vars and config fields. */ +const arbNonEmptyString = fc.string({ minLength: 1, maxLength: 100 }) + .filter((s) => s.trim().length > 0 && !s.includes('\n') && !s.includes('\0')); + +/** Arbitrary for a valid git platform. */ +const arbPlatform = fc.constantFrom('github' as const, 'bitbucket' as const); + +/** Arbitrary for a non-empty URL string. */ +const arbUrl = fc.string({ minLength: 1, maxLength: 200 }) + .filter((s) => s.trim().length > 0 && !s.includes('\n') && !s.includes('\0')); + +/** Arbitrary for a non-empty pr.target string. */ +const arbPrTarget = fc.string({ minLength: 1, maxLength: 200 }) + .filter((s) => s.trim().length > 0 && !s.includes('\n') && !s.includes('\0')); + +/** Arbitrary for a valid config object that passes schema validation. */ +const arbValidConfig = fc.record({ + platform: arbPlatform, + url: arbUrl, + prTarget: arbPrTarget, +}).map(({ platform, url, prTarget }) => ({ + git: { + platform, + url, + pr: { target: prTarget }, + }, +})); + +/** Arbitrary for init params. */ +const arbInitParams = fc.record({ + platform: arbPlatform, + url: arbUrl, + prTarget: arbPrTarget, + format: fc.constantFrom('json' as const, 'yaml' as const), +}); + +// --------------------------------------------------------------------------- +// Property 4: Env var mapping +// --------------------------------------------------------------------------- + +/** + * **Validates: Requirements 3.5** + * + * For any env var VERSIONINGS_, the mapping function converts it to + * the correct dot-notation config path. Reverse mapping should give the + * original env var name. + */ +describe('Property 4: Env var mapping', () => { + + test('forward mapping: setting env var produces correct config path value', () => { + fc.assert( + fc.property(arbMapping, arbNonEmptyString, (mapping, value) => { + // For env vars that map to platform, constrain value to valid enum + let effectiveValue = value; + if (mapping.configPath === 'git.platform') { + effectiveValue = Math.random() > 0.5 ? 'github' : 'bitbucket'; + } else if (mapping.configPath === 'git.branching.strategy') { + const strategies = ['default', 'trunk-based', 'git-flow', 'release-branch', 'hotfix', 'maintenance']; + effectiveValue = strategies[Math.floor(Math.random() * strategies.length)]; + } + + // Build env with the required fields + the test env var + const env: Record = { + VERSIONINGS_GIT_PLATFORM: 'github', + VERSIONINGS_GIT_URL: 'https://github.com/org/repo.git', + [mapping.envVar]: effectiveValue, + }; + + const fs = createMockFs({}); + const result = loadConfig({ cwd: CWD, env, ...fs }); + + // The config field at the mapped path should have the env var value + const actual = getAtPath(result.config, mapping.configPath); + expect(actual).toBe(effectiveValue); + }), + { numRuns: 100 }, + ); + }); + + test('reverse mapping: config path converts back to original env var name', () => { + fc.assert( + fc.property(arbMapping, (mapping) => { + // Forward: envVar → configPath (from known map) + const configPath = envVarToConfigPath(mapping.envVar); + expect(configPath).toBe(mapping.configPath); + + // Reverse: configPath → envVar (via convention) + const reconstructedEnvVar = configPathToEnvVar(mapping.configPath); + expect(reconstructedEnvVar).toBe(mapping.envVar); + }), + { numRuns: 100 }, + ); + }); + + test('env var source appears in provenance for mapped fields', () => { + fc.assert( + fc.property(arbMapping, arbNonEmptyString, (mapping, value) => { + let effectiveValue = value; + if (mapping.configPath === 'git.platform') { + effectiveValue = Math.random() > 0.5 ? 'github' : 'bitbucket'; + } else if (mapping.configPath === 'git.branching.strategy') { + const strategies = ['default', 'trunk-based', 'git-flow', 'release-branch', 'hotfix', 'maintenance']; + effectiveValue = strategies[Math.floor(Math.random() * strategies.length)]; + } + + const env: Record = { + VERSIONINGS_GIT_PLATFORM: 'github', + VERSIONINGS_GIT_URL: 'https://github.com/org/repo.git', + [mapping.envVar]: effectiveValue, + }; + + const fs = createMockFs({}); + const result = loadConfig({ cwd: CWD, env, ...fs }); + + // Provenance should show 'env' as source for the mapped field + expect(result.provenance[mapping.configPath]).toBeDefined(); + expect(result.provenance[mapping.configPath].source).toBe('env'); + expect(result.provenance[mapping.configPath].value).toBe(effectiveValue); + }), + { numRuns: 100 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 5: Validation is invariant to source +// --------------------------------------------------------------------------- + +/** + * **Validates: Requirements 4.3, 5.1, 5.2** + * + * For any valid config object, validation result is the same regardless of + * whether it came from JSON, YAML, package.json, or env vars. + */ +describe('Property 5: Validation is invariant to source', () => { + + test('valid config loaded from JSON, YAML, and package.json produces same validated config', () => { + fc.assert( + fc.property(arbValidConfig, (configObj) => { + const jsonContent = JSON.stringify(configObj); + const yamlContent = serializeYaml(configObj); + const pkgContent = JSON.stringify({ + name: 'test', + version: '1.0.0', + versionings: configObj, + }); + + // Load from version.json + const fsJson = createMockFs({ + [path.join(CWD, 'version.json')]: jsonContent, + }); + const resultJson = loadConfig({ cwd: CWD, env: {}, ...fsJson }); + + // Load from .versioningsrc.yml (YAML) + const fsYaml = createMockFs({ + [path.join(CWD, '.versioningsrc.yml')]: yamlContent, + }); + const resultYaml = loadConfig({ cwd: CWD, env: {}, ...fsYaml }); + + // Load from package.json#versionings + const fsPkg = createMockFs({ + [path.join(CWD, 'package.json')]: pkgContent, + }); + const resultPkg = loadConfig({ cwd: CWD, env: {}, ...fsPkg }); + + // All three should produce the same validated config + expect(resultJson.config.git.platform).toBe(resultYaml.config.git.platform); + expect(resultJson.config.git.platform).toBe(resultPkg.config.git.platform); + + expect(resultJson.config.git.url).toBe(resultYaml.config.git.url); + expect(resultJson.config.git.url).toBe(resultPkg.config.git.url); + + expect(resultJson.config.git.pr.target).toBe(resultYaml.config.git.pr.target); + expect(resultJson.config.git.pr.target).toBe(resultPkg.config.git.pr.target); + + // All should have the same default values for fields not in the source + expect(resultJson.config.git.remote).toBe(resultYaml.config.git.remote); + expect(resultJson.config.git.remote).toBe(resultPkg.config.git.remote); + }), + { numRuns: 100 }, + ); + }); + + test('valid config from env vars produces same core fields as from JSON file', () => { + fc.assert( + fc.property(arbPlatform, arbUrl, (platform, url) => { + // Load from version.json + const configObj = { git: { platform, url } }; + const fsJson = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(configObj), + }); + const resultJson = loadConfig({ cwd: CWD, env: {}, ...fsJson }); + + // Load from env vars + const fsEmpty = createMockFs({}); + const resultEnv = loadConfig({ + cwd: CWD, + env: { + VERSIONINGS_GIT_PLATFORM: platform, + VERSIONINGS_GIT_URL: url, + }, + ...fsEmpty, + }); + + // Core fields should match + expect(resultJson.config.git.platform).toBe(resultEnv.config.git.platform); + expect(resultJson.config.git.url).toBe(resultEnv.config.git.url); + + // Default fields should also match (both get defaults) + expect(resultJson.config.git.remote).toBe(resultEnv.config.git.remote); + }), + { numRuns: 100 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 8: Init generates valid config +// --------------------------------------------------------------------------- + +/** + * **Validates: Requirements 6.5** + * + * For any valid combination of init params (platform from ['github', 'bitbucket'], + * non-empty URL, non-empty pr.target, format from ['json', 'yaml']), + * the generated config passes schema validation. + */ +describe('Property 8: Init generates valid config', () => { + + test('generated init config passes JSON Schema validation for any valid params', () => { + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schema); + + fc.assert( + fc.property(arbInitParams, ({ platform, url, prTarget, format }) => { + // Build the config object that init would generate + const generatedConfig: Record = { + git: { + platform, + url, + pr: { target: prTarget }, + }, + }; + + // Validate against schema + const valid = validate(generatedConfig); + expect(valid).toBe(true); + + // Verify the config can be serialized in the chosen format and re-parsed + if (format === 'json') { + const serialized = JSON.stringify(generatedConfig, null, 2); + const reparsed = JSON.parse(serialized); + expect(reparsed).toEqual(generatedConfig); + } else { + const serialized = serializeYaml(generatedConfig); + // YAML round-trip: re-parse should match + const yaml = require('js-yaml'); + const reparsed = yaml.load(serialized); + expect(reparsed).toEqual(generatedConfig); + } + }), + { numRuns: 100 }, + ); + }); + + test('generated init config can be loaded by loadConfig without errors', () => { + fc.assert( + fc.property(arbInitParams, ({ platform, url, prTarget, format }) => { + const generatedConfig = { + git: { + platform, + url, + pr: { target: prTarget }, + }, + }; + + // Simulate writing the config and loading it + let content: string; + let fileName: string; + if (format === 'json') { + content = JSON.stringify(generatedConfig, null, 2); + fileName = 'version.json'; + } else { + content = serializeYaml(generatedConfig); + fileName = '.versioningsrc.yml'; + } + + const fs = createMockFs({ + [path.join(CWD, fileName)]: content, + }); + + const result = loadConfig({ cwd: CWD, env: {}, ...fs }); + + // Should load without throwing and produce correct values + expect(result.config.git.platform).toBe(platform); + expect(result.config.git.url).toBe(url); + expect(result.config.git.pr.target).toBe(prTarget); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/config/config.merger.property.test.ts b/__tests__/properties/config/config.merger.property.test.ts new file mode 100644 index 0000000..021ffc0 --- /dev/null +++ b/__tests__/properties/config/config.merger.property.test.ts @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: core-ux-config-cli, Property 3 & Property 6: Config merger properties + +import * as fc from 'fast-check'; +import { mergeConfigs, ConfigSource } from '../../../src/config/config.merger'; + +// --- Generators --- + +/** + * Arbitrary for safe object keys — lowercase alpha strings. + * Excludes prototype-pollution keys. + */ +const arbKey = fc + .stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), { + minLength: 1, + maxLength: 15, + }) + .filter((k) => !['__proto__', 'constructor', 'prototype'].includes(k)); + +/** + * Arbitrary for JSON-safe leaf values: strings, integers, booleans. + * No undefined, NaN, Infinity, or complex types. + */ +const arbLeafValue: fc.Arbitrary = fc.oneof( + fc.string({ minLength: 1, maxLength: 50 }).filter((s) => s.trim().length > 0), + fc.integer({ min: -100000, max: 100000 }), + fc.boolean(), +); + +/** + * Arbitrary for a non-empty path of keys (1-3 segments). + */ +const arbPath = fc.array(arbKey, { minLength: 1, maxLength: 3 }); + +/** + * Build a nested object from a dot-path and a leaf value. + * e.g. (['git', 'platform'], 'github') => { git: { platform: 'github' } } + */ +function buildNestedObject(pathSegments: string[], value: any): Record { + if (pathSegments.length === 1) { + return { [pathSegments[0]]: value }; + } + return { [pathSegments[0]]: buildNestedObject(pathSegments.slice(1), value) }; +} + +/** + * Resolve a value at a nested path in an object. + */ +function getAtPath(obj: Record, pathSegments: string[]): any { + let current: any = obj; + for (const seg of pathSegments) { + if (current === undefined || current === null || typeof current !== 'object') { + return undefined; + } + current = current[seg]; + } + return current; +} + +/** + * Arbitrary for nested config objects with strings, numbers, booleans, + * and nested objects up to 2 levels deep. JSON-safe only. + */ +const arbConfigObject: fc.Arbitrary> = fc.letrec((tie) => ({ + leaf: arbLeafValue, + node: fc.dictionary(arbKey, fc.oneof( + { weight: 3, arbitrary: tie('leaf') }, + { weight: 1, arbitrary: tie('nested') }, + ), { minKeys: 1, maxKeys: 5 }), + nested: fc.dictionary(arbKey, tie('leaf'), { minKeys: 1, maxKeys: 4 }), +})).node; + +// --- Property 3: Priority and deep merge --- + +/** + * **Validates: Requirements 3.1, 3.2** + * + * For any two sources A (priority N) and B (priority N+1) with the same + * leaf field but different values, merged config SHALL contain value from B. + * Fields only in A SHALL be preserved (deep merge, not shallow replace). + */ +describe('Property 3: Priority and deep merge', () => { + + test('overlapping leaf field takes value from higher-priority source B', () => { + const arbInput = fc.tuple( + arbPath, // shared path (overlap) + arbLeafValue, // value in A + arbLeafValue, // value in B + arbPath, // A-only path + arbLeafValue, // A-only value + ).filter(([sharedPath, valA, valB, aOnlyPath]) => { + // Ensure A and B have different values for the shared field + if (valA === valB) return false; + // Ensure A-only path doesn't collide with shared path + if (sharedPath.join('.') === aOnlyPath.join('.')) return false; + return true; + }); + + fc.assert( + fc.property(arbInput, ([sharedPath, valA, valB, aOnlyPath, aOnlyVal]) => { + const aData = { + ...buildNestedObject(sharedPath, valA), + ...buildNestedObject(aOnlyPath, aOnlyVal), + }; + const bData = buildNestedObject(sharedPath, valB); + + const sources: ConfigSource[] = [ + { name: 'sourceA', data: aData }, + { name: 'sourceB', data: bData }, + ]; + + const { merged, provenance } = mergeConfigs(sources); + + // Overlapping field: B wins + expect(getAtPath(merged, sharedPath)).toBe(valB); + + // A-only field: preserved + expect(getAtPath(merged, aOnlyPath)).toBe(aOnlyVal); + + // Provenance: shared field source is B + const sharedDotPath = sharedPath.join('.'); + expect(provenance[sharedDotPath]).toBeDefined(); + expect(provenance[sharedDotPath].value).toBe(valB); + expect(provenance[sharedDotPath].source).toBe('sourceB'); + + // Provenance: A-only field source is A + const aOnlyDotPath = aOnlyPath.join('.'); + expect(provenance[aOnlyDotPath]).toBeDefined(); + expect(provenance[aOnlyDotPath].value).toBe(aOnlyVal); + expect(provenance[aOnlyDotPath].source).toBe('sourceA'); + }), + { numRuns: 100 }, + ); + }); + + test('fields only in lower-priority source A are preserved after merge with B', () => { + fc.assert( + fc.property(arbConfigObject, arbConfigObject, (dataA, dataB) => { + const sources: ConfigSource[] = [ + { name: 'low', data: dataA }, + { name: 'high', data: dataB }, + ]; + + const { merged } = mergeConfigs(sources); + + // Every leaf in A that is NOT overridden by B should be in merged + function checkPreserved(obj: Record, bObj: Record, mergedObj: Record, path: string[]): void { + for (const key of Object.keys(obj)) { + const val = obj[key]; + if (val === undefined) continue; + const currentPath = [...path, key]; + + if (typeof val === 'object' && val !== null && !Array.isArray(val)) { + const bSub = bObj && typeof bObj[key] === 'object' && bObj[key] !== null ? bObj[key] : {}; + const mSub = mergedObj && typeof mergedObj[key] === 'object' ? mergedObj[key] : {}; + checkPreserved(val, bSub, mSub, currentPath); + } else { + // If B doesn't have this key at this level, A's value should be preserved + if (bObj === undefined || bObj === null || !(key in bObj) || bObj[key] === undefined) { + expect(getAtPath(merged, currentPath)).toBe(val); + } + } + } + } + + checkPreserved(dataA, dataB, merged, []); + }), + { numRuns: 100 }, + ); + }); +}); + +// --- Property 6: Round-trip JSON config --- + +/** + * **Validates: Requirements 13.1** + * + * For any valid VersioningsConfig, JSON.parse(JSON.stringify(config)) + * SHALL deeply equal the original. + */ +describe('Property 6: Round-trip JSON config', () => { + + test('JSON.parse(JSON.stringify(config)) deeply equals the original for any config object', () => { + fc.assert( + fc.property(arbConfigObject, (config) => { + const roundTripped = JSON.parse(JSON.stringify(config)); + expect(roundTripped).toEqual(config); + }), + { numRuns: 100 }, + ); + }); + + test('round-trip preserves merged config from mergeConfigs', () => { + fc.assert( + fc.property(arbConfigObject, arbConfigObject, (dataA, dataB) => { + const sources: ConfigSource[] = [ + { name: 'first', data: dataA }, + { name: 'second', data: dataB }, + ]; + + const { merged } = mergeConfigs(sources); + const roundTripped = JSON.parse(JSON.stringify(merged)); + expect(roundTripped).toEqual(merged); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/config.property.test.ts b/__tests__/properties/config/config.property.test.ts similarity index 96% rename from __tests__/properties/config.property.test.ts rename to __tests__/properties/config/config.property.test.ts index 244539a..48462e8 100644 --- a/__tests__/properties/config.property.test.ts +++ b/__tests__/properties/config/config.property.test.ts @@ -6,8 +6,8 @@ import * as fc from 'fast-check'; import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; -import { loadAndValidateConfig } from '../../config.validator'; -import { EXIT_CODES, VersioningsError } from '../../errors'; +import { loadAndValidateConfig } from '../../../src/config/config.validator'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; // --- Generators --- diff --git a/__tests__/properties/config/schema.validation.property.test.ts b/__tests__/properties/config/schema.validation.property.test.ts new file mode 100644 index 0000000..b7ec190 --- /dev/null +++ b/__tests__/properties/config/schema.validation.property.test.ts @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: scm-provider-pr-automation, Property 14: Extended configuration validation +// **Validates: Requirements 2.1, 2.3, 5.1-5.6, 8.1-8.3, 8.5-8.7** + +import * as fc from 'fast-check'; +import * as path from 'path'; +import * as os from 'os'; +import * as fs from 'fs'; +import { loadAndValidateConfig } from '../../../src/config/config.validator'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; + +// --- Generators --- + +const VALID_PLATFORMS = ['github', 'github-enterprise', 'bitbucket', 'bitbucket-server', 'gitlab', 'azure-devops'] as const; +const SELF_HOSTED_REQUIRING_API_URL = ['github-enterprise', 'bitbucket-server'] as const; +const NO_API_URL_REQUIRED = ['github', 'bitbucket', 'gitlab', 'azure-devops'] as const; + +const arbPlatform = fc.constantFrom(...VALID_PLATFORMS); +const arbSelfHostedPlatform = fc.constantFrom(...SELF_HOSTED_REQUIRING_API_URL); +const arbNoApiUrlPlatform = fc.constantFrom(...NO_API_URL_REQUIRED); + +const arbInvalidPlatform = fc.string({ minLength: 1, maxLength: 30 }) + .filter((s) => !VALID_PLATFORMS.includes(s as any) && s.trim().length > 0); + +const arbApiUrl = fc.oneof( + fc.webUrl({ withFragments: false, withQueryParameters: false }), + fc.constant('https://git.corp.example.com/api'), + fc.constant('http://localhost:8080'), +); + +const arbInvalidApiUrl = fc.string({ minLength: 1, maxLength: 50 }) + .filter((s) => !s.startsWith('https://') && !s.startsWith('http://') && s.trim().length > 0); + +const arbTimeout = fc.integer({ min: 1000, max: 120000 }); +const arbInvalidTimeoutLow = fc.integer({ min: -10000, max: 999 }); +const arbInvalidTimeoutHigh = fc.integer({ min: 120001, max: 999999 }); + +const arbReviewers = fc.array(fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0), { minLength: 0, maxLength: 5 }); +const arbLabels = fc.array(fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0), { minLength: 0, maxLength: 5 }); +const arbLinkedIssues = fc.array(fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0), { minLength: 0, maxLength: 3 }); + +/** + * Generate a valid extended config object. + * For self-hosted platforms, always includes apiUrl. + */ +const arbValidExtendedConfig = fc.record({ + platform: arbPlatform, + url: fc.constant('https://github.com/org/repo.git'), + hasApiUrl: fc.boolean(), + apiUrl: arbApiUrl, + hasAuth: fc.boolean(), + authToken: fc.string({ minLength: 1, maxLength: 40 }).filter(s => s.trim().length > 0), + authMethod: fc.constantFrom('token' as const, 'bearer' as const), + hasApi: fc.boolean(), + timeout: arbTimeout, + hasReviewers: fc.boolean(), + reviewers: arbReviewers, + hasLabels: fc.boolean(), + labels: arbLabels, + hasDraft: fc.boolean(), + draft: fc.boolean(), + hasTemplate: fc.boolean(), + template: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), + hasMilestone: fc.boolean(), + milestone: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), + hasLinkedIssues: fc.boolean(), + linkedIssues: arbLinkedIssues, +}).map((r) => { + const config: any = { + git: { + platform: r.platform, + url: r.url, + }, + }; + + // Self-hosted platforms always need apiUrl + const needsApiUrl = SELF_HOSTED_REQUIRING_API_URL.includes(r.platform as any); + if (needsApiUrl || r.hasApiUrl) { + config.git.apiUrl = r.apiUrl; + } + + if (r.hasAuth) { + config.git.auth = { token: r.authToken, method: r.authMethod }; + } + + if (r.hasApi) { + config.git.api = { timeout: r.timeout }; + } + + const pr: any = {}; + if (r.hasReviewers) pr.reviewers = r.reviewers; + if (r.hasLabels) pr.labels = r.labels; + if (r.hasDraft) pr.draft = r.draft; + if (r.hasTemplate) pr.template = r.template; + if (r.hasMilestone) pr.milestone = r.milestone; + if (r.hasLinkedIssues) pr.linkedIssues = r.linkedIssues; + if (Object.keys(pr).length > 0) { + config.git.pr = pr; + } + + return config; +}); + +// --- Helpers --- + +let tmpDir: string; +let tmpFiles: string[] = []; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-prop14-')); + tmpFiles = []; +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeTempConfig(obj: any): string { + const filePath = path.join(tmpDir, `version-${tmpFiles.length}.json`); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8'); + tmpFiles.push(filePath); + return filePath; +} + +// --- Property 14: Extended configuration validation --- + +describe('Property 14: Extended configuration validation', () => { + + test('valid extended configs always pass validation', () => { + fc.assert( + fc.property(arbValidExtendedConfig, (configObj: any) => { + const filePath = writeTempConfig(configObj); + const result = loadAndValidateConfig(filePath); + expect(result).toBeDefined(); + expect(result.git.platform).toBe(configObj.git.platform); + expect(result.git.url).toBe(configObj.git.url); + }), + { numRuns: 100 }, + ); + }); + + test('invalid platform values always fail validation', () => { + fc.assert( + fc.property(arbInvalidPlatform, (platform: string) => { + const configObj = { + git: { platform, url: 'https://example.com/org/repo.git' }, + }; + const filePath = writeTempConfig(configObj); + try { + loadAndValidateConfig(filePath); + throw new Error('Expected VersioningsError'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }), + { numRuns: 100 }, + ); + }); + + test('self-hosted platforms without apiUrl always fail validation', () => { + fc.assert( + fc.property(arbSelfHostedPlatform, (platform: string) => { + const configObj = { + git: { platform, url: 'https://example.com/org/repo.git' }, + }; + const filePath = writeTempConfig(configObj); + try { + loadAndValidateConfig(filePath); + throw new Error('Expected VersioningsError'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }), + { numRuns: 20 }, + ); + }); + + test('apiUrl must start with http:// or https://', () => { + fc.assert( + fc.property(arbInvalidApiUrl, (apiUrl: string) => { + const configObj = { + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + apiUrl, + }, + }; + const filePath = writeTempConfig(configObj); + try { + loadAndValidateConfig(filePath); + throw new Error('Expected VersioningsError'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }), + { numRuns: 50 }, + ); + }); + + test('api.timeout must be integer in [1000, 120000]', () => { + fc.assert( + fc.property( + fc.oneof(arbInvalidTimeoutLow, arbInvalidTimeoutHigh), + (timeout: number) => { + const configObj = { + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + api: { timeout }, + }, + }; + const filePath = writeTempConfig(configObj); + try { + loadAndValidateConfig(filePath); + throw new Error('Expected VersioningsError'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }, + ), + { numRuns: 50 }, + ); + }); + + test('config without new sections passes validation (backward compatibility)', () => { + fc.assert( + fc.property( + fc.constantFrom('github', 'bitbucket'), + fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0), + (platform: string, url: string) => { + const configObj = { git: { platform, url } }; + const filePath = writeTempConfig(configObj); + const result = loadAndValidateConfig(filePath); + expect(result).toBeDefined(); + expect(result.git.platform).toBe(platform); + expect(result.git.auth).toBeUndefined(); + expect(result.git.api).toBeUndefined(); + expect(result.git.apiUrl).toBeUndefined(); + }, + ), + { numRuns: 50 }, + ); + }); +}); diff --git a/__tests__/properties/config/unknown.keys.property.test.ts b/__tests__/properties/config/unknown.keys.property.test.ts new file mode 100644 index 0000000..289749b --- /dev/null +++ b/__tests__/properties/config/unknown.keys.property.test.ts @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: core-ux-config-cli, Property 15 & 16: Unknown keys policy + +import * as fc from 'fast-check'; +import { validateWithProvenance, ValidationResult } from '../../../src/config/config.validator'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; + +// --------------------------------------------------------------------------- +// Known schema keys at each nesting level +// --------------------------------------------------------------------------- + +const ROOT_KEYS = new Set(['git']); +const GIT_KEYS = new Set([ + 'platform', 'url', 'remote', 'branchType', 'pr', 'limits', 'commit', +]); +const GIT_PR_KEYS = new Set(['target']); +const GIT_BRANCH_TYPE_KEYS = new Set(['version']); +const GIT_LIMITS_KEYS = new Set(['branchMaxCommentLength']); +const GIT_COMMIT_KEYS = new Set(['message']); +const GIT_COMMIT_MESSAGE_KEYS = new Set(['semver']); +const GIT_SEMVER_KEYS = new Set([ + 'patch', 'prepatch', 'minor', 'preminor', 'premajor', 'major', 'prerelease', +]); + +// Prototype-polluting keys to avoid +const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf']); + +// --------------------------------------------------------------------------- +// Generators +// --------------------------------------------------------------------------- + +/** Safe key that doesn't collide with known schema keys or prototype keys. */ +function arbSafeKey(knownKeys: Set): fc.Arbitrary { + return fc.string({ minLength: 1, maxLength: 30 }) + .filter((s) => + /^[a-zA-Z][a-zA-Z0-9_]*$/.test(s) && + !knownKeys.has(s) && + !FORBIDDEN_KEYS.has(s) + ); +} + +/** Arbitrary primitive value for unknown fields. */ +const arbUnknownValue = fc.oneof( + fc.string({ minLength: 0, maxLength: 50 }), + fc.integer({ min: -1000, max: 1000 }), + fc.boolean(), +); + +/** Valid git platform. */ +const arbPlatform = fc.constantFrom('github' as const, 'bitbucket' as const); + +/** Non-empty URL string. */ +const arbUrl = fc.string({ minLength: 1, maxLength: 200 }) + .filter((s) => s.trim().length > 0); + +/** Source name for provenance. */ +const arbSource = fc.constantFrom( + 'defaults', 'version.json', '.versioningsrc', '.versioningsrc.json', + '.versioningsrc.yml', 'package.json#versionings', 'env', 'cli', +); + +/** + * Nesting level where an unknown key can be injected. + * Each level maps to a dot-prefix and the set of known keys at that level. + */ +interface NestingLevel { + prefix: string; + knownKeys: Set; +} + +const NESTING_LEVELS: NestingLevel[] = [ + { prefix: '', knownKeys: ROOT_KEYS }, + { prefix: 'git', knownKeys: GIT_KEYS }, + { prefix: 'git.pr', knownKeys: GIT_PR_KEYS }, + { prefix: 'git.branchType', knownKeys: GIT_BRANCH_TYPE_KEYS }, + { prefix: 'git.limits', knownKeys: GIT_LIMITS_KEYS }, + { prefix: 'git.commit', knownKeys: GIT_COMMIT_KEYS }, + { prefix: 'git.commit.message', knownKeys: GIT_COMMIT_MESSAGE_KEYS }, + { prefix: 'git.commit.message.semver', knownKeys: GIT_SEMVER_KEYS }, +]; + +/** Generate 1–3 unknown field injections at random nesting levels. */ +const arbUnknownInjections = fc.array( + fc.record({ + levelIdx: fc.integer({ min: 0, max: NESTING_LEVELS.length - 1 }), + value: arbUnknownValue, + source: arbSource, + }), + { minLength: 1, maxLength: 3 }, +).chain((injections) => { + // Generate unique keys for each injection at its level + const keyArbs = injections.map((inj) => + arbSafeKey(NESTING_LEVELS[inj.levelIdx].knownKeys) + ); + return fc.tuple(...keyArbs).map((keys) => + injections.map((inj, i) => ({ + ...inj, + key: keys[i], + level: NESTING_LEVELS[inj.levelIdx], + })) + ); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a valid base config with required fields. */ +function buildBaseConfig(platform: string, url: string): Record { + return { + git: { + platform, + url, + remote: 'origin', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + commit: { + message: { + semver: { + patch: 'Patch: v%s.', + prepatch: 'Prepatch: v%s.', + minor: 'Minor: v%s.', + preminor: 'Preminor: v%s.', + premajor: 'Premajor: v%s.', + major: 'Major: v%s.', + prerelease: 'Pre: v%s.', + }, + }, + }, + }, + }; +} + +/** Build provenance for the base config. */ +function buildBaseProvenance(platform: string, url: string): Record { + return { + 'git.platform': { value: platform, source: 'version.json' }, + 'git.url': { value: url, source: 'version.json' }, + 'git.remote': { value: 'origin', source: 'defaults' }, + 'git.branchType.version': { value: 'version', source: 'defaults' }, + 'git.pr.target': { value: 'master', source: 'defaults' }, + 'git.limits.branchMaxCommentLength': { value: 96, source: 'defaults' }, + }; +} + +/** + * Set a value at a dot-notation path in a nested object, creating + * intermediate objects as needed. + */ +function setAtPath(obj: Record, dotPath: string, value: any): void { + const parts = dotPath.split('.'); + let current = obj; + for (let i = 0; i < parts.length - 1; i++) { + if (!(parts[i] in current) || typeof current[parts[i]] !== 'object') { + current[parts[i]] = {}; + } + current = current[parts[i]]; + } + current[parts[parts.length - 1]] = value; +} + +// --------------------------------------------------------------------------- +// Property 15: Unknown fields — warning in default mode +// --------------------------------------------------------------------------- + +/** + * **Validates: Requirements 16.1** + * + * For any config with fields not in Config_Schema, without --strict, + * Config_Validator SHALL: (a) not throw, (b) include warnings listing + * unknown fields with sources, (c) return valid ValidationResult. + */ +describe('Property 15: Unknown fields — warning in default mode', () => { + + test('unknown fields produce warnings without throwing', () => { + fc.assert( + fc.property( + arbPlatform, + arbUrl, + arbUnknownInjections, + (platform, url, injections) => { + const config = buildBaseConfig(platform, url); + const provenance = buildBaseProvenance(platform, url); + + // Deduplicate by full path to avoid collisions + const seen = new Set(); + const uniqueInjections = injections.filter((inj) => { + const fullPath = inj.level.prefix + ? `${inj.level.prefix}.${inj.key}` + : inj.key; + if (seen.has(fullPath)) return false; + seen.add(fullPath); + return true; + }); + + // Inject unknown fields + const expectedPaths: string[] = []; + const expectedSources: Record = {}; + + for (const inj of uniqueInjections) { + const fullPath = inj.level.prefix + ? `${inj.level.prefix}.${inj.key}` + : inj.key; + setAtPath(config, fullPath, inj.value); + provenance[fullPath] = { value: inj.value, source: inj.source }; + expectedPaths.push(fullPath); + expectedSources[fullPath] = inj.source; + } + + // (a) SHALL not throw + let result: ValidationResult; + try { + result = validateWithProvenance(config, provenance, false); + } catch (err) { + throw new Error( + `Expected no throw in default mode, but got: ${err}` + ); + } + + // (c) SHALL return valid ValidationResult + expect(result.valid).toBe(true); + + // (b) SHALL include warnings listing unknown fields with sources + const warningPaths = result.warnings.map((w) => w.path); + for (const expectedPath of expectedPaths) { + expect(warningPaths).toContain(expectedPath); + } + + // Each warning should have the correct source from provenance + for (const w of result.warnings) { + if (expectedSources[w.path]) { + expect(w.source).toBe(expectedSources[w.path]); + } + } + }, + ), + { numRuns: 100 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 16: Unknown fields — error in strict mode +// --------------------------------------------------------------------------- + +/** + * **Validates: Requirements 16.2** + * + * For any config with at least one unknown field, with --strict, + * Config_Validator SHALL throw VersioningsError(CONFIG_ERROR) listing + * unknown fields. + */ +describe('Property 16: Unknown fields — error in strict mode', () => { + + test('unknown fields throw VersioningsError(CONFIG_ERROR) in strict mode', () => { + fc.assert( + fc.property( + arbPlatform, + arbUrl, + arbUnknownInjections, + (platform, url, injections) => { + const config = buildBaseConfig(platform, url); + const provenance = buildBaseProvenance(platform, url); + + // Deduplicate by full path + const seen = new Set(); + const uniqueInjections = injections.filter((inj) => { + const fullPath = inj.level.prefix + ? `${inj.level.prefix}.${inj.key}` + : inj.key; + if (seen.has(fullPath)) return false; + seen.add(fullPath); + return true; + }); + + const expectedPaths: string[] = []; + + for (const inj of uniqueInjections) { + const fullPath = inj.level.prefix + ? `${inj.level.prefix}.${inj.key}` + : inj.key; + setAtPath(config, fullPath, inj.value); + provenance[fullPath] = { value: inj.value, source: inj.source }; + expectedPaths.push(fullPath); + } + + // SHALL throw VersioningsError(CONFIG_ERROR) + try { + validateWithProvenance(config, provenance, true); + throw new Error( + 'Expected VersioningsError to be thrown in strict mode' + ); + } catch (err: any) { + if (err.message === 'Expected VersioningsError to be thrown in strict mode') { + throw err; + } + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + + // Details should list unknown fields + expect(err.details).toBeDefined(); + expect(Array.isArray(err.details.unknownFields)).toBe(true); + expect(err.details.unknownFields.length).toBeGreaterThanOrEqual(1); + + // All injected unknown paths should appear in the error + const reportedPaths = err.details.unknownFields.map( + (f: any) => f.path + ); + for (const expectedPath of expectedPaths) { + expect(reportedPaths).toContain(expectedPath); + } + } + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/config/yaml.roundtrip.property.test.ts b/__tests__/properties/config/yaml.roundtrip.property.test.ts new file mode 100644 index 0000000..c113375 --- /dev/null +++ b/__tests__/properties/config/yaml.roundtrip.property.test.ts @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: core-ux-config-cli, Property 7: Round-trip YAML configuration + +import * as fc from 'fast-check'; +import { parseYaml, serializeYaml } from '../../../src/config/yaml.parser'; + +// --- Generators --- + +/** + * Arbitrary for YAML-safe leaf values: strings, numbers, booleans. + * Strings are filtered to avoid values that js-yaml interprets + * as non-string types (e.g. "true", "null", "1.5") and to avoid + * characters that break YAML round-trip (control chars, leading/trailing whitespace). + */ +const arbYamlSafeString = fc + .stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-./'.split('')), { + minLength: 1, + maxLength: 80, + }) + .filter((s) => { + const trimmed = s.trim(); + if (trimmed.length === 0) return false; + // Exclude strings that js-yaml would parse as non-string scalars + const lower = trimmed.toLowerCase(); + if (['true', 'false', 'yes', 'no', 'on', 'off', 'null', 'undefined', 'nan', 'inf', '-inf', '.inf', '-.inf', '.nan'].includes(lower)) return false; + // Exclude pure numeric strings + if (/^-?\d+(\.\d+)?$/.test(trimmed)) return false; + // Exclude strings with leading/trailing whitespace (would be trimmed) + if (s !== trimmed) return false; + return true; + }); + +const arbLeafValue: fc.Arbitrary = fc.oneof( + arbYamlSafeString, + fc.integer({ min: -1000000, max: 1000000 }), + fc.double({ min: -1e6, max: 1e6, noNaN: true, noDefaultInfinity: true }) + .filter((n) => Number.isFinite(n)), + fc.boolean(), +); + +/** + * Arbitrary for YAML-safe object keys (valid identifiers). + */ +const arbKey = fc.stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), + { minLength: 1, maxLength: 20 }, +); + +/** + * Arbitrary for nested config objects with strings, numbers, booleans, + * and nested objects up to 3 levels deep. + */ +const arbConfigObject: fc.Arbitrary> = fc.letrec((tie) => ({ + leaf: arbLeafValue, + node: fc.dictionary(arbKey, fc.oneof( + { weight: 3, arbitrary: tie('leaf') }, + { weight: 1, arbitrary: tie('nested') }, + ), { minKeys: 1, maxKeys: 6 }), + nested: fc.dictionary(arbKey, fc.oneof( + { weight: 4, arbitrary: tie('leaf') }, + { weight: 1, arbitrary: fc.dictionary(arbKey, tie('leaf'), { minKeys: 1, maxKeys: 4 }) }, + ), { minKeys: 1, maxKeys: 5 }), +})).node; + +// --- Property 7: Round-trip YAML configuration --- + +/** + * **Validates: Requirements 13.2, 4.4** + * + * For any valid config object, parseYaml(serializeYaml(config)) should + * deeply equal the original. This must hold for all data types used in + * Config_Schema: strings, numbers, booleans, and nested objects. + */ +describe('Property 7: Round-trip YAML configuration', () => { + + test('for any valid config object, parseYaml(serializeYaml(config)) deeply equals the original', () => { + fc.assert( + fc.property(arbConfigObject, (config) => { + const yamlStr = serializeYaml(config); + const parsed = parseYaml(yamlStr, 'roundtrip-test.yml'); + expect(parsed).toEqual(config); + }), + { numRuns: 100 }, + ); + }); + + test('round-trip preserves nested structure depth', () => { + fc.assert( + fc.property(arbConfigObject, (config) => { + const yamlStr = serializeYaml(config); + const parsed = parseYaml(yamlStr, 'depth-test.yml'); + + // Verify all keys at every level are preserved + function checkKeys(original: Record, result: Record): void { + expect(Object.keys(result).sort()).toEqual(Object.keys(original).sort()); + for (const key of Object.keys(original)) { + if (typeof original[key] === 'object' && original[key] !== null) { + expect(typeof result[key]).toBe('object'); + checkKeys(original[key], result[key]); + } + } + } + checkKeys(config, parsed); + }), + { numRuns: 100 }, + ); + }); + + test('serializeYaml always produces a non-empty string for non-empty objects', () => { + fc.assert( + fc.property(arbConfigObject, (config) => { + const yamlStr = serializeYaml(config); + expect(typeof yamlStr).toBe('string'); + expect(yamlStr.length).toBeGreaterThan(0); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/artifact.property.test.ts b/__tests__/properties/core/artifact.property.test.ts similarity index 98% rename from __tests__/properties/artifact.property.test.ts rename to __tests__/properties/core/artifact.property.test.ts index 243e546..1c8fd34 100644 --- a/__tests__/properties/artifact.property.test.ts +++ b/__tests__/properties/core/artifact.property.test.ts @@ -2,9 +2,9 @@ // Copyright (c) 2018-present Raman Marozau import * as fc from 'fast-check'; -import { createArtifactChecker } from '../../artifact.checker'; -import { EXIT_CODES, VersioningsError } from '../../errors'; -import type { Executor, ExecutorResult } from '../../executor'; +import { createArtifactChecker } from '../../../src/core/artifact.checker'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; +import type { Executor, ExecutorResult } from '../../../src/core/executor'; const gitSafeChar = fc.constantFrom( ...'abcdefghijklmnopqrstuvwxyz0123456789'.split('') diff --git a/__tests__/properties/dry-run.property.test.ts b/__tests__/properties/core/dry-run.property.test.ts similarity index 96% rename from __tests__/properties/dry-run.property.test.ts rename to __tests__/properties/core/dry-run.property.test.ts index 005f977..3178537 100644 --- a/__tests__/properties/dry-run.property.test.ts +++ b/__tests__/properties/core/dry-run.property.test.ts @@ -6,7 +6,7 @@ */ import * as fc from 'fast-check'; -import { EXIT_CODES, VersioningsError } from '../../errors'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; // Mock fs — must come before requiring pipeline jest.mock('fs', () => { const actual = jest.requireActual('fs'); @@ -19,7 +19,7 @@ jest.mock('fs', () => { const fs = require('fs'); -jest.mock('../../version.utils', () => ({ +jest.mock('../../../src/versioning/version.utils', () => ({ AVAILABLE_SEMVERS: ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease'], composeVersionBranchName: (semver: string, version: string, comment: string) => `version/${semver}/${version}/${comment}`, @@ -34,7 +34,7 @@ jest.mock('../../version.utils', () => ({ `https://github.com/user/repo/compare/develop...${branch}?expand=1`, })); -const { runPipeline } = require('../../pipeline'); +const { runPipeline } = require('../../../src/core/pipeline'); const mockConfig = { git: { @@ -173,7 +173,7 @@ describe('Property 1: Dry-run safety', () => { describe('Property 2: Dry-run plan completeness in JSON format', () => { test('for any valid input with dryRun=true and json=true, plan contains all required fields and is valid JSON', async () => { - const { createReporter } = require('../../reporter'); + const { createReporter } = require('../../../src/core/reporter'); await fc.assert( fc.asyncProperty(arbSemver, arbBranch, arbPush, async (semver, branch, push) => { const executor = createMockExecutor(); diff --git a/__tests__/properties/errors.property.test.ts b/__tests__/properties/core/errors.property.test.ts similarity index 92% rename from __tests__/properties/errors.property.test.ts rename to __tests__/properties/core/errors.property.test.ts index 7427c5c..7e7d3e5 100644 --- a/__tests__/properties/errors.property.test.ts +++ b/__tests__/properties/core/errors.property.test.ts @@ -3,7 +3,7 @@ // Feature: enterprise-readiness, Property 9: Exit codes and error format import * as fc from 'fast-check'; -import { EXIT_CODES, VersioningsError } from '../../errors'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; const exitCodeValues = Object.values(EXIT_CODES); @@ -20,12 +20,12 @@ const arbDetails = fc.oneof( describe('Property 9: Exit codes and error format', () => { - test('code is always in range 0-7 and is one of EXIT_CODES values', () => { + test('code is always in range 0-9 and is one of EXIT_CODES values', () => { fc.assert( fc.property(arbExitCode, arbMessage, arbDetails, (code, message, details) => { const err = new VersioningsError(code, message, details); expect(err.code).toBeGreaterThanOrEqual(0); - expect(err.code).toBeLessThanOrEqual(7); + expect(err.code).toBeLessThanOrEqual(11); expect(exitCodeValues).toContain(err.code); }), { numRuns: 100 } diff --git a/__tests__/properties/executor.property.test.ts b/__tests__/properties/core/executor.property.test.ts similarity index 96% rename from __tests__/properties/executor.property.test.ts rename to __tests__/properties/core/executor.property.test.ts index 1fb5e57..c935802 100644 --- a/__tests__/properties/executor.property.test.ts +++ b/__tests__/properties/core/executor.property.test.ts @@ -2,8 +2,8 @@ // Copyright (c) 2018-present Raman Marozau import * as fc from 'fast-check'; -import { createExecutor, ExecFn } from '../../executor'; -import { EXIT_CODES, VersioningsError } from '../../errors'; +import { createExecutor, ExecFn } from '../../../src/core/executor'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; // --- Generators --- diff --git a/__tests__/properties/core/operation.log.property.test.ts b/__tests__/properties/core/operation.log.property.test.ts new file mode 100644 index 0000000..c75b0fb --- /dev/null +++ b/__tests__/properties/core/operation.log.property.test.ts @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: core-ux-config-cli, Property 11: Operation log structure + +import * as fc from 'fast-check'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { createOperationLog, OperationLogEntry } from '../../../src/core/operation.log'; + +// --- Helpers --- + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'oplog-prop-')); +} + +function rmrf(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +} + +/** + * Replicates the sanitization logic from operation.log.ts + * to deterministically verify filenames. + */ +function sanitizeForFilename(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]/g, '-'); +} + +// --- Generators --- + +/** Arbitrary for valid semver type strings */ +const arbSemverType = fc.constantFrom( + 'patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease', +); + +/** Arbitrary for semver-like version strings (e.g. "1.2.3", "0.0.1-beta.1") */ +const arbVersion = fc.tuple( + fc.nat({ max: 99 }), + fc.nat({ max: 99 }), + fc.nat({ max: 99 }), + fc.option( + fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')), { + minLength: 1, + maxLength: 8, + }), + { nil: undefined }, + ), +).map(([major, minor, patch, pre]) => + pre ? `${major}.${minor}.${patch}-${pre}` : `${major}.${minor}.${patch}`, +); + +/** Arbitrary for ISO 8601 timestamps via real Date objects */ +const arbTimestamp = fc.date({ + min: new Date('2020-01-01T00:00:00.000Z'), + max: new Date('2030-12-31T23:59:59.999Z'), +}).map((d) => d.toISOString()); + +/** Arbitrary for non-empty branch-like strings */ +const arbBranch = fc.stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789/-_.'.split('')), + { minLength: 1, maxLength: 40 }, +); + +/** Arbitrary for non-empty tag-like strings */ +const arbTag = fc.stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789-_.'.split('')), + { minLength: 1, maxLength: 30 }, +); + +/** Arbitrary for RollbackStep objects */ +const arbStep = fc.record({ + type: fc.constantFrom( + 'npm_version_bump', 'branch_created', 'tag_created', 'committed', 'pushed', + ), + meta: fc.dictionary( + fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), { + minLength: 1, + maxLength: 10, + }), + fc.oneof(fc.string({ maxLength: 20 }), fc.integer(), fc.boolean()), + { minKeys: 0, maxKeys: 3 }, + ), +}); + +/** Arbitrary for a complete OperationLogEntry */ +const arbEntry: fc.Arbitrary = fc.tuple( + arbTimestamp, + arbSemverType, + arbVersion, + arbVersion, + arbBranch, + arbTag, + fc.array(arbStep, { minLength: 0, maxLength: 5 }), + fc.constantFrom('success' as const, 'failed' as const), +).chain(([timestamp, semver, version, previousVersion, branch, tag, steps, result]) => { + if (result === 'failed') { + return fc.record({ + code: fc.integer({ min: 1, max: 9 }), + message: fc.string({ minLength: 1, maxLength: 50 }), + }).map((error) => ({ + schemaVersion: 1 as const, + timestamp, + semver, + version, + previousVersion, + branch, + tag, + steps, + result, + error, + })); + } + return fc.constant({ + schemaVersion: 1 as const, + timestamp, + semver, + version, + previousVersion, + branch, + tag, + steps, + result, + }); +}); + +// --- Property 11: Operation log structure --- + +/** + * **Validates: Requirements 10.5, 10.7** + * + * For any pipeline result (success or failed), the saved log SHALL: + * (a) have filename matching --.json + * (b) contain schemaVersion: 1 + * (c) contain all required fields (timestamp, semver, version, previousVersion, steps, result) + * (d) be valid JSON + */ +describe('Property 11: Operation log structure', () => { + test('saved log has correct filename, schemaVersion, required fields, and valid JSON', async () => { + await fc.assert( + fc.asyncProperty(arbEntry, async (entry) => { + const tmpDir = makeTmpDir(); + try { + const log = createOperationLog(tmpDir); + const savedPath = await log.save(entry); + const filename = path.basename(savedPath); + + // (a) Filename matches --.json + const expectedTs = entry.timestamp.replace(/[:.]/g, '-'); + const expectedSemver = sanitizeForFilename(entry.semver); + const expectedVersion = sanitizeForFilename(entry.version); + const expectedFilename = `${expectedTs}-${expectedSemver}-${expectedVersion}.json`; + expect(filename).toBe(expectedFilename); + + // (d) File content is valid JSON + const raw = fs.readFileSync(savedPath, 'utf8'); + const parsed = JSON.parse(raw); + + // (b) Contains schemaVersion: 1 + expect(parsed.schemaVersion).toBe(1); + + // (c) Contains all required fields + expect(parsed).toHaveProperty('timestamp', entry.timestamp); + expect(parsed).toHaveProperty('semver', entry.semver); + expect(parsed).toHaveProperty('version', entry.version); + expect(parsed).toHaveProperty('previousVersion', entry.previousVersion); + expect(parsed).toHaveProperty('steps'); + expect(Array.isArray(parsed.steps)).toBe(true); + expect(parsed).toHaveProperty('result'); + expect(['success', 'failed']).toContain(parsed.result); + } finally { + rmrf(tmpDir); + } + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/core/provenance.property.test.ts b/__tests__/properties/core/provenance.property.test.ts new file mode 100644 index 0000000..5c4aec9 --- /dev/null +++ b/__tests__/properties/core/provenance.property.test.ts @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: core-ux-config-cli, Property 12, 13, 14: Provenance and output properties + +import * as fc from 'fast-check'; +import { createReporter } from '../../../src/core/reporter'; +import type { DoctorCheck, ValidateResult, DryRunPlan } from '../../../src/core/reporter'; +import type { ConfigProvenance } from '../../../src/config/config.merger'; + +// eslint-disable-next-line no-control-regex +const ANSI_PATTERN = /\x1b\[[0-9;]*m/; + +// --- Generators --- + +const arbStatus = fc.constantFrom<'pass' | 'fail' | 'warn'>('pass', 'fail', 'warn'); + +const arbDoctorCheck: fc.Arbitrary = fc.record({ + name: fc.string({ minLength: 1, maxLength: 60 }).filter((s) => s.trim().length > 0), + status: arbStatus, + found: fc.string({ minLength: 1, maxLength: 80 }).filter((s) => s.trim().length > 0), + expected: fc.option( + fc.string({ minLength: 1, maxLength: 80 }).filter((s) => s.trim().length > 0), + { nil: undefined }, + ), +}); + +const arbDoctorChecks = fc.array(arbDoctorCheck, { minLength: 1, maxLength: 10 }); + +const arbValidateResult: fc.Arbitrary = fc.record({ + valid: fc.boolean(), + checks: fc.array( + fc.record({ + name: fc.string({ minLength: 1, maxLength: 60 }).filter((s) => s.trim().length > 0), + status: arbStatus, + details: fc.string({ minLength: 1, maxLength: 100 }), + }), + { minLength: 0, maxLength: 8 }, + ), + provenance: fc.constant({} as ConfigProvenance), +}); + +const arbDryRunPlan: fc.Arbitrary = fc.record({ + dryRun: fc.constant(true as const), + currentVersion: fc.stringOf( + fc.constantFrom('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.'), + { minLength: 3, maxLength: 15 }, + ).filter((s) => /^\d/.test(s)), + nextVersion: fc.stringOf( + fc.constantFrom('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.'), + { minLength: 3, maxLength: 15 }, + ).filter((s) => /^\d/.test(s)), + semver: fc.constantFrom('patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease'), + branch: fc.string({ minLength: 1, maxLength: 100 }).filter((s) => s.trim().length > 0), + tag: fc.string({ minLength: 1, maxLength: 100 }).filter((s) => s.trim().length > 0), + commitMessage: fc.string({ minLength: 1, maxLength: 200 }), + pullRequestUrl: fc.oneof( + fc.constant(null), + fc.string({ minLength: 5, maxLength: 200 }).filter((s) => s.trim().length > 0), + ), + steps: fc.array(fc.string({ minLength: 1, maxLength: 100 }), { minLength: 1, maxLength: 10 }), +}); + +/** + * Arbitrary for safe provenance keys — dot-separated lowercase alpha segments. + */ +const arbProvenanceKey = fc + .array( + fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), { + minLength: 1, + maxLength: 12, + }), + { minLength: 1, maxLength: 4 }, + ) + .map((segs) => segs.join('.')); + +const arbLeafValue: fc.Arbitrary = fc.oneof( + fc.string({ minLength: 1, maxLength: 50 }).filter((s) => s.trim().length > 0), + fc.integer({ min: -100000, max: 100000 }), + fc.boolean(), +); + +const arbSourceName = fc.constantFrom( + 'defaults', + 'version.json', + '.versioningsrc', + '.versioningsrc.json', + '.versioningsrc.yml', + 'package.json#versionings', + 'env', + 'cli', +); + +/** + * Arbitrary for ConfigProvenance with 1-8 leaf fields. + */ +const arbProvenance: fc.Arbitrary = fc + .array( + fc.tuple(arbProvenanceKey, arbLeafValue, arbSourceName), + { minLength: 1, maxLength: 8 }, + ) + .map((entries) => { + const prov: ConfigProvenance = {}; + for (const [key, value, source] of entries) { + prov[key] = { value, source }; + } + return prov; + }); + +// --- Property 12: Doctor output completeness --- + +/** + * **Validates: Requirements 11.2** + * + * For any set of DoctorCheck[], the output SHALL contain for each check: + * status (pass/fail/warn), name, and found value. + */ +describe('Property 12: Doctor output completeness', () => { + test('human-readable output contains status, name, and found for each check', () => { + const reporter = createReporter({ json: false }); + + fc.assert( + fc.property(arbDoctorChecks, (checks) => { + const output = reporter.reportDoctor(checks); + // Strip ANSI for content matching + const clean = output.replace(/\x1b\[[0-9;]*m/g, ''); + + for (const check of checks) { + // Name must appear + expect(clean).toContain(check.name); + // Found value must appear + expect(clean).toContain(check.found); + // Status indicator: ✓ for pass, ⚠ for warn, ✗ for fail + // (in raw output with ANSI stripped, these symbols should be present) + if (check.status === 'pass') { + expect(clean).toContain('✓'); + } else if (check.status === 'warn') { + expect(clean).toContain('⚠'); + } else { + expect(clean).toContain('✗'); + } + } + }), + { numRuns: 100 }, + ); + }); + + test('JSON output contains status, name, and found for each check', () => { + const reporter = createReporter({ json: true }); + + fc.assert( + fc.property(arbDoctorChecks, (checks) => { + const output = reporter.reportDoctor(checks); + const parsed = JSON.parse(output) as DoctorCheck[]; + + expect(parsed).toHaveLength(checks.length); + for (let i = 0; i < checks.length; i++) { + expect(parsed[i].status).toBe(checks[i].status); + expect(parsed[i].name).toBe(checks[i].name); + expect(parsed[i].found).toBe(checks[i].found); + } + }), + { numRuns: 100 }, + ); + }); +}); + +// --- Property 13: JSON output validity --- + +/** + * **Validates: Requirements 7.4, 11.5** + * + * For any result of validate, doctor, plan, release with --json flag, + * stdout SHALL contain exactly one valid JSON object, terminated by newline, + * without ANSI escape sequences. + */ +describe('Property 13: JSON output validity', () => { + const reporter = createReporter({ json: true }); + + test('reportValidation JSON: one valid JSON, newline-terminated, no ANSI', () => { + fc.assert( + fc.property(arbValidateResult, (result) => { + const output = reporter.reportValidation(result); + expect(output.endsWith('\n')).toBe(true); + const body = output.slice(0, -1); + expect(() => JSON.parse(body)).not.toThrow(); + expect(ANSI_PATTERN.test(output)).toBe(false); + }), + { numRuns: 100 }, + ); + }); + + test('reportDoctor JSON: one valid JSON, newline-terminated, no ANSI', () => { + fc.assert( + fc.property(arbDoctorChecks, (checks) => { + const output = reporter.reportDoctor(checks); + expect(output.endsWith('\n')).toBe(true); + const body = output.slice(0, -1); + expect(() => JSON.parse(body)).not.toThrow(); + expect(ANSI_PATTERN.test(output)).toBe(false); + }), + { numRuns: 100 }, + ); + }); + + test('reportDryRun JSON: one valid JSON, newline-terminated, no ANSI', () => { + fc.assert( + fc.property(arbDryRunPlan, (plan) => { + const output = reporter.reportDryRun(plan); + expect(output.endsWith('\n')).toBe(true); + const body = output.slice(0, -1); + expect(() => JSON.parse(body)).not.toThrow(); + expect(ANSI_PATTERN.test(output)).toBe(false); + }), + { numRuns: 100 }, + ); + }); + + test('reportProvenance JSON: one valid JSON, newline-terminated, no ANSI', () => { + fc.assert( + fc.property(arbProvenance, (provenance) => { + const output = reporter.reportProvenance(provenance); + expect(output.endsWith('\n')).toBe(true); + const body = output.slice(0, -1); + expect(() => JSON.parse(body)).not.toThrow(); + expect(ANSI_PATTERN.test(output)).toBe(false); + }), + { numRuns: 100 }, + ); + }); +}); + +// --- Property 14: Config Provenance in print-config --- + +/** + * **Validates: Requirements 15.1, 15.4** + * + * For any config loaded from N sources, --print-config output SHALL contain + * for each leaf field: value and source. With --json, output SHALL be valid + * JSON with value and source fields. + */ +describe('Property 14: Config Provenance in print-config', () => { + test('human-readable output contains value and source for each leaf field', () => { + const reporter = createReporter({ json: false }); + + fc.assert( + fc.property(arbProvenance, (provenance) => { + const output = reporter.reportProvenance(provenance); + const clean = output.replace(/\x1b\[[0-9;]*m/g, ''); + + const paths = Object.keys(provenance); + for (const fieldPath of paths) { + const entry = provenance[fieldPath]; + const valStr = typeof entry.value === 'object' && entry.value !== null + ? JSON.stringify(entry.value) + : String(entry.value); + + // Field path must appear + expect(clean).toContain(fieldPath); + // Value must appear + expect(clean).toContain(valStr); + // Source must appear + expect(clean).toContain(entry.source); + } + }), + { numRuns: 100 }, + ); + }); + + test('JSON output contains value and source for each leaf field', () => { + const reporter = createReporter({ json: true }); + + fc.assert( + fc.property(arbProvenance, (provenance) => { + const output = reporter.reportProvenance(provenance); + const parsed = JSON.parse(output) as ConfigProvenance; + + const paths = Object.keys(provenance); + for (const fieldPath of paths) { + expect(parsed[fieldPath]).toBeDefined(); + expect(parsed[fieldPath].value).toEqual(provenance[fieldPath].value); + expect(parsed[fieldPath].source).toBe(provenance[fieldPath].source); + } + }), + { numRuns: 100 }, + ); + }); + + test('JSON print-config output is valid JSON with value and source structure', () => { + const reporter = createReporter({ json: true }); + + fc.assert( + fc.property(arbProvenance, (provenance) => { + const output = reporter.reportProvenance(provenance); + // Valid JSON, newline-terminated, no ANSI + expect(output.endsWith('\n')).toBe(true); + const body = output.slice(0, -1); + const parsed = JSON.parse(body); + expect(ANSI_PATTERN.test(output)).toBe(false); + + // Every field has value and source + for (const key of Object.keys(parsed)) { + expect(parsed[key]).toHaveProperty('value'); + expect(parsed[key]).toHaveProperty('source'); + expect(typeof parsed[key].source).toBe('string'); + } + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/reporter.property.test.ts b/__tests__/properties/core/reporter.property.test.ts similarity index 97% rename from __tests__/properties/reporter.property.test.ts rename to __tests__/properties/core/reporter.property.test.ts index e418224..ab3b959 100644 --- a/__tests__/properties/reporter.property.test.ts +++ b/__tests__/properties/core/reporter.property.test.ts @@ -2,9 +2,9 @@ // Copyright (c) 2018-present Raman Marozau import * as fc from 'fast-check'; -import { createReporter } from '../../reporter'; -import type { PipelineResult, DryRunPlan } from '../../reporter'; -import { EXIT_CODES, VersioningsError } from '../../errors'; +import { createReporter } from '../../../src/core/reporter'; +import type { PipelineResult, DryRunPlan } from '../../../src/core/reporter'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; const exitCodeValues = Object.values(EXIT_CODES); diff --git a/__tests__/properties/rollback.property.test.ts b/__tests__/properties/core/rollback.property.test.ts similarity index 96% rename from __tests__/properties/rollback.property.test.ts rename to __tests__/properties/core/rollback.property.test.ts index d920e86..3199355 100644 --- a/__tests__/properties/rollback.property.test.ts +++ b/__tests__/properties/core/rollback.property.test.ts @@ -2,9 +2,9 @@ // Copyright (c) 2018-present Raman Marozau import * as fc from 'fast-check'; -import { createRollbackManager } from '../../rollback'; -import type { Executor, ExecutorResult } from '../../executor'; -import type { RollbackStep } from '../../rollback'; +import { createRollbackManager } from '../../../src/core/rollback'; +import type { Executor, ExecutorResult } from '../../../src/core/executor'; +import type { RollbackStep } from '../../../src/core/rollback'; // --- Generators --- diff --git a/__tests__/properties/docs/ci-examples.property.test.ts b/__tests__/properties/docs/ci-examples.property.test.ts new file mode 100644 index 0000000..e8134a2 --- /dev/null +++ b/__tests__/properties/docs/ci-examples.property.test.ts @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +// Feature: documentation-pack, Property 12: CI example completeness + +import * as fc from 'fast-check'; +import * as fs from 'fs'; +import * as path from 'path'; + +// --- Constants --- + +const DOCS_DIR = path.join(__dirname, '..', '..', '..', 'docs'); +const CI_EXAMPLES_PATH = path.join(DOCS_DIR, 'ci-examples.md'); + +const CI_PLATFORMS = ['GitHub Actions', 'GitLab CI', 'Azure Pipelines', 'Bitbucket Pipelines'] as const; +type CIPlatform = (typeof CI_PLATFORMS)[number]; + +// Token patterns per platform (any of these is acceptable) +const TOKEN_PATTERNS: Record = { + 'GitHub Actions': [/GITHUB_TOKEN/, /VERSIONINGS_TOKEN/, /secrets\./], + 'GitLab CI': [/GITLAB_TOKEN/, /VERSIONINGS_TOKEN/, /secrets\./], + 'Azure Pipelines': [/AZURE_DEVOPS_TOKEN/, /VERSIONINGS_TOKEN/, /secrets\./], + 'Bitbucket Pipelines': [/BITBUCKET_TOKEN/, /VERSIONINGS_TOKEN/, /secrets\./], +}; + +// --- Helpers --- + +interface CISection { + platform: CIPlatform; + content: string; +} + +/** + * Read ci-examples.md and split into sections by `## ` headings. + * Returns only the 4 CI platform sections. + */ +function extractCISections(): CISection[] { + const content = fs.readFileSync(CI_EXAMPLES_PATH, 'utf-8'); + const lines = content.split('\n'); + + const sections: CISection[] = []; + let currentPlatform: CIPlatform | null = null; + let currentLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('## ')) { + // Flush previous section + if (currentPlatform !== null) { + sections.push({ platform: currentPlatform, content: currentLines.join('\n') }); + } + + // Check if this heading matches a CI platform + const heading = line.replace(/^## /, '').trim(); + const matched = CI_PLATFORMS.find((p) => heading === p); + currentPlatform = matched ?? null; + currentLines = matched ? [line] : []; + } else if (currentPlatform !== null) { + currentLines.push(line); + } + } + + // Flush last section + if (currentPlatform !== null) { + sections.push({ platform: currentPlatform, content: currentLines.join('\n') }); + } + + return sections; +} + +// --- Collect data --- + +const ciSections = extractCISections(); +const ciSectionMap = new Map(ciSections.map((s) => [s.platform, s.content])); + +// --- Tests --- + +/** + * Property 12: CI example completeness + * + * For each CI example section in ci-examples.md (GitHub Actions, GitLab CI, + * Azure Pipelines, Bitbucket Pipelines): + * - Verify the section contains `--ci` flag + * - Verify the section contains `--json` flag + * - Verify the section contains token passing via environment variables/secrets + * - Verify the section contains `versionings validate` step + * + * **Validates: Requirements 6.5, 6.6, 6.7** + */ +describe('Feature: documentation-pack, Property 12: CI example completeness', () => { + // --- Guard: all 4 CI platform sections must be present --- + + describe('deterministic: all 4 CI platform sections exist', () => { + test.each(CI_PLATFORMS)('ci-examples.md contains a "## %s" section', (platform) => { + expect(ciSectionMap.has(platform)).toBe(true); + }); + + test('exactly 4 CI platform sections are extracted', () => { + expect(ciSections.length).toBe(CI_PLATFORMS.length); + }); + }); + + // --- Deterministic: each section contains --ci flag --- + + describe('deterministic: each CI section contains --ci flag', () => { + test.each(CI_PLATFORMS)('%s section contains --ci', (platform) => { + const content = ciSectionMap.get(platform)!; + expect(content).toContain('--ci'); + }); + }); + + // --- Deterministic: each section contains --json flag --- + + describe('deterministic: each CI section contains --json flag', () => { + test.each(CI_PLATFORMS)('%s section contains --json', (platform) => { + const content = ciSectionMap.get(platform)!; + expect(content).toContain('--json'); + }); + }); + + // --- Deterministic: each section contains token pattern --- + + describe('deterministic: each CI section contains token passing', () => { + test.each(CI_PLATFORMS)('%s section contains a TOKEN pattern', (platform) => { + const content = ciSectionMap.get(platform)!; + const patterns = TOKEN_PATTERNS[platform]; + const hasToken = patterns.some((re) => re.test(content)); + expect(hasToken).toBe(true); + }); + }); + + // --- Deterministic: each section contains versionings validate --- + + describe('deterministic: each CI section contains versionings validate step', () => { + test.each(CI_PLATFORMS)('%s section contains "versionings validate"', (platform) => { + const content = ciSectionMap.get(platform)!; + expect(content).toContain('versionings validate'); + }); + }); + + // --- Property-based: random sampling of CI sections --- + + if (ciSections.length > 0) { + const arbCISection = fc.constantFrom(...ciSections); + + test('property: randomly sampled CI section contains --ci flag', () => { + fc.assert( + fc.property(arbCISection, (section) => { + expect(section.content).toContain('--ci'); + }), + { numRuns: 100 }, + ); + }); + + test('property: randomly sampled CI section contains --json flag', () => { + fc.assert( + fc.property(arbCISection, (section) => { + expect(section.content).toContain('--json'); + }), + { numRuns: 100 }, + ); + }); + + test('property: randomly sampled CI section contains token pattern', () => { + fc.assert( + fc.property(arbCISection, (section) => { + const patterns = TOKEN_PATTERNS[section.platform]; + const hasToken = patterns.some((re) => re.test(section.content)); + expect(hasToken).toBe(true); + }), + { numRuns: 100 }, + ); + }); + + test('property: randomly sampled CI section contains versionings validate', () => { + fc.assert( + fc.property(arbCISection, (section) => { + expect(section.content).toContain('versionings validate'); + }), + { numRuns: 100 }, + ); + }); + } +}); diff --git a/__tests__/properties/docs/code-examples.property.test.ts b/__tests__/properties/docs/code-examples.property.test.ts new file mode 100644 index 0000000..291a98a --- /dev/null +++ b/__tests__/properties/docs/code-examples.property.test.ts @@ -0,0 +1,407 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +// Feature: documentation-pack, Property 2: JSON example validity +// Feature: documentation-pack, Property 3: YAML example validity +// Feature: documentation-pack, Property 4: Config examples match JSON Schema +// Feature: documentation-pack, Property 5: CLI invocation correctness + +import * as fc from 'fast-check'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import Ajv from 'ajv'; + +import { SUBCOMMANDS } from '../../../src/cli/command.router'; +import { AVAILABLE_SEMVERS } from '../../../src/versioning/version.utils'; + +// --- Constants --- + +const DOCS_DIR = path.join(__dirname, '..', '..', '..', 'docs'); +const ROOT_DIR = path.join(__dirname, '..', '..', '..'); +const README_PATH = path.join(ROOT_DIR, 'README.md'); +const SCHEMA_PATH = path.join(ROOT_DIR, 'version.schema.json'); + +// --- Helpers --- + +/** List all .md files in docs/ directory */ +function getDocFiles(): string[] { + return fs.readdirSync(DOCS_DIR) + .filter((f) => f.endsWith('.md')) + .map((f) => path.join(DOCS_DIR, f)); +} + +/** Get all documentation files (docs/*.md + README.md) */ +function getAllDocFiles(): string[] { + return [...getDocFiles(), README_PATH]; +} + +interface CodeBlock { + /** Source file (absolute path) */ + sourceFile: string; + /** Source file basename for display */ + sourceBasename: string; + /** Language tag (json, yaml, bash, etc.) */ + language: string; + /** Content of the code block */ + content: string; +} + +/** + * Extract all fenced code blocks with language tags from a Markdown file. + * Matches ```\n...\n``` patterns. + */ +function extractCodeBlocks(filePath: string): CodeBlock[] { + const fileContent = fs.readFileSync(filePath, 'utf-8'); + const blocks: CodeBlock[] = []; + const regex = /```([a-z]+)\n([\s\S]*?)```/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(fileContent)) !== null) { + blocks.push({ + sourceFile: filePath, + sourceBasename: path.basename(filePath), + language: match[1], + content: match[2].trimEnd(), + }); + } + + return blocks; +} + +/** Check if a parsed config object looks like a Versionings config (has git.platform) */ +function isVersioningsConfig(obj: unknown): boolean { + if (typeof obj !== 'object' || obj === null) return false; + const record = obj as Record; + const git = record['git']; + if (typeof git !== 'object' || git === null) return false; + const gitRecord = git as Record; + return typeof gitRecord['platform'] === 'string'; +} + +/** + * Extract CLI invocations of `versionings` from a bash code block. + * Returns an array of parsed invocations with subcommand and semver value. + */ +interface CliInvocation { + /** The raw line */ + line: string; + /** Subcommand name (first arg not starting with -), or null */ + subcommand: string | null; + /** Value of --semver flag, or null */ + semverValue: string | null; +} + +function extractCliInvocations(content: string): CliInvocation[] { + const invocations: CliInvocation[] = []; + const lines = content.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + // Skip comments and empty lines + if (!trimmed || trimmed.startsWith('#')) continue; + + // Match lines that invoke versionings (directly or via npx/pnpm/npm exec) + const versioningsIndex = trimmed.indexOf('versionings'); + if (versioningsIndex < 0) continue; + + // Get everything after 'versionings' + const afterCmd = trimmed.substring(versioningsIndex + 'versionings'.length).trim(); + const tokens = afterCmd.split(/\s+/).filter(Boolean); + + // Extract subcommand: first token that doesn't start with '-' + let subcommand: string | null = null; + for (const token of tokens) { + if (!token.startsWith('-')) { + subcommand = token; + break; + } + } + + // Extract --semver value + let semverValue: string | null = null; + const semverMatch = afterCmd.match(/--semver[=\s]+([a-z-]+)/); + if (semverMatch) { + semverValue = semverMatch[1]; + } + + invocations.push({ line: trimmed, subcommand, semverValue }); + } + + return invocations; +} + +// --- Collect all code blocks --- + +const allDocFiles = getAllDocFiles(); +const allCodeBlocks = allDocFiles.flatMap((f) => extractCodeBlocks(f)); + +const jsonBlocks = allCodeBlocks.filter((b) => b.language === 'json'); +const yamlBlocks = allCodeBlocks.filter((b) => b.language === 'yaml'); +const bashBlocks = allCodeBlocks.filter((b) => b.language === 'bash' || b.language === 'shell'); + +// Config blocks: JSON or YAML blocks that represent Versionings config +const configBlocks: Array = []; +for (const block of jsonBlocks) { + try { + const parsed = JSON.parse(block.content); + if (isVersioningsConfig(parsed)) { + configBlocks.push({ ...block, parsed }); + } + } catch { + // Will be caught by Property 2 + } +} +for (const block of yamlBlocks) { + try { + const parsed = yaml.load(block.content); + if (isVersioningsConfig(parsed)) { + configBlocks.push({ ...block, parsed }); + } + } catch { + // Will be caught by Property 3 + } +} + +// CLI invocation blocks: bash blocks containing 'versionings' +interface CliBlock { + sourceBasename: string; + content: string; + invocations: CliInvocation[]; +} + +const cliBlocks: CliBlock[] = bashBlocks + .filter((b) => b.content.includes('versionings')) + .map((b) => ({ + sourceBasename: b.sourceBasename, + content: b.content, + invocations: extractCliInvocations(b.content), + })) + .filter((b) => b.invocations.length > 0); + +// Load JSON Schema +const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf-8')); +const ajv = new Ajv({ allErrors: true, strict: false }); +const validateSchema = ajv.compile(schema); + +// --- Tests --- + +/** + * Property 2: JSON example validity + * + * For any fenced code block with tag `json` in any document of the + * Documentation Pack, the content SHALL parse as syntactically valid JSON. + * + * **Validates: Requirements 12.1** + */ +describe('Feature: documentation-pack, Property 2: JSON example validity', () => { + describe('deterministic: all JSON blocks parse successfully', () => { + if (jsonBlocks.length === 0) { + test('no JSON blocks found', () => { + expect(jsonBlocks.length).toBeGreaterThan(0); + }); + } else { + test.each( + jsonBlocks.map((b, i) => [ + `${b.sourceBasename} #${i + 1}`, + b, + ] as const), + )('%s', (_label, block) => { + expect(() => JSON.parse(block.content)).not.toThrow(); + }); + } + }); + + if (jsonBlocks.length > 0) { + const arbJsonBlock = fc.constantFrom(...jsonBlocks); + + test('property: randomly sampled JSON blocks parse successfully', () => { + fc.assert( + fc.property(arbJsonBlock, (block) => { + expect(() => JSON.parse(block.content)).not.toThrow(); + }), + { numRuns: 100 }, + ); + }); + } +}); + +/** + * Property 3: YAML example validity + * + * For any fenced code block with tag `yaml` in any document of the + * Documentation Pack, the content SHALL parse as syntactically valid YAML. + * + * **Validates: Requirements 12.2** + */ +describe('Feature: documentation-pack, Property 3: YAML example validity', () => { + describe('deterministic: all YAML blocks parse successfully', () => { + if (yamlBlocks.length === 0) { + test('no YAML blocks found', () => { + expect(yamlBlocks.length).toBeGreaterThan(0); + }); + } else { + test.each( + yamlBlocks.map((b, i) => [ + `${b.sourceBasename} #${i + 1}`, + b, + ] as const), + )('%s', (_label, block) => { + expect(() => yaml.load(block.content)).not.toThrow(); + }); + } + }); + + if (yamlBlocks.length > 0) { + const arbYamlBlock = fc.constantFrom(...yamlBlocks); + + test('property: randomly sampled YAML blocks parse successfully', () => { + fc.assert( + fc.property(arbYamlBlock, (block) => { + expect(() => yaml.load(block.content)).not.toThrow(); + }), + { numRuns: 100 }, + ); + }); + } +}); + +/** + * Property 4: Config examples match JSON Schema + * + * For any fenced code block (JSON or YAML) in the Documentation Pack that + * represents a Versionings configuration (containing `git.platform`), the + * content SHALL validate against version.schema.json. + * + * **Validates: Requirements 12.3** + */ +describe('Feature: documentation-pack, Property 4: Config examples match JSON Schema', () => { + describe('deterministic: all config blocks validate against schema', () => { + if (configBlocks.length === 0) { + test('no config blocks found', () => { + expect(configBlocks.length).toBeGreaterThan(0); + }); + } else { + test.each( + configBlocks.map((b, i) => [ + `${b.sourceBasename} #${i + 1} (${b.language})`, + b, + ] as const), + )('%s', (_label, block) => { + const valid = validateSchema(block.parsed); + if (!valid) { + // Include schema errors in failure message for debugging + const errors = validateSchema.errors?.map((e) => `${e.instancePath} ${e.message}`).join('; '); + expect(valid).toBe(true); // Will fail with context + throw new Error(`Schema validation failed: ${errors}`); + } + }); + } + }); + + if (configBlocks.length > 0) { + const arbConfigBlock = fc.constantFrom(...configBlocks); + + test('property: randomly sampled config blocks validate against schema', () => { + fc.assert( + fc.property(arbConfigBlock, (block) => { + const valid = validateSchema(block.parsed); + expect(valid).toBe(true); + }), + { numRuns: 100 }, + ); + }); + } +}); + +/** + * Property 5: CLI invocation correctness + * + * For any fenced code block with tag `bash` or `shell` in the Documentation + * Pack containing a `versionings` invocation, the subcommand name (if present) + * SHALL belong to SUBCOMMANDS, and the --semver value (if present) SHALL + * belong to AVAILABLE_SEMVERS. + * + * **Validates: Requirements 12.4** + */ +describe('Feature: documentation-pack, Property 5: CLI invocation correctness', () => { + // Flatten all invocations for deterministic testing + interface FlatInvocation { + sourceBasename: string; + line: string; + subcommand: string | null; + semverValue: string | null; + } + + const allInvocations: FlatInvocation[] = cliBlocks.flatMap((b) => + b.invocations.map((inv) => ({ + sourceBasename: b.sourceBasename, + line: inv.line, + subcommand: inv.subcommand, + semverValue: inv.semverValue, + })), + ); + + const invocationsWithSubcommand = allInvocations.filter((inv) => inv.subcommand !== null); + const invocationsWithSemver = allInvocations.filter((inv) => inv.semverValue !== null); + + describe('deterministic: all subcommands are valid', () => { + if (invocationsWithSubcommand.length === 0) { + test('no CLI invocations with subcommands found', () => { + expect(invocationsWithSubcommand.length).toBeGreaterThan(0); + }); + } else { + test.each( + invocationsWithSubcommand.map((inv) => [ + `${inv.sourceBasename}: ${inv.line}`, + inv, + ] as const), + )('%s', (_label, inv) => { + expect(SUBCOMMANDS).toContain(inv.subcommand); + }); + } + }); + + describe('deterministic: all --semver values are valid', () => { + if (invocationsWithSemver.length === 0) { + test('no CLI invocations with --semver found', () => { + expect(invocationsWithSemver.length).toBeGreaterThan(0); + }); + } else { + test.each( + invocationsWithSemver.map((inv) => [ + `${inv.sourceBasename}: --semver=${inv.semverValue}`, + inv, + ] as const), + )('%s', (_label, inv) => { + expect(AVAILABLE_SEMVERS).toContain(inv.semverValue); + }); + } + }); + + if (allInvocations.length > 0) { + const arbInvocation = fc.constantFrom(...allInvocations); + + test('property: randomly sampled CLI invocations use valid subcommands', () => { + fc.assert( + fc.property(arbInvocation, (inv) => { + if (inv.subcommand !== null) { + expect(SUBCOMMANDS).toContain(inv.subcommand); + } + }), + { numRuns: 100 }, + ); + }); + + test('property: randomly sampled CLI invocations use valid --semver values', () => { + fc.assert( + fc.property(arbInvocation, (inv) => { + if (inv.semverValue !== null) { + expect(AVAILABLE_SEMVERS).toContain(inv.semverValue); + } + }), + { numRuns: 100 }, + ); + }); + } +}); diff --git a/__tests__/properties/docs/cross-references.property.test.ts b/__tests__/properties/docs/cross-references.property.test.ts new file mode 100644 index 0000000..cbf6211 --- /dev/null +++ b/__tests__/properties/docs/cross-references.property.test.ts @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +// Feature: documentation-pack, Property 1: Cross-reference integrity + +import * as fc from 'fast-check'; +import * as fs from 'fs'; +import * as path from 'path'; + +// --- Constants --- + +const DOCS_DIR = path.join(__dirname, '..', '..', '..', 'docs'); +const ROOT_DIR = path.join(__dirname, '..', '..', '..'); +const README_PATH = path.join(ROOT_DIR, 'README.md'); + +// --- Helpers --- + +/** List all .md files in docs/ directory */ +function getDocFiles(): string[] { + return fs.readdirSync(DOCS_DIR) + .filter((f) => f.endsWith('.md')) + .map((f) => path.join(DOCS_DIR, f)); +} + +/** Get all documentation files (docs/*.md + README.md) */ +function getAllDocFiles(): string[] { + return [...getDocFiles(), README_PATH]; +} + +interface MarkdownLink { + /** Source file (absolute path) */ + sourceFile: string; + /** Link text */ + text: string; + /** Raw href from markdown */ + href: string; + /** File portion of href (without anchor) */ + filePart: string; + /** Anchor portion (without #), or null */ + anchor: string | null; +} + +/** + * Extract all relative Markdown links from a file. + * Skips: image links, external links (http/https/mailto), pure anchors (#only), + * and links inside fenced code blocks. + */ +function extractRelativeLinks(filePath: string): MarkdownLink[] { + const content = fs.readFileSync(filePath, 'utf-8'); + const links: MarkdownLink[] = []; + + // Remove fenced code blocks to avoid false positives + const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, ''); + + // Match [text](href) but not ![text](href) + const linkRegex = /(?= 0 ? href.substring(0, hashIndex) : href; + const anchor = hashIndex >= 0 ? href.substring(hashIndex + 1) : null; + + // Skip pure same-file anchors (no file part) + if (filePart === '') { + continue; + } + + links.push({ + sourceFile: filePath, + text: match[1], + href, + filePart, + anchor, + }); + } + + return links; +} + +/** + * Convert a Markdown heading text to a GitHub-style anchor slug. + * Algorithm: lowercase, replace spaces with hyphens, remove non-alphanumeric + * chars (except hyphens), collapse consecutive hyphens, trim leading/trailing hyphens. + */ +function headingToAnchor(heading: string): string { + return heading + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +/** + * Extract all heading anchors from a Markdown file. + * Handles duplicate headings by appending -1, -2, etc. (GitHub behavior). + */ +function extractHeadingAnchors(filePath: string): Set { + const content = fs.readFileSync(filePath, 'utf-8'); + const anchors = new Set(); + const anchorCounts = new Map(); + + // Remove fenced code blocks + const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, ''); + + const headingRegex = /^#{1,6}\s+(.+)$/gm; + let match: RegExpExecArray | null; + + while ((match = headingRegex.exec(withoutCodeBlocks)) !== null) { + const baseAnchor = headingToAnchor(match[1]); + const count = anchorCounts.get(baseAnchor) || 0; + + if (count === 0) { + anchors.add(baseAnchor); + } else { + anchors.add(`${baseAnchor}-${count}`); + } + anchorCounts.set(baseAnchor, count + 1); + } + + return anchors; +} + +/** + * Resolve a relative link href to an absolute file path. + * Links from docs/*.md are resolved relative to docs/. + * Links from README.md are resolved relative to project root. + */ +function resolveLink(sourceFile: string, filePart: string): string { + const sourceDir = path.dirname(sourceFile); + return path.resolve(sourceDir, filePart); +} + +// --- Collect all links and documents --- + +const allDocFiles = getAllDocFiles(); +const docFiles = getDocFiles(); + +interface LinkEntry { + sourceFile: string; + sourceBasename: string; + text: string; + href: string; + filePart: string; + anchor: string | null; + resolvedPath: string; +} + +const allLinks: LinkEntry[] = allDocFiles.flatMap((file) => + extractRelativeLinks(file).map((link) => ({ + ...link, + sourceBasename: path.basename(link.sourceFile), + resolvedPath: resolveLink(link.sourceFile, link.filePart), + })), +); + +// --- Tests --- + +/** + * Property 1: Cross-reference integrity + * + * For any relative Markdown link in any document of the Documentation Pack + * (docs/*.md and README.md), the link SHALL use a relative path, the target + * file SHALL exist in the filesystem, and the anchor (if present) SHALL + * correspond to a heading in the target file. Additionally, every document + * in docs/ SHALL contain a navigation link to index.md. + * + * **Validates: Requirements 1.3, 1.5, 1.6** + */ +describe('Feature: documentation-pack, Property 1: Cross-reference integrity', () => { + // --- Deterministic tests: check ALL links --- + + describe('deterministic: all links target existing files', () => { + if (allLinks.length === 0) { + test('no links found (skip)', () => { + expect(allLinks.length).toBeGreaterThan(0); + }); + } else { + test.each( + allLinks.map((l) => [`${l.sourceBasename} → ${l.href}`, l] as const), + )('%s', (_label, link) => { + expect(fs.existsSync(link.resolvedPath)).toBe(true); + }); + } + }); + + describe('deterministic: all anchors match headings in target files', () => { + const linksWithAnchors = allLinks.filter((l) => l.anchor !== null); + + if (linksWithAnchors.length === 0) { + test('no anchor links found (skip)', () => { + // No anchors to check — pass + expect(true).toBe(true); + }); + } else { + test.each( + linksWithAnchors.map((l) => [`${l.sourceBasename} → ${l.href}`, l] as const), + )('%s', (_label, link) => { + const targetPath = link.resolvedPath; + expect(fs.existsSync(targetPath)).toBe(true); + + const headings = extractHeadingAnchors(targetPath); + expect(headings.has(link.anchor!)).toBe(true); + }); + } + }); + + describe('deterministic: every docs/*.md contains a link to index.md', () => { + const docsWithoutIndex = docFiles.filter( + (f) => path.basename(f) !== 'index.md', + ); + + test.each( + docsWithoutIndex.map((f) => [path.basename(f), f] as const), + )('%s links to index.md', (_basename, filePath) => { + const links = extractRelativeLinks(filePath); + const linksToIndex = links.filter((l) => { + const resolved = resolveLink(filePath, l.filePart); + return path.basename(resolved) === 'index.md'; + }); + expect(linksToIndex.length).toBeGreaterThanOrEqual(1); + }); + }); + + // --- Property-based tests: random sampling --- + + if (allLinks.length > 0) { + const arbLink = fc.constantFrom(...allLinks); + + test('property: randomly sampled links target existing files', () => { + fc.assert( + fc.property(arbLink, (link) => { + expect(fs.existsSync(link.resolvedPath)).toBe(true); + }), + { numRuns: 100 }, + ); + }); + + test('property: randomly sampled links with anchors match headings', () => { + const linksWithAnchors = allLinks.filter((l) => l.anchor !== null); + if (linksWithAnchors.length === 0) { + // No anchor links — property trivially holds + return; + } + + const arbAnchorLink = fc.constantFrom(...linksWithAnchors); + fc.assert( + fc.property(arbAnchorLink, (link) => { + const headings = extractHeadingAnchors(link.resolvedPath); + expect(headings.has(link.anchor!)).toBe(true); + }), + { numRuns: 100 }, + ); + }); + } + + if (docFiles.length > 0) { + const docsWithoutIndex = docFiles.filter( + (f) => path.basename(f) !== 'index.md', + ); + + if (docsWithoutIndex.length > 0) { + const arbDocFile = fc.constantFrom(...docsWithoutIndex); + + test('property: randomly sampled docs/*.md files contain a link to index.md', () => { + fc.assert( + fc.property(arbDocFile, (filePath) => { + const links = extractRelativeLinks(filePath); + const linksToIndex = links.filter((l) => { + const resolved = resolveLink(filePath, l.filePart); + return path.basename(resolved) === 'index.md'; + }); + expect(linksToIndex.length).toBeGreaterThanOrEqual(1); + }), + { numRuns: 100 }, + ); + }); + } + } +}); diff --git a/__tests__/properties/docs/document-format.property.test.ts b/__tests__/properties/docs/document-format.property.test.ts new file mode 100644 index 0000000..465e8f2 --- /dev/null +++ b/__tests__/properties/docs/document-format.property.test.ts @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +// Feature: documentation-pack, Property 6: Document format requirements + +import * as fc from 'fast-check'; +import * as fs from 'fs'; +import * as path from 'path'; + +// --- Constants --- + +const DOCS_DIR = path.join(__dirname, '..', '..', '..', 'docs'); + +// --- Helpers --- + +/** List all .md files in docs/ directory */ +function getDocFiles(): string[] { + return fs.readdirSync(DOCS_DIR) + .filter((f) => f.endsWith('.md')) + .map((f) => path.join(DOCS_DIR, f)); +} + +/** + * Get the first non-empty, non-navigation line from a Markdown file. + * Navigation lines start with `>` (blockquote used for navigation sections). + */ +function getFirstContentLine(filePath: string): string | null { + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === '') continue; + if (trimmed.startsWith('>')) continue; + return trimmed; + } + + return null; +} + +interface CodeBlockViolation { + /** Line number (1-based) of the opener without a language tag */ + lineNumber: number; + /** The raw line content */ + line: string; +} + +/** + * Find fenced code block openers that lack a language tag. + * Uses a line-by-line state machine to distinguish openers from closers. + * A valid opener is a line matching /^```[a-zA-Z]/ (backticks followed by at least one letter). + * A bare opener is a line that is exactly ``` (with optional trailing whitespace) when not inside a block. + */ +function findBareCodeBlockOpeners(filePath: string): CodeBlockViolation[] { + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + const violations: CodeBlockViolation[] = []; + let insideCodeBlock = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!insideCodeBlock) { + // Check if this line opens a fenced code block + if (trimmed.startsWith('```')) { + insideCodeBlock = true; + // Check if it has a language tag: at least one alphanumeric char after ``` + const afterBackticks = trimmed.slice(3).trim(); + if (!/^[a-zA-Z]/.test(afterBackticks)) { + violations.push({ lineNumber: i + 1, line: trimmed }); + } + } + } else { + // Inside a code block — check for closer + if (trimmed === '```') { + insideCodeBlock = false; + } + } + } + + return violations; +} + +// --- Collect data --- + +const docFiles = getDocFiles(); + +interface DocFileEntry { + filePath: string; + basename: string; +} + +const docEntries: DocFileEntry[] = docFiles.map((f) => ({ + filePath: f, + basename: path.basename(f), +})); + +// --- Tests --- + +/** + * Property 6: Document format requirements + * + * For every Markdown file in docs/*.md: + * - The first non-empty content line (skipping navigation blockquotes) starts with `# ` (H1 heading) + * - Every fenced code block has a language tag (not just bare ```) + * + * **Validates: Requirements 13.3, 13.4** + */ +describe('Feature: documentation-pack, Property 6: Document format requirements', () => { + // --- Deterministic: H1 heading --- + + describe('deterministic: every docs/*.md has an H1 heading as first content line', () => { + if (docEntries.length === 0) { + test('no doc files found', () => { + expect(docEntries.length).toBeGreaterThan(0); + }); + } else { + test.each( + docEntries.map((e) => [e.basename, e] as const), + )('%s', (_label, entry) => { + const firstLine = getFirstContentLine(entry.filePath); + expect(firstLine).not.toBeNull(); + expect(firstLine!.startsWith('# ')).toBe(true); + }); + } + }); + + // --- Deterministic: language tags on code blocks --- + + describe('deterministic: every fenced code block in docs/*.md has a language tag', () => { + if (docEntries.length === 0) { + test('no doc files found', () => { + expect(docEntries.length).toBeGreaterThan(0); + }); + } else { + test.each( + docEntries.map((e) => [e.basename, e] as const), + )('%s', (_label, entry) => { + const violations = findBareCodeBlockOpeners(entry.filePath); + if (violations.length > 0) { + const details = violations + .map((v) => ` line ${v.lineNumber}: ${v.line}`) + .join('\n'); + fail( + `Found ${violations.length} code block(s) without language tag in ${entry.basename}:\n${details}`, + ); + } + }); + } + }); + + // --- Property-based: random sampling --- + + if (docEntries.length > 0) { + const arbDocEntry = fc.constantFrom(...docEntries); + + test('property: randomly sampled docs/*.md files have H1 as first content line', () => { + fc.assert( + fc.property(arbDocEntry, (entry) => { + const firstLine = getFirstContentLine(entry.filePath); + expect(firstLine).not.toBeNull(); + expect(firstLine!.startsWith('# ')).toBe(true); + }), + { numRuns: 100 }, + ); + }); + + test('property: randomly sampled docs/*.md files have language tags on all code blocks', () => { + fc.assert( + fc.property(arbDocEntry, (entry) => { + const violations = findBareCodeBlockOpeners(entry.filePath); + expect(violations).toHaveLength(0); + }), + { numRuns: 100 }, + ); + }); + } +}); diff --git a/__tests__/properties/docs/sync-exit-codes.property.test.ts b/__tests__/properties/docs/sync-exit-codes.property.test.ts new file mode 100644 index 0000000..8d98213 --- /dev/null +++ b/__tests__/properties/docs/sync-exit-codes.property.test.ts @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +// Feature: documentation-pack, Property 8: Exit code synchronization with code + +import * as fc from 'fast-check'; +import * as fs from 'fs'; +import * as path from 'path'; +import { EXIT_CODES } from '../../../src/core/errors'; + +// --- Constants --- + +const DOCS_DIR = path.join(__dirname, '..', '..', '..', 'docs'); +const CLI_REFERENCE_PATH = path.join(DOCS_DIR, 'cli-reference.md'); +const FAILURE_MATRIX_PATH = path.join(DOCS_DIR, 'failure-matrix.md'); +const README_PATH = path.join(__dirname, '..', '..', '..', 'README.md'); + +// All expected exit code values from the source of truth +const EXPECTED_CODES: number[] = Object.values(EXIT_CODES).filter( + (v): v is number => typeof v === 'number' && Number.isInteger(v), +); +const EXPECTED_SET = new Set(EXPECTED_CODES); + +// Codes 1–11 must each have a dedicated ## Exit Code N: section in failure-matrix.md +const FAILURE_SECTION_CODES = EXPECTED_CODES.filter((c) => c >= 1 && c <= 11); + +// --- Helpers --- + +/** + * Extract exit code numbers from a Markdown table inside the `## Exit Codes` section. + * Reads the first column of each data row and parses it as an integer. + */ +function extractExitCodesFromSection(filePath: string): number[] { + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + + let inSection = false; + let inTable = false; + let separatorSeen = false; + const codes: number[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + + // Enter the Exit Codes section + if (/^## Exit Codes\s*$/i.test(trimmed) || /^## Exit Codes Overview\s*$/i.test(trimmed)) { + inSection = true; + inTable = false; + separatorSeen = false; + continue; + } + + // Leave on next ## heading (but not ### headings) + if (inSection && /^## /.test(trimmed) && !/^### /.test(trimmed)) { + break; + } + + if (!inSection) continue; + + if (trimmed.startsWith('|')) { + if (!inTable) { + // First | line is the header row — skip it + inTable = true; + continue; + } + + // Separator row (|---|---|...) + if (/^\|[\s\-:|]+\|$/.test(trimmed)) { + separatorSeen = true; + continue; + } + + if (!separatorSeen) continue; + + // Data row — extract first column + const columns = trimmed.split('|').filter((c) => c.trim() !== ''); + if (columns.length > 0) { + const val = Number(columns[0].trim()); + if (Number.isInteger(val)) { + codes.push(val); + } + } + } else if (inTable) { + // Non-table line after table — table ended + break; + } + } + + return codes; +} + +/** + * Extract `## Exit Code N:` section heading numbers from failure-matrix.md. + * Returns the set of N values found. + */ +function extractFailureMatrixSectionCodes(): number[] { + const content = fs.readFileSync(FAILURE_MATRIX_PATH, 'utf-8'); + const codes: number[] = []; + const regex = /^## Exit Code (\d+):/gm; + let match: RegExpExecArray | null; + + while ((match = regex.exec(content)) !== null) { + const val = Number(match[1]); + if (Number.isInteger(val)) { + codes.push(val); + } + } + + return codes; +} + +// --- Collect data --- + +const cliRefCodes = extractExitCodesFromSection(CLI_REFERENCE_PATH); +const failureMatrixCodes = extractExitCodesFromSection(FAILURE_MATRIX_PATH); +const readmeCodes = extractExitCodesFromSection(README_PATH); +const failureSectionCodes = extractFailureMatrixSectionCodes(); + +const cliRefSet = new Set(cliRefCodes); +const failureMatrixSet = new Set(failureMatrixCodes); +const readmeSet = new Set(readmeCodes); +const failureSectionSet = new Set(failureSectionCodes); + +// --- Tests --- + +/** + * Property 8: Exit code synchronization with code + * + * For any document containing an exit codes table (cli-reference.md, failure-matrix.md, + * README.md), the set of documented codes SHALL be equal to the set of values from + * EXIT_CODES in src/core/errors.ts: SUCCESS (0) through NO_CONVENTIONAL_COMMITS (11). + * Additionally, failure-matrix.md SHALL have a separate `## Exit Code N:` section + * for each code 1–11. + * + * **Validates: Requirements 4.5, 5.1, 5.2, 11.3, 14.2** + */ +describe('Feature: documentation-pack, Property 8: Exit code synchronization with code', () => { + // --- Deterministic: cli-reference.md --- + + describe('deterministic: cli-reference.md exit codes match EXIT_CODES', () => { + test('cli-reference.md contains all EXIT_CODES values', () => { + const missing = EXPECTED_CODES.filter((c) => !cliRefSet.has(c)); + expect(missing).toEqual([]); + }); + + test('cli-reference.md contains no extra exit codes', () => { + const extra = cliRefCodes.filter((c) => !EXPECTED_SET.has(c)); + expect(extra).toEqual([]); + }); + + test('cli-reference.md exit code set equals EXIT_CODES set', () => { + expect(cliRefSet).toEqual(EXPECTED_SET); + }); + }); + + // --- Deterministic: failure-matrix.md --- + + describe('deterministic: failure-matrix.md exit codes match EXIT_CODES', () => { + test('failure-matrix.md overview table contains all EXIT_CODES values', () => { + const missing = EXPECTED_CODES.filter((c) => !failureMatrixSet.has(c)); + expect(missing).toEqual([]); + }); + + test('failure-matrix.md overview table contains no extra exit codes', () => { + const extra = failureMatrixCodes.filter((c) => !EXPECTED_SET.has(c)); + expect(extra).toEqual([]); + }); + + test('failure-matrix.md overview table set equals EXIT_CODES set', () => { + expect(failureMatrixSet).toEqual(EXPECTED_SET); + }); + }); + + // --- Deterministic: failure-matrix.md section headings --- + + describe('deterministic: failure-matrix.md has ## Exit Code N: sections for codes 1–11', () => { + test('failure-matrix.md has a section for every code 1–11', () => { + const missing = FAILURE_SECTION_CODES.filter((c) => !failureSectionSet.has(c)); + expect(missing).toEqual([]); + }); + + test('failure-matrix.md has no extra Exit Code sections beyond expected', () => { + const extra = failureSectionCodes.filter((c) => !EXPECTED_SET.has(c)); + expect(extra).toEqual([]); + }); + }); + + // --- Deterministic: README.md --- + + describe('deterministic: README.md exit codes match EXIT_CODES', () => { + test('README.md contains all EXIT_CODES values', () => { + const missing = EXPECTED_CODES.filter((c) => !readmeSet.has(c)); + expect(missing).toEqual([]); + }); + + test('README.md contains no extra exit codes', () => { + const extra = readmeCodes.filter((c) => !EXPECTED_SET.has(c)); + expect(extra).toEqual([]); + }); + + test('README.md exit code set equals EXIT_CODES set', () => { + expect(readmeSet).toEqual(EXPECTED_SET); + }); + }); + + // --- Property-based: random sampling --- + + if (EXPECTED_CODES.length > 0) { + const arbExitCode = fc.constantFrom(...EXPECTED_CODES); + + test('property: every randomly sampled EXIT_CODE appears in cli-reference.md', () => { + fc.assert( + fc.property(arbExitCode, (code) => { + expect(cliRefSet.has(code)).toBe(true); + }), + { numRuns: 100 }, + ); + }); + + test('property: every randomly sampled EXIT_CODE appears in failure-matrix.md overview', () => { + fc.assert( + fc.property(arbExitCode, (code) => { + expect(failureMatrixSet.has(code)).toBe(true); + }), + { numRuns: 100 }, + ); + }); + + test('property: every randomly sampled EXIT_CODE appears in README.md', () => { + fc.assert( + fc.property(arbExitCode, (code) => { + expect(readmeSet.has(code)).toBe(true); + }), + { numRuns: 100 }, + ); + }); + + const arbSectionCode = fc.constantFrom(...FAILURE_SECTION_CODES); + + test('property: every randomly sampled code 1–11 has a ## Exit Code section in failure-matrix.md', () => { + fc.assert( + fc.property(arbSectionCode, (code) => { + expect(failureSectionSet.has(code)).toBe(true); + }), + { numRuns: 100 }, + ); + }); + } +}); diff --git a/__tests__/properties/docs/sync-platforms.property.test.ts b/__tests__/properties/docs/sync-platforms.property.test.ts new file mode 100644 index 0000000..b22d8d9 --- /dev/null +++ b/__tests__/properties/docs/sync-platforms.property.test.ts @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +// Feature: documentation-pack, Property 10: SCM platform synchronization with code + +import * as fc from 'fast-check'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createSCMRegistry } from '../../../src/scm/scm.registry'; + +// --- Constants --- + +const DOCS_DIR = path.join(__dirname, '..', '..', '..', 'docs'); +const SCM_GUIDE_PATH = path.join(DOCS_DIR, 'scm-provider-guide.md'); +const CONFIG_REF_PATH = path.join(DOCS_DIR, 'configuration-reference.md'); + +// --- Source of truth --- + +const registry = createSCMRegistry(); +const registryPlatforms = registry.availablePlatforms(); +const registrySet = new Set(registryPlatforms); + +// --- Helpers --- + +/** + * Extract platform values from scm-provider-guide.md. + * Looks for `**Platform value:** \`\`` patterns. + */ +function extractScmGuidePlatforms(): string[] { + const content = fs.readFileSync(SCM_GUIDE_PATH, 'utf-8'); + const platforms: string[] = []; + const regex = /\*\*Platform value:\*\*\s*`([^`]+)`/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(content)) !== null) { + platforms.push(match[1].trim()); + } + + return platforms; +} + +/** + * Extract platform enum values from configuration-reference.md. + * Looks for backtick-wrapped values in the table under `### git.platform`. + */ +function extractConfigRefPlatforms(): string[] { + const content = fs.readFileSync(CONFIG_REF_PATH, 'utf-8'); + const lines = content.split('\n'); + + let inSection = false; + let inTable = false; + let separatorSeen = false; + const platforms: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + + // Enter the git.platform section + if (/^### git\.platform\s*$/.test(trimmed)) { + inSection = true; + inTable = false; + separatorSeen = false; + continue; + } + + // Leave on next ### or ## heading + if (inSection && /^#{2,3} /.test(trimmed) && !/^### git\.platform/.test(trimmed)) { + break; + } + + if (!inSection) continue; + + if (trimmed.startsWith('|')) { + if (!inTable) { + // First | line is the header row — skip it + inTable = true; + continue; + } + + // Separator row (|---|---|...) + if (/^\|[\s\-:|]+\|$/.test(trimmed)) { + separatorSeen = true; + continue; + } + + if (!separatorSeen) continue; + + // Data row — extract first column backtick-wrapped value + const columns = trimmed.split('|').filter((c) => c.trim() !== ''); + if (columns.length > 0) { + const firstCol = columns[0].trim(); + const colMatch = firstCol.match(/^`([^`]+)`$/); + if (colMatch) { + platforms.push(colMatch[1].trim()); + } + } + } else if (inTable) { + // Non-table line after table — table ended + break; + } + } + + return platforms; +} + +// --- Collect data --- + +const scmGuidePlatforms = extractScmGuidePlatforms(); +const configRefPlatforms = extractConfigRefPlatforms(); +const scmGuideSet = new Set(scmGuidePlatforms); +const configRefSet = new Set(configRefPlatforms); + +// --- Tests --- + +/** + * Property 10: SCM platform synchronization with code + * + * For any document containing a list of SCM platforms (scm-provider-guide.md, + * configuration-reference.md), the set of documented platforms SHALL be equal to the set + * of DEFAULT_PLATFORMS in src/scm/scm.registry.ts: + * github, github-enterprise, bitbucket, bitbucket-server, gitlab, azure-devops. + * + * **Validates: Requirements 8.1, 8.2, 14.5** + */ +describe('Feature: documentation-pack, Property 10: SCM platform synchronization with code', () => { + // --- Deterministic: scm-provider-guide.md --- + + describe('deterministic: scm-provider-guide.md platforms match registry', () => { + test('scm guide contains all registry platforms', () => { + const missing = registryPlatforms.filter((p) => !scmGuideSet.has(p)); + expect(missing).toEqual([]); + }); + + test('scm guide contains no extra platforms', () => { + const extra = scmGuidePlatforms.filter((p) => !registrySet.has(p)); + expect(extra).toEqual([]); + }); + + test('scm guide platform set equals registry set', () => { + expect(scmGuideSet).toEqual(registrySet); + }); + }); + + // --- Deterministic: configuration-reference.md --- + + describe('deterministic: configuration-reference.md platforms match registry', () => { + test('config reference contains all registry platforms', () => { + const missing = registryPlatforms.filter((p) => !configRefSet.has(p)); + expect(missing).toEqual([]); + }); + + test('config reference contains no extra platforms', () => { + const extra = configRefPlatforms.filter((p) => !registrySet.has(p)); + expect(extra).toEqual([]); + }); + + test('config reference platform set equals registry set', () => { + expect(configRefSet).toEqual(registrySet); + }); + }); + + // --- Property-based: random sampling --- + + if (registryPlatforms.length > 0) { + const arbPlatform = fc.constantFrom(...registryPlatforms); + + test('property: every randomly sampled registry platform appears in scm-provider-guide.md', () => { + fc.assert( + fc.property(arbPlatform, (platform) => { + expect(scmGuideSet.has(platform)).toBe(true); + }), + { numRuns: 100 }, + ); + }); + + test('property: every randomly sampled registry platform appears in configuration-reference.md', () => { + fc.assert( + fc.property(arbPlatform, (platform) => { + expect(configRefSet.has(platform)).toBe(true); + }), + { numRuns: 100 }, + ); + }); + } +}); diff --git a/__tests__/properties/docs/sync-semvers.property.test.ts b/__tests__/properties/docs/sync-semvers.property.test.ts new file mode 100644 index 0000000..616cf72 --- /dev/null +++ b/__tests__/properties/docs/sync-semvers.property.test.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +// Feature: documentation-pack, Property 11: --semver value synchronization with code + +import * as fc from 'fast-check'; +import * as fs from 'fs'; +import * as path from 'path'; +import { AVAILABLE_SEMVERS } from '../../../src/versioning/version.utils'; + +// --- Constants --- + +const DOCS_DIR = path.join(__dirname, '..', '..', '..', 'docs'); +const CLI_REF_PATH = path.join(DOCS_DIR, 'cli-reference.md'); + +// --- Source of truth --- + +const sourceSet = new Set(AVAILABLE_SEMVERS); + +// --- Helpers --- + +/** + * Extract --semver Choices values from cli-reference.md. + * Looks for `--semver` parameter rows in tables where the description + * contains "Choices:" followed by backtick-wrapped values. + */ +function extractSemverChoices(): string[] { + const content = fs.readFileSync(CLI_REF_PATH, 'utf-8'); + const values = new Set(); + + // Match table rows that contain `--semver` and a Choices list + // Pattern: | `--semver` | ... | ... Choices: `val1`, `val2`, ... | + const rowRegex = /\|\s*`--semver`\s*\|[^|]*\|[^|]*\|[^|]*\|[^|]*Choices:\s*([^|]+)\|/g; + let rowMatch: RegExpExecArray | null; + + while ((rowMatch = rowRegex.exec(content)) !== null) { + const choicesPart = rowMatch[1]; + // Extract backtick-wrapped values + const valRegex = /`([^`]+)`/g; + let valMatch: RegExpExecArray | null; + while ((valMatch = valRegex.exec(choicesPart)) !== null) { + values.add(valMatch[1].trim()); + } + } + + return Array.from(values); +} + +// --- Collect data --- + +const docSemvers = extractSemverChoices(); +const docSet = new Set(docSemvers); + +// --- Tests --- + +/** + * Property 11: --semver value synchronization with code + * + * For any document containing a list of allowed --semver values (cli-reference.md), + * the set of documented values SHALL be equal to the set of AVAILABLE_SEMVERS + * from src/versioning/version.utils.ts: + * patch, prepatch, minor, preminor, premajor, prerelease, major, auto. + * + * **Validates: Requirements 14.6** + */ +describe('Feature: documentation-pack, Property 11: --semver value synchronization with code', () => { + // --- Deterministic: cli-reference.md --- + + describe('deterministic: cli-reference.md semver choices match AVAILABLE_SEMVERS', () => { + test('cli-reference contains all AVAILABLE_SEMVERS values', () => { + const missing = AVAILABLE_SEMVERS.filter((s) => !docSet.has(s)); + expect(missing).toEqual([]); + }); + + test('cli-reference contains no extra semver values', () => { + const extra = docSemvers.filter((s) => !sourceSet.has(s)); + expect(extra).toEqual([]); + }); + + test('cli-reference semver set equals AVAILABLE_SEMVERS set', () => { + expect(docSet).toEqual(sourceSet); + }); + }); + + // --- Property-based: random sampling --- + + if (AVAILABLE_SEMVERS.length > 0) { + const arbSemver = fc.constantFrom(...AVAILABLE_SEMVERS); + + test('property: every randomly sampled AVAILABLE_SEMVERS value appears in cli-reference.md', () => { + fc.assert( + fc.property(arbSemver, (semver) => { + expect(docSet.has(semver)).toBe(true); + }), + { numRuns: 100 }, + ); + }); + } +}); diff --git a/__tests__/properties/docs/sync-strategies.property.test.ts b/__tests__/properties/docs/sync-strategies.property.test.ts new file mode 100644 index 0000000..54559e1 --- /dev/null +++ b/__tests__/properties/docs/sync-strategies.property.test.ts @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +// Feature: documentation-pack, Property 9: Branching strategy synchronization with code + +import * as fc from 'fast-check'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createStrategyRegistry } from '../../../src/branching/strategy.registry'; + +// --- Constants --- + +const DOCS_DIR = path.join(__dirname, '..', '..', '..', 'docs'); +const COOKBOOK_PATH = path.join(DOCS_DIR, 'branch-strategy-cookbook.md'); +const CONFIG_REF_PATH = path.join(DOCS_DIR, 'configuration-reference.md'); + +// Heading-name → registry-key mapping for branch-strategy-cookbook.md +const HEADING_TO_KEY: Record = { + 'Default': 'default', + 'Trunk-Based': 'trunk-based', + 'Git-Flow': 'git-flow', + 'Release Branch': 'release-branch', + 'Hotfix': 'hotfix', + 'Maintenance': 'maintenance', +}; + +// --- Source of truth --- + +const registry = createStrategyRegistry(); +const registryStrategies = registry.availableStrategies(); +const registrySet = new Set(registryStrategies); + +// --- Helpers --- + +/** + * Extract strategy names from branch-strategy-cookbook.md. + * Looks for `## Strategy` headings and maps them to registry keys. + */ +function extractCookbookStrategies(): string[] { + const content = fs.readFileSync(COOKBOOK_PATH, 'utf-8'); + const strategies: string[] = []; + const regex = /^## (.+?) Strategy\s*$/gm; + let match: RegExpExecArray | null; + + while ((match = regex.exec(content)) !== null) { + const headingName = match[1].trim(); + const key = HEADING_TO_KEY[headingName]; + if (key) { + strategies.push(key); + } + } + + return strategies; +} + +/** + * Extract strategy enum values from configuration-reference.md. + * Looks for backtick-wrapped values in the table under `### git.branching.strategy`. + */ +function extractConfigRefStrategies(): string[] { + const content = fs.readFileSync(CONFIG_REF_PATH, 'utf-8'); + const lines = content.split('\n'); + + let inSection = false; + let inTable = false; + let separatorSeen = false; + const strategies: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + + // Enter the git.branching.strategy section + if (/^### git\.branching\.strategy\s*$/.test(trimmed)) { + inSection = true; + inTable = false; + separatorSeen = false; + continue; + } + + // Leave on next ### or ## heading + if (inSection && /^#{2,3} /.test(trimmed) && !/^### git\.branching\.strategy/.test(trimmed)) { + break; + } + + if (!inSection) continue; + + if (trimmed.startsWith('|')) { + if (!inTable) { + // First | line is the header row — skip it + inTable = true; + continue; + } + + // Separator row (|---|---|...) + if (/^\|[\s\-:|]+\|$/.test(trimmed)) { + separatorSeen = true; + continue; + } + + if (!separatorSeen) continue; + + // Data row — extract first column backtick-wrapped value + const columns = trimmed.split('|').filter((c) => c.trim() !== ''); + if (columns.length > 0) { + const firstCol = columns[0].trim(); + const match = firstCol.match(/^`([^`]+)`$/); + if (match) { + strategies.push(match[1].trim()); + } + } + } else if (inTable) { + // Non-table line after table — table ended + break; + } + } + + return strategies; +} + +// --- Collect data --- + +const cookbookStrategies = extractCookbookStrategies(); +const configRefStrategies = extractConfigRefStrategies(); +const cookbookSet = new Set(cookbookStrategies); +const configRefSet = new Set(configRefStrategies); + +// --- Tests --- + +/** + * Property 9: Branching strategy synchronization with code + * + * For any document containing a list of branching strategies (branch-strategy-cookbook.md, + * configuration-reference.md), the set of documented strategies SHALL be equal to the set + * of keys in the strategy registry from src/branching/strategy.registry.ts: + * default, trunk-based, git-flow, release-branch, hotfix, maintenance. + * + * **Validates: Requirements 7.1, 14.4** + */ +describe('Feature: documentation-pack, Property 9: Branching strategy synchronization with code', () => { + // --- Deterministic: branch-strategy-cookbook.md --- + + describe('deterministic: branch-strategy-cookbook.md strategies match registry', () => { + test('cookbook contains all registry strategies', () => { + const missing = registryStrategies.filter((s) => !cookbookSet.has(s)); + expect(missing).toEqual([]); + }); + + test('cookbook contains no extra strategies', () => { + const extra = cookbookStrategies.filter((s) => !registrySet.has(s)); + expect(extra).toEqual([]); + }); + + test('cookbook strategy set equals registry set', () => { + expect(cookbookSet).toEqual(registrySet); + }); + }); + + // --- Deterministic: configuration-reference.md --- + + describe('deterministic: configuration-reference.md strategies match registry', () => { + test('config reference contains all registry strategies', () => { + const missing = registryStrategies.filter((s) => !configRefSet.has(s)); + expect(missing).toEqual([]); + }); + + test('config reference contains no extra strategies', () => { + const extra = configRefStrategies.filter((s) => !registrySet.has(s)); + expect(extra).toEqual([]); + }); + + test('config reference strategy set equals registry set', () => { + expect(configRefSet).toEqual(registrySet); + }); + }); + + // --- Property-based: random sampling --- + + if (registryStrategies.length > 0) { + const arbStrategy = fc.constantFrom(...registryStrategies); + + test('property: every randomly sampled registry strategy appears in branch-strategy-cookbook.md', () => { + fc.assert( + fc.property(arbStrategy, (strategy) => { + expect(cookbookSet.has(strategy)).toBe(true); + }), + { numRuns: 100 }, + ); + }); + + test('property: every randomly sampled registry strategy appears in configuration-reference.md', () => { + fc.assert( + fc.property(arbStrategy, (strategy) => { + expect(configRefSet.has(strategy)).toBe(true); + }), + { numRuns: 100 }, + ); + }); + } +}); diff --git a/__tests__/properties/docs/sync-subcommands.property.test.ts b/__tests__/properties/docs/sync-subcommands.property.test.ts new file mode 100644 index 0000000..322a416 --- /dev/null +++ b/__tests__/properties/docs/sync-subcommands.property.test.ts @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +// Feature: documentation-pack, Property 7: Subcommand synchronization with code + +import * as fc from 'fast-check'; +import * as fs from 'fs'; +import * as path from 'path'; +import { SUBCOMMANDS } from '../../../src/cli/command.router'; + +// --- Constants --- + +const DOCS_DIR = path.join(__dirname, '..', '..', '..', 'docs'); +const CLI_REFERENCE_PATH = path.join(DOCS_DIR, 'cli-reference.md'); +const README_PATH = path.join(__dirname, '..', '..', '..', 'README.md'); + +// --- Helpers --- + +/** + * Extract subcommand names from cli-reference.md. + * Looks for `### ` headings within the `## Commands` section. + * Stops at the next `## ` heading (not `### `). + */ +function extractCliReferenceSubcommands(): string[] { + const content = fs.readFileSync(CLI_REFERENCE_PATH, 'utf-8'); + const lines = content.split('\n'); + + let inCommandsSection = false; + const subcommands: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed === '## Commands') { + inCommandsSection = true; + continue; + } + + // Stop at the next ## heading (but not ### headings) + if (inCommandsSection && /^## /.test(trimmed) && !/^### /.test(trimmed)) { + break; + } + + if (inCommandsSection && /^### /.test(trimmed)) { + const name = trimmed.replace(/^###\s+/, '').trim().toLowerCase(); + if (name.length > 0) { + subcommands.push(name); + } + } + } + + return subcommands; +} + +/** + * Extract subcommand names from README.md Commands table. + * Looks for backtick-wrapped names in the first column of the table + * under the `## Commands` section. + */ +function extractReadmeSubcommands(): string[] { + const content = fs.readFileSync(README_PATH, 'utf-8'); + const lines = content.split('\n'); + + let inCommandsSection = false; + let inTable = false; + let headerSkipped = false; + const subcommands: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed === '## Commands') { + inCommandsSection = true; + inTable = false; + headerSkipped = false; + continue; + } + + // Stop at the next ## heading + if (inCommandsSection && /^## /.test(trimmed)) { + break; + } + + if (!inCommandsSection) continue; + + // Detect table rows (lines starting with |) + if (trimmed.startsWith('|')) { + if (!inTable) { + inTable = true; + // Skip header row + continue; + } + + // Skip separator row (|---|---|...) + if (/^\|[\s-|]+\|$/.test(trimmed)) { + headerSkipped = true; + continue; + } + + if (!headerSkipped) { + headerSkipped = true; + continue; + } + + // Extract first column value (backtick-wrapped command name) + const columns = trimmed.split('|').filter((c) => c.trim() !== ''); + if (columns.length > 0) { + const firstCol = columns[0].trim(); + const match = firstCol.match(/^`([^`]+)`$/); + if (match) { + subcommands.push(match[1].trim().toLowerCase()); + } + } + } else if (inTable) { + // Non-table line after table started — table ended + inTable = false; + } + } + + return subcommands; +} + +// --- Collect data --- + +const cliRefSubcommands = extractCliReferenceSubcommands(); +const readmeSubcommands = extractReadmeSubcommands(); +const expectedSet = new Set(SUBCOMMANDS.map((s) => s.toLowerCase())); +const cliRefSet = new Set(cliRefSubcommands); +const readmeSet = new Set(readmeSubcommands); + +// --- Tests --- + +/** + * Property 7: Subcommand synchronization with code + * + * For any document containing a list of subcommands (cli-reference.md, README.md), + * the set of documented subcommands SHALL be equal to the set SUBCOMMANDS from + * src/cli/command.router.ts: init, validate, plan, release, rollback, doctor, changelog. + * + * **Validates: Requirements 4.2, 11.2, 14.1** + */ +describe('Feature: documentation-pack, Property 7: Subcommand synchronization with code', () => { + // --- Deterministic: cli-reference.md --- + + describe('deterministic: cli-reference.md subcommands match SUBCOMMANDS', () => { + test('cli-reference.md contains all SUBCOMMANDS', () => { + const missing = SUBCOMMANDS.filter((s) => !cliRefSet.has(s.toLowerCase())); + expect(missing).toEqual([]); + }); + + test('cli-reference.md contains no extra subcommands', () => { + const extra = cliRefSubcommands.filter((s) => !expectedSet.has(s)); + expect(extra).toEqual([]); + }); + + test('cli-reference.md subcommand set equals SUBCOMMANDS set', () => { + expect(cliRefSet).toEqual(expectedSet); + }); + }); + + // --- Deterministic: README.md --- + + describe('deterministic: README.md subcommands match SUBCOMMANDS', () => { + test('README.md contains all SUBCOMMANDS', () => { + const missing = SUBCOMMANDS.filter((s) => !readmeSet.has(s.toLowerCase())); + expect(missing).toEqual([]); + }); + + test('README.md contains no extra subcommands', () => { + const extra = readmeSubcommands.filter((s) => !expectedSet.has(s)); + expect(extra).toEqual([]); + }); + + test('README.md subcommand set equals SUBCOMMANDS set', () => { + expect(readmeSet).toEqual(expectedSet); + }); + }); + + // --- Property-based: random sampling --- + + if (SUBCOMMANDS.length > 0) { + const arbSubcommand = fc.constantFrom(...SUBCOMMANDS); + + test('property: every randomly sampled SUBCOMMAND appears in cli-reference.md', () => { + fc.assert( + fc.property(arbSubcommand, (subcommand) => { + expect(cliRefSet.has(subcommand.toLowerCase())).toBe(true); + }), + { numRuns: 100 }, + ); + }); + + test('property: every randomly sampled SUBCOMMAND appears in README.md', () => { + fc.assert( + fc.property(arbSubcommand, (subcommand) => { + expect(readmeSet.has(subcommand.toLowerCase())).toBe(true); + }), + { numRuns: 100 }, + ); + }); + } +}); diff --git a/__tests__/properties/lock.manager.property.test.ts b/__tests__/properties/lock.manager.property.test.ts new file mode 100644 index 0000000..3dbd1f7 --- /dev/null +++ b/__tests__/properties/lock.manager.property.test.ts @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import * as fc from 'fast-check'; +import type { LockData } from '../../src/core/lock.manager'; + +/** Arbitrary for a valid PID (positive integer) */ +const arbPid = fc.integer({ min: 1, max: 2147483647 }); + +/** Arbitrary for a UUID-like operationId */ +const arbOperationId = fc.uuid(); + +/** Arbitrary for a non-empty command string */ +const arbCommand = fc.stringOf( + fc.char().filter((c) => c >= ' ' && c <= '~'), + { minLength: 1, maxLength: 100 }, +); + +/** Arbitrary for a valid ISO 8601 timestamp */ +const arbCreatedAt = fc.date({ + min: new Date('2020-01-01T00:00:00.000Z'), + max: new Date('2030-12-31T23:59:59.999Z'), +}).map((d) => d.toISOString()); + +/** Arbitrary for a hostname string */ +const arbHostname = fc.stringOf( + fc.char().filter((c) => /[a-zA-Z0-9.\-]/.test(c)), + { minLength: 1, maxLength: 64 }, +); + +/** Arbitrary for ci boolean */ +const arbCi = fc.boolean(); + +/** Arbitrary for a valid LockData object */ +const arbLockData: fc.Arbitrary = fc.record({ + pid: arbPid, + operationId: arbOperationId, + command: arbCommand, + createdAt: arbCreatedAt, + hostname: arbHostname, + ci: arbCi, +}); + +/** + * Property 7: Round-trip Lock Data + * + * For any valid LockData object, serialization to JSON (JSON.stringify) + * and subsequent deserialization (JSON.parse) SHALL produce an object + * deeply equal to the original. + * + * **Validates: Requirements 15.1, 15.2, 15.3** + */ +describe('Feature: operational-hardening, Property 7: Round-trip Lock Data', () => { + test('JSON.parse(JSON.stringify(lockData)) is deeply equal to the original', () => { + fc.assert( + fc.property(arbLockData, (lockData) => { + const serialized = JSON.stringify(lockData); + const deserialized = JSON.parse(serialized) as LockData; + + expect(deserialized).toEqual(lockData); + }), + { numRuns: 100 }, + ); + }); +}); + +import { VersioningsError, EXIT_CODES } from '../../src/core/errors'; + +/** + * Pre-transform lock.manager.ts with esbuild to bypass the esbuild-jest + * Babel fallback (source file contains function names that trigger it). + */ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const esbuildLib = require('esbuild'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const nodeFs = require('fs'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const nodePath = require('path'); + +const lockManagerSrcPath = nodePath.resolve(__dirname, '../../src/core/lock.manager.ts'); +const lockManagerSrcDir = nodePath.dirname(lockManagerSrcPath); +const lockManagerRaw = nodeFs.readFileSync(lockManagerSrcPath, 'utf-8'); +const lockManagerTransformed = esbuildLib.transformSync(lockManagerRaw, { + loader: 'ts', + format: 'cjs', + target: 'es2018', +}); + +// Create a custom require that resolves relative paths from the source directory +const lockManagerRequire = (id: string): unknown => { + if (id.startsWith('.')) { + return require(nodePath.resolve(lockManagerSrcDir, id)); + } + return require(id); +}; + +const lockManagerExports: Record = {}; +const lockManagerMod = { exports: lockManagerExports }; +const lockManagerRunner = new Function( + 'exports', 'require', 'module', '__filename', '__dirname', + lockManagerTransformed.code, +); +lockManagerRunner( + lockManagerExports, + lockManagerRequire, + lockManagerMod, + lockManagerSrcPath, + lockManagerSrcDir, +); + +const createLockManager = lockManagerMod.exports.createLockManager as + typeof import('../../src/core/lock.manager').createLockManager; + +/** Arbitrary for lockTimeoutMs (positive integer, reasonable range) */ +const arbLockTimeoutMs = fc.integer({ min: 1, max: 600_000 }); + +/** Arbitrary for a positive time delta in ms */ +const arbPositiveDelta = fc.integer({ min: 50, max: 300_000 }); + +/** + * Helper: create mock fs that simulates an existing lock file with given LockData. + */ +function createMockFs(lockData: LockData | null): { + writeFileSync: jest.Mock; + readFileSync: jest.Mock; + renameSync: jest.Mock; + unlinkSync: jest.Mock; + existsSync: jest.Mock; + mkdirSync: jest.Mock; +} { + return { + writeFileSync: jest.fn(), + readFileSync: jest.fn(() => { + if (lockData === null) throw new Error('ENOENT'); + return JSON.stringify(lockData); + }), + renameSync: jest.fn(), + unlinkSync: jest.fn(), + existsSync: jest.fn(() => lockData !== null), + mkdirSync: jest.fn(), + }; +} + +/** + * Helper: create mock processInfo. + * @param alive - whether the PID should appear alive + */ +function createMockProcessInfo(alive: boolean): { + pid: number; + kill: jest.Mock; + on: jest.Mock; +} { + return { + pid: 99999, + kill: jest.fn((_pid: number, _signal: number) => { + if (!alive) { + const err = new Error('ESRCH') as NodeJS.ErrnoException; + err.code = 'ESRCH'; + throw err; + } + return true; + }), + on: jest.fn(), + }; +} + +/** + * Property 8: Stale Detection по timeout (CI-aware) + * + * For any lock file with createdAt and for any lockTimeoutMs value, + * lock SHALL be considered stale if and only if + * `Date.now() - Date.parse(createdAt) > lockTimeoutMs`. + * + * In CI environment (ci === true), stale detection SHALL use only timeout, + * ignoring PID (process.kill should never be called). + * + * **Validates: Requirements 7.3, 9.1, 9.2** + */ +describe('Feature: operational-hardening, Property 8: Stale Detection по timeout (CI-aware)', () => { + test('lock is stale when timeout exceeded → acquire succeeds', () => { + fc.assert( + fc.property( + arbPid, + arbOperationId, + arbCommand, + arbHostname, + arbCi, + arbLockTimeoutMs, + arbPositiveDelta, + (pid, operationId, command, hostname, ci, lockTimeoutMs, delta) => { + // createdAt is far enough in the past that timeout is exceeded + const now = Date.now(); + const createdAtMs = now - lockTimeoutMs - delta; // guarantees now - createdAt > lockTimeoutMs + const createdAt = new Date(createdAtMs).toISOString(); + + const lockData: LockData = { pid, operationId, command, createdAt, hostname, ci }; + const mockFs = createMockFs(lockData); + // PID appears alive — but timeout should still win + const mockProc = createMockProcessInfo(true); + + const lm = createLockManager({ + lockDir: '/tmp/test-lock', + lockTimeoutMs, + ci, + processInfo: mockProc, + fs: mockFs, + hostname: 'test-host', + }); + + // Should NOT throw — stale lock is removed and new lock acquired + expect(() => lm.acquire('new-op-id', 'release')).not.toThrow(); + + // Lock file should have been removed (stale cleanup) + expect(mockFs.unlinkSync).toHaveBeenCalled(); + }, + ), + { numRuns: 100 }, + ); + }); + + test('lock is NOT stale when timeout not exceeded → acquire throws', () => { + fc.assert( + fc.property( + arbPid, + arbOperationId, + arbCommand, + arbHostname, + arbLockTimeoutMs, + arbPositiveDelta, + (pid, operationId, command, hostname, lockTimeoutMs, delta) => { + // createdAt is recent enough that timeout is NOT exceeded + const now = Date.now(); + const createdAtMs = now - Math.max(0, lockTimeoutMs - delta); // guarantees now - createdAt < lockTimeoutMs + const createdAt = new Date(createdAtMs).toISOString(); + + const lockData: LockData = { pid, operationId, command, createdAt, hostname, ci: false }; + const mockFs = createMockFs(lockData); + // PID appears alive — lock is active + const mockProc = createMockProcessInfo(true); + + const lm = createLockManager({ + lockDir: '/tmp/test-lock', + lockTimeoutMs, + ci: false, + processInfo: mockProc, + fs: mockFs, + hostname: 'test-host', + }); + + // Should throw — lock is active (PID alive + not timed out) + expect(() => lm.acquire('new-op-id', 'release')).toThrow(VersioningsError); + }, + ), + { numRuns: 100 }, + ); + }); + + test('boundary: age exactly equals timeout → lock is NOT stale (strict >)', () => { + fc.assert( + fc.property( + arbPid, + arbOperationId, + arbCommand, + arbHostname, + arbLockTimeoutMs, + (pid, operationId, command, hostname, lockTimeoutMs) => { + // Set createdAt so that now - createdAt === lockTimeoutMs (approximately) + // Since Date.now() may advance between calls, we use a tight window + const now = Date.now(); + const createdAtMs = now - lockTimeoutMs; + const createdAt = new Date(createdAtMs).toISOString(); + + const lockData: LockData = { pid, operationId, command, createdAt, hostname, ci: false }; + const mockFs = createMockFs(lockData); + const mockProc = createMockProcessInfo(true); + + const lm = createLockManager({ + lockDir: '/tmp/test-lock', + lockTimeoutMs, + ci: false, + processInfo: mockProc, + fs: mockFs, + hostname: 'test-host', + }); + + // At boundary (age === timeout), strict > means NOT stale → should throw + // Note: Due to ms precision and Date.parse round-trip, the actual elapsed + // time may be slightly more. We accept that this is a best-effort boundary test. + // The key invariant is: if age <= timeout, lock is not stale. + try { + lm.acquire('new-op-id', 'release'); + // If acquire succeeds, it means the tiny time elapsed pushed us past boundary. + // This is acceptable — the property still holds (age > timeout at check time). + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.COMMAND_FAILED); + } + }, + ), + { numRuns: 100 }, + ); + }); + + test('CI mode: PID is never checked (kill not called), only timeout used', () => { + fc.assert( + fc.property( + arbPid, + arbOperationId, + arbCommand, + arbHostname, + arbLockTimeoutMs, + arbPositiveDelta, + (pid, operationId, command, hostname, lockTimeoutMs, delta) => { + // Stale case in CI — timeout exceeded + const now = Date.now(); + const createdAtMs = now - lockTimeoutMs - delta; + const createdAt = new Date(createdAtMs).toISOString(); + + const lockData: LockData = { pid, operationId, command, createdAt, hostname, ci: true }; + const mockFs = createMockFs(lockData); + const mockProc = createMockProcessInfo(true); + + const lm = createLockManager({ + lockDir: '/tmp/test-lock', + lockTimeoutMs, + ci: true, // CI mode + processInfo: mockProc, + fs: mockFs, + hostname: 'test-host', + }); + + lm.acquire('new-op-id', 'release'); + + // In CI mode, process.kill should NEVER be called — PID is ignored + expect(mockProc.kill).not.toHaveBeenCalled(); + }, + ), + { numRuns: 100 }, + ); + }); + + test('CI mode: non-stale lock throws without checking PID', () => { + fc.assert( + fc.property( + arbPid, + arbOperationId, + arbCommand, + arbHostname, + arbLockTimeoutMs, + arbPositiveDelta, + (pid, operationId, command, hostname, lockTimeoutMs, delta) => { + // Non-stale case in CI — timeout NOT exceeded + const now = Date.now(); + const createdAtMs = now - Math.max(0, lockTimeoutMs - delta); + const createdAt = new Date(createdAtMs).toISOString(); + + const lockData: LockData = { pid, operationId, command, createdAt, hostname, ci: true }; + const mockFs = createMockFs(lockData); + const mockProc = createMockProcessInfo(true); + + const lm = createLockManager({ + lockDir: '/tmp/test-lock', + lockTimeoutMs, + ci: true, // CI mode + processInfo: mockProc, + fs: mockFs, + hostname: 'test-host', + }); + + // Should throw — lock is active (not timed out) + expect(() => lm.acquire('new-op-id', 'release')).toThrow(VersioningsError); + + // In CI mode, process.kill should NEVER be called + expect(mockProc.kill).not.toHaveBeenCalled(); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/reporter.observability.property.test.ts b/__tests__/properties/reporter.observability.property.test.ts new file mode 100644 index 0000000..eb57ede --- /dev/null +++ b/__tests__/properties/reporter.observability.property.test.ts @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import * as fc from 'fast-check'; +import { createReporter } from '../../src/core/reporter'; +import type { PipelineResult } from '../../src/core/reporter'; + +/** + * Property 3: Reporter JSON включает operationId и totalDurationMs + * + * Для любого PipelineResult с полями operationId (строка UUID v4) и totalDurationMs (число >= 0), + * вызов reportSuccess() в JSON-режиме SHALL формировать JSON-строку, содержащую оба поля + * с исходными значениями. + * + * **Validates: Requirements 2.4, 4.5, 14.1, 14.2** + */ + +const arbPipelineResultWithObservability: fc.Arbitrary = fc.record({ + success: fc.constant(true as const), + version: fc.stringOf( + fc.constantFrom('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '-'), + { minLength: 3, maxLength: 20 } + ).filter((s) => /^\d/.test(s)), + previousVersion: fc.stringOf( + fc.constantFrom('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '-'), + { minLength: 3, maxLength: 20 } + ).filter((s) => /^\d/.test(s)), + semver: fc.constantFrom('patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease'), + branch: fc.string({ minLength: 1, maxLength: 100 }).filter((s) => s.trim().length > 0), + tag: fc.string({ minLength: 1, maxLength: 100 }).filter((s) => s.trim().length > 0), + pullRequestUrl: fc.oneof( + fc.constant(null), + fc.string({ minLength: 5, maxLength: 200 }).filter((s) => s.trim().length > 0) + ), + exitCode: fc.constant(0), + operationId: fc.uuid(), + totalDurationMs: fc.nat(), +}); + +describe('Feature: operational-hardening, Property 3: Reporter JSON включает operationId и totalDurationMs', () => { + const reporter = createReporter({ json: true }); + + test('reportSuccess() in JSON mode includes operationId and totalDurationMs with original values', () => { + fc.assert( + fc.property(arbPipelineResultWithObservability, (result) => { + const output = reporter.reportSuccess(result); + const parsed = JSON.parse(output); + + expect(parsed).toHaveProperty('operationId', result.operationId); + expect(parsed).toHaveProperty('totalDurationMs', result.totalDurationMs); + }), + { numRuns: 100 } + ); + }); +}); diff --git a/__tests__/properties/scm/auth.resolver.property.test.ts b/__tests__/properties/scm/auth.resolver.property.test.ts new file mode 100644 index 0000000..9d72a88 --- /dev/null +++ b/__tests__/properties/scm/auth.resolver.property.test.ts @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: scm-provider-pr-automation, Property 4: Token resolution priority +// Feature: scm-provider-pr-automation, Property 5: Token masking + +import * as fc from 'fast-check'; +import { resolveAuth, maskToken, type AuthResolverDeps } from '../../../src/scm/auth.resolver'; + +// --- Generators --- + +/** Non-empty token string 1–100 chars */ +const arbToken = fc.stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'.split('')), + { minLength: 1, maxLength: 100 }, +); + +/** Valid platform identifier */ +const arbPlatform = fc.constantFrom( + 'github', + 'github-enterprise', + 'gitlab', + 'bitbucket', + 'bitbucket-server', + 'azure-devops', +); + +/** Platform → env var name mapping */ +const PLATFORM_ENV_MAP: Record = { + 'github': 'GITHUB_TOKEN', + 'github-enterprise': 'GITHUB_TOKEN', + 'gitlab': 'GITLAB_TOKEN', + 'bitbucket': 'BITBUCKET_TOKEN', + 'bitbucket-server': 'BITBUCKET_TOKEN', + 'azure-devops': 'AZURE_DEVOPS_TOKEN', +}; + +/** Random combination of auth sources: { configToken?, envToken?, platformToken? } */ +const arbAuthSources = fc.record({ + configToken: fc.option(arbToken, { nil: undefined }), + envToken: fc.option(arbToken, { nil: undefined }), + platformToken: fc.option(arbToken, { nil: undefined }), +}); + +// --- Property 4: Token resolution priority --- +// **Validates: Requirements 3.1, 3.2** + +describe('Property 4: Token resolution priority', () => { + test('resolveAuth returns the highest-priority token for any combination of sources', () => { + fc.assert( + fc.property(arbAuthSources, arbPlatform, (sources, platform) => { + const { configToken, envToken, platformToken } = sources; + const platformEnvVar = PLATFORM_ENV_MAP[platform]; + + const config: Record = {}; + if (configToken !== undefined) { + config.git = { auth: { token: configToken } }; + } + + const env: Record = {}; + if (envToken !== undefined) { + env.VERSIONINGS_TOKEN = envToken; + } + if (platformToken !== undefined && platformEnvVar) { + env[platformEnvVar] = platformToken; + } + + const deps: AuthResolverDeps = { config, env }; + const result = resolveAuth(deps, platform); + + // Determine expected token by priority: config > VERSIONINGS_TOKEN > platform-specific + const expectedToken = configToken ?? envToken ?? platformToken ?? null; + + expect(result.token).toBe(expectedToken); + + // If all sources are absent, token is null and method defaults to 'token' + if (expectedToken === null) { + expect(result.method).toBe('token'); + } + }), + { numRuns: 100 }, + ); + }); + + test('resolveAuth never throws regardless of source combination', () => { + fc.assert( + fc.property(arbAuthSources, arbPlatform, (sources, platform) => { + const { configToken, envToken, platformToken } = sources; + const platformEnvVar = PLATFORM_ENV_MAP[platform]; + + const config: Record = {}; + if (configToken !== undefined) { + config.git = { auth: { token: configToken } }; + } + + const env: Record = {}; + if (envToken !== undefined) { + env.VERSIONINGS_TOKEN = envToken; + } + if (platformToken !== undefined && platformEnvVar) { + env[platformEnvVar] = platformToken; + } + + const deps: AuthResolverDeps = { config, env }; + + // Should never throw + expect(() => resolveAuth(deps, platform)).not.toThrow(); + }), + { numRuns: 100 }, + ); + }); +}); + +// --- Property 5: Token masking --- +// **Validates: Requirements 3.4** + +describe('Property 5: Token masking', () => { + test('for tokens >= 5 chars: first 4 visible + stars, total length preserved', () => { + const arbLongToken = fc.stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'.split('')), + { minLength: 5, maxLength: 100 }, + ); + + fc.assert( + fc.property(arbLongToken, (token) => { + const masked = maskToken(token); + + // Total length preserved + expect(masked.length).toBe(token.length); + + // First 4 chars visible + expect(masked.slice(0, 4)).toBe(token.slice(0, 4)); + + // Rest are stars + const stars = masked.slice(4); + expect(stars).toBe('*'.repeat(token.length - 4)); + }), + { numRuns: 100 }, + ); + }); + + test('for tokens < 5 chars: fully masked with stars, length preserved', () => { + const arbShortToken = fc.stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'.split('')), + { minLength: 1, maxLength: 4 }, + ); + + fc.assert( + fc.property(arbShortToken, (token) => { + const masked = maskToken(token); + + // Length preserved + expect(masked.length).toBe(token.length); + + // Fully masked + expect(masked).toBe('*'.repeat(token.length)); + }), + { numRuns: 100 }, + ); + }); + + test('maskToken(token) !== token for any non-empty token', () => { + fc.assert( + fc.property(arbToken, (token) => { + const masked = maskToken(token); + expect(masked).not.toBe(token); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/scm/http.client.property.test.ts b/__tests__/properties/scm/http.client.property.test.ts new file mode 100644 index 0000000..59709c7 --- /dev/null +++ b/__tests__/properties/scm/http.client.property.test.ts @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: scm-provider-pr-automation, Property 9: HTTP error mapping to VersioningsError +// Feature: scm-provider-pr-automation, Property 10: User-Agent header in HTTP requests + +import * as fc from 'fast-check'; +import { createHttpClient, type FetchFn } from '../../../src/scm/http.client'; +import { VersioningsError, EXIT_CODES } from '../../../src/core/errors'; + +/** + * Helper: builds a minimal Response-like object accepted by the http client. + */ +function mockResponse( + status: number, + body: any, + contentType = 'application/json', +): Response { + const headers = new Headers({ 'content-type': contentType }); + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + headers, + } as unknown as Response; +} + +// --- Generators --- + +/** HTTP status codes that map to CONFIG_ERROR (auth failures) */ +const arbAuthStatus = fc.constantFrom(401, 403); + +/** Network error types that map to NETWORK_ERROR */ +const arbNetworkError = fc.oneof( + fc.constant(new TypeError('fetch failed')), + fc.constant(new TypeError('Failed to fetch')), + fc.constant(new DOMException('The operation was aborted.', 'AbortError')), + fc.constant(new TypeError('getaddrinfo ENOTFOUND api.example.com')), + fc.constant(new TypeError('connect ECONNREFUSED 127.0.0.1:443')), +); + +/** Random valid URL for requests */ +const arbUrl = fc + .tuple( + fc.constantFrom('https://api.github.com', 'https://gitlab.com', 'https://api.bitbucket.org', 'https://dev.azure.com'), + fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789-/'.split('')), { + minLength: 1, + maxLength: 40, + }), + ) + .map(([base, path]) => `${base}/${path}`); + +/** HTTP method: GET, POST, or PATCH */ +const arbMethod = fc.constantFrom('GET' as const, 'POST' as const, 'PATCH' as const); + +// --- Property 9: HTTP error mapping to VersioningsError --- +// **Validates: Requirements 6.3, 6.4, 6.5** + +describe('Property 9: HTTP error mapping to VersioningsError', () => { + test('HTTP 401/403 → VersioningsError(CONFIG_ERROR) with token hint', async () => { + await fc.assert( + fc.asyncProperty(arbAuthStatus, arbUrl, async (status, url) => { + const mockFetch = jest.fn().mockResolvedValueOnce(mockResponse(status, { message: 'Unauthorized' })); + const client = createHttpClient(mockFetch as unknown as FetchFn); + + try { + await client.get(url); + throw new Error('Expected VersioningsError'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + expect((err as VersioningsError).message).toContain('token'); + } + }), + { numRuns: 100 }, + ); + }); + + test('HTTP 404 → VersioningsError(CONFIG_ERROR) with apiUrl hint', async () => { + await fc.assert( + fc.asyncProperty(arbUrl, async (url) => { + const mockFetch = jest.fn().mockResolvedValueOnce(mockResponse(404, { message: 'Not Found' })); + const client = createHttpClient(mockFetch as unknown as FetchFn); + + try { + await client.get(url); + throw new Error('Expected VersioningsError'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + expect((err as VersioningsError).message).toContain('apiUrl'); + } + }), + { numRuns: 100 }, + ); + }); + + test('Network errors → VersioningsError(NETWORK_ERROR)', async () => { + await fc.assert( + fc.asyncProperty(arbNetworkError, arbUrl, async (networkErr, url) => { + const mockFetch = jest.fn().mockRejectedValueOnce(networkErr); + const client = createHttpClient(mockFetch as unknown as FetchFn); + + try { + await client.get(url); + throw new Error('Expected VersioningsError'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.NETWORK_ERROR); + expect((err as VersioningsError).message).toContain('Network error'); + } + }), + { numRuns: 100 }, + ); + }); +}); + + +// --- Property 10: User-Agent header in HTTP requests --- +// **Validates: Requirements 6.6** + +describe('Property 10: User-Agent header in HTTP requests', () => { + test('for any request (GET/POST/PATCH), User-Agent matches versionings/', async () => { + await fc.assert( + fc.asyncProperty(arbMethod, arbUrl, async (method, url) => { + const mockFetch = jest.fn().mockResolvedValueOnce(mockResponse(200, { ok: true })); + const client = createHttpClient(mockFetch as unknown as FetchFn); + + switch (method) { + case 'GET': + await client.get(url); + break; + case 'POST': + await client.post(url, { data: 'test' }); + break; + case 'PATCH': + await client.patch(url, { data: 'test' }); + break; + } + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [, init] = mockFetch.mock.calls[0]; + const headers = init?.headers as Record; + expect(headers['User-Agent']).toMatch(/^versionings\/\d+\.\d+\.\d+$/); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/scm/pr.creator.property.test.ts b/__tests__/properties/scm/pr.creator.property.test.ts new file mode 100644 index 0000000..cf90fe7 --- /dev/null +++ b/__tests__/properties/scm/pr.creator.property.test.ts @@ -0,0 +1,423 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: scm-provider-pr-automation, Property 3: Backward compatibility github/bitbucket without token +// Feature: scm-provider-pr-automation, Property 6: Fallback strategy +// Feature: scm-provider-pr-automation, Property 7: PR/MR error does not interrupt pipeline +// Feature: scm-provider-pr-automation, Property 8: Unsupported PR parameters → warnings + +import * as fc from 'fast-check'; +import { createPR, type PrCreatorDeps } from '../../../src/scm/pr.creator'; +import type { PR_Result, SCM_Provider } from '../../../src/scm/scm.provider'; +import type { SCM_Registry } from '../../../src/scm/scm.registry'; +import type { HttpClient } from '../../../src/scm/http.client'; +import type { UrlParser } from '../../../src/scm/url.parser'; +import type { VersioningsConfig } from '../../../src/config/config.validator'; +import { VersioningsError, EXIT_CODES } from '../../../src/core/errors'; + +// --------------------------------------------------------------------------- +// Generators +// --------------------------------------------------------------------------- + +const ALPHA_CHARS = 'abcdefghijklmnopqrstuvwxyz'; + +const arbIdent = fc.stringOf(fc.constantFrom(...ALPHA_CHARS.split('')), { + minLength: 1, + maxLength: 20, +}); + +const arbBranch = arbIdent.map((s) => `version/patch/${s}`); +const arbCommitMsg = arbIdent.map((s) => `Patch: v1.0.0-${s}`); +const arbTarget = fc.constantFrom('main', 'master', 'develop'); + +const arbToken = fc.stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')), + { minLength: 8, maxLength: 40 }, +).map((s) => `ghp_${s}`); + +// --------------------------------------------------------------------------- +// Config builder +// --------------------------------------------------------------------------- + +function makeConfig(overrides: Record = {}): VersioningsConfig { + const base: any = { + git: { + platform: 'github', + url: 'https://github.com/owner/repo', + pr: { target: 'main' }, + api: { timeout: 30000 }, + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + prepatch: '', patch: '', preminor: '', minor: '', + premajor: '', major: '', prerelease: '', + }, + }, + }, + ...overrides.git, + }, + package: { + semver: { + patch: 'patch', prepatch: 'prepatch', minor: 'minor', + preminor: 'preminor', premajor: 'premajor', prerelease: 'prerelease', major: 'major', + }, + }, + common: { + messages: { + versionConfigDoesNotExist: '', undefinedGitRepositoryUrl: '', + unavailableVersioningDirectory: '', unavailableSemanticVersion: '', + undefinedVersionBranchName: '', incorrectVersionBranchNameLength: '', + incorrectVersionBranchNameCharactersDashes: '', versionBranchAlreadyExists: '', + untrackedGitFiles: '', unavailableGitPlatform: '', + unavailableGitTargetBranch: '', versionAlreadyExists: '', + versionAlreadyExistsTag: '', versionAlreadyExistsBranch: '', + incorrectGitRemote: '', + }, + }, + }; + return base; +} + +// --------------------------------------------------------------------------- +// Mock factories +// --------------------------------------------------------------------------- + +function makeMockProvider(overrides: Partial = {}): SCM_Provider { + return { + name: () => 'github', + createPullRequest: jest.fn().mockResolvedValue({ + url: 'https://github.com/owner/repo/pull/42', + number: 42, + status: 'created' as const, + fallbackReason: null, + platform: 'github', + warnings: [], + }), + generatePullRequestUrl: jest.fn().mockReturnValue( + 'https://github.com/owner/repo/compare/main...branch', + ), + ...overrides, + }; +} + +function makeDeps(overrides: Partial = {}): PrCreatorDeps { + const mockProvider = makeMockProvider(); + const registry: SCM_Registry = { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github', 'bitbucket']), + }; + return { + registry, + httpClient: {} as HttpClient, + urlParser: {} as UrlParser, + resolveAuth: jest.fn().mockReturnValue({ token: 'ghp_test123', method: 'token' as const }), + env: {}, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Property 3: Backward compatibility github/bitbucket without token +// **Validates: Requirements 2.2, 15.1, 15.2** +// --------------------------------------------------------------------------- + +describe('Property 3: Backward compatibility github/bitbucket without token', () => { + const arbBackwardPlatform = fc.constantFrom('github' as const, 'bitbucket' as const); + + test('for any config with github/bitbucket and no auth, createPR in auto mode returns fallback with reason no_token', async () => { + await fc.assert( + fc.asyncProperty( + arbBackwardPlatform, + arbBranch, + arbCommitMsg, + arbTarget, + async (platform, branch, commitMsg, target) => { + const config = makeConfig({ + git: { + platform, + url: `https://${platform === 'github' ? 'github.com' : 'bitbucket.org'}/owner/repo`, + pr: { target }, + api: { timeout: 30000 }, + }, + }); + + const fallbackUrl = `https://example.com/${platform}/compare/${target}...${branch}`; + const mockProvider = makeMockProvider({ + name: () => platform, + generatePullRequestUrl: jest.fn().mockReturnValue(fallbackUrl), + }); + + const deps = makeDeps({ + resolveAuth: jest.fn().mockReturnValue({ token: null, method: 'token' as const }), + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github', 'bitbucket']), + }, + }); + + const result = await createPR(config, branch, commitMsg, 'auto', deps); + + expect(result.status).toBe('fallback'); + expect(result.fallbackReason).toBe('no_token'); + expect(result.url).toBe(fallbackUrl); + expect(result.number).toBeNull(); + expect(mockProvider.createPullRequest).not.toHaveBeenCalled(); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 6: Fallback strategy +// **Validates: Requirements 4.1, 4.3, 4.4, 7.1, 7.2** +// --------------------------------------------------------------------------- + +describe('Property 6: Fallback strategy', () => { + const arbScenario = fc.record({ + hasToken: fc.boolean(), + apiSucceeds: fc.boolean(), + branch: arbBranch, + commitMsg: arbCommitMsg, + target: arbTarget, + token: arbToken, + errorMessage: arbIdent.map((s) => `API error: ${s}`), + }); + + test('token+API success → created; no token → fallback(no_token); token+API error → fallback(error)', async () => { + await fc.assert( + fc.asyncProperty(arbScenario, async (scenario) => { + const { hasToken, apiSucceeds, branch, commitMsg, target, token, errorMessage } = scenario; + + const config = makeConfig({ + git: { + platform: 'github', + url: 'https://github.com/owner/repo', + pr: { target }, + api: { timeout: 30000 }, + }, + }); + + const createdResult: PR_Result = { + url: 'https://github.com/owner/repo/pull/99', + number: 99, + status: 'created', + fallbackReason: null, + platform: 'github', + warnings: [], + }; + + const fallbackUrl = 'https://github.com/owner/repo/compare/main...branch'; + + const mockProvider = makeMockProvider({ + createPullRequest: apiSucceeds + ? jest.fn().mockResolvedValue(createdResult) + : jest.fn().mockRejectedValue(new Error(errorMessage)), + generatePullRequestUrl: jest.fn().mockReturnValue(fallbackUrl), + }); + + const deps = makeDeps({ + resolveAuth: jest.fn().mockReturnValue( + hasToken + ? { token, method: 'token' as const } + : { token: null, method: 'token' as const }, + ), + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + }); + + const result = await createPR(config, branch, commitMsg, 'auto', deps); + + if (hasToken && apiSucceeds) { + expect(result.status).toBe('created'); + expect(result.url).toBe(createdResult.url); + expect(result.number).toBe(99); + } else if (!hasToken) { + expect(result.status).toBe('fallback'); + expect(result.fallbackReason).toBe('no_token'); + expect(result.number).toBeNull(); + } else { + // hasToken && !apiSucceeds + expect(result.status).toBe('fallback'); + expect(result.fallbackReason).toBe(errorMessage); + expect(result.number).toBeNull(); + } + }), + { numRuns: 100 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 7: PR/MR error does not interrupt pipeline +// **Validates: Requirements 4.5** +// --------------------------------------------------------------------------- + +describe('Property 7: PR/MR error does not interrupt pipeline', () => { + const arbErrorType = fc.constantFrom('network', 'auth', 'api', 'timeout', 'unknown'); + + const arbErrorScenario = fc.record({ + errorType: arbErrorType, + errorMessage: arbIdent.map((s) => `Error: ${s}`), + branch: arbBranch, + commitMsg: arbCommitMsg, + }); + + test('for any API error, createPR in auto mode returns a result (not throws), with status=fallback', async () => { + await fc.assert( + fc.asyncProperty(arbErrorScenario, async (scenario) => { + const { errorType, errorMessage, branch, commitMsg } = scenario; + const config = makeConfig(); + + let error: Error; + switch (errorType) { + case 'network': + error = new VersioningsError(EXIT_CODES.NETWORK_ERROR, errorMessage); + break; + case 'auth': + error = new VersioningsError(EXIT_CODES.CONFIG_ERROR, errorMessage); + break; + case 'api': + error = new VersioningsError(EXIT_CODES.COMMAND_FAILED, errorMessage); + break; + case 'timeout': + error = new Error(`Timeout: ${errorMessage}`); + break; + default: + error = new Error(errorMessage); + } + + const fallbackUrl = 'https://github.com/owner/repo/compare/main...branch'; + const mockProvider = makeMockProvider({ + createPullRequest: jest.fn().mockRejectedValue(error), + generatePullRequestUrl: jest.fn().mockReturnValue(fallbackUrl), + }); + + const deps = makeDeps({ + resolveAuth: jest.fn().mockReturnValue({ token: 'ghp_test', method: 'token' as const }), + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + }); + + // In auto mode, createPR should NEVER throw — always returns a result + const result = await createPR(config, branch, commitMsg, 'auto', deps); + + expect(result.status).toBe('fallback'); + expect(result.url).toBe(fallbackUrl); + expect(result.number).toBeNull(); + expect(typeof result.fallbackReason).toBe('string'); + expect(result.fallbackReason!.length).toBeGreaterThan(0); + }), + { numRuns: 100 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 8: Unsupported PR parameters → warnings +// **Validates: Requirements 5.7** +// --------------------------------------------------------------------------- + +describe('Property 8: Unsupported PR parameters → warnings', () => { + const arbPrOptionsWithUnsupported = fc.record({ + title: arbIdent.map((s) => `PR: ${s}`), + body: arbIdent, + sourceBranch: arbBranch, + targetBranch: arbTarget, + reviewers: fc.option(fc.array(arbIdent, { minLength: 1, maxLength: 3 }), { nil: undefined }), + labels: fc.option(fc.array(arbIdent, { minLength: 1, maxLength: 3 }), { nil: undefined }), + draft: fc.option(fc.boolean(), { nil: undefined }), + milestone: fc.option(arbIdent, { nil: undefined }), + linkedIssues: fc.option(fc.array(arbIdent, { minLength: 1, maxLength: 3 }), { nil: undefined }), + }); + + /** Parameters that a Bitbucket-like provider considers unsupported */ + const UNSUPPORTED_PARAMS = ['labels', 'milestone', 'linkedIssues'] as const; + + test('for any provider and PR_Options with unsupported params, createPullRequest includes warnings', async () => { + await fc.assert( + fc.asyncProperty(arbPrOptionsWithUnsupported, async (opts) => { + // Determine which unsupported params are actually present in this run + const presentUnsupported = UNSUPPORTED_PARAMS.filter( + (p) => opts[p] !== undefined, + ); + + // Skip trivial case where no unsupported params are present + fc.pre(presentUnsupported.length > 0); + + // Build a mock provider that returns warnings for unsupported params + const warnings = presentUnsupported.map( + (p) => `Parameter "${p}" is not supported by this provider`, + ); + + const mockResult: PR_Result = { + url: 'https://bitbucket.org/owner/repo/pull-requests/1', + number: 1, + status: 'created', + fallbackReason: null, + platform: 'bitbucket', + warnings, + }; + + const mockProvider: SCM_Provider = { + name: () => 'bitbucket', + createPullRequest: jest.fn().mockResolvedValue(mockResult), + generatePullRequestUrl: jest.fn().mockReturnValue( + 'https://bitbucket.org/owner/repo/pull-requests/new', + ), + }; + + const config = makeConfig({ + git: { + platform: 'bitbucket', + url: 'https://bitbucket.org/owner/repo', + pr: { + target: opts.targetBranch, + reviewers: opts.reviewers, + labels: opts.labels, + draft: opts.draft, + milestone: opts.milestone, + linkedIssues: opts.linkedIssues, + }, + api: { timeout: 30000 }, + }, + }); + + const deps = makeDeps({ + resolveAuth: jest.fn().mockReturnValue({ token: 'bb_test', method: 'token' as const }), + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['bitbucket']), + }, + }); + + const result = await createPR(config, opts.sourceBranch, opts.title, 'auto', deps); + + // (a) Should not throw — we got a result + expect(result).toBeDefined(); + expect(result.status).toBe('created'); + + // (b) Each unsupported param that was present should have a warning + for (const param of presentUnsupported) { + expect(result.warnings.some((w) => w.includes(param))).toBe(true); + } + + // (c) PR was created with supported parameters + expect(result.number).toBe(1); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/scm/pr.reporter.property.test.ts b/__tests__/properties/scm/pr.reporter.property.test.ts new file mode 100644 index 0000000..5bb0f37 --- /dev/null +++ b/__tests__/properties/scm/pr.reporter.property.test.ts @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +// Feature: scm-provider-pr-automation +// Property 11: JSON output completeness and compatibility +// Property 15: GitLab uses term "Merge Request" + +import * as fc from 'fast-check'; +import { createReporter } from '../../../src/core/reporter'; +import type { PipelineResult } from '../../../src/core/reporter'; +import type { PR_Result } from '../../../src/scm/scm.provider'; + +// --- Arbitraries --- + +const arbPlatform = fc.constantFrom('github', 'github-enterprise', 'gitlab', 'bitbucket', 'bitbucket-server', 'azure-devops'); + +const arbPrStatus = fc.constantFrom('created' as const, 'fallback' as const, 'skipped' as const); + +const arbPrResult: fc.Arbitrary = fc.record({ + url: fc.webUrl(), + number: fc.oneof(fc.constant(null), fc.integer({ min: 1, max: 99999 })), + status: arbPrStatus, + fallbackReason: fc.oneof(fc.constant(null), fc.constantFrom('no_token', 'api_error', 'network_error')), + platform: arbPlatform, + warnings: fc.array(fc.string({ minLength: 1, maxLength: 50 }), { minLength: 0, maxLength: 3 }), +}); + +const arbVersion = fc.stringOf( + fc.constantFrom('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '-'), + { minLength: 3, maxLength: 15 }, +).filter((s) => /^\d/.test(s)); + +const arbSemver = fc.constantFrom('patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease'); + +const arbPipelineResultWithPR: fc.Arbitrary = fc.record({ + success: fc.constant(true as const), + version: arbVersion, + previousVersion: arbVersion, + semver: arbSemver, + branch: fc.string({ minLength: 1, maxLength: 80 }).filter((s) => s.trim().length > 0), + tag: fc.string({ minLength: 1, maxLength: 80 }).filter((s) => s.trim().length > 0), + pullRequestUrl: fc.constant(null), + exitCode: fc.constant(0), + pullRequest: arbPrResult, +}); + +// eslint-disable-next-line no-control-regex +const ANSI_PATTERN = /\x1b\[[0-9;]*m/; + +// --- Property 11: JSON output completeness and compatibility --- +// **Validates: Requirements 7.4, 13.3, 13.4** + +describe('Property 11: JSON output completeness and compatibility', () => { + const reporter = createReporter({ json: true }); + + test('JSON output with pullRequest contains pullRequest object with all required fields', () => { + fc.assert( + fc.property(arbPipelineResultWithPR, (result) => { + const output = reporter.reportSuccess(result); + const parsed = JSON.parse(output); + + // pullRequest object must be present with required fields + expect(parsed.pullRequest).toBeDefined(); + expect(typeof parsed.pullRequest.url).toBe('string'); + expect(parsed.pullRequest.number === null || typeof parsed.pullRequest.number === 'number').toBe(true); + expect(typeof parsed.pullRequest.status).toBe('string'); + expect(parsed.pullRequest.fallbackReason === null || typeof parsed.pullRequest.fallbackReason === 'string').toBe(true); + expect(typeof parsed.pullRequest.platform).toBe('string'); + }), + { numRuns: 100 }, + ); + }); + + test('pullRequestUrl alias equals pullRequest.url for backward compatibility', () => { + fc.assert( + fc.property(arbPipelineResultWithPR, (result) => { + const output = reporter.reportSuccess(result); + const parsed = JSON.parse(output); + + // pullRequestUrl must be present as alias + expect(parsed).toHaveProperty('pullRequestUrl'); + expect(parsed.pullRequestUrl).toBe(parsed.pullRequest.url); + }), + { numRuns: 100 }, + ); + }); + + test('JSON output is valid JSON with no ANSI sequences', () => { + fc.assert( + fc.property(arbPipelineResultWithPR, (result) => { + const output = reporter.reportSuccess(result); + expect(output.endsWith('\n')).toBe(true); + const body = output.slice(0, -1); + expect(() => JSON.parse(body)).not.toThrow(); + expect(ANSI_PATTERN.test(output)).toBe(false); + }), + { numRuns: 100 }, + ); + }); +}); + + +// --- Property 15: GitLab uses term "Merge Request" --- +// **Validates: Requirements 2.5** + +describe('Property 15: GitLab uses term "Merge Request"', () => { + const reporter = createReporter({ json: false }); + + const arbGitLabPrResult: fc.Arbitrary = fc.record({ + url: fc.webUrl(), + number: fc.oneof(fc.constant(null), fc.integer({ min: 1, max: 99999 })), + status: fc.constantFrom('created' as const, 'fallback' as const), + fallbackReason: fc.oneof(fc.constant(null), fc.constantFrom('no_token', 'api_error')), + platform: fc.constant('gitlab'), + warnings: fc.array(fc.string({ minLength: 1, maxLength: 50 }), { minLength: 0, maxLength: 3 }), + }); + + const arbGitLabResult: fc.Arbitrary = fc.record({ + success: fc.constant(true as const), + version: arbVersion, + previousVersion: arbVersion, + semver: arbSemver, + branch: fc.string({ minLength: 1, maxLength: 80 }).filter((s) => s.trim().length > 0), + tag: fc.string({ minLength: 1, maxLength: 80 }).filter((s) => s.trim().length > 0), + pullRequestUrl: fc.constant(null), + exitCode: fc.constant(0), + pullRequest: arbGitLabPrResult, + }); + + test('human-readable output for GitLab uses "Merge request" not "Pull request"', () => { + fc.assert( + fc.property(arbGitLabResult, (result) => { + const output = reporter.reportSuccess(result); + // GitLab output must use "Merge request" terminology + expect(output).toContain('Merge request'); + expect(output).not.toContain('Pull request'); + }), + { numRuns: 100 }, + ); + }); + + test('GitLab PR_Result has platform === "gitlab"', () => { + fc.assert( + fc.property(arbGitLabResult, (result) => { + expect(result.pullRequest!.platform).toBe('gitlab'); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/scm/scm.registry.property.test.ts b/__tests__/properties/scm/scm.registry.property.test.ts new file mode 100644 index 0000000..b07518f --- /dev/null +++ b/__tests__/properties/scm/scm.registry.property.test.ts @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: scm-provider-pr-automation, Property 1: Registry provider mapping +// Feature: scm-provider-pr-automation, Property 2: Registry error for unknown platform + +import * as fc from 'fast-check'; +import { createSCMRegistry } from '../../../src/scm/scm.registry'; +import type { SCM_ProviderConfig } from '../../../src/scm/scm.provider'; +import type { HttpClient } from '../../../src/scm/http.client'; +import type { UrlParser } from '../../../src/scm/url.parser'; +import { VersioningsError, EXIT_CODES } from '../../../src/core/errors'; + +// --- Minimal mocks (not exercised by registry logic) --- + +const mockHttpClient: HttpClient = { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), +}; + +const mockUrlParser: UrlParser = { + parse: jest.fn(), + format: jest.fn(), +}; + +// --- Generators --- + +const VALID_PLATFORMS = [ + 'github', + 'github-enterprise', + 'bitbucket', + 'bitbucket-server', + 'gitlab', + 'azure-devops', +] as const; + +/** Arbitrary valid platform from the registered set */ +const arbPlatform = fc.constantFrom(...VALID_PLATFORMS); + +/** Arbitrary string that is NOT in the valid platform set */ +const arbInvalidPlatform = fc + .string({ minLength: 1, maxLength: 50 }) + .filter((s) => !(VALID_PLATFORMS as readonly string[]).includes(s)); + +/** Builds a minimal SCM_ProviderConfig for a given platform */ +function makeConfig(platform: string): SCM_ProviderConfig { + return { + platform, + url: `https://example.com/${platform}/repo`, + token: 'test-token', + authMethod: 'token', + timeout: 30_000, + }; +} + +// --- Property 1: Registry provider mapping --- +// **Validates: Requirements 1.2** + +describe('Property 1: Registry provider mapping', () => { + test('for any registered platform, getProvider returns a provider whose name() equals the platform', () => { + fc.assert( + fc.property(arbPlatform, (platform) => { + const registry = createSCMRegistry(); + const config = makeConfig(platform); + const provider = registry.getProvider(config, mockHttpClient, mockUrlParser); + + expect(provider).toBeDefined(); + expect(provider.name()).toBe(platform); + }), + { numRuns: 100 }, + ); + }); +}); + +// --- Property 2: Registry error for unknown platform --- +// **Validates: Requirements 1.3** + +describe('Property 2: Registry error for unknown platform', () => { + test('for any string not in the registered set, getProvider throws VersioningsError(CONFIG_ERROR) with available platforms listed', () => { + fc.assert( + fc.property(arbInvalidPlatform, (platform) => { + const registry = createSCMRegistry(); + const config = makeConfig(platform); + + try { + registry.getProvider(config, mockHttpClient, mockUrlParser); + fail('Expected VersioningsError to be thrown'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + + // Error message must list all available platforms + const message = (err as VersioningsError).message; + for (const p of VALID_PLATFORMS) { + expect(message).toContain(p); + } + } + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/scm/template.renderer.property.test.ts b/__tests__/properties/scm/template.renderer.property.test.ts new file mode 100644 index 0000000..ca2dd50 --- /dev/null +++ b/__tests__/properties/scm/template.renderer.property.test.ts @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: branching-policy-enforcement, Properties 10–13: Template Renderer + +import * as fc from 'fast-check'; +import { parseTemplate, formatTemplate, renderTemplate, TEMPLATE_VARIABLES, INVALID_BRANCH_CHARS } from '../../../src/scm/template.renderer'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; +import type { TemplateContext } from '../../../src/scm/template.renderer'; + +// --- Generators --- + +/** Generate a literal segment: alphanumeric + `/` + `-` + `.`, non-empty */ +const arbLiteralSegment = fc + .stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789/-.'.split('')), { minLength: 1, maxLength: 10 }) + .filter((s) => s.length > 0); + +/** Generate a variable reference wrapped in {} from TEMPLATE_VARIABLES */ +const arbVariableSegment = fc.constantFrom(...TEMPLATE_VARIABLES).map((v) => `{${v}}`); + +/** Generate a valid template by combining literal segments and variable references */ +const arbTemplate = fc + .array(fc.oneof(arbLiteralSegment, arbVariableSegment), { minLength: 1, maxLength: 8 }) + .map((parts) => parts.join('')); + +/** Generate a safe string for TemplateContext values: alphanumeric + `-` + `.`, no forbidden chars */ +const arbSafeValue = fc.stringOf( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789-.'.split('')), + { minLength: 1, maxLength: 20 }, +); + +/** Generate a TemplateContext where all values are safe strings */ +const arbTemplateContext: fc.Arbitrary = fc.record({ + version: arbSafeValue, + major: arbSafeValue, + minor: arbSafeValue, + patch: arbSafeValue, + semver: arbSafeValue, + comment: arbSafeValue, + branchType: arbSafeValue, +}); + +/** Generate a string NOT in TEMPLATE_VARIABLES (for unknown variable testing) */ +const arbInvalidVarName = fc + .stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), { minLength: 1, maxLength: 15 }) + .filter((s) => !(TEMPLATE_VARIABLES as readonly string[]).includes(s)); + +// --- Property 10: Round-trip of naming templates --- +// Feature: branching-policy-enforcement, Property 10: Round-trip of naming templates — formatTemplate(parseTemplate(t)) === t + +describe('Property 10: Round-trip of naming templates', () => { + it('formatTemplate(parseTemplate(template)) === template for any valid template', () => { + fc.assert( + fc.property(arbTemplate, (template: string) => { + const parts = parseTemplate(template); + const roundTripped = formatTemplate(parts); + expect(roundTripped).toBe(template); + }), + { numRuns: 100 }, + ); + }); +}); + +// --- Property 11: Rendered template produces valid Git ref name --- +// Feature: branching-policy-enforcement, Property 11: Rendered template produces valid Git ref name (no forbidden chars, no leading/trailing / or -) + +describe('Property 11: Rendered template produces valid Git ref name', () => { + it('renderTemplate result does NOT contain INVALID_BRANCH_CHARS and does NOT start/end with / or -', () => { + fc.assert( + fc.property(arbTemplate, arbTemplateContext, (template: string, context: TemplateContext) => { + const result = renderTemplate(template, context); + // No forbidden characters + expect(INVALID_BRANCH_CHARS.test(result)).toBe(false); + // Does not start with / or - + expect(result.startsWith('/')).toBe(false); + expect(result.startsWith('-')).toBe(false); + // Does not end with / or - + expect(result.endsWith('/')).toBe(false); + expect(result.endsWith('-')).toBe(false); + }), + { numRuns: 100 }, + ); + }); +}); + +// --- Property 12: Error on unknown variable in template --- +// Feature: branching-policy-enforcement, Property 12: Error on unknown variable in template + +describe('Property 12: Error on unknown variable in template', () => { + it('parseTemplate throws VersioningsError(CONFIG_ERROR) for template containing {unknownVar}', () => { + fc.assert( + fc.property(arbInvalidVarName, (unknownVar: string) => { + const template = `prefix/{${unknownVar}}/suffix`; + try { + parseTemplate(template); + throw new Error('Expected VersioningsError to be thrown'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(err.message).toContain(unknownVar); + } + }), + { numRuns: 100 }, + ); + }); +}); + +// --- Property 13: All variables in template are substituted --- +// Feature: branching-policy-enforcement, Property 13: All variables in template are substituted (no {varName} remains in output) + +describe('Property 13: All variables in template are substituted', () => { + it('renderTemplate result does NOT contain {variableName} substrings', () => { + fc.assert( + fc.property(arbTemplate, arbTemplateContext, (template: string, context: TemplateContext) => { + const result = renderTemplate(template, context); + // No unsubstituted variable placeholders should remain + for (const varName of TEMPLATE_VARIABLES) { + expect(result).not.toContain(`{${varName}}`); + } + // Also check with a general regex for any {word} pattern + expect(result).not.toMatch(/\{[a-zA-Z]+\}/); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/scm/url.parser.property.test.ts b/__tests__/properties/scm/url.parser.property.test.ts new file mode 100644 index 0000000..82b0d65 --- /dev/null +++ b/__tests__/properties/scm/url.parser.property.test.ts @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: scm-provider-pr-automation, Property 12: Round-trip URL parsing +// Feature: scm-provider-pr-automation, Property 13: Invalid URL parsing error + +import * as fc from 'fast-check'; +import { createUrlParser, type UrlParser } from '../../../src/scm/url.parser'; +import { VersioningsError, EXIT_CODES } from '../../../src/core/errors'; + +let parser: UrlParser; + +beforeEach(() => { + parser = createUrlParser(); +}); + +// --- Generators --- + +/** Valid owner/repo segment: alphanumeric + hyphens, 1-30 chars, no leading/trailing hyphen */ +const arbOwnerRepo = fc + .stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789-'.split('')), { + minLength: 1, + maxLength: 30, + }) + .filter((s) => !s.startsWith('-') && !s.endsWith('-') && s.length > 0); + +/** Generate a valid GitHub HTTPS URL from components */ +const arbGitHubUrl = fc.tuple(arbOwnerRepo, arbOwnerRepo).map(([owner, repo]) => ({ + url: `https://github.com/${owner}/${repo}`, + owner, + repo, +})); + +/** Generate a valid GitLab HTTPS URL with optional nested groups */ +const arbGitLabUrl = fc + .tuple( + fc.array(arbOwnerRepo, { minLength: 1, maxLength: 3 }), + arbOwnerRepo, + ) + .map(([groups, project]) => ({ + url: `https://gitlab.com/${groups.join('/')}/${project}`, + namespacePath: groups.join('/'), + project, + })); + +/** Generate a valid Bitbucket Cloud HTTPS URL from components */ +const arbBitbucketUrl = fc.tuple(arbOwnerRepo, arbOwnerRepo).map(([workspace, repoSlug]) => ({ + url: `https://bitbucket.org/${workspace}/${repoSlug}`, + workspace, + repoSlug, +})); + +/** Generate a valid Azure DevOps HTTPS URL from components */ +const arbAzureDevOpsUrl = fc + .tuple(arbOwnerRepo, arbOwnerRepo, arbOwnerRepo) + .map(([org, project, repo]) => ({ + url: `https://dev.azure.com/${org}/${project}/_git/${repo}`, + organization: org, + project, + repo, + })); + +/** Generate strings that don't match any expected URL format */ +const arbInvalidUrl = fc.oneof( + // Random short strings + fc.string({ minLength: 1, maxLength: 10 }).filter( + (s) => !s.startsWith('https://') && !s.startsWith('git@') && !s.startsWith('ssh://'), + ), + // Strings with no path segments + fc.constant('https://github.com/'), + fc.constant('https://github.com/onlyone'), + // Completely random words + fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz '.split('')), { + minLength: 1, + maxLength: 50, + }).filter((s) => !s.startsWith('https://') && !s.startsWith('git@') && !s.startsWith('ssh://')), +); + +// --- Property 12: Round-trip URL parsing --- +// **Validates: Requirements 14.1, 14.2, 14.3, 14.4, 14.5, 14.6, 14.8, 14.9** + +describe('Property 12: Round-trip URL parsing', () => { + test('GitHub: format(parse(url)) produces equivalent URL', () => { + fc.assert( + fc.property(arbGitHubUrl, ({ url, owner, repo }) => { + const parsed = parser.parse(url, 'github'); + const formatted = parser.format(parsed); + expect(formatted).toBe(`https://github.com/${owner}/${repo}`); + }), + { numRuns: 100 }, + ); + }); + + test('GitLab: format(parse(url)) produces equivalent URL', () => { + fc.assert( + fc.property(arbGitLabUrl, ({ url, namespacePath, project }) => { + const parsed = parser.parse(url, 'gitlab'); + const formatted = parser.format(parsed); + expect(formatted).toBe(`https://gitlab.com/${namespacePath}/${project}`); + }), + { numRuns: 100 }, + ); + }); + + test('Bitbucket Cloud: format(parse(url)) produces equivalent URL', () => { + fc.assert( + fc.property(arbBitbucketUrl, ({ url, workspace, repoSlug }) => { + const parsed = parser.parse(url, 'bitbucket'); + const formatted = parser.format(parsed); + expect(formatted).toBe(`https://bitbucket.org/${workspace}/${repoSlug}`); + }), + { numRuns: 100 }, + ); + }); + + test('Azure DevOps: format(parse(url)) produces equivalent URL', () => { + fc.assert( + fc.property(arbAzureDevOpsUrl, ({ url, organization, project, repo }) => { + const parsed = parser.parse(url, 'azure-devops'); + const formatted = parser.format(parsed); + expect(formatted).toBe( + `https://dev.azure.com/${organization}/${project}/_git/${repo}`, + ); + }), + { numRuns: 100 }, + ); + }); +}); + +// --- Property 13: Invalid URL parsing error --- +// **Validates: Requirements 14.7** + +describe('Property 13: Invalid URL parsing error', () => { + const platforms = ['github', 'gitlab', 'bitbucket', 'azure-devops'] as const; + const arbPlatform = fc.constantFrom(...platforms); + + test('parse() throws VersioningsError(CONFIG_ERROR) for invalid URLs', () => { + fc.assert( + fc.property(arbInvalidUrl, arbPlatform, (url, platform) => { + try { + parser.parse(url, platform); + // If parse didn't throw, the URL happened to be valid — skip this case + // (some random strings could accidentally form valid paths) + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + expect((err as VersioningsError).message).toContain(platform); + } + }), + { numRuns: 100 }, + ); + }); + + test('parse() throws VersioningsError(CONFIG_ERROR) for unsupported platform', () => { + const arbBadPlatform = fc + .string({ minLength: 1, maxLength: 30 }) + .filter( + (s) => + !['github', 'github-enterprise', 'bitbucket', 'bitbucket-server', 'gitlab', 'azure-devops'].includes(s), + ); + + fc.assert( + fc.property(arbBadPlatform, (platform) => { + expect(() => parser.parse('https://example.com/owner/repo', platform)).toThrow( + VersioningsError, + ); + try { + parser.parse('https://example.com/owner/repo', platform); + } catch (err) { + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + expect((err as VersioningsError).message).toContain('Unsupported platform'); + } + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/structured.logger.property.test.ts b/__tests__/properties/structured.logger.property.test.ts new file mode 100644 index 0000000..fe0c9ae --- /dev/null +++ b/__tests__/properties/structured.logger.property.test.ts @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import * as fc from 'fast-check'; +import { createStructuredLogger, LOG_LEVEL_PRIORITY } from '../../src/core/structured.logger'; +import type { LogLevel } from '../../src/core/structured.logger'; + +/** + * Helper: create an array-backed writable stream that captures written chunks. + */ +function createCapture(): { stream: NodeJS.WritableStream; lines: string[] } { + const lines: string[] = []; + const stream = { + write(chunk: string): boolean { + lines.push(chunk); + return true; + }, + } as unknown as NodeJS.WritableStream; + return { stream, lines }; +} + +const LOG_LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error']; + +const ISO_8601_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/; + +/** Arbitrary for a valid log level */ +const arbLogLevel: fc.Arbitrary = fc.constantFrom(...LOG_LEVELS); + +/** Arbitrary for a non-empty message string (JSON-safe) */ +const arbMessage = fc.string({ minLength: 1, maxLength: 200 }); + +/** Arbitrary for a UUID-like operationId */ +const arbOperationId = fc.uuid(); + +/** Arbitrary for optional context object */ +const arbContext: fc.Arbitrary | undefined> = fc.oneof( + fc.constant(undefined), + fc.dictionary( + fc.string({ minLength: 1, maxLength: 20 }).filter((s) => /^[a-zA-Z]/.test(s)), + fc.oneof(fc.string({ maxLength: 50 }), fc.integer(), fc.boolean()), + ), +); + +/** Arbitrary for optional actor string */ +const arbActor: fc.Arbitrary = fc.oneof( + fc.constant(undefined), + fc.string({ minLength: 1, maxLength: 50 }), +); + +/** Arbitrary for optional ci boolean */ +const arbCi: fc.Arbitrary = fc.oneof( + fc.constant(undefined), + fc.constant(true), + fc.constant(false), +); + + +/** + * Property 1: Структура лог-сообщения + * + * For any valid set of inputs (level, message, operationId, context, actor, ci), + * calling the corresponding Structured Logger method SHALL produce a string that, + * when parsed as JSON, contains: timestamp (valid ISO 8601), level, message, operationId. + * Actor is present when level >= info AND actor is set. + * ci is present when ci === true. + * + * **Validates: Requirements 1.1, 2.2, 3.6, 9.5** + */ +describe('Feature: operational-hardening, Property 1: Структура лог-сообщения', () => { + test('output parses as JSON and contains timestamp (ISO 8601), level, message, operationId', () => { + fc.assert( + fc.property( + arbLogLevel, + arbMessage, + arbOperationId, + arbContext, + arbActor, + arbCi, + (msgLevel, message, operationId, context, actor, ci) => { + const { stream, lines } = createCapture(); + + // Configure logger at 'debug' so all messages pass through + const logger = createStructuredLogger({ + output: stream, + level: 'debug', + operationId, + actor, + ci, + }); + + // Call the method matching msgLevel + logger[msgLevel](message, context); + + // Exactly one line should be written + expect(lines).toHaveLength(1); + + // Must parse as JSON + const entry = JSON.parse(lines[0]); + + // timestamp must be a valid ISO 8601 string + expect(typeof entry.timestamp).toBe('string'); + expect(ISO_8601_REGEX.test(entry.timestamp)).toBe(true); + // Verify it's a valid date + expect(Number.isNaN(Date.parse(entry.timestamp))).toBe(false); + + // level must match the called method + expect(entry.level).toBe(msgLevel); + + // message must match the input + expect(entry.message).toBe(message); + + // operationId must match the input + expect(entry.operationId).toBe(operationId); + + // context: if provided, must be present + if (context !== undefined) { + expect(entry.context).toEqual(context); + } + }, + ), + { numRuns: 100 }, + ); + }); + + test('actor is present when level >= info AND actor is set', () => { + fc.assert( + fc.property( + arbLogLevel, + arbMessage, + arbOperationId, + arbActor, + arbCi, + (msgLevel, message, operationId, actor, ci) => { + const { stream, lines } = createCapture(); + + const logger = createStructuredLogger({ + output: stream, + level: 'debug', + operationId, + actor, + ci, + }); + + logger[msgLevel](message); + + expect(lines).toHaveLength(1); + const entry = JSON.parse(lines[0]); + + const msgPriority = LOG_LEVEL_PRIORITY[msgLevel]; + const infoPriority = LOG_LEVEL_PRIORITY['info']; + + if (msgPriority >= infoPriority && actor !== undefined) { + // actor SHOULD be present + expect(entry.actor).toBe(actor); + } else { + // actor SHOULD NOT be present + expect(entry).not.toHaveProperty('actor'); + } + }, + ), + { numRuns: 100 }, + ); + }); + + test('ci is present when ci === true', () => { + fc.assert( + fc.property( + arbLogLevel, + arbMessage, + arbOperationId, + arbActor, + arbCi, + (msgLevel, message, operationId, actor, ci) => { + const { stream, lines } = createCapture(); + + const logger = createStructuredLogger({ + output: stream, + level: 'debug', + operationId, + actor, + ci, + }); + + logger[msgLevel](message); + + expect(lines).toHaveLength(1); + const entry = JSON.parse(lines[0]); + + if (ci === true) { + expect(entry.ci).toBe(true); + } else { + // ci should NOT be present when ci is false or undefined + expect(entry).not.toHaveProperty('ci'); + } + }, + ), + { numRuns: 100 }, + ); + }); +}); + + +/** + * Property 2: Фильтрация по уровню логирования + * + * For any configured log level and any message log level, the message SHALL be + * output if and only if priority[messageLevel] >= priority[configuredLevel]. + * When verbose=true (level='debug'), all messages are output. + * + * **Validates: Requirements 1.4, 1.5, 13.5** + */ +describe('Feature: operational-hardening, Property 2: Фильтрация по уровню логирования', () => { + test('message is output ⟺ priority[messageLevel] >= priority[configuredLevel]', () => { + fc.assert( + fc.property( + arbLogLevel, + arbLogLevel, + arbMessage, + arbOperationId, + (configuredLevel, messageLevel, message, operationId) => { + const { stream, lines } = createCapture(); + + const logger = createStructuredLogger({ + output: stream, + level: configuredLevel, + operationId, + }); + + logger[messageLevel](message); + + const configuredPriority = LOG_LEVEL_PRIORITY[configuredLevel]; + const messagePriority = LOG_LEVEL_PRIORITY[messageLevel]; + + if (messagePriority >= configuredPriority) { + // Message SHOULD be output + expect(lines).toHaveLength(1); + const entry = JSON.parse(lines[0]); + expect(entry.level).toBe(messageLevel); + expect(entry.message).toBe(message); + } else { + // Message SHOULD NOT be output + expect(lines).toHaveLength(0); + } + }, + ), + { numRuns: 100 }, + ); + }); + + test('when verbose=true (level="debug"), all messages are output', () => { + fc.assert( + fc.property( + arbLogLevel, + arbMessage, + arbOperationId, + (messageLevel, message, operationId) => { + const { stream, lines } = createCapture(); + + // verbose=true means configuredLevel='debug' (priority 0) + const logger = createStructuredLogger({ + output: stream, + level: 'debug', + operationId, + }); + + logger[messageLevel](message); + + // All messages must pass through when level is 'debug' + expect(lines).toHaveLength(1); + const entry = JSON.parse(lines[0]); + expect(entry.level).toBe(messageLevel); + expect(entry.message).toBe(message); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/token.masking.property.test.ts b/__tests__/properties/token.masking.property.test.ts new file mode 100644 index 0000000..2cbcd56 --- /dev/null +++ b/__tests__/properties/token.masking.property.test.ts @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import * as fc from 'fast-check'; +import { maskTokens } from '../../src/core/operation.log'; + +// --- Generators --- + +/** Arbitrary for hex token strings (>= 20 hex characters, lowercase) */ +const arbHexToken = fc.stringOf( + fc.constantFrom(...'0123456789abcdef'.split('')), + { minLength: 20, maxLength: 64 }, +); + +/** Arbitrary for base64-like token strings (>= 20 chars, must contain at least one of +/=) */ +const arbBase64Token = fc.tuple( + fc.stringOf( + fc.constantFrom(...'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'.split('')), + { minLength: 17, maxLength: 60 }, + ), + fc.constantFrom('+', '/', '='), + fc.stringOf( + fc.constantFrom(...'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='.split('')), + { minLength: 2, maxLength: 10 }, + ), +).map(([prefix, special, suffix]) => prefix + special + suffix); + +/** Arbitrary for safe prefix/suffix text that won't be mistaken for tokens */ +const arbSafeText = fc.constantFrom( + 'versionings release', + 'npm version patch', + 'git push origin main', + '--semver=minor', + '--branch=develop', + '--push', + '--verbose', + '--json', + 'some-command', + 'run test', +); + +/** Arbitrary for env-var-style secret keys */ +const arbSecretKey = fc.constantFrom( + 'GITHUB_TOKEN', 'NPM_TOKEN', 'MY_SECRET', 'API_KEY', + 'AUTH_TOKEN', 'GIT_PASSWORD', 'CREDENTIAL_STORE', +); + +/** Arbitrary for flag-style secret keys */ +const arbSecretFlag = fc.constantFrom( + '--token', '--password', '--secret', '--auth', '--api-key', '--credentials', +); + +// --- Property 9: Token masking in command --- + +/** + * Property 9: Маскирование токенов в command + * + * For any command string containing token-like substrings (hex/base64 strings + * >= 20 characters, or values of secret env var / flag patterns), maskTokens() + * SHALL replace all such tokens with `***`, and the result SHALL NOT contain + * the original token values. + * + * **Validates: Requirements 5.4** + */ +describe('Feature: operational-hardening, Property 9: Маскирование токенов в command', () => { + test('hex tokens (>= 20 chars) are replaced with *** and absent from result', () => { + fc.assert( + fc.property(arbSafeText, arbHexToken, (prefix, token) => { + const command = `${prefix} ${token}`; + const result = maskTokens(command); + + expect(result).not.toContain(token); + expect(result).toContain('***'); + }), + { numRuns: 100 }, + ); + }); + + test('base64-like tokens (>= 20 chars with +/=) are replaced with *** and absent from result', () => { + fc.assert( + fc.property(arbSafeText, arbBase64Token, (prefix, token) => { + const command = `${prefix} ${token}`; + const result = maskTokens(command); + + expect(result).not.toContain(token); + expect(result).toContain('***'); + }), + { numRuns: 100 }, + ); + }); + + test('env-var-style secret values (KEY=value) are masked', () => { + fc.assert( + fc.property(arbSecretKey, arbHexToken, (key, value) => { + const command = `${key}=${value} versionings release`; + const result = maskTokens(command); + + expect(result).not.toContain(value); + expect(result).toContain('***'); + }), + { numRuns: 100 }, + ); + }); + + test('flag-style secret values (--token=value) are masked', () => { + fc.assert( + fc.property(arbSecretFlag, arbHexToken, (flag, value) => { + const command = `versionings release ${flag}=${value}`; + const result = maskTokens(command); + + expect(result).not.toContain(value); + expect(result).toContain('***'); + }), + { numRuns: 100 }, + ); + }); + + test('multiple tokens in a single command are all masked', () => { + fc.assert( + fc.property(arbHexToken, arbHexToken, (token1, token2) => { + const command = `cmd ${token1} --flag ${token2}`; + const result = maskTokens(command); + + expect(result).not.toContain(token1); + expect(result).not.toContain(token2); + }), + { numRuns: 100 }, + ); + }); + + test('commands without tokens are returned unchanged', () => { + fc.assert( + fc.property(arbSafeText, (command) => { + const result = maskTokens(command); + + expect(result).toBe(command); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/versioning/auto-bump.pipeline.property.test.ts b/__tests__/properties/versioning/auto-bump.pipeline.property.test.ts new file mode 100644 index 0000000..df55863 --- /dev/null +++ b/__tests__/properties/versioning/auto-bump.pipeline.property.test.ts @@ -0,0 +1,752 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: version-intelligence-release-narrative +// Property 6: Backward compatibility — non-auto semver does not call Commit_Analyzer +// Property 7: Auto-bump with preid converts to prerelease modifier +// Property 15: Merging PR template and changelog via separator +// Property 16: Changelog preview in dry-run limited to 50 lines +// Property 17: DryRunPlan and PipelineResult contain autoBump when --semver=auto +// Property 18: Dry-run includes changelog write step when changelog.file is configured +// Property 21: Reporter formats NO_CONVENTIONAL_COMMITS error with details + +import * as fc from 'fast-check'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; +import { createReporter } from '../../../src/core/reporter'; + +// ── Mock fs ───────────────────────────────────────────────────────────────── + +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + readFileSync: jest.fn(), + existsSync: jest.fn(), + writeFileSync: jest.fn(), + }; +}); + +const fs = require('fs'); + +// ── Mock version.utils ────────────────────────────────────────────────────── + +jest.mock('../../../src/versioning/version.utils', () => ({ + AVAILABLE_SEMVERS: ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease', 'auto'], + composeVersionBranchName: (semver: string, version: string, comment: string) => + `version/${semver}/${version}/${comment}`, + composeVersionTagName: (semver: string, version: string, comment: string) => + `${version}--${comment}`, + semverMessage: (semver: string, version: string) => + `Patch: v${version}. You SHOULD consider changes.`, + semverNpmMessage: (semver: string, branch: string) => + `Version: ${semver}. Comment: ${branch}.`, + preidParam: (preid?: string) => (preid ? `--preid=${preid}` : ''), + generatePullRequestUrl: (branch: string) => + `https://github.com/user/repo/compare/develop...${branch}?expand=1`, +})); + +// ── Mock pr.creator ───────────────────────────────────────────────────────── + +const mockCreatePR = jest.fn(); +jest.mock('../../../src/scm/pr.creator', () => ({ + createPR: (...args: any[]) => mockCreatePR(...args), +})); + +const { runPipeline } = require('../../../src/core/pipeline'); + +// ── Shared fixtures ───────────────────────────────────────────────────────── + +const mockConfig = { + git: { + platform: 'github', + url: 'https://github.com/user/repo.git', + branchType: { version: 'version' }, + pr: { target: 'develop', template: undefined }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + patch: 'Patch: v%s.', + minor: 'Minor: v%s.', + major: 'Release: v%s.', + prepatch: 'Prepatch: v%s.', + preminor: 'Preminor: v%s.', + premajor: 'Premajor: v%s.', + prerelease: 'Prerelease: v%s.', + }, + }, + }, + }, + package: { + semver: { + patch: 'patch', prepatch: 'prepatch', minor: 'minor', + preminor: 'preminor', premajor: 'premajor', prerelease: 'prerelease', major: 'major', + }, + }, + common: { + messages: { + unavailableSemanticVersion: 'Invalid semver', + undefinedVersionBranchName: 'Branch name required', + incorrectVersionBranchNameLength: 'Branch too long', + incorrectVersionBranchNameCharactersDashes: 'No double dashes', + untrackedGitFiles: 'Dirty tree', + incorrectGitRemote: 'Wrong remote', + }, + }, +} as any; + +function createMockExecutor() { + return { + run: jest.fn(async (cmd: string) => { + if (cmd.includes('git status --porcelain')) return { stdout: '', lines: [] }; + if (cmd.includes('git remote --verbose')) + return { stdout: 'origin\thttps://github.com/user/repo.git (fetch)', lines: ['origin\thttps://github.com/user/repo.git (fetch)'] }; + if (cmd.includes('npm --no-git-tag-version version')) return { stdout: 'v1.2.3', lines: ['v1.2.3'] }; + if (cmd.includes('git checkout -- package')) return { stdout: '', lines: [] }; + if (cmd.includes('git tag --list')) return { stdout: '', lines: [] }; + if (cmd.includes('git branch --list')) return { stdout: ' main', lines: ['main'] }; + if (cmd.includes('git add')) return { stdout: '', lines: [] }; + return { stdout: '', lines: [] }; + }), + }; +} + +function createMockRollbackManager() { + return { + record: jest.fn(), + rollback: jest.fn(async () => ({ success: true, failedSteps: [] })), + }; +} + +function createMockArtifactChecker() { + return { checkUniqueness: jest.fn(async () => { }) }; +} + + +function createMockCommitAnalyzer(bump: 'major' | 'minor' | 'patch' = 'minor') { + return { + analyzeBump: jest.fn(async () => ({ + bump, + commits: [ + { hash: 'abc1234', parsed: { valid: true, type: 'feat', scope: null, description: 'add feature', body: null, footers: [], breaking: false, rawMessage: 'feat: add feature' } }, + ], + conventionalCommits: [ + { valid: true, type: 'feat', scope: null, description: 'add feature', body: null, footers: [], breaking: false, rawMessage: 'feat: add feature' }, + ], + breakingChanges: [], + range: { from: 'v1.0.0', to: 'HEAD' }, + commitsByType: { feat: 1 }, + })), + bumpPolicy: { feat: 'minor' as const, fix: 'patch' as const, chore: 'none' as const }, + fallbackBump: null as 'major' | 'minor' | 'patch' | null, + }; +} + +function createMockChangelogGenerator(opts: { + markdown?: string; + changelogFile?: string; +} = {}) { + return { + generateChangelog: jest.fn(() => ({ + markdown: opts.markdown ?? '## [1.2.3] - 2024-01-01\n\n### Features\n\n- add feature\n', + groups: [{ title: 'Features', commits: [{ description: 'add feature', scope: null, breaking: false }] }], + })), + changelogConfig: { + version: '1.2.3', + date: '2024-01-01', + format: 'markdown' as const, + groupTitles: { feat: 'Features', fix: 'Bug Fixes' }, + excludeTypes: [] as string[], + includeNonConventional: false, + bumpPolicy: { feat: 'minor' as const, fix: 'patch' as const }, + }, + changelogFile: opts.changelogFile, + }; +} + +const baseOpts = { + semver: 'patch', + branch: 'fix-login', + push: false, + dryRun: false, + json: false, + verbose: false, +}; + +beforeEach(() => { + fs.readFileSync.mockReturnValue(JSON.stringify({ version: '1.2.2' })); + fs.existsSync.mockReturnValue(true); + mockCreatePR.mockReset(); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +// ── Arbitraries ───────────────────────────────────────────────────────────── + +/** Non-auto semver values */ +const arbNonAutoSemver = fc.constantFrom( + 'patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease', +); + +/** Bump types that auto-bump can produce */ +const arbBumpType = fc.constantFrom('major' as const, 'minor' as const, 'patch' as const); + +/** Safe alphanumeric string for preid */ +const arbPreid = fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, + { num: 10, build: (v) => String.fromCharCode(48 + v) }, + ), + { minLength: 1, maxLength: 10 }, +); + +/** Safe non-empty string for templates/changelog bodies */ +const arbNonEmptyString = fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, + { num: 10, build: (v) => String.fromCharCode(48 + v) }, + { num: 1, build: () => ' ' }, + { num: 1, build: () => '\n' }, + { num: 1, build: () => '-' }, + ), + { minLength: 1, maxLength: 100 }, +).filter((s) => s.trim().length > 0); + +/** Generates a changelog string with a specific number of lines */ +const arbChangelogLines = (minLines: number, maxLines: number) => + fc.integer({ min: minLines, max: maxLines }).chain((numLines) => + fc.array( + fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, + { num: 1, build: () => ' ' }, + { num: 1, build: () => '-' }, + ), + { minLength: 1, maxLength: 40 }, + ), + { minLength: numLines, maxLength: numLines }, + ).map((lines) => lines.join('\n')), + ); + +/** Safe file path for changelog.file */ +const arbChangelogFilePath = fc.constantFrom( + 'CHANGELOG.md', 'docs/CHANGELOG.md', 'CHANGES.md', 'changelog.txt', +); + +// ── Property 6 ────────────────────────────────────────────────────────────── + +describe('Property 6: Backward compatibility — non-auto semver does not call Commit_Analyzer', () => { + /** + * **Validates: Requirements 5.5** + * + * For any semver value from ['patch', 'minor', 'major', 'prepatch', + * 'preminor', 'premajor', 'prerelease'], pipeline SHALL NOT call + * analyzeBump() and SHALL use the passed semver value directly. + */ + test('non-auto semver values never invoke analyzeBump', async () => { + await fc.assert( + fc.asyncProperty(arbNonAutoSemver, async (semver) => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer(); + + const result = await runPipeline( + { ...baseOpts, semver }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer }, + ); + + expect(result.success).toBe(true); + expect(result.semver).toBe(semver); + expect(commitAnalyzer.analyzeBump).not.toHaveBeenCalled(); + expect(result.autoBump).toBeUndefined(); + }), + { numRuns: 50 }, + ); + }); +}); + +// ── Property 7 ────────────────────────────────────────────────────────────── + +describe('Property 7: Auto-bump with preid converts to prerelease modifier', () => { + /** + * **Validates: Requirements 5.7** + * + * For any detected bump (major, minor, patch) with --preid present, + * pipeline SHALL convert: major→premajor, minor→preminor, patch→prepatch. + */ + test('auto-bump + preid maps bump to correct prerelease type', async () => { + const preMap: Record = { + major: 'premajor', + minor: 'preminor', + patch: 'prepatch', + }; + + await fc.assert( + fc.asyncProperty(arbBumpType, arbPreid, async (bump, preid) => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer(bump); + + const result = await runPipeline( + { ...baseOpts, semver: 'auto', preid }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer }, + ); + + expect(result.success).toBe(true); + expect(result.semver).toBe(preMap[bump]); + + // Verify npm version command uses the prerelease modifier with --preid + const npmCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => cmd.includes('npm --no-git-tag-version version')); + expect(npmCmds.length).toBeGreaterThan(0); + expect(npmCmds[0]).toContain(preMap[bump]); + expect(npmCmds[0]).toContain(`--preid=${preid}`); + }), + { numRuns: 50 }, + ); + }); +}); + + +// ── Property 15 ───────────────────────────────────────────────────────────── + +describe('Property 15: Merging PR template and changelog via separator', () => { + /** + * **Validates: Requirements 9.3** + * + * For any non-empty template and non-empty changelog, the merged PR body + * SHALL contain template first, separator '---', then changelog. + * When template is empty, body equals changelog. + * When changelog is empty, body equals template. + */ + test('createPR receives changelogBody merged with template via separator', async () => { + await fc.assert( + fc.asyncProperty( + arbNonEmptyString, // template content + arbNonEmptyString, // changelog content + async (templateContent, changelogContent) => { + // Reset mock between iterations + mockCreatePR.mockReset(); + + // Setup: config with pr.template pointing to a file + const configWithTemplate = { + ...mockConfig, + git: { + ...mockConfig.git, + pr: { ...mockConfig.git.pr, template: 'pr-template.md' }, + }, + }; + + // Mock createPR to capture the deps passed to it + let capturedDeps: any = null; + mockCreatePR.mockImplementation(async (_cfg: any, _branch: string, _msg: string, _mode: string, deps: any) => { + capturedDeps = deps; + return { + url: 'https://github.com/user/repo/pull/1', + number: 1, + status: 'created' as const, + fallbackReason: null, + platform: 'github', + warnings: [], + }; + }); + + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer(); + const changelogGenerator = createMockChangelogGenerator({ + markdown: changelogContent, + }); + const prCreator = { + registry: {} as any, + httpClient: {} as any, + urlParser: {} as any, + resolveAuth: jest.fn(() => ({ token: 'test-token', method: 'token' as const })), + env: {}, + }; + + await runPipeline( + { ...baseOpts, semver: 'auto', push: true }, + { + executor, + config: configWithTemplate, + rollbackManager: rollback, + artifactChecker, + commitAnalyzer, + changelogGenerator, + prCreator, + }, + ); + + // The pipeline passes changelogBody to prCreator deps + expect(mockCreatePR).toHaveBeenCalledTimes(1); + expect(capturedDeps).toBeDefined(); + expect(capturedDeps.changelogBody).toBe(changelogContent); + }, + ), + { numRuns: 30 }, + ); + }); + + test('without changelog, no changelogBody in prCreator deps', async () => { + mockCreatePR.mockImplementation(async (_cfg: any, _branch: string, _msg: string, _mode: string, deps: any) => ({ + url: 'https://github.com/user/repo/pull/1', + number: 1, + status: 'created' as const, + fallbackReason: null, + platform: 'github', + warnings: [], + })); + + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + // semver=patch (non-auto) → no changelog generated + await runPipeline( + { ...baseOpts, semver: 'patch', push: true }, + { + executor, + config: mockConfig, + rollbackManager: rollback, + artifactChecker, + prCreator: { + registry: {} as any, + httpClient: {} as any, + urlParser: {} as any, + resolveAuth: jest.fn(() => ({ token: 'test-token', method: 'token' as const })), + env: {}, + }, + }, + ); + + expect(mockCreatePR).toHaveBeenCalledTimes(1); + const prDeps = mockCreatePR.mock.calls[0][4]; + expect(prDeps.changelogBody).toBeUndefined(); + }); +}); + +// ── Property 16 ───────────────────────────────────────────────────────────── + +describe('Property 16: Changelog preview in dry-run limited to 50 lines', () => { + /** + * **Validates: Requirements 9.5** + * + * For any changelog longer than 50 lines, changelogPreview in DryRunPlan + * SHALL contain exactly the first 50 lines. For changelog with N ≤ 50 lines, + * changelogPreview SHALL contain all N lines. + */ + test('changelogPreview is capped at 50 lines for long changelogs', async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 51, max: 120 }), + async (numLines) => { + const lines = Array.from({ length: numLines }, (_, i) => `line ${i + 1}`); + const longChangelog = lines.join('\n'); + + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer(); + const changelogGenerator = createMockChangelogGenerator({ markdown: longChangelog }); + + const plan = await runPipeline( + { ...baseOpts, semver: 'auto', dryRun: true }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer, changelogGenerator }, + ); + + expect(plan.dryRun).toBe(true); + expect(plan.changelogPreview).toBeDefined(); + const previewLines = plan.changelogPreview!.split('\n'); + expect(previewLines).toHaveLength(50); + // Preview is a prefix of the original + expect(plan.changelogPreview).toBe(lines.slice(0, 50).join('\n')); + }, + ), + { numRuns: 30 }, + ); + }); + + test('changelogPreview contains all lines for short changelogs (≤ 50 lines)', async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 50 }), + async (numLines) => { + const lines = Array.from({ length: numLines }, (_, i) => `line ${i + 1}`); + const shortChangelog = lines.join('\n'); + + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer(); + const changelogGenerator = createMockChangelogGenerator({ markdown: shortChangelog }); + + const plan = await runPipeline( + { ...baseOpts, semver: 'auto', dryRun: true }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer, changelogGenerator }, + ); + + expect(plan.dryRun).toBe(true); + expect(plan.changelogPreview).toBeDefined(); + const previewLines = plan.changelogPreview!.split('\n'); + expect(previewLines).toHaveLength(numLines); + expect(plan.changelogPreview).toBe(shortChangelog); + }, + ), + { numRuns: 30 }, + ); + }); +}); + +// ── Property 17 ───────────────────────────────────────────────────────────── + +describe('Property 17: DryRunPlan and PipelineResult contain autoBump when --semver=auto', () => { + /** + * **Validates: Requirements 5.4, 5.8, 13.1, 13.2, 13.3, 13.4** + * + * For any pipeline result with --semver=auto, autoBump SHALL be present + * with: detectedBump (major|minor|patch), totalCommits (≥0), + * breakingChanges (≥0, ≤totalCommits), commitsByType (object), + * range ({from, to}). When --semver is not 'auto', autoBump SHALL be absent. + */ + test('PipelineResult includes autoBump with correct structure for any bump type', async () => { + await fc.assert( + fc.asyncProperty(arbBumpType, async (bump) => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer(bump); + + const result = await runPipeline( + { ...baseOpts, semver: 'auto' }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer }, + ); + + expect(result.success).toBe(true); + expect(result.autoBump).toBeDefined(); + + const ab = result.autoBump; + expect(['major', 'minor', 'patch']).toContain(ab.detectedBump); + expect(ab.detectedBump).toBe(bump); + expect(typeof ab.totalCommits).toBe('number'); + expect(ab.totalCommits).toBeGreaterThanOrEqual(0); + expect(typeof ab.breakingChanges).toBe('number'); + expect(ab.breakingChanges).toBeGreaterThanOrEqual(0); + expect(ab.breakingChanges).toBeLessThanOrEqual(ab.totalCommits); + expect(typeof ab.commitsByType).toBe('object'); + expect(ab.range).toBeDefined(); + expect(typeof ab.range.from).toBe('string'); + expect(typeof ab.range.to).toBe('string'); + }), + { numRuns: 30 }, + ); + }); + + test('DryRunPlan includes autoBump with correct structure for any bump type', async () => { + await fc.assert( + fc.asyncProperty(arbBumpType, async (bump) => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer(bump); + + const plan = await runPipeline( + { ...baseOpts, semver: 'auto', dryRun: true }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer }, + ); + + expect(plan.dryRun).toBe(true); + expect(plan.autoBump).toBeDefined(); + + const ab = plan.autoBump; + expect(['major', 'minor', 'patch']).toContain(ab.detectedBump); + expect(ab.detectedBump).toBe(bump); + expect(typeof ab.totalCommits).toBe('number'); + expect(ab.totalCommits).toBeGreaterThanOrEqual(0); + expect(typeof ab.breakingChanges).toBe('number'); + expect(ab.breakingChanges).toBeGreaterThanOrEqual(0); + expect(ab.breakingChanges).toBeLessThanOrEqual(ab.totalCommits); + expect(typeof ab.commitsByType).toBe('object'); + expect(ab.range).toBeDefined(); + expect(typeof ab.range.from).toBe('string'); + expect(typeof ab.range.to).toBe('string'); + }), + { numRuns: 30 }, + ); + }); + + test('non-auto semver never includes autoBump in result', async () => { + await fc.assert( + fc.asyncProperty(arbNonAutoSemver, async (semver) => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const result = await runPipeline( + { ...baseOpts, semver }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker }, + ); + + expect(result.success).toBe(true); + expect(result.autoBump).toBeUndefined(); + }), + { numRuns: 50 }, + ); + }); +}); + + +// ── Property 18 ───────────────────────────────────────────────────────────── + +describe('Property 18: Dry-run includes changelog write step when changelog.file is configured', () => { + /** + * **Validates: Requirements 10.6** + * + * For any configuration with non-empty changelog.file and --semver=auto, + * DryRunPlan.steps SHALL contain a step for writing changelog to file + * and a step for `git add `. When changelog.file is absent, + * these steps SHALL not appear. + */ + test('dry-run steps include changelog write and git add when changelog.file is set', async () => { + await fc.assert( + fc.asyncProperty(arbChangelogFilePath, async (filePath) => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer(); + const changelogGenerator = createMockChangelogGenerator({ changelogFile: filePath }); + + const plan = await runPipeline( + { ...baseOpts, semver: 'auto', dryRun: true }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer, changelogGenerator }, + ); + + expect(plan.dryRun).toBe(true); + // Steps should include changelog write + const writeStep = plan.steps.find((s: string) => s.includes('write changelog') && s.includes(filePath)); + expect(writeStep).toBeDefined(); + // Steps should include git add for the changelog file + const addStep = plan.steps.find((s: string) => s.includes('git add') && s.includes(filePath)); + expect(addStep).toBeDefined(); + // No actual file write in dry-run + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }), + { numRuns: 30 }, + ); + }); + + test('dry-run steps do NOT include changelog write when changelog.file is absent', async () => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer(); + const changelogGenerator = createMockChangelogGenerator(); // no changelogFile + + const plan = await runPipeline( + { ...baseOpts, semver: 'auto', dryRun: true }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer, changelogGenerator }, + ); + + expect(plan.dryRun).toBe(true); + const writeSteps = plan.steps.filter((s: string) => s.includes('write changelog')); + expect(writeSteps).toHaveLength(0); + const addSteps = plan.steps.filter((s: string) => s.includes('git add')); + expect(addSteps).toHaveLength(0); + }); +}); + +// ── Property 21 ───────────────────────────────────────────────────────────── + +describe('Property 21: Reporter formats NO_CONVENTIONAL_COMMITS error with details', () => { + /** + * **Validates: Requirements 12.4** + * + * For any VersioningsError with code NO_CONVENTIONAL_COMMITS (11) and + * details containing range and totalCommits, reporter SHALL include in + * output: the commit range, total commits count, and a recommendation + * to use --semver=patch|minor|major or configure fallbackBump. + */ + test('JSON reporter includes error code, range, totalCommits, and recommendation', () => { + fc.assert( + fc.property( + fc.nat({ max: 500 }), // totalCommits + fc.tuple( + fc.stringOf(fc.mapToConstant({ num: 26, build: (v) => String.fromCharCode(97 + v) }), { minLength: 1, maxLength: 10 }), + fc.constantFrom('HEAD', 'v2.0.0', 'abc1234'), + ), + (totalCommits, [from, to]) => { + const error = new VersioningsError( + EXIT_CODES.NO_CONVENTIONAL_COMMITS, + `No conventional commits found in range ${from}..${to}`, + { + totalCommits, + range: { from, to }, + recommendation: 'Use --semver=patch|minor|major or set conventionalCommits.fallbackBump', + }, + ); + + // Test JSON mode + const jsonReporter = createReporter({ json: true }); + const jsonOutput = jsonReporter.reportError(error); + const parsed = JSON.parse(jsonOutput); + + expect(parsed.success).toBe(false); + expect(parsed.exitCode).toBe(11); + expect(parsed.error.code).toBe('NO_CONVENTIONAL_COMMITS'); + expect(parsed.error.message).toContain(from); + expect(parsed.error.message).toContain(to); + expect(parsed.error.details).toBeDefined(); + expect(parsed.error.details.totalCommits).toBe(totalCommits); + expect(parsed.error.details.range).toEqual({ from, to }); + expect(parsed.error.details.recommendation).toBeDefined(); + }, + ), + { numRuns: 50 }, + ); + }); + + test('human-readable reporter includes range, totalCommits, and recommendation', () => { + fc.assert( + fc.property( + fc.nat({ max: 500 }), + fc.tuple( + fc.stringOf(fc.mapToConstant({ num: 26, build: (v) => String.fromCharCode(97 + v) }), { minLength: 1, maxLength: 10 }), + fc.constantFrom('HEAD', 'v2.0.0', 'abc1234'), + ), + (totalCommits, [from, to]) => { + const error = new VersioningsError( + EXIT_CODES.NO_CONVENTIONAL_COMMITS, + `No conventional commits found in range ${from}..${to}`, + { + totalCommits, + range: { from, to }, + recommendation: 'Use --semver=patch|minor|major or set conventionalCommits.fallbackBump', + }, + ); + + const humanReporter = createReporter({ json: false }); + const output = humanReporter.reportError(error); + + // Should contain the error code name + expect(output).toContain('NO_CONVENTIONAL_COMMITS'); + // Should contain exit code 11 + expect(output).toContain('11'); + // Should contain the range info + expect(output).toContain(from); + expect(output).toContain(to); + // Should contain totalCommits + expect(output).toContain(String(totalCommits)); + // Should contain recommendation + expect(output).toContain('recommendation'); + }, + ), + { numRuns: 50 }, + ); + }); +}); diff --git a/__tests__/properties/versioning/changelog.file.property.test.ts b/__tests__/properties/versioning/changelog.file.property.test.ts new file mode 100644 index 0000000..c230cb7 --- /dev/null +++ b/__tests__/properties/versioning/changelog.file.property.test.ts @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: version-intelligence-release-narrative +// Property 13: Prepend changelog to existing file preserves content + +import * as fc from 'fast-check'; +import { prependChangelogContent } from '../../../src/core/pipeline'; + +// ── Arbitraries ───────────────────────────────────────────────────────────── + +/** Safe printable string without null bytes */ +const arbSafeLine = fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, + { num: 26, build: (v) => String.fromCharCode(65 + v) }, + { num: 10, build: (v) => String.fromCharCode(48 + v) }, + { num: 1, build: () => ' ' }, + { num: 1, build: () => '-' }, + { num: 1, build: () => '#' }, + { num: 1, build: () => '.' }, + ), + { minLength: 1, maxLength: 60 }, +); + +/** Generates multi-line content (simulating existing file body) */ +const arbMultiLineContent = fc.array(arbSafeLine, { minLength: 1, maxLength: 10 }) + .map((lines) => lines.join('\n')); + +/** Generates existing file content WITH `# Changelog` header */ +const arbExistingWithHeader = arbMultiLineContent.map( + (body) => `# Changelog\n\n${body}\n`, +); + +/** Generates existing file content WITHOUT `# Changelog` header */ +const arbExistingWithoutHeader = arbMultiLineContent.filter( + (s) => !s.startsWith('# Changelog'), +); + +/** Generates a new changelog section (like what generateChangelog produces) */ +const arbNewSection = fc.tuple( + fc.stringOf(fc.mapToConstant({ num: 10, build: (v) => String.fromCharCode(48 + v) }, { num: 1, build: () => '.' }), { minLength: 3, maxLength: 11 }), + fc.array(arbSafeLine, { minLength: 1, maxLength: 5 }), +).map(([version, items]) => { + const lines = [`## [${version}] - 2024-01-15`, '', '### Features', '']; + for (const item of items) { + lines.push(`- ${item}`); + } + lines.push(''); + return lines.join('\n'); +}); + +// ── Property 13 ───────────────────────────────────────────────────────────── + +describe('Property 13: Prepend changelog to existing file preserves content', () => { + /** + * **Validates: Requirements 7.5, 10.2** + * + * For any existing file content and any new changelog section, the prepend + * operation SHALL: + * (a) preserve all existing file content + * (b) insert new section before existing content (after `# Changelog` header if present) + * (c) not duplicate the `# Changelog` header + */ + + test('file with # Changelog header: new section inserted after header, existing body preserved', () => { + fc.assert( + fc.property(arbExistingWithHeader, arbNewSection, (existing, newSection) => { + const result = prependChangelogContent(existing, newSection); + + // (a) Existing body content (after header line) is preserved in result + const headerEnd = existing.indexOf('\n'); + const existingBody = existing.slice(headerEnd + 1); + expect(result).toContain(existingBody); + + // (b) New section appears in result + expect(result).toContain(newSection); + + // (c) `# Changelog` header appears exactly once + const headerCount = (result.match(/^# Changelog$/gm) || []).length; + expect(headerCount).toBe(1); + + // Result starts with the header + expect(result.startsWith('# Changelog')).toBe(true); + + // New section appears before existing body + const newSectionIdx = result.indexOf(newSection); + const existingBodyIdx = result.indexOf(existingBody); + expect(newSectionIdx).toBeLessThan(existingBodyIdx); + }), + { numRuns: 100 }, + ); + }); + + test('file without # Changelog header: new section prepended before existing content', () => { + fc.assert( + fc.property(arbExistingWithoutHeader, arbNewSection, (existing, newSection) => { + const result = prependChangelogContent(existing, newSection); + + // (a) All existing content is preserved + expect(result).toContain(existing); + + // (b) New section appears in result + expect(result).toContain(newSection); + + // New section appears before existing content + const newSectionIdx = result.indexOf(newSection); + const existingIdx = result.indexOf(existing); + expect(newSectionIdx).toBeLessThan(existingIdx); + }), + { numRuns: 100 }, + ); + }); + + test('no existing file (null): creates content with # Changelog header and new section', () => { + fc.assert( + fc.property(arbNewSection, (newSection) => { + const result = prependChangelogContent(null, newSection); + + // Starts with # Changelog header + expect(result.startsWith('# Changelog')).toBe(true); + + // Contains the new section + expect(result).toContain(newSection); + + // Header appears exactly once + const headerCount = (result.match(/^# Changelog$/gm) || []).length; + expect(headerCount).toBe(1); + }), + { numRuns: 100 }, + ); + }); + + test('file with only # Changelog header (no trailing newline): new section appended correctly', () => { + fc.assert( + fc.property(arbNewSection, (newSection) => { + const existing = '# Changelog'; + const result = prependChangelogContent(existing, newSection); + + // Starts with header + expect(result.startsWith('# Changelog')).toBe(true); + + // Contains new section + expect(result).toContain(newSection); + + // Header appears exactly once + const headerCount = (result.match(/^# Changelog$/gm) || []).length; + expect(headerCount).toBe(1); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/versioning/changelog.generator.property.test.ts b/__tests__/properties/versioning/changelog.generator.property.test.ts new file mode 100644 index 0000000..1091414 --- /dev/null +++ b/__tests__/properties/versioning/changelog.generator.property.test.ts @@ -0,0 +1,557 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: version-intelligence-release-narrative +// Property 8: Changelog contains header and correctly formatted commits +// Property 9: Changelog groups commits in defined order with groupTitles +// Property 10: Changelog excludes commits with none types and excludeTypes +// Property 11: Changelog includes non-conventional commits when includeNonConventional=true +// Property 12: Difference between Markdown and plain text formats +// Property 14: JSON output of changelog contains required fields + +import * as fc from 'fast-check'; +import { + generateChangelog, + DEFAULT_GROUP_TITLES, +} from '../../../src/versioning/changelog.generator'; +import type { + ChangelogOpts, +} from '../../../src/versioning/changelog.generator'; +import type { + CommitWithHash, + ConventionalCommit, + InvalidCommit, +} from '../../../src/versioning/commit.parser'; +import { DEFAULT_BUMP_POLICY } from '../../../src/versioning/commit.analyzer'; +import type { BumpPolicy, BumpLevel } from '../../../src/versioning/commit.analyzer'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +let hashCounter = 0; + +function nextHash(): string { + hashCounter++; + return hashCounter.toString(16).padStart(40, '0'); +} + +function makeConventional( + overrides: Partial> & { type: string; description: string }, +): CommitWithHash { + const cc: ConventionalCommit = { + valid: true, + type: overrides.type, + scope: overrides.scope ?? null, + description: overrides.description, + body: overrides.body ?? null, + footers: overrides.footers ?? [], + breaking: overrides.breaking ?? false, + rawMessage: overrides.rawMessage ?? `${overrides.type}: ${overrides.description}`, + }; + return { hash: nextHash(), parsed: cc }; +} + +function makeInvalid(rawMessage: string): CommitWithHash { + const inv: InvalidCommit = { valid: false, rawMessage }; + return { hash: nextHash(), parsed: inv }; +} + +function defaultOpts(overrides?: Partial): ChangelogOpts { + return { + version: '1.0.0', + date: '2024-01-15', + format: 'markdown', + groupTitles: { ...DEFAULT_GROUP_TITLES }, + excludeTypes: [], + includeNonConventional: false, + bumpPolicy: { ...DEFAULT_BUMP_POLICY }, + ...overrides, + }; +} + +beforeEach(() => { + hashCounter = 0; +}); + +// ── Arbitraries ───────────────────────────────────────────────────────────── + +/** Safe alphanumeric string for descriptions/scopes (no newlines, no markdown control) */ +const arbSafeString = fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, // a-z + { num: 10, build: (v) => String.fromCharCode(48 + v) }, // 0-9 + { num: 1, build: () => ' ' }, + { num: 1, build: () => '-' }, + ), + { minLength: 1, maxLength: 30 }, +).map((s) => s.trim()).filter((s) => s.length > 0); + +/** Standard conventional commit types */ +const arbConventionalType = fc.constantFrom( + 'feat', 'fix', 'chore', 'docs', 'style', 'refactor', + 'perf', 'test', 'build', 'ci', 'revert', +); + +/** Types that produce actual changelog entries (not mapped to none by default) */ +const arbVisibleType = fc.constantFrom('feat', 'fix', 'perf', 'revert'); + +/** Types mapped to none by default policy */ +const arbNoneType = fc.constantFrom('chore', 'docs', 'style', 'refactor', 'test', 'build', 'ci'); + +/** Scope: null or a safe string */ +const arbScope = fc.oneof( + fc.constant(null as string | null), + fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, + { num: 10, build: (v) => String.fromCharCode(48 + v) }, + ), + { minLength: 1, maxLength: 10 }, + ), +); + +/** Version string */ +const arbVersion = fc.oneof( + fc.constant(null as string | null), + fc.tuple( + fc.integer({ min: 0, max: 99 }), + fc.integer({ min: 0, max: 99 }), + fc.integer({ min: 0, max: 99 }), + ).map(([a, b, c]) => `${a}.${b}.${c}`), +); + +/** Date string YYYY-MM-DD */ +const arbDate = fc.date({ min: new Date('2020-01-01'), max: new Date('2030-12-31') }) + .map((d) => d.toISOString().slice(0, 10)); + +/** Generate a CommitWithHash with a conventional commit */ +const arbConventionalCommitWithHash = fc.tuple( + arbConventionalType, + arbScope, + arbSafeString, + fc.boolean(), // breaking +).map(([type, scope, desc, breaking]) => + makeConventional({ type, scope, description: desc, breaking }), +); + +/** Generate a CommitWithHash with a visible (non-none) conventional commit */ +const arbVisibleCommitWithHash = fc.tuple( + arbVisibleType, + arbScope, + arbSafeString, +).map(([type, scope, desc]) => + makeConventional({ type, scope, description: desc }), +); + +/** Generate a non-conventional (invalid) commit */ +const arbInvalidCommitWithHash = arbSafeString.map((msg) => makeInvalid(msg)); + +// ── Known group ordering ──────────────────────────────────────────────────── + +const KNOWN_ORDER = ['breaking', 'feat', 'fix', 'perf', 'revert']; + + +// ── Property 8: Changelog contains header and correctly formatted commits ─── + +describe('Property 8: Changelog contains header and correctly formatted commits', () => { + /** + * **Validates: Requirements 6.1, 6.3, 6.4** + * + * For any non-empty array of ConventionalCommit, for any version and date, + * generateChangelog() returns a string starting with the header + * `## [] - `. Each included commit is formatted as + * `- ` (no scope) or `- ()` (with scope). + * The result string is non-empty. + */ + test('changelog has correct header and commit formatting', () => { + fc.assert( + fc.property( + fc.array(arbVisibleCommitWithHash, { minLength: 1, maxLength: 20 }), + arbVersion, + arbDate, + (commits, version, date) => { + const opts = defaultOpts({ version, date }); + const result = generateChangelog(commits, opts); + + // Result is non-empty + expect(result.markdown.length).toBeGreaterThan(0); + + // Header contains version and date + const versionStr = version || 'Unreleased'; + expect(result.markdown).toContain(`[${versionStr}] - ${date}`); + + // In markdown format, header starts with ## + expect(result.markdown).toMatch(/^## \[/); + + // Each commit in groups is formatted correctly + for (const group of result.groups) { + for (const commit of group.commits) { + if (commit.scope) { + expect(result.markdown).toContain( + `- ${commit.description} (${commit.scope})`, + ); + } else { + expect(result.markdown).toContain(`- ${commit.description}`); + } + } + } + }, + ), + { numRuns: 100 }, + ); + }); +}); + +// ── Property 9: Changelog groups commits in defined order with groupTitles ── + +describe('Property 9: Changelog groups commits in defined order with groupTitles', () => { + /** + * **Validates: Requirements 6.2, 8.3** + * + * For any array of commits with various types, generateChangelog() groups + * them in order: Breaking Changes first, then feat, fix, perf, revert, + * remaining types in alphabetical order. For any custom groupTitles, + * group titles match the custom values instead of defaults. + */ + test('groups appear in defined order: breaking, feat, fix, perf, revert, then alphabetical', () => { + fc.assert( + fc.property( + // Generate at least one breaking and one of each visible type + fc.tuple( + fc.array( + fc.tuple(arbVisibleType, arbScope, arbSafeString).map( + ([type, scope, desc]) => makeConventional({ type, scope, description: desc }), + ), + { minLength: 1, maxLength: 10 }, + ), + fc.array( + fc.tuple(arbVisibleType, arbScope, arbSafeString).map( + ([type, scope, desc]) => makeConventional({ type, scope, description: desc, breaking: true }), + ), + { minLength: 0, maxLength: 3 }, + ), + ), + ([nonBreaking, breaking]) => { + const commits = [...breaking, ...nonBreaking]; + const result = generateChangelog(commits, defaultOpts()); + + const groupTitles = result.groups.map((g) => g.title); + + // Determine expected order based on what's present + const expectedOrder: string[] = []; + const breakingTitle = DEFAULT_GROUP_TITLES.breaking; + const knownTitles: Record = { + feat: DEFAULT_GROUP_TITLES.feat, + fix: DEFAULT_GROUP_TITLES.fix, + perf: DEFAULT_GROUP_TITLES.perf, + revert: DEFAULT_GROUP_TITLES.revert, + }; + + // Breaking first if present + if (groupTitles.includes(breakingTitle)) { + expectedOrder.push(breakingTitle); + } + + // Known types in order + for (const type of ['feat', 'fix', 'perf', 'revert']) { + const title = knownTitles[type]; + if (groupTitles.includes(title)) { + expectedOrder.push(title); + } + } + + // Remaining (alphabetical) — anything not in expectedOrder + const remaining = groupTitles.filter((t) => !expectedOrder.includes(t)); + const sortedRemaining = [...remaining].sort(); + expectedOrder.push(...sortedRemaining); + + expect(groupTitles).toEqual(expectedOrder); + + // Total commits in groups should not exceed input commits + const totalInGroups = result.groups.reduce( + (sum, g) => sum + g.commits.length, 0, + ); + expect(totalInGroups).toBeLessThanOrEqual(commits.length); + }, + ), + { numRuns: 100 }, + ); + }); + + test('custom groupTitles override default titles', () => { + fc.assert( + fc.property( + fc.array(arbVisibleCommitWithHash, { minLength: 1, maxLength: 10 }), + fc.record({ + feat: arbSafeString, + fix: arbSafeString, + perf: arbSafeString, + revert: arbSafeString, + breaking: arbSafeString, + }), + (commits, customTitles) => { + const opts = defaultOpts({ groupTitles: customTitles }); + const result = generateChangelog(commits, opts); + + // Every group title should come from customTitles (or the type name for unknown types) + for (const group of result.groups) { + const matchesCustom = Object.values(customTitles).includes(group.title); + const isTypeName = !matchesCustom; // fallback to type name for unknown types + expect(matchesCustom || isTypeName).toBe(true); + } + }, + ), + { numRuns: 100 }, + ); + }); +}); + +// ── Property 10: Changelog excludes commits with none types and excludeTypes ─ + +describe('Property 10: Changelog excludes commits with none types and excludeTypes', () => { + /** + * **Validates: Requirements 6.5, 8.4** + * + * For any array of commits and any BumpPolicy, commits with types mapped + * to 'none' do not appear in the changelog output. For any excludeTypes + * array, commits with those types are excluded regardless of BumpPolicy. + */ + test('commits with types mapped to none in bumpPolicy are excluded', () => { + fc.assert( + fc.property( + // Mix of visible and none-type commits + fc.tuple( + fc.array(arbVisibleCommitWithHash, { minLength: 1, maxLength: 5 }), + fc.array( + fc.tuple(arbNoneType, arbScope, arbSafeString).map( + ([type, scope, desc]) => makeConventional({ type, scope, description: desc }), + ), + { minLength: 1, maxLength: 5 }, + ), + ), + ([visible, noneTyped]) => { + const commits = [...visible, ...noneTyped]; + const result = generateChangelog(commits, defaultOpts()); + + // None-typed commit types should not appear as group titles + const groupTitles = result.groups.map((g) => g.title); + const noneTypes = new Set(noneTyped.map((c) => (c.parsed as ConventionalCommit).type)); + + for (const noneType of noneTypes) { + // The none-type should not have its own group (neither by title nor by type name) + const title = DEFAULT_GROUP_TITLES[noneType] || noneType; + expect(groupTitles).not.toContain(title); + } + + // Total commits in groups should be <= visible commits count + // (none-typed commits are excluded) + const totalInGroups = result.groups.reduce( + (sum, g) => sum + g.commits.length, 0, + ); + expect(totalInGroups).toBeLessThanOrEqual(visible.length); + }, + ), + { numRuns: 100 }, + ); + }); + + test('excludeTypes excludes specified types regardless of bumpPolicy', () => { + fc.assert( + fc.property( + fc.array(arbVisibleCommitWithHash, { minLength: 2, maxLength: 10 }), + // Pick 1-2 types to exclude from the visible types + fc.subarray(['feat', 'fix', 'perf', 'revert'] as const, { minLength: 1, maxLength: 2 }), + (commits, excludeTypes) => { + const opts = defaultOpts({ excludeTypes: [...excludeTypes] }); + const result = generateChangelog(commits, opts); + + // Excluded types should not appear in groups + // (group titles for excluded types should be absent) + const excludedTitles = excludeTypes.map( + (t) => DEFAULT_GROUP_TITLES[t] || t, + ); + + for (const group of result.groups) { + expect(excludedTitles).not.toContain(group.title); + } + }, + ), + { numRuns: 100 }, + ); + }); +}); + +// ── Property 11: Changelog includes non-conventional commits ──────────────── + +describe('Property 11: Changelog includes non-conventional commits when includeNonConventional=true', () => { + /** + * **Validates: Requirements 6.6** + * + * For any array containing commits with valid: false, when + * includeNonConventional is true, those commits appear in the + * "Other Changes" group. When false, they are excluded. + */ + test('non-conventional commits included in Other Changes when flag is true', () => { + fc.assert( + fc.property( + fc.array(arbVisibleCommitWithHash, { minLength: 0, maxLength: 5 }), + fc.array(arbInvalidCommitWithHash, { minLength: 1, maxLength: 5 }), + (conventional, invalid) => { + const commits = [...conventional, ...invalid]; + + // With includeNonConventional=true + const resultInclude = generateChangelog( + commits, + defaultOpts({ includeNonConventional: true }), + ); + const otherGroup = resultInclude.groups.find( + (g) => g.title === 'Other Changes', + ); + expect(otherGroup).toBeDefined(); + expect(otherGroup!.commits).toHaveLength(invalid.length); + + // Other Changes is the last group + if (resultInclude.groups.length > 1) { + expect( + resultInclude.groups[resultInclude.groups.length - 1].title, + ).toBe('Other Changes'); + } + }, + ), + { numRuns: 100 }, + ); + }); + + test('non-conventional commits excluded when flag is false', () => { + fc.assert( + fc.property( + fc.array(arbVisibleCommitWithHash, { minLength: 1, maxLength: 5 }), + fc.array(arbInvalidCommitWithHash, { minLength: 1, maxLength: 5 }), + (conventional, invalid) => { + const commits = [...conventional, ...invalid]; + + const resultExclude = generateChangelog( + commits, + defaultOpts({ includeNonConventional: false }), + ); + const otherGroup = resultExclude.groups.find( + (g) => g.title === 'Other Changes', + ); + expect(otherGroup).toBeUndefined(); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +// ── Property 12: Difference between Markdown and plain text formats ───────── + +describe('Property 12: Difference between Markdown and plain text formats', () => { + /** + * **Validates: Requirements 6.7** + * + * For any array of commits, markdown format output contains `##` and `###` + * markers. Plain text format output does not contain `##` or `###` markers. + * Both formats produce the same groups structure. + */ + test('markdown has ## markers, plain does not; groups are equivalent', () => { + fc.assert( + fc.property( + fc.array(arbVisibleCommitWithHash, { minLength: 1, maxLength: 10 }), + arbVersion, + arbDate, + (commits, version, date) => { + const baseOpts = defaultOpts({ version, date }); + + const mdResult = generateChangelog(commits, { + ...baseOpts, + format: 'markdown', + }); + const plainResult = generateChangelog(commits, { + ...baseOpts, + format: 'plain', + }); + + // Markdown contains ## markers + expect(mdResult.markdown).toMatch(/^## \[/m); + if (mdResult.groups.length > 0) { + expect(mdResult.markdown).toMatch(/^### /m); + } + + // Plain does not contain ## or ### markers + expect(plainResult.markdown).not.toMatch(/^## /m); + expect(plainResult.markdown).not.toMatch(/^### /m); + + // Both contain the version/date header content + const versionStr = version || 'Unreleased'; + expect(plainResult.markdown).toContain(`[${versionStr}] - ${date}`); + + // Groups structure is identical + expect(mdResult.groups.length).toBe(plainResult.groups.length); + for (let i = 0; i < mdResult.groups.length; i++) { + expect(mdResult.groups[i].title).toBe(plainResult.groups[i].title); + expect(mdResult.groups[i].commits.length).toBe( + plainResult.groups[i].commits.length, + ); + } + }, + ), + { numRuns: 100 }, + ); + }); +}); + +// ── Property 14: JSON output of changelog contains required fields ────────── + +describe('Property 14: JSON output of changelog contains required fields', () => { + /** + * **Validates: Requirements 7.7, 13.5** + * + * For any result of generateChangelog(), the ChangelogResult structure + * contains: markdown (string), groups (array of objects with title and + * commits). Each group has a string title and an array of commits. + * The result is JSON-serializable. + */ + test('ChangelogResult is JSON-serializable with required fields', () => { + fc.assert( + fc.property( + fc.array(arbConventionalCommitWithHash, { minLength: 0, maxLength: 15 }), + arbVersion, + arbDate, + fc.constantFrom('markdown' as const, 'plain' as const), + fc.boolean(), // includeNonConventional + (commits, version, date, format, includeNonConventional) => { + const opts = defaultOpts({ version, date, format, includeNonConventional }); + const result = generateChangelog(commits, opts); + + // JSON-serializable (no circular refs, no functions) + const json = JSON.stringify(result); + const parsed = JSON.parse(json); + + // Required fields exist + expect(typeof parsed.markdown).toBe('string'); + expect(Array.isArray(parsed.groups)).toBe(true); + + // Each group has required structure + for (const group of parsed.groups) { + expect(typeof group.title).toBe('string'); + expect(group.title.length).toBeGreaterThan(0); + expect(Array.isArray(group.commits)).toBe(true); + + for (const commit of group.commits) { + expect(typeof commit.description).toBe('string'); + expect(commit.description.length).toBeGreaterThan(0); + expect( + commit.scope === null || typeof commit.scope === 'string', + ).toBe(true); + expect(typeof commit.breaking).toBe('boolean'); + } + } + + // markdown field is non-empty (always has at least a header) + expect(parsed.markdown.length).toBeGreaterThan(0); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/versioning/commit.analyzer.property.test.ts b/__tests__/properties/versioning/commit.analyzer.property.test.ts new file mode 100644 index 0000000..e32d3bc --- /dev/null +++ b/__tests__/properties/versioning/commit.analyzer.property.test.ts @@ -0,0 +1,486 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: version-intelligence-release-narrative +// Property 3: Maximum bump determination with BumpPolicy +// Property 4: Breaking change always leads to major +// Property 5: Fallback when no conventional commits + +import * as fc from 'fast-check'; +import { + analyzeBump, + DEFAULT_BUMP_POLICY, +} from '../../../src/versioning/commit.analyzer'; +import type { BumpPolicy, BumpLevel, CommitAnalyzerDeps } from '../../../src/versioning/commit.analyzer'; +import type { Executor, ExecutorResult } from '../../../src/core/executor'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; +import { COMMIT_SEPARATOR } from '../../../src/versioning/commit.parser'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function ok(stdout: string): ExecutorResult { + const trimmed = stdout.trim(); + const lines = trimmed ? trimmed.split('\n').filter(Boolean) : []; + return { stdout: trimmed, lines }; +} + +/** Builds a git log chunk for a single commit */ +function logEntry(hash: string, message: string): string { + return `${hash}\n${message}\n\n${COMMIT_SEPARATOR}\n`; +} + +/** + * Creates a mock executor that returns a tag and git log output. + * The tag lookup always returns 'v1.0.0' so analyzeBump uses tag..HEAD range. + */ +function createTagAndLogExecutor(gitLogOutput: string): Executor { + return { + run(cmd: string): Promise { + if (cmd.includes('git tag --list')) { + return Promise.resolve(ok('v1.0.0')); + } + if (cmd.includes('git log')) { + return Promise.resolve(ok(gitLogOutput)); + } + return Promise.reject(new Error(`Unexpected command: ${cmd}`)); + }, + }; +} + +function makeDeps( + executor: Executor, + bumpPolicy: BumpPolicy, + fallbackBump: 'major' | 'minor' | 'patch' | null, +): CommitAnalyzerDeps { + return { executor, bumpPolicy, fallbackBump }; +} + +// ── Arbitraries ───────────────────────────────────────────────────────────── + +/** Safe alphanumeric string for commit descriptions */ +const arbSafeString = fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, // a-z + { num: 10, build: (v) => String.fromCharCode(48 + v) }, // 0-9 + { num: 1, build: () => ' ' }, + { num: 1, build: () => '-' }, + ), + { minLength: 1, maxLength: 40 }, +).map((s) => s.trim()).filter((s) => s.length > 0); + +/** Standard conventional commit types */ +const arbConventionalType = fc.constantFrom( + 'feat', 'fix', 'chore', 'docs', 'style', 'refactor', + 'perf', 'test', 'build', 'ci', 'revert', +); + +/** Bump level for policy generation */ +const arbBumpLevel: fc.Arbitrary = fc.constantFrom( + 'major' as BumpLevel, + 'minor' as BumpLevel, + 'patch' as BumpLevel, + 'none' as BumpLevel, +); + +/** Non-none bump level for fallback */ +const arbNonNoneBump: fc.Arbitrary<'major' | 'minor' | 'patch'> = fc.constantFrom( + 'major' as const, + 'minor' as const, + 'patch' as const, +); + +/** 40-char hex hash */ +const arbHash = fc.hexaString({ minLength: 40, maxLength: 40 }); + +/** Scope: null or a safe string */ +const arbScope = fc.oneof( + fc.constant(null), + fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, + { num: 10, build: (v) => String.fromCharCode(48 + v) }, + { num: 1, build: () => '-' }, + ), + { minLength: 1, maxLength: 15 }, + ), +); + +/** + * Generates a conventional commit message string (non-breaking). + * Format: type[(scope)]: description + */ +const arbConventionalMessage = fc.tuple( + arbConventionalType, + arbScope, + arbSafeString, +).map(([type, scope, desc]) => { + const scopePart = scope ? `(${scope})` : ''; + return `${type}${scopePart}: ${desc}`; +}); + +/** + * Generates a breaking conventional commit message. + * Uses either ! bang or BREAKING CHANGE footer. + */ +const arbBreakingMessage = fc.tuple( + arbConventionalType, + arbScope, + arbSafeString, + fc.constantFrom('bang', 'footer') as fc.Arbitrary<'bang' | 'footer'>, +).map(([type, scope, desc, via]) => { + const scopePart = scope ? `(${scope})` : ''; + if (via === 'bang') { + return `${type}${scopePart}!: ${desc}`; + } + return `${type}${scopePart}: ${desc}\n\nBREAKING CHANGE: ${desc}`; +}); + +/** + * Generates a non-conventional commit message (no CC format). + */ +const arbNonConventionalMessage = arbSafeString.filter( + (s) => !/^\w+(\([^)]*\))?!?\s*:\s*.+$/.test(s), +); + +/** + * Generates a git log output string from an array of (hash, message) pairs. + */ +function buildGitLog(entries: Array<{ hash: string; message: string }>): string { + return entries.map((e) => logEntry(e.hash, e.message)).join(''); +} + +// ── Bump priority for expected value computation ──────────────────────────── + +const BUMP_PRIORITY: Record = { + major: 3, + minor: 2, + patch: 1, + none: 0, +}; + +function maxBumpLevel(a: BumpLevel, b: BumpLevel): BumpLevel { + return BUMP_PRIORITY[a] >= BUMP_PRIORITY[b] ? a : b; +} + +// ── Property Tests ────────────────────────────────────────────────────────── + +describe('Property 3: Maximum bump determination with BumpPolicy', () => { + /** + * **Validates: Requirements 3.4, 3.5, 3.8** + * + * For any set of conventional commits and any valid BumpPolicy, + * analyzeBump() returns a BumpResult where bump equals the maximum + * bump level among all commits (major > minor > patch > none), + * determined via BumpPolicy. commitsByType contains correct counters. + * breakingChanges contains the subset with breaking: true. + * conventionalCommits contains all commits with valid: true. + */ + test('analyzeBump returns the maximum bump level per BumpPolicy', async () => { + await fc.assert( + fc.asyncProperty( + // Generate 1-10 conventional commits with types and a BumpPolicy + fc.array( + fc.tuple(arbHash, arbConventionalType, arbScope, arbSafeString), + { minLength: 1, maxLength: 10 }, + ), + // Generate a BumpPolicy that maps all standard types + fc.record({ + feat: arbBumpLevel, + fix: arbBumpLevel, + chore: arbBumpLevel, + docs: arbBumpLevel, + style: arbBumpLevel, + refactor: arbBumpLevel, + perf: arbBumpLevel, + test: arbBumpLevel, + build: arbBumpLevel, + ci: arbBumpLevel, + revert: arbBumpLevel, + }), + async (commits, policy) => { + // Build commit messages (non-breaking) + const entries = commits.map(([hash, type, scope, desc]) => ({ + hash, + message: `${type}${scope ? `(${scope})` : ''}: ${desc}`, + })); + + // Compute expected bump + let expectedBump: BumpLevel = 'none'; + const expectedByType: Record = {}; + for (const [, type] of commits) { + expectedByType[type] = (expectedByType[type] || 0) + 1; + const level = policy[type] || 'none'; + expectedBump = maxBumpLevel(expectedBump, level); + } + + // If expected is 'none', we need a fallback to avoid error + const fallback: 'patch' | null = expectedBump === 'none' ? 'patch' : null; + const gitLog = buildGitLog(entries); + const executor = createTagAndLogExecutor(gitLog); + const result = await analyzeBump(makeDeps(executor, policy, fallback)); + + if (expectedBump === 'none') { + // Fallback was used + expect(result.bump).toBe('patch'); + } else { + expect(result.bump).toBe(expectedBump); + } + + // Verify commitsByType counters + expect(result.commitsByType).toEqual(expectedByType); + + // Verify conventionalCommits contains all commits (all are valid CC) + expect(result.conventionalCommits).toHaveLength(commits.length); + + // No breaking changes in this test (non-breaking messages) + expect(result.breakingChanges).toHaveLength(0); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +describe('Property 4: Breaking change always leads to major', () => { + /** + * **Validates: Requirements 3.4** + * + * For any array of commits containing at least one commit with + * breaking: true, analyzeBump() returns bump: 'major', + * regardless of commit type and BumpPolicy settings. + */ + test('any breaking commit forces bump to major (via bang)', async () => { + await fc.assert( + fc.asyncProperty( + // Generate 0-5 non-breaking commits + fc.array( + fc.tuple(arbHash, arbConventionalMessage), + { minLength: 0, maxLength: 5 }, + ), + // Generate 1-3 breaking commits (via bang) + fc.array( + fc.tuple( + arbHash, + fc.tuple(arbConventionalType, arbScope, arbSafeString).map( + ([type, scope, desc]) => + `${type}${scope ? `(${scope})` : ''}!: ${desc}`, + ), + ), + { minLength: 1, maxLength: 3 }, + ), + // Random BumpPolicy (should not matter) + fc.record({ + feat: arbBumpLevel, + fix: arbBumpLevel, + chore: arbBumpLevel, + docs: arbBumpLevel, + style: arbBumpLevel, + refactor: arbBumpLevel, + perf: arbBumpLevel, + test: arbBumpLevel, + build: arbBumpLevel, + ci: arbBumpLevel, + revert: arbBumpLevel, + }), + async (nonBreaking, breaking, policy) => { + const allEntries = [ + ...nonBreaking.map(([hash, msg]) => ({ hash, message: msg })), + ...breaking.map(([hash, msg]) => ({ hash, message: msg })), + ]; + + const gitLog = buildGitLog(allEntries); + const executor = createTagAndLogExecutor(gitLog); + const result = await analyzeBump(makeDeps(executor, policy, null)); + + expect(result.bump).toBe('major'); + expect(result.breakingChanges.length).toBeGreaterThanOrEqual(1); + }, + ), + { numRuns: 100 }, + ); + }); + + test('any breaking commit forces bump to major (via BREAKING CHANGE footer)', async () => { + await fc.assert( + fc.asyncProperty( + // Generate 0-5 non-breaking commits + fc.array( + fc.tuple(arbHash, arbConventionalMessage), + { minLength: 0, maxLength: 5 }, + ), + // Generate 1-2 breaking commits (via footer) + fc.array( + fc.tuple( + arbHash, + fc.tuple(arbConventionalType, arbScope, arbSafeString).map( + ([type, scope, desc]) => + `${type}${scope ? `(${scope})` : ''}: ${desc}\n\nBREAKING CHANGE: ${desc}`, + ), + ), + { minLength: 1, maxLength: 2 }, + ), + fc.record({ + feat: arbBumpLevel, + fix: arbBumpLevel, + chore: arbBumpLevel, + docs: arbBumpLevel, + style: arbBumpLevel, + refactor: arbBumpLevel, + perf: arbBumpLevel, + test: arbBumpLevel, + build: arbBumpLevel, + ci: arbBumpLevel, + revert: arbBumpLevel, + }), + async (nonBreaking, breaking, policy) => { + const allEntries = [ + ...nonBreaking.map(([hash, msg]) => ({ hash, message: msg })), + ...breaking.map(([hash, msg]) => ({ hash, message: msg })), + ]; + + const gitLog = buildGitLog(allEntries); + const executor = createTagAndLogExecutor(gitLog); + const result = await analyzeBump(makeDeps(executor, policy, null)); + + expect(result.bump).toBe('major'); + expect(result.breakingChanges.length).toBeGreaterThanOrEqual(1); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +describe('Property 5: Fallback when no conventional commits', () => { + /** + * **Validates: Requirements 3.6, 3.7, 12.1** + * + * When all commits are invalid (non-conventional) or all conventional + * commit types map to 'none' in BumpPolicy: + * - If fallbackBump is set, analyzeBump returns bump === fallbackBump + * - If fallbackBump is null, analyzeBump throws VersioningsError + * with code NO_CONVENTIONAL_COMMITS (11) + */ + test('all non-conventional commits with fallback → uses fallbackBump', async () => { + await fc.assert( + fc.asyncProperty( + // Generate 1-10 non-conventional commit messages + fc.array( + fc.tuple(arbHash, arbNonConventionalMessage), + { minLength: 1, maxLength: 10 }, + ), + arbNonNoneBump, + async (commits, fallback) => { + const entries = commits.map(([hash, msg]) => ({ hash, message: msg })); + const gitLog = buildGitLog(entries); + const executor = createTagAndLogExecutor(gitLog); + const result = await analyzeBump( + makeDeps(executor, DEFAULT_BUMP_POLICY, fallback), + ); + + expect(result.bump).toBe(fallback); + expect(result.conventionalCommits).toHaveLength(0); + expect(result.breakingChanges).toHaveLength(0); + }, + ), + { numRuns: 100 }, + ); + }); + + test('all non-conventional commits without fallback → throws NO_CONVENTIONAL_COMMITS', async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.tuple(arbHash, arbNonConventionalMessage), + { minLength: 1, maxLength: 10 }, + ), + async (commits) => { + const entries = commits.map(([hash, msg]) => ({ hash, message: msg })); + const gitLog = buildGitLog(entries); + const executor = createTagAndLogExecutor(gitLog); + + try { + await analyzeBump(makeDeps(executor, DEFAULT_BUMP_POLICY, null)); + // Should not reach here + expect(true).toBe(false); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.NO_CONVENTIONAL_COMMITS); + } + }, + ), + { numRuns: 100 }, + ); + }); + + test('all types mapped to none with fallback → uses fallbackBump', async () => { + await fc.assert( + fc.asyncProperty( + // Generate 1-10 conventional commits with types that all map to 'none' + fc.array( + fc.tuple(arbHash, arbConventionalType, arbScope, arbSafeString), + { minLength: 1, maxLength: 10 }, + ), + arbNonNoneBump, + async (commits, fallback) => { + // Create a policy where ALL types map to 'none' + const allNonePolicy: BumpPolicy = {}; + for (const type of ['feat', 'fix', 'chore', 'docs', 'style', 'refactor', + 'perf', 'test', 'build', 'ci', 'revert']) { + allNonePolicy[type] = 'none'; + } + + const entries = commits.map(([hash, type, scope, desc]) => ({ + hash, + message: `${type}${scope ? `(${scope})` : ''}: ${desc}`, + })); + + const gitLog = buildGitLog(entries); + const executor = createTagAndLogExecutor(gitLog); + const result = await analyzeBump( + makeDeps(executor, allNonePolicy, fallback), + ); + + expect(result.bump).toBe(fallback); + // All commits are valid CC, just mapped to 'none' + expect(result.conventionalCommits.length).toBeGreaterThanOrEqual(1); + }, + ), + { numRuns: 100 }, + ); + }); + + test('all types mapped to none without fallback → throws NO_CONVENTIONAL_COMMITS', async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.tuple(arbHash, arbConventionalType, arbScope, arbSafeString), + { minLength: 1, maxLength: 10 }, + ), + async (commits) => { + const allNonePolicy: BumpPolicy = {}; + for (const type of ['feat', 'fix', 'chore', 'docs', 'style', 'refactor', + 'perf', 'test', 'build', 'ci', 'revert']) { + allNonePolicy[type] = 'none'; + } + + const entries = commits.map(([hash, type, scope, desc]) => ({ + hash, + message: `${type}${scope ? `(${scope})` : ''}: ${desc}`, + })); + + const gitLog = buildGitLog(entries); + const executor = createTagAndLogExecutor(gitLog); + + try { + await analyzeBump(makeDeps(executor, allNonePolicy, null)); + expect(true).toBe(false); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.NO_CONVENTIONAL_COMMITS); + } + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/properties/versioning/commit.parser.property.test.ts b/__tests__/properties/versioning/commit.parser.property.test.ts new file mode 100644 index 0000000..b2968b4 --- /dev/null +++ b/__tests__/properties/versioning/commit.parser.property.test.ts @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau +// Feature: version-intelligence-release-narrative +// Property 1: Round-trip of parsing and printing Conventional Commits +// Property 2: Invalid messages return valid: false + +import * as fc from 'fast-check'; +import { parseCommit } from '../../../src/versioning/commit.parser'; +import { printCommit } from '../../../src/versioning/commit.printer'; +import type { ConventionalCommit, CommitFooter } from '../../../src/versioning/commit.parser'; + +// ── Arbitraries ───────────────────────────────────────────────────────────── + +/** Word-char string suitable for CC type (e.g. feat, fix, customType123) */ +const arbType = fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, // a-z + { num: 26, build: (v) => String.fromCharCode(65 + v) }, // A-Z + { num: 10, build: (v) => String.fromCharCode(48 + v) }, // 0-9 + { num: 1, build: () => '_' }, + ), + { minLength: 1, maxLength: 20 }, +); + +/** Scope: null or a non-empty word-char string (no parens, no spaces) */ +const arbScope = fc.oneof( + fc.constant(null), + fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, + { num: 10, build: (v) => String.fromCharCode(48 + v) }, + { num: 1, build: () => '-' }, + ), + { minLength: 1, maxLength: 30 }, + ), +); + +/** + * Description: non-empty, single-line, no leading/trailing whitespace. + * Avoids chars that could confuse the parser. + */ +const arbDescription = fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, + { num: 26, build: (v) => String.fromCharCode(65 + v) }, + { num: 10, build: (v) => String.fromCharCode(48 + v) }, + { num: 1, build: () => ' ' }, + { num: 1, build: () => '-' }, + { num: 1, build: () => '.' }, + ), + { minLength: 1, maxLength: 80 }, +).map((s) => s.trim()).filter((s) => s.length > 0); + +/** + * Body: null or multi-line text that does NOT contain footer-like lines. + * We use simple alphanumeric + space lines to avoid ambiguity with footer patterns. + */ +const arbBodyLine = fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, + { num: 26, build: (v) => String.fromCharCode(65 + v) }, + { num: 10, build: (v) => String.fromCharCode(48 + v) }, + { num: 1, build: () => ' ' }, + { num: 1, build: () => ',' }, + { num: 1, build: () => '.' }, + ), + { minLength: 1, maxLength: 60 }, +).map((s) => s.trim()).filter((s) => s.length > 0); + +const arbBody = fc.oneof( + fc.constant(null), + fc.array(arbBodyLine, { minLength: 1, maxLength: 3 }).map((lines) => lines.join('\n')), +); + +/** + * Footer token: word-char string (not BREAKING CHANGE/BREAKING-CHANGE, + * those are handled separately). Must not be empty. + */ +const arbFooterToken = fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(65 + v) }, + { num: 26, build: (v) => String.fromCharCode(97 + v) }, + { num: 1, build: () => '-' }, + ), + { minLength: 2, maxLength: 20 }, +).filter((s) => s !== 'BREAKING-CHANGE' && !s.startsWith('-') && !s.endsWith('-')); + +/** Footer value: non-empty, single-line */ +const arbFooterValue = fc.stringOf( + fc.mapToConstant( + { num: 26, build: (v) => String.fromCharCode(97 + v) }, + { num: 10, build: (v) => String.fromCharCode(48 + v) }, + { num: 1, build: () => ' ' }, + ), + { minLength: 1, maxLength: 40 }, +).map((s) => s.trim()).filter((s) => s.length > 0); + +/** Regular footer (non-breaking) */ +const arbRegularFooter: fc.Arbitrary = fc.record({ + token: arbFooterToken, + value: arbFooterValue, +}); + +/** Breaking footer */ +const arbBreakingFooter: fc.Arbitrary = fc.record({ + token: fc.constantFrom('BREAKING CHANGE', 'BREAKING-CHANGE'), + value: arbFooterValue, +}); + +/** + * Generate a valid ConventionalCommit with consistent breaking flag. + * The printer omits `!` when a BREAKING CHANGE footer exists, + * so we model this correctly for round-trip. + */ +const arbConventionalCommit: fc.Arbitrary = fc.record({ + type: arbType, + scope: arbScope, + description: arbDescription, + body: arbBody, + regularFooters: fc.array(arbRegularFooter, { minLength: 0, maxLength: 3 }), + breakingVia: fc.constantFrom('none', 'bang', 'footer') as fc.Arbitrary<'none' | 'bang' | 'footer'>, +}).chain(({ type, scope, description, body, regularFooters, breakingVia }) => { + if (breakingVia === 'footer') { + return arbBreakingFooter.map((bf) => ({ + valid: true as const, + type, + scope, + description, + body, + footers: [...regularFooters, bf], + breaking: true, + rawMessage: '', + })); + } + + return fc.constant({ + valid: true as const, + type, + scope, + description, + body, + footers: regularFooters, + breaking: breakingVia === 'bang', + rawMessage: '', + }); +}); + +// ── Header regex (same as in commit.parser.ts) ───────────────────────────── + +const HEADER_RE = /^(\w+)(?:\(([^)]*)\))?(!)?\s*:\s*(.+)$/; + +// ── Property Tests ────────────────────────────────────────────────────────── + +describe('Property 1: Round-trip of parsing and printing Conventional Commits', () => { + /** + * **Validates: Requirements 1.1, 1.2, 1.3, 1.5, 1.6, 1.7, 2.1, 2.2, 2.3, 2.4, 2.5** + * + * For any valid ConventionalCommit, printCommit → parseCommit should + * produce an equivalent ConventionalCommit (same type, scope, description, + * body, footers, breaking). + */ + test('parseCommit(printCommit(commit)) preserves all semantic fields', () => { + fc.assert( + fc.property(arbConventionalCommit, (commit) => { + const printed = printCommit(commit); + const reparsed = parseCommit(printed); + + expect(reparsed.valid).toBe(true); + const c = reparsed as ConventionalCommit; + expect(c.type).toBe(commit.type); + expect(c.scope).toBe(commit.scope); + expect(c.description).toBe(commit.description); + expect(c.body).toBe(commit.body); + expect(c.footers).toEqual(commit.footers); + expect(c.breaking).toBe(commit.breaking); + }), + { numRuns: 200 }, + ); + }); + + test('printed output is always parseable as valid', () => { + fc.assert( + fc.property(arbConventionalCommit, (commit) => { + const printed = printCommit(commit); + const reparsed = parseCommit(printed); + expect(reparsed.valid).toBe(true); + }), + { numRuns: 200 }, + ); + }); +}); + +describe('Property 2: Invalid messages return valid: false', () => { + /** + * **Validates: Requirements 1.5** + * + * Any string whose first line does not match the Conventional Commits + * header format should be parsed as invalid. + */ + test('strings not matching CC header format return valid: false', () => { + fc.assert( + fc.property( + fc.string({ minLength: 0, maxLength: 200 }), + (message) => { + const firstLine = message.split('\n')[0]; + fc.pre(!HEADER_RE.test(firstLine)); + + const result = parseCommit(message); + expect(result.valid).toBe(false); + expect(result.rawMessage).toBe(message); + }, + ), + { numRuns: 200 }, + ); + }); + + test('empty and whitespace-only strings return valid: false', () => { + fc.assert( + fc.property( + fc.stringOf(fc.constantFrom(' ', '\t', '\n', '\r'), { minLength: 0, maxLength: 20 }), + (message) => { + const result = parseCommit(message); + expect(result.valid).toBe(false); + }, + ), + { numRuns: 100 }, + ); + }); + + test('strings without colon separator return valid: false', () => { + fc.assert( + fc.property( + fc.string({ minLength: 1, maxLength: 100 }).filter((s) => !s.includes(':')), + (message) => { + const result = parseCommit(message); + expect(result.valid).toBe(false); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/__tests__/unit/branching/default.strategy.test.ts b/__tests__/unit/branching/default.strategy.test.ts new file mode 100644 index 0000000..6606950 --- /dev/null +++ b/__tests__/unit/branching/default.strategy.test.ts @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createDefaultStrategy } from '../../../src/branching/strategies/default.strategy'; +import { composeVersionBranchName, composeVersionTagName, semverMessage } from '../../../src/versioning/version.utils'; +import type { VersioningsConfig } from '../../../src/config/config.validator'; +import type { StrategyParams } from '../../../src/branching/branching.strategy'; + +// --------------------------------------------------------------------------- +// Minimal mock config +// --------------------------------------------------------------------------- + +const mockConfig = { + git: { + platform: 'github', + url: 'https://github.com/test/repo', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + prepatch: 'Prepatch: v%s.', + patch: 'Patch: v%s.', + preminor: 'Preminor: v%s.', + minor: 'Minor: v%s.', + premajor: 'Premajor: v%s.', + major: 'Major: v%s.', + prerelease: 'Prerelease: v%s.', + }, + }, + }, + }, + package: { + semver: { + patch: 'patch', + prepatch: 'prepatch', + minor: 'minor', + preminor: 'preminor', + premajor: 'premajor', + prerelease: 'prerelease', + major: 'major', + }, + }, + common: { messages: {} }, +} as unknown as VersioningsConfig; + +function makeParams(overrides: Partial = {}): StrategyParams { + return { + semver: 'patch', + version: '1.2.3', + comment: 'fix-login', + config: mockConfig, + currentBranch: 'master', + ...overrides, + }; +} + + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createDefaultStrategy', () => { + test('name() returns "default"', () => { + const strategy = createDefaultStrategy(mockConfig); + expect(strategy.name()).toBe('default'); + }); + + // ------------------------------------------------------------------------- + // composeBranchName — equivalence with legacy composeVersionBranchName + // ------------------------------------------------------------------------- + + describe('composeBranchName', () => { + test('matches legacy composeVersionBranchName for patch', () => { + const strategy = createDefaultStrategy(mockConfig); + const params = makeParams({ semver: 'patch', version: '1.2.3', comment: 'fix-login' }); + const result = strategy.composeBranchName(params); + const legacy = composeVersionBranchName('patch', '1.2.3', 'fix-login', mockConfig); + expect(result.branchName).toBe(legacy); + expect(result.reuseBranch).toBe(false); + }); + + test('matches legacy composeVersionBranchName for minor', () => { + const strategy = createDefaultStrategy(mockConfig); + const params = makeParams({ semver: 'minor', version: '1.3.0', comment: 'add-feature' }); + const result = strategy.composeBranchName(params); + const legacy = composeVersionBranchName('minor', '1.3.0', 'add-feature', mockConfig); + expect(result.branchName).toBe(legacy); + }); + + test('matches legacy composeVersionBranchName for major', () => { + const strategy = createDefaultStrategy(mockConfig); + const params = makeParams({ semver: 'major', version: '2.0.0', comment: 'breaking' }); + const result = strategy.composeBranchName(params); + const legacy = composeVersionBranchName('major', '2.0.0', 'breaking', mockConfig); + expect(result.branchName).toBe(legacy); + }); + + test('uses custom branchTemplate when provided', () => { + const configWithTemplate = { + ...mockConfig, + git: { + ...mockConfig.git, + branching: { branchTemplate: 'release/{version}' }, + }, + } as unknown as VersioningsConfig; + const strategy = createDefaultStrategy(configWithTemplate); + const result = strategy.composeBranchName(makeParams({ config: configWithTemplate })); + expect(result).toEqual({ branchName: 'release/1.2.3', reuseBranch: false }); + }); + }); + + // ------------------------------------------------------------------------- + // composeTagName — equivalence with legacy composeVersionTagName + // ------------------------------------------------------------------------- + + describe('composeTagName', () => { + test('matches legacy composeVersionTagName', () => { + const strategy = createDefaultStrategy(mockConfig); + const params = makeParams(); + const result = strategy.composeTagName(params); + const legacy = composeVersionTagName('patch', '1.2.3', 'fix-login'); + expect(result).toBe(legacy); + }); + + test('uses custom tagTemplate when provided', () => { + const configWithTemplate = { + ...mockConfig, + git: { + ...mockConfig.git, + branching: { tagTemplate: 'v{version}' }, + }, + } as unknown as VersioningsConfig; + const strategy = createDefaultStrategy(configWithTemplate); + expect(strategy.composeTagName(makeParams({ config: configWithTemplate }))).toBe('v1.2.3'); + }); + }); + + // ------------------------------------------------------------------------- + // composeCommitMessage — equivalence with legacy semverMessage + // ------------------------------------------------------------------------- + + describe('composeCommitMessage', () => { + test('matches legacy semverMessage for patch', () => { + const strategy = createDefaultStrategy(mockConfig); + const params = makeParams({ semver: 'patch', version: '1.2.3' }); + const result = strategy.composeCommitMessage(params); + const legacy = semverMessage('patch', '1.2.3', mockConfig); + expect(result).toBe(legacy); + }); + + test('matches legacy semverMessage for minor', () => { + const strategy = createDefaultStrategy(mockConfig); + const params = makeParams({ semver: 'minor', version: '1.3.0' }); + const result = strategy.composeCommitMessage(params); + const legacy = semverMessage('minor', '1.3.0', mockConfig); + expect(result).toBe(legacy); + }); + + test('returns fallback message when template is empty', () => { + const emptyConfig = { + ...mockConfig, + git: { + ...mockConfig.git, + commit: { message: { semver: { patch: '', minor: '', major: '', prepatch: '', preminor: '', premajor: '', prerelease: '' } } }, + }, + } as unknown as VersioningsConfig; + const strategy = createDefaultStrategy(emptyConfig); + expect(strategy.composeCommitMessage(makeParams({ config: emptyConfig }))).toBe( + 'Read documentation and try to use versioning tool according to the standard.', + ); + }); + }); + + // ------------------------------------------------------------------------- + // validateContext — always valid + // ------------------------------------------------------------------------- + + describe('validateContext', () => { + test('always returns valid for any branch', () => { + const strategy = createDefaultStrategy(mockConfig); + expect(strategy.validateContext(makeParams({ currentBranch: 'master' }))).toEqual({ valid: true, errors: [] }); + expect(strategy.validateContext(makeParams({ currentBranch: 'develop' }))).toEqual({ valid: true, errors: [] }); + expect(strategy.validateContext(makeParams({ currentBranch: 'feature/xyz' }))).toEqual({ valid: true, errors: [] }); + }); + + test('always returns valid for any semver type', () => { + const strategy = createDefaultStrategy(mockConfig); + expect(strategy.validateContext(makeParams({ semver: 'patch' }))).toEqual({ valid: true, errors: [] }); + expect(strategy.validateContext(makeParams({ semver: 'minor' }))).toEqual({ valid: true, errors: [] }); + expect(strategy.validateContext(makeParams({ semver: 'major' }))).toEqual({ valid: true, errors: [] }); + }); + }); +}); diff --git a/__tests__/unit/branching/gitflow.strategy.test.ts b/__tests__/unit/branching/gitflow.strategy.test.ts new file mode 100644 index 0000000..94e41b5 --- /dev/null +++ b/__tests__/unit/branching/gitflow.strategy.test.ts @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createGitFlowStrategy } from '../../../src/branching/strategies/gitflow.strategy'; +import type { VersioningsConfig } from '../../../src/config/config.validator'; +import type { StrategyParams } from '../../../src/branching/branching.strategy'; + +// --------------------------------------------------------------------------- +// Minimal mock config +// --------------------------------------------------------------------------- + +const mockConfig = { + git: { + platform: 'github', + url: 'https://github.com/test/repo', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + prepatch: 'Prepatch: v%s.', + patch: 'Patch: v%s.', + preminor: 'Preminor: v%s.', + minor: 'Minor: v%s.', + premajor: 'Premajor: v%s.', + major: 'Major: v%s.', + prerelease: 'Prerelease: v%s.', + }, + }, + }, + }, + package: { + semver: { + patch: 'patch', + prepatch: 'prepatch', + minor: 'minor', + preminor: 'preminor', + premajor: 'premajor', + prerelease: 'prerelease', + major: 'major', + }, + }, + common: { messages: {} }, +} as unknown as VersioningsConfig; + +function makeParams(overrides: Partial = {}): StrategyParams { + return { + semver: 'minor', + version: '1.2.0', + comment: 'new-feature', + config: mockConfig, + currentBranch: 'develop', + ...overrides, + }; +} + + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createGitFlowStrategy', () => { + test('name() returns "git-flow"', () => { + const strategy = createGitFlowStrategy(mockConfig); + expect(strategy.name()).toBe('git-flow'); + }); + + // ------------------------------------------------------------------------- + // composeBranchName — release/hotfix routing + // ------------------------------------------------------------------------- + + describe('composeBranchName', () => { + test('minor → release/{version}', () => { + const strategy = createGitFlowStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams({ semver: 'minor', version: '1.2.0' })); + expect(result).toEqual({ branchName: 'release/1.2.0', reuseBranch: false }); + }); + + test('major → release/{version}', () => { + const strategy = createGitFlowStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams({ semver: 'major', version: '2.0.0' })); + expect(result).toEqual({ branchName: 'release/2.0.0', reuseBranch: false }); + }); + + test('patch → hotfix/{version}', () => { + const strategy = createGitFlowStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams({ semver: 'patch', version: '1.2.3' })); + expect(result).toEqual({ branchName: 'hotfix/1.2.3', reuseBranch: false }); + }); + + test('prerelease → release/{version}', () => { + const strategy = createGitFlowStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams({ semver: 'prerelease', version: '1.2.3-beta.1' })); + expect(result).toEqual({ branchName: 'release/1.2.3-beta.1', reuseBranch: false }); + }); + + test('prepatch → hotfix/{version}', () => { + const strategy = createGitFlowStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams({ semver: 'prepatch', version: '1.2.4-rc.0' })); + expect(result).toEqual({ branchName: 'hotfix/1.2.4-rc.0', reuseBranch: false }); + }); + + test('preminor → release/{version}', () => { + const strategy = createGitFlowStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams({ semver: 'preminor', version: '1.3.0-alpha.0' })); + expect(result).toEqual({ branchName: 'release/1.3.0-alpha.0', reuseBranch: false }); + }); + + test('premajor → release/{version}', () => { + const strategy = createGitFlowStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams({ semver: 'premajor', version: '2.0.0-alpha.0' })); + expect(result).toEqual({ branchName: 'release/2.0.0-alpha.0', reuseBranch: false }); + }); + }); + + // ------------------------------------------------------------------------- + // composeTagName + // ------------------------------------------------------------------------- + + describe('composeTagName', () => { + test('returns v{version}', () => { + const strategy = createGitFlowStrategy(mockConfig); + expect(strategy.composeTagName(makeParams())).toBe('v1.2.0'); + }); + + test('uses custom tagTemplate when provided', () => { + const configWithTemplate = { + ...mockConfig, + git: { + ...mockConfig.git, + branching: { tagTemplate: 'release-{version}' }, + }, + } as unknown as VersioningsConfig; + const strategy = createGitFlowStrategy(configWithTemplate); + expect(strategy.composeTagName(makeParams({ config: configWithTemplate }))).toBe('release-1.2.0'); + }); + }); + + // ------------------------------------------------------------------------- + // composeCommitMessage + // ------------------------------------------------------------------------- + + describe('composeCommitMessage', () => { + test('uses commit message template from config', () => { + const strategy = createGitFlowStrategy(mockConfig); + expect(strategy.composeCommitMessage(makeParams({ semver: 'minor', version: '1.2.0' }))).toBe('Minor: 1.2.0.'); + }); + }); + + // ------------------------------------------------------------------------- + // validateContext — develop for release, main for hotfix + // ------------------------------------------------------------------------- + + describe('validateContext', () => { + test('minor on develop → valid', () => { + const strategy = createGitFlowStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'minor', currentBranch: 'develop' })); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + test('minor on master → invalid', () => { + const strategy = createGitFlowStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'minor', currentBranch: 'master' })); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('requires current branch to be "develop"'); + }); + + test('patch on master → valid', () => { + const strategy = createGitFlowStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'patch', currentBranch: 'master' })); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + test('patch on main → valid', () => { + const strategy = createGitFlowStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'patch', currentBranch: 'main' })); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + test('patch on develop → invalid', () => { + const strategy = createGitFlowStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'patch', currentBranch: 'develop' })); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('requires current branch to be "master" or "main"'); + }); + + test('prerelease on develop → valid', () => { + const strategy = createGitFlowStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'prerelease', currentBranch: 'develop' })); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + test('major on develop → valid', () => { + const strategy = createGitFlowStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'major', currentBranch: 'develop' })); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + test('prepatch on master → valid (hotfix type)', () => { + const strategy = createGitFlowStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'prepatch', currentBranch: 'master' })); + expect(result).toEqual({ valid: true, errors: [] }); + }); + }); +}); diff --git a/__tests__/unit/branching/hotfix.strategy.test.ts b/__tests__/unit/branching/hotfix.strategy.test.ts new file mode 100644 index 0000000..d05294d --- /dev/null +++ b/__tests__/unit/branching/hotfix.strategy.test.ts @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createHotfixStrategy } from '../../../src/branching/strategies/hotfix.strategy'; +import type { VersioningsConfig } from '../../../src/config/config.validator'; +import type { StrategyParams } from '../../../src/branching/branching.strategy'; + +// --------------------------------------------------------------------------- +// Minimal mock config +// --------------------------------------------------------------------------- + +const mockConfig = { + git: { + platform: 'github', + url: 'https://github.com/test/repo', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + prepatch: 'Prepatch: v%s.', + patch: 'Patch: v%s.', + preminor: 'Preminor: v%s.', + minor: 'Minor: v%s.', + premajor: 'Premajor: v%s.', + major: 'Major: v%s.', + prerelease: 'Prerelease: v%s.', + }, + }, + }, + }, + package: { + semver: { + patch: 'patch', + prepatch: 'prepatch', + minor: 'minor', + preminor: 'preminor', + premajor: 'premajor', + prerelease: 'prerelease', + major: 'major', + }, + }, + common: { messages: {} }, +} as unknown as VersioningsConfig; + +function makeParams(overrides: Partial = {}): StrategyParams { + return { + semver: 'patch', + version: '1.2.3', + comment: 'fix-login', + config: mockConfig, + currentBranch: 'master', + ...overrides, + }; +} + + +// --------------------------------------------------------------------------- +// name() +// --------------------------------------------------------------------------- + +describe('createHotfixStrategy', () => { + test('name() returns "hotfix"', () => { + const strategy = createHotfixStrategy(mockConfig); + expect(strategy.name()).toBe('hotfix'); + }); + + // ------------------------------------------------------------------------- + // composeBranchName + // ------------------------------------------------------------------------- + + describe('composeBranchName', () => { + test('returns hotfix/{version} by default', () => { + const strategy = createHotfixStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams()); + expect(result).toEqual({ branchName: 'hotfix/1.2.3', reuseBranch: false }); + }); + + test('uses custom branchTemplate when provided', () => { + const configWithTemplate = { + ...mockConfig, + git: { + ...mockConfig.git, + branching: { branchTemplate: 'fix/{version}' }, + }, + } as unknown as VersioningsConfig; + const strategy = createHotfixStrategy(configWithTemplate); + const result = strategy.composeBranchName(makeParams({ config: configWithTemplate })); + expect(result).toEqual({ branchName: 'fix/1.2.3', reuseBranch: false }); + }); + + test('reuseBranch is always false', () => { + const strategy = createHotfixStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams()); + expect(result.reuseBranch).toBe(false); + }); + }); + + // ------------------------------------------------------------------------- + // composeTagName + // ------------------------------------------------------------------------- + + describe('composeTagName', () => { + test('returns v{version} by default', () => { + const strategy = createHotfixStrategy(mockConfig); + expect(strategy.composeTagName(makeParams())).toBe('v1.2.3'); + }); + + test('uses custom tagTemplate when provided', () => { + const configWithTemplate = { + ...mockConfig, + git: { + ...mockConfig.git, + branching: { tagTemplate: 'release-{version}' }, + }, + } as unknown as VersioningsConfig; + const strategy = createHotfixStrategy(configWithTemplate); + expect(strategy.composeTagName(makeParams({ config: configWithTemplate }))).toBe('release-1.2.3'); + }); + }); + + // ------------------------------------------------------------------------- + // composeCommitMessage + // ------------------------------------------------------------------------- + + describe('composeCommitMessage', () => { + test('uses commit message template from config', () => { + const strategy = createHotfixStrategy(mockConfig); + expect(strategy.composeCommitMessage(makeParams())).toBe('Patch: 1.2.3.'); + }); + + test('returns fallback message when template is empty', () => { + const emptyConfig = { + ...mockConfig, + git: { + ...mockConfig.git, + commit: { message: { semver: { patch: '', minor: '', major: '', prepatch: '', preminor: '', premajor: '', prerelease: '' } } }, + }, + } as unknown as VersioningsConfig; + const strategy = createHotfixStrategy(emptyConfig); + expect(strategy.composeCommitMessage(makeParams({ config: emptyConfig }))).toBe( + 'Read documentation and try to use versioning tool according to the standard.', + ); + }); + }); + + // ------------------------------------------------------------------------- + // validateContext + // ------------------------------------------------------------------------- + + describe('validateContext', () => { + test('valid when semver=patch and currentBranch=master', () => { + const strategy = createHotfixStrategy(mockConfig); + const result = strategy.validateContext(makeParams()); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + test('valid when semver=patch and currentBranch=main', () => { + const strategy = createHotfixStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ currentBranch: 'main' })); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + test('valid when semver=patch and currentBranch matches custom mainBranch', () => { + const configWithMain = { + ...mockConfig, + git: { + ...mockConfig.git, + branching: { mainBranch: 'production' }, + }, + } as unknown as VersioningsConfig; + const strategy = createHotfixStrategy(configWithMain); + const result = strategy.validateContext(makeParams({ currentBranch: 'production' })); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + test('invalid when semver is not patch — returns error about semver type', () => { + const strategy = createHotfixStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'minor' })); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual( + expect.stringContaining('Hotfix strategy only allows "patch" semver type, got "minor"'), + ); + }); + + test('invalid when semver is major', () => { + const strategy = createHotfixStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'major' })); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('got "major"'); + }); + + test('invalid when semver is prerelease', () => { + const strategy = createHotfixStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'prerelease' })); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('got "prerelease"'); + }); + + test('invalid when currentBranch is not main/master — returns error about branch', () => { + const strategy = createHotfixStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ currentBranch: 'develop' })); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual( + expect.stringContaining('Hotfix strategy requires current branch to be "master" or "main", but got "develop"'), + ); + }); + + test('returns both errors when semver is not patch AND branch is wrong', () => { + const strategy = createHotfixStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'minor', currentBranch: 'develop' })); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(2); + expect(result.errors[0]).toContain('Hotfix strategy only allows "patch" semver type'); + expect(result.errors[1]).toContain('Hotfix strategy requires current branch to be'); + }); + }); +}); diff --git a/__tests__/unit/branching/maintenance.strategy.test.ts b/__tests__/unit/branching/maintenance.strategy.test.ts new file mode 100644 index 0000000..27ba3b4 --- /dev/null +++ b/__tests__/unit/branching/maintenance.strategy.test.ts @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createMaintenanceStrategy } from '../../../src/branching/strategies/maintenance.strategy'; +import type { VersioningsConfig } from '../../../src/config/config.validator'; +import type { StrategyParams } from '../../../src/branching/branching.strategy'; + +// --------------------------------------------------------------------------- +// Minimal mock config +// --------------------------------------------------------------------------- + +const mockConfig = { + git: { + platform: 'github', + url: 'https://github.com/test/repo', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + prepatch: 'Prepatch: v%s.', + patch: 'Patch: v%s.', + preminor: 'Preminor: v%s.', + minor: 'Minor: v%s.', + premajor: 'Premajor: v%s.', + major: 'Major: v%s.', + prerelease: 'Prerelease: v%s.', + }, + }, + }, + }, + package: { + semver: { + patch: 'patch', + prepatch: 'prepatch', + minor: 'minor', + preminor: 'preminor', + premajor: 'premajor', + prerelease: 'prerelease', + major: 'major', + }, + }, + common: { messages: {} }, +} as unknown as VersioningsConfig; + +function makeParams(overrides: Partial = {}): StrategyParams { + return { + semver: 'patch', + version: '1.2.3', + comment: 'fix-login', + config: mockConfig, + currentBranch: 'support/1.2', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// name() +// --------------------------------------------------------------------------- + +describe('createMaintenanceStrategy', () => { + test('name() returns "maintenance"', () => { + const strategy = createMaintenanceStrategy(mockConfig); + expect(strategy.name()).toBe('maintenance'); + }); + + // ------------------------------------------------------------------------- + // composeBranchName + // ------------------------------------------------------------------------- + + describe('composeBranchName', () => { + test('returns support/{major}.{minor} by default', () => { + const strategy = createMaintenanceStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams()); + expect(result.branchName).toBe('support/1.2'); + }); + + test('does not include patch component in branch name', () => { + const strategy = createMaintenanceStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams({ version: '3.4.5' })); + expect(result.branchName).toBe('support/3.4'); + }); + + test('reuseBranch is true when patch > 0', () => { + const strategy = createMaintenanceStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams({ version: '1.2.3' })); + expect(result.reuseBranch).toBe(true); + }); + + test('reuseBranch is false when patch is 0 (first creation)', () => { + const strategy = createMaintenanceStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams({ version: '1.2.0' })); + expect(result).toEqual({ branchName: 'support/1.2', reuseBranch: false }); + }); + + test('uses custom branchTemplate when provided', () => { + const configWithTemplate = { + ...mockConfig, + git: { + ...mockConfig.git, + branching: { branchTemplate: 'maint/{major}.{minor}' }, + }, + } as unknown as VersioningsConfig; + const strategy = createMaintenanceStrategy(configWithTemplate); + const result = strategy.composeBranchName(makeParams({ config: configWithTemplate })); + expect(result.branchName).toBe('maint/1.2'); + }); + + test('custom branchTemplate bypasses reuse logic', () => { + const configWithTemplate = { + ...mockConfig, + git: { + ...mockConfig.git, + branching: { branchTemplate: 'maint/{major}.{minor}' }, + }, + } as unknown as VersioningsConfig; + const strategy = createMaintenanceStrategy(configWithTemplate); + // custom template always returns reuseBranch: false + const result = strategy.composeBranchName(makeParams({ config: configWithTemplate, version: '2.3.1' })); + expect(result.reuseBranch).toBe(false); + const result2 = strategy.composeBranchName(makeParams({ config: configWithTemplate, version: '2.3.0' })); + expect(result2.reuseBranch).toBe(false); + }); + }); + + // ------------------------------------------------------------------------- + // composeTagName + // ------------------------------------------------------------------------- + + describe('composeTagName', () => { + test('returns v{version} by default', () => { + const strategy = createMaintenanceStrategy(mockConfig); + expect(strategy.composeTagName(makeParams())).toBe('v1.2.3'); + }); + + test('uses custom tagTemplate when provided', () => { + const configWithTemplate = { + ...mockConfig, + git: { + ...mockConfig.git, + branching: { tagTemplate: 'release-{version}' }, + }, + } as unknown as VersioningsConfig; + const strategy = createMaintenanceStrategy(configWithTemplate); + expect(strategy.composeTagName(makeParams({ config: configWithTemplate }))).toBe('release-1.2.3'); + }); + }); + + // ------------------------------------------------------------------------- + // composeCommitMessage + // ------------------------------------------------------------------------- + + describe('composeCommitMessage', () => { + test('uses commit message template from config', () => { + const strategy = createMaintenanceStrategy(mockConfig); + expect(strategy.composeCommitMessage(makeParams())).toBe('Patch: 1.2.3.'); + }); + + test('returns fallback message when template is empty', () => { + const emptyConfig = { + ...mockConfig, + git: { + ...mockConfig.git, + commit: { message: { semver: { patch: '', minor: '', major: '', prepatch: '', preminor: '', premajor: '', prerelease: '' } } }, + }, + } as unknown as VersioningsConfig; + const strategy = createMaintenanceStrategy(emptyConfig); + expect(strategy.composeCommitMessage(makeParams({ config: emptyConfig }))).toBe( + 'Read documentation and try to use versioning tool according to the standard.', + ); + }); + }); + + // ------------------------------------------------------------------------- + // validateContext + // ------------------------------------------------------------------------- + + describe('validateContext', () => { + test('valid when semver=patch and currentBranch is a support branch', () => { + const strategy = createMaintenanceStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ currentBranch: 'support/1.2' })); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + test('valid when semver=patch and currentBranch=master (first creation)', () => { + const strategy = createMaintenanceStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ currentBranch: 'master' })); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + test('valid when semver=patch and currentBranch=main (first creation)', () => { + const strategy = createMaintenanceStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ currentBranch: 'main' })); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + test('valid when semver=patch and currentBranch matches custom mainBranch', () => { + const configWithMain = { + ...mockConfig, + git: { + ...mockConfig.git, + branching: { mainBranch: 'production' }, + }, + } as unknown as VersioningsConfig; + const strategy = createMaintenanceStrategy(configWithMain); + const result = strategy.validateContext(makeParams({ currentBranch: 'production' })); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + test('invalid when semver is not patch — returns error about semver type', () => { + const strategy = createMaintenanceStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'minor' })); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual( + expect.stringContaining('Maintenance strategy only allows "patch" semver type, got "minor"'), + ); + }); + + test('invalid when semver is major', () => { + const strategy = createMaintenanceStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'major' })); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('got "major"'); + }); + + test('invalid when semver is prerelease', () => { + const strategy = createMaintenanceStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'prerelease' })); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('got "prerelease"'); + }); + + test('invalid when currentBranch is not support/* or main/master — returns error about branch', () => { + const strategy = createMaintenanceStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ currentBranch: 'develop' })); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual( + expect.stringContaining('Maintenance strategy requires current branch to be a support branch or "master" or "main", but got "develop"'), + ); + }); + + test('returns both errors when semver is not patch AND branch is wrong', () => { + const strategy = createMaintenanceStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ semver: 'minor', currentBranch: 'develop' })); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(2); + expect(result.errors[0]).toContain('Maintenance strategy only allows "patch" semver type'); + expect(result.errors[1]).toContain('Maintenance strategy requires current branch to be'); + }); + }); +}); diff --git a/__tests__/unit/branching/policy.checker.test.ts b/__tests__/unit/branching/policy.checker.test.ts new file mode 100644 index 0000000..6f19b1a --- /dev/null +++ b/__tests__/unit/branching/policy.checker.test.ts @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { checkPolicy } from '../../../src/branching/policy.checker'; +import type { PolicyCheckerDeps } from '../../../src/branching/policy.checker'; +import type { Executor, ExecutorResult } from '../../../src/core/executor'; +import type { SCM_Provider, Branch_Protection_Rule } from '../../../src/scm/scm.provider'; +import type { VersioningsConfig } from '../../../src/config/config.validator'; + +// --------------------------------------------------------------------------- +// Minimal mock config (same pattern as other tests) +// --------------------------------------------------------------------------- + +const mockConfig = { + git: { + platform: 'github', + url: 'https://github.com/test/repo', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + prepatch: 'Prepatch: v%s.', + patch: 'Patch: v%s.', + preminor: 'Preminor: v%s.', + minor: 'Minor: v%s.', + premajor: 'Premajor: v%s.', + major: 'Major: v%s.', + prerelease: 'Prerelease: v%s.', + }, + }, + }, + }, + package: { + semver: { + patch: 'patch', + prepatch: 'prepatch', + minor: 'minor', + preminor: 'preminor', + premajor: 'premajor', + prerelease: 'prerelease', + major: 'major', + }, + }, + common: { messages: {} }, +} as unknown as VersioningsConfig; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +/** + * Creates a mock Executor where `run(cmd)` returns the matching response + * from `responses` or throws the Error. Commands not in `responses` throw + * (simulating "no config found" — the default git config behavior). + */ +function createMockExecutor( + responses: Record = {}, +): Executor { + return { + async run(cmd: string): Promise { + for (const [pattern, response] of Object.entries(responses)) { + if (cmd.includes(pattern)) { + if (response instanceof Error) throw response; + const trimmed = response.trim(); + return { + stdout: trimmed, + lines: trimmed.split(/\r?\n/).filter(Boolean), + }; + } + } + // Default: git config exits with error when no matching keys + throw new Error(`No config found for: ${cmd}`); + }, + }; +} + +/** + * Creates a mock SCM_Provider with getBranchProtection that returns + * the given rule, null, or throws the given Error. + */ +function createMockScmProvider( + rule: Branch_Protection_Rule | null | Error, +): SCM_Provider { + return { + name: () => 'mock', + createPullRequest: async () => ({ + url: '', + number: null, + status: 'skipped' as const, + fallbackReason: null, + platform: 'mock', + warnings: [], + }), + generatePullRequestUrl: () => '', + getBranchProtection: async (_branch: string) => { + if (rule instanceof Error) throw rule; + return rule; + }, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('checkPolicy', () => { + // 1. Branch without protection — no warnings, no errors, protectionInfo null + test('branch without protection — no warnings, no errors, protectionInfo null', async () => { + const executor = createMockExecutor(); + const deps: PolicyCheckerDeps = { executor, config: mockConfig }; + + const result = await checkPolicy('feature/test', deps); + + expect(result.warnings).toEqual([]); + expect(result.errors).toEqual([]); + expect(result.protectionInfo).toBeNull(); + }); + + // 2. Branch with pushRemote in git config — warning about pushRemote + test('branch with pushRemote in git config — warning about pushRemote', async () => { + const executor = createMockExecutor({ + 'git config --get-regexp branch.main.': 'branch.main.pushRemote upstream', + }); + const deps: PolicyCheckerDeps = { executor, config: mockConfig }; + + const result = await checkPolicy('main', deps); + + expect(result.warnings.length).toBeGreaterThanOrEqual(1); + expect(result.warnings.some((w) => w.includes('pushRemote'))).toBe(true); + expect(result.protectionInfo).not.toBeNull(); + expect(result.protectionInfo!.protected).toBe(true); + expect(result.protectionInfo!.source).toBe('git-config'); + }); + + // 3. gpgSign configured — gpgSignConfigured=true in protectionInfo + test('gpgSign configured — gpgSignConfigured=true', async () => { + const executor = createMockExecutor({ + 'commit.gpgSign': 'true', + }); + const deps: PolicyCheckerDeps = { executor, config: mockConfig }; + + const result = await checkPolicy('main', deps); + + // gpgSign alone doesn't set protected=true, but the info should be tracked + // If protectionInfo is null (no pushRemote/no SCM rule), gpgSign is still checked + // Looking at the implementation: gpgSign is stored in localResult.info but + // protectionInfo is only created when hasLocal || hasRemote + // hasLocal requires info.protected === true (set by pushRemote) + // So with only gpgSign, protectionInfo may be null — but the info is still in localResult + // Let's verify the actual behavior: + expect(result.errors).toEqual([]); + }); + + // Let's test gpgSign with pushRemote so protectionInfo is created + test('gpgSign configured with pushRemote — gpgSignConfigured=true in protectionInfo', async () => { + const executor = createMockExecutor({ + 'git config --get-regexp branch.main.': 'branch.main.pushRemote upstream', + 'commit.gpgSign': 'true', + }); + const deps: PolicyCheckerDeps = { executor, config: mockConfig }; + + const result = await checkPolicy('main', deps); + + expect(result.protectionInfo).not.toBeNull(); + expect(result.protectionInfo!.gpgSignConfigured).toBe(true); + }); + + // 4. gpgSign not configured — gpgSignConfigured=false + test('gpgSign not configured — gpgSignConfigured=false when protectionInfo exists', async () => { + const executor = createMockExecutor({ + 'git config --get-regexp branch.main.': 'branch.main.pushRemote upstream', + }); + const deps: PolicyCheckerDeps = { executor, config: mockConfig }; + + const result = await checkPolicy('main', deps); + + expect(result.protectionInfo).not.toBeNull(); + expect(result.protectionInfo!.gpgSignConfigured).toBe(false); + }); + + // 5. SCM API returns protection rule with requirePullRequest=true — warning about PR + test('SCM API returns requirePullRequest=true — warning about PR', async () => { + const executor = createMockExecutor(); + const scmProvider = createMockScmProvider({ + protected: true, + allowForcePush: false, + requirePullRequest: true, + requiredReviewers: 0, + requiredStatusChecks: [], + requireSignedCommits: false, + }); + const deps: PolicyCheckerDeps = { executor, scmProvider, config: mockConfig }; + + const result = await checkPolicy('main', deps); + + expect(result.warnings.some((w) => w.includes('pull request'))).toBe(true); + expect(result.protectionInfo).not.toBeNull(); + expect(result.protectionInfo!.requirePullRequest).toBe(true); + expect(result.protectionInfo!.source).toBe('scm-api'); + }); + + // 6. SCM API returns requiredReviewers=2 — warning about reviewers + test('SCM API returns requiredReviewers=2 — warning about reviewers', async () => { + const executor = createMockExecutor(); + const scmProvider = createMockScmProvider({ + protected: true, + allowForcePush: false, + requirePullRequest: false, + requiredReviewers: 2, + requiredStatusChecks: [], + requireSignedCommits: false, + }); + const deps: PolicyCheckerDeps = { executor, scmProvider, config: mockConfig }; + + const result = await checkPolicy('main', deps); + + expect(result.warnings.some((w) => w.includes('2') && w.includes('reviewer'))).toBe(true); + expect(result.protectionInfo).not.toBeNull(); + expect(result.protectionInfo!.requiredReviewers).toBe(2); + }); + + // 7. SCM API returns requiredStatusChecks=['ci/build'] — warning about status checks + test('SCM API returns requiredStatusChecks — warning about status checks', async () => { + const executor = createMockExecutor(); + const scmProvider = createMockScmProvider({ + protected: true, + allowForcePush: false, + requirePullRequest: false, + requiredReviewers: 0, + requiredStatusChecks: ['ci/build'], + requireSignedCommits: false, + }); + const deps: PolicyCheckerDeps = { executor, scmProvider, config: mockConfig }; + + const result = await checkPolicy('main', deps); + + expect(result.warnings.some((w) => w.includes('status checks') && w.includes('ci/build'))).toBe(true); + expect(result.protectionInfo).not.toBeNull(); + expect(result.protectionInfo!.requiredStatusChecks).toEqual(['ci/build']); + }); + + // 8. SCM API returns requireSignedCommits=true — warning about signed commits + test('SCM API returns requireSignedCommits=true — warning about signed commits', async () => { + const executor = createMockExecutor(); + const scmProvider = createMockScmProvider({ + protected: true, + allowForcePush: false, + requirePullRequest: false, + requiredReviewers: 0, + requiredStatusChecks: [], + requireSignedCommits: true, + }); + const deps: PolicyCheckerDeps = { executor, scmProvider, config: mockConfig }; + + const result = await checkPolicy('main', deps); + + expect(result.warnings.some((w) => w.includes('signed commits'))).toBe(true); + expect(result.protectionInfo).not.toBeNull(); + expect(result.protectionInfo!.requireSignedCommits).toBe(true); + }); + + // 9. SCM API error → warning (not error), pipeline continues, protectionInfo null + test('SCM API error → warning (not error), protectionInfo null', async () => { + const executor = createMockExecutor(); + const scmProvider = createMockScmProvider(new Error('401 Unauthorized')); + const deps: PolicyCheckerDeps = { executor, scmProvider, config: mockConfig }; + + const result = await checkPolicy('main', deps); + + expect(result.errors).toEqual([]); + expect(result.warnings.some((w) => w.includes('Could not check') || w.includes('401'))).toBe(true); + expect(result.protectionInfo).toBeNull(); + }); + + // 10. No SCM_Provider → only local checks + test('no SCM_Provider → only local checks, no SCM warnings', async () => { + const executor = createMockExecutor({ + 'git config --get-regexp branch.main.': 'branch.main.pushRemote upstream', + }); + // No scmProvider in deps + const deps: PolicyCheckerDeps = { executor, config: mockConfig }; + + const result = await checkPolicy('main', deps); + + // Should have local pushRemote warning but no SCM-related warnings + expect(result.warnings.some((w) => w.includes('pushRemote'))).toBe(true); + expect(result.warnings.some((w) => w.includes('Could not check'))).toBe(false); + expect(result.protectionInfo).not.toBeNull(); + expect(result.protectionInfo!.source).toBe('git-config'); + }); + + // 11. Aggregation of local and remote results — both sources + test('aggregation of local and remote results — source is "both"', async () => { + const executor = createMockExecutor({ + 'git config --get-regexp branch.main.': 'branch.main.pushRemote upstream', + 'commit.gpgSign': 'true', + }); + const scmProvider = createMockScmProvider({ + protected: true, + allowForcePush: false, + requirePullRequest: true, + requiredReviewers: 1, + requiredStatusChecks: ['ci/test'], + requireSignedCommits: true, + }); + const deps: PolicyCheckerDeps = { executor, scmProvider, config: mockConfig }; + + const result = await checkPolicy('main', deps); + + // Should have warnings from both local and remote + expect(result.warnings.some((w) => w.includes('pushRemote'))).toBe(true); + expect(result.warnings.some((w) => w.includes('pull request'))).toBe(true); + expect(result.warnings.some((w) => w.includes('reviewer'))).toBe(true); + expect(result.warnings.some((w) => w.includes('status checks'))).toBe(true); + expect(result.warnings.some((w) => w.includes('signed commits'))).toBe(true); + + // protectionInfo should aggregate both sources + expect(result.protectionInfo).not.toBeNull(); + expect(result.protectionInfo!.source).toBe('both'); + expect(result.protectionInfo!.protected).toBe(true); + expect(result.protectionInfo!.gpgSignConfigured).toBe(true); + expect(result.protectionInfo!.requirePullRequest).toBe(true); + expect(result.protectionInfo!.requiredReviewers).toBe(1); + expect(result.protectionInfo!.requiredStatusChecks).toEqual(['ci/test']); + expect(result.protectionInfo!.requireSignedCommits).toBe(true); + + expect(result.errors).toEqual([]); + }); +}); diff --git a/__tests__/unit/branching/release.strategy.test.ts b/__tests__/unit/branching/release.strategy.test.ts new file mode 100644 index 0000000..0671eae --- /dev/null +++ b/__tests__/unit/branching/release.strategy.test.ts @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createReleaseBranchStrategy } from '../../../src/branching/strategies/release.strategy'; +import type { VersioningsConfig } from '../../../src/config/config.validator'; +import type { StrategyParams } from '../../../src/branching/branching.strategy'; + +// --------------------------------------------------------------------------- +// Minimal mock config +// --------------------------------------------------------------------------- + +const mockConfig = { + git: { + platform: 'github', + url: 'https://github.com/test/repo', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + prepatch: 'Prepatch: v%s.', + patch: 'Patch: v%s.', + preminor: 'Preminor: v%s.', + minor: 'Minor: v%s.', + premajor: 'Premajor: v%s.', + major: 'Major: v%s.', + prerelease: 'Prerelease: v%s.', + }, + }, + }, + }, + package: { + semver: { + patch: 'patch', + prepatch: 'prepatch', + minor: 'minor', + preminor: 'preminor', + premajor: 'premajor', + prerelease: 'prerelease', + major: 'major', + }, + }, + common: { messages: {} }, +} as unknown as VersioningsConfig; + +function makeParams(overrides: Partial = {}): StrategyParams { + return { + semver: 'minor', + version: '1.2.0', + comment: 'new-feature', + config: mockConfig, + currentBranch: 'master', + ...overrides, + }; +} + + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createReleaseBranchStrategy', () => { + test('name() returns "release-branch"', () => { + const strategy = createReleaseBranchStrategy(mockConfig); + expect(strategy.name()).toBe('release-branch'); + }); + + // ------------------------------------------------------------------------- + // composeBranchName — branch creation and reuse + // ------------------------------------------------------------------------- + + describe('composeBranchName', () => { + test('minor → release/{version} with reuseBranch: false', () => { + const strategy = createReleaseBranchStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams({ semver: 'minor', version: '1.2.0' })); + expect(result).toEqual({ branchName: 'release/1.2.0', reuseBranch: false }); + }); + + test('patch with Z>0 → release/{X}.{Y}.0 with reuseBranch: true', () => { + const strategy = createReleaseBranchStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams({ semver: 'patch', version: '1.2.3' })); + expect(result).toEqual({ branchName: 'release/1.2.0', reuseBranch: true }); + }); + + test('patch with Z=0 → release/{version} with reuseBranch: false', () => { + const strategy = createReleaseBranchStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams({ semver: 'patch', version: '1.2.0' })); + expect(result).toEqual({ branchName: 'release/1.2.0', reuseBranch: false }); + }); + + test('major → release/{version} with reuseBranch: false', () => { + const strategy = createReleaseBranchStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams({ semver: 'major', version: '2.0.0' })); + expect(result).toEqual({ branchName: 'release/2.0.0', reuseBranch: false }); + }); + + test('uses custom branchTemplate when provided', () => { + const configWithTemplate = { + ...mockConfig, + git: { + ...mockConfig.git, + branching: { branchTemplate: 'rel/{version}' }, + }, + } as unknown as VersioningsConfig; + const strategy = createReleaseBranchStrategy(configWithTemplate); + const result = strategy.composeBranchName(makeParams({ config: configWithTemplate })); + expect(result).toEqual({ branchName: 'rel/1.2.0', reuseBranch: false }); + }); + }); + + // ------------------------------------------------------------------------- + // composeTagName + // ------------------------------------------------------------------------- + + describe('composeTagName', () => { + test('returns v{version}', () => { + const strategy = createReleaseBranchStrategy(mockConfig); + expect(strategy.composeTagName(makeParams())).toBe('v1.2.0'); + }); + + test('uses custom tagTemplate when provided', () => { + const configWithTemplate = { + ...mockConfig, + git: { + ...mockConfig.git, + branching: { tagTemplate: 'release-{version}' }, + }, + } as unknown as VersioningsConfig; + const strategy = createReleaseBranchStrategy(configWithTemplate); + expect(strategy.composeTagName(makeParams({ config: configWithTemplate }))).toBe('release-1.2.0'); + }); + }); + + // ------------------------------------------------------------------------- + // composeCommitMessage + // ------------------------------------------------------------------------- + + describe('composeCommitMessage', () => { + test('uses commit message template from config', () => { + const strategy = createReleaseBranchStrategy(mockConfig); + expect(strategy.composeCommitMessage(makeParams({ semver: 'minor', version: '1.2.0' }))).toBe('Minor: 1.2.0.'); + }); + }); + + // ------------------------------------------------------------------------- + // validateContext — always valid + // ------------------------------------------------------------------------- + + describe('validateContext', () => { + test('always returns valid', () => { + const strategy = createReleaseBranchStrategy(mockConfig); + expect(strategy.validateContext(makeParams())).toEqual({ valid: true, errors: [] }); + }); + + test('valid on any branch', () => { + const strategy = createReleaseBranchStrategy(mockConfig); + expect(strategy.validateContext(makeParams({ currentBranch: 'release/1.2.0' }))).toEqual({ valid: true, errors: [] }); + expect(strategy.validateContext(makeParams({ currentBranch: 'master' }))).toEqual({ valid: true, errors: [] }); + expect(strategy.validateContext(makeParams({ currentBranch: 'develop' }))).toEqual({ valid: true, errors: [] }); + }); + }); +}); diff --git a/__tests__/unit/branching/strategy.registry.test.ts b/__tests__/unit/branching/strategy.registry.test.ts new file mode 100644 index 0000000..beb8a4d --- /dev/null +++ b/__tests__/unit/branching/strategy.registry.test.ts @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createStrategyRegistry } from '../../../src/branching/strategy.registry'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; +import type { VersioningsConfig } from '../../../src/config/config.validator'; +import type { Branching_Strategy } from '../../../src/branching/branching.strategy'; + +// --------------------------------------------------------------------------- +// Minimal mock config for testing +// --------------------------------------------------------------------------- + +const mockConfig = { + git: { + platform: 'github', + url: 'https://github.com/test/repo', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { message: { semver: { prepatch: '', patch: '', preminor: '', minor: '', premajor: '', major: '', prerelease: '' } } }, + }, + package: { semver: { patch: 'patch', prepatch: 'prepatch', minor: 'minor', preminor: 'preminor', premajor: 'premajor', prerelease: 'prerelease', major: 'major' } }, + common: { messages: {} }, +} as unknown as VersioningsConfig; + +// --------------------------------------------------------------------------- +// All 6 default strategies +// --------------------------------------------------------------------------- + +const ALL_STRATEGIES = [ + 'default', + 'trunk-based', + 'git-flow', + 'release-branch', + 'hotfix', + 'maintenance', +]; + +// --------------------------------------------------------------------------- +// availableStrategies() returns all 6 strategies +// --------------------------------------------------------------------------- + +describe('availableStrategies()', () => { + test('returns all 6 default strategies', () => { + const registry = createStrategyRegistry(); + const strategies = registry.availableStrategies(); + expect(strategies).toHaveLength(6); + for (const s of ALL_STRATEGIES) { + expect(strategies).toContain(s); + } + }); +}); + + +// --------------------------------------------------------------------------- +// getStrategy returns a strategy for each registered name, name() matches +// --------------------------------------------------------------------------- + +describe('getStrategy for each strategy name', () => { + const registry = createStrategyRegistry(); + + test.each(ALL_STRATEGIES)('returns a strategy for name "%s"', (name) => { + const strategy = registry.getStrategy(name, mockConfig); + expect(strategy).toBeDefined(); + expect(typeof strategy.name).toBe('function'); + expect(typeof strategy.composeBranchName).toBe('function'); + expect(typeof strategy.composeTagName).toBe('function'); + expect(typeof strategy.composeCommitMessage).toBe('function'); + expect(typeof strategy.validateContext).toBe('function'); + }); +}); + +describe('name() matches the strategy name', () => { + const registry = createStrategyRegistry(); + + test.each(ALL_STRATEGIES)('strategy.name() === "%s"', (name) => { + const strategy = registry.getStrategy(name, mockConfig); + expect(strategy.name()).toBe(name); + }); +}); + +// --------------------------------------------------------------------------- +// Unknown strategy → VersioningsError(CONFIG_ERROR) with available strategies +// --------------------------------------------------------------------------- + +describe('unknown strategy throws VersioningsError', () => { + const registry = createStrategyRegistry(); + + test('throws VersioningsError with CONFIG_ERROR code', () => { + expect(() => registry.getStrategy('nonexistent', mockConfig)).toThrow(VersioningsError); + }); + + test('error code is CONFIG_ERROR', () => { + try { + registry.getStrategy('nonexistent', mockConfig); + fail('Expected VersioningsError'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }); + + test('error message lists all available strategies', () => { + try { + registry.getStrategy('nonexistent', mockConfig); + fail('Expected VersioningsError'); + } catch (err) { + const message = (err as VersioningsError).message; + for (const s of ALL_STRATEGIES) { + expect(message).toContain(s); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// register() allows adding a custom strategy +// --------------------------------------------------------------------------- + +describe('register custom strategy', () => { + test('registers a new strategy and getStrategy returns it', () => { + const registry = createStrategyRegistry(); + + const customStrategy: Branching_Strategy = { + name: () => 'custom', + composeBranchName: jest.fn(), + composeTagName: jest.fn().mockReturnValue('v1.0.0'), + composeCommitMessage: jest.fn().mockReturnValue('bump'), + validateContext: jest.fn().mockReturnValue({ valid: true, errors: [] }), + }; + + registry.register('custom', () => customStrategy); + + const strategy = registry.getStrategy('custom', mockConfig); + expect(strategy.name()).toBe('custom'); + }); + + test('registered strategy appears in availableStrategies()', () => { + const registry = createStrategyRegistry(); + registry.register('custom', () => ({ + name: () => 'custom', + composeBranchName: jest.fn(), + composeTagName: jest.fn().mockReturnValue(''), + composeCommitMessage: jest.fn().mockReturnValue(''), + validateContext: jest.fn().mockReturnValue({ valid: true, errors: [] }), + })); + expect(registry.availableStrategies()).toContain('custom'); + }); + + test('overrides an existing default strategy factory', () => { + const registry = createStrategyRegistry(); + + const overriddenStrategy: Branching_Strategy = { + name: () => 'default', + composeBranchName: jest.fn().mockReturnValue({ branchName: 'overridden/branch', reuseBranch: false }), + composeTagName: jest.fn().mockReturnValue('overridden-tag'), + composeCommitMessage: jest.fn().mockReturnValue('overridden commit'), + validateContext: jest.fn().mockReturnValue({ valid: true, errors: [] }), + }; + + registry.register('default', () => overriddenStrategy); + + const strategy = registry.getStrategy('default', mockConfig); + expect(strategy.composeTagName({} as any)).toBe('overridden-tag'); + }); +}); diff --git a/__tests__/unit/branching/trunk.strategy.test.ts b/__tests__/unit/branching/trunk.strategy.test.ts new file mode 100644 index 0000000..6290a3d --- /dev/null +++ b/__tests__/unit/branching/trunk.strategy.test.ts @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createTrunkStrategy } from '../../../src/branching/strategies/trunk.strategy'; +import type { VersioningsConfig } from '../../../src/config/config.validator'; +import type { StrategyParams } from '../../../src/branching/branching.strategy'; + +// --------------------------------------------------------------------------- +// Minimal mock config +// --------------------------------------------------------------------------- + +const mockConfig = { + git: { + platform: 'github', + url: 'https://github.com/test/repo', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + prepatch: 'Prepatch: v%s.', + patch: 'Patch: v%s.', + preminor: 'Preminor: v%s.', + minor: 'Minor: v%s.', + premajor: 'Premajor: v%s.', + major: 'Major: v%s.', + prerelease: 'Prerelease: v%s.', + }, + }, + }, + }, + package: { + semver: { + patch: 'patch', + prepatch: 'prepatch', + minor: 'minor', + preminor: 'preminor', + premajor: 'premajor', + prerelease: 'prerelease', + major: 'major', + }, + }, + common: { messages: {} }, +} as unknown as VersioningsConfig; + +function makeParams(overrides: Partial = {}): StrategyParams { + return { + semver: 'patch', + version: '1.2.3', + comment: 'fix-login', + config: mockConfig, + currentBranch: 'master', + ...overrides, + }; +} + + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createTrunkStrategy', () => { + test('name() returns "trunk-based"', () => { + const strategy = createTrunkStrategy(mockConfig); + expect(strategy.name()).toBe('trunk-based'); + }); + + // ------------------------------------------------------------------------- + // composeBranchName — always null + // ------------------------------------------------------------------------- + + describe('composeBranchName', () => { + test('returns { branchName: null, reuseBranch: false }', () => { + const strategy = createTrunkStrategy(mockConfig); + const result = strategy.composeBranchName(makeParams()); + expect(result).toEqual({ branchName: null, reuseBranch: false }); + }); + + test('returns null branch regardless of semver type', () => { + const strategy = createTrunkStrategy(mockConfig); + expect(strategy.composeBranchName(makeParams({ semver: 'minor' })).branchName).toBeNull(); + expect(strategy.composeBranchName(makeParams({ semver: 'major' })).branchName).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // composeTagName — v{version} + // ------------------------------------------------------------------------- + + describe('composeTagName', () => { + test('returns v{version} by default', () => { + const strategy = createTrunkStrategy(mockConfig); + expect(strategy.composeTagName(makeParams())).toBe('v1.2.3'); + }); + + test('returns v{version} for different versions', () => { + const strategy = createTrunkStrategy(mockConfig); + expect(strategy.composeTagName(makeParams({ version: '2.0.0' }))).toBe('v2.0.0'); + }); + + test('uses custom tagTemplate when provided', () => { + const configWithTemplate = { + ...mockConfig, + git: { + ...mockConfig.git, + branching: { tagTemplate: 'release-{version}' }, + }, + } as unknown as VersioningsConfig; + const strategy = createTrunkStrategy(configWithTemplate); + expect(strategy.composeTagName(makeParams({ config: configWithTemplate }))).toBe('release-1.2.3'); + }); + }); + + // ------------------------------------------------------------------------- + // composeCommitMessage + // ------------------------------------------------------------------------- + + describe('composeCommitMessage', () => { + test('uses commit message template from config', () => { + const strategy = createTrunkStrategy(mockConfig); + expect(strategy.composeCommitMessage(makeParams())).toBe('Patch: 1.2.3.'); + }); + }); + + // ------------------------------------------------------------------------- + // validateContext — main/master only + // ------------------------------------------------------------------------- + + describe('validateContext', () => { + test('valid on master', () => { + const strategy = createTrunkStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ currentBranch: 'master' })); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + test('valid on main', () => { + const strategy = createTrunkStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ currentBranch: 'main' })); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + test('invalid on feature/xxx', () => { + const strategy = createTrunkStrategy(mockConfig); + const result = strategy.validateContext(makeParams({ currentBranch: 'feature/xxx' })); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('Trunk-based strategy requires current branch to be "master" or "main"'); + }); + + test('valid on custom mainBranch', () => { + const configWithMain = { + ...mockConfig, + git: { + ...mockConfig.git, + branching: { mainBranch: 'production' }, + }, + } as unknown as VersioningsConfig; + const strategy = createTrunkStrategy(configWithMain); + const result = strategy.validateContext(makeParams({ currentBranch: 'production' })); + expect(result).toEqual({ valid: true, errors: [] }); + }); + }); +}); diff --git a/__tests__/unit/cli/changelog.command.test.ts b/__tests__/unit/cli/changelog.command.test.ts new file mode 100644 index 0000000..056ce8f --- /dev/null +++ b/__tests__/unit/cli/changelog.command.test.ts @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { PassThrough } from 'stream'; +import { runChangelogCommand, ChangelogCommandOpts, ChangelogCommandDeps } from '../../../src/cli/commands/changelog.command'; +import { COMMIT_SEPARATOR, GIT_LOG_FORMAT } from '../../../src/versioning/commit.parser'; +import { DEFAULT_BUMP_POLICY } from '../../../src/versioning/commit.analyzer'; +import { DEFAULT_GROUP_TITLES } from '../../../src/versioning/changelog.generator'; +import type { Executor, ExecutorResult } from '../../../src/core/executor'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/** Build a mock git log output string from commit entries */ +function buildGitLogOutput(commits: Array<{ hash: string; message: string }>): string { + return commits + .map((c) => `${c.hash}\n${c.message}\n${COMMIT_SEPARATOR}`) + .join('\n'); +} + +const SAMPLE_COMMITS = [ + { hash: 'aaa1111111111111111111111111111111111111', message: 'feat(auth): add login endpoint' }, + { hash: 'bbb2222222222222222222222222222222222222', message: 'fix(db): resolve connection leak' }, + { hash: 'ccc3333333333333333333333333333333333333', message: 'chore: update deps' }, +]; + +const SAMPLE_GIT_LOG = buildGitLogOutput(SAMPLE_COMMITS); + +function createMockExecutor(overrides: Record = {}): Executor { + const defaults: Record = { + [`git tag --list "v*" --sort=-version:refSort`]: 'v1.0.0', + [`git log v1.0.0..HEAD --format="${GIT_LOG_FORMAT}"`]: SAMPLE_GIT_LOG, + }; + + return { + run: jest.fn(async (cmd: string): Promise => { + const val = overrides[cmd] ?? defaults[cmd]; + if (val instanceof Error) throw val; + if (val !== undefined) { + const s = val as string; + return { stdout: s, lines: s.split('\n').filter(Boolean) }; + } + // Fallback: return empty for unknown commands + return { stdout: '', lines: [] }; + }), + }; +} + +function createDeps(overrides: Partial = {}): ChangelogCommandDeps & { stdout: PassThrough } { + const stdout = new PassThrough(); + stdout.setEncoding('utf8'); + + return { + executor: overrides.executor ?? createMockExecutor(), + bumpPolicy: overrides.bumpPolicy ?? DEFAULT_BUMP_POLICY, + changelogConfig: overrides.changelogConfig ?? { + groupTitles: DEFAULT_GROUP_TITLES, + excludeTypes: [], + includeNonConventional: false, + }, + stdout: overrides.stdout as any ?? stdout, + existsSync: overrides.existsSync ?? (() => false), + readFileSync: overrides.readFileSync ?? (() => ''), + writeFileSync: overrides.writeFileSync ?? jest.fn(), + ...overrides, + }; +} + +function drainStdout(stdout: PassThrough): string { + let output = ''; + let chunk: string | null; + while ((chunk = stdout.read() as string | null) !== null) { + output += chunk; + } + return output; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('changelog.command — generate to stdout', () => { + test('outputs markdown changelog to stdout by default', async () => { + const deps = createDeps(); + + await runChangelogCommand({ json: false }, deps); + + const output = drainStdout(deps.stdout); + expect(output).toContain('Features'); + expect(output).toContain('add login endpoint'); + expect(output).toContain('Bug Fixes'); + expect(output).toContain('resolve connection leak'); + }); + + test('does not include chore commits (mapped to none in default policy)', async () => { + const deps = createDeps(); + + await runChangelogCommand({ json: false }, deps); + + const output = drainStdout(deps.stdout); + expect(output).not.toContain('update deps'); + }); + + test('includes markdown header with Unreleased and date', async () => { + const deps = createDeps(); + + await runChangelogCommand({ json: false }, deps); + + const output = drainStdout(deps.stdout); + expect(output).toMatch(/^## \[Unreleased\] - \d{4}-\d{2}-\d{2}/); + }); +}); + +describe('changelog.command — --output writes to file', () => { + test('writes changelog to a new file when file does not exist', async () => { + const writeFileSync = jest.fn(); + const deps = createDeps({ + existsSync: () => false, + writeFileSync, + }); + + await runChangelogCommand({ json: false, output: 'CHANGELOG.md' }, deps); + + expect(writeFileSync).toHaveBeenCalledTimes(1); + const [path, content, encoding] = writeFileSync.mock.calls[0]; + expect(path).toBe('CHANGELOG.md'); + expect(encoding).toBe('utf8'); + expect(content).toContain('# Changelog'); + expect(content).toContain('Features'); + expect(content).toContain('add login endpoint'); + }); + + test('does not write to stdout when --output is specified', async () => { + const writeFileSync = jest.fn(); + const deps = createDeps({ + existsSync: () => false, + writeFileSync, + }); + + await runChangelogCommand({ json: false, output: 'CHANGELOG.md' }, deps); + + const output = drainStdout(deps.stdout); + expect(output).toBe(''); + }); +}); + +describe('changelog.command — --json outputs JSON', () => { + test('outputs valid JSON with required fields', async () => { + const deps = createDeps(); + + await runChangelogCommand({ json: true }, deps); + + const output = drainStdout(deps.stdout); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty('version', null); + expect(parsed).toHaveProperty('date'); + expect(parsed).toHaveProperty('groups'); + expect(parsed).toHaveProperty('range'); + expect(parsed).toHaveProperty('markdown'); + expect(parsed.range).toHaveProperty('from'); + expect(parsed.range).toHaveProperty('to'); + }); + + test('JSON groups contain correct commit data', async () => { + const deps = createDeps(); + + await runChangelogCommand({ json: true }, deps); + + const output = drainStdout(deps.stdout); + const parsed = JSON.parse(output); + expect(Array.isArray(parsed.groups)).toBe(true); + const featGroup = parsed.groups.find((g: any) => g.title === 'Features'); + expect(featGroup).toBeDefined(); + expect(featGroup.commits.some((c: any) => c.description === 'add login endpoint')).toBe(true); + }); + + test('JSON markdown field contains rendered changelog', async () => { + const deps = createDeps(); + + await runChangelogCommand({ json: true }, deps); + + const output = drainStdout(deps.stdout); + const parsed = JSON.parse(output); + expect(parsed.markdown).toContain('Features'); + expect(parsed.markdown).toContain('add login endpoint'); + }); +}); + +describe('changelog.command — --from/--to specify range', () => { + test('uses --from and --to to build git log command', async () => { + const executor = createMockExecutor({ + [`git log v0.5.0..v1.0.0 --format="${GIT_LOG_FORMAT}"`]: SAMPLE_GIT_LOG, + }); + const deps = createDeps({ executor }); + + await runChangelogCommand({ json: false, from: 'v0.5.0', to: 'v1.0.0' }, deps); + + expect(executor.run).toHaveBeenCalledWith( + `git log v0.5.0..v1.0.0 --format="${GIT_LOG_FORMAT}"`, + ); + const output = drainStdout(deps.stdout); + expect(output).toContain('Features'); + }); + + test('uses --from with default --to=HEAD', async () => { + const executor = createMockExecutor({ + [`git log v0.5.0..HEAD --format="${GIT_LOG_FORMAT}"`]: SAMPLE_GIT_LOG, + }); + const deps = createDeps({ executor }); + + await runChangelogCommand({ json: false, from: 'v0.5.0' }, deps); + + expect(executor.run).toHaveBeenCalledWith( + `git log v0.5.0..HEAD --format="${GIT_LOG_FORMAT}"`, + ); + }); +}); + + +describe('changelog.command — prepend to existing file', () => { + test('prepends new section after # Changelog header in existing file', async () => { + const existingContent = '# Changelog\n\n## [1.0.0] - 2024-01-01\n\n### Features\n\n- old feature\n'; + const writeFileSync = jest.fn(); + const deps = createDeps({ + existsSync: () => true, + readFileSync: () => existingContent, + writeFileSync, + }); + + await runChangelogCommand({ json: false, output: 'CHANGELOG.md' }, deps); + + expect(writeFileSync).toHaveBeenCalledTimes(1); + const content: string = writeFileSync.mock.calls[0][1]; + // New content should start with # Changelog header + expect(content).toMatch(/^# Changelog/); + // New section should appear before old content + const newSectionIdx = content.indexOf('add login endpoint'); + const oldSectionIdx = content.indexOf('old feature'); + expect(newSectionIdx).toBeGreaterThan(-1); + expect(oldSectionIdx).toBeGreaterThan(-1); + expect(newSectionIdx).toBeLessThan(oldSectionIdx); + }); + + test('prepends to existing file without # Changelog header', async () => { + const existingContent = '## [1.0.0] - 2024-01-01\n\n- old feature\n'; + const writeFileSync = jest.fn(); + const deps = createDeps({ + existsSync: () => true, + readFileSync: () => existingContent, + writeFileSync, + }); + + await runChangelogCommand({ json: false, output: 'CHANGELOG.md' }, deps); + + expect(writeFileSync).toHaveBeenCalledTimes(1); + const content: string = writeFileSync.mock.calls[0][1]; + // New section should appear before old content + const newSectionIdx = content.indexOf('add login endpoint'); + const oldSectionIdx = content.indexOf('old feature'); + expect(newSectionIdx).toBeGreaterThan(-1); + expect(oldSectionIdx).toBeGreaterThan(-1); + expect(newSectionIdx).toBeLessThan(oldSectionIdx); + }); +}); + +describe('changelog.command — no commits → NO_OPERATION (exit 8)', () => { + test('throws VersioningsError with NO_OPERATION when no commits in range', async () => { + const executor = createMockExecutor({ + [`git tag --list "v*" --sort=-version:refSort`]: 'v1.0.0', + [`git log v1.0.0..HEAD --format="${GIT_LOG_FORMAT}"`]: '', + }); + const deps = createDeps({ executor }); + + await expect( + runChangelogCommand({ json: false }, deps), + ).rejects.toThrow(VersioningsError); + + try { + await runChangelogCommand({ json: false }, deps); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.NO_OPERATION); + expect(err.message).toContain('No commits found'); + } + }); + + test('throws NO_OPERATION when git log returns only whitespace', async () => { + const executor = createMockExecutor({ + [`git tag --list "v*" --sort=-version:refSort`]: 'v1.0.0', + [`git log v1.0.0..HEAD --format="${GIT_LOG_FORMAT}"`]: ' \n \n ', + }); + const deps = createDeps({ executor }); + + await expect( + runChangelogCommand({ json: false }, deps), + ).rejects.toThrow(VersioningsError); + }); +}); + +describe('changelog.command — markdown vs plain format', () => { + test('markdown format includes ### headers for groups', async () => { + const deps = createDeps(); + + await runChangelogCommand({ json: false, format: 'markdown' }, deps); + + const output = drainStdout(deps.stdout); + expect(output).toContain('### Features'); + expect(output).toContain('### Bug Fixes'); + expect(output).toMatch(/^## \[Unreleased\]/); + }); + + test('plain format does not include markdown headers', async () => { + const deps = createDeps(); + + await runChangelogCommand({ json: false, format: 'plain' }, deps); + + const output = drainStdout(deps.stdout); + expect(output).not.toContain('### '); + expect(output).not.toContain('## '); + expect(output).toContain('Features'); + expect(output).toContain('Bug Fixes'); + expect(output).toMatch(/^\[Unreleased\]/); + }); +}); + +describe('changelog.command — no tags fallback', () => { + test('falls back to root commit range when no version tags exist', async () => { + const rootHash = 'ddd4444444444444444444444444444444444444'; + const executor = createMockExecutor({ + [`git tag --list "v*" --sort=-version:refSort`]: '', + [`git rev-list --max-parents=0 HEAD`]: rootHash, + [`git log ${rootHash}..HEAD --format="${GIT_LOG_FORMAT}"`]: SAMPLE_GIT_LOG, + }); + const deps = createDeps({ executor }); + + await runChangelogCommand({ json: false }, deps); + + expect(executor.run).toHaveBeenCalledWith( + `git log ${rootHash}..HEAD --format="${GIT_LOG_FORMAT}"`, + ); + const output = drainStdout(deps.stdout); + expect(output).toContain('Features'); + }); + + test('uses git log HEAD when findLastVersionTag returns null', async () => { + // Simulate both git tag and git rev-list failing → findLastVersionTag returns null → from='' + const executor: Executor = { + run: jest.fn(async (cmd: string): Promise => { + if (cmd.includes('git tag --list')) { + throw new VersioningsError(EXIT_CODES.COMMAND_FAILED, 'no tags', {}); + } + if (cmd.includes('git rev-list')) { + throw new VersioningsError(EXIT_CODES.COMMAND_FAILED, 'empty repo', {}); + } + if (cmd === `git log HEAD --format="${GIT_LOG_FORMAT}"`) { + return { stdout: SAMPLE_GIT_LOG, lines: SAMPLE_GIT_LOG.split('\n').filter(Boolean) }; + } + return { stdout: '', lines: [] }; + }), + }; + const deps = createDeps({ executor }); + + await runChangelogCommand({ json: false }, deps); + + expect(executor.run).toHaveBeenCalledWith( + `git log HEAD --format="${GIT_LOG_FORMAT}"`, + ); + const output = drainStdout(deps.stdout); + expect(output).toContain('Features'); + }); +}); + +describe('changelog.command — scope in output', () => { + test('includes scope in parentheses for scoped commits', async () => { + const deps = createDeps(); + + await runChangelogCommand({ json: false }, deps); + + const output = drainStdout(deps.stdout); + expect(output).toContain('add login endpoint (auth)'); + expect(output).toContain('resolve connection leak (db)'); + }); +}); diff --git a/__tests__/unit/cli/command.router.test.ts b/__tests__/unit/cli/command.router.test.ts new file mode 100644 index 0000000..baa529b --- /dev/null +++ b/__tests__/unit/cli/command.router.test.ts @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { preprocessArgv, SUBCOMMANDS, buildCli } from '../../../src/cli/command.router'; +import { VersioningsError, EXIT_CODES } from '../../../src/core/errors'; + +// --------------------------------------------------------------------------- +// Tests: SUBCOMMANDS constant +// --------------------------------------------------------------------------- + +describe('SUBCOMMANDS', () => { + it('contains all seven expected subcommands', () => { + expect(SUBCOMMANDS).toEqual(['init', 'validate', 'plan', 'release', 'rollback', 'doctor', 'changelog']); + }); + + it('has no duplicates', () => { + const unique = new Set(SUBCOMMANDS); + expect(unique.size).toBe(SUBCOMMANDS.length); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: preprocessArgv — backward compatibility routing +// Requirements: 12.1, 12.2, 12.3, 12.4, 12.5 +// --------------------------------------------------------------------------- + +describe('preprocessArgv', () => { + describe('subcommand passthrough', () => { + it.each(SUBCOMMANDS)('passes through when first arg is "%s"', (cmd) => { + const input = [cmd, '--json']; + expect(preprocessArgv(input)).toEqual(input); + }); + + it('preserves all trailing args for a known subcommand', () => { + const input = ['release', '--semver=patch', '--branch=fix', '--push']; + expect(preprocessArgv(input)).toEqual(input); + }); + }); + + describe('backward compatibility: --semver + --branch → release', () => { + it('prepends "release" when --semver and --branch are present without subcommand', () => { + const input = ['--semver=patch', '--branch=fix']; + expect(preprocessArgv(input)).toEqual(['release', '--semver=patch', '--branch=fix']); + }); + + it('prepends "release" with additional flags', () => { + const input = ['--semver=minor', '--branch=feat', '--push', '--json']; + expect(preprocessArgv(input)).toEqual(['release', '--semver=minor', '--branch=feat', '--push', '--json']); + }); + + it('prepends "release" when --semver and --branch use space-separated values', () => { + const input = ['--semver', 'patch', '--branch', 'fix']; + expect(preprocessArgv(input)).toEqual(['release', '--semver', 'patch', '--branch', 'fix']); + }); + }); + + describe('no subcommand and no --semver/--branch → unchanged (help)', () => { + it('returns empty array unchanged', () => { + expect(preprocessArgv([])).toEqual([]); + }); + + it('returns args unchanged when only --json is passed', () => { + const input = ['--json']; + expect(preprocessArgv(input)).toEqual(input); + }); + + it('returns args unchanged when only --semver is passed (no --branch)', () => { + const input = ['--semver=patch']; + expect(preprocessArgv(input)).toEqual(input); + }); + + it('returns args unchanged when only --branch is passed (no --semver)', () => { + const input = ['--branch=fix']; + expect(preprocessArgv(input)).toEqual(input); + }); + }); + + describe('unknown first arg that is not a flag', () => { + it('does not prepend release for unknown subcommand without --semver/--branch', () => { + const input = ['nonexistent']; + expect(preprocessArgv(input)).toEqual(input); + }); + + it('does not prepend release for unknown subcommand even with --semver/--branch', () => { + // 'nonexistent' is not a flag (no --) and not in SUBCOMMANDS, + // so hasSubcommand is false. But --semver and --branch are present → prepends release. + const input = ['nonexistent', '--semver=patch', '--branch=fix']; + // Since 'nonexistent' doesn't start with '-' but is not in SUBCOMMANDS, + // hasSubcommand = false, hasSemver = true, hasBranch = true → prepend release + expect(preprocessArgv(input)).toEqual(['release', 'nonexistent', '--semver=patch', '--branch=fix']); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: buildCli — yargs routing +// Requirements: 12.1, 12.4, 12.5 +// --------------------------------------------------------------------------- + +describe('buildCli', () => { + describe('subcommand routing', () => { + it.each(SUBCOMMANDS)('recognizes "%s" as a valid subcommand', (cmd) => { + // For commands that require --semver/--branch, provide them + const needsArgs = ['plan', 'release']; + const args = needsArgs.includes(cmd) + ? [cmd, '--semver=patch', '--branch=fix'] + : [cmd]; + + const cli = buildCli(args); + // Parse without executing handler — just verify no error + const parsed = cli.parse(); + expect(parsed._[0]).toBe(cmd); + }); + }); + + describe('unknown subcommand → error', () => { + it('throws VersioningsError with INVALID_ARGS for unknown command', () => { + expect(() => { + const cli = buildCli(['nonexistent']); + cli.parse(); + }).toThrow(VersioningsError); + + try { + const cli = buildCli(['nonexistent']); + cli.parse(); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.INVALID_ARGS); + expect(err.message).toContain('Available commands'); + // Should list all subcommands + for (const cmd of SUBCOMMANDS) { + expect(err.message).toContain(cmd); + } + } + }); + }); + + describe('--print-config flag', () => { + it('parses --print-config as a boolean global option', () => { + const cli = buildCli(['--print-config']); + const parsed = cli.parse(); + expect(parsed['print-config']).toBe(true); + }); + + it('defaults --print-config to false', () => { + const cli = buildCli([]); + const parsed = cli.parse(); + expect(parsed['print-config']).toBe(false); + }); + }); + + describe('global options', () => { + it('parses --json, --verbose, --ci, --yes, --strict', () => { + const cli = buildCli(['validate', '--json', '--verbose', '--ci', '--yes', '--strict']); + const parsed = cli.parse(); + expect(parsed.json).toBe(true); + expect(parsed.verbose).toBe(true); + expect(parsed.ci).toBe(true); + expect(parsed.yes).toBe(true); + expect(parsed.strict).toBe(true); + }); + + it('parses -y as alias for --yes', () => { + const cli = buildCli(['validate', '-y']); + const parsed = cli.parse(); + expect(parsed.yes).toBe(true); + }); + + it('parses --non-interactive', () => { + const cli = buildCli(['validate', '--non-interactive']); + const parsed = cli.parse(); + expect(parsed['non-interactive']).toBe(true); + }); + }); +}); diff --git a/__tests__/unit/cli/doctor.command.test.ts b/__tests__/unit/cli/doctor.command.test.ts new file mode 100644 index 0000000..a369b40 --- /dev/null +++ b/__tests__/unit/cli/doctor.command.test.ts @@ -0,0 +1,966 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { PassThrough } from 'stream'; +import { runDoctorCommand, DoctorCommandDeps } from '../../../src/cli/commands/doctor.command'; +import type { Executor, ExecutorResult } from '../../../src/core/executor'; +import type { Reporter, DoctorCheck } from '../../../src/core/reporter'; +import type { ConfigLoadResult } from '../../../src/config/config.loader'; +import type { ConfigProvenance } from '../../../src/config/config.merger'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeConfigLoadResult(overrides: Partial = {}): ConfigLoadResult { + return { + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { + message: { + semver: { + prepatch: 'Patch version is preparing now: v%s.', + patch: 'Patch: v%s.', + preminor: 'Minor version is preparing now: v%s.', + minor: 'Minor: v%s.', + premajor: 'Release is preparing now: v%s.', + major: 'Release: v%s.', + prerelease: 'Preparing: v%s.', + }, + }, + }, + }, + } as any, + sources: [ + { name: 'defaults', data: {} }, + { name: 'version.json', data: { git: { platform: 'github' } }, filePath: '/tmp/version.json' }, + ], + provenance: { + 'git.platform': { value: 'github', source: 'version.json' }, + 'git.url': { value: 'https://github.com/org/repo', source: 'version.json' }, + }, + warnings: [], + ...overrides, + }; +} + +function createMockConfigLoader(result?: ConfigLoadResult, error?: Error) { + return jest.fn((_deps: any) => { + if (error) throw error; + return result ?? makeConfigLoadResult(); + }); +} + +function createMockExecutor(overrides: Record = {}): Executor { + const defaults: Record = { + 'git --version': 'git version 2.43.0', + 'git remote get-url origin': 'https://github.com/org/repo', + }; + + return { + run: jest.fn(async (cmd: string): Promise => { + const val = overrides[cmd] ?? defaults[cmd]; + if (val instanceof Error) throw val; + if (val !== undefined) { + const s = val as string; + return { stdout: s, lines: s.split('\n').filter(Boolean) }; + } + throw new VersioningsError(EXIT_CODES.COMMAND_FAILED, `Unknown cmd: ${cmd}`, {}); + }), + }; +} + +function createMockReporter(): Reporter { + return { + reportSuccess: jest.fn(() => ''), + reportError: jest.fn(() => ''), + reportDryRun: jest.fn(() => ''), + reportValidation: jest.fn(() => ''), + reportDoctor: jest.fn((checks: DoctorCheck[]) => JSON.stringify(checks)), + reportProvenance: jest.fn((p: ConfigProvenance) => JSON.stringify(p)), + reportConfirmPlan: jest.fn(() => ''), + }; +} + +function makeDeps(overrides: Partial = {}): DoctorCommandDeps & { stdout: PassThrough } { + const stdout = new PassThrough(); + stdout.setEncoding('utf8'); + + // Default existsSync: returns true for most paths, false for lock file + const defaultExistsSync = (p: string): boolean => !p.endsWith('/lock'); + + return { + configLoader: overrides.configLoader ?? createMockConfigLoader(), + executor: overrides.executor ?? createMockExecutor(), + reporter: overrides.reporter ?? createMockReporter(), + cwd: overrides.cwd ?? '/tmp/test-project', + env: overrides.env ?? {}, + stdout: overrides.stdout as any ?? stdout, + existsSync: overrides.existsSync ?? defaultExistsSync, + nodeVersion: overrides.nodeVersion ?? 'v20.10.0', + readFileSync: overrides.readFileSync ?? (() => { throw new Error('ENOENT'); }), + readdirSync: overrides.readdirSync ?? (() => []), + processKill: overrides.processKill ?? (() => true), + now: overrides.now ?? (() => Date.now()), + lockTimeoutMs: overrides.lockTimeoutMs ?? 300000, + }; +} + +function drainStdout(stdout: PassThrough): string { + let output = ''; + let chunk: string | null; + while ((chunk = stdout.read() as string | null) !== null) { + output += chunk; + } + return output; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('doctor.command — all checks pass', () => { + test('returns all checks with pass status when environment is healthy', async () => { + const executor = createMockExecutor({ + 'git log -10 --format=%s': 'feat: add feature\nfix: bug fix\nchore: cleanup', + }); + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + }, + conventionalCommits: { enabled: true }, + changelog: { file: 'CHANGELOG.md' }, + } as any, + }), + ); + const deps = makeDeps({ executor, configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + expect(checks).toHaveLength(10); + expect(checks.find((c) => c.name === 'node_version')?.status).toBe('pass'); + expect(checks.find((c) => c.name === 'git_version')?.status).toBe('pass'); + expect(checks.find((c) => c.name === 'config')?.status).toBe('pass'); + expect(checks.find((c) => c.name === 'git_remote')?.status).toBe('pass'); + expect(checks.find((c) => c.name === 'package_json')?.status).toBe('pass'); + expect(checks.find((c) => c.name === 'versionings_dir')?.status).toBe('pass'); + expect(checks.find((c) => c.name === 'stale_lock')?.status).toBe('pass'); + expect(checks.find((c) => c.name === 'operation_log')?.status).toBe('pass'); + expect(checks.find((c) => c.name === 'conventional_commits')?.status).toBe('pass'); + expect(checks.find((c) => c.name === 'cc_config')?.status).toBe('pass'); + }); + + test('node_version check includes found and expected values', async () => { + const deps = makeDeps({ nodeVersion: 'v20.10.0' }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const nodeCheck = checks.find((c) => c.name === 'node_version')!; + expect(nodeCheck.found).toBe('v20.10.0'); + expect(nodeCheck.expected).toContain('>= 18'); + }); + + test('git_version check extracts version string', async () => { + const deps = makeDeps(); + + const checks = await runDoctorCommand({ json: false }, deps); + + const gitCheck = checks.find((c) => c.name === 'git_version')!; + expect(gitCheck.found).toBe('2.43.0'); + }); + + test('writes reporter output to stdout', async () => { + const reporter = createMockReporter(); + const deps = makeDeps({ reporter }); + + await runDoctorCommand({ json: false }, deps); + + expect(reporter.reportDoctor).toHaveBeenCalledTimes(1); + const output = drainStdout(deps.stdout); + expect(output.length).toBeGreaterThan(0); + }); +}); + +describe('doctor.command — Node.js version fail', () => { + test('returns fail when Node.js version is below minimum', async () => { + const deps = makeDeps({ nodeVersion: 'v16.20.0' }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const nodeCheck = checks.find((c) => c.name === 'node_version')!; + expect(nodeCheck.status).toBe('fail'); + expect(nodeCheck.found).toBe('v16.20.0'); + expect(nodeCheck.expected).toContain('>= 18'); + }); + + test('returns fail for unparseable Node.js version', async () => { + const deps = makeDeps({ nodeVersion: 'unknown' }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const nodeCheck = checks.find((c) => c.name === 'node_version')!; + expect(nodeCheck.status).toBe('fail'); + }); +}); + +describe('doctor.command — Git missing', () => { + test('returns fail when git is not installed', async () => { + const executor = createMockExecutor({ + 'git --version': new VersioningsError( + EXIT_CODES.COMMAND_FAILED, 'git not found', {}, + ) as any, + }); + const deps = makeDeps({ executor }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const gitCheck = checks.find((c) => c.name === 'git_version')!; + expect(gitCheck.status).toBe('fail'); + expect(gitCheck.found).toBe('not found'); + }); +}); + +describe('doctor.command — config invalid', () => { + test('returns fail when configLoader throws', async () => { + const configLoader = createMockConfigLoader( + undefined, + new VersioningsError(EXIT_CODES.CONFIG_ERROR, 'Configuration does not match schema.', {}), + ); + const deps = makeDeps({ configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const configCheck = checks.find((c) => c.name === 'config')!; + expect(configCheck.status).toBe('fail'); + expect(configCheck.found).toContain('Configuration does not match schema.'); + }); + + test('returns warn when only defaults are loaded (no user config)', async () => { + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + sources: [{ name: 'defaults', data: {} }], + }), + ); + const deps = makeDeps({ configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const configCheck = checks.find((c) => c.name === 'config')!; + expect(configCheck.status).toBe('warn'); + expect(configCheck.found).toContain('defaults only'); + }); +}); + +describe('doctor.command — JSON output', () => { + test('reporter.reportDoctor is called with all checks', async () => { + const reporter = createMockReporter(); + const deps = makeDeps({ reporter }); + + const checks = await runDoctorCommand({ json: true }, deps); + + expect(reporter.reportDoctor).toHaveBeenCalledWith(checks); + }); + + test('each check has name, status, and found fields', async () => { + const deps = makeDeps(); + + const checks = await runDoctorCommand({ json: true }, deps); + + for (const check of checks) { + expect(check).toHaveProperty('name'); + expect(check).toHaveProperty('status'); + expect(check).toHaveProperty('found'); + expect(['pass', 'fail', 'warn']).toContain(check.status); + } + }); +}); + +describe('doctor.command — provenance in diagnostics', () => { + test('outputs provenance when config loads successfully', async () => { + const provenance: ConfigProvenance = { + 'git.platform': { value: 'github', source: 'env' }, + 'git.url': { value: 'https://github.com/org/repo', source: '.versioningsrc' }, + }; + const configLoader = createMockConfigLoader(makeConfigLoadResult({ provenance })); + const reporter = createMockReporter(); + const deps = makeDeps({ configLoader, reporter }); + + await runDoctorCommand({ json: false }, deps); + + expect(reporter.reportProvenance).toHaveBeenCalledWith(provenance); + }); + + test('does not output provenance when config fails to load', async () => { + const configLoader = createMockConfigLoader( + undefined, + new VersioningsError(EXIT_CODES.CONFIG_ERROR, 'bad config', {}), + ); + const reporter = createMockReporter(); + const deps = makeDeps({ configLoader, reporter }); + + await runDoctorCommand({ json: false }, deps); + + expect(reporter.reportProvenance).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Branching strategy check tests +// --------------------------------------------------------------------------- + +describe('doctor.command — branching strategy check', () => { + test('skips branching check when strategy is default', async () => { + const deps = makeDeps(); + + const checks = await runDoctorCommand({ json: false }, deps); + + const bsCheck = checks.find((c) => c.name === 'branching_strategy'); + expect(bsCheck).toBeUndefined(); + }); + + test('skips branching check when git.branching is absent', async () => { + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + config: { git: { platform: 'github', url: 'https://github.com/org/repo' } } as any, + }), + ); + const deps = makeDeps({ configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const bsCheck = checks.find((c) => c.name === 'branching_strategy'); + expect(bsCheck).toBeUndefined(); + }); + + test('returns pass for trunk-based when on main branch', async () => { + const executor = createMockExecutor({ + 'git rev-parse --abbrev-ref HEAD': 'main', + }); + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + branching: { strategy: 'trunk-based', mainBranch: 'master' }, + }, + } as any, + }), + ); + const deps = makeDeps({ executor, configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const bsCheck = checks.find((c) => c.name === 'branching_strategy')!; + expect(bsCheck.status).toBe('pass'); + expect(bsCheck.found).toContain('main'); + expect(bsCheck.found).toContain('trunk-based'); + }); + + test('returns pass for trunk-based when on configured mainBranch', async () => { + const executor = createMockExecutor({ + 'git rev-parse --abbrev-ref HEAD': 'master', + }); + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + branching: { strategy: 'trunk-based', mainBranch: 'master' }, + }, + } as any, + }), + ); + const deps = makeDeps({ executor, configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const bsCheck = checks.find((c) => c.name === 'branching_strategy')!; + expect(bsCheck.status).toBe('pass'); + }); + + test('returns warn for trunk-based when on feature branch', async () => { + const executor = createMockExecutor({ + 'git rev-parse --abbrev-ref HEAD': 'feature/my-feature', + }); + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + branching: { strategy: 'trunk-based', mainBranch: 'master' }, + }, + } as any, + }), + ); + const deps = makeDeps({ executor, configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const bsCheck = checks.find((c) => c.name === 'branching_strategy')!; + expect(bsCheck.status).toBe('warn'); + expect(bsCheck.expected).toContain('master'); + expect(bsCheck.expected).toContain('main'); + }); + + test('returns pass for git-flow when on develop branch', async () => { + const executor = createMockExecutor({ + 'git rev-parse --abbrev-ref HEAD': 'develop', + }); + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + branching: { strategy: 'git-flow', mainBranch: 'master', developBranch: 'develop' }, + }, + } as any, + }), + ); + const deps = makeDeps({ executor, configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const bsCheck = checks.find((c) => c.name === 'branching_strategy')!; + expect(bsCheck.status).toBe('pass'); + expect(bsCheck.found).toContain('develop'); + expect(bsCheck.found).toContain('git-flow'); + }); + + test('returns warn for git-flow when on feature branch', async () => { + const executor = createMockExecutor({ + 'git rev-parse --abbrev-ref HEAD': 'feature/login', + }); + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + branching: { strategy: 'git-flow', mainBranch: 'master', developBranch: 'develop' }, + }, + } as any, + }), + ); + const deps = makeDeps({ executor, configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const bsCheck = checks.find((c) => c.name === 'branching_strategy')!; + expect(bsCheck.status).toBe('warn'); + expect(bsCheck.expected).toContain('develop'); + expect(bsCheck.expected).toContain('master'); + }); + + test('returns pass for hotfix when on main branch', async () => { + const executor = createMockExecutor({ + 'git rev-parse --abbrev-ref HEAD': 'main', + }); + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + branching: { strategy: 'hotfix', mainBranch: 'master' }, + }, + } as any, + }), + ); + const deps = makeDeps({ executor, configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const bsCheck = checks.find((c) => c.name === 'branching_strategy')!; + expect(bsCheck.status).toBe('pass'); + }); + + test('returns pass for release-branch strategy (any branch)', async () => { + const executor = createMockExecutor({ + 'git rev-parse --abbrev-ref HEAD': 'release/1.2.0', + }); + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + branching: { strategy: 'release-branch' }, + }, + } as any, + }), + ); + const deps = makeDeps({ executor, configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const bsCheck = checks.find((c) => c.name === 'branching_strategy')!; + expect(bsCheck.status).toBe('pass'); + expect(bsCheck.found).toContain('release-branch'); + }); + + test('returns pass for maintenance strategy (any branch)', async () => { + const executor = createMockExecutor({ + 'git rev-parse --abbrev-ref HEAD': 'support/1.0', + }); + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + branching: { strategy: 'maintenance' }, + }, + } as any, + }), + ); + const deps = makeDeps({ executor, configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const bsCheck = checks.find((c) => c.name === 'branching_strategy')!; + expect(bsCheck.status).toBe('pass'); + expect(bsCheck.found).toContain('maintenance'); + }); + + test('returns warn when git rev-parse fails', async () => { + const executor = createMockExecutor({ + 'git rev-parse --abbrev-ref HEAD': new VersioningsError( + EXIT_CODES.COMMAND_FAILED, 'not a git repo', {}, + ) as any, + }); + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + branching: { strategy: 'trunk-based' }, + }, + } as any, + }), + ); + const deps = makeDeps({ executor, configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const bsCheck = checks.find((c) => c.name === 'branching_strategy')!; + expect(bsCheck.status).toBe('warn'); + expect(bsCheck.found).toContain('cannot determine current branch'); + }); + + test('skips branching check when config load fails', async () => { + const configLoader = createMockConfigLoader( + undefined, + new VersioningsError(EXIT_CODES.CONFIG_ERROR, 'bad config', {}), + ); + const deps = makeDeps({ configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const bsCheck = checks.find((c) => c.name === 'branching_strategy'); + expect(bsCheck).toBeUndefined(); + }); +}); + + +// --------------------------------------------------------------------------- +// Conventional Commits history check tests +// --------------------------------------------------------------------------- + +describe('doctor.command — conventional_commits check', () => { + test('returns pass when some commits match CC format', async () => { + const executor = createMockExecutor({ + 'git log -10 --format=%s': 'feat: add feature\nrandom commit\nfix: bug fix', + }); + const deps = makeDeps({ executor }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const ccCheck = checks.find((c) => c.name === 'conventional_commits')!; + expect(ccCheck.status).toBe('pass'); + expect(ccCheck.found).toContain('2/3'); + }); + + test('returns warn when no commits match CC format', async () => { + const executor = createMockExecutor({ + 'git log -10 --format=%s': 'random commit\nanother commit\nno format here', + }); + const deps = makeDeps({ executor }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const ccCheck = checks.find((c) => c.name === 'conventional_commits')!; + expect(ccCheck.status).toBe('warn'); + expect(ccCheck.found).toContain('0/3'); + expect(ccCheck.expected).toContain('Conventional Commits'); + }); + + test('returns warn when git log returns empty output', async () => { + const executor = createMockExecutor({ + 'git log -10 --format=%s': '', + }); + const deps = makeDeps({ executor }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const ccCheck = checks.find((c) => c.name === 'conventional_commits')!; + expect(ccCheck.status).toBe('warn'); + expect(ccCheck.found).toContain('no commits found'); + }); + + test('returns warn when git log command fails', async () => { + const executor = createMockExecutor({ + 'git log -10 --format=%s': new VersioningsError( + EXIT_CODES.COMMAND_FAILED, 'not a git repo', {}, + ) as any, + }); + const deps = makeDeps({ executor }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const ccCheck = checks.find((c) => c.name === 'conventional_commits')!; + expect(ccCheck.status).toBe('warn'); + expect(ccCheck.found).toContain('unable to read commit history'); + }); + + test('returns pass when all commits match CC format', async () => { + const executor = createMockExecutor({ + 'git log -10 --format=%s': 'feat: one\nfix: two\nchore: three', + }); + const deps = makeDeps({ executor }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const ccCheck = checks.find((c) => c.name === 'conventional_commits')!; + expect(ccCheck.status).toBe('pass'); + expect(ccCheck.found).toContain('3/3'); + }); +}); + +// --------------------------------------------------------------------------- +// CC config check tests +// --------------------------------------------------------------------------- + +describe('doctor.command — cc_config check', () => { + test('returns pass when conventionalCommits section is configured', async () => { + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + config: { + git: { platform: 'github', url: 'https://github.com/org/repo' }, + conventionalCommits: { enabled: true, fallbackBump: 'patch' }, + } as any, + }), + ); + const deps = makeDeps({ configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const ccCfgCheck = checks.find((c) => c.name === 'cc_config')!; + expect(ccCfgCheck.status).toBe('pass'); + expect(ccCfgCheck.found).toContain('conventionalCommits: enabled'); + expect(ccCfgCheck.found).toContain('fallbackBump: patch'); + }); + + test('returns pass when changelog section is configured', async () => { + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + config: { + git: { platform: 'github', url: 'https://github.com/org/repo' }, + changelog: { file: 'CHANGELOG.md', excludeTypes: ['chore', 'docs'] }, + } as any, + }), + ); + const deps = makeDeps({ configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const ccCfgCheck = checks.find((c) => c.name === 'cc_config')!; + expect(ccCfgCheck.status).toBe('pass'); + expect(ccCfgCheck.found).toContain('changelog.file: CHANGELOG.md'); + expect(ccCfgCheck.found).toContain('changelog.excludeTypes: chore, docs'); + }); + + test('returns warn when neither conventionalCommits nor changelog is configured', async () => { + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + config: { + git: { platform: 'github', url: 'https://github.com/org/repo' }, + } as any, + }), + ); + const deps = makeDeps({ configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const ccCfgCheck = checks.find((c) => c.name === 'cc_config')!; + expect(ccCfgCheck.status).toBe('warn'); + expect(ccCfgCheck.found).toContain('not configured'); + expect(ccCfgCheck.expected).toContain('conventionalCommits'); + }); + + test('reports conventionalCommits disabled when enabled is false', async () => { + const configLoader = createMockConfigLoader( + makeConfigLoadResult({ + config: { + git: { platform: 'github', url: 'https://github.com/org/repo' }, + conventionalCommits: { enabled: false }, + } as any, + }), + ); + const deps = makeDeps({ configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const ccCfgCheck = checks.find((c) => c.name === 'cc_config')!; + expect(ccCfgCheck.status).toBe('pass'); + expect(ccCfgCheck.found).toContain('conventionalCommits: disabled'); + }); + + test('skips cc_config check when config load fails', async () => { + const configLoader = createMockConfigLoader( + undefined, + new VersioningsError(EXIT_CODES.CONFIG_ERROR, 'bad config', {}), + ); + const deps = makeDeps({ configLoader }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const ccCfgCheck = checks.find((c) => c.name === 'cc_config'); + expect(ccCfgCheck).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// .versionings/ directory check tests +// --------------------------------------------------------------------------- + +describe('doctor.command — versionings_dir check', () => { + test('returns pass when .versionings/ directory exists', async () => { + const deps = makeDeps({ existsSync: () => true }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const dirCheck = checks.find((c) => c.name === 'versionings_dir')!; + expect(dirCheck.status).toBe('pass'); + expect(dirCheck.found).toContain('.versionings'); + }); + + test('returns warn when .versionings/ directory is missing', async () => { + const existsSync = (p: string): boolean => !p.includes('.versionings'); + const deps = makeDeps({ existsSync }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const dirCheck = checks.find((c) => c.name === 'versionings_dir')!; + expect(dirCheck.status).toBe('warn'); + expect(dirCheck.found).toBe('not found'); + expect(dirCheck.expected).toContain('.versionings/'); + }); +}); + +// --------------------------------------------------------------------------- +// Stale lock check tests +// --------------------------------------------------------------------------- + +describe('doctor.command — stale_lock check', () => { + test('returns pass when no lock file exists', async () => { + const deps = makeDeps(); + + const checks = await runDoctorCommand({ json: false }, deps); + + const lockCheck = checks.find((c) => c.name === 'stale_lock')!; + expect(lockCheck.status).toBe('pass'); + expect(lockCheck.found).toContain('no lock file'); + }); + + test('returns pass when lock file has active process', async () => { + const lockData = JSON.stringify({ + pid: 12345, + operationId: 'abc-123', + command: 'release', + createdAt: new Date().toISOString(), + hostname: 'test-host', + ci: false, + }); + const deps = makeDeps({ + existsSync: () => true, + readFileSync: () => lockData, + processKill: () => true, // process is alive + readdirSync: () => [], + }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const lockCheck = checks.find((c) => c.name === 'stale_lock')!; + expect(lockCheck.status).toBe('pass'); + expect(lockCheck.found).toContain('active lock'); + expect(lockCheck.found).toContain('12345'); + }); + + test('returns warn when lock file has dead process (ESRCH)', async () => { + const lockData = JSON.stringify({ + pid: 99999, + operationId: 'dead-op-id', + command: 'release', + createdAt: new Date().toISOString(), + hostname: 'test-host', + ci: false, + }); + const esrchError = Object.assign(new Error('ESRCH'), { code: 'ESRCH' }); + const deps = makeDeps({ + existsSync: () => true, + readFileSync: () => lockData, + processKill: () => { throw esrchError; }, + readdirSync: () => [], + }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const lockCheck = checks.find((c) => c.name === 'stale_lock')!; + expect(lockCheck.status).toBe('warn'); + expect(lockCheck.found).toContain('stale lock'); + expect(lockCheck.found).toContain('process not found'); + expect(lockCheck.found).toContain('99999'); + }); + + test('returns warn when lock file has timed out', async () => { + const oldTime = new Date(Date.now() - 600000).toISOString(); // 10 minutes ago + const lockData = JSON.stringify({ + pid: 12345, + operationId: 'timeout-op', + command: 'release', + createdAt: oldTime, + hostname: 'test-host', + ci: false, + }); + const deps = makeDeps({ + existsSync: () => true, + readFileSync: () => lockData, + processKill: () => true, + lockTimeoutMs: 300000, // 5 minutes + readdirSync: () => [], + }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const lockCheck = checks.find((c) => c.name === 'stale_lock')!; + expect(lockCheck.status).toBe('warn'); + expect(lockCheck.found).toContain('stale lock'); + expect(lockCheck.found).toContain('timeout exceeded'); + }); + + test('returns warn when lock file contains invalid JSON', async () => { + const deps = makeDeps({ + existsSync: () => true, + readFileSync: () => 'not valid json {{{', + readdirSync: () => [], + }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const lockCheck = checks.find((c) => c.name === 'stale_lock')!; + expect(lockCheck.status).toBe('warn'); + expect(lockCheck.found).toContain('invalid JSON'); + }); + + test('returns pass when EPERM (process exists but no permission)', async () => { + const lockData = JSON.stringify({ + pid: 1, + operationId: 'eperm-op', + command: 'release', + createdAt: new Date().toISOString(), + hostname: 'test-host', + ci: false, + }); + const epermError = Object.assign(new Error('EPERM'), { code: 'EPERM' }); + const deps = makeDeps({ + existsSync: () => true, + readFileSync: () => lockData, + processKill: () => { throw epermError; }, + readdirSync: () => [], + }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const lockCheck = checks.find((c) => c.name === 'stale_lock')!; + expect(lockCheck.status).toBe('pass'); + expect(lockCheck.found).toContain('active lock'); + }); +}); + +// --------------------------------------------------------------------------- +// Operation log count check tests +// --------------------------------------------------------------------------- + +describe('doctor.command — operation_log check', () => { + test('returns pass with 0 entries when operations directory is missing', async () => { + const existsSync = (p: string): boolean => { + if (p.includes('operations')) return false; + return !p.endsWith('/lock'); + }; + const deps = makeDeps({ existsSync }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const logCheck = checks.find((c) => c.name === 'operation_log')!; + expect(logCheck.status).toBe('pass'); + expect(logCheck.found).toContain('0 entries'); + }); + + test('returns pass with correct count of operation log entries', async () => { + const deps = makeDeps({ + readdirSync: () => [ + '2024-01-01T00-00-00-000Z-patch-1.0.1.json', + '2024-01-02T00-00-00-000Z-minor-1.1.0.json', + '2024-01-03T00-00-00-000Z-major-2.0.0.json', + 'last.json', + ], + }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const logCheck = checks.find((c) => c.name === 'operation_log')!; + expect(logCheck.status).toBe('pass'); + expect(logCheck.found).toBe('3 entries'); + }); + + test('returns pass with singular "entry" for single log', async () => { + const deps = makeDeps({ + readdirSync: () => ['2024-01-01T00-00-00-000Z-patch-1.0.1.json'], + }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const logCheck = checks.find((c) => c.name === 'operation_log')!; + expect(logCheck.status).toBe('pass'); + expect(logCheck.found).toBe('1 entry'); + }); + + test('returns warn when operations directory cannot be read', async () => { + const deps = makeDeps({ + readdirSync: () => { throw new Error('EACCES'); }, + }); + + const checks = await runDoctorCommand({ json: false }, deps); + + const logCheck = checks.find((c) => c.name === 'operation_log')!; + expect(logCheck.status).toBe('warn'); + expect(logCheck.found).toContain('unable to read'); + }); +}); diff --git a/__tests__/unit/cli/init.command.test.ts b/__tests__/unit/cli/init.command.test.ts new file mode 100644 index 0000000..eecab51 --- /dev/null +++ b/__tests__/unit/cli/init.command.test.ts @@ -0,0 +1,354 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { PassThrough } from 'stream'; +import * as path from 'path'; +import { runInitCommand, InitCommandDeps } from '../../../src/cli/commands/init.command'; +import type { InteractionManager } from '../../../src/cli/interaction.manager'; +import type { Executor, ExecutorResult } from '../../../src/core/executor'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockExecutor(remoteUrl?: string): Executor { + return { + run: jest.fn(async (_cmd: string): Promise => { + if (remoteUrl !== undefined) { + return { stdout: remoteUrl, lines: remoteUrl ? [remoteUrl] : [] }; + } + throw new VersioningsError(EXIT_CODES.COMMAND_FAILED, 'git remote failed', {}); + }), + }; +} + +function createMockInteractionManager(interactive: boolean): InteractionManager { + return { + isInteractive: jest.fn(() => interactive), + confirm: jest.fn(async () => true), + }; +} + +function makeDeps(overrides: Partial & { + interactive?: boolean; + remoteUrl?: string; +} = {}): InitCommandDeps & { stdin: PassThrough; stdout: PassThrough; written: { path?: string; data?: string } } { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + stdout.setEncoding('utf8'); + + const written: { path?: string; data?: string } = {}; + + return { + interactionManager: overrides.interactionManager + ?? createMockInteractionManager(overrides.interactive ?? false), + executor: overrides.executor ?? createMockExecutor(overrides.remoteUrl ?? 'https://github.com/org/repo'), + cwd: overrides.cwd ?? '/tmp/test-project', + stdin: overrides.stdin as any ?? stdin, + stdout: overrides.stdout as any ?? stdout, + existsSync: overrides.existsSync ?? jest.fn(() => false), + writeFileSync: overrides.writeFileSync ?? jest.fn((p: string, data: string) => { + written.path = p; + written.data = data; + }), + written, + }; +} + +function drainStdout(stdout: PassThrough): string { + let output = ''; + let chunk: string | null; + while ((chunk = stdout.read() as string | null) !== null) { + output += chunk; + } + return output; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('init.command — JSON generation (non-interactive)', () => { + test('generates version.json with defaults from auto-detected URL', async () => { + const deps = makeDeps({ remoteUrl: 'https://github.com/org/repo' }); + + await runInitCommand({}, deps); + + expect(deps.writeFileSync).toHaveBeenCalledTimes(1); + const [filePath, content] = (deps.writeFileSync as jest.Mock).mock.calls[0]; + expect(filePath).toBe(path.join('/tmp/test-project', 'version.json')); + + const parsed = JSON.parse(content); + expect(parsed.git.platform).toBe('github'); + expect(parsed.git.url).toBe('https://github.com/org/repo'); + expect(parsed.git.pr.target).toBe('main'); + }); + + test('infers bitbucket platform from URL', async () => { + const deps = makeDeps({ remoteUrl: 'https://bitbucket.org/team/repo' }); + + await runInitCommand({}, deps); + + const [, content] = (deps.writeFileSync as jest.Mock).mock.calls[0]; + const parsed = JSON.parse(content); + expect(parsed.git.platform).toBe('bitbucket'); + }); +}); + +describe('init.command — YAML generation (non-interactive)', () => { + test('generates .versioningsrc.yml when --format=yaml', async () => { + const deps = makeDeps({ remoteUrl: 'https://github.com/org/repo' }); + + await runInitCommand({ format: 'yaml' }, deps); + + expect(deps.writeFileSync).toHaveBeenCalledTimes(1); + const [filePath, content] = (deps.writeFileSync as jest.Mock).mock.calls[0]; + expect(filePath).toBe(path.join('/tmp/test-project', '.versioningsrc.yml')); + + // YAML content should contain the key fields + expect(content).toContain('platform'); + expect(content).toContain('github'); + expect(content).toContain('https://github.com/org/repo'); + }); +}); + +describe('init.command — --format=json flag', () => { + test('generates version.json when --format=json explicitly', async () => { + const deps = makeDeps({ remoteUrl: 'https://github.com/org/repo' }); + + await runInitCommand({ format: 'json' }, deps); + + const [filePath] = (deps.writeFileSync as jest.Mock).mock.calls[0]; + expect(filePath).toBe(path.join('/tmp/test-project', 'version.json')); + }); +}); + +describe('init.command — overwrite existing file', () => { + test('non-interactive mode overwrites existing file silently', async () => { + const existsSync = jest.fn(() => true); + const deps = makeDeps({ remoteUrl: 'https://github.com/org/repo', existsSync }); + + await runInitCommand({}, deps); + + expect(deps.writeFileSync).toHaveBeenCalledTimes(1); + }); + + test('interactive mode asks for overwrite confirmation — user accepts', async () => { + const existsSync = jest.fn(() => true); + const stdin = new PassThrough(); + const stdout = new PassThrough(); + stdout.setEncoding('utf8'); + + const deps = makeDeps({ + interactive: true, + remoteUrl: 'https://github.com/org/repo', + existsSync, + stdin, + stdout, + }); + + const promise = runInitCommand({}, deps); + + // Answer wizard questions: platform, url (accept default), pr target (accept default), format (accept default) + stdin.write('github\n'); + // Small delay to let readline process each answer + await new Promise((r) => setTimeout(r, 20)); + stdin.write('https://github.com/org/repo\n'); + await new Promise((r) => setTimeout(r, 20)); + stdin.write('main\n'); + await new Promise((r) => setTimeout(r, 20)); + stdin.write('json\n'); + await new Promise((r) => setTimeout(r, 20)); + // Overwrite confirmation + stdin.write('y\n'); + + await promise; + expect(deps.writeFileSync).toHaveBeenCalledTimes(1); + }); + + test('interactive mode asks for overwrite confirmation — user declines', async () => { + const existsSync = jest.fn(() => true); + const stdin = new PassThrough(); + const stdout = new PassThrough(); + stdout.setEncoding('utf8'); + + const deps = makeDeps({ + interactive: true, + remoteUrl: 'https://github.com/org/repo', + existsSync, + stdin, + stdout, + }); + + const promise = runInitCommand({}, deps); + + // Answer wizard questions + stdin.write('github\n'); + await new Promise((r) => setTimeout(r, 20)); + stdin.write('https://github.com/org/repo\n'); + await new Promise((r) => setTimeout(r, 20)); + stdin.write('main\n'); + await new Promise((r) => setTimeout(r, 20)); + stdin.write('json\n'); + await new Promise((r) => setTimeout(r, 20)); + // Decline overwrite + stdin.write('n\n'); + + await expect(promise).rejects.toThrow(VersioningsError); + await expect(promise).rejects.toMatchObject({ code: EXIT_CODES.USER_CANCELLED }); + expect(deps.writeFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('init.command — non-interactive mode', () => { + test('generates config with defaults without reading stdin', async () => { + const deps = makeDeps({ remoteUrl: 'https://github.com/org/repo' }); + + await runInitCommand({}, deps); + + expect(deps.writeFileSync).toHaveBeenCalledTimes(1); + const [, content] = (deps.writeFileSync as jest.Mock).mock.calls[0]; + const parsed = JSON.parse(content); + expect(parsed.git.platform).toBe('github'); + expect(parsed.git.pr.target).toBe('main'); + }); + + test('throws CONFIG_ERROR when no remote URL detected in non-interactive mode', async () => { + const executor = createMockExecutor(undefined); + const deps = makeDeps({ executor }); + + try { + await runInitCommand({}, deps); + fail('Expected VersioningsError to be thrown'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + expect(deps.writeFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('init.command — auto-detect URL', () => { + test('calls git remote get-url origin via executor', async () => { + const executor = createMockExecutor('https://github.com/org/repo'); + const deps = makeDeps({ executor }); + + await runInitCommand({}, deps); + + expect(executor.run).toHaveBeenCalledWith('git remote get-url origin'); + }); + + test('uses detected URL in generated config', async () => { + const deps = makeDeps({ remoteUrl: 'https://github.com/my-org/my-repo' }); + + await runInitCommand({}, deps); + + const [, content] = (deps.writeFileSync as jest.Mock).mock.calls[0]; + const parsed = JSON.parse(content); + expect(parsed.git.url).toBe('https://github.com/my-org/my-repo'); + }); +}); + +describe('init.command — validation before writing', () => { + test('generated JSON config is valid against schema', async () => { + const deps = makeDeps({ remoteUrl: 'https://github.com/org/repo' }); + + await runInitCommand({}, deps); + + const [, content] = (deps.writeFileSync as jest.Mock).mock.calls[0]; + const parsed = JSON.parse(content); + + // Must have required fields + expect(parsed.git).toBeDefined(); + expect(parsed.git.platform).toBeDefined(); + expect(parsed.git.url).toBeDefined(); + expect(['github', 'bitbucket']).toContain(parsed.git.platform); + expect(parsed.git.url.length).toBeGreaterThan(0); + }); + + test('generated YAML config is valid against schema', async () => { + const deps = makeDeps({ remoteUrl: 'https://bitbucket.org/team/repo' }); + + await runInitCommand({ format: 'yaml' }, deps); + + const [, content] = (deps.writeFileSync as jest.Mock).mock.calls[0]; + // YAML should be parseable and contain required fields + const yaml = require('js-yaml'); + const parsed = yaml.load(content); + expect(parsed.git).toBeDefined(); + expect(parsed.git.platform).toBe('bitbucket'); + expect(parsed.git.url).toBe('https://bitbucket.org/team/repo'); + }); + + test('writes "Created " to stdout on success', async () => { + const deps = makeDeps({ remoteUrl: 'https://github.com/org/repo' }); + + await runInitCommand({}, deps); + + const output = drainStdout(deps.stdout); + expect(output).toContain('Created version.json'); + }); +}); + +describe('init.command — interactive wizard', () => { + test('runs full wizard and generates JSON config', async () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + stdout.setEncoding('utf8'); + + const deps = makeDeps({ + interactive: true, + remoteUrl: 'https://github.com/org/repo', + stdin, + stdout, + }); + + const promise = runInitCommand({}, deps); + + // Answer all wizard questions + stdin.write('github\n'); + await new Promise((r) => setTimeout(r, 20)); + stdin.write('https://github.com/org/repo\n'); + await new Promise((r) => setTimeout(r, 20)); + stdin.write('main\n'); + await new Promise((r) => setTimeout(r, 20)); + stdin.write('json\n'); + + await promise; + + expect(deps.writeFileSync).toHaveBeenCalledTimes(1); + const [filePath, content] = (deps.writeFileSync as jest.Mock).mock.calls[0]; + expect(filePath).toContain('version.json'); + const parsed = JSON.parse(content); + expect(parsed.git.platform).toBe('github'); + expect(parsed.git.url).toBe('https://github.com/org/repo'); + }); + + test('wizard with --format=yaml skips format question', async () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + stdout.setEncoding('utf8'); + + const deps = makeDeps({ + interactive: true, + remoteUrl: 'https://github.com/org/repo', + stdin, + stdout, + }); + + const promise = runInitCommand({ format: 'yaml' }, deps); + + // Only 3 questions (no format question) + stdin.write('github\n'); + await new Promise((r) => setTimeout(r, 20)); + stdin.write('https://github.com/org/repo\n'); + await new Promise((r) => setTimeout(r, 20)); + stdin.write('main\n'); + + await promise; + + const [filePath] = (deps.writeFileSync as jest.Mock).mock.calls[0]; + expect(filePath).toContain('.versioningsrc.yml'); + }); +}); diff --git a/__tests__/unit/cli/interaction.manager.test.ts b/__tests__/unit/cli/interaction.manager.test.ts new file mode 100644 index 0000000..35675b8 --- /dev/null +++ b/__tests__/unit/cli/interaction.manager.test.ts @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { PassThrough } from 'stream'; +import { createInteractionManager } from '../../../src/cli/interaction.manager'; +import type { InteractionOpts } from '../../../src/cli/interaction.manager'; +import type { DryRunPlan } from '../../../src/core/reporter'; + +const mockPlan: DryRunPlan = { + dryRun: false, + currentVersion: '1.0.0', + nextVersion: '1.0.1', + semver: 'patch', + branch: 'version/patch/1.0.1/fix-bug', + tag: '1.0.1--fix-bug', + commitMessage: 'Patch: v1.0.1. You SHOULD consider changes.', + pullRequestUrl: null, + steps: ['npm --no-git-tag-version version patch', 'git checkout -b version/patch/1.0.1/fix-bug'], +}; + +function makeStreams() { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + return { stdin, stdout }; +} + +describe('InteractionManager — isInteractive()', () => { + test('returns true only when isTTY=true AND ci=false AND nonInteractive=false AND yes=false', () => { + const { stdin, stdout } = makeStreams(); + const mgr = createInteractionManager( + { isTTY: true, ci: false, nonInteractive: false, yes: false }, + stdin, + stdout, + ); + expect(mgr.isInteractive()).toBe(true); + }); + + test.each<[string, InteractionOpts, boolean]>([ + ['ci=true', { isTTY: true, ci: true, nonInteractive: false, yes: false }, false], + ['nonInteractive=true', { isTTY: true, ci: false, nonInteractive: true, yes: false }, false], + ['yes=true', { isTTY: true, ci: false, nonInteractive: false, yes: true }, false], + ['isTTY=false', { isTTY: false, ci: false, nonInteractive: false, yes: false }, false], + ['all flags true', { isTTY: true, ci: true, nonInteractive: true, yes: true }, false], + ['isTTY=false + ci=true', { isTTY: false, ci: true, nonInteractive: false, yes: false }, false], + ['isTTY=false + yes=true', { isTTY: false, ci: false, nonInteractive: false, yes: true }, false], + ])('returns false when %s', (_label, opts, expected) => { + const { stdin, stdout } = makeStreams(); + const mgr = createInteractionManager(opts, stdin, stdout); + expect(mgr.isInteractive()).toBe(expected); + }); +}); + +describe('InteractionManager — confirm() in non-interactive mode', () => { + test('returns true without reading stdin', async () => { + const { stdin, stdout } = makeStreams(); + const mgr = createInteractionManager( + { isTTY: false, ci: false, nonInteractive: false, yes: false }, + stdin, + stdout, + ); + expect(mgr.isInteractive()).toBe(false); + + const result = await mgr.confirm(mockPlan); + expect(result).toBe(true); + + // stdout should NOT have received the plan output (no prompt in non-interactive) + const written = stdout.read(); + expect(written).toBeNull(); + }); +}); + +describe('InteractionManager — confirm() in interactive mode', () => { + function createInteractive() { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const mgr = createInteractionManager( + { isTTY: true, ci: false, nonInteractive: false, yes: false }, + stdin, + stdout, + ); + return { stdin, stdout, mgr }; + } + + test('user answers "y" — returns true', async () => { + const { stdin, mgr } = createInteractive(); + const promise = mgr.confirm(mockPlan); + // Simulate user typing "y\n" + stdin.write('y\n'); + const result = await promise; + expect(result).toBe(true); + }); + + test('user answers "n" — returns false', async () => { + const { stdin, mgr } = createInteractive(); + const promise = mgr.confirm(mockPlan); + stdin.write('n\n'); + const result = await promise; + expect(result).toBe(false); + }); + + test('user answers "yes" — returns true', async () => { + const { stdin, mgr } = createInteractive(); + const promise = mgr.confirm(mockPlan); + stdin.write('yes\n'); + const result = await promise; + expect(result).toBe(true); + }); + + test('user answers empty string — returns false', async () => { + const { stdin, mgr } = createInteractive(); + const promise = mgr.confirm(mockPlan); + stdin.write('\n'); + const result = await promise; + expect(result).toBe(false); + }); + + test('user answers "maybe" — returns false', async () => { + const { stdin, mgr } = createInteractive(); + const promise = mgr.confirm(mockPlan); + stdin.write('maybe\n'); + const result = await promise; + expect(result).toBe(false); + }); +}); + +describe('InteractionManager — plan output includes ANSI color codes', () => { + test('confirm() writes plan with ANSI escape sequences to stdout', async () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + stdout.setEncoding('utf8'); + + const mgr = createInteractionManager( + { isTTY: true, ci: false, nonInteractive: false, yes: false }, + stdin, + stdout, + ); + + const promise = mgr.confirm(mockPlan); + stdin.write('y\n'); + await promise; + + // Collect all data written to stdout + let output = ''; + let chunk: string | null; + while ((chunk = stdout.read() as string | null) !== null) { + output += chunk; + } + + // eslint-disable-next-line no-control-regex + const ansiPattern = /\x1b\[/; + expect(ansiPattern.test(output)).toBe(true); + expect(output).toContain('Release plan:'); + expect(output).toContain(mockPlan.nextVersion); + expect(output).toContain(mockPlan.branch); + expect(output).toContain(mockPlan.tag); + }); +}); diff --git a/__tests__/unit/cli/plan.command.test.ts b/__tests__/unit/cli/plan.command.test.ts new file mode 100644 index 0000000..3b34a09 --- /dev/null +++ b/__tests__/unit/cli/plan.command.test.ts @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { runPlanCommand, PlanCommandDeps } from '../../../src/cli/commands/plan.command'; +import { DryRunPlan, Reporter, createReporter } from '../../../src/core/reporter'; +import { PipelineOpts, PipelineDeps } from '../../../src/core/pipeline'; +import { PassThrough } from 'stream'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makePlan(overrides: Partial = {}): DryRunPlan { + return { + dryRun: true, + currentVersion: '1.0.0', + nextVersion: '1.0.1', + semver: 'patch', + branch: 'version/patch/1.0.1/fix', + tag: '1.0.1--fix', + commitMessage: 'patch version 1.0.1', + pullRequestUrl: null, + steps: [ + 'npm --no-git-tag-version version patch --message "patch version 1.0.1"', + 'git checkout -b version/patch/1.0.1/fix', + 'git tag --annotate 1.0.1--fix --message "patch version 1.0.1"', + 'git commit --all --message "patch version 1.0.1"', + ], + ...overrides, + }; +} + +function makeDeps(overrides: { + plan?: DryRunPlan; + json?: boolean; +} = {}): { deps: PlanCommandDeps; stdout: PassThrough; runPipelineMock: jest.Mock } { + const plan = overrides.plan ?? makePlan(); + const runPipelineMock = jest.fn().mockResolvedValue(plan); + const stdout = new PassThrough(); + const reporter = createReporter({ json: overrides.json ?? false }); + + const deps: PlanCommandDeps = { + runPipeline: runPipelineMock, + pipelineDeps: {} as PipelineDeps, + reporter, + stdout, + }; + + return { deps, stdout, runPipelineMock }; +} + +function collectOutput(stream: PassThrough): string { + const chunks: Buffer[] = []; + stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + return () => Buffer.concat(chunks).toString('utf8'); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('plan.command', () => { + it('delegates to runPipeline with dryRun: true', async () => { + const { deps, runPipelineMock } = makeDeps(); + + await runPlanCommand( + { semver: 'patch', branch: 'fix', push: false, json: false }, + deps, + ); + + expect(runPipelineMock).toHaveBeenCalledTimes(1); + const [pipelineOpts] = runPipelineMock.mock.calls[0]; + expect(pipelineOpts.dryRun).toBe(true); + expect(pipelineOpts.semver).toBe('patch'); + expect(pipelineOpts.branch).toBe('fix'); + expect(pipelineOpts.push).toBe(false); + }); + + it('passes all parameters to pipeline', async () => { + const { deps, runPipelineMock } = makeDeps(); + + await runPlanCommand( + { semver: 'minor', branch: 'feat', push: true, preid: 'beta', json: false }, + deps, + ); + + const [pipelineOpts] = runPipelineMock.mock.calls[0]; + expect(pipelineOpts).toMatchObject({ + semver: 'minor', + branch: 'feat', + push: true, + preid: 'beta', + dryRun: true, + json: false, + verbose: false, + }); + }); + + it('returns the DryRunPlan from pipeline', async () => { + const plan = makePlan({ nextVersion: '2.0.0' }); + const { deps } = makeDeps({ plan }); + + const result = await runPlanCommand( + { semver: 'major', branch: 'release', push: false, json: false }, + deps, + ); + + expect(result).toBe(plan); + expect(result.nextVersion).toBe('2.0.0'); + expect(result.dryRun).toBe(true); + }); + + it('writes human-readable output to stdout', async () => { + const plan = makePlan(); + const { deps, stdout } = makeDeps({ plan, json: false }); + + let output = ''; + stdout.on('data', (chunk: Buffer) => { output += chunk.toString(); }); + + await runPlanCommand( + { semver: 'patch', branch: 'fix', push: false, json: false }, + deps, + ); + + expect(output).toContain('Dry run'); + expect(output).toContain('1.0.1'); + expect(output).toContain('patch'); + }); + + it('writes JSON output to stdout when json: true', async () => { + const plan = makePlan(); + const { deps, stdout } = makeDeps({ plan, json: true }); + + let output = ''; + stdout.on('data', (chunk: Buffer) => { output += chunk.toString(); }); + + await runPlanCommand( + { semver: 'patch', branch: 'fix', push: false, json: true }, + deps, + ); + + // Should be valid JSON + const parsed = JSON.parse(output.trim()); + expect(parsed.dryRun).toBe(true); + expect(parsed.nextVersion).toBe('1.0.1'); + expect(parsed.semver).toBe('patch'); + }); + + it('includes push steps when push: true', async () => { + const plan = makePlan({ + pullRequestUrl: 'https://github.com/org/repo/compare/main...version/patch/1.0.1/fix', + steps: [ + 'npm --no-git-tag-version version patch --message "patch version 1.0.1"', + 'git checkout -b version/patch/1.0.1/fix', + 'git tag --annotate 1.0.1--fix --message "patch version 1.0.1"', + 'git commit --all --message "patch version 1.0.1"', + 'git push origin version/patch/1.0.1/fix --follow-tags', + ], + }); + const { deps, runPipelineMock } = makeDeps({ plan }); + + await runPlanCommand( + { semver: 'patch', branch: 'fix', push: true, json: false }, + deps, + ); + + const [pipelineOpts] = runPipelineMock.mock.calls[0]; + expect(pipelineOpts.push).toBe(true); + }); + + it('propagates pipeline errors', async () => { + const runPipelineMock = jest.fn().mockRejectedValue(new Error('pipeline failed')); + const stdout = new PassThrough(); + const deps: PlanCommandDeps = { + runPipeline: runPipelineMock, + pipelineDeps: {} as PipelineDeps, + reporter: createReporter({ json: false }), + stdout, + }; + + await expect( + runPlanCommand( + { semver: 'patch', branch: 'fix', push: false, json: false }, + deps, + ), + ).rejects.toThrow('pipeline failed'); + }); +}); diff --git a/__tests__/unit/cli/release.command.test.ts b/__tests__/unit/cli/release.command.test.ts new file mode 100644 index 0000000..5bb1a6d --- /dev/null +++ b/__tests__/unit/cli/release.command.test.ts @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { runReleaseCommand, ReleaseCommandOpts, ReleaseCommandDeps } from '../../../src/cli/commands/release.command'; +import { DryRunPlan, PipelineResult, createReporter } from '../../../src/core/reporter'; +import { PipelineOpts, PipelineDeps } from '../../../src/core/pipeline'; +import { InteractionManager } from '../../../src/cli/interaction.manager'; +import { OperationLog } from '../../../src/core/operation.log'; +import { VersioningsError, EXIT_CODES } from '../../../src/core/errors'; +import { PassThrough } from 'stream'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makePlan(overrides: Partial = {}): DryRunPlan { + return { + dryRun: true, + currentVersion: '1.0.0', + nextVersion: '1.0.1', + semver: 'patch', + branch: 'version/patch/1.0.1/fix', + tag: '1.0.1--fix', + commitMessage: 'patch version 1.0.1', + pullRequestUrl: null, + steps: [ + 'npm --no-git-tag-version version patch --message "patch version 1.0.1"', + 'git checkout -b version/patch/1.0.1/fix', + ], + ...overrides, + }; +} + +function makeResult(overrides: Partial = {}): PipelineResult { + return { + success: true, + version: '1.0.1', + previousVersion: '1.0.0', + semver: 'patch', + branch: 'version/patch/1.0.1/fix', + tag: '1.0.1--fix', + pullRequestUrl: null, + exitCode: EXIT_CODES.SUCCESS, + ...overrides, + }; +} + +function makeOpts(overrides: Partial = {}): ReleaseCommandOpts { + return { + semver: 'patch', + branch: 'fix', + push: false, + dryRun: false, + json: false, + verbose: false, + ...overrides, + }; +} + +function makeMockInteraction(interactive: boolean, confirmResult = true): InteractionManager { + return { + isInteractive: jest.fn().mockReturnValue(interactive), + confirm: jest.fn().mockResolvedValue(confirmResult), + }; +} + +function makeMockOperationLog(): OperationLog & { saveMock: jest.Mock } { + const saveMock = jest.fn().mockResolvedValue('/path/to/log.json'); + return { + save: saveMock, + loadLast: jest.fn().mockResolvedValue(null), + loadFrom: jest.fn().mockRejectedValue(new Error('not found')), + saveMock, + }; +} + +interface MakeDepsResult { + deps: ReleaseCommandDeps; + stdout: PassThrough; + runPipelineMock: jest.Mock; + interactionManager: InteractionManager; + operationLog: OperationLog & { saveMock: jest.Mock }; +} + +function makeDeps(overrides: { + plan?: DryRunPlan; + result?: PipelineResult; + interactive?: boolean; + confirmResult?: boolean; + json?: boolean; + pipelineError?: Error; +} = {}): MakeDepsResult { + const plan = overrides.plan ?? makePlan(); + const result = overrides.result ?? makeResult(); + const interactive = overrides.interactive ?? false; + const confirmResult = overrides.confirmResult ?? true; + + const runPipelineMock = jest.fn().mockImplementation((opts: PipelineOpts) => { + if (overrides.pipelineError && !opts.dryRun) { + return Promise.reject(overrides.pipelineError); + } + return opts.dryRun ? Promise.resolve(plan) : Promise.resolve(result); + }); + + const stdout = new PassThrough(); + const reporter = createReporter({ json: overrides.json ?? false }); + const interactionManager = makeMockInteraction(interactive, confirmResult); + const operationLog = makeMockOperationLog(); + + const deps: ReleaseCommandDeps = { + runPipeline: runPipelineMock, + pipelineDeps: {} as PipelineDeps, + interactionManager, + operationLog, + reporter, + stdout, + }; + + return { deps, stdout, runPipelineMock, interactionManager, operationLog }; +} + +function captureOutput(stream: PassThrough): () => string { + let output = ''; + stream.on('data', (chunk: Buffer) => { output += chunk.toString(); }); + return () => output; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('release.command', () => { + describe('successful release (non-interactive)', () => { + it('executes pipeline and returns result', async () => { + const result = makeResult({ version: '2.0.0' }); + const { deps, runPipelineMock } = makeDeps({ result }); + + const returned = await runReleaseCommand(makeOpts(), deps); + + expect(runPipelineMock).toHaveBeenCalledTimes(1); + expect((returned as PipelineResult).version).toBe('2.0.0'); + }); + + it('passes all parameters to pipeline', async () => { + const { deps, runPipelineMock } = makeDeps(); + + await runReleaseCommand( + makeOpts({ semver: 'minor', branch: 'feat', push: true, preid: 'beta', verbose: true }), + deps, + ); + + const [pipelineOpts] = runPipelineMock.mock.calls[0]; + expect(pipelineOpts).toMatchObject({ + semver: 'minor', + branch: 'feat', + push: true, + preid: 'beta', + dryRun: false, + verbose: true, + }); + }); + + it('writes success output to stdout', async () => { + const { deps, stdout } = makeDeps(); + const getOutput = captureOutput(stdout); + + await runReleaseCommand(makeOpts(), deps); + + expect(getOutput()).toContain('1.0.1'); + }); + }); + + describe('confirm flow (interactive mode)', () => { + it('shows plan and confirms before executing', async () => { + const plan = makePlan(); + const result = makeResult(); + const { deps, runPipelineMock, interactionManager, stdout } = makeDeps({ + plan, + result, + interactive: true, + confirmResult: true, + }); + const getOutput = captureOutput(stdout); + + await runReleaseCommand(makeOpts(), deps); + + // First call: dry-run for plan; second call: actual execution + expect(runPipelineMock).toHaveBeenCalledTimes(2); + expect(runPipelineMock.mock.calls[0][0].dryRun).toBe(true); + expect(runPipelineMock.mock.calls[1][0].dryRun).toBe(false); + expect(interactionManager.confirm).toHaveBeenCalledWith(plan); + expect(getOutput()).toContain('1.0.1'); + }); + + it('throws USER_CANCELLED when user declines', async () => { + const { deps } = makeDeps({ + interactive: true, + confirmResult: false, + }); + + await expect(runReleaseCommand(makeOpts(), deps)).rejects.toThrow(VersioningsError); + + try { + await runReleaseCommand(makeOpts(), deps); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.USER_CANCELLED); + expect(err.message).toContain('cancelled'); + } + }); + + it('does not execute pipeline when user declines', async () => { + const { deps, runPipelineMock } = makeDeps({ + interactive: true, + confirmResult: false, + }); + + try { + await runReleaseCommand(makeOpts(), deps); + } catch (_) { + // expected + } + + // Only the dry-run call, no execution call + expect(runPipelineMock).toHaveBeenCalledTimes(1); + expect(runPipelineMock.mock.calls[0][0].dryRun).toBe(true); + }); + }); + + describe('non-interactive mode', () => { + it('skips confirm flow and executes directly', async () => { + const { deps, runPipelineMock, interactionManager } = makeDeps({ + interactive: false, + }); + + await runReleaseCommand(makeOpts(), deps); + + // Only one pipeline call (no dry-run preflight) + expect(runPipelineMock).toHaveBeenCalledTimes(1); + expect(runPipelineMock.mock.calls[0][0].dryRun).toBe(false); + expect(interactionManager.confirm).not.toHaveBeenCalled(); + }); + }); + + describe('operation log saving', () => { + it('saves log on successful release', async () => { + const { deps, operationLog } = makeDeps(); + + await runReleaseCommand(makeOpts(), deps); + + expect(operationLog.saveMock).toHaveBeenCalledTimes(1); + const entry = operationLog.saveMock.mock.calls[0][0]; + expect(entry.result).toBe('success'); + expect(entry.schemaVersion).toBe(1); + expect(entry.version).toBe('1.0.1'); + expect(entry.semver).toBe('patch'); + }); + + it('saves log on failed release', async () => { + const pipelineError = new VersioningsError(EXIT_CODES.COMMAND_FAILED, 'git push failed'); + const { deps, operationLog } = makeDeps({ pipelineError }); + + await expect(runReleaseCommand(makeOpts(), deps)).rejects.toThrow('git push failed'); + + expect(operationLog.saveMock).toHaveBeenCalledTimes(1); + const entry = operationLog.saveMock.mock.calls[0][0]; + expect(entry.result).toBe('failed'); + expect(entry.error).toEqual({ code: EXIT_CODES.COMMAND_FAILED, message: 'git push failed' }); + }); + + it('does not mask pipeline error if log save fails', async () => { + const pipelineError = new VersioningsError(EXIT_CODES.DIRTY_TREE, 'dirty tree'); + const { deps, operationLog } = makeDeps({ pipelineError }); + operationLog.saveMock.mockRejectedValue(new Error('disk full')); + + await expect(runReleaseCommand(makeOpts(), deps)).rejects.toThrow('dirty tree'); + }); + + it('does not mask success if log save fails', async () => { + const { deps, operationLog } = makeDeps(); + operationLog.saveMock.mockRejectedValue(new Error('disk full')); + + const result = await runReleaseCommand(makeOpts(), deps); + expect((result as PipelineResult).success).toBe(true); + }); + }); + + describe('dry-run mode', () => { + it('delegates to pipeline with dryRun: true', async () => { + const plan = makePlan({ nextVersion: '3.0.0' }); + const { deps, runPipelineMock } = makeDeps({ plan }); + + const result = await runReleaseCommand(makeOpts({ dryRun: true }), deps); + + expect(runPipelineMock).toHaveBeenCalledTimes(1); + expect(runPipelineMock.mock.calls[0][0].dryRun).toBe(true); + expect((result as DryRunPlan).nextVersion).toBe('3.0.0'); + }); + + it('skips confirm flow in dry-run mode', async () => { + const { deps, interactionManager } = makeDeps({ interactive: true }); + + await runReleaseCommand(makeOpts({ dryRun: true }), deps); + + expect(interactionManager.isInteractive).not.toHaveBeenCalled(); + expect(interactionManager.confirm).not.toHaveBeenCalled(); + }); + + it('does not save operation log in dry-run mode', async () => { + const { deps, operationLog } = makeDeps(); + + await runReleaseCommand(makeOpts({ dryRun: true }), deps); + + expect(operationLog.saveMock).not.toHaveBeenCalled(); + }); + + it('writes dry-run output to stdout', async () => { + const { deps, stdout } = makeDeps(); + const getOutput = captureOutput(stdout); + + await runReleaseCommand(makeOpts({ dryRun: true }), deps); + + expect(getOutput()).toContain('Dry run'); + }); + }); +}); diff --git a/__tests__/unit/cli/rollback.command.test.ts b/__tests__/unit/cli/rollback.command.test.ts new file mode 100644 index 0000000..589c15c --- /dev/null +++ b/__tests__/unit/cli/rollback.command.test.ts @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { runRollbackCommand, RollbackCommandOpts, RollbackCommandDeps } from '../../../src/cli/commands/rollback.command'; +import { VersioningsError, EXIT_CODES } from '../../../src/core/errors'; +import { createReporter } from '../../../src/core/reporter'; +import type { OperationLog, OperationLogEntry } from '../../../src/core/operation.log'; +import type { InteractionManager } from '../../../src/cli/interaction.manager'; +import type { RollbackManager, RollbackResult, RollbackStep } from '../../../src/core/rollback'; +import type { Executor, ExecutorResult } from '../../../src/core/executor'; +import { PassThrough } from 'stream'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeEntry(overrides: Partial = {}): OperationLogEntry { + return { + schemaVersion: 1, + timestamp: '2025-01-15T10:30:00.000Z', + semver: 'patch', + version: '1.0.1', + previousVersion: '1.0.0', + branch: 'version/patch/1.0.1/fix', + tag: '1.0.1--fix', + steps: [ + { type: 'npm_version_bump', meta: {} }, + { type: 'branch_created', meta: { name: 'version/patch/1.0.1/fix' } }, + { type: 'tag_created', meta: { name: '1.0.1--fix' } }, + { type: 'committed', meta: {} }, + ], + result: 'success', + ...overrides, + }; +} + +function makeOpts(overrides: Partial = {}): RollbackCommandOpts { + return { + json: false, + ci: false, + yes: false, + ...overrides, + }; +} + +function makeMockExecutor(): Executor { + return { + run: jest.fn(async (): Promise => ({ stdout: '', lines: [] })), + }; +} + +function makeMockInteraction(interactive: boolean, confirmResult = true): InteractionManager { + return { + isInteractive: jest.fn().mockReturnValue(interactive), + confirm: jest.fn().mockResolvedValue(confirmResult), + }; +} + +function makeMockRollbackManager(result?: Partial): RollbackManager & { recordedSteps: RollbackStep[] } { + const recordedSteps: RollbackStep[] = []; + const rollbackResult: RollbackResult = { + success: true, + failedSteps: [], + ...result, + }; + return { + record: jest.fn((step: RollbackStep) => { recordedSteps.push(step); }), + rollback: jest.fn().mockResolvedValue(rollbackResult), + recordedSteps, + }; +} + +function makeMockOperationLog(entry: OperationLogEntry | null = makeEntry()): OperationLog { + return { + save: jest.fn().mockResolvedValue('/path/to/log.json'), + loadLast: jest.fn().mockResolvedValue(entry), + loadFrom: jest.fn().mockResolvedValue(entry), + }; +} + +interface MakeDepsResult { + deps: RollbackCommandDeps; + stdout: PassThrough; + rollbackManager: RollbackManager & { recordedSteps: RollbackStep[] }; + operationLog: OperationLog; + interactionManager: InteractionManager; +} + +function makeDeps(overrides: { + entry?: OperationLogEntry | null; + interactive?: boolean; + confirmResult?: boolean; + rollbackResult?: Partial; + loadFromError?: Error; + json?: boolean; +} = {}): MakeDepsResult { + const entry = overrides.entry !== undefined ? overrides.entry : makeEntry(); + const interactive = overrides.interactive ?? false; + const confirmResult = overrides.confirmResult ?? true; + + const stdout = new PassThrough(); + const reporter = createReporter({ json: overrides.json ?? false }); + const interactionManager = makeMockInteraction(interactive, confirmResult); + const rollbackManager = makeMockRollbackManager(overrides.rollbackResult); + const operationLog = makeMockOperationLog(entry); + + if (overrides.loadFromError) { + (operationLog.loadFrom as jest.Mock).mockRejectedValue(overrides.loadFromError); + } + + const executor = makeMockExecutor(); + + const deps: RollbackCommandDeps = { + operationLog, + executor, + createRollbackManager: jest.fn().mockReturnValue(rollbackManager), + interactionManager, + reporter, + stdout, + }; + + return { deps, stdout, rollbackManager, operationLog, interactionManager }; +} + +function captureOutput(stream: PassThrough): () => string { + let output = ''; + stream.on('data', (chunk: Buffer) => { output += chunk.toString(); }); + return () => output; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('rollback.command', () => { + describe('successful rollback', () => { + it('loads last operation log and rolls back all steps', async () => { + const entry = makeEntry(); + const { deps, rollbackManager, operationLog } = makeDeps({ entry }); + + const result = await runRollbackCommand(makeOpts(), deps); + + expect(operationLog.loadLast).toHaveBeenCalledTimes(1); + expect(rollbackManager.record).toHaveBeenCalledTimes(entry.steps.length); + expect(rollbackManager.rollback).toHaveBeenCalledTimes(1); + expect(result.success).toBe(true); + expect(result.failedSteps).toEqual([]); + }); + + it('records all steps from log into rollback manager', async () => { + const entry = makeEntry(); + const { deps, rollbackManager } = makeDeps({ entry }); + + await runRollbackCommand(makeOpts(), deps); + + expect(rollbackManager.recordedSteps).toEqual(entry.steps); + }); + + it('writes success message to stdout', async () => { + const { deps, stdout } = makeDeps(); + const getOutput = captureOutput(stdout); + + await runRollbackCommand(makeOpts(), deps); + + expect(getOutput()).toContain('Rollback completed successfully'); + expect(getOutput()).toContain('4 step(s) rolled back'); + }); + }); + + describe('missing log (code 8)', () => { + it('throws NO_OPERATION when loadLast returns null', async () => { + const { deps } = makeDeps({ entry: null }); + + await expect(runRollbackCommand(makeOpts(), deps)).rejects.toThrow(VersioningsError); + + try { + await runRollbackCommand(makeOpts(), deps); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.NO_OPERATION); + expect(err.message).toContain('No operation log found'); + } + }); + }); + + describe('corrupted log (code 1)', () => { + it('propagates CONFIG_ERROR from loadFrom on corrupted log', async () => { + const corruptedError = new VersioningsError( + EXIT_CODES.CONFIG_ERROR, + 'Corrupted operation log: /path/to/log.json — invalid JSON: Unexpected token', + { filePath: '/path/to/log.json' }, + ); + const { deps } = makeDeps({ loadFromError: corruptedError }); + + await expect( + runRollbackCommand(makeOpts({ from: '/path/to/log.json' }), deps), + ).rejects.toThrow(VersioningsError); + + try { + await runRollbackCommand(makeOpts({ from: '/path/to/log.json' }), deps); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(err.message).toContain('Corrupted operation log'); + } + }); + }); + + describe('--from ', () => { + it('loads log from specified file instead of last', async () => { + const entry = makeEntry({ version: '2.0.0' }); + const { deps, operationLog } = makeDeps({ entry }); + + await runRollbackCommand(makeOpts({ from: '/custom/path.json' }), deps); + + expect(operationLog.loadFrom).toHaveBeenCalledWith('/custom/path.json'); + expect(operationLog.loadLast).not.toHaveBeenCalled(); + }); + }); + + describe('confirm flow (interactive mode)', () => { + it('shows plan and confirms before rollback', async () => { + const entry = makeEntry(); + const { deps, rollbackManager, interactionManager, stdout } = makeDeps({ + entry, + interactive: true, + confirmResult: true, + }); + const getOutput = captureOutput(stdout); + + await runRollbackCommand(makeOpts(), deps); + + expect(interactionManager.isInteractive).toHaveBeenCalled(); + expect(interactionManager.confirm).toHaveBeenCalledTimes(1); + expect(rollbackManager.rollback).toHaveBeenCalledTimes(1); + expect(getOutput()).toContain('Rollback completed successfully'); + }); + + it('throws USER_CANCELLED when user declines', async () => { + const { deps, rollbackManager } = makeDeps({ + interactive: true, + confirmResult: false, + }); + + await expect(runRollbackCommand(makeOpts(), deps)).rejects.toThrow(VersioningsError); + + try { + await runRollbackCommand(makeOpts(), deps); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.USER_CANCELLED); + expect(err.message).toContain('cancelled'); + } + + expect(rollbackManager.rollback).not.toHaveBeenCalled(); + }); + + it('skips confirm flow in non-interactive mode', async () => { + const { deps, interactionManager, rollbackManager } = makeDeps({ + interactive: false, + }); + + await runRollbackCommand(makeOpts(), deps); + + expect(interactionManager.confirm).not.toHaveBeenCalled(); + expect(rollbackManager.rollback).toHaveBeenCalledTimes(1); + }); + }); + + describe('partial rollback', () => { + it('throws INCOMPLETE_ROLLBACK when some steps fail', async () => { + const failedStep: RollbackStep = { type: 'branch_created', meta: { name: 'version/patch/1.0.1/fix' } }; + const { deps, stdout } = makeDeps({ + rollbackResult: { + success: false, + failedSteps: [{ step: failedStep, error: new Error('git branch -D failed') }], + }, + }); + const getOutput = captureOutput(stdout); + + await expect(runRollbackCommand(makeOpts(), deps)).rejects.toThrow(VersioningsError); + + try { + await runRollbackCommand(makeOpts(), deps); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.INCOMPLETE_ROLLBACK); + expect(err.details.failedSteps).toHaveLength(1); + expect(err.details.failedSteps[0].type).toBe('branch_created'); + } + + expect(getOutput()).toContain('partially completed'); + expect(getOutput()).toContain('Failed'); + }); + }); +}); diff --git a/__tests__/unit/cli/validate.command.test.ts b/__tests__/unit/cli/validate.command.test.ts new file mode 100644 index 0000000..a932171 --- /dev/null +++ b/__tests__/unit/cli/validate.command.test.ts @@ -0,0 +1,965 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { PassThrough } from 'stream'; +import { runValidateCommand, ValidateCommandDeps } from '../../../src/cli/commands/validate.command'; +import type { Executor, ExecutorResult } from '../../../src/core/executor'; +import type { Reporter, ValidateResult } from '../../../src/core/reporter'; +import type { ConfigLoadResult } from '../../../src/config/config.loader'; +import type { ConfigProvenance } from '../../../src/config/config.merger'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeConfigLoadResult(overrides: Partial = {}): ConfigLoadResult { + return { + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { + message: { + semver: { + prepatch: 'Patch version is preparing now: v%s.', + patch: 'Patch: v%s.', + preminor: 'Minor version is preparing now: v%s.', + minor: 'Minor: v%s.', + premajor: 'Release is preparing now: v%s.', + major: 'Release: v%s.', + prerelease: 'Preparing: v%s.', + }, + }, + }, + }, + } as any, + sources: [ + { name: 'defaults', data: {} }, + { name: 'version.json', data: { git: { platform: 'github' } }, filePath: '/tmp/version.json' }, + ], + provenance: { + 'git.platform': { value: 'github', source: 'version.json' }, + 'git.url': { value: 'https://github.com/org/repo', source: 'version.json' }, + 'git.pr.target': { value: 'main', source: 'defaults' }, + 'git.remote': { value: 'origin', source: 'defaults' }, + }, + warnings: [], + ...overrides, + }; +} + +function createMockConfigLoader(result?: ConfigLoadResult, error?: Error) { + return jest.fn((_deps: any) => { + if (error) throw error; + return result ?? makeConfigLoadResult(); + }); +} + +function createMockExecutor(overrides: Record = {}): Executor { + const defaults: Record = { + 'git rev-parse --is-inside-work-tree': 'true', + 'git remote get-url origin': 'https://github.com/org/repo', + }; + + return { + run: jest.fn(async (cmd: string): Promise => { + const val = overrides[cmd] ?? defaults[cmd]; + if (val instanceof Error) throw val; + if (val !== undefined) { + const s = val as string; + return { stdout: s, lines: s.split('\n').filter(Boolean) }; + } + throw new VersioningsError(EXIT_CODES.COMMAND_FAILED, `Unknown cmd: ${cmd}`, {}); + }), + }; +} + +function createMockReporter(): Reporter { + return { + reportSuccess: jest.fn(() => ''), + reportError: jest.fn(() => ''), + reportDryRun: jest.fn(() => ''), + reportValidation: jest.fn((r: ValidateResult) => JSON.stringify(r)), + reportDoctor: jest.fn(() => ''), + reportProvenance: jest.fn(() => ''), + reportConfirmPlan: jest.fn(() => ''), + }; +} + +function makeDeps(overrides: Partial = {}): ValidateCommandDeps & { stdout: PassThrough } { + const stdout = new PassThrough(); + stdout.setEncoding('utf8'); + + return { + configLoader: overrides.configLoader ?? createMockConfigLoader(), + executor: overrides.executor ?? createMockExecutor(), + reporter: overrides.reporter ?? createMockReporter(), + cwd: overrides.cwd ?? '/tmp/test-project', + env: overrides.env ?? {}, + stdout: overrides.stdout as any ?? stdout, + }; +} + +function drainStdout(stdout: PassThrough): string { + let output = ''; + let chunk: string | null; + while ((chunk = stdout.read() as string | null) !== null) { + output += chunk; + } + return output; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('validate.command — all checks passed', () => { + test('returns valid=true when config, git repo, and git remote all pass', async () => { + const deps = makeDeps(); + + const result = await runValidateCommand({ json: false }, deps); + + expect(result.valid).toBe(true); + expect(result.checks.some((c) => c.name === 'config_exists' && c.status === 'pass')).toBe(true); + expect(result.checks.some((c) => c.name === 'config_valid' && c.status === 'pass')).toBe(true); + expect(result.checks.some((c) => c.name === 'git_repo' && c.status === 'pass')).toBe(true); + expect(result.checks.some((c) => c.name === 'git_remote' && c.status === 'pass')).toBe(true); + }); + + test('calls configLoader with cwd and env from deps', async () => { + const configLoader = createMockConfigLoader(); + const deps = makeDeps({ configLoader, env: { FOO: 'bar' } }); + + await runValidateCommand({ json: false }, deps); + + expect(configLoader).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/tmp/test-project', env: { FOO: 'bar' } }), + ); + }); + + test('writes reporter output to stdout', async () => { + const reporter = createMockReporter(); + const deps = makeDeps({ reporter }); + + await runValidateCommand({ json: false }, deps); + + expect(reporter.reportValidation).toHaveBeenCalledTimes(1); + const output = drainStdout(deps.stdout); + expect(output.length).toBeGreaterThan(0); + }); +}); + +describe('validate.command — config invalid', () => { + test('returns valid=false when configLoader throws VersioningsError', async () => { + const configLoader = createMockConfigLoader( + undefined, + new VersioningsError(EXIT_CODES.CONFIG_ERROR, 'Configuration does not match schema.', { + validationErrors: [{ path: '/git/platform', message: 'must be string' }], + }), + ); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + expect(result.valid).toBe(false); + expect(result.checks.some((c) => c.name === 'config_exists' && c.status === 'fail')).toBe(true); + }); + + test('includes error message in check details', async () => { + const msg = 'Configuration does not match schema.'; + const configLoader = createMockConfigLoader( + undefined, + new VersioningsError(EXIT_CODES.CONFIG_ERROR, msg, {}), + ); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const failCheck = result.checks.find((c) => c.status === 'fail'); + expect(failCheck).toBeDefined(); + expect(failCheck!.details).toContain(msg); + }); +}); + +describe('validate.command — git remote inaccessible', () => { + test('returns warn when git repo is not accessible', async () => { + const executor = createMockExecutor({ + 'git rev-parse --is-inside-work-tree': new VersioningsError( + EXIT_CODES.COMMAND_FAILED, 'not a git repo', {}, + ) as any, + }); + const deps = makeDeps({ executor }); + + const result = await runValidateCommand({ json: false }, deps); + + expect(result.checks.some((c) => c.name === 'git_repo' && c.status === 'fail')).toBe(true); + // git_remote should be skipped/warn when repo is inaccessible + expect(result.checks.some((c) => c.name === 'git_remote' && c.status === 'warn')).toBe(true); + }); + + test('returns warn when git remote get-url fails', async () => { + const executor = createMockExecutor({ + 'git remote get-url origin': new VersioningsError( + EXIT_CODES.COMMAND_FAILED, 'no remote', {}, + ) as any, + }); + const deps = makeDeps({ executor }); + + const result = await runValidateCommand({ json: false }, deps); + + expect(result.checks.some((c) => c.name === 'git_remote' && c.status === 'warn')).toBe(true); + }); + + test('returns fail when git remote URL does not match config', async () => { + const executor = createMockExecutor({ + 'git remote get-url origin': 'https://github.com/other/repo', + }); + const deps = makeDeps({ executor }); + + const result = await runValidateCommand({ json: false }, deps); + + expect(result.checks.some((c) => c.name === 'git_remote' && c.status === 'fail')).toBe(true); + }); +}); + +describe('validate.command — JSON output', () => { + test('passes json option through to reporter', async () => { + const reporter = createMockReporter(); + const deps = makeDeps({ reporter }); + + await runValidateCommand({ json: true }, deps); + + expect(reporter.reportValidation).toHaveBeenCalledWith( + expect.objectContaining({ valid: true }), + ); + }); + + test('result structure matches ValidateResult shape', async () => { + const deps = makeDeps(); + + const result = await runValidateCommand({ json: true }, deps); + + expect(result).toHaveProperty('valid'); + expect(result).toHaveProperty('checks'); + expect(result).toHaveProperty('provenance'); + expect(Array.isArray(result.checks)).toBe(true); + }); +}); + +describe('validate.command — provenance in output', () => { + test('includes provenance from configLoader in result', async () => { + const provenance: ConfigProvenance = { + 'git.platform': { value: 'github', source: 'env' }, + 'git.url': { value: 'https://github.com/org/repo', source: '.versioningsrc' }, + }; + const configLoader = createMockConfigLoader(makeConfigLoadResult({ provenance })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + expect(result.provenance).toEqual(provenance); + expect(result.provenance['git.platform'].source).toBe('env'); + expect(result.provenance['git.url'].source).toBe('.versioningsrc'); + }); + + test('provenance is empty when configLoader fails', async () => { + const configLoader = createMockConfigLoader( + undefined, + new VersioningsError(EXIT_CODES.CONFIG_ERROR, 'bad config', {}), + ); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + expect(result.provenance).toEqual({}); + }); + + test('reporter receives provenance in ValidateResult', async () => { + const provenance: ConfigProvenance = { + 'git.platform': { value: 'bitbucket', source: 'cli' }, + }; + const configLoader = createMockConfigLoader(makeConfigLoadResult({ provenance })); + const reporter = createMockReporter(); + const deps = makeDeps({ configLoader, reporter }); + + await runValidateCommand({ json: false }, deps); + + expect(reporter.reportValidation).toHaveBeenCalledWith( + expect.objectContaining({ + provenance: expect.objectContaining({ + 'git.platform': { value: 'bitbucket', source: 'cli' }, + }), + }), + ); + }); +}); + +describe('validate.command — branching strategy check', () => { + test('returns branching_strategy pass with default when no git.branching configured', async () => { + const deps = makeDeps(); + + const result = await runValidateCommand({ json: false }, deps); + + const bsCheck = result.checks.find((c) => c.name === 'branching_strategy'); + expect(bsCheck).toBeDefined(); + expect(bsCheck!.status).toBe('pass'); + expect(bsCheck!.details).toContain('default'); + expect(bsCheck!.details).toContain('backward compatible'); + }); + + test('returns branching_strategy pass with default when strategy is "default"', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + branching: { strategy: 'default', mainBranch: 'master', developBranch: 'develop' }, + }, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const bsCheck = result.checks.find((c) => c.name === 'branching_strategy'); + expect(bsCheck).toBeDefined(); + expect(bsCheck!.status).toBe('pass'); + expect(bsCheck!.details).toContain('default'); + }); + + test('returns branching_strategy pass with details for trunk-based strategy', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + branching: { strategy: 'trunk-based', mainBranch: 'main' }, + }, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const bsCheck = result.checks.find((c) => c.name === 'branching_strategy'); + expect(bsCheck).toBeDefined(); + expect(bsCheck!.status).toBe('pass'); + expect(bsCheck!.details).toContain('trunk-based'); + expect(bsCheck!.details).toContain('mainBranch: main'); + }); + + test('returns branching_strategy pass with all details for git-flow strategy', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + branching: { + strategy: 'git-flow', + mainBranch: 'main', + developBranch: 'develop', + branchTemplate: 'release/{version}', + tagTemplate: 'v{version}', + }, + }, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const bsCheck = result.checks.find((c) => c.name === 'branching_strategy'); + expect(bsCheck).toBeDefined(); + expect(bsCheck!.status).toBe('pass'); + expect(bsCheck!.details).toContain('git-flow'); + expect(bsCheck!.details).toContain('mainBranch: main'); + expect(bsCheck!.details).toContain('developBranch: develop'); + expect(bsCheck!.details).toContain('branchTemplate: release/{version}'); + expect(bsCheck!.details).toContain('tagTemplate: v{version}'); + }); + + test('returns branching_strategy fail for unknown strategy', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + branching: { strategy: 'unknown-strategy' }, + }, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + expect(result.valid).toBe(false); + const bsCheck = result.checks.find((c) => c.name === 'branching_strategy'); + expect(bsCheck).toBeDefined(); + expect(bsCheck!.status).toBe('fail'); + expect(bsCheck!.details).toContain('Unknown branching strategy'); + expect(bsCheck!.details).toContain('unknown-strategy'); + expect(bsCheck!.details).toContain('trunk-based'); + expect(bsCheck!.details).toContain('git-flow'); + }); + + test('does not include branching_strategy check when config fails to load', async () => { + const configLoader = createMockConfigLoader( + undefined, + new VersioningsError(EXIT_CODES.CONFIG_ERROR, 'bad config', {}), + ); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const bsCheck = result.checks.find((c) => c.name === 'branching_strategy'); + expect(bsCheck).toBeUndefined(); + }); +}); + +describe('validate.command — conventional_commits check', () => { + test('returns pass with defaults when no conventionalCommits section', async () => { + const deps = makeDeps(); + + const result = await runValidateCommand({ json: false }, deps); + + const ccCheck = result.checks.find((c) => c.name === 'conventional_commits'); + expect(ccCheck).toBeDefined(); + expect(ccCheck!.status).toBe('pass'); + expect(ccCheck!.details).toContain('using defaults'); + }); + + test('returns pass with details when conventionalCommits is configured', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + }, + conventionalCommits: { + enabled: true, + types: { refactor: 'patch' }, + fallbackBump: 'patch', + }, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const ccCheck = result.checks.find((c) => c.name === 'conventional_commits'); + expect(ccCheck).toBeDefined(); + expect(ccCheck!.status).toBe('pass'); + expect(ccCheck!.details).toContain('enabled: true'); + expect(ccCheck!.details).toContain('refactor→patch'); + expect(ccCheck!.details).toContain('fallbackBump: patch'); + }); + + test('returns warn when conventionalCommits.enabled is false', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + }, + conventionalCommits: { + enabled: false, + types: {}, + fallbackBump: null, + }, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const ccCheck = result.checks.find((c) => c.name === 'conventional_commits'); + expect(ccCheck).toBeDefined(); + expect(ccCheck!.status).toBe('warn'); + expect(ccCheck!.details).toContain('enabled: false'); + }); + + test('shows fallbackBump as none when null', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + }, + conventionalCommits: { + enabled: true, + types: {}, + fallbackBump: null, + }, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const ccCheck = result.checks.find((c) => c.name === 'conventional_commits'); + expect(ccCheck).toBeDefined(); + expect(ccCheck!.details).toContain('fallbackBump: none'); + }); + + test('does not include conventional_commits check when config fails to load', async () => { + const configLoader = createMockConfigLoader( + undefined, + new VersioningsError(EXIT_CODES.CONFIG_ERROR, 'bad config', {}), + ); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const ccCheck = result.checks.find((c) => c.name === 'conventional_commits'); + expect(ccCheck).toBeUndefined(); + }); +}); + +describe('validate.command — changelog_config check', () => { + test('returns pass with "not configured" when no changelog section', async () => { + const deps = makeDeps(); + + const result = await runValidateCommand({ json: false }, deps); + + const clCheck = result.checks.find((c) => c.name === 'changelog_config'); + expect(clCheck).toBeDefined(); + expect(clCheck!.status).toBe('pass'); + expect(clCheck!.details).toContain('not configured'); + }); + + test('returns pass with file path when changelog.file is set', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + }, + changelog: { + file: 'CHANGELOG.md', + groupTitles: {}, + excludeTypes: [], + includeNonConventional: false, + }, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const clCheck = result.checks.find((c) => c.name === 'changelog_config'); + expect(clCheck).toBeDefined(); + expect(clCheck!.status).toBe('pass'); + expect(clCheck!.details).toContain('file: CHANGELOG.md'); + }); + + test('returns pass with custom groupTitles details', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + }, + changelog: { + groupTitles: { feat: 'New Features', fix: 'Bugfixes' }, + excludeTypes: [], + includeNonConventional: false, + }, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const clCheck = result.checks.find((c) => c.name === 'changelog_config'); + expect(clCheck).toBeDefined(); + expect(clCheck!.status).toBe('pass'); + expect(clCheck!.details).toContain('custom groupTitles'); + expect(clCheck!.details).toContain('feat'); + expect(clCheck!.details).toContain('fix'); + }); + + test('returns pass with excludeTypes details', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + }, + changelog: { + groupTitles: {}, + excludeTypes: ['chore', 'docs'], + includeNonConventional: false, + }, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const clCheck = result.checks.find((c) => c.name === 'changelog_config'); + expect(clCheck).toBeDefined(); + expect(clCheck!.details).toContain('excludeTypes: chore, docs'); + }); + + test('returns pass with includeNonConventional when true', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + }, + changelog: { + groupTitles: {}, + excludeTypes: [], + includeNonConventional: true, + }, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const clCheck = result.checks.find((c) => c.name === 'changelog_config'); + expect(clCheck).toBeDefined(); + expect(clCheck!.details).toContain('includeNonConventional: true'); + }); + + test('returns pass with "configured with defaults" when changelog section has only defaults', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + }, + changelog: { + groupTitles: {}, + excludeTypes: [], + includeNonConventional: false, + }, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const clCheck = result.checks.find((c) => c.name === 'changelog_config'); + expect(clCheck).toBeDefined(); + expect(clCheck!.status).toBe('pass'); + expect(clCheck!.details).toContain('configured with defaults'); + }); + + test('does not include changelog_config check when config fails to load', async () => { + const configLoader = createMockConfigLoader( + undefined, + new VersioningsError(EXIT_CODES.CONFIG_ERROR, 'bad config', {}), + ); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const clCheck = result.checks.find((c) => c.name === 'changelog_config'); + expect(clCheck).toBeUndefined(); + }); +}); + + +describe('validate.command — log_level check', () => { + test('returns pass with default when logLevel is not configured', async () => { + const deps = makeDeps(); + + const result = await runValidateCommand({ json: false }, deps); + + const check = result.checks.find((c) => c.name === 'log_level'); + expect(check).toBeDefined(); + expect(check!.status).toBe('pass'); + expect(check!.details).toContain('warn'); + expect(check!.details).toContain('default'); + }); + + test('returns pass with value when logLevel is valid', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + }, + logLevel: 'debug', + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const check = result.checks.find((c) => c.name === 'log_level'); + expect(check).toBeDefined(); + expect(check!.status).toBe('pass'); + expect(check!.details).toContain('debug'); + }); + + test('returns pass for each valid logLevel value', async () => { + for (const level of ['debug', 'info', 'warn', 'error']) { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + }, + logLevel: level, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const check = result.checks.find((c) => c.name === 'log_level'); + expect(check).toBeDefined(); + expect(check!.status).toBe('pass'); + expect(check!.details).toContain(level); + } + }); + + test('returns fail for invalid logLevel value', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + }, + logLevel: 'verbose', + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + expect(result.valid).toBe(false); + const check = result.checks.find((c) => c.name === 'log_level'); + expect(check).toBeDefined(); + expect(check!.status).toBe('fail'); + expect(check!.details).toContain('verbose'); + expect(check!.details).toContain('debug'); + expect(check!.details).toContain('error'); + }); + + test('does not include log_level check when config fails to load', async () => { + const configLoader = createMockConfigLoader( + undefined, + new VersioningsError(EXIT_CODES.CONFIG_ERROR, 'bad config', {}), + ); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const check = result.checks.find((c) => c.name === 'log_level'); + expect(check).toBeUndefined(); + }); +}); + +describe('validate.command — lock_timeout check', () => { + test('returns pass with default when lockTimeoutMs is not configured', async () => { + const deps = makeDeps(); + + const result = await runValidateCommand({ json: false }, deps); + + const check = result.checks.find((c) => c.name === 'lock_timeout'); + expect(check).toBeDefined(); + expect(check!.status).toBe('pass'); + expect(check!.details).toContain('300000'); + expect(check!.details).toContain('default'); + }); + + test('returns pass with value when lockTimeoutMs is valid', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + }, + lockTimeoutMs: 600000, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const check = result.checks.find((c) => c.name === 'lock_timeout'); + expect(check).toBeDefined(); + expect(check!.status).toBe('pass'); + expect(check!.details).toContain('600000'); + }); + + test('returns fail for non-integer lockTimeoutMs', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + }, + lockTimeoutMs: 1.5, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + expect(result.valid).toBe(false); + const check = result.checks.find((c) => c.name === 'lock_timeout'); + expect(check).toBeDefined(); + expect(check!.status).toBe('fail'); + expect(check!.details).toContain('1.5'); + expect(check!.details).toContain('positive integer'); + }); + + test('returns fail for zero lockTimeoutMs', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + }, + lockTimeoutMs: 0, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + expect(result.valid).toBe(false); + const check = result.checks.find((c) => c.name === 'lock_timeout'); + expect(check).toBeDefined(); + expect(check!.status).toBe('fail'); + }); + + test('returns fail for negative lockTimeoutMs', async () => { + const configLoader = createMockConfigLoader(makeConfigLoadResult({ + config: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + commit: { message: { semver: {} } }, + }, + lockTimeoutMs: -100, + } as any, + })); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + expect(result.valid).toBe(false); + const check = result.checks.find((c) => c.name === 'lock_timeout'); + expect(check).toBeDefined(); + expect(check!.status).toBe('fail'); + }); + + test('does not include lock_timeout check when config fails to load', async () => { + const configLoader = createMockConfigLoader( + undefined, + new VersioningsError(EXIT_CODES.CONFIG_ERROR, 'bad config', {}), + ); + const deps = makeDeps({ configLoader }); + + const result = await runValidateCommand({ json: false }, deps); + + const check = result.checks.find((c) => c.name === 'lock_timeout'); + expect(check).toBeUndefined(); + }); +}); diff --git a/__tests__/unit/config.validator.test.ts b/__tests__/unit/config.validator.test.ts deleted file mode 100644 index 96bc670..0000000 --- a/__tests__/unit/config.validator.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2018-present Raman Marozau - -import * as path from 'path'; -import * as os from 'os'; -import * as fs from 'fs'; -import { loadAndValidateConfig } from '../../config.validator'; -import { EXIT_CODES, VersioningsError } from '../../errors'; - -describe('config.validator — loadAndValidateConfig', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-')); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - function writeConfig(obj: Record): string { - const filePath = path.join(tmpDir, 'version.json'); - fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8'); - return filePath; - } - - function writeRawConfig(content: string): string { - const filePath = path.join(tmpDir, 'version.json'); - fs.writeFileSync(filePath, content, 'utf8'); - return filePath; - } - - test('valid config — returns merged config with defaults', () => { - const filePath = writeConfig({ - git: { - platform: 'github', - url: 'https://github.com/user/repo.git', - }, - }); - const config = loadAndValidateConfig(filePath); - expect(config.git.platform).toBe('github'); - expect(config.git.url).toBe('https://github.com/user/repo.git'); - expect(config.git.remote).toBe('origin'); - expect(config.git.branchType).toEqual({ version: 'version' }); - expect(config.git.pr.target).toBe('master'); - expect(config.git.limits).toEqual({ branchMaxCommentLength: 96 }); - expect(config.git.commit.message.semver.patch).toBe( - 'Patch: v%s. You SHOULD consider changes.' - ); - }); - - test('missing file — throws VersioningsError with CONFIG_ERROR and expectedPath', () => { - const filePath = path.join(tmpDir, 'nonexistent.json'); - expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); - try { - loadAndValidateConfig(filePath); - } catch (err: any) { - expect(err).toBeInstanceOf(VersioningsError); - expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); - expect(err.details).toBeDefined(); - expect(err.details.expectedPath).toBe(filePath); - } - }); - - test('invalid JSON — throws VersioningsError with CONFIG_ERROR and parseError', () => { - const filePath = writeRawConfig('{ not valid json!!!'); - expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); - try { - loadAndValidateConfig(filePath); - } catch (err: any) { - expect(err).toBeInstanceOf(VersioningsError); - expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); - expect(err.details).toBeDefined(); - expect(err.details.parseError).toBeDefined(); - expect(typeof err.details.parseError).toBe('string'); - } - }); - - test('schema mismatch — missing required field throws with validationErrors', () => { - const filePath = writeConfig({ git: { platform: 'github' } }); - expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); - try { - loadAndValidateConfig(filePath); - } catch (err: any) { - expect(err).toBeInstanceOf(VersioningsError); - expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); - expect(err.details).toBeDefined(); - expect(Array.isArray(err.details.validationErrors)).toBe(true); - expect(err.details.validationErrors.length).toBeGreaterThan(0); - err.details.validationErrors.forEach((ve: any) => { - expect(ve).toHaveProperty('path'); - expect(ve).toHaveProperty('message'); - }); - } - }); - - test('schema mismatch — invalid platform value throws with validationErrors', () => { - const filePath = writeConfig({ - git: { platform: 'gitlab', url: 'https://gitlab.com/user/repo.git' }, - }); - expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); - try { - loadAndValidateConfig(filePath); - } catch (err: any) { - expect(err).toBeInstanceOf(VersioningsError); - expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); - expect(Array.isArray(err.details.validationErrors)).toBe(true); - expect(err.details.validationErrors.length).toBeGreaterThan(0); - } - }); - - test('merge with defaults — pr.target defaults to master when not in config', () => { - const filePath = writeConfig({ - git: { platform: 'bitbucket', url: 'https://bitbucket.org/user/repo.git' }, - }); - const config = loadAndValidateConfig(filePath); - expect(config.git.pr.target).toBe('master'); - expect(config.git.remote).toBe('origin'); - expect(config.git.platform).toBe('bitbucket'); - }); - - test('merge with defaults — pr.target from config overrides default', () => { - const filePath = writeConfig({ - git: { - platform: 'github', - url: 'https://github.com/user/repo.git', - pr: { target: 'develop' }, - }, - }); - const config = loadAndValidateConfig(filePath); - expect(config.git.pr.target).toBe('develop'); - expect(config.git.remote).toBe('origin'); - }); -}); diff --git a/__tests__/unit/config/config.loader.test.ts b/__tests__/unit/config/config.loader.test.ts new file mode 100644 index 0000000..dcb5089 --- /dev/null +++ b/__tests__/unit/config/config.loader.test.ts @@ -0,0 +1,720 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import * as path from 'path'; +import { loadConfig, ConfigLoaderDeps } from '../../../src/config/config.loader'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; + +/** + * Helper: create a mock filesystem from a map of filePath → content. + * Returns existsSync and readFileSync stubs. + */ +function createMockFs(files: Record) { + const existsSync = (p: string): boolean => p in files; + const readFileSync = (p: string, _enc: BufferEncoding): string => { + if (!(p in files)) throw new Error(`ENOENT: no such file: ${p}`); + return files[p]; + }; + return { existsSync, readFileSync }; +} + +const CWD = '/project'; + +/** Minimal valid git config that satisfies schema (platform + url required) */ +const VALID_GIT = { git: { platform: 'github', url: 'https://github.com/org/repo.git' } }; + +describe('config.loader — loadConfig', () => { + // ----------------------------------------------------------------------- + // 1. Loading from each source individually + // ----------------------------------------------------------------------- + + describe('loading from individual sources', () => { + test('loads from version.json', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + + const result = loadConfig({ cwd: CWD, env: {}, ...fs }); + + expect(result.config.git.platform).toBe('github'); + expect(result.config.git.url).toBe('https://github.com/org/repo.git'); + expect(result.sources.some((s) => s.name === 'version.json')).toBe(true); + }); + + test('loads from .versioningsrc (JSON)', () => { + const fs = createMockFs({ + [path.join(CWD, '.versioningsrc')]: JSON.stringify(VALID_GIT), + }); + + const result = loadConfig({ cwd: CWD, env: {}, ...fs }); + + expect(result.config.git.platform).toBe('github'); + expect(result.sources.some((s) => s.name === '.versioningsrc')).toBe(true); + }); + + test('loads from .versioningsrc.json', () => { + const fs = createMockFs({ + [path.join(CWD, '.versioningsrc.json')]: JSON.stringify(VALID_GIT), + }); + + const result = loadConfig({ cwd: CWD, env: {}, ...fs }); + + expect(result.config.git.platform).toBe('github'); + expect(result.sources.some((s) => s.name === '.versioningsrc.json')).toBe(true); + }); + + test('loads from .versioningsrc.yml (YAML)', () => { + const yamlContent = 'git:\n platform: github\n url: https://github.com/org/repo.git\n'; + const fs = createMockFs({ + [path.join(CWD, '.versioningsrc.yml')]: yamlContent, + }); + + const result = loadConfig({ cwd: CWD, env: {}, ...fs }); + + expect(result.config.git.platform).toBe('github'); + expect(result.config.git.url).toBe('https://github.com/org/repo.git'); + expect(result.sources.some((s) => s.name === '.versioningsrc.yml')).toBe(true); + }); + + test('loads from .versioningsrc.yaml (YAML)', () => { + const yamlContent = 'git:\n platform: bitbucket\n url: https://bitbucket.org/org/repo.git\n'; + const fs = createMockFs({ + [path.join(CWD, '.versioningsrc.yaml')]: yamlContent, + }); + + const result = loadConfig({ cwd: CWD, env: {}, ...fs }); + + expect(result.config.git.platform).toBe('bitbucket'); + expect(result.sources.some((s) => s.name === '.versioningsrc.yaml')).toBe(true); + }); + + test('loads from package.json#versionings', () => { + const pkg = { + name: 'my-app', + version: '1.0.0', + versionings: VALID_GIT, + }; + const fs = createMockFs({ + [path.join(CWD, 'package.json')]: JSON.stringify(pkg), + }); + + const result = loadConfig({ cwd: CWD, env: {}, ...fs }); + + expect(result.config.git.platform).toBe('github'); + expect(result.sources.some((s) => s.name === 'package.json#versionings')).toBe(true); + }); + + test('loads from env vars (VERSIONINGS_GIT_PLATFORM, VERSIONINGS_GIT_URL)', () => { + const fs = createMockFs({}); + const env = { + VERSIONINGS_GIT_PLATFORM: 'bitbucket', + VERSIONINGS_GIT_URL: 'https://bitbucket.org/org/repo.git', + }; + + const result = loadConfig({ cwd: CWD, env, ...fs }); + + expect(result.config.git.platform).toBe('bitbucket'); + expect(result.config.git.url).toBe('https://bitbucket.org/org/repo.git'); + expect(result.sources.some((s) => s.name === 'env')).toBe(true); + }); + + test('loads from CLI overrides', () => { + const fs = createMockFs({}); + + const result = loadConfig({ + cwd: CWD, + env: {}, + cliOverrides: VALID_GIT, + ...fs, + }); + + expect(result.config.git.platform).toBe('github'); + expect(result.sources.some((s) => s.name === 'cli')).toBe(true); + }); + }); + + // ----------------------------------------------------------------------- + // 2. Merging multiple sources with correct priority + // ----------------------------------------------------------------------- + + describe('merging multiple sources with correct priority', () => { + test('CLI overrides beat env vars, env vars beat version.json', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify({ + git: { platform: 'github', url: 'https://github.com/org/repo.git' }, + }), + }); + + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_GIT_PLATFORM: 'bitbucket' }, + cliOverrides: { git: { platform: 'github' } }, + ...fs, + }); + + // CLI wins over env + expect(result.config.git.platform).toBe('github'); + }); + + test('env vars override RC file values', () => { + const fs = createMockFs({ + [path.join(CWD, '.versioningsrc')]: JSON.stringify({ + git: { platform: 'github', url: 'https://github.com/org/repo.git' }, + }), + }); + + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_GIT_PLATFORM: 'bitbucket' }, + ...fs, + }); + + expect(result.config.git.platform).toBe('bitbucket'); + }); + + test('deep merge preserves fields from lower-priority sources', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + pr: { target: 'develop' }, + }, + }), + }); + + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_GIT_REMOTE: 'upstream' }, + ...fs, + }); + + // version.json fields preserved + expect(result.config.git.platform).toBe('github'); + expect(result.config.git.pr.target).toBe('develop'); + // env override applied + expect(result.config.git.remote).toBe('upstream'); + }); + }); + + // ----------------------------------------------------------------------- + // 3. Env var mapping + // ----------------------------------------------------------------------- + + describe('env var mapping', () => { + // All env var tests provide both required fields to pass schema validation + + test('VERSIONINGS_GIT_PLATFORM → git.platform', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_GIT_PLATFORM: 'bitbucket' }, + ...fs, + }); + expect(result.config.git.platform).toBe('bitbucket'); + }); + + test('VERSIONINGS_GIT_URL → git.url', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_GIT_URL: 'https://github.com/other/repo.git' }, + ...fs, + }); + expect(result.config.git.url).toBe('https://github.com/other/repo.git'); + }); + + test('VERSIONINGS_GIT_PR_TARGET → git.pr.target', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_GIT_PR_TARGET: 'develop' }, + ...fs, + }); + expect(result.config.git.pr.target).toBe('develop'); + }); + + test('VERSIONINGS_GIT_REMOTE → git.remote', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_GIT_REMOTE: 'upstream' }, + ...fs, + }); + expect(result.config.git.remote).toBe('upstream'); + }); + + test('VERSIONINGS_GIT_BRANCH_TYPE_VERSION → git.branchType.version', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_GIT_BRANCH_TYPE_VERSION: 'release' }, + ...fs, + }); + expect(result.config.git.branchType.version).toBe('release'); + }); + + test('VERSIONINGS_GIT_BRANCHING_STRATEGY → git.branching.strategy', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_GIT_BRANCHING_STRATEGY: 'trunk-based' }, + ...fs, + }); + expect(result.config.git.branching.strategy).toBe('trunk-based'); + }); + + test('VERSIONINGS_GIT_BRANCHING_MAIN_BRANCH → git.branching.mainBranch', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_GIT_BRANCHING_MAIN_BRANCH: 'main' }, + ...fs, + }); + expect(result.config.git.branching.mainBranch).toBe('main'); + }); + + test('VERSIONINGS_GIT_BRANCHING_DEVELOP_BRANCH → git.branching.developBranch', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_GIT_BRANCHING_DEVELOP_BRANCH: 'dev' }, + ...fs, + }); + expect(result.config.git.branching.developBranch).toBe('dev'); + }); + + test('VERSIONINGS_CONVENTIONAL_COMMITS_ENABLED=true → conventionalCommits.enabled (boolean true)', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_CONVENTIONAL_COMMITS_ENABLED: 'true' }, + ...fs, + }); + expect(result.config.conventionalCommits!.enabled).toBe(true); + expect(typeof result.config.conventionalCommits!.enabled).toBe('boolean'); + }); + + test('VERSIONINGS_CONVENTIONAL_COMMITS_ENABLED=false → conventionalCommits.enabled (boolean false)', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_CONVENTIONAL_COMMITS_ENABLED: 'false' }, + ...fs, + }); + expect(result.config.conventionalCommits!.enabled).toBe(false); + expect(typeof result.config.conventionalCommits!.enabled).toBe('boolean'); + }); + + test('VERSIONINGS_CONVENTIONAL_COMMITS_FALLBACK_BUMP=patch → conventionalCommits.fallbackBump', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_CONVENTIONAL_COMMITS_FALLBACK_BUMP: 'patch' }, + ...fs, + }); + expect(result.config.conventionalCommits!.fallbackBump).toBe('patch'); + }); + + test('VERSIONINGS_CONVENTIONAL_COMMITS_FALLBACK_BUMP=null → conventionalCommits.fallbackBump (actual null)', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_CONVENTIONAL_COMMITS_FALLBACK_BUMP: 'null' }, + ...fs, + }); + expect(result.config.conventionalCommits!.fallbackBump).toBeNull(); + }); + + test('VERSIONINGS_LOG_LEVEL → logLevel', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_LOG_LEVEL: 'debug' }, + ...fs, + }); + expect(result.config.logLevel).toBe('debug'); + expect(result.sources.some((s) => s.name === 'env')).toBe(true); + }); + + test('VERSIONINGS_LOG_LEVEL validates via schema (invalid value rejected)', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + expect(() => + loadConfig({ + cwd: CWD, + env: { VERSIONINGS_LOG_LEVEL: 'verbose' }, + ...fs, + }), + ).toThrow(VersioningsError); + }); + + test('VERSIONINGS_LOCK_TIMEOUT_MS → lockTimeoutMs (parsed as integer)', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_LOCK_TIMEOUT_MS: '600000' }, + ...fs, + }); + expect(result.config.lockTimeoutMs).toBe(600000); + expect(typeof result.config.lockTimeoutMs).toBe('number'); + }); + + test('VERSIONINGS_LOCK_TIMEOUT_MS with non-integer value is rejected by schema', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + expect(() => + loadConfig({ + cwd: CWD, + env: { VERSIONINGS_LOCK_TIMEOUT_MS: 'abc' }, + ...fs, + }), + ).toThrow(VersioningsError); + }); + + test('config without logLevel and lockTimeoutMs env vars passes validation (backward compat)', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ cwd: CWD, env: {}, ...fs }); + // No error thrown — backward compatible + expect(result.config.git.platform).toBe('github'); + }); + + test('config without new env vars works as before (backward compatibility)', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_GIT_REMOTE: 'upstream' }, + ...fs, + }); + // No conventionalCommits env vars set → no conventionalCommits in merged config + // (defaults for conventionalCommits are applied by config.validator, not config.loader) + expect(result.config.git.remote).toBe('upstream'); + expect(result.sources.some((s) => s.name === 'env')).toBe(true); + }); + + test('empty env var values are ignored', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + }); + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_GIT_PLATFORM: '' }, + ...fs, + }); + // Should not have env source since value is empty + expect(result.sources.some((s) => s.name === 'env')).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // 4. Multiple RC files → warning, first one used + // ----------------------------------------------------------------------- + + describe('multiple RC files', () => { + test('warns when multiple RC files found and uses the first one', () => { + const fs = createMockFs({ + [path.join(CWD, '.versioningsrc')]: JSON.stringify(VALID_GIT), + [path.join(CWD, '.versioningsrc.json')]: JSON.stringify({ + git: { platform: 'bitbucket', url: 'https://bitbucket.org/org/repo.git' }, + }), + }); + + const result = loadConfig({ cwd: CWD, env: {}, ...fs }); + + // First RC file (.versioningsrc) wins + expect(result.config.git.platform).toBe('github'); + expect(result.sources.some((s) => s.name === '.versioningsrc')).toBe(true); + // Warning about multiple RC files + expect(result.warnings.some((w) => w.includes('Multiple RC files'))).toBe(true); + }); + + test('warns when .versioningsrc and .versioningsrc.yml both exist', () => { + const fs = createMockFs({ + [path.join(CWD, '.versioningsrc')]: JSON.stringify(VALID_GIT), + [path.join(CWD, '.versioningsrc.yml')]: 'git:\n platform: bitbucket\n url: https://bitbucket.org/org/repo.git\n', + }); + + const result = loadConfig({ cwd: CWD, env: {}, ...fs }); + + expect(result.config.git.platform).toBe('github'); // .versioningsrc wins + expect(result.warnings.some((w) => w.includes('Multiple RC files'))).toBe(true); + }); + }); + + // ----------------------------------------------------------------------- + // 5. No sources found → warning with example config + // ----------------------------------------------------------------------- + + describe('no sources found', () => { + test('warns with example config when no user-provided sources exist', () => { + const fs = createMockFs({}); + + // loadConfig will throw because defaults don't satisfy schema (platform/url undefined). + // But the warning should be generated before validation. Let's check that the + // "no sources" scenario produces the expected warning by catching the error. + let result: any; + try { + result = loadConfig({ cwd: CWD, env: {}, ...fs }); + } catch (err: any) { + // Schema validation fails because defaults have undefined platform/url. + // This is expected — the "no sources" warning is still generated. + // We verify the error is CONFIG_ERROR (schema validation). + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + return; + } + + // If it somehow passes (shouldn't), check warnings + expect(result.warnings.length).toBeGreaterThan(0); + }); + + test('defaults source is always present even with no user sources', () => { + const fs = createMockFs({}); + + // Same as above — defaults alone fail schema validation. + // We verify the error is thrown (defaults are the only source). + expect(() => loadConfig({ cwd: CWD, env: {}, ...fs })).toThrow(VersioningsError); + }); + }); + + // ----------------------------------------------------------------------- + // 6. Strict mode — unknown fields cause errors via ajv validation + // ----------------------------------------------------------------------- + + describe('strict mode (unknown fields)', () => { + test('unknown top-level field causes schema validation error (additionalProperties: false at root)', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify({ + ...VALID_GIT, + unknownField: 'value', + }), + }); + + expect(() => loadConfig({ cwd: CWD, env: {}, strict: true, ...fs })).toThrow(VersioningsError); + try { + loadConfig({ cwd: CWD, env: {}, strict: true, ...fs }); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }); + }); + + // ----------------------------------------------------------------------- + // 7. YAML RC file loading + // ----------------------------------------------------------------------- + + describe('YAML RC file loading', () => { + test('loads valid YAML from .versioningsrc.yml', () => { + const yaml = [ + 'git:', + ' platform: github', + ' url: https://github.com/org/repo.git', + ' pr:', + ' target: develop', + ].join('\n'); + + const fs = createMockFs({ + [path.join(CWD, '.versioningsrc.yml')]: yaml, + }); + + const result = loadConfig({ cwd: CWD, env: {}, ...fs }); + + expect(result.config.git.platform).toBe('github'); + expect(result.config.git.pr.target).toBe('develop'); + }); + + test('loads valid YAML from .versioningsrc.yaml', () => { + const yaml = [ + 'git:', + ' platform: bitbucket', + ' url: https://bitbucket.org/org/repo.git', + ].join('\n'); + + const fs = createMockFs({ + [path.join(CWD, '.versioningsrc.yaml')]: yaml, + }); + + const result = loadConfig({ cwd: CWD, env: {}, ...fs }); + + expect(result.config.git.platform).toBe('bitbucket'); + }); + }); + + // ----------------------------------------------------------------------- + // 8. Invalid JSON in version.json → VersioningsError + // ----------------------------------------------------------------------- + + describe('invalid JSON in version.json', () => { + test('throws VersioningsError(CONFIG_ERROR) for malformed JSON', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: '{ not valid json!!!', + }); + + expect(() => loadConfig({ cwd: CWD, env: {}, ...fs })).toThrow(VersioningsError); + try { + loadConfig({ cwd: CWD, env: {}, ...fs }); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(err.message).toContain('Invalid JSON'); + } + }); + + test('throws VersioningsError(CONFIG_ERROR) for truncated JSON', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: '{"git": {"platform": "github"', + }); + + expect(() => loadConfig({ cwd: CWD, env: {}, ...fs })).toThrow(VersioningsError); + try { + loadConfig({ cwd: CWD, env: {}, ...fs }); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }); + }); + + // ----------------------------------------------------------------------- + // 9. Invalid YAML in RC file → VersioningsError + // ----------------------------------------------------------------------- + + describe('invalid YAML in RC file', () => { + test('throws VersioningsError(CONFIG_ERROR) for malformed YAML in .versioningsrc.yml', () => { + const badYaml = 'git:\n platform: github\n url:\n - this is\n bad: [unclosed'; + const fs = createMockFs({ + [path.join(CWD, '.versioningsrc.yml')]: badYaml, + }); + + expect(() => loadConfig({ cwd: CWD, env: {}, ...fs })).toThrow(VersioningsError); + try { + loadConfig({ cwd: CWD, env: {}, ...fs }); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(err.message).toContain('Invalid YAML'); + } + }); + + test('throws VersioningsError(CONFIG_ERROR) for invalid YAML in .versioningsrc.yaml', () => { + const badYaml = ':\n :\n : [}'; + const fs = createMockFs({ + [path.join(CWD, '.versioningsrc.yaml')]: badYaml, + }); + + expect(() => loadConfig({ cwd: CWD, env: {}, ...fs })).toThrow(VersioningsError); + try { + loadConfig({ cwd: CWD, env: {}, ...fs }); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }); + }); + + // ----------------------------------------------------------------------- + // Provenance tracking + // ----------------------------------------------------------------------- + + describe('provenance tracking', () => { + test('provenance tracks which source set each field', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify({ + git: { platform: 'github', url: 'https://github.com/org/repo.git' }, + }), + }); + + const result = loadConfig({ + cwd: CWD, + env: { VERSIONINGS_GIT_REMOTE: 'upstream' }, + ...fs, + }); + + expect(result.provenance['git.platform'].source).toBe('version.json'); + expect(result.provenance['git.remote'].value).toBe('upstream'); + expect(result.provenance['git.remote'].source).toBe('env'); + }); + }); + + // ----------------------------------------------------------------------- + // package.json edge cases + // ----------------------------------------------------------------------- + + describe('package.json edge cases', () => { + test('ignores package.json without versionings section', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + [path.join(CWD, 'package.json')]: JSON.stringify({ name: 'app', version: '1.0.0' }), + }); + + const result = loadConfig({ cwd: CWD, env: {}, ...fs }); + + expect(result.sources.some((s) => s.name === 'package.json#versionings')).toBe(false); + }); + + test('ignores package.json with non-object versionings', () => { + const fs = createMockFs({ + [path.join(CWD, 'version.json')]: JSON.stringify(VALID_GIT), + [path.join(CWD, 'package.json')]: JSON.stringify({ name: 'app', versionings: 'string' }), + }); + + const result = loadConfig({ cwd: CWD, env: {}, ...fs }); + + expect(result.sources.some((s) => s.name === 'package.json#versionings')).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // Invalid JSON in .versioningsrc (plain JSON RC) + // ----------------------------------------------------------------------- + + describe('invalid JSON in .versioningsrc', () => { + test('throws VersioningsError(CONFIG_ERROR) for malformed JSON in .versioningsrc', () => { + const fs = createMockFs({ + [path.join(CWD, '.versioningsrc')]: '{ broken json', + }); + + expect(() => loadConfig({ cwd: CWD, env: {}, ...fs })).toThrow(VersioningsError); + try { + loadConfig({ cwd: CWD, env: {}, ...fs }); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }); + }); +}); diff --git a/__tests__/unit/config/config.merger.test.ts b/__tests__/unit/config/config.merger.test.ts new file mode 100644 index 0000000..6f0888b --- /dev/null +++ b/__tests__/unit/config/config.merger.test.ts @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { mergeConfigs, ConfigSource, ConfigProvenance } from '../../../src/config/config.merger'; + +describe('mergeConfigs', () => { + test('merges two sources — last source wins for overlapping leaf fields', () => { + const sources: ConfigSource[] = [ + { name: 'defaults', data: { git: { platform: 'github', remote: 'origin' } } }, + { name: 'version.json', data: { git: { platform: 'bitbucket' } } }, + ]; + + const { merged } = mergeConfigs(sources); + + expect(merged.git.platform).toBe('bitbucket'); + expect(merged.git.remote).toBe('origin'); + }); + + test('deep merges nested objects — preserves fields from lower-priority sources', () => { + const sources: ConfigSource[] = [ + { + name: 'defaults', + data: { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + remote: 'origin', + }, + }, + }, + { + name: '.versioningsrc', + data: { + git: { + url: 'https://github.com/org/other-repo', + pr: { target: 'develop' }, + }, + }, + }, + ]; + + const { merged } = mergeConfigs(sources); + + // Overridden by higher-priority source + expect(merged.git.url).toBe('https://github.com/org/other-repo'); + expect(merged.git.pr.target).toBe('develop'); + // Preserved from lower-priority source (not overridden) + expect(merged.git.platform).toBe('github'); + expect(merged.git.remote).toBe('origin'); + }); + + test('last source wins priority across multiple sources', () => { + const sources: ConfigSource[] = [ + { name: 'defaults', data: { git: { platform: 'github' } } }, + { name: 'version.json', data: { git: { platform: 'bitbucket' } } }, + { name: 'env', data: { git: { platform: 'github' } } }, + { name: 'cli', data: { git: { platform: 'bitbucket' } } }, + ]; + + const { merged } = mergeConfigs(sources); + + // The last source ('cli') wins + expect(merged.git.platform).toBe('bitbucket'); + }); + + test('provenance tracks which source set each leaf field', () => { + const sources: ConfigSource[] = [ + { + name: 'defaults', + data: { git: { platform: 'github', remote: 'origin', pr: { target: 'main' } } }, + }, + { + name: '.versioningsrc', + data: { git: { platform: 'bitbucket' } }, + }, + { + name: 'env', + data: { git: { pr: { target: 'develop' } } }, + }, + ]; + + const { provenance } = mergeConfigs(sources); + + // platform was last set by '.versioningsrc' + expect(provenance['git.platform']).toEqual({ value: 'bitbucket', source: '.versioningsrc' }); + // remote was only set by 'defaults' + expect(provenance['git.remote']).toEqual({ value: 'origin', source: 'defaults' }); + // pr.target was last set by 'env' + expect(provenance['git.pr.target']).toEqual({ value: 'develop', source: 'env' }); + }); + + test('provenance reflects the last source that wrote each field', () => { + const sources: ConfigSource[] = [ + { name: 'defaults', data: { git: { platform: 'github' } } }, + { name: 'version.json', data: { git: { platform: 'bitbucket' } } }, + { name: 'cli', data: { git: { platform: 'github' } } }, + ]; + + const { provenance } = mergeConfigs(sources); + + // Even though value is 'github' again, source should be 'cli' (last writer) + expect(provenance['git.platform']).toEqual({ value: 'github', source: 'cli' }); + }); + + test('returns empty merged and provenance for empty sources array', () => { + const { merged, provenance } = mergeConfigs([]); + + expect(merged).toEqual({}); + expect(provenance).toEqual({}); + }); + + test('handles single source correctly', () => { + const sources: ConfigSource[] = [ + { + name: 'version.json', + data: { git: { platform: 'github', url: 'https://github.com/org/repo' } }, + }, + ]; + + const { merged, provenance } = mergeConfigs(sources); + + expect(merged).toEqual({ git: { platform: 'github', url: 'https://github.com/org/repo' } }); + expect(provenance['git.platform']).toEqual({ value: 'github', source: 'version.json' }); + expect(provenance['git.url']).toEqual({ + value: 'https://github.com/org/repo', + source: 'version.json', + }); + }); + + test('deep merge preserves deeply nested fields from lower-priority sources', () => { + const sources: ConfigSource[] = [ + { + name: 'defaults', + data: { + git: { + commit: { message: { semver: { patch: 'chore: bump', minor: 'feat: bump' } } }, + }, + }, + }, + { + name: '.versioningsrc', + data: { + git: { + commit: { message: { semver: { patch: 'fix: version bump' } } }, + }, + }, + }, + ]; + + const { merged, provenance } = mergeConfigs(sources); + + // Overridden + expect(merged.git.commit.message.semver.patch).toBe('fix: version bump'); + // Preserved from defaults + expect(merged.git.commit.message.semver.minor).toBe('feat: bump'); + + expect(provenance['git.commit.message.semver.patch']).toEqual({ + value: 'fix: version bump', + source: '.versioningsrc', + }); + expect(provenance['git.commit.message.semver.minor']).toEqual({ + value: 'feat: bump', + source: 'defaults', + }); + }); + + test('skips undefined values in source data', () => { + const sources: ConfigSource[] = [ + { name: 'defaults', data: { git: { platform: 'github', remote: 'origin' } } }, + { name: 'env', data: { git: { platform: undefined as any, remote: 'upstream' } } }, + ]; + + const { merged, provenance } = mergeConfigs(sources); + + // platform should remain from defaults since env has undefined + expect(merged.git.platform).toBe('github'); + expect(merged.git.remote).toBe('upstream'); + expect(provenance['git.platform']).toEqual({ value: 'github', source: 'defaults' }); + expect(provenance['git.remote']).toEqual({ value: 'upstream', source: 'env' }); + }); +}); diff --git a/__tests__/unit/config/config.validator.test.ts b/__tests__/unit/config/config.validator.test.ts new file mode 100644 index 0000000..a655c63 --- /dev/null +++ b/__tests__/unit/config/config.validator.test.ts @@ -0,0 +1,1215 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import * as path from 'path'; +import * as os from 'os'; +import * as fs from 'fs'; +import { loadAndValidateConfig, validateWithProvenance } from '../../../src/config/config.validator'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; + +describe('config.validator — loadAndValidateConfig', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeConfig(obj: Record): string { + const filePath = path.join(tmpDir, 'version.json'); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8'); + return filePath; + } + + function writeRawConfig(content: string): string { + const filePath = path.join(tmpDir, 'version.json'); + fs.writeFileSync(filePath, content, 'utf8'); + return filePath; + } + + test('valid config — returns merged config with defaults', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/user/repo.git', + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.platform).toBe('github'); + expect(config.git.url).toBe('https://github.com/user/repo.git'); + expect(config.git.remote).toBe('origin'); + expect(config.git.branchType).toEqual({ version: 'version' }); + expect(config.git.pr.target).toBe('master'); + expect(config.git.limits).toEqual({ branchMaxCommentLength: 96 }); + expect(config.git.commit.message.semver.patch).toBe( + 'Patch: v%s. You SHOULD consider changes.' + ); + }); + + test('missing file — throws VersioningsError with CONFIG_ERROR and expectedPath', () => { + const filePath = path.join(tmpDir, 'nonexistent.json'); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + try { + loadAndValidateConfig(filePath); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(err.details).toBeDefined(); + expect(err.details.expectedPath).toBe(filePath); + } + }); + + test('invalid JSON — throws VersioningsError with CONFIG_ERROR and parseError', () => { + const filePath = writeRawConfig('{ not valid json!!!'); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + try { + loadAndValidateConfig(filePath); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(err.details).toBeDefined(); + expect(err.details.parseError).toBeDefined(); + expect(typeof err.details.parseError).toBe('string'); + } + }); + + test('schema mismatch — missing required field throws with validationErrors', () => { + const filePath = writeConfig({ git: { platform: 'github' } }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + try { + loadAndValidateConfig(filePath); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(err.details).toBeDefined(); + expect(Array.isArray(err.details.validationErrors)).toBe(true); + expect(err.details.validationErrors.length).toBeGreaterThan(0); + err.details.validationErrors.forEach((ve: any) => { + expect(ve).toHaveProperty('path'); + expect(ve).toHaveProperty('message'); + }); + } + }); + + test('schema mismatch — invalid platform value throws with validationErrors', () => { + const filePath = writeConfig({ + git: { platform: 'unknown-platform', url: 'https://example.com/user/repo.git' }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + try { + loadAndValidateConfig(filePath); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(Array.isArray(err.details.validationErrors)).toBe(true); + expect(err.details.validationErrors.length).toBeGreaterThan(0); + } + }); + + test('merge with defaults — pr.target defaults to master when not in config', () => { + const filePath = writeConfig({ + git: { platform: 'bitbucket', url: 'https://bitbucket.org/user/repo.git' }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.pr.target).toBe('master'); + expect(config.git.remote).toBe('origin'); + expect(config.git.platform).toBe('bitbucket'); + }); + + test('merge with defaults — pr.target from config overrides default', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/user/repo.git', + pr: { target: 'develop' }, + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.pr.target).toBe('develop'); + expect(config.git.remote).toBe('origin'); + }); +}); + + +describe('config.validator — validateWithProvenance', () => { + const validConfig = { + git: { + platform: 'github', + url: 'https://github.com/user/repo.git', + remote: 'origin', + branchType: { version: 'version' }, + pr: { target: 'master' }, + limits: { branchMaxCommentLength: 96 }, + commit: { + message: { + semver: { + patch: 'Patch: v%s.', + prepatch: 'Prepatch: v%s.', + minor: 'Minor: v%s.', + preminor: 'Preminor: v%s.', + premajor: 'Premajor: v%s.', + major: 'Major: v%s.', + prerelease: 'Pre: v%s.', + }, + }, + }, + }, + }; + + const provenance = { + 'git.platform': { value: 'github', source: 'version.json' }, + 'git.url': { value: 'https://github.com/user/repo.git', source: 'version.json' }, + 'git.remote': { value: 'origin', source: 'defaults' }, + 'git.branchType.version': { value: 'version', source: 'defaults' }, + 'git.pr.target': { value: 'master', source: 'defaults' }, + 'git.limits.branchMaxCommentLength': { value: 96, source: 'defaults' }, + }; + + test('valid config in default mode — returns valid result with no warnings', () => { + const result = validateWithProvenance(validConfig, provenance, false); + expect(result.valid).toBe(true); + expect(result.warnings).toEqual([]); + }); + + test('valid config in strict mode — returns valid result', () => { + const result = validateWithProvenance(validConfig, provenance, true); + expect(result.valid).toBe(true); + expect(result.warnings).toEqual([]); + }); + + test('default mode with unknown top-level field — returns warnings', () => { + const configWithExtra = { + ...validConfig, + unknownTopLevel: 'some value', + }; + const provenanceWithExtra = { + ...provenance, + unknownTopLevel: { value: 'some value', source: '.versioningsrc' }, + }; + + const result = validateWithProvenance(configWithExtra, provenanceWithExtra, false); + expect(result.valid).toBe(true); + expect(result.warnings.length).toBe(1); + expect(result.warnings[0].path).toBe('unknownTopLevel'); + expect(result.warnings[0].source).toBe('.versioningsrc'); + expect(result.warnings[0].message).toContain('Unknown field'); + }); + + test('default mode with unknown nested field inside git — returns warnings', () => { + const configWithNestedExtra = { + git: { + ...validConfig.git, + unknownGitField: 'value', + }, + }; + const provenanceWithNestedExtra = { + ...provenance, + 'git.unknownGitField': { value: 'value', source: 'env' }, + }; + + const result = validateWithProvenance(configWithNestedExtra, provenanceWithNestedExtra, false); + expect(result.valid).toBe(true); + expect(result.warnings.length).toBe(1); + expect(result.warnings[0].path).toBe('git.unknownGitField'); + expect(result.warnings[0].source).toBe('env'); + }); + + test('strict mode with unknown field — throws VersioningsError(CONFIG_ERROR)', () => { + const configWithExtra = { + ...validConfig, + unknownField: 'value', + }; + const provenanceWithExtra = { + ...provenance, + unknownField: { value: 'value', source: 'cli' }, + }; + + expect(() => validateWithProvenance(configWithExtra, provenanceWithExtra, true)).toThrow(VersioningsError); + try { + validateWithProvenance(configWithExtra, provenanceWithExtra, true); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(err.message).toContain('unknown field'); + expect(err.details.unknownFields).toBeDefined(); + expect(err.details.unknownFields.length).toBe(1); + expect(err.details.unknownFields[0].path).toBe('unknownField'); + expect(err.details.unknownFields[0].source).toBe('cli'); + } + }); + + test('strict mode with multiple unknown fields — throws with all listed', () => { + const configWithMultipleExtra = { + ...validConfig, + extra1: 'a', + extra2: 'b', + }; + const provenanceMulti = { + ...provenance, + extra1: { value: 'a', source: 'env' }, + extra2: { value: 'b', source: '.versioningsrc' }, + }; + + try { + validateWithProvenance(configWithMultipleExtra, provenanceMulti, true); + throw new Error('Expected VersioningsError'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(err.details.unknownFields.length).toBe(2); + } + }); + + test('type error always throws regardless of strict mode', () => { + const invalidConfig = { + git: { + platform: 123, // should be string + url: 'https://github.com/user/repo.git', + }, + }; + + expect(() => validateWithProvenance(invalidConfig, provenance, false)).toThrow(VersioningsError); + expect(() => validateWithProvenance(invalidConfig, provenance, true)).toThrow(VersioningsError); + + try { + validateWithProvenance(invalidConfig, provenance, false); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(err.details.validationErrors).toBeDefined(); + } + }); + + test('missing required field always throws regardless of strict mode', () => { + const missingRequired = { + git: { + platform: 'github', + // url is missing + }, + }; + + expect(() => validateWithProvenance(missingRequired, provenance, false)).toThrow(VersioningsError); + expect(() => validateWithProvenance(missingRequired, provenance, true)).toThrow(VersioningsError); + }); + + test('error messages include source from provenance', () => { + const invalidConfig = { + git: { + platform: 'unknown-platform', // invalid enum value + url: 'https://example.com/repo.git', + }, + }; + const provenanceInvalid = { + 'git.platform': { value: 'unknown-platform', source: 'env' }, + 'git.url': { value: 'https://example.com/repo.git', source: 'version.json' }, + }; + + try { + validateWithProvenance(invalidConfig, provenanceInvalid, false); + throw new Error('Expected VersioningsError'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + // Check that validation errors include source info + const ve = err.details.validationErrors; + expect(ve.length).toBeGreaterThan(0); + const platformError = ve.find((e: any) => e.path.includes('platform')); + expect(platformError).toBeDefined(); + expect(platformError.source).toBeDefined(); + } + }); + + test('default strict parameter is false', () => { + const configWithExtra = { + ...validConfig, + extraField: 'value', + }; + + // Should not throw (default is non-strict) + const result = validateWithProvenance(configWithExtra, provenance); + expect(result.valid).toBe(true); + expect(result.warnings.length).toBe(1); + }); + + test('provenance fallback to parent path when exact path not found', () => { + const configWithNestedUnknown = { + git: { + ...validConfig.git, + unknownNested: { deep: 'value' }, + }, + }; + // Only parent path in provenance, not the exact unknown path + const sparseProvenance = { + ...provenance, + }; + + const result = validateWithProvenance(configWithNestedUnknown, sparseProvenance, false); + expect(result.valid).toBe(true); + expect(result.warnings.length).toBe(1); + // Source should fall back to 'unknown' since no provenance entry exists + expect(result.warnings[0].source).toBe('unknown'); + }); +}); + + +describe('config.validator — extended platform enum (P2)', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeConfig(obj: Record): string { + const filePath = path.join(tmpDir, 'version.json'); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8'); + return filePath; + } + + const extendedPlatforms = ['github', 'github-enterprise', 'bitbucket', 'bitbucket-server', 'gitlab', 'azure-devops']; + + test.each(extendedPlatforms.filter(p => p !== 'github-enterprise' && p !== 'bitbucket-server'))( + 'platform "%s" passes validation without apiUrl', + (platform) => { + const filePath = writeConfig({ + git: { platform, url: 'https://example.com/org/repo.git' }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.platform).toBe(platform); + } + ); + + test.each(['github-enterprise', 'bitbucket-server'])( + 'platform "%s" requires apiUrl — fails without it', + (platform) => { + const filePath = writeConfig({ + git: { platform, url: 'https://example.com/org/repo.git' }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + try { + loadAndValidateConfig(filePath); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + } + ); + + test.each(['github-enterprise', 'bitbucket-server'])( + 'platform "%s" passes validation with apiUrl', + (platform) => { + const filePath = writeConfig({ + git: { platform, url: 'https://example.com/org/repo.git', apiUrl: 'https://git.corp.com/api' }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.platform).toBe(platform); + expect(config.git.apiUrl).toBe('https://git.corp.com/api'); + } + ); +}); + + +describe('config.validator — git.apiUrl validation', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeConfig(obj: Record): string { + const filePath = path.join(tmpDir, 'version.json'); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8'); + return filePath; + } + + test('valid https apiUrl passes', () => { + const filePath = writeConfig({ + git: { platform: 'github', url: 'https://github.com/org/repo.git', apiUrl: 'https://api.github.com' }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.apiUrl).toBe('https://api.github.com'); + }); + + test('valid http apiUrl passes', () => { + const filePath = writeConfig({ + git: { platform: 'github', url: 'https://github.com/org/repo.git', apiUrl: 'http://localhost:8080' }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.apiUrl).toBe('http://localhost:8080'); + }); + + test('apiUrl without http/https prefix fails', () => { + const filePath = writeConfig({ + git: { platform: 'github', url: 'https://github.com/org/repo.git', apiUrl: 'ftp://example.com' }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); +}); + + +describe('config.validator — git.auth section', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeConfig(obj: Record): string { + const filePath = path.join(tmpDir, 'version.json'); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8'); + return filePath; + } + + test('auth section with token and method passes', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + auth: { token: 'ghp_abc123', method: 'token' }, + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.auth).toEqual({ token: 'ghp_abc123', method: 'token' }); + }); + + test('auth section with bearer method passes', () => { + const filePath = writeConfig({ + git: { + platform: 'gitlab', + url: 'https://gitlab.com/org/repo.git', + auth: { token: 'glpat-abc', method: 'bearer' }, + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.auth).toEqual({ token: 'glpat-abc', method: 'bearer' }); + }); + + test('auth section with invalid method fails', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + auth: { token: 'abc', method: 'oauth' }, + }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); +}); + + +describe('config.validator — git.api.timeout range', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeConfig(obj: Record): string { + const filePath = path.join(tmpDir, 'version.json'); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8'); + return filePath; + } + + test('timeout at minimum (1000) passes', () => { + const filePath = writeConfig({ + git: { platform: 'github', url: 'https://github.com/org/repo.git', api: { timeout: 1000 } }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.api).toEqual({ timeout: 1000 }); + }); + + test('timeout at maximum (120000) passes', () => { + const filePath = writeConfig({ + git: { platform: 'github', url: 'https://github.com/org/repo.git', api: { timeout: 120000 } }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.api).toEqual({ timeout: 120000 }); + }); + + test('timeout below minimum (999) fails', () => { + const filePath = writeConfig({ + git: { platform: 'github', url: 'https://github.com/org/repo.git', api: { timeout: 999 } }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); + + test('timeout above maximum (120001) fails', () => { + const filePath = writeConfig({ + git: { platform: 'github', url: 'https://github.com/org/repo.git', api: { timeout: 120001 } }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); + + test('timeout as float (5000.5) fails — must be integer', () => { + const filePath = writeConfig({ + git: { platform: 'github', url: 'https://github.com/org/repo.git', api: { timeout: 5000.5 } }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); +}); + + +describe('config.validator — git.pr extended fields', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeConfig(obj: Record): string { + const filePath = path.join(tmpDir, 'version.json'); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8'); + return filePath; + } + + test('pr with reviewers, labels, draft, template, milestone, linkedIssues passes', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + pr: { + target: 'main', + reviewers: ['alice', 'bob'], + labels: ['release', 'auto'], + draft: true, + template: '.github/PULL_REQUEST_TEMPLATE.md', + milestone: 'v1.0', + linkedIssues: ['#42', '#43'], + }, + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.pr.reviewers).toEqual(['alice', 'bob']); + expect(config.git.pr.labels).toEqual(['release', 'auto']); + expect(config.git.pr.draft).toBe(true); + expect(config.git.pr.template).toBe('.github/PULL_REQUEST_TEMPLATE.md'); + expect(config.git.pr.milestone).toBe('v1.0'); + expect(config.git.pr.linkedIssues).toEqual(['#42', '#43']); + }); + + test('pr with empty reviewers array passes', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + pr: { target: 'main', reviewers: [] }, + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.pr.reviewers).toEqual([]); + }); + + test('pr.draft defaults to false when not specified', () => { + const filePath = writeConfig({ + git: { platform: 'github', url: 'https://github.com/org/repo.git' }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.pr.draft).toBeUndefined(); + }); +}); + + +describe('config.validator — backward compatibility', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeConfig(obj: Record): string { + const filePath = path.join(tmpDir, 'version.json'); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8'); + return filePath; + } + + test('old config with only github platform and url passes', () => { + const filePath = writeConfig({ + git: { platform: 'github', url: 'https://github.com/org/repo.git' }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.platform).toBe('github'); + expect(config.git.auth).toBeUndefined(); + expect(config.git.api).toBeUndefined(); + expect(config.git.apiUrl).toBeUndefined(); + }); + + test('old config with bitbucket platform passes', () => { + const filePath = writeConfig({ + git: { platform: 'bitbucket', url: 'https://bitbucket.org/org/repo.git' }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.platform).toBe('bitbucket'); + expect(config.git.pr.target).toBe('master'); + expect(config.git.remote).toBe('origin'); + }); + + test('old config with pr.target passes and preserves value', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + pr: { target: 'develop' }, + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.pr.target).toBe('develop'); + }); +}); + + +describe('config.validator — git.branching section', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeConfig(obj: Record): string { + const filePath = path.join(tmpDir, 'version.json'); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8'); + return filePath; + } + + test('config without git.branching passes validation (backward compatibility)', () => { + const filePath = writeConfig({ + git: { platform: 'github', url: 'https://github.com/org/repo.git' }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.platform).toBe('github'); + // branching defaults should be applied + expect(config.git.branching).toBeDefined(); + expect(config.git.branching!.strategy).toBe('default'); + expect(config.git.branching!.mainBranch).toBe('master'); + expect(config.git.branching!.developBranch).toBe('develop'); + }); + + test('git.branching.strategy enum — valid values pass', () => { + const strategies = ['default', 'trunk-based', 'git-flow', 'release-branch', 'hotfix', 'maintenance']; + for (const strategy of strategies) { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + branching: { strategy }, + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.branching!.strategy).toBe(strategy); + } + }); + + test('git.branching.strategy — invalid value fails validation', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + branching: { strategy: 'invalid-strategy' }, + }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + try { + loadAndValidateConfig(filePath); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(Array.isArray(err.details.validationErrors)).toBe(true); + } + }); + + test('git.branching.branchTemplate — non-empty string passes', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + branching: { strategy: 'default', branchTemplate: '{branchType}/{semver}/{version}' }, + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.branching!.branchTemplate).toBe('{branchType}/{semver}/{version}'); + }); + + test('git.branching.branchTemplate — empty string fails validation', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + branching: { strategy: 'default', branchTemplate: '' }, + }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); + + test('git.branching.tagTemplate — non-empty string passes', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + branching: { strategy: 'default', tagTemplate: 'v{version}' }, + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.branching!.tagTemplate).toBe('v{version}'); + }); + + test('git.branching.tagTemplate — empty string fails validation', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + branching: { strategy: 'default', tagTemplate: '' }, + }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); + + test('git.branching.mainBranch and developBranch — custom values pass', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + branching: { strategy: 'git-flow', mainBranch: 'main', developBranch: 'dev' }, + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.branching!.mainBranch).toBe('main'); + expect(config.git.branching!.developBranch).toBe('dev'); + }); + + test('git.branching.mainBranch and developBranch — defaults applied when not specified', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + branching: { strategy: 'trunk-based' }, + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.branching!.mainBranch).toBe('master'); + expect(config.git.branching!.developBranch).toBe('develop'); + }); + + test('git.branching — additionalProperties rejected', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + branching: { strategy: 'default', unknownField: 'value' }, + }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); + + test('git.branching with all fields passes', () => { + const filePath = writeConfig({ + git: { + platform: 'github', + url: 'https://github.com/org/repo.git', + branching: { + strategy: 'git-flow', + branchTemplate: 'release/{version}', + tagTemplate: 'v{version}', + mainBranch: 'main', + developBranch: 'dev', + }, + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.branching).toEqual({ + strategy: 'git-flow', + branchTemplate: 'release/{version}', + tagTemplate: 'v{version}', + mainBranch: 'main', + developBranch: 'dev', + }); + }); +}); + + +describe('config.validator — conventionalCommits section', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeConfig(obj: Record): string { + const filePath = path.join(tmpDir, 'version.json'); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8'); + return filePath; + } + + const baseGit = { platform: 'github', url: 'https://github.com/org/repo.git' }; + + test('valid conventionalCommits with all fields passes', () => { + const filePath = writeConfig({ + git: baseGit, + conventionalCommits: { + enabled: true, + types: { feat: 'minor', fix: 'patch', chore: 'none', breaking: 'major' }, + fallbackBump: 'patch', + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.conventionalCommits).toBeDefined(); + expect(config.conventionalCommits!.enabled).toBe(true); + expect(config.conventionalCommits!.types.feat).toBe('minor'); + expect(config.conventionalCommits!.types.breaking).toBe('major'); + expect(config.conventionalCommits!.fallbackBump).toBe('patch'); + }); + + test('conventionalCommits.types — valid enum values (major, minor, patch, none) pass', () => { + const filePath = writeConfig({ + git: baseGit, + conventionalCommits: { + types: { a: 'major', b: 'minor', c: 'patch', d: 'none' }, + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.conventionalCommits!.types.a).toBe('major'); + expect(config.conventionalCommits!.types.b).toBe('minor'); + expect(config.conventionalCommits!.types.c).toBe('patch'); + expect(config.conventionalCommits!.types.d).toBe('none'); + }); + + test('conventionalCommits.types — invalid enum value fails', () => { + const filePath = writeConfig({ + git: baseGit, + conventionalCommits: { + types: { feat: 'invalid-level' }, + }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + try { + loadAndValidateConfig(filePath); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(Array.isArray(err.details.validationErrors)).toBe(true); + } + }); + + test('conventionalCommits.types — partial map passes (not all types required)', () => { + const filePath = writeConfig({ + git: baseGit, + conventionalCommits: { + types: { feat: 'minor' }, + }, + }); + const config = loadAndValidateConfig(filePath); + // User-provided type merged with defaults + expect(config.conventionalCommits!.types.feat).toBe('minor'); + }); + + test('conventionalCommits.fallbackBump — valid string values pass', () => { + for (const fb of ['patch', 'minor', 'major']) { + const filePath = writeConfig({ + git: baseGit, + conventionalCommits: { fallbackBump: fb }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.conventionalCommits!.fallbackBump).toBe(fb); + } + }); + + test('conventionalCommits.fallbackBump — null passes', () => { + const filePath = writeConfig({ + git: baseGit, + conventionalCommits: { fallbackBump: null }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.conventionalCommits!.fallbackBump).toBeNull(); + }); + + test('conventionalCommits.fallbackBump — invalid value fails', () => { + const filePath = writeConfig({ + git: baseGit, + conventionalCommits: { fallbackBump: 'prerelease' }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + try { + loadAndValidateConfig(filePath); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }); + + test('conventionalCommits.enabled — boolean passes, non-boolean fails', () => { + const validPath = writeConfig({ + git: baseGit, + conventionalCommits: { enabled: false }, + }); + const config = loadAndValidateConfig(validPath); + expect(config.conventionalCommits!.enabled).toBe(false); + + const invalidPath = writeConfig({ + git: baseGit, + conventionalCommits: { enabled: 'yes' }, + }); + expect(() => loadAndValidateConfig(invalidPath)).toThrow(VersioningsError); + }); + + test('conventionalCommits — unknown field rejected (additionalProperties: false)', () => { + const filePath = writeConfig({ + git: baseGit, + conventionalCommits: { unknownField: 'value' }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); + + test('conventionalCommits defaults applied when section is empty object', () => { + const filePath = writeConfig({ + git: baseGit, + conventionalCommits: {}, + }); + const config = loadAndValidateConfig(filePath); + expect(config.conventionalCommits!.enabled).toBe(true); + expect(config.conventionalCommits!.fallbackBump).toBeNull(); + expect(config.conventionalCommits!.types.feat).toBe('minor'); + expect(config.conventionalCommits!.types.fix).toBe('patch'); + }); +}); + + +describe('config.validator — changelog section', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versionings-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeConfig(obj: Record): string { + const filePath = path.join(tmpDir, 'version.json'); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8'); + return filePath; + } + + const baseGit = { platform: 'github', url: 'https://github.com/org/repo.git' }; + + test('valid changelog with all fields passes', () => { + const filePath = writeConfig({ + git: baseGit, + changelog: { + template: 'my-template.hbs', + groupTitles: { feat: 'New Features', fix: 'Fixes' }, + excludeTypes: ['chore', 'docs'], + includeNonConventional: true, + file: 'CHANGELOG.md', + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.changelog).toBeDefined(); + expect(config.changelog!.groupTitles.feat).toBe('New Features'); + expect(config.changelog!.excludeTypes).toEqual(['chore', 'docs']); + expect(config.changelog!.includeNonConventional).toBe(true); + expect(config.changelog!.file).toBe('CHANGELOG.md'); + }); + + test('changelog.excludeTypes — valid array of strings passes', () => { + const filePath = writeConfig({ + git: baseGit, + changelog: { excludeTypes: ['chore', 'test', 'ci'] }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.changelog!.excludeTypes).toEqual(['chore', 'test', 'ci']); + }); + + test('changelog.excludeTypes — empty array passes', () => { + const filePath = writeConfig({ + git: baseGit, + changelog: { excludeTypes: [] }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.changelog!.excludeTypes).toEqual([]); + }); + + test('changelog.excludeTypes — array with empty string fails (minLength 1)', () => { + const filePath = writeConfig({ + git: baseGit, + changelog: { excludeTypes: ['chore', ''] }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); + + test('changelog.excludeTypes — non-array value fails', () => { + const filePath = writeConfig({ + git: baseGit, + changelog: { excludeTypes: 'chore' }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); + + test('changelog.groupTitles — valid object with string values passes', () => { + const filePath = writeConfig({ + git: baseGit, + changelog: { groupTitles: { feat: 'Features', fix: 'Bug Fixes', perf: 'Performance' } }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.changelog!.groupTitles.feat).toBe('Features'); + expect(config.changelog!.groupTitles.fix).toBe('Bug Fixes'); + }); + + test('changelog.groupTitles — empty string value fails (minLength 1)', () => { + const filePath = writeConfig({ + git: baseGit, + changelog: { groupTitles: { feat: '' } }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); + + test('changelog.groupTitles — non-string value fails', () => { + const filePath = writeConfig({ + git: baseGit, + changelog: { groupTitles: { feat: 123 } }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); + + test('changelog.file — valid string passes', () => { + const filePath = writeConfig({ + git: baseGit, + changelog: { file: 'CHANGELOG.md' }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.changelog!.file).toBe('CHANGELOG.md'); + }); + + test('changelog.file — empty string fails (minLength 1)', () => { + const filePath = writeConfig({ + git: baseGit, + changelog: { file: '' }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); + + test('changelog.file — string exceeding maxLength (512) fails', () => { + const filePath = writeConfig({ + git: baseGit, + changelog: { file: 'a'.repeat(513) }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); + + test('changelog.includeNonConventional — boolean passes, non-boolean fails', () => { + const validPath = writeConfig({ + git: baseGit, + changelog: { includeNonConventional: true }, + }); + const config = loadAndValidateConfig(validPath); + expect(config.changelog!.includeNonConventional).toBe(true); + + const invalidPath = writeConfig({ + git: baseGit, + changelog: { includeNonConventional: 'yes' }, + }); + expect(() => loadAndValidateConfig(invalidPath)).toThrow(VersioningsError); + }); + + test('changelog.template — valid non-empty string passes', () => { + const filePath = writeConfig({ + git: baseGit, + changelog: { template: 'custom-template.hbs' }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.changelog!.template).toBe('custom-template.hbs'); + }); + + test('changelog.template — empty string fails (minLength 1)', () => { + const filePath = writeConfig({ + git: baseGit, + changelog: { template: '' }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); + + test('changelog — unknown field rejected (additionalProperties: false)', () => { + const filePath = writeConfig({ + git: baseGit, + changelog: { unknownField: 'value' }, + }); + expect(() => loadAndValidateConfig(filePath)).toThrow(VersioningsError); + }); + + test('changelog defaults applied when section is empty object', () => { + const filePath = writeConfig({ + git: baseGit, + changelog: {}, + }); + const config = loadAndValidateConfig(filePath); + expect(config.changelog!.includeNonConventional).toBe(false); + expect(config.changelog!.excludeTypes).toEqual([]); + expect(config.changelog!.groupTitles.feat).toBe('Features'); + expect(config.changelog!.groupTitles.fix).toBe('Bug Fixes'); + expect(config.changelog!.groupTitles.breaking).toBe('BREAKING CHANGES'); + }); + + test('backward compatibility — old config without conventionalCommits and changelog passes', () => { + const filePath = writeConfig({ + git: baseGit, + }); + const config = loadAndValidateConfig(filePath); + expect(config.git.platform).toBe('github'); + // Defaults should be applied for new sections + expect(config.conventionalCommits).toBeDefined(); + expect(config.conventionalCommits!.enabled).toBe(true); + expect(config.conventionalCommits!.fallbackBump).toBeNull(); + expect(config.changelog).toBeDefined(); + expect(config.changelog!.excludeTypes).toEqual([]); + expect(config.changelog!.includeNonConventional).toBe(false); + }); + + test('config with both conventionalCommits and changelog sections passes', () => { + const filePath = writeConfig({ + git: baseGit, + conventionalCommits: { + enabled: true, + types: { feat: 'minor', fix: 'patch' }, + fallbackBump: 'patch', + }, + changelog: { + groupTitles: { feat: 'New', fix: 'Fixed' }, + excludeTypes: ['chore'], + includeNonConventional: false, + file: 'CHANGES.md', + }, + }); + const config = loadAndValidateConfig(filePath); + expect(config.conventionalCommits!.enabled).toBe(true); + expect(config.conventionalCommits!.fallbackBump).toBe('patch'); + expect(config.changelog!.file).toBe('CHANGES.md'); + expect(config.changelog!.groupTitles.feat).toBe('New'); + expect(config.changelog!.excludeTypes).toEqual(['chore']); + }); +}); diff --git a/__tests__/unit/config/yaml.parser.test.ts b/__tests__/unit/config/yaml.parser.test.ts new file mode 100644 index 0000000..429e2dd --- /dev/null +++ b/__tests__/unit/config/yaml.parser.test.ts @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { parseYaml, serializeYaml } from '../../../src/config/yaml.parser'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; + +describe('parseYaml', () => { + test('parses valid YAML into a JavaScript object', () => { + const yaml = ` +git: + platform: github + url: https://github.com/org/repo +`; + const result = parseYaml(yaml, '.versioningsrc.yml'); + expect(result).toEqual({ + git: { + platform: 'github', + url: 'https://github.com/org/repo', + }, + }); + }); + + test('parses YAML with strings, numbers, booleans, and nested objects', () => { + const yaml = ` +name: my-project +version: 42 +enabled: true +nested: + deep: + value: hello +`; + const result = parseYaml(yaml, 'test.yml'); + expect(result).toEqual({ + name: 'my-project', + version: 42, + enabled: true, + nested: { deep: { value: 'hello' } }, + }); + }); + + test('throws VersioningsError with CONFIG_ERROR on invalid YAML syntax', () => { + const badYaml = ` +git: + platform: github + url: bad-indent +`; + try { + parseYaml(badYaml, 'broken.yml'); + fail('Expected VersioningsError to be thrown'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + const ve = err as VersioningsError; + expect(ve.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(ve.message).toContain('broken.yml'); + } + }); + + test('includes line number in error for invalid YAML', () => { + const badYaml = `key: value\n bad: indent`; + try { + parseYaml(badYaml, 'file.yml'); + fail('Expected VersioningsError to be thrown'); + } catch (err) { + const ve = err as VersioningsError; + expect(ve.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(ve.message).toMatch(/line \d+/); + expect(ve.details).toBeDefined(); + expect(ve.details!.filePath).toBe('file.yml'); + } + }); + + test('throws VersioningsError when YAML is null', () => { + expect(() => parseYaml('', 'empty.yml')).toThrow(VersioningsError); + try { + parseYaml('', 'empty.yml'); + } catch (err) { + const ve = err as VersioningsError; + expect(ve.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(ve.message).toContain('null'); + } + }); + + test('throws VersioningsError when YAML is an array', () => { + const arrayYaml = `- one\n- two\n- three`; + expect(() => parseYaml(arrayYaml, 'array.yml')).toThrow(VersioningsError); + try { + parseYaml(arrayYaml, 'array.yml'); + } catch (err) { + const ve = err as VersioningsError; + expect(ve.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(ve.message).toContain('array'); + } + }); + + test('throws VersioningsError when YAML is a scalar', () => { + expect(() => parseYaml('just a string', 'scalar.yml')).toThrow(VersioningsError); + try { + parseYaml('just a string', 'scalar.yml'); + } catch (err) { + const ve = err as VersioningsError; + expect(ve.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(ve.details!.filePath).toBe('scalar.yml'); + } + }); +}); + +describe('serializeYaml', () => { + test('produces valid YAML that can be parsed back', () => { + const obj = { + git: { + platform: 'github', + url: 'https://github.com/org/repo', + pr: { target: 'main' }, + }, + }; + const yamlStr = serializeYaml(obj); + const parsed = parseYaml(yamlStr, 'roundtrip.yml'); + expect(parsed).toEqual(obj); + }); + + test('round-trip preserves strings, numbers, booleans, and nested objects', () => { + const obj = { + str: 'hello', + num: 123, + float: 3.14, + bool: true, + boolFalse: false, + nested: { + a: 1, + b: 'two', + deep: { c: true }, + }, + }; + const yamlStr = serializeYaml(obj); + const parsed = parseYaml(yamlStr, 'types.yml'); + expect(parsed).toEqual(obj); + }); + + test('returns a string', () => { + const result = serializeYaml({ key: 'value' }); + expect(typeof result).toBe('string'); + }); +}); diff --git a/__tests__/unit/artifact.checker.test.ts b/__tests__/unit/core/artifact.checker.test.ts similarity index 67% rename from __tests__/unit/artifact.checker.test.ts rename to __tests__/unit/core/artifact.checker.test.ts index 4d60a8a..4fb31ea 100644 --- a/__tests__/unit/artifact.checker.test.ts +++ b/__tests__/unit/core/artifact.checker.test.ts @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2018-present Raman Marozau -import { createArtifactChecker } from '../../artifact.checker'; -import { EXIT_CODES, VersioningsError } from '../../errors'; -import type { Executor, ExecutorResult } from '../../executor'; +import { createArtifactChecker } from '../../../src/core/artifact.checker'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; +import type { Executor, ExecutorResult } from '../../../src/core/executor'; function createMockExecutor(responses: Record): Executor { return { @@ -202,4 +202,87 @@ describe('createArtifactChecker', () => { expect(calls[1]).toContain('git branch --list'); }); }); + + describe('branchName null and skipBranchCheck', () => { + test('branchName=null → skips branch check (checks only tag)', async () => { + const executor = createMockExecutor({ + 'git tag --list': '', + 'git branch --list': ' main', + }); + const checker = createArtifactChecker(executor); + await checker.checkUniqueness({ + tagName: '1.0.0--fix', + branchName: null, + push: false, + }); + const calls = (executor.run as jest.Mock).mock.calls.map((c: any[]) => c[0]); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('git tag --list'); + expect(calls).not.toEqual( + expect.arrayContaining([expect.stringContaining('git branch --list')]) + ); + }); + + test('skipBranchCheck=true → skips branch check (checks only tag)', async () => { + const executor = createMockExecutor({ + 'git tag --list': '', + 'git branch --list': ' main', + }); + const checker = createArtifactChecker(executor); + await checker.checkUniqueness({ + tagName: '1.0.0--fix', + branchName: 'release/1.0.0', + push: false, + skipBranchCheck: true, + }); + const calls = (executor.run as jest.Mock).mock.calls.map((c: any[]) => c[0]); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('git tag --list'); + expect(calls).not.toEqual( + expect.arrayContaining([expect.stringContaining('git branch --list')]) + ); + }); + + test('backward compatibility — branchName=string, skipBranchCheck=false → checks both', async () => { + const executor = createMockExecutor({ + 'git tag --list': '', + 'git branch --list': ' main', + }); + const checker = createArtifactChecker(executor); + await checker.checkUniqueness({ + tagName: '1.0.0--fix', + branchName: 'release/1.0.0', + push: false, + skipBranchCheck: false, + }); + const calls = (executor.run as jest.Mock).mock.calls.map((c: any[]) => c[0]); + expect(calls).toHaveLength(2); + expect(calls[0]).toContain('git tag --list'); + expect(calls[1]).toContain('git branch --list'); + }); + + test('branchName=null with existing tag → still throws ARTIFACT_CONFLICT for tag', async () => { + const executor = createMockExecutor({ + 'git tag --list': '0.9.0--old\n1.0.0--fix\n2.0.0--next', + 'git branch --list': ' main', + }); + const checker = createArtifactChecker(executor); + try { + await checker.checkUniqueness({ + tagName: '1.0.0--fix', + branchName: null, + push: false, + }); + throw new Error('Expected to throw'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.ARTIFACT_CONFLICT); + expect(err.details).toEqual({ + type: 'tag', + name: '1.0.0--fix', + scope: 'local', + }); + } + }); + }); }); diff --git a/__tests__/unit/errors.test.ts b/__tests__/unit/core/errors.test.ts similarity index 69% rename from __tests__/unit/errors.test.ts rename to __tests__/unit/core/errors.test.ts index 443e238..0878515 100644 --- a/__tests__/unit/errors.test.ts +++ b/__tests__/unit/core/errors.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2018-present Raman Marozau -import { EXIT_CODES, VersioningsError } from '../../errors'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; describe('EXIT_CODES', () => { test('contains all expected codes with correct values', () => { @@ -14,11 +14,34 @@ describe('EXIT_CODES', () => { COMMAND_FAILED: 5, NETWORK_ERROR: 6, INCOMPLETE_ROLLBACK: 7, + NO_OPERATION: 8, + USER_CANCELLED: 9, + POLICY_VIOLATION: 10, + NO_CONVENTIONAL_COMMITS: 11, }); }); - test('has exactly 8 entries', () => { - expect(Object.keys(EXIT_CODES)).toHaveLength(8); + test('has exactly 12 entries', () => { + expect(Object.keys(EXIT_CODES)).toHaveLength(12); + }); + + test('existing codes 0–7 are unchanged', () => { + expect(EXIT_CODES.SUCCESS).toBe(0); + expect(EXIT_CODES.CONFIG_ERROR).toBe(1); + expect(EXIT_CODES.DIRTY_TREE).toBe(2); + expect(EXIT_CODES.INVALID_ARGS).toBe(3); + expect(EXIT_CODES.ARTIFACT_CONFLICT).toBe(4); + expect(EXIT_CODES.COMMAND_FAILED).toBe(5); + expect(EXIT_CODES.NETWORK_ERROR).toBe(6); + expect(EXIT_CODES.INCOMPLETE_ROLLBACK).toBe(7); + }); + + test('NO_OPERATION exit code equals 8', () => { + expect(EXIT_CODES.NO_OPERATION).toBe(8); + }); + + test('USER_CANCELLED exit code equals 9', () => { + expect(EXIT_CODES.USER_CANCELLED).toBe(9); }); test('is frozen (immutable)', () => { diff --git a/__tests__/unit/executor.test.ts b/__tests__/unit/core/executor.test.ts similarity index 71% rename from __tests__/unit/executor.test.ts rename to __tests__/unit/core/executor.test.ts index a0c544c..5845219 100644 --- a/__tests__/unit/executor.test.ts +++ b/__tests__/unit/core/executor.test.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2018-present Raman Marozau -import { createExecutor, ExecFn } from '../../executor'; -import { EXIT_CODES, VersioningsError } from '../../errors'; +import { createExecutor, ExecFn } from '../../../src/core/executor'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; +import type { StructuredLogger } from '../../../src/core/structured.logger'; describe('createExecutor', () => { describe('successful execution', () => { @@ -134,5 +135,48 @@ describe('createExecutor', () => { expect(executor).toHaveProperty('run'); expect(typeof executor.run).toBe('function'); }); + + test('uses logger.debug instead of console.log when logger is provided', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => { }); + const debugSpy = jest.fn(); + const logger: StructuredLogger = { + debug: debugSpy, + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const mockExec: ExecFn = (cmd, callback) => callback(null, 'ok', ''); + const executor = createExecutor(mockExec, { logger }); + await executor.run('git status'); + expect(debugSpy).toHaveBeenCalledWith('git status', { component: 'executor' }); + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + test('uses logger.debug even when verbose is also true', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => { }); + const debugSpy = jest.fn(); + const logger: StructuredLogger = { + debug: debugSpy, + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const mockExec: ExecFn = (cmd, callback) => callback(null, 'ok', ''); + const executor = createExecutor(mockExec, { verbose: true, logger }); + await executor.run('git tag'); + expect(debugSpy).toHaveBeenCalledWith('git tag', { component: 'executor' }); + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + test('falls back to console.log when logger is not provided and verbose is true', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => { }); + const mockExec: ExecFn = (cmd, callback) => callback(null, 'ok', ''); + const executor = createExecutor(mockExec, { verbose: true }); + await executor.run('git push'); + expect(consoleSpy).toHaveBeenCalledWith('git push'); + consoleSpy.mockRestore(); + }); }); }); diff --git a/__tests__/unit/core/operation.log.test.ts b/__tests__/unit/core/operation.log.test.ts new file mode 100644 index 0000000..7980801 --- /dev/null +++ b/__tests__/unit/core/operation.log.test.ts @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { createOperationLog, OperationLogEntry } from '../../../src/core/operation.log'; +import { VersioningsError, EXIT_CODES } from '../../../src/core/errors'; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'oplog-test-')); +} + +function rmrf(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +} + +function makeEntry(overrides: Partial = {}): OperationLogEntry { + return { + schemaVersion: 1, + timestamp: '2025-01-15T10:30:00.000Z', + semver: 'patch', + version: '1.2.3', + previousVersion: '1.2.2', + branch: 'version/patch/1.2.3/fix-login', + tag: '1.2.3--fix-login', + steps: [ + { type: 'npm_version_bump', meta: {} }, + { type: 'branch_created', meta: { name: 'version/patch/1.2.3/fix-login' } }, + ], + result: 'success', + ...overrides, + }; +} + +describe('operation.log', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + + afterEach(() => { + rmrf(tmpDir); + }); + + // --- Requirement 10.5, 10.6: save + loadLast round-trip --- + + test('save + loadLast round-trip — saved entry is loaded back identically', async () => { + const log = createOperationLog(tmpDir); + const entry = makeEntry(); + + await log.save(entry); + const loaded = await log.loadLast(); + + expect(loaded).toEqual(entry); + }); + + test('save creates last.json as a symlink pointing to the saved file', async () => { + const log = createOperationLog(tmpDir); + const entry = makeEntry(); + + const savedPath = await log.save(entry); + const linkPath = path.join(tmpDir, 'last.json'); + + const stat = fs.lstatSync(linkPath); + expect(stat.isSymbolicLink()).toBe(true); + + const target = fs.readlinkSync(linkPath); + expect(target).toBe(path.basename(savedPath)); + }); + + test('multiple saves — loadLast returns the most recent entry', async () => { + const log = createOperationLog(tmpDir); + + const entry1 = makeEntry({ version: '1.0.0', timestamp: '2025-01-15T10:00:00.000Z' }); + const entry2 = makeEntry({ version: '2.0.0', timestamp: '2025-01-15T11:00:00.000Z' }); + + await log.save(entry1); + await log.save(entry2); + + const loaded = await log.loadLast(); + expect(loaded).toEqual(entry2); + }); + + // --- Requirement 10.9: loadFrom specific file --- + + test('loadFrom — loads entry from a specific file path', async () => { + const log = createOperationLog(tmpDir); + const entry = makeEntry(); + + const savedPath = await log.save(entry); + const loaded = await log.loadFrom(savedPath); + + expect(loaded).toEqual(entry); + }); + + // --- Requirement 10.5: missing log returns null --- + + test('loadLast — returns null when no operations have been saved', async () => { + const log = createOperationLog(tmpDir); + const result = await log.loadLast(); + expect(result).toBeNull(); + }); + + // --- Requirement 10.8: corrupted JSON --- + + test('loadFrom — throws VersioningsError for corrupted JSON', async () => { + const filePath = path.join(tmpDir, 'corrupted.json'); + fs.writeFileSync(filePath, '{not valid json!!!', 'utf8'); + + const log = createOperationLog(tmpDir); + + await expect(log.loadFrom(filePath)).rejects.toThrow(VersioningsError); + await expect(log.loadFrom(filePath)).rejects.toMatchObject({ + code: EXIT_CODES.CONFIG_ERROR, + }); + }); + + test('loadFrom — error message mentions invalid JSON for corrupted file', async () => { + const filePath = path.join(tmpDir, 'corrupted.json'); + fs.writeFileSync(filePath, '<<>>', 'utf8'); + + const log = createOperationLog(tmpDir); + + try { + await log.loadFrom(filePath); + fail('Expected VersioningsError'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.message).toMatch(/invalid JSON/i); + } + }); + + // --- Requirement 10.8: schemaVersion mismatch --- + + test('loadFrom — throws VersioningsError when schemaVersion does not match', async () => { + const filePath = path.join(tmpDir, 'wrong-schema.json'); + const badEntry = { ...makeEntry(), schemaVersion: 99 }; + fs.writeFileSync(filePath, JSON.stringify(badEntry, null, 2), 'utf8'); + + const log = createOperationLog(tmpDir); + + await expect(log.loadFrom(filePath)).rejects.toThrow(VersioningsError); + await expect(log.loadFrom(filePath)).rejects.toMatchObject({ + code: EXIT_CODES.CONFIG_ERROR, + }); + }); + + test('loadFrom — schemaVersion mismatch error mentions expected and found versions', async () => { + const filePath = path.join(tmpDir, 'wrong-schema.json'); + const badEntry = { ...makeEntry(), schemaVersion: 42 }; + fs.writeFileSync(filePath, JSON.stringify(badEntry, null, 2), 'utf8'); + + const log = createOperationLog(tmpDir); + + try { + await log.loadFrom(filePath); + fail('Expected VersioningsError'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.message).toMatch(/schemaVersion/); + expect(err.message).toMatch(/1/); // expected + expect(err.message).toMatch(/42/); // found + } + }); + + // --- Requirement 10.6: atomic write (no temp files remain) --- + + test('save — no .tmp- files remain after successful write', async () => { + const log = createOperationLog(tmpDir); + const entry = makeEntry(); + + await log.save(entry); + + const files = fs.readdirSync(tmpDir); + const tmpFiles = files.filter(f => f.startsWith('.tmp-')); + expect(tmpFiles).toHaveLength(0); + }); + + test('save — target file exists and contains valid JSON after write', async () => { + const log = createOperationLog(tmpDir); + const entry = makeEntry(); + + const savedPath = await log.save(entry); + + expect(fs.existsSync(savedPath)).toBe(true); + const content = JSON.parse(fs.readFileSync(savedPath, 'utf8')); + expect(content.schemaVersion).toBe(1); + expect(content.version).toBe('1.2.3'); + }); + + // --- Requirement 10.5: filename format --- + + test('save — filename matches pattern --.json', async () => { + const log = createOperationLog(tmpDir); + const entry = makeEntry({ + timestamp: '2025-01-15T10:30:00.000Z', + semver: 'patch', + version: '1.2.3', + }); + + const savedPath = await log.save(entry); + const filename = path.basename(savedPath); + + // Filename should contain sanitized timestamp, semver, and version + expect(filename).toMatch(/\.json$/); + expect(filename).toContain('patch'); + expect(filename).toContain('1.2.3'); + // Timestamp colons/dots replaced with dashes + expect(filename).toContain('2025-01-15T10-30-00-000Z'); + }); + + test('save — filename sanitizes special characters', async () => { + const log = createOperationLog(tmpDir); + const entry = makeEntry({ + semver: 'prerelease', + version: '1.0.0-beta.1', + }); + + const savedPath = await log.save(entry); + const filename = path.basename(savedPath); + + // Dots and hyphens are allowed, but other specials should be sanitized + expect(filename).toMatch(/^[a-zA-Z0-9._-]+\.json$/); + }); + + // --- Requirement 10.7: schemaVersion field --- + + test('saved entry always contains schemaVersion: 1', async () => { + const log = createOperationLog(tmpDir); + const entry = makeEntry(); + + const savedPath = await log.save(entry); + const raw = JSON.parse(fs.readFileSync(savedPath, 'utf8')); + + expect(raw.schemaVersion).toBe(1); + }); + + // --- Requirement 10.8: loadFrom non-existent file --- + + test('loadFrom — throws VersioningsError for non-existent file', async () => { + const log = createOperationLog(tmpDir); + const fakePath = path.join(tmpDir, 'does-not-exist.json'); + + await expect(log.loadFrom(fakePath)).rejects.toThrow(VersioningsError); + await expect(log.loadFrom(fakePath)).rejects.toMatchObject({ + code: EXIT_CODES.CONFIG_ERROR, + }); + }); + + // --- Requirement 10.5: failed result with error field --- + + test('save + loadLast round-trip for failed operation with error field', async () => { + const log = createOperationLog(tmpDir); + const entry = makeEntry({ + result: 'failed', + error: { code: 5, message: 'git push failed' }, + }); + + await log.save(entry); + const loaded = await log.loadLast(); + + expect(loaded).toEqual(entry); + expect(loaded!.result).toBe('failed'); + expect(loaded!.error).toEqual({ code: 5, message: 'git push failed' }); + }); +}); diff --git a/__tests__/unit/core/pipeline.test.ts b/__tests__/unit/core/pipeline.test.ts new file mode 100644 index 0000000..d9c5c27 --- /dev/null +++ b/__tests__/unit/core/pipeline.test.ts @@ -0,0 +1,1215 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; + +// Mock fs for package.json reads and changelog writes +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + readFileSync: jest.fn(), + existsSync: jest.fn(), + writeFileSync: jest.fn(), + }; +}); + +const fs = require('fs'); + +// Mock version.utils to avoid config.ts side-effect +jest.mock('../../../src/versioning/version.utils', () => ({ + AVAILABLE_SEMVERS: ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease', 'auto'], + composeVersionBranchName: (semver: string, version: string, comment: string) => + `version/${semver}/${version}/${comment}`, + composeVersionTagName: (semver: string, version: string, comment: string) => + `${version}--${comment}`, + semverMessage: (semver: string, version: string) => + `Patch: v${version}. You SHOULD consider changes.`, + semverNpmMessage: (semver: string, branch: string) => + `Version: ${semver}. Comment: ${branch}.`, + preidParam: (preid?: string) => (preid ? `--preid=${preid}` : ''), + generatePullRequestUrl: (branch: string) => + `https://github.com/user/repo/compare/develop...${branch}?expand=1`, +})); + +// Mock pr.creator to control createPR behavior in tests +const mockCreatePR = jest.fn(); +jest.mock('../../../src/scm/pr.creator', () => ({ + createPR: (...args: any[]) => mockCreatePR(...args), +})); + +const { runPipeline } = require('../../../src/core/pipeline'); + +const mockConfig = { + git: { + platform: 'github', + url: 'https://github.com/user/repo.git', + branchType: { version: 'version' }, + pr: { target: 'develop' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + patch: 'Patch: v%s. You SHOULD consider changes.', + minor: 'Minor: v%s. You MUST consider changes.', + major: 'Release: v%s.', + prepatch: 'Patch version is preparing now: v%s.', + preminor: 'Minor version is preparing now: v%s.', + premajor: 'Release is preparing now: v%s.', + prerelease: 'Preparing: v%s.', + } + } + }, + }, + package: { semver: { patch: 'patch', prepatch: 'prepatch', minor: 'minor', preminor: 'preminor', premajor: 'premajor', prerelease: 'prerelease', major: 'major' } }, + common: { + messages: { + unavailableSemanticVersion: 'Invalid semver', + undefinedVersionBranchName: 'Branch name required', + incorrectVersionBranchNameLength: 'Branch too long', + incorrectVersionBranchNameCharactersDashes: 'No double dashes', + untrackedGitFiles: 'Dirty tree', + incorrectGitRemote: 'Wrong remote', + } + }, +} as any; + +function createMockExecutor() { + return { + run: jest.fn(async (cmd: string) => { + if (cmd.includes('git status --porcelain')) return { stdout: '', lines: [] }; + if (cmd.includes('git remote --verbose')) return { stdout: 'origin\thttps://github.com/user/repo.git (fetch)', lines: ['origin\thttps://github.com/user/repo.git (fetch)'] }; + if (cmd.includes('npm --no-git-tag-version version')) return { stdout: 'v1.2.3', lines: ['v1.2.3'] }; + if (cmd.includes('git checkout -- package')) return { stdout: '', lines: [] }; + if (cmd.includes('git tag --list')) return { stdout: '', lines: [] }; + if (cmd.includes('git branch --list')) return { stdout: ' main', lines: ['main'] }; + return { stdout: '', lines: [] }; + }), + }; +} + +function createMockRollbackManager(rollbackSuccess = true) { + return { + record: jest.fn(), + rollback: jest.fn(async () => ({ + success: rollbackSuccess, + failedSteps: rollbackSuccess ? [] : [{ step: { type: 'pushed', meta: {} }, error: new Error('network') }], + })), + }; +} + +function createMockArtifactChecker() { + return { + checkUniqueness: jest.fn(async () => { }), + }; +} + +const baseOpts = { + semver: 'patch', + branch: 'fix-login', + push: false, + dryRun: false, + json: false, + verbose: false, +}; + +beforeEach(() => { + fs.readFileSync.mockReturnValue(JSON.stringify({ version: '1.2.2' })); + fs.existsSync.mockReturnValue(true); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('runPipeline', () => { + test('successful workflow — returns PipelineResult with correct fields', async () => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const result = await runPipeline(baseOpts, { + executor, + config: mockConfig, + rollbackManager: rollback, + artifactChecker, + }); + expect(result.success).toBe(true); + expect(result.version).toBe('1.2.3'); + expect(result.previousVersion).toBe('1.2.2'); + expect(result.semver).toBe('patch'); + expect(result.branch).toBe('version/patch/1.2.3/fix-login'); + expect(result.tag).toBe('1.2.3--fix-login'); + expect(result.exitCode).toBe(EXIT_CODES.SUCCESS); + expect(result.pullRequestUrl).toBeNull(); + expect(rollback.record).toHaveBeenCalled(); + }); + + test('dry-run — returns DryRunPlan without executing mutation commands', async () => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const plan = await runPipeline( + { ...baseOpts, dryRun: true }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker }, + ); + expect(plan.dryRun).toBe(true); + expect(plan.currentVersion).toBe('1.2.2'); + expect(plan.nextVersion).toBe('1.2.3'); + expect(plan.semver).toBe('patch'); + expect(plan.branch).toBe('version/patch/1.2.3/fix-login'); + expect(plan.tag).toBe('1.2.3--fix-login'); + expect(Array.isArray(plan.steps)).toBe(true); + expect(plan.steps.length).toBeGreaterThan(0); + expect(rollback.record).not.toHaveBeenCalled(); + const mutationCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => + cmd.includes('git checkout -b') || + cmd.includes('git tag --annotate') || + cmd.includes('git commit') || + cmd.includes('git push') + ); + expect(mutationCmds).toHaveLength(0); + }); + + test('error with rollback — when a mutation step fails, rollback is called', async () => { + const executor = createMockExecutor(); + (executor.run as jest.Mock).mockImplementation(async (cmd: string) => { + if (cmd.includes('git status --porcelain')) return { stdout: '', lines: [] }; + if (cmd.includes('git remote --verbose')) return { stdout: 'origin\thttps://github.com/user/repo.git (fetch)', lines: ['origin\thttps://github.com/user/repo.git (fetch)'] }; + if (cmd.includes('npm --no-git-tag-version version')) return { stdout: 'v1.2.3', lines: ['v1.2.3'] }; + if (cmd.includes('git checkout -- package')) return { stdout: '', lines: [] }; + if (cmd.includes('git checkout -b')) throw new VersioningsError(EXIT_CODES.COMMAND_FAILED, 'branch creation failed'); + return { stdout: '', lines: [] }; + }); + const rollback = createMockRollbackManager(true); + const artifactChecker = createMockArtifactChecker(); + await expect( + runPipeline(baseOpts, { executor, config: mockConfig, rollbackManager: rollback, artifactChecker }) + ).rejects.toThrow(); + expect(rollback.rollback).toHaveBeenCalled(); + }); + + test('invalid arguments — invalid semver throws INVALID_ARGS', async () => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + try { + await runPipeline( + { ...baseOpts, semver: 'not-a-semver' }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker }, + ); + throw new Error('Expected to throw'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.INVALID_ARGS); + } + }); + + test('dirty tree — non-empty git status throws DIRTY_TREE', async () => { + const executor = createMockExecutor(); + (executor.run as jest.Mock).mockImplementation(async (cmd: string) => { + if (cmd.includes('git status --porcelain')) return { stdout: 'M file.js', lines: ['M file.js'] }; + return { stdout: '', lines: [] }; + }); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + try { + await runPipeline(baseOpts, { executor, config: mockConfig, rollbackManager: rollback, artifactChecker }); + throw new Error('Expected to throw'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.DIRTY_TREE); + } + }); + + test('incomplete rollback — when rollback fails, throws INCOMPLETE_ROLLBACK (exit code 7)', async () => { + const executor = createMockExecutor(); + (executor.run as jest.Mock).mockImplementation(async (cmd: string) => { + if (cmd.includes('git status --porcelain')) return { stdout: '', lines: [] }; + if (cmd.includes('git remote --verbose')) return { stdout: 'origin\thttps://github.com/user/repo.git (fetch)', lines: ['origin\thttps://github.com/user/repo.git (fetch)'] }; + if (cmd.includes('npm --no-git-tag-version version')) return { stdout: 'v1.2.3', lines: ['v1.2.3'] }; + if (cmd.includes('git checkout -- package')) return { stdout: '', lines: [] }; + if (cmd.includes('git checkout -b')) throw new VersioningsError(EXIT_CODES.COMMAND_FAILED, 'branch failed'); + return { stdout: '', lines: [] }; + }); + const rollback = createMockRollbackManager(false); + const artifactChecker = createMockArtifactChecker(); + try { + await runPipeline(baseOpts, { executor, config: mockConfig, rollbackManager: rollback, artifactChecker }); + throw new Error('Expected to throw'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.INCOMPLETE_ROLLBACK); + expect(err.details).toBeDefined(); + expect(err.details.failedSteps).toBeDefined(); + expect(err.details.failedSteps.length).toBeGreaterThan(0); + } + }); +}); + +describe('runPipeline — operation log integration', () => { + function createMockOperationLog() { + return { + save: jest.fn(async () => '/tmp/log.json'), + loadLast: jest.fn(async () => null), + loadFrom: jest.fn(async () => ({})), + }; + } + + test('saves log with result "success" after successful pipeline', async () => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const operationLog = createMockOperationLog(); + + await runPipeline(baseOpts, { + executor, + config: mockConfig, + rollbackManager: rollback, + artifactChecker, + operationLog, + }); + + expect(operationLog.save).toHaveBeenCalledTimes(1); + const entry = operationLog.save.mock.calls[0][0]; + expect(entry.schemaVersion).toBe(1); + expect(entry.result).toBe('success'); + expect(entry.semver).toBe('patch'); + expect(entry.version).toBe('1.2.3'); + expect(entry.previousVersion).toBe('1.2.2'); + expect(entry.branch).toBe('version/patch/1.2.3/fix-login'); + expect(entry.tag).toBe('1.2.3--fix-login'); + expect(entry.steps.length).toBeGreaterThan(0); + expect(entry.error).toBeUndefined(); + expect(entry.timestamp).toBeDefined(); + }); + + test('saves log with result "failed" when mutation step fails and rollback succeeds', async () => { + const executor = createMockExecutor(); + (executor.run as jest.Mock).mockImplementation(async (cmd: string) => { + if (cmd.includes('git status --porcelain')) return { stdout: '', lines: [] }; + if (cmd.includes('git remote --verbose')) return { stdout: 'origin\thttps://github.com/user/repo.git (fetch)', lines: ['origin\thttps://github.com/user/repo.git (fetch)'] }; + if (cmd.includes('npm --no-git-tag-version version')) return { stdout: 'v1.2.3', lines: ['v1.2.3'] }; + if (cmd.includes('git checkout -- package')) return { stdout: '', lines: [] }; + if (cmd.includes('git checkout -b')) throw new VersioningsError(EXIT_CODES.COMMAND_FAILED, 'branch creation failed'); + return { stdout: '', lines: [] }; + }); + const rollback = createMockRollbackManager(true); + const artifactChecker = createMockArtifactChecker(); + const operationLog = createMockOperationLog(); + + await expect( + runPipeline(baseOpts, { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, operationLog }) + ).rejects.toThrow(); + + expect(operationLog.save).toHaveBeenCalledTimes(1); + const entry = operationLog.save.mock.calls[0][0]; + expect(entry.result).toBe('failed'); + expect(entry.error).toBeDefined(); + expect(entry.error.message).toBe('branch creation failed'); + expect(entry.error.code).toBe(EXIT_CODES.COMMAND_FAILED); + expect(entry.steps.length).toBeGreaterThan(0); + }); + + test('saves log with result "failed" on incomplete rollback', async () => { + const executor = createMockExecutor(); + (executor.run as jest.Mock).mockImplementation(async (cmd: string) => { + if (cmd.includes('git status --porcelain')) return { stdout: '', lines: [] }; + if (cmd.includes('git remote --verbose')) return { stdout: 'origin\thttps://github.com/user/repo.git (fetch)', lines: ['origin\thttps://github.com/user/repo.git (fetch)'] }; + if (cmd.includes('npm --no-git-tag-version version')) return { stdout: 'v1.2.3', lines: ['v1.2.3'] }; + if (cmd.includes('git checkout -- package')) return { stdout: '', lines: [] }; + if (cmd.includes('git checkout -b')) throw new VersioningsError(EXIT_CODES.COMMAND_FAILED, 'branch failed'); + return { stdout: '', lines: [] }; + }); + const rollback = createMockRollbackManager(false); + const artifactChecker = createMockArtifactChecker(); + const operationLog = createMockOperationLog(); + + await expect( + runPipeline(baseOpts, { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, operationLog }) + ).rejects.toThrow(); + + expect(operationLog.save).toHaveBeenCalledTimes(1); + const entry = operationLog.save.mock.calls[0][0]; + expect(entry.result).toBe('failed'); + expect(entry.error).toBeDefined(); + }); + + test('pipeline works without operationLog (backward compatibility)', async () => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + // No operationLog in deps — should work exactly as before + const result = await runPipeline(baseOpts, { + executor, + config: mockConfig, + rollbackManager: rollback, + artifactChecker, + }); + + expect(result.success).toBe(true); + expect(result.version).toBe('1.2.3'); + }); + + test('pipeline still succeeds if operationLog.save throws', async () => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const operationLog = createMockOperationLog(); + operationLog.save.mockRejectedValue(new Error('disk full')); + + const result = await runPipeline(baseOpts, { + executor, + config: mockConfig, + rollbackManager: rollback, + artifactChecker, + operationLog, + }); + + expect(result.success).toBe(true); + expect(result.version).toBe('1.2.3'); + expect(operationLog.save).toHaveBeenCalledTimes(1); + }); +}); + + +// --- PR_Creator integration tests (Task 8.3) --- + +describe('runPipeline — PR_Creator integration', () => { + function createMockPrCreator() { + return { + registry: {} as any, + httpClient: {} as any, + urlParser: {} as any, + resolveAuth: jest.fn(() => ({ token: 'test-token', method: 'token' as const })), + env: {}, + }; + } + + afterEach(() => { + mockCreatePR.mockReset(); + }); + + test('pipeline with prCreator — API success returns pullRequest in result', async () => { + const prResult = { + url: 'https://github.com/user/repo/pull/42', + number: 42, + status: 'created' as const, + fallbackReason: null, + platform: 'github', + warnings: [], + }; + mockCreatePR.mockResolvedValue(prResult); + + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const prCreator = createMockPrCreator(); + + const result = await runPipeline( + { ...baseOpts, push: true }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, prCreator }, + ); + + expect(result.success).toBe(true); + expect(result.pullRequest).toBeDefined(); + expect(result.pullRequest!.url).toBe('https://github.com/user/repo/pull/42'); + expect(result.pullRequest!.number).toBe(42); + expect(result.pullRequest!.status).toBe('created'); + expect(result.pullRequestUrl).toBe('https://github.com/user/repo/pull/42'); + expect(mockCreatePR).toHaveBeenCalledTimes(1); + }); + + test('pipeline with prCreator — fallback returns pullRequest with fallback status', async () => { + const prResult = { + url: 'https://github.com/user/repo/compare/develop...branch', + number: null, + status: 'fallback' as const, + fallbackReason: 'no_token', + platform: 'github', + warnings: [], + }; + mockCreatePR.mockResolvedValue(prResult); + + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const prCreator = createMockPrCreator(); + + const result = await runPipeline( + { ...baseOpts, push: true }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, prCreator }, + ); + + expect(result.success).toBe(true); + expect(result.pullRequest).toBeDefined(); + expect(result.pullRequest!.status).toBe('fallback'); + expect(result.pullRequest!.fallbackReason).toBe('no_token'); + }); + + test('pipeline without prCreator — backward compatibility uses generatePullRequestUrl', async () => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const result = await runPipeline( + { ...baseOpts, push: true }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker }, + ); + + expect(result.success).toBe(true); + expect(result.pullRequestUrl).toContain('https://github.com/user/repo/compare/develop'); + expect(result.pullRequest).toBeUndefined(); + expect(mockCreatePR).not.toHaveBeenCalled(); + }); + + test('--no-pr skips PR/MR creation entirely', async () => { + mockCreatePR.mockResolvedValue({ + url: 'https://github.com/user/repo/pull/42', + number: 42, + status: 'created' as const, + fallbackReason: null, + platform: 'github', + warnings: [], + }); + + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const prCreator = createMockPrCreator(); + + const result = await runPipeline( + { ...baseOpts, push: true, noPr: true }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, prCreator }, + ); + + expect(result.success).toBe(true); + expect(result.pullRequestUrl).toBeNull(); + expect(result.pullRequest).toBeUndefined(); + expect(mockCreatePR).not.toHaveBeenCalled(); + }); + + test('PR error does not trigger rollback — pipeline completes with success=true', async () => { + mockCreatePR.mockRejectedValue(new Error('API rate limit exceeded')); + + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const prCreator = createMockPrCreator(); + + const result = await runPipeline( + { ...baseOpts, push: true }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, prCreator }, + ); + + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.pullRequest).toBeDefined(); + expect(result.pullRequest!.status).toBe('fallback'); + expect(result.pullRequest!.fallbackReason).toBe('API rate limit exceeded'); + // Rollback should NOT have been called for PR errors + expect(rollback.rollback).not.toHaveBeenCalled(); + }); + + test('dry-run with prCreator includes pullRequest info in plan', async () => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const prCreator = createMockPrCreator(); + + const plan = await runPipeline( + { ...baseOpts, push: true, dryRun: true }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, prCreator }, + ); + + expect(plan.dryRun).toBe(true); + expect(plan.pullRequest).toBeDefined(); + expect(plan.pullRequest!.mode).toBe('auto'); + expect(plan.pullRequest!.platform).toBe('github'); + expect(plan.pullRequest!.hasToken).toBe(true); + }); +}); + + +// --- Strategy Registry and Policy Checker integration tests (Task 9.6) --- + +describe('runPipeline — Strategy Registry and Policy Checker integration', () => { + function createMockStrategy(overrides: Partial<{ + name: string; + branchName: string | null; + reuseBranch: boolean; + tagName: string; + commitMessage: string; + valid: boolean; + validationErrors: string[]; + }> = {}) { + const opts = { + name: 'default', + branchName: 'version/patch/1.2.3/fix-login' as string | null, + reuseBranch: false, + tagName: 'v1.2.3', + commitMessage: 'Release v1.2.3', + valid: true, + validationErrors: [] as string[], + ...overrides, + }; + return { + name: jest.fn(() => opts.name), + composeBranchName: jest.fn(() => ({ branchName: opts.branchName, reuseBranch: opts.reuseBranch })), + composeTagName: jest.fn(() => opts.tagName), + composeCommitMessage: jest.fn(() => opts.commitMessage), + validateContext: jest.fn(() => ({ valid: opts.valid, errors: opts.validationErrors })), + }; + } + + function createMockStrategyRegistry(strategy?: ReturnType) { + const mockStrategy = strategy || createMockStrategy(); + return { + register: jest.fn(), + getStrategy: jest.fn(() => mockStrategy), + availableStrategies: jest.fn(() => ['default', 'trunk-based', 'git-flow', 'release-branch', 'hotfix', 'maintenance']), + _mockStrategy: mockStrategy, + }; + } + + function createMockExecutorWithCurrentBranch(currentBranch = 'main') { + return { + run: jest.fn(async (cmd: string) => { + if (cmd.includes('git status --porcelain')) return { stdout: '', lines: [] }; + if (cmd.includes('git remote --verbose')) return { stdout: 'origin\thttps://github.com/user/repo.git (fetch)', lines: ['origin\thttps://github.com/user/repo.git (fetch)'] }; + if (cmd.includes('npm --no-git-tag-version version')) return { stdout: 'v1.2.3', lines: ['v1.2.3'] }; + if (cmd.includes('git checkout -- package')) return { stdout: '', lines: [] }; + if (cmd.includes('git rev-parse --abbrev-ref HEAD')) return { stdout: currentBranch, lines: [currentBranch] }; + if (cmd.includes('git tag --list')) return { stdout: '', lines: [] }; + if (cmd.includes('git branch --list')) return { stdout: ' main', lines: ['main'] }; + return { stdout: '', lines: [] }; + }), + }; + } + + test('pipeline with strategyRegistry (default strategy) — uses strategy for branch/tag names', async () => { + const strategy = createMockStrategy({ + name: 'default', + branchName: 'version/patch/1.2.3/fix-login', + tagName: 'v1.2.3', + commitMessage: 'Release v1.2.3', + }); + const registry = createMockStrategyRegistry(strategy); + const executor = createMockExecutorWithCurrentBranch('main'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const result = await runPipeline(baseOpts, { + executor, + config: mockConfig, + rollbackManager: rollback, + artifactChecker, + strategyRegistry: registry, + }); + + expect(result.success).toBe(true); + expect(result.version).toBe('1.2.3'); + expect(result.branch).toBe('version/patch/1.2.3/fix-login'); + expect(result.tag).toBe('v1.2.3'); + expect(registry.getStrategy).toHaveBeenCalled(); + expect(strategy.composeBranchName).toHaveBeenCalled(); + expect(strategy.composeTagName).toHaveBeenCalled(); + expect(strategy.composeCommitMessage).toHaveBeenCalled(); + expect(strategy.validateContext).toHaveBeenCalled(); + }); + + test('pipeline with trunk-based (null branch, skip checkout -b) — no git checkout -b called', async () => { + const strategy = createMockStrategy({ + name: 'trunk-based', + branchName: null, + reuseBranch: false, + tagName: 'v1.2.3', + commitMessage: 'Release v1.2.3', + }); + const registry = createMockStrategyRegistry(strategy); + const executor = createMockExecutorWithCurrentBranch('main'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const result = await runPipeline(baseOpts, { + executor, + config: mockConfig, + rollbackManager: rollback, + artifactChecker, + strategyRegistry: registry, + }); + + expect(result.success).toBe(true); + expect(result.branch).toBe('main'); + // No git checkout -b should have been called + const checkoutCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => cmd.includes('git checkout -b')); + expect(checkoutCmds).toHaveLength(0); + // Rollback should not record BRANCH_CREATED + const recordCalls = (rollback.record as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((step: any) => step.type === 'branch_created'); + expect(recordCalls).toHaveLength(0); + }); + + test('pipeline with reuseBranch (git checkout without -b, BRANCH_SWITCHED) — git checkout called without -b', async () => { + const strategy = createMockStrategy({ + name: 'release-branch', + branchName: 'release/1.2.3', + reuseBranch: true, + tagName: 'v1.2.3', + commitMessage: 'Release v1.2.3', + }); + const registry = createMockStrategyRegistry(strategy); + const executor = createMockExecutorWithCurrentBranch('develop'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const result = await runPipeline(baseOpts, { + executor, + config: mockConfig, + rollbackManager: rollback, + artifactChecker, + strategyRegistry: registry, + }); + + expect(result.success).toBe(true); + expect(result.branch).toBe('release/1.2.3'); + // Should call git checkout (without -b) for reuse + const checkoutCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => cmd.match(/git checkout (?!-b)(?!--)/) && cmd.includes('release/1.2.3')); + expect(checkoutCmds.length).toBeGreaterThan(0); + // Should NOT call git checkout -b + const checkoutNewCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => cmd.includes('git checkout -b')); + expect(checkoutNewCmds).toHaveLength(0); + // Rollback should record BRANCH_SWITCHED, not BRANCH_CREATED + const switchedSteps = (rollback.record as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((step: any) => step.type === 'branch_switched'); + expect(switchedSteps.length).toBe(1); + expect(switchedSteps[0].meta.previousBranch).toBe('develop'); + }); + + test('pipeline with policyChecker (warnings → stderr) — warnings written to stderr, pipeline continues', async () => { + const executor = createMockExecutorWithCurrentBranch('main'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const strategy = createMockStrategy(); + const registry = createMockStrategyRegistry(strategy); + + const mockPolicyChecker = jest.fn(async () => ({ + warnings: ['Branch "main" has pushRemote configured'], + errors: [], + protectionInfo: null, + })); + + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + const result = await runPipeline(baseOpts, { + executor, + config: mockConfig, + rollbackManager: rollback, + artifactChecker, + strategyRegistry: registry, + policyChecker: mockPolicyChecker, + }); + + expect(result.success).toBe(true); + expect(mockPolicyChecker).toHaveBeenCalledTimes(1); + // Warnings should be written to stderr + const stderrCalls = stderrSpy.mock.calls.map((c: any[]) => c[0]); + const warningOutput = stderrCalls.some((msg: string) => msg.includes('pushRemote')); + expect(warningOutput).toBe(true); + } finally { + stderrSpy.mockRestore(); + } + }); + + test('pipeline with policyChecker (errors → POLICY_VIOLATION) — throws VersioningsError with POLICY_VIOLATION code', async () => { + const executor = createMockExecutorWithCurrentBranch('main'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const strategy = createMockStrategy(); + const registry = createMockStrategyRegistry(strategy); + + const mockPolicyChecker = jest.fn(async () => ({ + warnings: [], + errors: ['Direct push to protected branch is not allowed'], + protectionInfo: { protected: true, source: 'scm-api' as const }, + })); + + try { + await runPipeline(baseOpts, { + executor, + config: mockConfig, + rollbackManager: rollback, + artifactChecker, + strategyRegistry: registry, + policyChecker: mockPolicyChecker, + }); + throw new Error('Expected to throw'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.POLICY_VIOLATION); + expect(err.message).toContain('Direct push to protected branch is not allowed'); + } + + // No mutation commands should have been executed after policy check + const mutationCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => + cmd.includes('git checkout -b') || + cmd.includes('git tag --annotate') || + cmd.includes('git commit') || + cmd.includes('git push') + ); + expect(mutationCmds).toHaveLength(0); + }); + + test('dry-run with strategy and policyCheck — plan contains strategy and policyCheck fields', async () => { + const strategy = createMockStrategy({ + name: 'git-flow', + branchName: 'release/1.2.3', + tagName: 'v1.2.3', + commitMessage: 'Release v1.2.3', + }); + const registry = createMockStrategyRegistry(strategy); + const executor = createMockExecutorWithCurrentBranch('develop'); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const mockPolicyChecker = jest.fn(async () => ({ + warnings: ['Signed commits required'], + errors: [], + protectionInfo: { protected: true, source: 'git-config' as const, gpgSignConfigured: true }, + })); + + const configWithStrategy = { + ...mockConfig, + git: { + ...mockConfig.git, + branching: { strategy: 'git-flow' }, + }, + }; + + const plan = await runPipeline( + { ...baseOpts, dryRun: true }, + { + executor, + config: configWithStrategy, + rollbackManager: rollback, + artifactChecker, + strategyRegistry: registry, + policyChecker: mockPolicyChecker, + }, + ); + + expect(plan.dryRun).toBe(true); + expect((plan as any).strategy).toBe('git-flow'); + expect((plan as any).policyCheck).toBeDefined(); + expect((plan as any).policyCheck.warnings).toContain('Signed commits required'); + expect((plan as any).policyCheck.protectionInfo).toBeDefined(); + expect((plan as any).policyCheck.protectionInfo.protected).toBe(true); + }); + + test('backward compatibility (without strategyRegistry → legacy) — existing behavior preserved', async () => { + const executor = createMockExecutor(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + // No strategyRegistry, no policyChecker — should use legacy functions + const result = await runPipeline(baseOpts, { + executor, + config: mockConfig, + rollbackManager: rollback, + artifactChecker, + }); + + expect(result.success).toBe(true); + expect(result.version).toBe('1.2.3'); + expect(result.branch).toBe('version/patch/1.2.3/fix-login'); + expect(result.tag).toBe('1.2.3--fix-login'); + // strategy field should not be set when no strategyRegistry + expect((result as any).strategy).toBeUndefined(); + }); +}); + + +// --- Auto-bump and Changelog integration tests (Task 9.4) --- + +describe('runPipeline — auto-bump and changelog integration', () => { + function createMockCommitAnalyzer(bumpResultOverrides: Partial<{ + bump: string; + commits: any[]; + conventionalCommits: any[]; + breakingChanges: any[]; + range: { from: string; to: string }; + commitsByType: Record; + }> = {}) { + const defaultBumpResult = { + bump: 'minor', + commits: [ + { hash: 'abc1234', parsed: { valid: true, type: 'feat', scope: null, description: 'add feature', body: null, footers: [], breaking: false, rawMessage: 'feat: add feature' } }, + { hash: 'def5678', parsed: { valid: true, type: 'fix', scope: 'auth', description: 'fix login', body: null, footers: [], breaking: false, rawMessage: 'fix(auth): fix login' } }, + ], + conventionalCommits: [ + { valid: true, type: 'feat', scope: null, description: 'add feature', body: null, footers: [], breaking: false, rawMessage: 'feat: add feature' }, + { valid: true, type: 'fix', scope: 'auth', description: 'fix login', body: null, footers: [], breaking: false, rawMessage: 'fix(auth): fix login' }, + ], + breakingChanges: [], + range: { from: 'v1.0.0', to: 'HEAD' }, + commitsByType: { feat: 1, fix: 1 }, + ...bumpResultOverrides, + }; + + return { + analyzeBump: jest.fn(async () => defaultBumpResult), + bumpPolicy: { feat: 'minor' as const, fix: 'patch' as const, chore: 'none' as const }, + fallbackBump: null as 'major' | 'minor' | 'patch' | null, + }; + } + + function createMockChangelogGenerator(overrides: Partial<{ + markdown: string; + groups: any[]; + changelogFile: string; + }> = {}) { + const defaultResult = { + markdown: '## [1.2.3] - 2024-01-01\n\n### Features\n\n- add feature\n\n### Bug Fixes\n\n- fix login (auth)\n', + groups: [ + { title: 'Features', commits: [{ description: 'add feature', scope: null, breaking: false }] }, + { title: 'Bug Fixes', commits: [{ description: 'fix login', scope: 'auth', breaking: false }] }, + ], + }; + + return { + generateChangelog: jest.fn(() => ({ + markdown: overrides.markdown ?? defaultResult.markdown, + groups: overrides.groups ?? defaultResult.groups, + })), + changelogConfig: { + version: '1.2.3', + date: '2024-01-01', + format: 'markdown' as const, + groupTitles: { feat: 'Features', fix: 'Bug Fixes' }, + excludeTypes: [] as string[], + includeNonConventional: false, + bumpPolicy: { feat: 'minor' as const, fix: 'patch' as const }, + }, + changelogFile: overrides.changelogFile, + }; + } + + function createMockExecutorForAuto(currentBranch = 'main') { + return { + run: jest.fn(async (cmd: string) => { + if (cmd.includes('git status --porcelain')) return { stdout: '', lines: [] }; + if (cmd.includes('git remote --verbose')) return { stdout: 'origin\thttps://github.com/user/repo.git (fetch)', lines: ['origin\thttps://github.com/user/repo.git (fetch)'] }; + if (cmd.includes('npm --no-git-tag-version version')) return { stdout: 'v1.2.3', lines: ['v1.2.3'] }; + if (cmd.includes('git checkout -- package')) return { stdout: '', lines: [] }; + if (cmd.includes('git rev-parse --abbrev-ref HEAD')) return { stdout: currentBranch, lines: [currentBranch] }; + if (cmd.includes('git tag --list')) return { stdout: '', lines: [] }; + if (cmd.includes('git branch --list')) return { stdout: ' main', lines: ['main'] }; + if (cmd.includes('git add')) return { stdout: '', lines: [] }; + return { stdout: '', lines: [] }; + }), + }; + } + + test('pipeline with semver=auto — calls analyzeBump and uses resolvedSemver', async () => { + const executor = createMockExecutorForAuto(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer({ bump: 'minor' }); + + const result = await runPipeline( + { ...baseOpts, semver: 'auto' }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer }, + ); + + expect(result.success).toBe(true); + expect(commitAnalyzer.analyzeBump).toHaveBeenCalledTimes(1); + // resolvedSemver should be 'minor' (from analyzeBump) + expect(result.semver).toBe('minor'); + // npm version command should use 'minor' + const npmCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => cmd.includes('npm --no-git-tag-version version')); + expect(npmCmds.length).toBeGreaterThan(0); + expect(npmCmds[0]).toContain('minor'); + // autoBump info should be in result + expect(result.autoBump).toBeDefined(); + expect(result.autoBump.detectedBump).toBe('minor'); + expect(result.autoBump.totalCommits).toBe(2); + expect(result.autoBump.breakingChanges).toBe(0); + expect(result.autoBump.commitsByType).toEqual({ feat: 1, fix: 1 }); + expect(result.autoBump.range).toEqual({ from: 'v1.0.0', to: 'HEAD' }); + }); + + test('pipeline with semver=patch — does not call analyzeBump', async () => { + const executor = createMockExecutorForAuto(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer(); + + const result = await runPipeline( + { ...baseOpts, semver: 'patch' }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer }, + ); + + expect(result.success).toBe(true); + expect(result.semver).toBe('patch'); + expect(commitAnalyzer.analyzeBump).not.toHaveBeenCalled(); + // autoBump should not be present + expect(result.autoBump).toBeUndefined(); + }); + + test('auto + preid — bump converts to prerelease modifier (minor → preminor)', async () => { + const executor = createMockExecutorForAuto(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer({ bump: 'minor' }); + + const result = await runPipeline( + { ...baseOpts, semver: 'auto', preid: 'beta' }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer }, + ); + + expect(result.success).toBe(true); + // resolvedSemver should be 'preminor' (minor + preid → preminor) + expect(result.semver).toBe('preminor'); + // npm version command should use 'preminor' with --preid=beta + const npmCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => cmd.includes('npm --no-git-tag-version version')); + expect(npmCmds[0]).toContain('preminor'); + expect(npmCmds[0]).toContain('--preid=beta'); + }); + + test('auto + preid — major converts to premajor', async () => { + const executor = createMockExecutorForAuto(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer({ bump: 'major' }); + + const result = await runPipeline( + { ...baseOpts, semver: 'auto', preid: 'rc' }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer }, + ); + + expect(result.success).toBe(true); + expect(result.semver).toBe('premajor'); + }); + + test('auto + preid — patch converts to prepatch', async () => { + const executor = createMockExecutorForAuto(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer({ bump: 'patch' }); + + const result = await runPipeline( + { ...baseOpts, semver: 'auto', preid: 'alpha' }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer }, + ); + + expect(result.success).toBe(true); + expect(result.semver).toBe('prepatch'); + }); + + test('changelog file write — writes changelog and calls git add', async () => { + const executor = createMockExecutorForAuto(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer(); + const changelogGenerator = createMockChangelogGenerator({ changelogFile: 'CHANGELOG.md' }); + + // File does not exist — should create new + fs.existsSync.mockImplementation((path: string) => { + if (path === 'CHANGELOG.md') return false; + if (path === './package-lock.json') return true; + return true; + }); + + const result = await runPipeline( + { ...baseOpts, semver: 'auto' }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer, changelogGenerator }, + ); + + expect(result.success).toBe(true); + // writeFileSync should have been called with the changelog file + expect(fs.writeFileSync).toHaveBeenCalled(); + const writeCall = fs.writeFileSync.mock.calls.find((c: any[]) => c[0] === 'CHANGELOG.md'); + expect(writeCall).toBeDefined(); + expect(writeCall[1]).toContain('# Changelog'); + // git add should have been called for the changelog file + const gitAddCmds = (executor.run as jest.Mock).mock.calls + .map((c: any[]) => c[0]) + .filter((cmd: string) => cmd.includes('git add CHANGELOG.md')); + expect(gitAddCmds.length).toBe(1); + }); + + test('changelog file write — prepends to existing file with header', async () => { + const executor = createMockExecutorForAuto(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer(); + const changelogGenerator = createMockChangelogGenerator({ changelogFile: 'CHANGELOG.md' }); + + const existingContent = '# Changelog\n\n## [1.0.0] - 2023-01-01\n\n- old entry\n'; + fs.existsSync.mockImplementation((path: string) => { + if (path === 'CHANGELOG.md') return true; + if (path === './package-lock.json') return true; + return true; + }); + fs.readFileSync.mockImplementation((path: string) => { + if (path === 'CHANGELOG.md') return existingContent; + return JSON.stringify({ version: '1.2.2' }); + }); + + const result = await runPipeline( + { ...baseOpts, semver: 'auto' }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer, changelogGenerator }, + ); + + expect(result.success).toBe(true); + const writeCall = fs.writeFileSync.mock.calls.find((c: any[]) => c[0] === 'CHANGELOG.md'); + expect(writeCall).toBeDefined(); + // Should start with # Changelog header + expect(writeCall[1]).toMatch(/^# Changelog/); + // Should contain new changelog content + expect(writeCall[1]).toContain('Features'); + // Should preserve old content + expect(writeCall[1]).toContain('old entry'); + }); + + test('dry-run with autoBump and changelogPreview — plan includes both fields', async () => { + const executor = createMockExecutorForAuto(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer(); + const changelogGenerator = createMockChangelogGenerator({ changelogFile: 'CHANGELOG.md' }); + + const plan = await runPipeline( + { ...baseOpts, semver: 'auto', dryRun: true }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer, changelogGenerator }, + ); + + expect(plan.dryRun).toBe(true); + // autoBump should be present + expect(plan.autoBump).toBeDefined(); + expect(plan.autoBump.detectedBump).toBe('minor'); + expect(plan.autoBump.totalCommits).toBe(2); + expect(plan.autoBump.breakingChanges).toBe(0); + expect(plan.autoBump.commitsByType).toEqual({ feat: 1, fix: 1 }); + expect(plan.autoBump.range).toEqual({ from: 'v1.0.0', to: 'HEAD' }); + // changelogPreview should be present + expect(plan.changelogPreview).toBeDefined(); + expect(typeof plan.changelogPreview).toBe('string'); + expect(plan.changelogPreview.length).toBeGreaterThan(0); + // dry-run steps should include changelog write step + expect(plan.steps.some((s: string) => s.includes('write changelog'))).toBe(true); + expect(plan.steps.some((s: string) => s.includes('git add CHANGELOG.md'))).toBe(true); + // No actual file write in dry-run + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + test('dry-run with semver=auto but no changelogFile — no changelog steps in plan', async () => { + const executor = createMockExecutorForAuto(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer(); + const changelogGenerator = createMockChangelogGenerator(); // no changelogFile + + const plan = await runPipeline( + { ...baseOpts, semver: 'auto', dryRun: true }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer, changelogGenerator }, + ); + + expect(plan.dryRun).toBe(true); + expect(plan.autoBump).toBeDefined(); + // changelogPreview should still be present (changelog was generated) + expect(plan.changelogPreview).toBeDefined(); + // But no changelog file write steps + expect(plan.steps.some((s: string) => s.includes('write changelog'))).toBe(false); + expect(plan.steps.some((s: string) => s.includes('git add'))).toBe(false); + }); + + test('PR body with changelog — changelogBody passed to prCreator deps', async () => { + const prResult = { + url: 'https://github.com/user/repo/pull/99', + number: 99, + status: 'created' as const, + fallbackReason: null, + platform: 'github', + warnings: [], + }; + mockCreatePR.mockResolvedValue(prResult); + + const executor = createMockExecutorForAuto(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + const commitAnalyzer = createMockCommitAnalyzer(); + const changelogGenerator = createMockChangelogGenerator(); + const prCreator = { + registry: {} as any, + httpClient: {} as any, + urlParser: {} as any, + resolveAuth: jest.fn(() => ({ token: 'test-token', method: 'token' as const })), + env: {}, + }; + + const result = await runPipeline( + { ...baseOpts, semver: 'auto', push: true }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker, commitAnalyzer, changelogGenerator, prCreator }, + ); + + expect(result.success).toBe(true); + expect(mockCreatePR).toHaveBeenCalledTimes(1); + // The deps passed to createPR should include changelogBody + const createPRArgs = mockCreatePR.mock.calls[0]; + const prDeps = createPRArgs[4]; // 5th argument is deps + expect(prDeps.changelogBody).toBeDefined(); + expect(prDeps.changelogBody).toContain('Features'); + }); + + test('backward compatibility — without commitAnalyzer, semver=auto throws CONFIG_ERROR', async () => { + const executor = createMockExecutorForAuto(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + try { + await runPipeline( + { ...baseOpts, semver: 'auto' }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker }, + ); + throw new Error('Expected to throw'); + } catch (err: any) { + expect(err).toBeInstanceOf(VersioningsError); + expect(err.code).toBe(EXIT_CODES.CONFIG_ERROR); + expect(err.message).toContain('commitAnalyzer'); + } + }); + + test('backward compatibility — without commitAnalyzer, semver=patch works normally', async () => { + const executor = createMockExecutorForAuto(); + const rollback = createMockRollbackManager(); + const artifactChecker = createMockArtifactChecker(); + + const result = await runPipeline( + { ...baseOpts, semver: 'patch' }, + { executor, config: mockConfig, rollbackManager: rollback, artifactChecker }, + ); + + expect(result.success).toBe(true); + expect(result.semver).toBe('patch'); + expect(result.autoBump).toBeUndefined(); + }); +}); diff --git a/__tests__/unit/core/reporter.test.ts b/__tests__/unit/core/reporter.test.ts new file mode 100644 index 0000000..4ae0e77 --- /dev/null +++ b/__tests__/unit/core/reporter.test.ts @@ -0,0 +1,937 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createReporter } from '../../../src/core/reporter'; +import type { PipelineResult, DryRunPlan } from '../../../src/core/reporter'; +import { VersioningsError } from '../../../src/core/errors'; + +const successResult: PipelineResult = { + success: true, + version: '1.2.3', + previousVersion: '1.2.2', + semver: 'patch', + branch: 'version/patch/1.2.3/fix-login', + tag: '1.2.3--fix-login', + pullRequestUrl: 'https://github.com/user/repo/compare/develop...branch', + exitCode: 0, +}; + +const errorResult = new VersioningsError(1, 'Config missing', { expectedPath: './version.json' }); + +const dryRunPlan: DryRunPlan = { + dryRun: true, + currentVersion: '1.2.2', + nextVersion: '1.2.3', + semver: 'patch', + branch: 'version/patch/1.2.3/fix-login', + tag: '1.2.3--fix-login', + commitMessage: 'Patch: v1.2.3. You SHOULD consider changes.', + pullRequestUrl: null, + steps: ['npm --no-git-tag-version version patch', 'git checkout -b ...'], +}; + +describe('createReporter — JSON mode', () => { + const reporter = createReporter({ json: true }); + + test('reportSuccess — contains all required fields and is parseable JSON', () => { + const output = reporter.reportSuccess(successResult); + const parsed = JSON.parse(output); + expect(parsed.success).toBe(true); + expect(parsed.version).toBe('1.2.3'); + expect(parsed.previousVersion).toBe('1.2.2'); + expect(parsed.semver).toBe('patch'); + expect(parsed.branch).toBe('version/patch/1.2.3/fix-login'); + expect(parsed.tag).toBe('1.2.3--fix-login'); + expect(parsed.pullRequestUrl).toBe('https://github.com/user/repo/compare/develop...branch'); + expect(parsed.exitCode).toBe(0); + }); + + test('reportError — contains success:false, exitCode, error object with code/message/details', () => { + const output = reporter.reportError(errorResult); + const parsed = JSON.parse(output); + expect(parsed.success).toBe(false); + expect(parsed.exitCode).toBe(1); + expect(parsed.error).toBeDefined(); + expect(parsed.error.code).toBe('CONFIG_ERROR'); + expect(parsed.error.message).toBe('Config missing'); + expect(parsed.error.details).toEqual({ expectedPath: './version.json' }); + }); + + test('reportDryRun — contains all DryRunPlan fields', () => { + const output = reporter.reportDryRun(dryRunPlan); + const parsed = JSON.parse(output); + expect(parsed.dryRun).toBe(true); + expect(parsed.currentVersion).toBe('1.2.2'); + expect(parsed.nextVersion).toBe('1.2.3'); + expect(parsed.semver).toBe('patch'); + expect(parsed.branch).toBe('version/patch/1.2.3/fix-login'); + expect(parsed.tag).toBe('1.2.3--fix-login'); + expect(parsed.commitMessage).toBe('Patch: v1.2.3. You SHOULD consider changes.'); + expect(parsed.pullRequestUrl).toBeNull(); + expect(parsed.steps).toEqual([ + 'npm --no-git-tag-version version patch', + 'git checkout -b ...', + ]); + }); + + test('no ANSI escape sequences in JSON output', () => { + // eslint-disable-next-line no-control-regex + const ansiPattern = /\x1b\[/; + expect(ansiPattern.test(reporter.reportSuccess(successResult))).toBe(false); + expect(ansiPattern.test(reporter.reportError(errorResult))).toBe(false); + expect(ansiPattern.test(reporter.reportDryRun(dryRunPlan))).toBe(false); + }); + + test('JSON output ends with newline', () => { + expect(reporter.reportSuccess(successResult)).toMatch(/\n$/); + expect(reporter.reportError(errorResult)).toMatch(/\n$/); + expect(reporter.reportDryRun(dryRunPlan)).toMatch(/\n$/); + }); +}); + +describe('createReporter — human-readable mode', () => { + const reporter = createReporter({ json: false }); + + test('reportSuccess — contains version, branch, and semver', () => { + const output = reporter.reportSuccess(successResult); + expect(output).toContain('1.2.3'); + expect(output).toContain('version/patch/1.2.3/fix-login'); + expect(output).toContain('patch'); + }); + + test('reportError — contains error code name and message', () => { + const output = reporter.reportError(errorResult); + expect(output).toContain('CONFIG_ERROR'); + expect(output).toContain('Config missing'); + expect(output).toContain('expectedPath'); + }); +}); + +// --- Fixtures for new reporter methods --- + +import type { ValidateResult, DoctorCheck } from '../../../src/core/reporter'; +import type { ConfigProvenance } from '../../../src/config/config.merger'; + +const validateResultPass: ValidateResult = { + valid: true, + checks: [ + { name: 'config', status: 'pass', details: 'Configuration is valid' }, + { name: 'git-remote', status: 'warn', details: 'Remote not verified' }, + ], + provenance: { + 'git.platform': { value: 'github', source: 'version.json' }, + }, +}; + +const validateResultFail: ValidateResult = { + valid: false, + checks: [ + { name: 'config', status: 'fail', details: 'Missing required field git.url' }, + { name: 'git-remote', status: 'pass', details: 'Remote OK' }, + ], + provenance: {}, +}; + +const doctorChecks: DoctorCheck[] = [ + { name: 'Node.js', status: 'pass', found: 'v20.11.0', expected: '>=16.0.0' }, + { name: 'Git', status: 'pass', found: '2.43.0' }, + { name: 'Config', status: 'fail', found: 'missing', expected: 'version.json or .versioningsrc' }, + { name: 'package.json', status: 'warn', found: 'present, no versionings section' }, +]; + +const provenance: ConfigProvenance = { + 'git.url': { value: 'https://github.com/org/repo', source: '.versioningsrc' }, + 'git.platform': { value: 'github', source: 'env' }, + 'git.pr.target': { value: 'main', source: 'defaults' }, +}; + +// --- JSON mode tests for new methods --- + +describe('createReporter — JSON mode (new methods)', () => { + const reporter = createReporter({ json: true }); + // eslint-disable-next-line no-control-regex + const ansiPattern = /\x1b\[/; + + test('reportValidation — valid JSON with all fields', () => { + const output = reporter.reportValidation(validateResultPass); + const parsed = JSON.parse(output); + expect(parsed.valid).toBe(true); + expect(parsed.checks).toHaveLength(2); + expect(parsed.checks[0].name).toBe('config'); + expect(parsed.checks[0].status).toBe('pass'); + expect(parsed.provenance).toBeDefined(); + expect(parsed.provenance['git.platform'].source).toBe('version.json'); + }); + + test('reportValidation — no ANSI in JSON, ends with newline', () => { + const output = reporter.reportValidation(validateResultPass); + expect(ansiPattern.test(output)).toBe(false); + expect(output).toMatch(/\n$/); + }); + + test('reportDoctor — valid JSON array with all checks', () => { + const output = reporter.reportDoctor(doctorChecks); + const parsed = JSON.parse(output); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(4); + expect(parsed[0].name).toBe('Node.js'); + expect(parsed[0].found).toBe('v20.11.0'); + expect(parsed[0].expected).toBe('>=16.0.0'); + expect(parsed[2].status).toBe('fail'); + }); + + test('reportDoctor — no ANSI in JSON, ends with newline', () => { + const output = reporter.reportDoctor(doctorChecks); + expect(ansiPattern.test(output)).toBe(false); + expect(output).toMatch(/\n$/); + }); + + test('reportProvenance — valid JSON with field paths and sources', () => { + const output = reporter.reportProvenance(provenance); + const parsed = JSON.parse(output); + expect(parsed['git.platform'].value).toBe('github'); + expect(parsed['git.platform'].source).toBe('env'); + expect(parsed['git.url'].source).toBe('.versioningsrc'); + }); + + test('reportProvenance — no ANSI in JSON, ends with newline', () => { + const output = reporter.reportProvenance(provenance); + expect(ansiPattern.test(output)).toBe(false); + expect(output).toMatch(/\n$/); + }); + + test('reportConfirmPlan — valid JSON with all DryRunPlan fields', () => { + const output = reporter.reportConfirmPlan(dryRunPlan); + const parsed = JSON.parse(output); + expect(parsed.dryRun).toBe(true); + expect(parsed.currentVersion).toBe('1.2.2'); + expect(parsed.nextVersion).toBe('1.2.3'); + expect(parsed.semver).toBe('patch'); + expect(parsed.branch).toBe('version/patch/1.2.3/fix-login'); + expect(parsed.tag).toBe('1.2.3--fix-login'); + expect(parsed.steps).toEqual([ + 'npm --no-git-tag-version version patch', + 'git checkout -b ...', + ]); + }); + + test('reportConfirmPlan — no ANSI in JSON, ends with newline', () => { + const output = reporter.reportConfirmPlan(dryRunPlan); + expect(ansiPattern.test(output)).toBe(false); + expect(output).toMatch(/\n$/); + }); +}); + +// --- Human-readable mode tests for new methods --- + +describe('createReporter — human-readable mode (new methods)', () => { + const reporter = createReporter({ json: false }); + + test('reportValidation — shows "passed" with check icons for valid result', () => { + const output = reporter.reportValidation(validateResultPass); + expect(output).toContain('passed'); + expect(output).toContain('✓'); + expect(output).toContain('⚠'); + expect(output).toContain('config'); + expect(output).toContain('Configuration is valid'); + }); + + test('reportValidation — shows "failed" with fail icon for invalid result', () => { + const output = reporter.reportValidation(validateResultFail); + expect(output).toContain('failed'); + expect(output).toContain('✗'); + expect(output).toContain('Missing required field git.url'); + }); + + test('reportDoctor — shows status icons, found and expected values', () => { + const output = reporter.reportDoctor(doctorChecks); + expect(output).toContain('✓'); + expect(output).toContain('✗'); + expect(output).toContain('⚠'); + expect(output).toContain('Node.js'); + expect(output).toContain('found: v20.11.0'); + expect(output).toContain('expected: >=16.0.0'); + }); + + test('reportDoctor — check without expected omits expected field', () => { + const output = reporter.reportDoctor(doctorChecks); + // "Git" check has no expected — should show "found:" but not "expected:" on that line + const gitLine = output.split('\n').find((l: string) => l.includes('Git')); + expect(gitLine).toContain('found: 2.43.0'); + expect(gitLine).not.toContain('expected:'); + }); + + test('reportProvenance — shows sorted field paths with sources', () => { + const output = reporter.reportProvenance(provenance); + expect(output).toContain('git.platform'); + expect(output).toContain('github'); + expect(output).toContain('source: env'); + expect(output).toContain('git.url'); + expect(output).toContain('source: .versioningsrc'); + // Verify sorted order: git.platform before git.pr.target before git.url + const lines = output.split('\n'); + const platformIdx = lines.findIndex((l: string) => l.includes('git.platform')); + const prTargetIdx = lines.findIndex((l: string) => l.includes('git.pr.target')); + const urlIdx = lines.findIndex((l: string) => l.includes('git.url')); + expect(platformIdx).toBeLessThan(prTargetIdx); + expect(prTargetIdx).toBeLessThan(urlIdx); + }); + + test('reportConfirmPlan — shows plan details with colored values', () => { + const output = reporter.reportConfirmPlan(dryRunPlan); + expect(output).toContain('The following operations will be performed'); + expect(output).toContain('1.2.2'); + expect(output).toContain('1.2.3'); + expect(output).toContain('version/patch/1.2.3/fix-login'); + expect(output).toContain('1.2.3--fix-login'); + expect(output).toContain('Steps:'); + // ANSI colors should be present in human-readable mode + // eslint-disable-next-line no-control-regex + expect(/\x1b\[/.test(output)).toBe(true); + }); + + test('reportConfirmPlan — pullRequestUrl null is omitted', () => { + const output = reporter.reportConfirmPlan(dryRunPlan); + expect(output).not.toContain('Pull request URL'); + }); + + test('reportConfirmPlan — pullRequestUrl shown when present', () => { + const planWithPR: DryRunPlan = { + ...dryRunPlan, + pullRequestUrl: 'https://github.com/user/repo/compare/develop...branch', + }; + const output = reporter.reportConfirmPlan(planWithPR); + expect(output).toContain('Pull request URL'); + expect(output).toContain('https://github.com/user/repo/compare/develop...branch'); + }); +}); + + +// --- PR/MR output tests (Task 8.4) --- + +describe('createReporter — JSON mode (PR/MR output)', () => { + const reporter = createReporter({ json: true }); + + const resultWithPR: PipelineResult = { + success: true, + version: '1.2.3', + previousVersion: '1.2.2', + semver: 'patch', + branch: 'version/patch/1.2.3/fix-login', + tag: '1.2.3--fix-login', + pullRequestUrl: null, + exitCode: 0, + pullRequest: { + url: 'https://github.com/user/repo/pull/42', + number: 42, + status: 'created', + fallbackReason: null, + platform: 'github', + warnings: [], + }, + }; + + test('JSON with pullRequest object — contains all PR fields', () => { + const output = reporter.reportSuccess(resultWithPR); + const parsed = JSON.parse(output); + expect(parsed.pullRequest).toBeDefined(); + expect(parsed.pullRequest.url).toBe('https://github.com/user/repo/pull/42'); + expect(parsed.pullRequest.number).toBe(42); + expect(parsed.pullRequest.status).toBe('created'); + expect(parsed.pullRequest.fallbackReason).toBeNull(); + expect(parsed.pullRequest.platform).toBe('github'); + }); + + test('JSON with pullRequestUrl alias — equals pullRequest.url', () => { + const output = reporter.reportSuccess(resultWithPR); + const parsed = JSON.parse(output); + expect(parsed.pullRequestUrl).toBe(parsed.pullRequest.url); + expect(parsed.pullRequestUrl).toBe('https://github.com/user/repo/pull/42'); + }); + + test('JSON without pullRequest — pullRequestUrl preserved from legacy field', () => { + const output = reporter.reportSuccess(successResult); + const parsed = JSON.parse(output); + expect(parsed.pullRequest).toBeUndefined(); + expect(parsed.pullRequestUrl).toBe('https://github.com/user/repo/compare/develop...branch'); + }); + + test('JSON with fallback pullRequest — includes fallbackReason', () => { + const fallbackResult: PipelineResult = { + ...resultWithPR, + pullRequest: { + url: 'https://github.com/user/repo/compare/develop...branch', + number: null, + status: 'fallback', + fallbackReason: 'no_token', + platform: 'github', + warnings: [], + }, + }; + const output = reporter.reportSuccess(fallbackResult); + const parsed = JSON.parse(output); + expect(parsed.pullRequest.status).toBe('fallback'); + expect(parsed.pullRequest.fallbackReason).toBe('no_token'); + expect(parsed.pullRequest.number).toBeNull(); + }); +}); + +describe('createReporter — human-readable mode (PR/MR output)', () => { + const reporter = createReporter({ json: false }); + + test('human-readable created — "Pull request #N created: URL (Platform)"', () => { + const result: PipelineResult = { + success: true, + version: '1.2.3', + previousVersion: '1.2.2', + semver: 'patch', + branch: 'version/patch/1.2.3/fix-login', + tag: '1.2.3--fix-login', + pullRequestUrl: null, + exitCode: 0, + pullRequest: { + url: 'https://github.com/user/repo/pull/42', + number: 42, + status: 'created', + fallbackReason: null, + platform: 'github', + warnings: [], + }, + }; + const output = reporter.reportSuccess(result); + expect(output).toContain('Pull request #42 created'); + expect(output).toContain('https://github.com/user/repo/pull/42'); + expect(output).toContain('(GitHub)'); + }); + + test('human-readable fallback — "Pull request URL (fallback: reason): URL"', () => { + const result: PipelineResult = { + success: true, + version: '1.2.3', + previousVersion: '1.2.2', + semver: 'patch', + branch: 'version/patch/1.2.3/fix-login', + tag: '1.2.3--fix-login', + pullRequestUrl: null, + exitCode: 0, + pullRequest: { + url: 'https://github.com/user/repo/compare/develop...branch', + number: null, + status: 'fallback', + fallbackReason: 'no_token', + platform: 'github', + warnings: [], + }, + }; + const output = reporter.reportSuccess(result); + expect(output).toContain('Pull request URL (fallback: no_token)'); + expect(output).toContain('https://github.com/user/repo/compare/develop...branch'); + }); + + test('human-readable draft — "(draft)" next to URL', () => { + const result: PipelineResult = { + success: true, + version: '1.2.3', + previousVersion: '1.2.2', + semver: 'patch', + branch: 'version/patch/1.2.3/fix-login', + tag: '1.2.3--fix-login', + pullRequestUrl: null, + exitCode: 0, + pullRequest: { + url: 'https://github.com/user/repo/pull/42', + number: 42, + status: 'created', + fallbackReason: null, + platform: 'github', + warnings: ['Created as draft pull request'], + }, + }; + const output = reporter.reportSuccess(result); + expect(output).toContain('(draft)'); + expect(output).toContain('#42 created'); + }); + + test('GitLab — "Merge request" instead of "Pull request"', () => { + const result: PipelineResult = { + success: true, + version: '1.2.3', + previousVersion: '1.2.2', + semver: 'patch', + branch: 'version/patch/1.2.3/fix-login', + tag: '1.2.3--fix-login', + pullRequestUrl: null, + exitCode: 0, + pullRequest: { + url: 'https://gitlab.com/user/repo/-/merge_requests/42', + number: 42, + status: 'created', + fallbackReason: null, + platform: 'gitlab', + warnings: [], + }, + }; + const output = reporter.reportSuccess(result); + expect(output).toContain('Merge request !42 created'); + expect(output).toContain('(GitLab)'); + expect(output).not.toContain('Pull request'); + }); + + test('dry-run with PR info — shows PR/MR creation method and parameters', () => { + const plan: DryRunPlan = { + ...dryRunPlan, + pullRequestUrl: 'https://github.com/user/repo/compare/develop...branch', + pullRequest: { + mode: 'auto', + platform: 'github', + reviewers: ['alice', 'bob'], + labels: ['release'], + draft: true, + hasToken: true, + }, + }; + const output = reporter.reportDryRun(plan); + expect(output).toContain('PR/MR creation: API'); + expect(output).toContain('mode: auto'); + expect(output).toContain('platform: github'); + expect(output).toContain('Reviewers: alice, bob'); + expect(output).toContain('Labels: release'); + expect(output).toContain('Draft: yes'); + }); + + test('dry-run without token — shows URL method', () => { + const plan: DryRunPlan = { + ...dryRunPlan, + pullRequestUrl: 'https://github.com/user/repo/compare/develop...branch', + pullRequest: { + mode: 'auto', + platform: 'github', + hasToken: false, + }, + }; + const output = reporter.reportDryRun(plan); + expect(output).toContain('PR/MR creation: URL'); + }); +}); + + +// --- Strategy and policyCheck output tests (Task 11.5) --- + +import { EXIT_CODES } from '../../../src/core/errors'; + +describe('reporter — strategy and policyCheck output', () => { + // JSON mode tests + test('reportSuccess JSON includes strategy field when present', () => { + const reporter = createReporter({ json: true }); + const result: PipelineResult = { + ...successResult, + strategy: 'trunk-based', + }; + const output = reporter.reportSuccess(result); + const parsed = JSON.parse(output); + expect(parsed.strategy).toBe('trunk-based'); + }); + + test('reportSuccess JSON includes policyCheck field when present', () => { + const reporter = createReporter({ json: true }); + const result: PipelineResult = { + ...successResult, + policyCheck: { warnings: ['test'], errors: [], protectionInfo: null }, + }; + const output = reporter.reportSuccess(result); + const parsed = JSON.parse(output); + expect(parsed.policyCheck).toBeDefined(); + expect(parsed.policyCheck.warnings).toEqual(['test']); + expect(parsed.policyCheck.errors).toEqual([]); + expect(parsed.policyCheck.protectionInfo).toBeNull(); + }); + + test('reportSuccess JSON omits strategy when absent (backward compatibility)', () => { + const reporter = createReporter({ json: true }); + const output = reporter.reportSuccess(successResult); + const parsed = JSON.parse(output); + expect(parsed.strategy).toBeUndefined(); + }); + + // Human-readable mode tests + test('reportSuccess human-readable includes Strategy line when present', () => { + const reporter = createReporter({ json: false }); + const result: PipelineResult = { + ...successResult, + strategy: 'git-flow', + }; + const output = reporter.reportSuccess(result); + expect(output).toContain('Strategy: git-flow'); + }); + + test('reportSuccess human-readable omits Strategy line when absent', () => { + const reporter = createReporter({ json: false }); + const output = reporter.reportSuccess(successResult); + expect(output).not.toContain('Strategy:'); + }); + + // POLICY_VIOLATION error tests + test('reportError JSON for POLICY_VIOLATION includes error code', () => { + const reporter = createReporter({ json: true }); + const err = new VersioningsError(EXIT_CODES.POLICY_VIOLATION, 'Branch "main" is protected'); + const output = reporter.reportError(err); + const parsed = JSON.parse(output); + expect(parsed.error.code).toBe('POLICY_VIOLATION'); + expect(parsed.exitCode).toBe(10); + expect(parsed.success).toBe(false); + }); + + test('reportError human-readable for POLICY_VIOLATION includes code name', () => { + const reporter = createReporter({ json: false }); + const err = new VersioningsError(EXIT_CODES.POLICY_VIOLATION, 'Branch "main" is protected'); + const output = reporter.reportError(err); + expect(output).toContain('POLICY_VIOLATION'); + expect(output).toContain('exit code 10'); + }); +}); + + +// --- autoBump and NO_CONVENTIONAL_COMMITS output tests (Task 13.1) --- + +import type { AutoBumpInfo } from '../../../src/core/reporter'; + +const sampleAutoBump: AutoBumpInfo = { + detectedBump: 'minor', + totalCommits: 12, + breakingChanges: 0, + commitsByType: { feat: 5, fix: 3, refactor: 2, chore: 2 }, + range: { from: 'v1.2.0', to: 'HEAD' }, +}; + +describe('reporter — autoBump in reportSuccess', () => { + test('JSON includes autoBump field when present', () => { + const reporter = createReporter({ json: true }); + const result: PipelineResult = { + ...successResult, + semver: 'minor', + autoBump: sampleAutoBump, + }; + const output = reporter.reportSuccess(result); + const parsed = JSON.parse(output); + expect(parsed.autoBump).toBeDefined(); + expect(parsed.autoBump.detectedBump).toBe('minor'); + expect(parsed.autoBump.totalCommits).toBe(12); + expect(parsed.autoBump.breakingChanges).toBe(0); + expect(parsed.autoBump.commitsByType).toEqual({ feat: 5, fix: 3, refactor: 2, chore: 2 }); + expect(parsed.autoBump.range).toEqual({ from: 'v1.2.0', to: 'HEAD' }); + }); + + test('JSON omits autoBump field when absent (backward compatibility)', () => { + const reporter = createReporter({ json: true }); + const output = reporter.reportSuccess(successResult); + const parsed = JSON.parse(output); + expect(parsed.autoBump).toBeUndefined(); + }); + + test('human-readable includes auto-detected bump line', () => { + const reporter = createReporter({ json: false }); + const result: PipelineResult = { + ...successResult, + semver: 'minor', + autoBump: sampleAutoBump, + }; + const output = reporter.reportSuccess(result); + expect(output).toContain('Auto-detected bump: minor (12 commits analyzed, 0 breaking changes)'); + }); + + test('human-readable includes commitsByType breakdown', () => { + const reporter = createReporter({ json: false }); + const result: PipelineResult = { + ...successResult, + semver: 'minor', + autoBump: sampleAutoBump, + }; + const output = reporter.reportSuccess(result); + expect(output).toContain('chore: 2'); + expect(output).toContain('feat: 5'); + expect(output).toContain('fix: 3'); + expect(output).toContain('refactor: 2'); + }); + + test('human-readable includes range', () => { + const reporter = createReporter({ json: false }); + const result: PipelineResult = { + ...successResult, + semver: 'minor', + autoBump: sampleAutoBump, + }; + const output = reporter.reportSuccess(result); + expect(output).toContain('Range: v1.2.0..HEAD'); + }); + + test('human-readable omits auto-bump lines when autoBump absent', () => { + const reporter = createReporter({ json: false }); + const output = reporter.reportSuccess(successResult); + expect(output).not.toContain('Auto-detected bump'); + expect(output).not.toContain('Range:'); + }); +}); + +describe('reporter — autoBump and changelogPreview in reportDryRun', () => { + test('JSON includes autoBump when present in plan', () => { + const reporter = createReporter({ json: true }); + const plan: DryRunPlan = { + ...dryRunPlan, + semver: 'minor', + autoBump: sampleAutoBump, + }; + const output = reporter.reportDryRun(plan); + const parsed = JSON.parse(output); + expect(parsed.autoBump).toBeDefined(); + expect(parsed.autoBump.detectedBump).toBe('minor'); + expect(parsed.autoBump.totalCommits).toBe(12); + }); + + test('JSON includes changelogPreview when present in plan', () => { + const reporter = createReporter({ json: true }); + const plan: DryRunPlan = { + ...dryRunPlan, + changelogPreview: '## [1.3.0] - 2024-01-15\n\n### Features\n\n- add login', + }; + const output = reporter.reportDryRun(plan); + const parsed = JSON.parse(output); + expect(parsed.changelogPreview).toContain('## [1.3.0]'); + }); + + test('human-readable includes auto-detected bump line in dry-run', () => { + const reporter = createReporter({ json: false }); + const plan: DryRunPlan = { + ...dryRunPlan, + semver: 'minor', + autoBump: sampleAutoBump, + }; + const output = reporter.reportDryRun(plan); + expect(output).toContain('Auto-detected bump: minor (12 commits analyzed, 0 breaking changes)'); + expect(output).toContain('Range: v1.2.0..HEAD'); + }); + + test('human-readable includes changelog preview section', () => { + const reporter = createReporter({ json: false }); + const plan: DryRunPlan = { + ...dryRunPlan, + changelogPreview: '## [1.3.0] - 2024-01-15\n\n### Features\n\n- add login', + }; + const output = reporter.reportDryRun(plan); + expect(output).toContain('Changelog preview:'); + expect(output).toContain('## [1.3.0] - 2024-01-15'); + expect(output).toContain('- add login'); + }); + + test('human-readable omits changelog preview when empty string', () => { + const reporter = createReporter({ json: false }); + const plan: DryRunPlan = { + ...dryRunPlan, + changelogPreview: ' ', + }; + const output = reporter.reportDryRun(plan); + expect(output).not.toContain('Changelog preview:'); + }); + + test('human-readable omits auto-bump and changelog when absent', () => { + const reporter = createReporter({ json: false }); + const output = reporter.reportDryRun(dryRunPlan); + expect(output).not.toContain('Auto-detected bump'); + expect(output).not.toContain('Changelog preview'); + }); +}); + +describe('reporter — NO_CONVENTIONAL_COMMITS error formatting', () => { + test('JSON error includes NO_CONVENTIONAL_COMMITS code and details', () => { + const reporter = createReporter({ json: true }); + const err = new VersioningsError( + EXIT_CODES.NO_CONVENTIONAL_COMMITS, + 'No conventional commits found in range', + { + range: { from: 'v1.2.0', to: 'HEAD' }, + totalCommits: 5, + recommendation: 'Use --semver=patch|minor|major or configure conventionalCommits.fallbackBump', + }, + ); + const output = reporter.reportError(err); + const parsed = JSON.parse(output); + expect(parsed.success).toBe(false); + expect(parsed.exitCode).toBe(11); + expect(parsed.error.code).toBe('NO_CONVENTIONAL_COMMITS'); + expect(parsed.error.details.range).toEqual({ from: 'v1.2.0', to: 'HEAD' }); + expect(parsed.error.details.totalCommits).toBe(5); + expect(parsed.error.details.recommendation).toContain('--semver=patch'); + }); + + test('human-readable error includes range, commit count, and recommendation', () => { + const reporter = createReporter({ json: false }); + const err = new VersioningsError( + EXIT_CODES.NO_CONVENTIONAL_COMMITS, + 'No conventional commits found in range', + { + range: { from: 'v1.2.0', to: 'HEAD' }, + totalCommits: 5, + recommendation: 'Use --semver=patch|minor|major or configure conventionalCommits.fallbackBump', + }, + ); + const output = reporter.reportError(err); + expect(output).toContain('NO_CONVENTIONAL_COMMITS'); + expect(output).toContain('exit code 11'); + expect(output).toContain('Range: v1.2.0..HEAD'); + expect(output).toContain('Commits analyzed: 5'); + expect(output).toContain('Recommendation: Use --semver=patch'); + }); + + test('human-readable error handles partial details gracefully', () => { + const reporter = createReporter({ json: false }); + const err = new VersioningsError( + EXIT_CODES.NO_CONVENTIONAL_COMMITS, + 'No conventional commits found', + { totalCommits: 3 }, + ); + const output = reporter.reportError(err); + expect(output).toContain('NO_CONVENTIONAL_COMMITS'); + expect(output).toContain('Commits analyzed: 3'); + expect(output).not.toContain('Range:'); + expect(output).not.toContain('Recommendation:'); + }); + + test('human-readable error handles missing details gracefully', () => { + const reporter = createReporter({ json: false }); + const err = new VersioningsError( + EXIT_CODES.NO_CONVENTIONAL_COMMITS, + 'No conventional commits found', + ); + const output = reporter.reportError(err); + expect(output).toContain('NO_CONVENTIONAL_COMMITS'); + expect(output).toContain('exit code 11'); + expect(output).not.toContain('Range:'); + expect(output).not.toContain('Commits analyzed:'); + }); +}); + + +// --- operationId and totalDurationMs output tests (Task 8.4) --- + +describe('reporter — operationId and totalDurationMs in JSON output', () => { + const reporter = createReporter({ json: true }); + + test('reportSuccess JSON includes operationId when present', () => { + const result: PipelineResult = { + ...successResult, + operationId: '550e8400-e29b-41d4-a716-446655440000', + }; + const output = reporter.reportSuccess(result); + const parsed = JSON.parse(output); + expect(parsed.operationId).toBe('550e8400-e29b-41d4-a716-446655440000'); + }); + + test('reportSuccess JSON includes totalDurationMs when present', () => { + const result: PipelineResult = { + ...successResult, + totalDurationMs: 1234, + }; + const output = reporter.reportSuccess(result); + const parsed = JSON.parse(output); + expect(parsed.totalDurationMs).toBe(1234); + }); + + test('reportSuccess JSON includes both operationId and totalDurationMs', () => { + const result: PipelineResult = { + ...successResult, + operationId: 'abc-def-123', + totalDurationMs: 5678, + }; + const output = reporter.reportSuccess(result); + const parsed = JSON.parse(output); + expect(parsed.operationId).toBe('abc-def-123'); + expect(parsed.totalDurationMs).toBe(5678); + }); + + test('reportSuccess JSON omits operationId when absent (backward compatibility)', () => { + const output = reporter.reportSuccess(successResult); + const parsed = JSON.parse(output); + expect(parsed.operationId).toBeUndefined(); + }); + + test('reportSuccess JSON omits totalDurationMs when absent (backward compatibility)', () => { + const output = reporter.reportSuccess(successResult); + const parsed = JSON.parse(output); + expect(parsed.totalDurationMs).toBeUndefined(); + }); + + test('reportDryRun JSON includes operationId when present', () => { + const plan: DryRunPlan = { + ...dryRunPlan, + operationId: '660e8400-e29b-41d4-a716-446655440000', + }; + const output = reporter.reportDryRun(plan); + const parsed = JSON.parse(output); + expect(parsed.operationId).toBe('660e8400-e29b-41d4-a716-446655440000'); + }); + + test('reportDryRun JSON omits operationId when absent (backward compatibility)', () => { + const output = reporter.reportDryRun(dryRunPlan); + const parsed = JSON.parse(output); + expect(parsed.operationId).toBeUndefined(); + }); + + test('reportSuccess JSON totalDurationMs preserves zero value', () => { + const result: PipelineResult = { + ...successResult, + totalDurationMs: 0, + }; + const output = reporter.reportSuccess(result); + const parsed = JSON.parse(output); + expect(parsed.totalDurationMs).toBe(0); + }); +}); + +describe('reporter — operationId in human-readable verbose mode', () => { + test('verbose mode includes Operation ID in first line of reportSuccess', () => { + const reporter = createReporter({ json: false, verbose: true }); + const result: PipelineResult = { + ...successResult, + operationId: '550e8400-e29b-41d4-a716-446655440000', + }; + const output = reporter.reportSuccess(result); + const firstLine = output.split('\n')[0]; + expect(firstLine).toContain('Operation ID: 550e8400-e29b-41d4-a716-446655440000'); + }); + + test('verbose mode includes Operation ID in first line of reportDryRun', () => { + const reporter = createReporter({ json: false, verbose: true }); + const plan: DryRunPlan = { + ...dryRunPlan, + operationId: '660e8400-e29b-41d4-a716-446655440000', + }; + const output = reporter.reportDryRun(plan); + const firstLine = output.split('\n')[0]; + expect(firstLine).toContain('Operation ID: 660e8400-e29b-41d4-a716-446655440000'); + }); + + test('non-verbose mode does not include Operation ID in reportSuccess', () => { + const reporter = createReporter({ json: false }); + const result: PipelineResult = { + ...successResult, + operationId: '550e8400-e29b-41d4-a716-446655440000', + }; + const output = reporter.reportSuccess(result); + expect(output).not.toContain('Operation ID'); + }); + + test('non-verbose mode does not include Operation ID in reportDryRun', () => { + const reporter = createReporter({ json: false }); + const plan: DryRunPlan = { + ...dryRunPlan, + operationId: '660e8400-e29b-41d4-a716-446655440000', + }; + const output = reporter.reportDryRun(plan); + expect(output).not.toContain('Operation ID'); + }); + + test('verbose mode without operationId does not show Operation ID', () => { + const reporter = createReporter({ json: false, verbose: true }); + const output = reporter.reportSuccess(successResult); + expect(output).not.toContain('Operation ID'); + }); +}); diff --git a/__tests__/unit/core/rollback.test.ts b/__tests__/unit/core/rollback.test.ts new file mode 100644 index 0000000..af68e5f --- /dev/null +++ b/__tests__/unit/core/rollback.test.ts @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createRollbackManager, STEP_TYPES } from '../../../src/core/rollback'; +import type { Executor, ExecutorResult } from '../../../src/core/executor'; + +function createMockExecutor(): Executor & { commands: string[] } { + const commands: string[] = []; + return { + run: jest.fn(async (cmd: string): Promise => { + commands.push(cmd); + return { stdout: '', lines: [] }; + }), + commands, + }; +} + +function createFailingExecutor(failOnCommand: string): Executor & { commands: string[] } { + const commands: string[] = []; + return { + run: jest.fn(async (cmd: string): Promise => { + commands.push(cmd); + if (cmd.includes(failOnCommand)) { + throw new Error(`Failed: ${cmd}`); + } + return { stdout: '', lines: [] }; + }), + commands, + }; +} + +describe('rollback manager', () => { + test('record steps — steps are stored in order (verified via LIFO rollback)', async () => { + const executor = createMockExecutor(); + const mgr = createRollbackManager(executor); + mgr.record({ type: STEP_TYPES.TAG_CREATED, meta: { name: 'v1.0.0' } }); + mgr.record({ type: STEP_TYPES.BRANCH_CREATED, meta: { name: 'version/patch/1.0.0/fix' } }); + mgr.record({ type: STEP_TYPES.COMMITTED, meta: {} }); + await mgr.rollback(); + expect(executor.commands[0]).toBe('git reset --hard HEAD~1'); + expect(executor.commands[1]).toBe('git branch -D version/patch/1.0.0/fix'); + expect(executor.commands[2]).toBe('git tag -d v1.0.0'); + }); + + test('rollback in reverse order (LIFO)', async () => { + const executor = createMockExecutor(); + const mgr = createRollbackManager(executor); + mgr.record({ type: STEP_TYPES.NPM_VERSION_BUMP, meta: {} }); + mgr.record({ type: STEP_TYPES.BRANCH_CREATED, meta: { name: 'release/1.2.3' } }); + mgr.record({ type: STEP_TYPES.TAG_CREATED, meta: { name: '1.2.3--hotfix' } }); + await mgr.rollback(); + expect(executor.commands).toEqual([ + 'git tag -d 1.2.3--hotfix', + 'git branch -D release/1.2.3', + 'git reset --hard', + ]); + }); + + test('partial rollback on error — failed step recorded, remaining steps still execute', async () => { + const executor = createFailingExecutor('git branch -D'); + const mgr = createRollbackManager(executor); + mgr.record({ type: STEP_TYPES.NPM_VERSION_BUMP, meta: {} }); + mgr.record({ type: STEP_TYPES.BRANCH_CREATED, meta: { name: 'feat-branch' } }); + mgr.record({ type: STEP_TYPES.TAG_CREATED, meta: { name: 'v2.0.0' } }); + const result = await mgr.rollback(); + expect(result.success).toBe(false); + expect(result.failedSteps).toHaveLength(1); + expect(result.failedSteps[0].step.type).toBe(STEP_TYPES.BRANCH_CREATED); + expect(result.failedSteps[0].error).toBeInstanceOf(Error); + expect(executor.commands).toContain('git reset --hard'); + expect(executor.commands).toContain('git tag -d v2.0.0'); + }); + + test('empty journal — rollback returns success with no failed steps', async () => { + const executor = createMockExecutor(); + const mgr = createRollbackManager(executor); + const result = await mgr.rollback(); + expect(result).toEqual({ success: true, failedSteps: [] }); + expect(executor.commands).toHaveLength(0); + }); + + test('full success — all steps rolled back, success=true, failedSteps=[]', async () => { + const executor = createMockExecutor(); + const mgr = createRollbackManager(executor); + mgr.record({ type: STEP_TYPES.NPM_VERSION_BUMP, meta: {} }); + mgr.record({ type: STEP_TYPES.BRANCH_CREATED, meta: { name: 'version/minor/2.0.0/feature' } }); + mgr.record({ type: STEP_TYPES.TAG_CREATED, meta: { name: '2.0.0--feature' } }); + mgr.record({ type: STEP_TYPES.COMMITTED, meta: {} }); + const result = await mgr.rollback(); + expect(result.success).toBe(true); + expect(result.failedSteps).toEqual([]); + expect(executor.run).toHaveBeenCalledTimes(4); + }); + + test('PUSHED step — rolls back both branch and tag on remote', async () => { + const executor = createMockExecutor(); + const mgr = createRollbackManager(executor); + mgr.record({ + type: STEP_TYPES.PUSHED, + meta: { branch: 'version/patch/1.0.1/bugfix', tag: '1.0.1--bugfix', remote: 'origin' }, + }); + await mgr.rollback(); + expect(executor.commands).toEqual([ + 'git push origin --delete version/patch/1.0.1/bugfix', + 'git push origin --delete 1.0.1--bugfix', + ]); + }); +}); + +describe('BRANCH_SWITCHED rollback', () => { + test('rollback BRANCH_SWITCHED executes git checkout {previousBranch}', async () => { + const executor = createMockExecutor(); + const mgr = createRollbackManager(executor); + mgr.record({ type: STEP_TYPES.BRANCH_SWITCHED, meta: { previousBranch: 'main' } }); + await mgr.rollback(); + expect(executor.commands).toEqual(['git checkout main']); + }); + + test('LIFO order with BRANCH_SWITCHED', async () => { + const executor = createMockExecutor(); + const mgr = createRollbackManager(executor); + mgr.record({ type: STEP_TYPES.NPM_VERSION_BUMP, meta: {} }); + mgr.record({ type: STEP_TYPES.BRANCH_SWITCHED, meta: { previousBranch: 'develop' } }); + mgr.record({ type: STEP_TYPES.TAG_CREATED, meta: { name: 'v1.0.1' } }); + await mgr.rollback(); + expect(executor.commands).toEqual([ + 'git tag -d v1.0.1', + 'git checkout develop', + 'git reset --hard', + ]); + }); + + test('no BRANCH_SWITCHED → does not attempt to rollback branch switch', async () => { + const executor = createMockExecutor(); + const mgr = createRollbackManager(executor); + mgr.record({ type: STEP_TYPES.NPM_VERSION_BUMP, meta: {} }); + mgr.record({ type: STEP_TYPES.TAG_CREATED, meta: { name: 'v2.0.0' } }); + await mgr.rollback(); + expect(executor.commands).toEqual([ + 'git tag -d v2.0.0', + 'git reset --hard', + ]); + expect(executor.commands.every(cmd => !cmd.includes('git checkout'))).toBe(true); + }); +}); + +import type { StructuredLogger } from '../../../src/core/structured.logger'; + +function createMockLogger(): StructuredLogger & { calls: Array<{ method: string; message: string; context?: Record }> } { + const calls: Array<{ method: string; message: string; context?: Record }> = []; + return { + debug(message: string, context?: Record) { calls.push({ method: 'debug', message, context }); }, + info(message: string, context?: Record) { calls.push({ method: 'info', message, context }); }, + warn(message: string, context?: Record) { calls.push({ method: 'warn', message, context }); }, + error(message: string, context?: Record) { calls.push({ method: 'error', message, context }); }, + calls, + }; +} + +describe('rollback manager with StructuredLogger', () => { + test('logs rollback start, each step, and completion on success', async () => { + const executor = createMockExecutor(); + const logger = createMockLogger(); + const mgr = createRollbackManager(executor, logger); + mgr.record({ type: STEP_TYPES.TAG_CREATED, meta: { name: 'v1.0.0' } }); + mgr.record({ type: STEP_TYPES.COMMITTED, meta: {} }); + await mgr.rollback(); + + expect(logger.calls[0]).toEqual({ method: 'info', message: 'Rollback started', context: { totalSteps: 2 } }); + // Step 1 (COMMITTED — reversed index 1) + expect(logger.calls[1]).toEqual({ method: 'info', message: 'Rolling back step', context: { stepType: 'committed', index: 1 } }); + expect(logger.calls[2]).toEqual({ method: 'info', message: 'Rollback step completed', context: { stepType: 'committed', result: 'success' } }); + // Step 2 (TAG_CREATED — reversed index 0) + expect(logger.calls[3]).toEqual({ method: 'info', message: 'Rolling back step', context: { stepType: 'tag_created', index: 0 } }); + expect(logger.calls[4]).toEqual({ method: 'info', message: 'Rollback step completed', context: { stepType: 'tag_created', result: 'success' } }); + // Completion + expect(logger.calls[5]).toEqual({ method: 'info', message: 'Rollback completed', context: { success: true, totalSteps: 2, failedCount: 0 } }); + }); + + test('logs warn on failed step', async () => { + const executor = createFailingExecutor('git branch -D'); + const logger = createMockLogger(); + const mgr = createRollbackManager(executor, logger); + mgr.record({ type: STEP_TYPES.BRANCH_CREATED, meta: { name: 'feat' } }); + await mgr.rollback(); + + const warnCalls = logger.calls.filter(c => c.method === 'warn'); + expect(warnCalls).toHaveLength(1); + expect(warnCalls[0].message).toBe('Rollback step failed'); + expect(warnCalls[0].context).toMatchObject({ stepType: 'branch_created', result: 'failed' }); + + const completionCall = logger.calls[logger.calls.length - 1]; + expect(completionCall).toEqual({ method: 'info', message: 'Rollback completed', context: { success: false, totalSteps: 1, failedCount: 1 } }); + }); + + test('without logger — no errors, same behavior as before', async () => { + const executor = createMockExecutor(); + const mgr = createRollbackManager(executor); + mgr.record({ type: STEP_TYPES.NPM_VERSION_BUMP, meta: {} }); + const result = await mgr.rollback(); + expect(result.success).toBe(true); + expect(executor.commands).toEqual(['git reset --hard']); + }); + + test('logger errors do not break rollback flow', async () => { + const executor = createMockExecutor(); + const throwingLogger: StructuredLogger = { + debug() { throw new Error('logger boom'); }, + info() { throw new Error('logger boom'); }, + warn() { throw new Error('logger boom'); }, + error() { throw new Error('logger boom'); }, + }; + const mgr = createRollbackManager(executor, throwingLogger); + mgr.record({ type: STEP_TYPES.TAG_CREATED, meta: { name: 'v1.0.0' } }); + const result = await mgr.rollback(); + expect(result.success).toBe(true); + expect(executor.commands).toEqual(['git tag -d v1.0.0']); + }); +}); diff --git a/__tests__/unit/pipeline.test.ts b/__tests__/unit/pipeline.test.ts deleted file mode 100644 index 8b97548..0000000 --- a/__tests__/unit/pipeline.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2018-present Raman Marozau - -import { EXIT_CODES, VersioningsError } from '../../errors'; - -// Mock fs for package.json reads -jest.mock('fs', () => { - const actual = jest.requireActual('fs'); - return { - ...actual, - readFileSync: jest.fn(), - existsSync: jest.fn(), - }; -}); - -const fs = require('fs'); - -// Mock version.utils to avoid config.ts side-effect -jest.mock('../../version.utils', () => ({ - AVAILABLE_SEMVERS: ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease'], - composeVersionBranchName: (semver: string, version: string, comment: string) => - `version/${semver}/${version}/${comment}`, - composeVersionTagName: (semver: string, version: string, comment: string) => - `${version}--${comment}`, - semverMessage: (semver: string, version: string) => - `Patch: v${version}. You SHOULD consider changes.`, - semverNpmMessage: (semver: string, branch: string) => - `Version: ${semver}. Comment: ${branch}.`, - preidParam: (preid?: string) => (preid ? `--preid=${preid}` : ''), - generatePullRequestUrl: (branch: string) => - `https://github.com/user/repo/compare/develop...${branch}?expand=1`, -})); - -const { runPipeline } = require('../../pipeline'); - -const mockConfig = { - git: { - platform: 'github', - url: 'https://github.com/user/repo.git', - branchType: { version: 'version' }, - pr: { target: 'develop' }, - limits: { branchMaxCommentLength: 96 }, - remote: 'origin', - commit: { - message: { - semver: { - patch: 'Patch: v%s. You SHOULD consider changes.', - minor: 'Minor: v%s. You MUST consider changes.', - major: 'Release: v%s.', - prepatch: 'Patch version is preparing now: v%s.', - preminor: 'Minor version is preparing now: v%s.', - premajor: 'Release is preparing now: v%s.', - prerelease: 'Preparing: v%s.', - } - } - }, - }, - package: { semver: { patch: 'patch', prepatch: 'prepatch', minor: 'minor', preminor: 'preminor', premajor: 'premajor', prerelease: 'prerelease', major: 'major' } }, - common: { - messages: { - unavailableSemanticVersion: 'Invalid semver', - undefinedVersionBranchName: 'Branch name required', - incorrectVersionBranchNameLength: 'Branch too long', - incorrectVersionBranchNameCharactersDashes: 'No double dashes', - untrackedGitFiles: 'Dirty tree', - incorrectGitRemote: 'Wrong remote', - } - }, -} as any; - -function createMockExecutor() { - return { - run: jest.fn(async (cmd: string) => { - if (cmd.includes('git status --porcelain')) return { stdout: '', lines: [] }; - if (cmd.includes('git remote --verbose')) return { stdout: 'origin\thttps://github.com/user/repo.git (fetch)', lines: ['origin\thttps://github.com/user/repo.git (fetch)'] }; - if (cmd.includes('npm --no-git-tag-version version')) return { stdout: 'v1.2.3', lines: ['v1.2.3'] }; - if (cmd.includes('git checkout -- package')) return { stdout: '', lines: [] }; - if (cmd.includes('git tag --list')) return { stdout: '', lines: [] }; - if (cmd.includes('git branch --list')) return { stdout: ' main', lines: ['main'] }; - return { stdout: '', lines: [] }; - }), - }; -} - -function createMockRollbackManager(rollbackSuccess = true) { - return { - record: jest.fn(), - rollback: jest.fn(async () => ({ - success: rollbackSuccess, - failedSteps: rollbackSuccess ? [] : [{ step: { type: 'pushed', meta: {} }, error: new Error('network') }], - })), - }; -} - -function createMockArtifactChecker() { - return { - checkUniqueness: jest.fn(async () => { }), - }; -} - -const baseOpts = { - semver: 'patch', - branch: 'fix-login', - push: false, - dryRun: false, - json: false, - verbose: false, -}; - -beforeEach(() => { - fs.readFileSync.mockReturnValue(JSON.stringify({ version: '1.2.2' })); - fs.existsSync.mockReturnValue(true); -}); - -afterEach(() => { - jest.clearAllMocks(); -}); - -describe('runPipeline', () => { - test('successful workflow — returns PipelineResult with correct fields', async () => { - const executor = createMockExecutor(); - const rollback = createMockRollbackManager(); - const artifactChecker = createMockArtifactChecker(); - const result = await runPipeline(baseOpts, { - executor, - config: mockConfig, - rollbackManager: rollback, - artifactChecker, - }); - expect(result.success).toBe(true); - expect(result.version).toBe('1.2.3'); - expect(result.previousVersion).toBe('1.2.2'); - expect(result.semver).toBe('patch'); - expect(result.branch).toBe('version/patch/1.2.3/fix-login'); - expect(result.tag).toBe('1.2.3--fix-login'); - expect(result.exitCode).toBe(EXIT_CODES.SUCCESS); - expect(result.pullRequestUrl).toBeNull(); - expect(rollback.record).toHaveBeenCalled(); - }); - - test('dry-run — returns DryRunPlan without executing mutation commands', async () => { - const executor = createMockExecutor(); - const rollback = createMockRollbackManager(); - const artifactChecker = createMockArtifactChecker(); - const plan = await runPipeline( - { ...baseOpts, dryRun: true }, - { executor, config: mockConfig, rollbackManager: rollback, artifactChecker }, - ); - expect(plan.dryRun).toBe(true); - expect(plan.currentVersion).toBe('1.2.2'); - expect(plan.nextVersion).toBe('1.2.3'); - expect(plan.semver).toBe('patch'); - expect(plan.branch).toBe('version/patch/1.2.3/fix-login'); - expect(plan.tag).toBe('1.2.3--fix-login'); - expect(Array.isArray(plan.steps)).toBe(true); - expect(plan.steps.length).toBeGreaterThan(0); - expect(rollback.record).not.toHaveBeenCalled(); - const mutationCmds = (executor.run as jest.Mock).mock.calls - .map((c: any[]) => c[0]) - .filter((cmd: string) => - cmd.includes('git checkout -b') || - cmd.includes('git tag --annotate') || - cmd.includes('git commit') || - cmd.includes('git push') - ); - expect(mutationCmds).toHaveLength(0); - }); - - test('error with rollback — when a mutation step fails, rollback is called', async () => { - const executor = createMockExecutor(); - (executor.run as jest.Mock).mockImplementation(async (cmd: string) => { - if (cmd.includes('git status --porcelain')) return { stdout: '', lines: [] }; - if (cmd.includes('git remote --verbose')) return { stdout: 'origin\thttps://github.com/user/repo.git (fetch)', lines: ['origin\thttps://github.com/user/repo.git (fetch)'] }; - if (cmd.includes('npm --no-git-tag-version version')) return { stdout: 'v1.2.3', lines: ['v1.2.3'] }; - if (cmd.includes('git checkout -- package')) return { stdout: '', lines: [] }; - if (cmd.includes('git checkout -b')) throw new VersioningsError(EXIT_CODES.COMMAND_FAILED, 'branch creation failed'); - return { stdout: '', lines: [] }; - }); - const rollback = createMockRollbackManager(true); - const artifactChecker = createMockArtifactChecker(); - await expect( - runPipeline(baseOpts, { executor, config: mockConfig, rollbackManager: rollback, artifactChecker }) - ).rejects.toThrow(); - expect(rollback.rollback).toHaveBeenCalled(); - }); - - test('invalid arguments — invalid semver throws INVALID_ARGS', async () => { - const executor = createMockExecutor(); - const rollback = createMockRollbackManager(); - const artifactChecker = createMockArtifactChecker(); - try { - await runPipeline( - { ...baseOpts, semver: 'not-a-semver' }, - { executor, config: mockConfig, rollbackManager: rollback, artifactChecker }, - ); - throw new Error('Expected to throw'); - } catch (err: any) { - expect(err).toBeInstanceOf(VersioningsError); - expect(err.code).toBe(EXIT_CODES.INVALID_ARGS); - } - }); - - test('dirty tree — non-empty git status throws DIRTY_TREE', async () => { - const executor = createMockExecutor(); - (executor.run as jest.Mock).mockImplementation(async (cmd: string) => { - if (cmd.includes('git status --porcelain')) return { stdout: 'M file.js', lines: ['M file.js'] }; - return { stdout: '', lines: [] }; - }); - const rollback = createMockRollbackManager(); - const artifactChecker = createMockArtifactChecker(); - try { - await runPipeline(baseOpts, { executor, config: mockConfig, rollbackManager: rollback, artifactChecker }); - throw new Error('Expected to throw'); - } catch (err: any) { - expect(err).toBeInstanceOf(VersioningsError); - expect(err.code).toBe(EXIT_CODES.DIRTY_TREE); - } - }); - - test('incomplete rollback — when rollback fails, throws INCOMPLETE_ROLLBACK (exit code 7)', async () => { - const executor = createMockExecutor(); - (executor.run as jest.Mock).mockImplementation(async (cmd: string) => { - if (cmd.includes('git status --porcelain')) return { stdout: '', lines: [] }; - if (cmd.includes('git remote --verbose')) return { stdout: 'origin\thttps://github.com/user/repo.git (fetch)', lines: ['origin\thttps://github.com/user/repo.git (fetch)'] }; - if (cmd.includes('npm --no-git-tag-version version')) return { stdout: 'v1.2.3', lines: ['v1.2.3'] }; - if (cmd.includes('git checkout -- package')) return { stdout: '', lines: [] }; - if (cmd.includes('git checkout -b')) throw new VersioningsError(EXIT_CODES.COMMAND_FAILED, 'branch failed'); - return { stdout: '', lines: [] }; - }); - const rollback = createMockRollbackManager(false); - const artifactChecker = createMockArtifactChecker(); - try { - await runPipeline(baseOpts, { executor, config: mockConfig, rollbackManager: rollback, artifactChecker }); - throw new Error('Expected to throw'); - } catch (err: any) { - expect(err).toBeInstanceOf(VersioningsError); - expect(err.code).toBe(EXIT_CODES.INCOMPLETE_ROLLBACK); - expect(err.details).toBeDefined(); - expect(err.details.failedSteps).toBeDefined(); - expect(err.details.failedSteps.length).toBeGreaterThan(0); - } - }); -}); diff --git a/__tests__/unit/reporter.test.ts b/__tests__/unit/reporter.test.ts deleted file mode 100644 index 61547bb..0000000 --- a/__tests__/unit/reporter.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2018-present Raman Marozau - -import { createReporter } from '../../reporter'; -import type { PipelineResult, DryRunPlan } from '../../reporter'; -import { VersioningsError } from '../../errors'; - -const successResult: PipelineResult = { - success: true, - version: '1.2.3', - previousVersion: '1.2.2', - semver: 'patch', - branch: 'version/patch/1.2.3/fix-login', - tag: '1.2.3--fix-login', - pullRequestUrl: 'https://github.com/user/repo/compare/develop...branch', - exitCode: 0, -}; - -const errorResult = new VersioningsError(1, 'Config missing', { expectedPath: './version.json' }); - -const dryRunPlan: DryRunPlan = { - dryRun: true, - currentVersion: '1.2.2', - nextVersion: '1.2.3', - semver: 'patch', - branch: 'version/patch/1.2.3/fix-login', - tag: '1.2.3--fix-login', - commitMessage: 'Patch: v1.2.3. You SHOULD consider changes.', - pullRequestUrl: null, - steps: ['npm --no-git-tag-version version patch', 'git checkout -b ...'], -}; - -describe('createReporter — JSON mode', () => { - const reporter = createReporter({ json: true }); - - test('reportSuccess — contains all required fields and is parseable JSON', () => { - const output = reporter.reportSuccess(successResult); - const parsed = JSON.parse(output); - expect(parsed.success).toBe(true); - expect(parsed.version).toBe('1.2.3'); - expect(parsed.previousVersion).toBe('1.2.2'); - expect(parsed.semver).toBe('patch'); - expect(parsed.branch).toBe('version/patch/1.2.3/fix-login'); - expect(parsed.tag).toBe('1.2.3--fix-login'); - expect(parsed.pullRequestUrl).toBe('https://github.com/user/repo/compare/develop...branch'); - expect(parsed.exitCode).toBe(0); - }); - - test('reportError — contains success:false, exitCode, error object with code/message/details', () => { - const output = reporter.reportError(errorResult); - const parsed = JSON.parse(output); - expect(parsed.success).toBe(false); - expect(parsed.exitCode).toBe(1); - expect(parsed.error).toBeDefined(); - expect(parsed.error.code).toBe('CONFIG_ERROR'); - expect(parsed.error.message).toBe('Config missing'); - expect(parsed.error.details).toEqual({ expectedPath: './version.json' }); - }); - - test('reportDryRun — contains all DryRunPlan fields', () => { - const output = reporter.reportDryRun(dryRunPlan); - const parsed = JSON.parse(output); - expect(parsed.dryRun).toBe(true); - expect(parsed.currentVersion).toBe('1.2.2'); - expect(parsed.nextVersion).toBe('1.2.3'); - expect(parsed.semver).toBe('patch'); - expect(parsed.branch).toBe('version/patch/1.2.3/fix-login'); - expect(parsed.tag).toBe('1.2.3--fix-login'); - expect(parsed.commitMessage).toBe('Patch: v1.2.3. You SHOULD consider changes.'); - expect(parsed.pullRequestUrl).toBeNull(); - expect(parsed.steps).toEqual([ - 'npm --no-git-tag-version version patch', - 'git checkout -b ...', - ]); - }); - - test('no ANSI escape sequences in JSON output', () => { - // eslint-disable-next-line no-control-regex - const ansiPattern = /\x1b\[/; - expect(ansiPattern.test(reporter.reportSuccess(successResult))).toBe(false); - expect(ansiPattern.test(reporter.reportError(errorResult))).toBe(false); - expect(ansiPattern.test(reporter.reportDryRun(dryRunPlan))).toBe(false); - }); - - test('JSON output ends with newline', () => { - expect(reporter.reportSuccess(successResult)).toMatch(/\n$/); - expect(reporter.reportError(errorResult)).toMatch(/\n$/); - expect(reporter.reportDryRun(dryRunPlan)).toMatch(/\n$/); - }); -}); - -describe('createReporter — human-readable mode', () => { - const reporter = createReporter({ json: false }); - - test('reportSuccess — contains version, branch, and semver', () => { - const output = reporter.reportSuccess(successResult); - expect(output).toContain('1.2.3'); - expect(output).toContain('version/patch/1.2.3/fix-login'); - expect(output).toContain('patch'); - }); - - test('reportError — contains error code name and message', () => { - const output = reporter.reportError(errorResult); - expect(output).toContain('CONFIG_ERROR'); - expect(output).toContain('Config missing'); - expect(output).toContain('expectedPath'); - }); -}); diff --git a/__tests__/unit/rollback.test.ts b/__tests__/unit/rollback.test.ts deleted file mode 100644 index 1e9994b..0000000 --- a/__tests__/unit/rollback.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2018-present Raman Marozau - -import { createRollbackManager, STEP_TYPES } from '../../rollback'; -import type { Executor, ExecutorResult } from '../../executor'; - -function createMockExecutor(): Executor & { commands: string[] } { - const commands: string[] = []; - return { - run: jest.fn(async (cmd: string): Promise => { - commands.push(cmd); - return { stdout: '', lines: [] }; - }), - commands, - }; -} - -function createFailingExecutor(failOnCommand: string): Executor & { commands: string[] } { - const commands: string[] = []; - return { - run: jest.fn(async (cmd: string): Promise => { - commands.push(cmd); - if (cmd.includes(failOnCommand)) { - throw new Error(`Failed: ${cmd}`); - } - return { stdout: '', lines: [] }; - }), - commands, - }; -} - -describe('rollback manager', () => { - test('record steps — steps are stored in order (verified via LIFO rollback)', async () => { - const executor = createMockExecutor(); - const mgr = createRollbackManager(executor); - mgr.record({ type: STEP_TYPES.TAG_CREATED, meta: { name: 'v1.0.0' } }); - mgr.record({ type: STEP_TYPES.BRANCH_CREATED, meta: { name: 'version/patch/1.0.0/fix' } }); - mgr.record({ type: STEP_TYPES.COMMITTED, meta: {} }); - await mgr.rollback(); - expect(executor.commands[0]).toBe('git reset --hard HEAD~1'); - expect(executor.commands[1]).toBe('git branch -D version/patch/1.0.0/fix'); - expect(executor.commands[2]).toBe('git tag -d v1.0.0'); - }); - - test('rollback in reverse order (LIFO)', async () => { - const executor = createMockExecutor(); - const mgr = createRollbackManager(executor); - mgr.record({ type: STEP_TYPES.NPM_VERSION_BUMP, meta: {} }); - mgr.record({ type: STEP_TYPES.BRANCH_CREATED, meta: { name: 'release/1.2.3' } }); - mgr.record({ type: STEP_TYPES.TAG_CREATED, meta: { name: '1.2.3--hotfix' } }); - await mgr.rollback(); - expect(executor.commands).toEqual([ - 'git tag -d 1.2.3--hotfix', - 'git branch -D release/1.2.3', - 'git reset --hard', - ]); - }); - - test('partial rollback on error — failed step recorded, remaining steps still execute', async () => { - const executor = createFailingExecutor('git branch -D'); - const mgr = createRollbackManager(executor); - mgr.record({ type: STEP_TYPES.NPM_VERSION_BUMP, meta: {} }); - mgr.record({ type: STEP_TYPES.BRANCH_CREATED, meta: { name: 'feat-branch' } }); - mgr.record({ type: STEP_TYPES.TAG_CREATED, meta: { name: 'v2.0.0' } }); - const result = await mgr.rollback(); - expect(result.success).toBe(false); - expect(result.failedSteps).toHaveLength(1); - expect(result.failedSteps[0].step.type).toBe(STEP_TYPES.BRANCH_CREATED); - expect(result.failedSteps[0].error).toBeInstanceOf(Error); - expect(executor.commands).toContain('git reset --hard'); - expect(executor.commands).toContain('git tag -d v2.0.0'); - }); - - test('empty journal — rollback returns success with no failed steps', async () => { - const executor = createMockExecutor(); - const mgr = createRollbackManager(executor); - const result = await mgr.rollback(); - expect(result).toEqual({ success: true, failedSteps: [] }); - expect(executor.commands).toHaveLength(0); - }); - - test('full success — all steps rolled back, success=true, failedSteps=[]', async () => { - const executor = createMockExecutor(); - const mgr = createRollbackManager(executor); - mgr.record({ type: STEP_TYPES.NPM_VERSION_BUMP, meta: {} }); - mgr.record({ type: STEP_TYPES.BRANCH_CREATED, meta: { name: 'version/minor/2.0.0/feature' } }); - mgr.record({ type: STEP_TYPES.TAG_CREATED, meta: { name: '2.0.0--feature' } }); - mgr.record({ type: STEP_TYPES.COMMITTED, meta: {} }); - const result = await mgr.rollback(); - expect(result.success).toBe(true); - expect(result.failedSteps).toEqual([]); - expect(executor.run).toHaveBeenCalledTimes(4); - }); - - test('PUSHED step — rolls back both branch and tag on remote', async () => { - const executor = createMockExecutor(); - const mgr = createRollbackManager(executor); - mgr.record({ - type: STEP_TYPES.PUSHED, - meta: { branch: 'version/patch/1.0.1/bugfix', tag: '1.0.1--bugfix', remote: 'origin' }, - }); - await mgr.rollback(); - expect(executor.commands).toEqual([ - 'git push origin --delete version/patch/1.0.1/bugfix', - 'git push origin --delete 1.0.1--bugfix', - ]); - }); -}); diff --git a/__tests__/unit/scm/auth.resolver.test.ts b/__tests__/unit/scm/auth.resolver.test.ts new file mode 100644 index 0000000..019ffe2 --- /dev/null +++ b/__tests__/unit/scm/auth.resolver.test.ts @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { resolveAuth, maskToken, type AuthResolverDeps } from '../../../src/scm/auth.resolver'; + +// --------------------------------------------------------------------------- +// Helper: builds AuthResolverDeps with sensible defaults +// --------------------------------------------------------------------------- + +function makeDeps( + overrides: { + config?: Record; + env?: Record; + } = {}, +): AuthResolverDeps { + return { + config: overrides.config ?? {}, + env: overrides.env ?? {}, + }; +} + +// --------------------------------------------------------------------------- +// Token resolution — individual sources +// --------------------------------------------------------------------------- + +describe('Token from config (git.auth.token)', () => { + test('returns token from config.git.auth.token', () => { + const deps = makeDeps({ config: { git: { auth: { token: 'cfg-token-12345' } } } }); + const result = resolveAuth(deps, 'github'); + expect(result.token).toBe('cfg-token-12345'); + }); +}); + +describe('Token from VERSIONINGS_TOKEN env', () => { + test('returns token from env.VERSIONINGS_TOKEN when config has no token', () => { + const deps = makeDeps({ env: { VERSIONINGS_TOKEN: 'env-token-67890' } }); + const result = resolveAuth(deps, 'github'); + expect(result.token).toBe('env-token-67890'); + }); +}); + +describe('Platform-specific tokens', () => { + test.each([ + ['github', 'GITHUB_TOKEN'], + ['github-enterprise', 'GITHUB_TOKEN'], + ['gitlab', 'GITLAB_TOKEN'], + ['bitbucket', 'BITBUCKET_TOKEN'], + ['bitbucket-server', 'BITBUCKET_TOKEN'], + ['azure-devops', 'AZURE_DEVOPS_TOKEN'], + ])('platform "%s" resolves token from %s', (platform, envVar) => { + const deps = makeDeps({ env: { [envVar]: `platform-token-${platform}` } }); + const result = resolveAuth(deps, platform); + expect(result.token).toBe(`platform-token-${platform}`); + }); +}); + +// --------------------------------------------------------------------------- +// Token resolution — priority +// --------------------------------------------------------------------------- + +describe('Token priority: config > VERSIONINGS_TOKEN > platform-specific', () => { + test('config.git.auth.token wins over VERSIONINGS_TOKEN', () => { + const deps = makeDeps({ + config: { git: { auth: { token: 'config-wins' } } }, + env: { VERSIONINGS_TOKEN: 'env-loses' }, + }); + const result = resolveAuth(deps, 'github'); + expect(result.token).toBe('config-wins'); + }); + + test('config.git.auth.token wins over platform-specific token', () => { + const deps = makeDeps({ + config: { git: { auth: { token: 'config-wins' } } }, + env: { GITHUB_TOKEN: 'platform-loses' }, + }); + const result = resolveAuth(deps, 'github'); + expect(result.token).toBe('config-wins'); + }); + + test('VERSIONINGS_TOKEN wins over platform-specific token', () => { + const deps = makeDeps({ + env: { VERSIONINGS_TOKEN: 'env-wins', GITHUB_TOKEN: 'platform-loses' }, + }); + const result = resolveAuth(deps, 'github'); + expect(result.token).toBe('env-wins'); + }); + + test('config.git.auth.token wins over all sources', () => { + const deps = makeDeps({ + config: { git: { auth: { token: 'config-wins' } } }, + env: { + VERSIONINGS_TOKEN: 'env-loses', + GITHUB_TOKEN: 'platform-loses', + }, + }); + const result = resolveAuth(deps, 'github'); + expect(result.token).toBe('config-wins'); + }); +}); + +// --------------------------------------------------------------------------- +// No token → null +// --------------------------------------------------------------------------- + +describe('No token available', () => { + test('returns null when no token source is available', () => { + const deps = makeDeps(); + const result = resolveAuth(deps, 'github'); + expect(result.token).toBeNull(); + }); + + test('returns null for unknown platform with no env tokens', () => { + const deps = makeDeps(); + const result = resolveAuth(deps, 'unknown-platform'); + expect(result.token).toBeNull(); + }); + + test('returns null when config.git.auth.token is empty string', () => { + const deps = makeDeps({ config: { git: { auth: { token: '' } } } }); + const result = resolveAuth(deps, 'github'); + expect(result.token).toBeNull(); + }); + + test('returns null when VERSIONINGS_TOKEN is empty string', () => { + const deps = makeDeps({ env: { VERSIONINGS_TOKEN: '' } }); + const result = resolveAuth(deps, 'github'); + expect(result.token).toBeNull(); + }); + + test('returns null when platform token is empty string', () => { + const deps = makeDeps({ env: { GITHUB_TOKEN: '' } }); + const result = resolveAuth(deps, 'github'); + expect(result.token).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// maskToken +// --------------------------------------------------------------------------- + +describe('maskToken', () => { + test('token >= 5 chars: shows first 4 chars + stars', () => { + expect(maskToken('ghp_abcdef12345')).toBe('ghp_***********'); + }); + + test('token exactly 5 chars: shows first 4 + 1 star', () => { + expect(maskToken('abcde')).toBe('abcd*'); + }); + + test('token < 5 chars: fully masked', () => { + expect(maskToken('abc')).toBe('***'); + }); + + test('token of 1 char: single star', () => { + expect(maskToken('x')).toBe('*'); + }); + + test('token of 4 chars: fully masked', () => { + expect(maskToken('abcd')).toBe('****'); + }); + + test('empty string: empty result', () => { + expect(maskToken('')).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// Auth method resolution +// --------------------------------------------------------------------------- + +describe('Auth method from config', () => { + test('uses config.git.auth.method when set to "bearer"', () => { + const deps = makeDeps({ config: { git: { auth: { method: 'bearer' } } } }); + const result = resolveAuth(deps, 'github'); + expect(result.method).toBe('bearer'); + }); + + test('uses config.git.auth.method when set to "token"', () => { + const deps = makeDeps({ config: { git: { auth: { method: 'token' } } } }); + const result = resolveAuth(deps, 'github'); + expect(result.method).toBe('token'); + }); +}); + +describe('Auth method from env', () => { + test('uses VERSIONINGS_AUTH_METHOD when config has no method', () => { + const deps = makeDeps({ env: { VERSIONINGS_AUTH_METHOD: 'bearer' } }); + const result = resolveAuth(deps, 'github'); + expect(result.method).toBe('bearer'); + }); +}); + +describe('Auth method default', () => { + test('defaults to "token" when no method is configured', () => { + const deps = makeDeps(); + const result = resolveAuth(deps, 'github'); + expect(result.method).toBe('token'); + }); +}); + +describe('Auth method priority: config > env > default', () => { + test('config.git.auth.method wins over VERSIONINGS_AUTH_METHOD', () => { + const deps = makeDeps({ + config: { git: { auth: { method: 'bearer' } } }, + env: { VERSIONINGS_AUTH_METHOD: 'token' }, + }); + const result = resolveAuth(deps, 'github'); + expect(result.method).toBe('bearer'); + }); + + test('VERSIONINGS_AUTH_METHOD wins over default', () => { + const deps = makeDeps({ env: { VERSIONINGS_AUTH_METHOD: 'bearer' } }); + const result = resolveAuth(deps, 'github'); + expect(result.method).toBe('bearer'); + }); + + test('invalid config method falls through to env', () => { + const deps = makeDeps({ + config: { git: { auth: { method: 'invalid' } } }, + env: { VERSIONINGS_AUTH_METHOD: 'bearer' }, + }); + const result = resolveAuth(deps, 'github'); + expect(result.method).toBe('bearer'); + }); + + test('invalid config and env methods fall through to default', () => { + const deps = makeDeps({ + config: { git: { auth: { method: 'invalid' } } }, + env: { VERSIONINGS_AUTH_METHOD: 'also-invalid' }, + }); + const result = resolveAuth(deps, 'github'); + expect(result.method).toBe('token'); + }); +}); diff --git a/__tests__/unit/scm/azure.provider.test.ts b/__tests__/unit/scm/azure.provider.test.ts new file mode 100644 index 0000000..cdb33ad --- /dev/null +++ b/__tests__/unit/scm/azure.provider.test.ts @@ -0,0 +1,471 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createAzureDevOpsProvider } from '../../../src/scm/providers/azure.provider'; +import type { SCM_ProviderConfig } from '../../../src/scm/scm.provider'; +import type { HttpClient, HttpResponse } from '../../../src/scm/http.client'; +import type { UrlParser, ParsedAzureDevOpsUrl } from '../../../src/scm/url.parser'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeConfig(overrides?: Partial): SCM_ProviderConfig { + return { + platform: 'azure-devops', + url: 'https://dev.azure.com/my-org/my-project/_git/my-repo', + token: 'ado-pat-test123456', + authMethod: 'token', + timeout: 30_000, + ...overrides, + }; +} + +function makeResponse(body: any, status = 200): HttpResponse { + return { status, body, headers: { 'content-type': 'application/json' } }; +} + +function makeMockHttpClient(): HttpClient & { + post: jest.Mock; + get: jest.Mock; + patch: jest.Mock; +} { + return { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + }; +} + +function makeMockUrlParser(parsed?: Partial): UrlParser { + return { + parse: jest.fn().mockReturnValue({ + platform: 'azure-devops', + organization: 'my-org', + project: 'my-project', + repo: 'my-repo', + ...parsed, + } as ParsedAzureDevOpsUrl), + format: jest.fn(), + }; +} + +const PR_RESPONSE = { + pullRequestId: 99, + url: 'https://dev.azure.com/my-org/my-project/_git/my-repo/pullrequest/99', +}; + +// --------------------------------------------------------------------------- +// name() +// --------------------------------------------------------------------------- + +describe('createAzureDevOpsProvider', () => { + describe('name()', () => { + test('returns "azure-devops"', () => { + const provider = createAzureDevOpsProvider(makeConfig(), makeMockHttpClient(), makeMockUrlParser()); + expect(provider.name()).toBe('azure-devops'); + }); + }); + + // --------------------------------------------------------------------------- + // createPullRequest — basic PR creation + // --------------------------------------------------------------------------- + + describe('createPullRequest()', () => { + test('creates a PR via POST /{org}/{project}/_apis/git/repositories/{repo}/pullrequests', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + + const provider = createAzureDevOpsProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'Version patch: 1.0.1', + body: 'Automated release', + sourceBranch: 'version/patch/1.0.1/release', + targetBranch: 'master', + }); + + expect(result.status).toBe('created'); + expect(result.number).toBe(99); + expect(result.url).toBe('https://dev.azure.com/my-org/my-project/_git/my-repo/pullrequest/99'); + expect(result.platform).toBe('azure-devops'); + expect(result.fallbackReason).toBeNull(); + expect(result.warnings).toEqual([]); + + // Verify the POST call + expect(http.post).toHaveBeenCalledWith( + 'https://dev.azure.com/my-org/my-project/_apis/git/repositories/my-repo/pullrequests?api-version=7.0', + { + title: 'Version patch: 1.0.1', + description: 'Automated release', + sourceRefName: 'refs/heads/version/patch/1.0.1/release', + targetRefName: 'refs/heads/master', + }, + expect.objectContaining({ + Authorization: expect.stringContaining('Basic '), + }), + ); + }); + + // --------------------------------------------------------------------------- + // refs/heads/ format + // --------------------------------------------------------------------------- + + test('uses refs/heads/ prefix for sourceRefName and targetRefName', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + + const provider = createAzureDevOpsProvider(makeConfig(), http, makeMockUrlParser()); + await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + const body = http.post.mock.calls[0][1]; + expect(body.sourceRefName).toBe('refs/heads/feat/x'); + expect(body.targetRefName).toBe('refs/heads/main'); + }); + + // --------------------------------------------------------------------------- + // Draft mode + // --------------------------------------------------------------------------- + + test('sends isDraft: true when opts.draft is true', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + + const provider = createAzureDevOpsProvider(makeConfig(), http, makeMockUrlParser()); + await provider.createPullRequest({ + title: 'Draft PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + draft: true, + }); + + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ isDraft: true }), + expect.any(Object), + ); + }); + + test('does not send isDraft field when opts.draft is false/undefined', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + + const provider = createAzureDevOpsProvider(makeConfig(), http, makeMockUrlParser()); + await provider.createPullRequest({ + title: 'Normal PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + const body = http.post.mock.calls[0][1]; + expect(body).not.toHaveProperty('isDraft'); + }); + + // --------------------------------------------------------------------------- + // Reviewers + // --------------------------------------------------------------------------- + + test('passes reviewers as array of { id } in PR body', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + + const provider = createAzureDevOpsProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR with reviewers', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + reviewers: ['user-guid-1', 'user-guid-2'], + }); + + expect(result.warnings).toEqual([]); + expect(http.post).toHaveBeenCalledWith( + expect.stringContaining('/pullrequests?api-version=7.0'), + expect.objectContaining({ + reviewers: [{ id: 'user-guid-1' }, { id: 'user-guid-2' }], + }), + expect.any(Object), + ); + }); + + // --------------------------------------------------------------------------- + // Labels (post-create) + // --------------------------------------------------------------------------- + + test('adds labels via POST .../{pullRequestId}/labels after PR creation', async () => { + const http = makeMockHttpClient(); + http.post + .mockResolvedValueOnce(makeResponse(PR_RESPONSE, 201)) // create PR + .mockResolvedValueOnce(makeResponse({}, 201)) // label 1 + .mockResolvedValueOnce(makeResponse({}, 201)); // label 2 + + const provider = createAzureDevOpsProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR with labels', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + labels: ['release', 'patch'], + }); + + expect(result.warnings).toEqual([]); + expect(http.post).toHaveBeenCalledTimes(3); + expect(http.post).toHaveBeenCalledWith( + 'https://dev.azure.com/my-org/my-project/_apis/git/repositories/my-repo/pullrequests/99/labels?api-version=7.0', + { name: 'release' }, + expect.objectContaining({ Authorization: expect.any(String) }), + ); + expect(http.post).toHaveBeenCalledWith( + 'https://dev.azure.com/my-org/my-project/_apis/git/repositories/my-repo/pullrequests/99/labels?api-version=7.0', + { name: 'patch' }, + expect.objectContaining({ Authorization: expect.any(String) }), + ); + }); + + test('adds warning when label request fails', async () => { + const http = makeMockHttpClient(); + http.post + .mockResolvedValueOnce(makeResponse(PR_RESPONSE, 201)) + .mockRejectedValueOnce(new Error('Label error')); + + const provider = createAzureDevOpsProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + labels: ['bad-label'], + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('Failed to add label'); + }); + + // --------------------------------------------------------------------------- + // Unsupported: milestone + // --------------------------------------------------------------------------- + + test('adds warning for milestone (unsupported)', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + + const provider = createAzureDevOpsProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + milestone: 'v1.0', + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toContainEqual( + expect.stringContaining('milestone'), + ); + }); + + // --------------------------------------------------------------------------- + // Unsupported: linkedIssues + // --------------------------------------------------------------------------- + + test('adds warning for linkedIssues (unsupported)', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + + const provider = createAzureDevOpsProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + linkedIssues: ['#123'], + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toContainEqual( + expect.stringContaining('linkedIssues'), + ); + }); + + // --------------------------------------------------------------------------- + // Extract org/project/repo via UrlParser + // --------------------------------------------------------------------------- + + test('extracts organization/project/repo from git.url via UrlParser', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse({ + pullRequestId: 7, + url: 'https://dev.azure.com/acme/infra/_git/tools/pullrequest/7', + }, 201)); + + const urlParser = makeMockUrlParser({ + organization: 'acme', + project: 'infra', + repo: 'tools', + }); + const config = makeConfig({ url: 'https://dev.azure.com/acme/infra/_git/tools' }); + const provider = createAzureDevOpsProvider(config, http, urlParser); + + await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(urlParser.parse).toHaveBeenCalledWith( + 'https://dev.azure.com/acme/infra/_git/tools', + 'azure-devops', + ); + expect(http.post).toHaveBeenCalledWith( + 'https://dev.azure.com/acme/infra/_apis/git/repositories/tools/pullrequests?api-version=7.0', + expect.any(Object), + expect.any(Object), + ); + }); + + // --------------------------------------------------------------------------- + // Custom apiUrl + // --------------------------------------------------------------------------- + + test('uses custom apiUrl when provided', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse({ + pullRequestId: 3, + url: 'https://ado.corp.com/org/proj/_git/repo/pullrequest/3', + }, 201)); + + const config = makeConfig({ apiUrl: 'https://ado.corp.com' }); + const provider = createAzureDevOpsProvider(config, http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'Custom API PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(result.url).toBe('https://ado.corp.com/org/proj/_git/repo/pullrequest/3'); + expect(http.post).toHaveBeenCalledWith( + 'https://ado.corp.com/my-org/my-project/_apis/git/repositories/my-repo/pullrequests?api-version=7.0', + expect.any(Object), + expect.any(Object), + ); + }); + + // --------------------------------------------------------------------------- + // Auth header: Basic (PAT) vs Bearer + // --------------------------------------------------------------------------- + + test('uses Basic auth with base64-encoded :{token} for PAT (default)', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + + const provider = createAzureDevOpsProvider(makeConfig(), http, makeMockUrlParser()); + await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + const expectedBasic = `Basic ${Buffer.from(':ado-pat-test123456').toString('base64')}`; + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + Authorization: expectedBasic, + }), + ); + }); + + test('uses Bearer auth when authMethod is "bearer"', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + + const config = makeConfig({ authMethod: 'bearer' }); + const provider = createAzureDevOpsProvider(config, http, makeMockUrlParser()); + await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + Authorization: 'Bearer ado-pat-test123456', + }), + ); + }); + + // --------------------------------------------------------------------------- + // All options combined + // --------------------------------------------------------------------------- + + test('handles reviewers + labels + draft in one PR', async () => { + const http = makeMockHttpClient(); + http.post + .mockResolvedValueOnce(makeResponse(PR_RESPONSE, 201)) // create PR + .mockResolvedValueOnce(makeResponse({}, 201)); // label + + const provider = createAzureDevOpsProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'Full PR', + body: 'All options', + sourceBranch: 'feat/x', + targetBranch: 'main', + reviewers: ['guid-1'], + labels: ['release'], + draft: true, + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toEqual([]); + expect(http.post).toHaveBeenCalledTimes(2); + + // Verify PR creation body + expect(http.post).toHaveBeenCalledWith( + expect.stringContaining('/pullrequests?api-version=7.0'), + expect.objectContaining({ + isDraft: true, + reviewers: [{ id: 'guid-1' }], + sourceRefName: 'refs/heads/feat/x', + targetRefName: 'refs/heads/main', + }), + expect.any(Object), + ); + }); + }); + + // --------------------------------------------------------------------------- + // generatePullRequestUrl + // --------------------------------------------------------------------------- + + describe('generatePullRequestUrl()', () => { + test('generates correct URL for Azure DevOps (default apiUrl)', () => { + const provider = createAzureDevOpsProvider(makeConfig(), makeMockHttpClient(), makeMockUrlParser()); + const url = provider.generatePullRequestUrl('version/patch/1.0.1/release', 'master'); + expect(url).toBe( + 'https://dev.azure.com/my-org/my-project/_git/my-repo/pullrequestcreate?sourceRef=version%2Fpatch%2F1.0.1%2Frelease&targetRef=master', + ); + }); + + test('generates correct URL with custom apiUrl', () => { + const config = makeConfig({ apiUrl: 'https://ado.corp.com' }); + const provider = createAzureDevOpsProvider(config, makeMockHttpClient(), makeMockUrlParser()); + const url = provider.generatePullRequestUrl('feat/x', 'develop'); + expect(url).toBe( + 'https://ado.corp.com/my-org/my-project/_git/my-repo/pullrequestcreate?sourceRef=feat%2Fx&targetRef=develop', + ); + }); + }); +}); diff --git a/__tests__/unit/scm/bitbucket.provider.test.ts b/__tests__/unit/scm/bitbucket.provider.test.ts new file mode 100644 index 0000000..573a57d --- /dev/null +++ b/__tests__/unit/scm/bitbucket.provider.test.ts @@ -0,0 +1,672 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createBitbucketCloudProvider, createBitbucketServerProvider } from '../../../src/scm/providers/bitbucket.provider'; +import type { SCM_ProviderConfig } from '../../../src/scm/scm.provider'; +import type { HttpClient, HttpResponse } from '../../../src/scm/http.client'; +import type { UrlParser, ParsedBitbucketCloudUrl, ParsedBitbucketServerUrl } from '../../../src/scm/url.parser'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeCloudConfig(overrides?: Partial): SCM_ProviderConfig { + return { + platform: 'bitbucket', + url: 'https://bitbucket.org/my-workspace/my-repo', + token: 'bb_test_token_123', + authMethod: 'bearer', + timeout: 30_000, + ...overrides, + }; +} + +function makeServerConfig(overrides?: Partial): SCM_ProviderConfig { + return { + platform: 'bitbucket-server', + url: 'https://bitbucket.corp.com/scm/PROJ/my-repo.git', + apiUrl: 'https://bitbucket.corp.com', + token: 'bbs_test_token_456', + authMethod: 'token', + timeout: 30_000, + ...overrides, + }; +} + +function makeResponse(body: any, status = 200): HttpResponse { + return { status, body, headers: { 'content-type': 'application/json' } }; +} + +function makeMockHttpClient(): HttpClient & { + post: jest.Mock; + get: jest.Mock; + patch: jest.Mock; +} { + return { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + }; +} + +function makeMockCloudUrlParser(parsed?: Partial): UrlParser { + return { + parse: jest.fn().mockReturnValue({ + platform: 'bitbucket', + workspace: 'my-workspace', + repoSlug: 'my-repo', + ...parsed, + } as ParsedBitbucketCloudUrl), + format: jest.fn(), + }; +} + +function makeMockServerUrlParser(parsed?: Partial): UrlParser { + return { + parse: jest.fn().mockReturnValue({ + platform: 'bitbucket-server', + projectKey: 'PROJ', + repositorySlug: 'my-repo', + ...parsed, + } as ParsedBitbucketServerUrl), + format: jest.fn(), + }; +} + +const CLOUD_PR_RESPONSE = { + id: 99, + links: { html: { href: 'https://bitbucket.org/my-workspace/my-repo/pull-requests/99' } }, +}; + +const SERVER_PR_RESPONSE = { + id: 55, + links: { self: [{ href: 'https://bitbucket.corp.com/projects/PROJ/repos/my-repo/pull-requests/55' }] }, +}; + +// =========================================================================== +// Bitbucket Cloud Provider +// =========================================================================== + +describe('createBitbucketCloudProvider', () => { + describe('name()', () => { + test('returns "bitbucket"', () => { + const provider = createBitbucketCloudProvider(makeCloudConfig(), makeMockHttpClient(), makeMockCloudUrlParser()); + expect(provider.name()).toBe('bitbucket'); + }); + }); + + // --------------------------------------------------------------------------- + // createPullRequest — basic PR creation + // --------------------------------------------------------------------------- + + describe('createPullRequest()', () => { + test('creates a PR via POST /2.0/repositories/{workspace}/{repo_slug}/pullrequests', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(CLOUD_PR_RESPONSE, 201)); + + const provider = createBitbucketCloudProvider(makeCloudConfig(), http, makeMockCloudUrlParser()); + const result = await provider.createPullRequest({ + title: 'Version patch: 1.0.1', + body: 'Automated release', + sourceBranch: 'version/patch/1.0.1/release', + targetBranch: 'master', + }); + + expect(result.status).toBe('created'); + expect(result.number).toBe(99); + expect(result.url).toBe('https://bitbucket.org/my-workspace/my-repo/pull-requests/99'); + expect(result.platform).toBe('bitbucket'); + expect(result.fallbackReason).toBeNull(); + expect(result.warnings).toEqual([]); + + // Verify the POST call + expect(http.post).toHaveBeenCalledWith( + 'https://api.bitbucket.org/2.0/repositories/my-workspace/my-repo/pullrequests', + { + title: 'Version patch: 1.0.1', + description: 'Automated release', + source: { branch: { name: 'version/patch/1.0.1/release' } }, + destination: { branch: { name: 'master' } }, + }, + expect.objectContaining({ + Authorization: 'Bearer bb_test_token_123', + }), + ); + }); + + // --------------------------------------------------------------------------- + // Reviewers — username → UUID resolution + // --------------------------------------------------------------------------- + + test('resolves reviewer usernames to UUIDs via GET /2.0/users/{username}', async () => { + const http = makeMockHttpClient(); + http.get + .mockResolvedValueOnce(makeResponse({ uuid: '{uuid-alice}' })) + .mockResolvedValueOnce(makeResponse({ uuid: '{uuid-bob}' })); + http.post.mockResolvedValue(makeResponse(CLOUD_PR_RESPONSE, 201)); + + const provider = createBitbucketCloudProvider(makeCloudConfig(), http, makeMockCloudUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR with reviewers', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + reviewers: ['alice', 'bob'], + }); + + expect(result.warnings).toEqual([]); + expect(http.get).toHaveBeenCalledTimes(2); + expect(http.get).toHaveBeenCalledWith( + 'https://api.bitbucket.org/2.0/users/alice', + expect.objectContaining({ Authorization: 'Bearer bb_test_token_123' }), + ); + expect(http.get).toHaveBeenCalledWith( + 'https://api.bitbucket.org/2.0/users/bob', + expect.objectContaining({ Authorization: 'Bearer bb_test_token_123' }), + ); + + // reviewers should be in the PR creation body + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + reviewers: [{ uuid: '{uuid-alice}' }, { uuid: '{uuid-bob}' }], + }), + expect.any(Object), + ); + }); + + test('adds warning when reviewer username cannot be resolved', async () => { + const http = makeMockHttpClient(); + http.get.mockResolvedValueOnce(makeResponse({})); // no uuid field + http.post.mockResolvedValue(makeResponse(CLOUD_PR_RESPONSE, 201)); + + const provider = createBitbucketCloudProvider(makeCloudConfig(), http, makeMockCloudUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + reviewers: ['nonexistent'], + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('nonexistent'); + expect(result.warnings[0]).toContain('not found'); + }); + + test('adds warning when reviewer resolution request fails', async () => { + const http = makeMockHttpClient(); + http.get.mockRejectedValueOnce(new Error('Network error')); + http.post.mockResolvedValue(makeResponse(CLOUD_PR_RESPONSE, 201)); + + const provider = createBitbucketCloudProvider(makeCloudConfig(), http, makeMockCloudUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + reviewers: ['alice'], + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('Failed to add reviewers'); + }); + + // --------------------------------------------------------------------------- + // Draft — warning (not supported) + // --------------------------------------------------------------------------- + + test('adds warning when draft is true (not supported by Cloud)', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(CLOUD_PR_RESPONSE, 201)); + + const provider = createBitbucketCloudProvider(makeCloudConfig(), http, makeMockCloudUrlParser()); + const result = await provider.createPullRequest({ + title: 'Draft PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + draft: true, + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('draft'); + }); + + // --------------------------------------------------------------------------- + // Unsupported params → warnings + // --------------------------------------------------------------------------- + + test('adds warning for labels (unsupported)', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(CLOUD_PR_RESPONSE, 201)); + + const provider = createBitbucketCloudProvider(makeCloudConfig(), http, makeMockCloudUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + labels: ['release', 'patch'], + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toContainEqual(expect.stringContaining('labels')); + }); + + test('adds warning for milestone (unsupported)', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(CLOUD_PR_RESPONSE, 201)); + + const provider = createBitbucketCloudProvider(makeCloudConfig(), http, makeMockCloudUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + milestone: 'v1.0', + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toContainEqual(expect.stringContaining('milestone')); + }); + + test('adds warning for linkedIssues (unsupported)', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(CLOUD_PR_RESPONSE, 201)); + + const provider = createBitbucketCloudProvider(makeCloudConfig(), http, makeMockCloudUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + linkedIssues: ['#123'], + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toContainEqual(expect.stringContaining('linkedIssues')); + }); + + // --------------------------------------------------------------------------- + // Auth header: Bearer + // --------------------------------------------------------------------------- + + test('uses Bearer auth for Cloud', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(CLOUD_PR_RESPONSE, 201)); + + const provider = createBitbucketCloudProvider(makeCloudConfig(), http, makeMockCloudUrlParser()); + await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + Authorization: 'Bearer bb_test_token_123', + }), + ); + }); + + // --------------------------------------------------------------------------- + // All unsupported params combined + // --------------------------------------------------------------------------- + + test('collects multiple warnings for unsupported params', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(CLOUD_PR_RESPONSE, 201)); + + const provider = createBitbucketCloudProvider(makeCloudConfig(), http, makeMockCloudUrlParser()); + const result = await provider.createPullRequest({ + title: 'Full PR', + body: 'All options', + sourceBranch: 'feat/x', + targetBranch: 'main', + draft: true, + labels: ['release'], + milestone: 'v2.0', + linkedIssues: ['#1'], + }); + + expect(result.status).toBe('created'); + // draft + labels + milestone + linkedIssues = 4 warnings + expect(result.warnings).toHaveLength(4); + }); + }); + + // --------------------------------------------------------------------------- + // generatePullRequestUrl — Cloud + // --------------------------------------------------------------------------- + + describe('generatePullRequestUrl()', () => { + test('generates correct URL for Bitbucket Cloud', () => { + const provider = createBitbucketCloudProvider(makeCloudConfig(), makeMockHttpClient(), makeMockCloudUrlParser()); + const url = provider.generatePullRequestUrl('version/patch/1.0.1/release', 'master'); + expect(url).toBe( + 'https://bitbucket.org/my-workspace/my-repo/pull-requests/new?source=version%2Fpatch%2F1.0.1%2Frelease&dest=master&t=1', + ); + }); + }); +}); + + +// =========================================================================== +// Bitbucket Server Provider +// =========================================================================== + +describe('createBitbucketServerProvider', () => { + describe('name()', () => { + test('returns "bitbucket-server"', () => { + const provider = createBitbucketServerProvider(makeServerConfig(), makeMockHttpClient(), makeMockServerUrlParser()); + expect(provider.name()).toBe('bitbucket-server'); + }); + }); + + // --------------------------------------------------------------------------- + // createPullRequest — basic PR creation + // --------------------------------------------------------------------------- + + describe('createPullRequest()', () => { + test('creates a PR via POST /rest/api/1.0/projects/{key}/repos/{slug}/pull-requests', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(SERVER_PR_RESPONSE, 201)); + + const provider = createBitbucketServerProvider(makeServerConfig(), http, makeMockServerUrlParser()); + const result = await provider.createPullRequest({ + title: 'Version patch: 1.0.1', + body: 'Automated release', + sourceBranch: 'version/patch/1.0.1/release', + targetBranch: 'master', + }); + + expect(result.status).toBe('created'); + expect(result.number).toBe(55); + expect(result.url).toBe('https://bitbucket.corp.com/projects/PROJ/repos/my-repo/pull-requests/55'); + expect(result.platform).toBe('bitbucket-server'); + expect(result.fallbackReason).toBeNull(); + expect(result.warnings).toEqual([]); + + // Verify the POST call + expect(http.post).toHaveBeenCalledWith( + 'https://bitbucket.corp.com/rest/api/1.0/projects/PROJ/repos/my-repo/pull-requests', + { + title: 'Version patch: 1.0.1', + description: 'Automated release', + fromRef: { id: 'refs/heads/version/patch/1.0.1/release' }, + toRef: { id: 'refs/heads/master' }, + }, + expect.objectContaining({ + Authorization: 'token bbs_test_token_456', + }), + ); + }); + + // --------------------------------------------------------------------------- + // Reviewers — array of { user: { name } } + // --------------------------------------------------------------------------- + + test('passes reviewers as array of { user: { name } }', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(SERVER_PR_RESPONSE, 201)); + + const provider = createBitbucketServerProvider(makeServerConfig(), http, makeMockServerUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR with reviewers', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + reviewers: ['alice', 'bob'], + }); + + expect(result.warnings).toEqual([]); + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + reviewers: [{ user: { name: 'alice' } }, { user: { name: 'bob' } }], + }), + expect.any(Object), + ); + }); + + // --------------------------------------------------------------------------- + // projectKey/repositorySlug extraction from URL + // --------------------------------------------------------------------------- + + test('extracts projectKey and repositorySlug from URL via UrlParser', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(SERVER_PR_RESPONSE, 201)); + + const urlParser = makeMockServerUrlParser({ + projectKey: 'TEAM', + repositorySlug: 'backend', + }); + const provider = createBitbucketServerProvider(makeServerConfig(), http, urlParser); + await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(urlParser.parse).toHaveBeenCalledWith( + 'https://bitbucket.corp.com/scm/PROJ/my-repo.git', + 'bitbucket-server', + ); + expect(http.post).toHaveBeenCalledWith( + 'https://bitbucket.corp.com/rest/api/1.0/projects/TEAM/repos/backend/pull-requests', + expect.any(Object), + expect.any(Object), + ); + }); + + // --------------------------------------------------------------------------- + // projectKey/repositorySlug from config overrides (git.project, git.repo) + // --------------------------------------------------------------------------- + + test('uses config.project and config.repo when provided', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(SERVER_PR_RESPONSE, 201)); + + const config = makeServerConfig() as any; + config.project = 'OVERRIDE_PROJ'; + config.repo = 'override-repo'; + + const provider = createBitbucketServerProvider(config, http, makeMockServerUrlParser()); + await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(http.post).toHaveBeenCalledWith( + 'https://bitbucket.corp.com/rest/api/1.0/projects/OVERRIDE_PROJ/repos/override-repo/pull-requests', + expect.any(Object), + expect.any(Object), + ); + }); + + // --------------------------------------------------------------------------- + // Draft — warning (not supported) + // --------------------------------------------------------------------------- + + test('adds warning when draft is true (not supported by Server)', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(SERVER_PR_RESPONSE, 201)); + + const provider = createBitbucketServerProvider(makeServerConfig(), http, makeMockServerUrlParser()); + const result = await provider.createPullRequest({ + title: 'Draft PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + draft: true, + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('draft'); + }); + + // --------------------------------------------------------------------------- + // Unsupported params → warnings + // --------------------------------------------------------------------------- + + test('adds warning for labels (unsupported)', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(SERVER_PR_RESPONSE, 201)); + + const provider = createBitbucketServerProvider(makeServerConfig(), http, makeMockServerUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + labels: ['release'], + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toContainEqual(expect.stringContaining('labels')); + }); + + test('adds warning for milestone (unsupported)', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(SERVER_PR_RESPONSE, 201)); + + const provider = createBitbucketServerProvider(makeServerConfig(), http, makeMockServerUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + milestone: 'v1.0', + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toContainEqual(expect.stringContaining('milestone')); + }); + + test('adds warning for linkedIssues (unsupported)', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(SERVER_PR_RESPONSE, 201)); + + const provider = createBitbucketServerProvider(makeServerConfig(), http, makeMockServerUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + linkedIssues: ['#123'], + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toContainEqual(expect.stringContaining('linkedIssues')); + }); + + // --------------------------------------------------------------------------- + // Auth header: token vs Bearer + // --------------------------------------------------------------------------- + + test('uses "token" auth when authMethod is "token"', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(SERVER_PR_RESPONSE, 201)); + + const provider = createBitbucketServerProvider(makeServerConfig(), http, makeMockServerUrlParser()); + await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + Authorization: 'token bbs_test_token_456', + }), + ); + }); + + test('uses Bearer auth when authMethod is "bearer"', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(SERVER_PR_RESPONSE, 201)); + + const config = makeServerConfig({ authMethod: 'bearer' }); + const provider = createBitbucketServerProvider(config, http, makeMockServerUrlParser()); + await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + Authorization: 'Bearer bbs_test_token_456', + }), + ); + }); + + // --------------------------------------------------------------------------- + // All unsupported params combined + // --------------------------------------------------------------------------- + + test('collects multiple warnings for unsupported params', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(SERVER_PR_RESPONSE, 201)); + + const provider = createBitbucketServerProvider(makeServerConfig(), http, makeMockServerUrlParser()); + const result = await provider.createPullRequest({ + title: 'Full PR', + body: 'All options', + sourceBranch: 'feat/x', + targetBranch: 'main', + draft: true, + labels: ['release'], + milestone: 'v2.0', + linkedIssues: ['#1'], + }); + + expect(result.status).toBe('created'); + // draft + labels + milestone + linkedIssues = 4 warnings + expect(result.warnings).toHaveLength(4); + }); + }); + + // --------------------------------------------------------------------------- + // generatePullRequestUrl — Server + // --------------------------------------------------------------------------- + + describe('generatePullRequestUrl()', () => { + test('generates correct URL for Bitbucket Server', () => { + const provider = createBitbucketServerProvider(makeServerConfig(), makeMockHttpClient(), makeMockServerUrlParser()); + const url = provider.generatePullRequestUrl('version/patch/1.0.1/release', 'master'); + expect(url).toBe( + 'https://bitbucket.corp.com/projects/PROJ/repos/my-repo/pull-requests?create&sourceBranch=version%2Fpatch%2F1.0.1%2Frelease&targetBranch=master', + ); + }); + + test('uses config.project and config.repo overrides in URL', () => { + const config = makeServerConfig() as any; + config.project = 'CUSTOM'; + config.repo = 'custom-repo'; + + const provider = createBitbucketServerProvider(config, makeMockHttpClient(), makeMockServerUrlParser()); + const url = provider.generatePullRequestUrl('feat/x', 'develop'); + expect(url).toBe( + 'https://bitbucket.corp.com/projects/CUSTOM/repos/custom-repo/pull-requests?create&sourceBranch=feat%2Fx&targetBranch=develop', + ); + }); + }); +}); diff --git a/__tests__/unit/scm/github.provider.test.ts b/__tests__/unit/scm/github.provider.test.ts new file mode 100644 index 0000000..a35bb3a --- /dev/null +++ b/__tests__/unit/scm/github.provider.test.ts @@ -0,0 +1,504 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createGitHubProvider } from '../../../src/scm/providers/github.provider'; +import type { SCM_ProviderConfig } from '../../../src/scm/scm.provider'; +import type { HttpClient, HttpResponse } from '../../../src/scm/http.client'; +import type { UrlParser } from '../../../src/scm/url.parser'; +import type { ParsedGitHubUrl } from '../../../src/scm/url.parser'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeConfig(overrides?: Partial): SCM_ProviderConfig { + return { + platform: 'github', + url: 'https://github.com/octocat/hello-world', + token: 'ghp_test123456', + authMethod: 'token', + timeout: 30_000, + ...overrides, + }; +} + +function makeResponse(body: any, status = 200): HttpResponse { + return { status, body, headers: { 'content-type': 'application/json' } }; +} + +function makeMockHttpClient(): HttpClient & { + post: jest.Mock; + get: jest.Mock; + patch: jest.Mock; +} { + return { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + }; +} + +function makeMockUrlParser(parsed?: Partial): UrlParser { + return { + parse: jest.fn().mockReturnValue({ + platform: 'github', + owner: 'octocat', + repo: 'hello-world', + ...parsed, + } as ParsedGitHubUrl), + format: jest.fn(), + }; +} + +const PR_RESPONSE = { + number: 42, + html_url: 'https://github.com/octocat/hello-world/pull/42', +}; + +// --------------------------------------------------------------------------- +// name() +// --------------------------------------------------------------------------- + +describe('createGitHubProvider', () => { + describe('name()', () => { + test('returns "github" for cloud', () => { + const provider = createGitHubProvider(makeConfig(), makeMockHttpClient(), makeMockUrlParser()); + expect(provider.name()).toBe('github'); + }); + + test('returns "github-enterprise" for enterprise', () => { + const config = makeConfig({ platform: 'github-enterprise', apiUrl: 'https://ghe.corp.com/api/v3' }); + const provider = createGitHubProvider(config, makeMockHttpClient(), makeMockUrlParser()); + expect(provider.name()).toBe('github-enterprise'); + }); + }); + + // --------------------------------------------------------------------------- + // createPullRequest — basic PR creation + // --------------------------------------------------------------------------- + + describe('createPullRequest()', () => { + test('creates a PR via POST /repos/{owner}/{repo}/pulls', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + + const provider = createGitHubProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'Version patch: 1.0.1', + body: 'Automated release', + sourceBranch: 'version/patch/1.0.1/release', + targetBranch: 'master', + }); + + expect(result.status).toBe('created'); + expect(result.number).toBe(42); + expect(result.url).toBe('https://github.com/octocat/hello-world/pull/42'); + expect(result.platform).toBe('github'); + expect(result.fallbackReason).toBeNull(); + expect(result.warnings).toEqual([]); + + // Verify the POST call + expect(http.post).toHaveBeenCalledWith( + 'https://api.github.com/repos/octocat/hello-world/pulls', + { + title: 'Version patch: 1.0.1', + body: 'Automated release', + head: 'version/patch/1.0.1/release', + base: 'master', + }, + expect.objectContaining({ + Accept: 'application/vnd.github+json', + Authorization: 'token ghp_test123456', + }), + ); + }); + + // --------------------------------------------------------------------------- + // Draft mode + // --------------------------------------------------------------------------- + + test('sends draft: true when opts.draft is true', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + + const provider = createGitHubProvider(makeConfig(), http, makeMockUrlParser()); + await provider.createPullRequest({ + title: 'Draft PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + draft: true, + }); + + expect(http.post).toHaveBeenCalledWith( + 'https://api.github.com/repos/octocat/hello-world/pulls', + expect.objectContaining({ draft: true }), + expect.any(Object), + ); + }); + + test('does not send draft field when opts.draft is false/undefined', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + + const provider = createGitHubProvider(makeConfig(), http, makeMockUrlParser()); + await provider.createPullRequest({ + title: 'Normal PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + const body = http.post.mock.calls[0][1]; + expect(body).not.toHaveProperty('draft'); + }); + + // --------------------------------------------------------------------------- + // Reviewers + // --------------------------------------------------------------------------- + + test('adds reviewers via POST /pulls/{n}/requested_reviewers', async () => { + const http = makeMockHttpClient(); + http.post + .mockResolvedValueOnce(makeResponse(PR_RESPONSE, 201)) // create PR + .mockResolvedValueOnce(makeResponse({}, 201)); // add reviewers + + const provider = createGitHubProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR with reviewers', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + reviewers: ['alice', 'bob'], + }); + + expect(result.warnings).toEqual([]); + expect(http.post).toHaveBeenCalledTimes(2); + expect(http.post).toHaveBeenCalledWith( + 'https://api.github.com/repos/octocat/hello-world/pulls/42/requested_reviewers', + { reviewers: ['alice', 'bob'] }, + expect.objectContaining({ Accept: 'application/vnd.github+json' }), + ); + }); + + test('adds warning when reviewers request fails', async () => { + const http = makeMockHttpClient(); + http.post + .mockResolvedValueOnce(makeResponse(PR_RESPONSE, 201)) + .mockRejectedValueOnce(new Error('Reviewer not found')); + + const provider = createGitHubProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + reviewers: ['nonexistent'], + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('Failed to add reviewers'); + }); + + // --------------------------------------------------------------------------- + // Labels + // --------------------------------------------------------------------------- + + test('adds labels via POST /issues/{n}/labels', async () => { + const http = makeMockHttpClient(); + http.post + .mockResolvedValueOnce(makeResponse(PR_RESPONSE, 201)) // create PR + .mockResolvedValueOnce(makeResponse({}, 200)); // add labels + + const provider = createGitHubProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR with labels', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + labels: ['release', 'patch'], + }); + + expect(result.warnings).toEqual([]); + expect(http.post).toHaveBeenCalledWith( + 'https://api.github.com/repos/octocat/hello-world/issues/42/labels', + { labels: ['release', 'patch'] }, + expect.objectContaining({ Accept: 'application/vnd.github+json' }), + ); + }); + + test('adds warning when labels request fails', async () => { + const http = makeMockHttpClient(); + http.post + .mockResolvedValueOnce(makeResponse(PR_RESPONSE, 201)) + .mockRejectedValueOnce(new Error('Label error')); + + const provider = createGitHubProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + labels: ['bug'], + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('Failed to add labels'); + }); + + // --------------------------------------------------------------------------- + // Milestone + // --------------------------------------------------------------------------- + + test('sets milestone via PATCH /issues/{n}', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + http.patch.mockResolvedValue(makeResponse({}, 200)); + + const provider = createGitHubProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR with milestone', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + milestone: 'v1.0', + }); + + expect(result.warnings).toEqual([]); + expect(http.patch).toHaveBeenCalledWith( + 'https://api.github.com/repos/octocat/hello-world/issues/42', + { milestone: 'v1.0' }, + expect.objectContaining({ Accept: 'application/vnd.github+json' }), + ); + }); + + test('adds warning when milestone request fails', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + http.patch.mockRejectedValue(new Error('Milestone not found')); + + const provider = createGitHubProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + milestone: 'nonexistent', + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('Failed to set milestone'); + }); + + // --------------------------------------------------------------------------- + // Unsupported: linkedIssues + // --------------------------------------------------------------------------- + + test('adds warning for linkedIssues (unsupported)', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + + const provider = createGitHubProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + linkedIssues: ['#123', '#456'], + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toContainEqual( + expect.stringContaining('linkedIssues'), + ); + }); + + // --------------------------------------------------------------------------- + // GitHub Enterprise — custom apiUrl + // --------------------------------------------------------------------------- + + test('uses custom apiUrl for GitHub Enterprise', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse({ + number: 10, + html_url: 'https://ghe.corp.com/octocat/hello-world/pull/10', + }, 201)); + + const config = makeConfig({ + platform: 'github-enterprise', + apiUrl: 'https://ghe.corp.com/api/v3', + }); + const provider = createGitHubProvider(config, http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'Enterprise PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(result.platform).toBe('github-enterprise'); + expect(http.post).toHaveBeenCalledWith( + 'https://ghe.corp.com/api/v3/repos/octocat/hello-world/pulls', + expect.any(Object), + expect.any(Object), + ); + }); + + // --------------------------------------------------------------------------- + // Auth header: Bearer vs token + // --------------------------------------------------------------------------- + + test('uses Bearer auth when authMethod is "bearer"', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + + const config = makeConfig({ authMethod: 'bearer' }); + const provider = createGitHubProvider(config, http, makeMockUrlParser()); + await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + Authorization: 'Bearer ghp_test123456', + }), + ); + }); + + // --------------------------------------------------------------------------- + // Accept header + // --------------------------------------------------------------------------- + + test('sets Accept: application/vnd.github+json header', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(PR_RESPONSE, 201)); + + const provider = createGitHubProvider(makeConfig(), http, makeMockUrlParser()); + await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + Accept: 'application/vnd.github+json', + }), + ); + }); + + // --------------------------------------------------------------------------- + // owner/repo extraction via UrlParser + // --------------------------------------------------------------------------- + + test('extracts owner/repo from git.url via UrlParser', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse({ + number: 7, + html_url: 'https://github.com/my-org/my-repo/pull/7', + }, 201)); + + const urlParser = makeMockUrlParser({ owner: 'my-org', repo: 'my-repo' }); + const config = makeConfig({ url: 'git@github.com:my-org/my-repo.git' }); + const provider = createGitHubProvider(config, http, urlParser); + + await provider.createPullRequest({ + title: 'PR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(urlParser.parse).toHaveBeenCalledWith( + 'git@github.com:my-org/my-repo.git', + 'github', + ); + expect(http.post).toHaveBeenCalledWith( + 'https://api.github.com/repos/my-org/my-repo/pulls', + expect.any(Object), + expect.any(Object), + ); + }); + + // --------------------------------------------------------------------------- + // All post-creation steps combined + // --------------------------------------------------------------------------- + + test('handles reviewers + labels + milestone in one PR', async () => { + const http = makeMockHttpClient(); + http.post + .mockResolvedValueOnce(makeResponse(PR_RESPONSE, 201)) // create PR + .mockResolvedValueOnce(makeResponse({}, 201)) // reviewers + .mockResolvedValueOnce(makeResponse({}, 200)); // labels + http.patch.mockResolvedValue(makeResponse({}, 200)); // milestone + + const provider = createGitHubProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'Full PR', + body: 'All options', + sourceBranch: 'feat/x', + targetBranch: 'main', + reviewers: ['alice'], + labels: ['release'], + milestone: 'v2.0', + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toEqual([]); + expect(http.post).toHaveBeenCalledTimes(3); + expect(http.patch).toHaveBeenCalledTimes(1); + }); + }); + + // --------------------------------------------------------------------------- + // generatePullRequestUrl + // --------------------------------------------------------------------------- + + describe('generatePullRequestUrl()', () => { + test('generates correct URL for GitHub Cloud (HTTPS)', () => { + const config = makeConfig({ url: 'https://github.com/octocat/hello-world' }); + const provider = createGitHubProvider(config, makeMockHttpClient(), makeMockUrlParser()); + const url = provider.generatePullRequestUrl('version/patch/1.0.1/release', 'master'); + expect(url).toBe( + 'https://github.com/octocat/hello-world/compare/master...version/patch/1.0.1/release?expand=1', + ); + }); + + test('generates correct URL for GitHub Cloud (HTTPS with .git)', () => { + const config = makeConfig({ url: 'https://github.com/octocat/hello-world.git' }); + const provider = createGitHubProvider(config, makeMockHttpClient(), makeMockUrlParser()); + const url = provider.generatePullRequestUrl('feat/x', 'main'); + expect(url).toBe( + 'https://github.com/octocat/hello-world/compare/main...feat/x?expand=1', + ); + }); + + test('generates correct URL for GitHub Enterprise (SSH url)', () => { + const config = makeConfig({ + platform: 'github-enterprise', + url: 'git@ghe.corp.com:team/project.git', + apiUrl: 'https://ghe.corp.com/api/v3', + }); + const provider = createGitHubProvider(config, makeMockHttpClient(), makeMockUrlParser({ + platform: 'github-enterprise', + owner: 'team', + repo: 'project', + })); + const url = provider.generatePullRequestUrl('feat/x', 'develop'); + expect(url).toBe( + 'https://ghe.corp.com/team/project/compare/develop...feat/x?expand=1', + ); + }); + }); +}); diff --git a/__tests__/unit/scm/gitlab.provider.test.ts b/__tests__/unit/scm/gitlab.provider.test.ts new file mode 100644 index 0000000..6ff1d75 --- /dev/null +++ b/__tests__/unit/scm/gitlab.provider.test.ts @@ -0,0 +1,539 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createGitLabProvider } from '../../../src/scm/providers/gitlab.provider'; +import type { SCM_ProviderConfig } from '../../../src/scm/scm.provider'; +import type { HttpClient, HttpResponse } from '../../../src/scm/http.client'; +import type { UrlParser, ParsedGitLabUrl } from '../../../src/scm/url.parser'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeConfig(overrides?: Partial): SCM_ProviderConfig { + return { + platform: 'gitlab', + url: 'https://gitlab.com/my-group/my-project', + token: 'glpat-test123456', + authMethod: 'token', + timeout: 30_000, + ...overrides, + }; +} + +function makeResponse(body: any, status = 200): HttpResponse { + return { status, body, headers: { 'content-type': 'application/json' } }; +} + +function makeMockHttpClient(): HttpClient & { + post: jest.Mock; + get: jest.Mock; + patch: jest.Mock; +} { + return { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + }; +} + +function makeMockUrlParser(parsed?: Partial): UrlParser { + return { + parse: jest.fn().mockReturnValue({ + platform: 'gitlab', + namespacePath: 'my-group', + project: 'my-project', + ...parsed, + } as ParsedGitLabUrl), + format: jest.fn(), + }; +} + +const MR_RESPONSE = { + iid: 17, + web_url: 'https://gitlab.com/my-group/my-project/-/merge_requests/17', +}; + +// --------------------------------------------------------------------------- +// name() +// --------------------------------------------------------------------------- + +describe('createGitLabProvider', () => { + describe('name()', () => { + test('returns "gitlab"', () => { + const provider = createGitLabProvider(makeConfig(), makeMockHttpClient(), makeMockUrlParser()); + expect(provider.name()).toBe('gitlab'); + }); + }); + + // --------------------------------------------------------------------------- + // createPullRequest — basic MR creation + // --------------------------------------------------------------------------- + + describe('createPullRequest()', () => { + test('creates an MR via POST /api/v4/projects/{id}/merge_requests', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const provider = createGitLabProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'Version patch: 1.0.1', + body: 'Automated release', + sourceBranch: 'version/patch/1.0.1/release', + targetBranch: 'master', + }); + + expect(result.status).toBe('created'); + expect(result.number).toBe(17); + expect(result.url).toBe('https://gitlab.com/my-group/my-project/-/merge_requests/17'); + expect(result.platform).toBe('gitlab'); + expect(result.fallbackReason).toBeNull(); + expect(result.warnings).toEqual([]); + + // Verify the POST call — project_id is URL-encoded + expect(http.post).toHaveBeenCalledWith( + 'https://gitlab.com/api/v4/projects/my-group%2Fmy-project/merge_requests', + { + title: 'Version patch: 1.0.1', + description: 'Automated release', + source_branch: 'version/patch/1.0.1/release', + target_branch: 'master', + }, + expect.objectContaining({ + 'PRIVATE-TOKEN': 'glpat-test123456', + }), + ); + }); + + // --------------------------------------------------------------------------- + // project_id extraction — URL-encoded path + // --------------------------------------------------------------------------- + + test('URL-encodes project_id from namespacePath/project', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const urlParser = makeMockUrlParser({ + namespacePath: 'org/team', + project: 'repo', + }); + const provider = createGitLabProvider(makeConfig(), http, urlParser); + await provider.createPullRequest({ + title: 'MR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(http.post).toHaveBeenCalledWith( + 'https://gitlab.com/api/v4/projects/org%2Fteam%2Frepo/merge_requests', + expect.any(Object), + expect.any(Object), + ); + }); + + // --------------------------------------------------------------------------- + // Draft mode — prefix "Draft: " to title + // --------------------------------------------------------------------------- + + test('prefixes "Draft: " to title when opts.draft is true', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const provider = createGitLabProvider(makeConfig(), http, makeMockUrlParser()); + await provider.createPullRequest({ + title: 'My MR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + draft: true, + }); + + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ title: 'Draft: My MR' }), + expect.any(Object), + ); + }); + + test('does not prefix title when draft is false/undefined', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const provider = createGitLabProvider(makeConfig(), http, makeMockUrlParser()); + await provider.createPullRequest({ + title: 'Normal MR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + const body = http.post.mock.calls[0][1]; + expect(body.title).toBe('Normal MR'); + }); + + // --------------------------------------------------------------------------- + // Reviewers — username → ID resolution + // --------------------------------------------------------------------------- + + test('resolves reviewer usernames to IDs via GET /api/v4/users', async () => { + const http = makeMockHttpClient(); + http.get + .mockResolvedValueOnce(makeResponse([{ id: 101 }])) // alice + .mockResolvedValueOnce(makeResponse([{ id: 202 }])); // bob + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const provider = createGitLabProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'MR with reviewers', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + reviewers: ['alice', 'bob'], + }); + + expect(result.warnings).toEqual([]); + expect(http.get).toHaveBeenCalledTimes(2); + expect(http.get).toHaveBeenCalledWith( + 'https://gitlab.com/api/v4/users?username=alice', + expect.objectContaining({ 'PRIVATE-TOKEN': 'glpat-test123456' }), + ); + expect(http.get).toHaveBeenCalledWith( + 'https://gitlab.com/api/v4/users?username=bob', + expect.objectContaining({ 'PRIVATE-TOKEN': 'glpat-test123456' }), + ); + + // reviewer_ids should be in the MR creation body + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ reviewer_ids: [101, 202] }), + expect.any(Object), + ); + }); + + test('adds warning when reviewer username cannot be resolved', async () => { + const http = makeMockHttpClient(); + http.get.mockResolvedValueOnce(makeResponse([])); // empty result + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const provider = createGitLabProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'MR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + reviewers: ['nonexistent'], + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('nonexistent'); + expect(result.warnings[0]).toContain('not found'); + }); + + test('adds warning when reviewer resolution request fails', async () => { + const http = makeMockHttpClient(); + http.get.mockRejectedValueOnce(new Error('Network error')); + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const provider = createGitLabProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'MR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + reviewers: ['alice'], + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('Failed to add reviewers'); + }); + + // --------------------------------------------------------------------------- + // Labels — comma-separated string + // --------------------------------------------------------------------------- + + test('passes labels as comma-separated string in MR body', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const provider = createGitLabProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'MR with labels', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + labels: ['release', 'patch', 'automated'], + }); + + expect(result.warnings).toEqual([]); + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ labels: 'release,patch,automated' }), + expect.any(Object), + ); + }); + + // --------------------------------------------------------------------------- + // Milestone — name → ID resolution + // --------------------------------------------------------------------------- + + test('resolves milestone name to ID via GET /api/v4/projects/{id}/milestones', async () => { + const http = makeMockHttpClient(); + http.get.mockResolvedValueOnce(makeResponse([{ id: 55 }])); // milestone + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const provider = createGitLabProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'MR with milestone', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + milestone: 'v1.0', + }); + + expect(result.warnings).toEqual([]); + expect(http.get).toHaveBeenCalledWith( + 'https://gitlab.com/api/v4/projects/my-group%2Fmy-project/milestones?title=v1.0', + expect.objectContaining({ 'PRIVATE-TOKEN': 'glpat-test123456' }), + ); + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ milestone_id: 55 }), + expect.any(Object), + ); + }); + + test('adds warning when milestone cannot be resolved', async () => { + const http = makeMockHttpClient(); + http.get.mockResolvedValueOnce(makeResponse([])); // empty + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const provider = createGitLabProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'MR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + milestone: 'nonexistent', + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('nonexistent'); + expect(result.warnings[0]).toContain('not found'); + }); + + test('adds warning when milestone resolution request fails', async () => { + const http = makeMockHttpClient(); + http.get.mockRejectedValueOnce(new Error('API error')); + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const provider = createGitLabProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'MR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + milestone: 'v2.0', + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('Failed to set milestone'); + }); + + // --------------------------------------------------------------------------- + // Unsupported: linkedIssues + // --------------------------------------------------------------------------- + + test('adds warning for linkedIssues (unsupported)', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const provider = createGitLabProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'MR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + linkedIssues: ['#123', '#456'], + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toContainEqual( + expect.stringContaining('linkedIssues'), + ); + }); + + // --------------------------------------------------------------------------- + // Self-hosted — custom apiUrl + // --------------------------------------------------------------------------- + + test('uses custom apiUrl for self-hosted GitLab', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse({ + iid: 5, + web_url: 'https://git.corp.com/team/project/-/merge_requests/5', + }, 201)); + + const config = makeConfig({ + apiUrl: 'https://git.corp.com', + }); + const provider = createGitLabProvider(config, http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'Self-hosted MR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(result.url).toBe('https://git.corp.com/team/project/-/merge_requests/5'); + expect(http.post).toHaveBeenCalledWith( + 'https://git.corp.com/api/v4/projects/my-group%2Fmy-project/merge_requests', + expect.any(Object), + expect.any(Object), + ); + }); + + // --------------------------------------------------------------------------- + // Auth header: Bearer vs PRIVATE-TOKEN + // --------------------------------------------------------------------------- + + test('uses PRIVATE-TOKEN header for token auth method', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const provider = createGitLabProvider(makeConfig(), http, makeMockUrlParser()); + await provider.createPullRequest({ + title: 'MR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + 'PRIVATE-TOKEN': 'glpat-test123456', + }), + ); + }); + + test('uses Bearer auth when authMethod is "bearer"', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const config = makeConfig({ authMethod: 'bearer' }); + const provider = createGitLabProvider(config, http, makeMockUrlParser()); + await provider.createPullRequest({ + title: 'MR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + Authorization: 'Bearer glpat-test123456', + }), + ); + }); + + // --------------------------------------------------------------------------- + // All options combined + // --------------------------------------------------------------------------- + + test('handles reviewers + labels + milestone + draft in one MR', async () => { + const http = makeMockHttpClient(); + http.get + .mockResolvedValueOnce(makeResponse([{ id: 10 }])) // reviewer + .mockResolvedValueOnce(makeResponse([{ id: 99 }])); // milestone + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const provider = createGitLabProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'Full MR', + body: 'All options', + sourceBranch: 'feat/x', + targetBranch: 'main', + reviewers: ['alice'], + labels: ['release'], + milestone: 'v2.0', + draft: true, + }); + + expect(result.status).toBe('created'); + expect(result.warnings).toEqual([]); + expect(http.get).toHaveBeenCalledTimes(2); + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + title: 'Draft: Full MR', + reviewer_ids: [10], + labels: 'release', + milestone_id: 99, + }), + expect.any(Object), + ); + }); + + // --------------------------------------------------------------------------- + // Term "Merge Request" — platform field + // --------------------------------------------------------------------------- + + test('returns platform "gitlab" in PR_Result', async () => { + const http = makeMockHttpClient(); + http.post.mockResolvedValue(makeResponse(MR_RESPONSE, 201)); + + const provider = createGitLabProvider(makeConfig(), http, makeMockUrlParser()); + const result = await provider.createPullRequest({ + title: 'MR', + body: '', + sourceBranch: 'feat/x', + targetBranch: 'main', + }); + + expect(result.platform).toBe('gitlab'); + }); + }); + + // --------------------------------------------------------------------------- + // generatePullRequestUrl + // --------------------------------------------------------------------------- + + describe('generatePullRequestUrl()', () => { + test('generates correct URL for GitLab Cloud', () => { + const provider = createGitLabProvider(makeConfig(), makeMockHttpClient(), makeMockUrlParser()); + const url = provider.generatePullRequestUrl('version/patch/1.0.1/release', 'master'); + expect(url).toBe( + 'https://gitlab.com/my-group/my-project/-/merge_requests/new?merge_request[source_branch]=version%2Fpatch%2F1.0.1%2Frelease&merge_request[target_branch]=master', + ); + }); + + test('generates correct URL for self-hosted GitLab', () => { + const config = makeConfig({ apiUrl: 'https://git.corp.com' }); + const provider = createGitLabProvider(config, makeMockHttpClient(), makeMockUrlParser()); + const url = provider.generatePullRequestUrl('feat/x', 'develop'); + expect(url).toBe( + 'https://git.corp.com/my-group/my-project/-/merge_requests/new?merge_request[source_branch]=feat%2Fx&merge_request[target_branch]=develop', + ); + }); + + test('generates correct URL with nested namespace', () => { + const urlParser = makeMockUrlParser({ + namespacePath: 'org/team/sub', + project: 'repo', + }); + const provider = createGitLabProvider(makeConfig(), makeMockHttpClient(), urlParser); + const url = provider.generatePullRequestUrl('feat/x', 'main'); + expect(url).toBe( + 'https://gitlab.com/org/team/sub/repo/-/merge_requests/new?merge_request[source_branch]=feat%2Fx&merge_request[target_branch]=main', + ); + }); + }); +}); diff --git a/__tests__/unit/scm/http.client.test.ts b/__tests__/unit/scm/http.client.test.ts new file mode 100644 index 0000000..27ef9cb --- /dev/null +++ b/__tests__/unit/scm/http.client.test.ts @@ -0,0 +1,324 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createHttpClient, type HttpClient, type FetchFn } from '../../../src/scm/http.client'; +import { VersioningsError, EXIT_CODES } from '../../../src/core/errors'; + +/** + * Helper: builds a minimal Response-like object accepted by the http client. + */ +function mockResponse( + status: number, + body: any, + contentType = 'application/json', +): Response { + const headers = new Headers({ 'content-type': contentType }); + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + headers, + } as unknown as Response; +} + +// --- Successful requests --- + +describe('Successful GET', () => { + test('returns status, parsed JSON body, and headers', async () => { + const payload = { id: 1, name: 'test' }; + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockResolvedValueOnce(mockResponse(200, payload)); + + const client = createHttpClient(mockFetch as unknown as FetchFn); + const res = await client.get('https://api.example.com/resource'); + + expect(res.status).toBe(200); + expect(res.body).toEqual(payload); + expect(res.headers['content-type']).toBe('application/json'); + + // Verify fetch was called with GET method + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.example.com/resource'); + expect(init?.method).toBe('GET'); + }); +}); + +describe('Successful POST', () => { + test('sends JSON body and returns parsed response', async () => { + const reqBody = { title: 'New PR', body: 'Description' }; + const resPayload = { id: 42, html_url: 'https://github.com/pr/42' }; + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockResolvedValueOnce(mockResponse(201, resPayload)); + + const client = createHttpClient(mockFetch as unknown as FetchFn); + const res = await client.post('https://api.example.com/pulls', reqBody); + + expect(res.status).toBe(201); + expect(res.body).toEqual(resPayload); + + const [, init] = mockFetch.mock.calls[0]; + expect(init?.method).toBe('POST'); + expect(JSON.parse(init?.body as string)).toEqual(reqBody); + // Content-Type should be set for POST + expect((init?.headers as Record)['Content-Type']).toBe('application/json'); + }); +}); + +describe('Successful PATCH', () => { + test('sends JSON body and returns parsed response', async () => { + const reqBody = { milestone: 5 }; + const resPayload = { id: 1, milestone: { number: 5 } }; + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockResolvedValueOnce(mockResponse(200, resPayload)); + + const client = createHttpClient(mockFetch as unknown as FetchFn); + const res = await client.patch('https://api.example.com/issues/1', reqBody); + + expect(res.status).toBe(200); + expect(res.body).toEqual(resPayload); + + const [, init] = mockFetch.mock.calls[0]; + expect(init?.method).toBe('PATCH'); + expect((init?.headers as Record)['Content-Type']).toBe('application/json'); + }); +}); + + +// --- HTTP error status codes --- + +describe('HTTP 401 → CONFIG_ERROR', () => { + test('throws VersioningsError with CONFIG_ERROR code', async () => { + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockResolvedValueOnce(mockResponse(401, { message: 'Bad credentials' })); + + const client = createHttpClient(mockFetch as unknown as FetchFn); + + try { + await client.get('https://api.example.com/resource'); + fail('Expected VersioningsError'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + expect((err as VersioningsError).message).toContain('token'); + } + }); +}); + +describe('HTTP 403 → CONFIG_ERROR', () => { + test('throws VersioningsError with CONFIG_ERROR code', async () => { + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockResolvedValueOnce(mockResponse(403, { message: 'Forbidden' })); + + const client = createHttpClient(mockFetch as unknown as FetchFn); + + try { + await client.get('https://api.example.com/resource'); + fail('Expected VersioningsError'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + expect((err as VersioningsError).message).toContain('token'); + } + }); +}); + +describe('HTTP 404 → CONFIG_ERROR', () => { + test('throws VersioningsError with CONFIG_ERROR and apiUrl hint', async () => { + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockResolvedValueOnce(mockResponse(404, { message: 'Not Found' })); + + const client = createHttpClient(mockFetch as unknown as FetchFn); + + try { + await client.get('https://api.example.com/resource'); + fail('Expected VersioningsError'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + expect((err as VersioningsError).message).toContain('apiUrl'); + } + }); +}); + +// --- Network errors --- + +describe('Network error → NETWORK_ERROR', () => { + test('wraps DNS failure (TypeError) into VersioningsError(NETWORK_ERROR)', async () => { + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockRejectedValueOnce(new TypeError('fetch failed')); + + const client = createHttpClient(mockFetch as unknown as FetchFn); + + try { + await client.get('https://api.example.com/resource'); + fail('Expected VersioningsError'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.NETWORK_ERROR); + expect((err as VersioningsError).message).toContain('Network error'); + expect((err as VersioningsError).message).toContain('fetch failed'); + } + }); +}); + +// --- Timeout --- + +describe('Timeout', () => { + test('aborts request when fetch takes longer than configured timeout', async () => { + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockImplementation((_url, init) => { + return new Promise((resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + if (signal) { + signal.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + } + // Never resolves on its own — relies on abort + }); + }); + + // Very short timeout to trigger abort quickly + const client = createHttpClient(mockFetch as unknown as FetchFn, { timeout: 50 }); + + try { + await client.get('https://api.example.com/slow'); + fail('Expected VersioningsError'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.NETWORK_ERROR); + } + }); +}); + +// --- User-Agent header --- + +describe('User-Agent header', () => { + test('sets User-Agent header on GET requests', async () => { + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockResolvedValueOnce(mockResponse(200, {})); + + const client = createHttpClient(mockFetch as unknown as FetchFn); + await client.get('https://api.example.com/resource'); + + const [, init] = mockFetch.mock.calls[0]; + const headers = init?.headers as Record; + expect(headers['User-Agent']).toMatch(/^versionings\//); + }); + + test('sets User-Agent header on POST requests', async () => { + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockResolvedValueOnce(mockResponse(201, {})); + + const client = createHttpClient(mockFetch as unknown as FetchFn); + await client.post('https://api.example.com/resource', { data: 1 }); + + const [, init] = mockFetch.mock.calls[0]; + const headers = init?.headers as Record; + expect(headers['User-Agent']).toMatch(/^versionings\//); + }); + + test('sets User-Agent header on PATCH requests', async () => { + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockResolvedValueOnce(mockResponse(200, {})); + + const client = createHttpClient(mockFetch as unknown as FetchFn); + await client.patch('https://api.example.com/resource', { data: 1 }); + + const [, init] = mockFetch.mock.calls[0]; + const headers = init?.headers as Record; + expect(headers['User-Agent']).toMatch(/^versionings\//); + }); + + test('uses custom userAgent when provided', async () => { + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockResolvedValueOnce(mockResponse(200, {})); + + const client = createHttpClient(mockFetch as unknown as FetchFn, { userAgent: 'custom-agent/2.0' }); + await client.get('https://api.example.com/resource'); + + const [, init] = mockFetch.mock.calls[0]; + const headers = init?.headers as Record; + expect(headers['User-Agent']).toBe('custom-agent/2.0'); + }); +}); + +// --- Custom timeout --- + +describe('Custom timeout', () => { + test('respects custom timeout value', async () => { + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockImplementation((_url, init) => { + return new Promise((resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + if (signal) { + signal.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + } + // Resolve after 200ms — should succeed with 500ms timeout, fail with 50ms + }); + }); + + // With a very short timeout, the request should abort + const client = createHttpClient(mockFetch as unknown as FetchFn, { timeout: 50 }); + + try { + await client.get('https://api.example.com/resource'); + fail('Expected VersioningsError'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.NETWORK_ERROR); + } + }); + + test('succeeds when response arrives before custom timeout', async () => { + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockResolvedValueOnce(mockResponse(200, { ok: true })); + + // Generous timeout — response is immediate + const client = createHttpClient(mockFetch as unknown as FetchFn, { timeout: 5000 }); + const res = await client.get('https://api.example.com/resource'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + }); +}); + +// --- Content-Type for POST/PATCH --- + +describe('Content-Type: application/json for POST and PATCH', () => { + test('POST sets Content-Type: application/json', async () => { + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockResolvedValueOnce(mockResponse(201, {})); + + const client = createHttpClient(mockFetch as unknown as FetchFn); + await client.post('https://api.example.com/resource', { key: 'value' }); + + const [, init] = mockFetch.mock.calls[0]; + expect((init?.headers as Record)['Content-Type']).toBe('application/json'); + }); + + test('PATCH sets Content-Type: application/json', async () => { + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockResolvedValueOnce(mockResponse(200, {})); + + const client = createHttpClient(mockFetch as unknown as FetchFn); + await client.patch('https://api.example.com/resource', { key: 'value' }); + + const [, init] = mockFetch.mock.calls[0]; + expect((init?.headers as Record)['Content-Type']).toBe('application/json'); + }); + + test('GET does not set Content-Type', async () => { + const mockFetch = jest.fn, [RequestInfo | URL, RequestInit?]>() + .mockResolvedValueOnce(mockResponse(200, {})); + + const client = createHttpClient(mockFetch as unknown as FetchFn); + await client.get('https://api.example.com/resource'); + + const [, init] = mockFetch.mock.calls[0]; + expect((init?.headers as Record)['Content-Type']).toBeUndefined(); + }); +}); diff --git a/__tests__/unit/scm/pr.creator.test.ts b/__tests__/unit/scm/pr.creator.test.ts new file mode 100644 index 0000000..a307402 --- /dev/null +++ b/__tests__/unit/scm/pr.creator.test.ts @@ -0,0 +1,535 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createPR, type PrCreatorDeps, type PrMode } from '../../../src/scm/pr.creator'; +import type { PR_Options, PR_Result, SCM_Provider, SCM_ProviderConfig } from '../../../src/scm/scm.provider'; +import type { SCM_Registry } from '../../../src/scm/scm.registry'; +import type { HttpClient } from '../../../src/scm/http.client'; +import type { UrlParser } from '../../../src/scm/url.parser'; +import type { AuthResult } from '../../../src/scm/auth.resolver'; +import { VersioningsError, EXIT_CODES } from '../../../src/core/errors'; +import type { VersioningsConfig } from '../../../src/config/config.validator'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const MOCK_PR_URL = 'https://github.com/owner/repo/compare/main...feature?expand=1'; + +const MOCK_PR_RESULT: PR_Result = { + url: 'https://github.com/owner/repo/pull/42', + number: 42, + status: 'created', + fallbackReason: null, + platform: 'github', + warnings: [], +}; + +function makeConfig(overrides: Record = {}): VersioningsConfig { + const base: any = { + git: { + platform: 'github', + url: 'https://github.com/owner/repo', + pr: { target: 'main' }, + api: { timeout: 30000 }, + branchType: { version: 'version' }, + limits: { branchMaxCommentLength: 96 }, + remote: 'origin', + commit: { + message: { + semver: { + prepatch: '', patch: '', preminor: '', minor: '', + premajor: '', major: '', prerelease: '', + } + } + }, + ...overrides.git, + }, + package: { + semver: { + patch: 'patch', prepatch: 'prepatch', minor: 'minor', + preminor: 'preminor', premajor: 'premajor', prerelease: 'prerelease', major: 'major', + } + }, + common: { + messages: { + versionConfigDoesNotExist: '', undefinedGitRepositoryUrl: '', + unavailableVersioningDirectory: '', unavailableSemanticVersion: '', + undefinedVersionBranchName: '', incorrectVersionBranchNameLength: '', + incorrectVersionBranchNameCharactersDashes: '', versionBranchAlreadyExists: '', + untrackedGitFiles: '', unavailableGitPlatform: '', + unavailableGitTargetBranch: '', versionAlreadyExists: '', + versionAlreadyExistsTag: '', versionAlreadyExistsBranch: '', + incorrectGitRemote: '', + } + }, + }; + return base; +} + +function makeMockProvider(overrides: Partial = {}): SCM_Provider { + return { + name: () => 'github', + createPullRequest: jest.fn().mockResolvedValue(MOCK_PR_RESULT), + generatePullRequestUrl: jest.fn().mockReturnValue(MOCK_PR_URL), + ...overrides, + }; +} + +function makeDeps(overrides: Partial = {}): PrCreatorDeps { + const mockProvider = makeMockProvider(); + const registry: SCM_Registry = { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }; + + return { + registry, + httpClient: {} as HttpClient, + urlParser: {} as UrlParser, + resolveAuth: jest.fn().mockReturnValue({ token: 'ghp_test123', method: 'token' as const }), + env: {}, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// prMode = 'auto' with token → API success (Req 4.1, 4.2) +// --------------------------------------------------------------------------- + +describe('prMode=auto with token (API success)', () => { + test('calls provider.createPullRequest and returns created result', async () => { + const deps = makeDeps(); + const config = makeConfig(); + + const result = await createPR(config, 'feature/v1.2.3', 'Patch: v1.2.3', 'auto', deps); + + expect(result.status).toBe('created'); + expect(result.url).toBe('https://github.com/owner/repo/pull/42'); + expect(result.number).toBe(42); + expect(result.platform).toBe('github'); + }); + + test('resolves auth with correct platform', async () => { + const deps = makeDeps(); + const config = makeConfig(); + + await createPR(config, 'feature/v1.2.3', 'Patch: v1.2.3', 'auto', deps); + + expect(deps.resolveAuth).toHaveBeenCalledWith( + expect.objectContaining({ config: expect.any(Object), env: deps.env }), + 'github', + ); + }); +}); + +// --------------------------------------------------------------------------- +// prMode = 'auto' without token → fallback (Req 4.4, 7.1) +// --------------------------------------------------------------------------- + +describe('prMode=auto without token (fallback)', () => { + test('returns fallback result with reason no_token', async () => { + const deps = makeDeps({ + resolveAuth: jest.fn().mockReturnValue({ token: null, method: 'token' }), + }); + const config = makeConfig(); + + const result = await createPR(config, 'feature/v1.2.3', 'Patch: v1.2.3', 'auto', deps); + + expect(result.status).toBe('fallback'); + expect(result.fallbackReason).toBe('no_token'); + expect(result.url).toBe(MOCK_PR_URL); + expect(result.number).toBeNull(); + }); + + test('does not call provider.createPullRequest', async () => { + const mockProvider = makeMockProvider(); + const deps = makeDeps({ + resolveAuth: jest.fn().mockReturnValue({ token: null, method: 'token' }), + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + }); + const config = makeConfig(); + + await createPR(config, 'feature/v1.2.3', 'Patch: v1.2.3', 'auto', deps); + + expect(mockProvider.createPullRequest).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// prMode = 'auto' with API error → fallback (Req 4.3, 7.2) +// --------------------------------------------------------------------------- + +describe('prMode=auto with API error (fallback)', () => { + test('returns fallback result with error reason', async () => { + const mockProvider = makeMockProvider({ + createPullRequest: jest.fn().mockRejectedValue(new Error('API rate limit exceeded')), + }); + const deps = makeDeps({ + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + }); + const config = makeConfig(); + + const result = await createPR(config, 'feature/v1.2.3', 'Patch: v1.2.3', 'auto', deps); + + expect(result.status).toBe('fallback'); + expect(result.fallbackReason).toBe('API rate limit exceeded'); + expect(result.url).toBe(MOCK_PR_URL); + expect(result.number).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// prMode = 'api' without token → VersioningsError (Req 7.6) +// --------------------------------------------------------------------------- + +describe('prMode=api without token (VersioningsError)', () => { + test('throws VersioningsError with CONFIG_ERROR', async () => { + const deps = makeDeps({ + resolveAuth: jest.fn().mockReturnValue({ token: null, method: 'token' }), + }); + const config = makeConfig(); + + await expect( + createPR(config, 'feature/v1.2.3', 'Patch: v1.2.3', 'api', deps), + ).rejects.toThrow(VersioningsError); + + try { + await createPR(config, 'feature/v1.2.3', 'Patch: v1.2.3', 'api', deps); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + expect((err as VersioningsError).message).toContain('authentication token'); + } + }); +}); + +// --------------------------------------------------------------------------- +// prMode = 'api' with API error → VersioningsError (Req 7.6) +// --------------------------------------------------------------------------- + +describe('prMode=api with API error (rethrows)', () => { + test('rethrows the API error', async () => { + const apiError = new VersioningsError(EXIT_CODES.NETWORK_ERROR, 'Connection refused'); + const mockProvider = makeMockProvider({ + createPullRequest: jest.fn().mockRejectedValue(apiError), + }); + const deps = makeDeps({ + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + }); + const config = makeConfig(); + + await expect( + createPR(config, 'feature/v1.2.3', 'Patch: v1.2.3', 'api', deps), + ).rejects.toThrow(apiError); + }); +}); + +// --------------------------------------------------------------------------- +// prMode = 'url' → URL only, no API (Req 7.7) +// --------------------------------------------------------------------------- + +describe('prMode=url (URL only)', () => { + test('returns fallback result with URL, no API call', async () => { + const mockProvider = makeMockProvider(); + const deps = makeDeps({ + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + }); + const config = makeConfig(); + + const result = await createPR(config, 'feature/v1.2.3', 'Patch: v1.2.3', 'url', deps); + + expect(result.status).toBe('fallback'); + expect(result.url).toBe(MOCK_PR_URL); + expect(result.number).toBeNull(); + expect(mockProvider.createPullRequest).not.toHaveBeenCalled(); + }); + + test('does not call resolveAuth', async () => { + const deps = makeDeps(); + const config = makeConfig(); + + await createPR(config, 'feature/v1.2.3', 'Patch: v1.2.3', 'url', deps); + + expect(deps.resolveAuth).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Reading template file (Req 4.1, 5.8) +// --------------------------------------------------------------------------- + +describe('Reading template file', () => { + test('reads template file and passes body to provider', async () => { + const mockProvider = makeMockProvider(); + const readFile = jest.fn().mockReturnValue('## PR Template\nDescription here'); + const deps = makeDeps({ + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + readFile, + }); + const config = makeConfig({ + git: { + platform: 'github', + url: 'https://github.com/owner/repo', + pr: { target: 'main', template: '.github/PULL_REQUEST_TEMPLATE.md' }, + api: { timeout: 30000 }, + }, + }); + + await createPR(config, 'feature/v1.2.3', 'Patch: v1.2.3', 'auto', deps); + + expect(readFile).toHaveBeenCalledWith('.github/PULL_REQUEST_TEMPLATE.md'); + expect(mockProvider.createPullRequest).toHaveBeenCalledWith( + expect.objectContaining({ body: '## PR Template\nDescription here' }), + ); + }); + + test('uses empty body when template file read fails', async () => { + const mockProvider = makeMockProvider(); + const readFile = jest.fn().mockImplementation(() => { throw new Error('ENOENT'); }); + const deps = makeDeps({ + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + readFile, + }); + const config = makeConfig({ + git: { + platform: 'github', + url: 'https://github.com/owner/repo', + pr: { target: 'main', template: 'nonexistent.md' }, + api: { timeout: 30000 }, + }, + }); + + await createPR(config, 'feature/v1.2.3', 'Patch: v1.2.3', 'auto', deps); + + expect(mockProvider.createPullRequest).toHaveBeenCalledWith( + expect.objectContaining({ body: '' }), + ); + }); + + test('uses empty body when no template is configured', async () => { + const mockProvider = makeMockProvider(); + const deps = makeDeps({ + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + }); + const config = makeConfig(); + + await createPR(config, 'feature/v1.2.3', 'Patch: v1.2.3', 'auto', deps); + + expect(mockProvider.createPullRequest).toHaveBeenCalledWith( + expect.objectContaining({ body: '' }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Building PR_Options from config (Req 4.1, 5.1–5.6) +// --------------------------------------------------------------------------- + +describe('Building PR_Options from config', () => { + test('builds PR_Options with all extended fields', async () => { + const mockProvider = makeMockProvider(); + const deps = makeDeps({ + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + }); + const config = makeConfig({ + git: { + platform: 'github', + url: 'https://github.com/owner/repo', + pr: { + target: 'develop', + reviewers: ['alice', 'bob'], + labels: ['release', 'automated'], + draft: true, + milestone: 'v1.3', + linkedIssues: ['#100', '#101'], + }, + api: { timeout: 30000 }, + }, + }); + + await createPR(config, 'feature/v1.2.3', 'Minor: v1.2.3', 'auto', deps); + + expect(mockProvider.createPullRequest).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Minor: v1.2.3', + sourceBranch: 'feature/v1.2.3', + targetBranch: 'develop', + reviewers: ['alice', 'bob'], + labels: ['release', 'automated'], + draft: true, + milestone: 'v1.3', + linkedIssues: ['#100', '#101'], + }), + ); + }); + + test('uses commit message as title', async () => { + const mockProvider = makeMockProvider(); + const deps = makeDeps({ + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + }); + const config = makeConfig(); + + await createPR(config, 'feature/v1.2.3', 'Release: v1.2.3', 'auto', deps); + + expect(mockProvider.createPullRequest).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Release: v1.2.3' }), + ); + }); + + test('defaults targetBranch to config git.pr.target', async () => { + const mockProvider = makeMockProvider(); + const deps = makeDeps({ + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + }); + const config = makeConfig(); + + await createPR(config, 'feature/v1.2.3', 'Patch: v1.2.3', 'auto', deps); + + expect(mockProvider.createPullRequest).toHaveBeenCalledWith( + expect.objectContaining({ targetBranch: 'main' }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Changelog body support (Req 9.3, 9.4) +// --------------------------------------------------------------------------- + +describe('Changelog body in PR', () => { + test('uses changelogBody as body when no template is configured', async () => { + const mockProvider = makeMockProvider(); + const deps = makeDeps({ + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + changelogBody: '## Features\n- add login', + }); + const config = makeConfig(); + + await createPR(config, 'feature/v1.2.3', 'Minor: v1.2.3', 'auto', deps); + + expect(mockProvider.createPullRequest).toHaveBeenCalledWith( + expect.objectContaining({ body: '## Features\n- add login' }), + ); + }); + + test('merges template and changelogBody with --- separator', async () => { + const mockProvider = makeMockProvider(); + const readFile = jest.fn().mockReturnValue('## PR Template\nPlease review'); + const deps = makeDeps({ + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + readFile, + changelogBody: '## Features\n- add login', + }); + const config = makeConfig({ + git: { + platform: 'github', + url: 'https://github.com/owner/repo', + pr: { target: 'main', template: '.github/PULL_REQUEST_TEMPLATE.md' }, + api: { timeout: 30000 }, + }, + }); + + await createPR(config, 'feature/v1.2.3', 'Minor: v1.2.3', 'auto', deps); + + expect(mockProvider.createPullRequest).toHaveBeenCalledWith( + expect.objectContaining({ + body: '## PR Template\nPlease review\n\n---\n\n## Features\n- add login', + }), + ); + }); + + test('uses only template when changelogBody is not provided', async () => { + const mockProvider = makeMockProvider(); + const readFile = jest.fn().mockReturnValue('## PR Template\nPlease review'); + const deps = makeDeps({ + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + readFile, + }); + const config = makeConfig({ + git: { + platform: 'github', + url: 'https://github.com/owner/repo', + pr: { target: 'main', template: '.github/PULL_REQUEST_TEMPLATE.md' }, + api: { timeout: 30000 }, + }, + }); + + await createPR(config, 'feature/v1.2.3', 'Minor: v1.2.3', 'auto', deps); + + expect(mockProvider.createPullRequest).toHaveBeenCalledWith( + expect.objectContaining({ body: '## PR Template\nPlease review' }), + ); + }); + + test('uses empty body when neither template nor changelogBody is provided', async () => { + const mockProvider = makeMockProvider(); + const deps = makeDeps({ + registry: { + register: jest.fn(), + getProvider: jest.fn().mockReturnValue(mockProvider), + availablePlatforms: jest.fn().mockReturnValue(['github']), + }, + }); + const config = makeConfig(); + + await createPR(config, 'feature/v1.2.3', 'Minor: v1.2.3', 'auto', deps); + + expect(mockProvider.createPullRequest).toHaveBeenCalledWith( + expect.objectContaining({ body: '' }), + ); + }); +}); diff --git a/__tests__/unit/scm/scm.registry.test.ts b/__tests__/unit/scm/scm.registry.test.ts new file mode 100644 index 0000000..cafe16e --- /dev/null +++ b/__tests__/unit/scm/scm.registry.test.ts @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createSCMRegistry, type ProviderFactory } from '../../../src/scm/scm.registry'; +import type { SCM_Provider, SCM_ProviderConfig } from '../../../src/scm/scm.provider'; +import type { HttpClient } from '../../../src/scm/http.client'; +import type { UrlParser } from '../../../src/scm/url.parser'; +import { VersioningsError, EXIT_CODES } from '../../../src/core/errors'; + +// --------------------------------------------------------------------------- +// Minimal mocks for HttpClient and UrlParser (not exercised by registry logic) +// --------------------------------------------------------------------------- + +const mockHttpClient: HttpClient = { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), +}; + +const mockUrlParser: UrlParser = { + parse: jest.fn(), + format: jest.fn(), +}; + +// --------------------------------------------------------------------------- +// Helper: builds a minimal SCM_ProviderConfig for a given platform +// --------------------------------------------------------------------------- + +function makeConfig(platform: string): SCM_ProviderConfig { + return { + platform, + url: `https://example.com/${platform}/repo`, + token: 'test-token', + authMethod: 'token', + timeout: 30_000, + }; +} + +// --------------------------------------------------------------------------- +// All 6 default platforms +// --------------------------------------------------------------------------- + +const ALL_PLATFORMS = [ + 'github', + 'github-enterprise', + 'bitbucket', + 'bitbucket-server', + 'gitlab', + 'azure-devops', +]; + +// --------------------------------------------------------------------------- +// getProvider returns a provider for each registered platform +// --------------------------------------------------------------------------- + +describe('getProvider for each platform', () => { + const registry = createSCMRegistry(); + + test.each(ALL_PLATFORMS)('returns a provider for platform "%s"', (platform) => { + const config = makeConfig(platform); + const provider = registry.getProvider(config, mockHttpClient, mockUrlParser); + expect(provider).toBeDefined(); + expect(typeof provider.name).toBe('function'); + expect(typeof provider.createPullRequest).toBe('function'); + expect(typeof provider.generatePullRequestUrl).toBe('function'); + }); +}); + +// --------------------------------------------------------------------------- +// name() matches the platform string +// --------------------------------------------------------------------------- + +describe('name() matches platform', () => { + const registry = createSCMRegistry(); + + test.each(ALL_PLATFORMS)('provider.name() === "%s"', (platform) => { + const config = makeConfig(platform); + const provider = registry.getProvider(config, mockHttpClient, mockUrlParser); + expect(provider.name()).toBe(platform); + }); +}); + +// --------------------------------------------------------------------------- +// Unknown platform → VersioningsError(CONFIG_ERROR) with available platforms +// --------------------------------------------------------------------------- + +describe('unknown platform throws VersioningsError', () => { + const registry = createSCMRegistry(); + + test('throws VersioningsError with CONFIG_ERROR code', () => { + const config = makeConfig('unknown-platform'); + expect(() => registry.getProvider(config, mockHttpClient, mockUrlParser)).toThrow( + VersioningsError, + ); + }); + + test('error code is CONFIG_ERROR', () => { + const config = makeConfig('unknown-platform'); + try { + registry.getProvider(config, mockHttpClient, mockUrlParser); + fail('Expected VersioningsError'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }); + + test('error message lists all available platforms', () => { + const config = makeConfig('nonexistent'); + try { + registry.getProvider(config, mockHttpClient, mockUrlParser); + fail('Expected VersioningsError'); + } catch (err) { + const message = (err as VersioningsError).message; + for (const p of ALL_PLATFORMS) { + expect(message).toContain(p); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// availablePlatforms() returns all 6 default platforms +// --------------------------------------------------------------------------- + +describe('availablePlatforms()', () => { + test('returns all 6 default platforms', () => { + const registry = createSCMRegistry(); + const platforms = registry.availablePlatforms(); + expect(platforms).toHaveLength(6); + for (const p of ALL_PLATFORMS) { + expect(platforms).toContain(p); + } + }); +}); + +// --------------------------------------------------------------------------- +// register() allows adding and overriding providers +// --------------------------------------------------------------------------- + +describe('register custom provider', () => { + test('registers a new platform and getProvider returns it', () => { + const registry = createSCMRegistry(); + + const customProvider: SCM_Provider = { + name: () => 'custom-scm', + createPullRequest: jest.fn(), + generatePullRequestUrl: () => 'https://custom.example.com/pr', + }; + + const factory: ProviderFactory = () => customProvider; + registry.register('custom-scm', factory); + + const config = makeConfig('custom-scm'); + const provider = registry.getProvider(config, mockHttpClient, mockUrlParser); + expect(provider.name()).toBe('custom-scm'); + }); + + test('registered platform appears in availablePlatforms()', () => { + const registry = createSCMRegistry(); + registry.register('custom-scm', () => ({ + name: () => 'custom-scm', + createPullRequest: jest.fn(), + generatePullRequestUrl: () => '', + })); + expect(registry.availablePlatforms()).toContain('custom-scm'); + }); + + test('overrides an existing platform factory', () => { + const registry = createSCMRegistry(); + + const overriddenProvider: SCM_Provider = { + name: () => 'github', + createPullRequest: jest.fn(), + generatePullRequestUrl: () => 'https://overridden.example.com/pr', + }; + + registry.register('github', () => overriddenProvider); + + const config = makeConfig('github'); + const provider = registry.getProvider(config, mockHttpClient, mockUrlParser); + expect(provider.generatePullRequestUrl('feat', 'main')).toBe( + 'https://overridden.example.com/pr', + ); + }); +}); diff --git a/__tests__/unit/scm/template.renderer.test.ts b/__tests__/unit/scm/template.renderer.test.ts new file mode 100644 index 0000000..f20fb07 --- /dev/null +++ b/__tests__/unit/scm/template.renderer.test.ts @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { parseTemplate, formatTemplate, renderTemplate, TEMPLATE_VARIABLES, INVALID_BRANCH_CHARS } from '../../../src/scm/template.renderer'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; +import type { TemplateContext } from '../../../src/scm/template.renderer'; + +const defaultContext: TemplateContext = { + version: '1.2.3', + major: '1', + minor: '2', + patch: '3', + semver: 'patch', + comment: 'fix-login', + branchType: 'version', +}; + +describe('parseTemplate', () => { + it('parses template with literals and variables', () => { + const parts = parseTemplate('release/{version}'); + expect(parts).toEqual([ + { type: 'literal', value: 'release/' }, + { type: 'variable', value: 'version' }, + ]); + }); + + it('parses template without variables (static branch)', () => { + const parts = parseTemplate('static-branch'); + expect(parts).toEqual([ + { type: 'literal', value: 'static-branch' }, + ]); + }); + + it('parses template only from variables', () => { + const parts = parseTemplate('{semver}/{version}'); + expect(parts).toEqual([ + { type: 'variable', value: 'semver' }, + { type: 'literal', value: '/' }, + { type: 'variable', value: 'version' }, + ]); + }); + + it('parses complex template with multiple variables and literals', () => { + const parts = parseTemplate('{branchType}/{semver}/{version}/{comment}'); + expect(parts).toEqual([ + { type: 'variable', value: 'branchType' }, + { type: 'literal', value: '/' }, + { type: 'variable', value: 'semver' }, + { type: 'literal', value: '/' }, + { type: 'variable', value: 'version' }, + { type: 'literal', value: '/' }, + { type: 'variable', value: 'comment' }, + ]); + }); + + it('throws VersioningsError with CONFIG_ERROR for unknown variable', () => { + try { + parseTemplate('{unknown}'); + fail('Expected VersioningsError to be thrown'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + expect((err as VersioningsError).message).toContain('unknown'); + expect((err as VersioningsError).message).toContain('{version}'); + } + }); + + it('throws VersioningsError for unknown variable mixed with valid ones', () => { + try { + parseTemplate('release/{version}/{foo}'); + fail('Expected VersioningsError to be thrown'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + expect((err as VersioningsError).message).toContain('foo'); + } + }); +}); + +describe('formatTemplate', () => { + it('formats parts back to template string', () => { + const parts = [ + { type: 'literal' as const, value: 'release/' }, + { type: 'variable' as const, value: 'version' }, + ]; + expect(formatTemplate(parts)).toBe('release/{version}'); + }); + + it('formats literal-only parts', () => { + const parts = [{ type: 'literal' as const, value: 'static-branch' }]; + expect(formatTemplate(parts)).toBe('static-branch'); + }); + + it('formats variable-only parts', () => { + const parts = [ + { type: 'variable' as const, value: 'semver' }, + { type: 'literal' as const, value: '/' }, + { type: 'variable' as const, value: 'version' }, + ]; + expect(formatTemplate(parts)).toBe('{semver}/{version}'); + }); +}); + +describe('round-trip: formatTemplate(parseTemplate(template))', () => { + const templates = [ + 'release/{version}', + '{branchType}/{semver}/{version}/{comment}', + 'static-branch', + '{semver}/{version}', + 'v{version}', + '{version}--{comment}', + 'hotfix/{version}', + 'support/{major}.{minor}', + ]; + + it.each(templates)('round-trip preserves template: %s', (template) => { + expect(formatTemplate(parseTemplate(template))).toBe(template); + }); +}); + +describe('renderTemplate', () => { + it('substitutes all variables correctly', () => { + const result = renderTemplate('{branchType}/{semver}/{version}/{comment}', defaultContext); + expect(result).toBe('version/patch/1.2.3/fix-login'); + }); + + it('renders template with single variable', () => { + const result = renderTemplate('v{version}', defaultContext); + expect(result).toBe('v1.2.3'); + }); + + it('renders tag template with version and comment', () => { + const result = renderTemplate('{version}--{comment}', defaultContext); + expect(result).toBe('1.2.3--fix-login'); + }); + + it('renders support branch with major and minor', () => { + const result = renderTemplate('support/{major}.{minor}', defaultContext); + expect(result).toBe('support/1.2'); + }); + + it('trims leading / and -', () => { + const result = renderTemplate('/{version}', defaultContext); + expect(result).toBe('1.2.3'); + }); + + it('trims trailing / and -', () => { + const ctx: TemplateContext = { ...defaultContext, comment: '' }; + const result = renderTemplate('{version}/{comment}', ctx); + // After substitution: "1.2.3/" → trimmed to "1.2.3" + expect(result).toBe('1.2.3'); + }); + + it('trims leading and trailing - characters', () => { + const result = renderTemplate('-{version}-', defaultContext); + expect(result).toBe('1.2.3'); + }); + + it('renders empty comment correctly (trailing separator trimmed)', () => { + const ctx: TemplateContext = { ...defaultContext, comment: '' }; + const result = renderTemplate('{version}--{comment}', ctx); + // After substitution: "1.2.3--" → trimmed trailing "-" → "1.2.3" + expect(result).toBe('1.2.3'); + }); + + it('renders template without variables', () => { + const result = renderTemplate('static-branch', defaultContext); + expect(result).toBe('static-branch'); + }); + + it('renders template only from variables', () => { + const result = renderTemplate('{semver}/{version}', defaultContext); + expect(result).toBe('patch/1.2.3'); + }); + + it('throws VersioningsError with INVALID_ARGS for invalid characters in result', () => { + const ctx: TemplateContext = { ...defaultContext, comment: 'has space' }; + try { + renderTemplate('{version}/{comment}', ctx); + fail('Expected VersioningsError to be thrown'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.INVALID_ARGS); + expect((err as VersioningsError).message).toContain('invalid'); + } + }); + + it('throws VersioningsError with INVALID_ARGS for tilde in result', () => { + const ctx: TemplateContext = { ...defaultContext, comment: 'bad~ref' }; + try { + renderTemplate('{version}/{comment}', ctx); + fail('Expected VersioningsError to be thrown'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.INVALID_ARGS); + } + }); + + it('throws VersioningsError with INVALID_ARGS for caret in result', () => { + const ctx: TemplateContext = { ...defaultContext, comment: 'bad^ref' }; + try { + renderTemplate('{version}/{comment}', ctx); + fail('Expected VersioningsError to be thrown'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.INVALID_ARGS); + } + }); +}); + +describe('TEMPLATE_VARIABLES', () => { + it('contains all expected variables', () => { + expect(TEMPLATE_VARIABLES).toEqual( + expect.arrayContaining(['version', 'major', 'minor', 'patch', 'semver', 'comment', 'branchType']), + ); + }); + + it('has exactly 7 variables', () => { + expect(TEMPLATE_VARIABLES).toHaveLength(7); + }); +}); + +describe('INVALID_BRANCH_CHARS', () => { + it('matches space', () => { + expect(INVALID_BRANCH_CHARS.test(' ')).toBe(true); + }); + + it('matches tilde', () => { + expect(INVALID_BRANCH_CHARS.test('~')).toBe(true); + }); + + it('matches caret', () => { + expect(INVALID_BRANCH_CHARS.test('^')).toBe(true); + }); + + it('matches colon', () => { + expect(INVALID_BRANCH_CHARS.test(':')).toBe(true); + }); + + it('does not match valid branch characters', () => { + expect(INVALID_BRANCH_CHARS.test('a')).toBe(false); + expect(INVALID_BRANCH_CHARS.test('1')).toBe(false); + expect(INVALID_BRANCH_CHARS.test('-')).toBe(false); + expect(INVALID_BRANCH_CHARS.test('/')).toBe(false); + expect(INVALID_BRANCH_CHARS.test('.')).toBe(false); + }); +}); diff --git a/__tests__/unit/scm/url.parser.test.ts b/__tests__/unit/scm/url.parser.test.ts new file mode 100644 index 0000000..113b2b4 --- /dev/null +++ b/__tests__/unit/scm/url.parser.test.ts @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { createUrlParser, type UrlParser, type ParsedUrl } from '../../../src/scm/url.parser'; +import { VersioningsError, EXIT_CODES } from '../../../src/core/errors'; + +let parser: UrlParser; + +beforeEach(() => { + parser = createUrlParser(); +}); + +// --- GitHub (cloud) --- + +describe('GitHub HTTPS', () => { + test('parses https://github.com/owner/repo', () => { + const result = parser.parse('https://github.com/owner/repo', 'github'); + expect(result).toEqual({ platform: 'github', owner: 'owner', repo: 'repo' }); + }); + + test('parses https://github.com/owner/repo.git (strips .git)', () => { + const result = parser.parse('https://github.com/owner/repo.git', 'github'); + expect(result).toEqual({ platform: 'github', owner: 'owner', repo: 'repo' }); + }); + + test('parses URL with trailing slash', () => { + const result = parser.parse('https://github.com/owner/repo/', 'github'); + expect(result).toEqual({ platform: 'github', owner: 'owner', repo: 'repo' }); + }); +}); + +describe('GitHub SSH', () => { + test('parses git@github.com:owner/repo.git', () => { + const result = parser.parse('git@github.com:owner/repo.git', 'github'); + expect(result).toEqual({ platform: 'github', owner: 'owner', repo: 'repo' }); + }); + + test('parses ssh://git@github.com/owner/repo.git', () => { + const result = parser.parse('ssh://git@github.com/owner/repo.git', 'github'); + expect(result).toEqual({ platform: 'github', owner: 'owner', repo: 'repo' }); + }); +}); + +// --- GitHub Enterprise --- + +describe('GitHub Enterprise (self-hosted)', () => { + test('parses HTTPS with custom domain', () => { + const result = parser.parse('https://github.mycompany.com/team/project', 'github-enterprise'); + expect(result).toEqual({ platform: 'github-enterprise', owner: 'team', repo: 'project' }); + }); + + test('parses SSH with custom domain', () => { + const result = parser.parse('git@github.mycompany.com:team/project.git', 'github-enterprise'); + expect(result).toEqual({ platform: 'github-enterprise', owner: 'team', repo: 'project' }); + }); +}); + + +// --- GitLab --- + +describe('GitLab', () => { + test('parses simple namespace https://gitlab.com/owner/project', () => { + const result = parser.parse('https://gitlab.com/owner/project', 'gitlab'); + expect(result).toEqual({ platform: 'gitlab', namespacePath: 'owner', project: 'project' }); + }); + + test('parses nested groups https://gitlab.com/group/subgroup/project', () => { + const result = parser.parse('https://gitlab.com/group/subgroup/project', 'gitlab'); + expect(result).toEqual({ platform: 'gitlab', namespacePath: 'group/subgroup', project: 'project' }); + }); + + test('parses deeply nested groups', () => { + const result = parser.parse('https://gitlab.com/a/b/c/d/project', 'gitlab'); + expect(result).toEqual({ platform: 'gitlab', namespacePath: 'a/b/c/d', project: 'project' }); + }); + + test('parses SSH format git@gitlab.com:owner/project.git', () => { + const result = parser.parse('git@gitlab.com:owner/project.git', 'gitlab'); + expect(result).toEqual({ platform: 'gitlab', namespacePath: 'owner', project: 'project' }); + }); +}); + +// --- Bitbucket Cloud --- + +describe('Bitbucket Cloud', () => { + test('parses https://bitbucket.org/workspace/repo', () => { + const result = parser.parse('https://bitbucket.org/workspace/repo', 'bitbucket'); + expect(result).toEqual({ platform: 'bitbucket', workspace: 'workspace', repoSlug: 'repo' }); + }); + + test('parses SSH format git@bitbucket.org:workspace/repo.git', () => { + const result = parser.parse('git@bitbucket.org:workspace/repo.git', 'bitbucket'); + expect(result).toEqual({ platform: 'bitbucket', workspace: 'workspace', repoSlug: 'repo' }); + }); +}); + +// --- Bitbucket Server --- + +describe('Bitbucket Server', () => { + test('parses HTTPS with /scm/ prefix: https://myserver.com/scm/PROJECT/repo', () => { + const result = parser.parse('https://myserver.com/scm/PROJECT/repo', 'bitbucket-server'); + expect(result).toEqual({ platform: 'bitbucket-server', projectKey: 'PROJECT', repositorySlug: 'repo' }); + }); + + test('parses SSH format git@myserver.com:PROJECT/repo.git', () => { + const result = parser.parse('git@myserver.com:PROJECT/repo.git', 'bitbucket-server'); + expect(result).toEqual({ platform: 'bitbucket-server', projectKey: 'PROJECT', repositorySlug: 'repo' }); + }); + + test('parses SSH with port git@myserver.com:7999/PROJECT/repo.git', () => { + const result = parser.parse('git@myserver.com:7999/PROJECT/repo.git', 'bitbucket-server'); + expect(result).toEqual({ platform: 'bitbucket-server', projectKey: 'PROJECT', repositorySlug: 'repo' }); + }); +}); + +// --- Azure DevOps --- + +describe('Azure DevOps', () => { + test('parses https://dev.azure.com/org/project/_git/repo', () => { + const result = parser.parse('https://dev.azure.com/org/project/_git/repo', 'azure-devops'); + expect(result).toEqual({ platform: 'azure-devops', organization: 'org', project: 'project', repo: 'repo' }); + }); + + test('parses SSH format git@ssh.dev.azure.com:v3/org/project/repo', () => { + const result = parser.parse('git@ssh.dev.azure.com:v3/org/project/repo', 'azure-devops'); + expect(result).toEqual({ platform: 'azure-devops', organization: 'org', project: 'project', repo: 'repo' }); + }); +}); + +// --- Invalid URLs → VersioningsError --- + +describe('Invalid URLs', () => { + test('throws VersioningsError with CONFIG_ERROR for empty URL', () => { + expect(() => parser.parse('', 'github')).toThrow(VersioningsError); + try { + parser.parse('', 'github'); + } catch (err) { + expect(err).toBeInstanceOf(VersioningsError); + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }); + + test('throws VersioningsError for GitHub URL with only owner (missing repo)', () => { + expect(() => parser.parse('https://github.com/owner', 'github')).toThrow(VersioningsError); + try { + parser.parse('https://github.com/owner', 'github'); + } catch (err) { + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + } + }); + + test('throws VersioningsError for Azure DevOps URL missing _git segment', () => { + expect(() => parser.parse('https://dev.azure.com/org/project/repo', 'azure-devops')).toThrow(VersioningsError); + }); + + test('throws VersioningsError for unsupported platform', () => { + expect(() => parser.parse('https://example.com/owner/repo', 'unknown-platform')).toThrow(VersioningsError); + try { + parser.parse('https://example.com/owner/repo', 'unknown-platform'); + } catch (err) { + expect((err as VersioningsError).code).toBe(EXIT_CODES.CONFIG_ERROR); + expect((err as VersioningsError).message).toContain('Unsupported platform'); + } + }); + + test('error message contains platform name and expected formats', () => { + try { + parser.parse('https://github.com/owner', 'github'); + } catch (err) { + const msg = (err as VersioningsError).message; + expect(msg).toContain('github'); + expect(msg).toContain('Expected formats'); + } + }); +}); + +// --- Round-trip: format(parse(url)) --- + +describe('Round-trip: format(parse(url))', () => { + test('GitHub HTTPS round-trip', () => { + const url = 'https://github.com/facebook/react'; + const parsed = parser.parse(url, 'github'); + expect(parser.format(parsed)).toBe(url); + }); + + test('GitHub HTTPS with .git normalizes to canonical form', () => { + const parsed = parser.parse('https://github.com/facebook/react.git', 'github'); + expect(parser.format(parsed)).toBe('https://github.com/facebook/react'); + }); + + test('GitHub SSH normalizes to HTTPS canonical form', () => { + const parsed = parser.parse('git@github.com:facebook/react.git', 'github'); + expect(parser.format(parsed)).toBe('https://github.com/facebook/react'); + }); + + test('GitLab nested groups round-trip', () => { + const url = 'https://gitlab.com/group/subgroup/project'; + const parsed = parser.parse(url, 'gitlab'); + expect(parser.format(parsed)).toBe(url); + }); + + test('Bitbucket Cloud round-trip', () => { + const url = 'https://bitbucket.org/atlassian/aui'; + const parsed = parser.parse(url, 'bitbucket'); + expect(parser.format(parsed)).toBe(url); + }); + + test('Bitbucket Server round-trip (HTTPS with /scm/)', () => { + const url = 'https://myserver.com/scm/PROJECT/repo'; + const parsed = parser.parse(url, 'bitbucket-server'); + // format uses default host 'bitbucket-server' since parsed doesn't carry host + const formatted = parser.format(parsed); + expect(formatted).toContain('/scm/PROJECT/repo'); + }); + + test('Azure DevOps round-trip', () => { + const url = 'https://dev.azure.com/myorg/myproject/_git/myrepo'; + const parsed = parser.parse(url, 'azure-devops'); + expect(parser.format(parsed)).toBe(url); + }); +}); diff --git a/__tests__/unit/utils.test.ts b/__tests__/unit/utils/utils.test.ts similarity index 99% rename from __tests__/unit/utils.test.ts rename to __tests__/unit/utils/utils.test.ts index 5b98c32..b6aff7b 100644 --- a/__tests__/unit/utils.test.ts +++ b/__tests__/unit/utils/utils.test.ts @@ -12,7 +12,7 @@ import { Logger, execAction, stop, -} from '../../utils'; +} from '../../../src/utils/utils'; afterEach(() => { jest.restoreAllMocks(); diff --git a/__tests__/unit/versioning/changelog.generator.test.ts b/__tests__/unit/versioning/changelog.generator.test.ts new file mode 100644 index 0000000..c7157bb --- /dev/null +++ b/__tests__/unit/versioning/changelog.generator.test.ts @@ -0,0 +1,374 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { + generateChangelog, + DEFAULT_GROUP_TITLES, +} from '../../../src/versioning/changelog.generator'; +import type { + ChangelogOpts, + ChangelogResult, +} from '../../../src/versioning/changelog.generator'; +import type { + CommitWithHash, + ConventionalCommit, + InvalidCommit, +} from '../../../src/versioning/commit.parser'; +import { DEFAULT_BUMP_POLICY } from '../../../src/versioning/commit.analyzer'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +let hashCounter = 0; + +function nextHash(): string { + hashCounter++; + return hashCounter.toString(16).padStart(40, '0'); +} + +function makeConventional( + overrides: Partial> & { type: string; description: string }, +): CommitWithHash { + const cc: ConventionalCommit = { + valid: true, + type: overrides.type, + scope: overrides.scope ?? null, + description: overrides.description, + body: overrides.body ?? null, + footers: overrides.footers ?? [], + breaking: overrides.breaking ?? false, + rawMessage: overrides.rawMessage ?? `${overrides.type}: ${overrides.description}`, + }; + return { hash: nextHash(), parsed: cc }; +} + +function makeInvalid(rawMessage: string): CommitWithHash { + const inv: InvalidCommit = { valid: false, rawMessage }; + return { hash: nextHash(), parsed: inv }; +} + +function defaultOpts(overrides?: Partial): ChangelogOpts { + return { + version: '1.0.0', + date: '2024-01-15', + format: 'markdown', + groupTitles: { ...DEFAULT_GROUP_TITLES }, + excludeTypes: [], + includeNonConventional: false, + bumpPolicy: { ...DEFAULT_BUMP_POLICY }, + ...overrides, + }; +} + +beforeEach(() => { + hashCounter = 0; +}); + + +// ── generateChangelog ─────────────────────────────────────────────────────── + +describe('generateChangelog', () => { + // ── Basic generation with feat and fix ────────────────────────────────── + + test('generates changelog with feat and fix commits', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'add login page' }), + makeConventional({ type: 'fix', description: 'resolve crash on startup' }), + ]; + const result = generateChangelog(commits, defaultOpts()); + + expect(result.groups).toHaveLength(2); + expect(result.groups[0].title).toBe('Features'); + expect(result.groups[0].commits).toHaveLength(1); + expect(result.groups[0].commits[0].description).toBe('add login page'); + expect(result.groups[1].title).toBe('Bug Fixes'); + expect(result.groups[1].commits).toHaveLength(1); + expect(result.groups[1].commits[0].description).toBe('resolve crash on startup'); + + expect(result.markdown).toContain('## [1.0.0] - 2024-01-15'); + expect(result.markdown).toContain('### Features'); + expect(result.markdown).toContain('- add login page'); + expect(result.markdown).toContain('### Bug Fixes'); + expect(result.markdown).toContain('- resolve crash on startup'); + }); + + // ── Grouping order ────────────────────────────────────────────────────── + + test('groups in correct order: breaking, feat, fix, perf, revert, then alphabetical', () => { + const commits = [ + makeConventional({ type: 'revert', description: 'revert bad change' }), + makeConventional({ type: 'perf', description: 'optimize query' }), + makeConventional({ type: 'fix', description: 'fix bug' }), + makeConventional({ type: 'feat', description: 'new feature' }), + makeConventional({ type: 'feat', description: 'breaking api', breaking: true }), + makeConventional({ type: 'custom', description: 'custom type commit' }), + ]; + // custom type is not in bumpPolicy, so not excluded + const opts = defaultOpts({ bumpPolicy: { ...DEFAULT_BUMP_POLICY } }); + const result = generateChangelog(commits, opts); + + const titles = result.groups.map((g) => g.title); + expect(titles[0]).toBe('BREAKING CHANGES'); + expect(titles[1]).toBe('Features'); + expect(titles[2]).toBe('Bug Fixes'); + expect(titles[3]).toBe('Performance Improvements'); + expect(titles[4]).toBe('Reverts'); + expect(titles[5]).toBe('custom'); + }); + + // ── Breaking changes first ────────────────────────────────────────────── + + test('breaking changes appear first and are not duplicated in type group', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'normal feature' }), + makeConventional({ type: 'feat', description: 'breaking feature', breaking: true }), + ]; + const result = generateChangelog(commits, defaultOpts()); + + expect(result.groups[0].title).toBe('BREAKING CHANGES'); + expect(result.groups[0].commits).toHaveLength(1); + expect(result.groups[0].commits[0].description).toBe('breaking feature'); + + expect(result.groups[1].title).toBe('Features'); + expect(result.groups[1].commits).toHaveLength(1); + expect(result.groups[1].commits[0].description).toBe('normal feature'); + }); + + // ── Scope in parentheses ──────────────────────────────────────────────── + + test('scope is rendered in parentheses after description', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'add button', scope: 'ui' }), + makeConventional({ type: 'feat', description: 'add route', scope: null }), + ]; + const result = generateChangelog(commits, defaultOpts()); + + expect(result.markdown).toContain('- add button (ui)'); + expect(result.markdown).toContain('- add route'); + expect(result.markdown).not.toContain('- add route ()'); + + expect(result.groups[0].commits[0].scope).toBe('ui'); + expect(result.groups[0].commits[1].scope).toBeNull(); + }); + + // ── Exclusion of none-types via bumpPolicy ────────────────────────────── + + test('excludes types mapped to none in bumpPolicy', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'new feature' }), + makeConventional({ type: 'chore', description: 'update deps' }), + makeConventional({ type: 'docs', description: 'update readme' }), + makeConventional({ type: 'fix', description: 'fix bug' }), + ]; + const result = generateChangelog(commits, defaultOpts()); + + const titles = result.groups.map((g) => g.title); + expect(titles).toContain('Features'); + expect(titles).toContain('Bug Fixes'); + expect(titles).not.toContain('chore'); + expect(titles).not.toContain('docs'); + }); + + // ── excludeTypes option ───────────────────────────────────────────────── + + test('excludeTypes excludes specified types from changelog', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'new feature' }), + makeConventional({ type: 'fix', description: 'fix bug' }), + makeConventional({ type: 'perf', description: 'optimize' }), + ]; + const result = generateChangelog(commits, defaultOpts({ excludeTypes: ['perf'] })); + + const titles = result.groups.map((g) => g.title); + expect(titles).toContain('Features'); + expect(titles).toContain('Bug Fixes'); + expect(titles).not.toContain('Performance Improvements'); + }); + + test('excludeTypes combined with bumpPolicy none — both exclusions apply', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'feature' }), + makeConventional({ type: 'fix', description: 'fix' }), + makeConventional({ type: 'chore', description: 'chore task' }), + ]; + // fix is excluded by excludeTypes, chore by bumpPolicy none + const result = generateChangelog(commits, defaultOpts({ excludeTypes: ['fix'] })); + + const titles = result.groups.map((g) => g.title); + expect(titles).toEqual(['Features']); + }); + + + // ── includeNonConventional ────────────────────────────────────────────── + + test('includeNonConventional=false excludes invalid commits', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'feature' }), + makeInvalid('random commit message'), + ]; + const result = generateChangelog(commits, defaultOpts({ includeNonConventional: false })); + + const titles = result.groups.map((g) => g.title); + expect(titles).not.toContain('Other Changes'); + expect(result.groups).toHaveLength(1); + }); + + test('includeNonConventional=true includes invalid commits in Other Changes group', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'feature' }), + makeInvalid('random commit message'), + makeInvalid('another non-conventional'), + ]; + const result = generateChangelog(commits, defaultOpts({ includeNonConventional: true })); + + const titles = result.groups.map((g) => g.title); + expect(titles).toContain('Other Changes'); + + const otherGroup = result.groups.find((g) => g.title === 'Other Changes')!; + expect(otherGroup.commits).toHaveLength(2); + expect(otherGroup.commits[0].description).toBe('random commit message'); + expect(otherGroup.commits[1].description).toBe('another non-conventional'); + }); + + test('Other Changes group appears last', () => { + const commits = [ + makeInvalid('non-conventional'), + makeConventional({ type: 'feat', description: 'feature' }), + makeConventional({ type: 'fix', description: 'fix' }), + ]; + const result = generateChangelog(commits, defaultOpts({ includeNonConventional: true })); + + const lastGroup = result.groups[result.groups.length - 1]; + expect(lastGroup.title).toBe('Other Changes'); + }); + + // ── Markdown vs plain format ──────────────────────────────────────────── + + test('markdown format includes ## and ### markers', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'feature' }), + ]; + const result = generateChangelog(commits, defaultOpts({ format: 'markdown' })); + + expect(result.markdown).toContain('## [1.0.0] - 2024-01-15'); + expect(result.markdown).toContain('### Features'); + }); + + test('plain format has no markdown markers (## or ###)', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'feature' }), + makeConventional({ type: 'fix', description: 'fix bug' }), + ]; + const result = generateChangelog(commits, defaultOpts({ format: 'plain' })); + + expect(result.markdown).toContain('[1.0.0] - 2024-01-15'); + expect(result.markdown).toContain('Features'); + expect(result.markdown).toContain('- feature'); + expect(result.markdown).not.toMatch(/^##\s/m); + expect(result.markdown).not.toMatch(/^###\s/m); + }); + + // ── Custom groupTitles ────────────────────────────────────────────────── + + test('custom groupTitles override defaults', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'feature' }), + makeConventional({ type: 'fix', description: 'fix' }), + ]; + const customTitles = { + ...DEFAULT_GROUP_TITLES, + feat: 'Новые возможности', + fix: 'Исправления', + }; + const result = generateChangelog(commits, defaultOpts({ groupTitles: customTitles })); + + expect(result.groups[0].title).toBe('Новые возможности'); + expect(result.groups[1].title).toBe('Исправления'); + expect(result.markdown).toContain('### Новые возможности'); + expect(result.markdown).toContain('### Исправления'); + }); + + test('partial custom groupTitles — non-overridden types use defaults', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'feature' }), + makeConventional({ type: 'fix', description: 'fix' }), + ]; + const customTitles = { + ...DEFAULT_GROUP_TITLES, + feat: 'New Stuff', + }; + const result = generateChangelog(commits, defaultOpts({ groupTitles: customTitles })); + + expect(result.groups[0].title).toBe('New Stuff'); + expect(result.groups[1].title).toBe('Bug Fixes'); // default preserved + }); + + test('custom groupTitles for breaking changes', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'breaking', breaking: true }), + ]; + const customTitles = { + ...DEFAULT_GROUP_TITLES, + breaking: 'КРИТИЧЕСКИЕ ИЗМЕНЕНИЯ', + }; + const result = generateChangelog(commits, defaultOpts({ groupTitles: customTitles })); + + expect(result.groups[0].title).toBe('КРИТИЧЕСКИЕ ИЗМЕНЕНИЯ'); + }); + + // ── Empty commits array ───────────────────────────────────────────────── + + test('empty commits array returns header only with no groups', () => { + const result = generateChangelog([], defaultOpts()); + + expect(result.groups).toEqual([]); + expect(result.markdown).toContain('## [1.0.0] - 2024-01-15'); + // Should be just the header and a trailing newline + expect(result.markdown.trim()).toBe('## [1.0.0] - 2024-01-15'); + }); + + // ── Header with version and date ──────────────────────────────────────── + + test('header includes version and date', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'feature' }), + ]; + const result = generateChangelog(commits, defaultOpts({ version: '2.5.0', date: '2025-06-01' })); + + expect(result.markdown).toContain('## [2.5.0] - 2025-06-01'); + }); + + test('null version renders as Unreleased', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'feature' }), + ]; + const result = generateChangelog(commits, defaultOpts({ version: null })); + + expect(result.markdown).toContain('## [Unreleased] - 2024-01-15'); + }); + + // ── Input order preserved within groups ───────────────────────────────── + + test('commits within a group preserve input order', () => { + const commits = [ + makeConventional({ type: 'feat', description: 'alpha' }), + makeConventional({ type: 'feat', description: 'beta' }), + makeConventional({ type: 'feat', description: 'gamma' }), + ]; + const result = generateChangelog(commits, defaultOpts()); + + const featGroup = result.groups.find((g) => g.title === 'Features')!; + expect(featGroup.commits.map((c) => c.description)).toEqual(['alpha', 'beta', 'gamma']); + }); +}); + +// ── DEFAULT_GROUP_TITLES ──────────────────────────────────────────────────── + +describe('DEFAULT_GROUP_TITLES', () => { + test('contains expected default titles', () => { + expect(DEFAULT_GROUP_TITLES.breaking).toBe('BREAKING CHANGES'); + expect(DEFAULT_GROUP_TITLES.feat).toBe('Features'); + expect(DEFAULT_GROUP_TITLES.fix).toBe('Bug Fixes'); + expect(DEFAULT_GROUP_TITLES.perf).toBe('Performance Improvements'); + expect(DEFAULT_GROUP_TITLES.revert).toBe('Reverts'); + }); +}); diff --git a/__tests__/unit/versioning/commit.analyzer.test.ts b/__tests__/unit/versioning/commit.analyzer.test.ts new file mode 100644 index 0000000..698419c --- /dev/null +++ b/__tests__/unit/versioning/commit.analyzer.test.ts @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { + findLastVersionTag, + getCommitsInRange, + analyzeBump, + DEFAULT_BUMP_POLICY, +} from '../../../src/versioning/commit.analyzer'; +import type { BumpPolicy, CommitAnalyzerDeps } from '../../../src/versioning/commit.analyzer'; +import type { Executor, ExecutorResult } from '../../../src/core/executor'; +import { EXIT_CODES, VersioningsError } from '../../../src/core/errors'; +import { COMMIT_SEPARATOR } from '../../../src/versioning/commit.parser'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function ok(stdout: string): ExecutorResult { + const trimmed = stdout.trim(); + const lines = trimmed ? trimmed.split('\n').filter(Boolean) : []; + return { stdout: trimmed, lines }; +} + +function createMockExecutor( + responses: Record, +): Executor { + return { + run(cmd: string): Promise { + for (const [key, value] of Object.entries(responses)) { + if (cmd.includes(key)) { + if (value instanceof Error) { + return Promise.reject(value); + } + return Promise.resolve(value); + } + } + return Promise.reject(new Error(`Unexpected command: ${cmd}`)); + }, + }; +} + +/** Builds a git log chunk for a single commit */ +function logEntry(hash: string, message: string): string { + return `${hash}\n${message}\n\n${COMMIT_SEPARATOR}\n`; +} + +const HASH_A = 'aaaa' + '0'.repeat(36); +const HASH_B = 'bbbb' + '0'.repeat(36); +const HASH_C = 'cccc' + '0'.repeat(36); +const HASH_D = 'dddd' + '0'.repeat(36); + +// ── findLastVersionTag ────────────────────────────────────────────────────── + +describe('findLastVersionTag', () => { + test('returns first tag when version tags exist', async () => { + const executor = createMockExecutor({ + 'git tag --list': ok('v2.0.0\nv1.1.0\nv1.0.0'), + }); + const tag = await findLastVersionTag(executor); + expect(tag).toBe('v2.0.0'); + }); + + test('returns root commit SHA when no tags exist', async () => { + const rootSha = 'abcd' + '0'.repeat(36); + const executor = createMockExecutor({ + 'git tag --list': ok(''), + 'git rev-list --max-parents=0': ok(rootSha), + }); + const tag = await findLastVersionTag(executor); + expect(tag).toBe(rootSha); + }); + + test('returns null when both tag and rev-list fail', async () => { + const executor = createMockExecutor({ + 'git tag --list': new VersioningsError(EXIT_CODES.COMMAND_FAILED, 'no tags'), + 'git rev-list': new VersioningsError(EXIT_CODES.COMMAND_FAILED, 'empty repo'), + }); + const tag = await findLastVersionTag(executor); + expect(tag).toBeNull(); + }); + + test('falls through to rev-list when tag command returns empty lines', async () => { + const rootSha = 'ffff' + '0'.repeat(36); + const executor = createMockExecutor({ + 'git tag --list': ok(''), + 'git rev-list --max-parents=0': ok(rootSha), + }); + const tag = await findLastVersionTag(executor); + expect(tag).toBe(rootSha); + }); +}); + +// ── getCommitsInRange ─────────────────────────────────────────────────────── + +describe('getCommitsInRange', () => { + test('parses commits from git log output', async () => { + const gitLog = logEntry(HASH_A, 'feat(core): add feature') + + logEntry(HASH_B, 'fix: resolve bug'); + const executor = createMockExecutor({ + 'git log': ok(gitLog), + }); + + const commits = await getCommitsInRange(executor, { from: 'v1.0.0', to: 'HEAD' }); + expect(commits).toHaveLength(2); + expect(commits[0].hash).toBe(HASH_A); + expect(commits[0].parsed.valid).toBe(true); + if (commits[0].parsed.valid) { + expect(commits[0].parsed.type).toBe('feat'); + } + expect(commits[1].hash).toBe(HASH_B); + expect(commits[1].parsed.valid).toBe(true); + if (commits[1].parsed.valid) { + expect(commits[1].parsed.type).toBe('fix'); + } + }); + + test('returns empty array for empty git log', async () => { + const executor = createMockExecutor({ + 'git log': ok(''), + }); + const commits = await getCommitsInRange(executor, { from: 'v1.0.0', to: 'HEAD' }); + expect(commits).toEqual([]); + }); + + test('propagates git log error as-is', async () => { + const error = new VersioningsError(EXIT_CODES.COMMAND_FAILED, 'git log failed'); + const executor = createMockExecutor({ + 'git log': error, + }); + await expect( + getCommitsInRange(executor, { from: 'v1.0.0', to: 'HEAD' }), + ).rejects.toThrow(VersioningsError); + }); +}); + +// ── analyzeBump ───────────────────────────────────────────────────────────── + +describe('analyzeBump', () => { + function makeDeps( + executor: Executor, + overrides?: Partial>, + ): CommitAnalyzerDeps { + return { + executor, + bumpPolicy: overrides?.bumpPolicy ?? DEFAULT_BUMP_POLICY, + fallbackBump: overrides?.fallbackBump ?? null, + }; + } + + function executorWithTagAndLog(tag: string, gitLogOutput: string): Executor { + return createMockExecutor({ + 'git tag --list': ok(tag), + 'git log': ok(gitLogOutput), + }); + } + + // ── Bump level detection ──────────────────────────────────────────────── + + test('feat commit → minor bump', async () => { + const log = logEntry(HASH_A, 'feat: add new feature'); + const executor = executorWithTagAndLog('v1.0.0', log); + const result = await analyzeBump(makeDeps(executor)); + expect(result.bump).toBe('minor'); + }); + + test('fix commit → patch bump', async () => { + const log = logEntry(HASH_A, 'fix: resolve issue'); + const executor = executorWithTagAndLog('v1.0.0', log); + const result = await analyzeBump(makeDeps(executor)); + expect(result.bump).toBe('patch'); + }); + + test('breaking change via bang → major bump', async () => { + const log = logEntry(HASH_A, 'feat!: breaking API change'); + const executor = executorWithTagAndLog('v1.0.0', log); + const result = await analyzeBump(makeDeps(executor)); + expect(result.bump).toBe('major'); + }); + + test('breaking change via BREAKING CHANGE footer → major bump', async () => { + const msg = 'refactor: change internals\n\nBREAKING CHANGE: removed old API'; + const log = logEntry(HASH_A, msg); + const executor = executorWithTagAndLog('v1.0.0', log); + const result = await analyzeBump(makeDeps(executor)); + expect(result.bump).toBe('major'); + }); + + test('mixed commits → highest bump wins (major > minor > patch)', async () => { + const log = + logEntry(HASH_A, 'fix: small fix') + + logEntry(HASH_B, 'feat: new feature') + + logEntry(HASH_C, 'feat!: breaking change'); + const executor = executorWithTagAndLog('v1.0.0', log); + const result = await analyzeBump(makeDeps(executor)); + expect(result.bump).toBe('major'); + }); + + test('feat + fix → minor (feat > fix)', async () => { + const log = + logEntry(HASH_A, 'fix: bug fix') + + logEntry(HASH_B, 'feat: new feature'); + const executor = executorWithTagAndLog('v1.0.0', log); + const result = await analyzeBump(makeDeps(executor)); + expect(result.bump).toBe('minor'); + }); + + // ── Custom BumpPolicy ─────────────────────────────────────────────────── + + test('custom BumpPolicy: refactor → patch', async () => { + const log = logEntry(HASH_A, 'refactor: clean up code'); + const executor = executorWithTagAndLog('v1.0.0', log); + const customPolicy: BumpPolicy = { + ...DEFAULT_BUMP_POLICY, + refactor: 'patch', + }; + const result = await analyzeBump(makeDeps(executor, { bumpPolicy: customPolicy })); + expect(result.bump).toBe('patch'); + }); + + test('custom BumpPolicy: docs → minor', async () => { + const log = logEntry(HASH_A, 'docs: update readme'); + const executor = executorWithTagAndLog('v1.0.0', log); + const customPolicy: BumpPolicy = { + ...DEFAULT_BUMP_POLICY, + docs: 'minor', + }; + const result = await analyzeBump(makeDeps(executor, { bumpPolicy: customPolicy })); + expect(result.bump).toBe('minor'); + }); + + // ── Fallback behavior ────────────────────────────────────────────────── + + test('all none types with fallback → uses fallbackBump', async () => { + const log = logEntry(HASH_A, 'chore: update deps') + + logEntry(HASH_B, 'docs: update readme'); + const executor = executorWithTagAndLog('v1.0.0', log); + const result = await analyzeBump(makeDeps(executor, { fallbackBump: 'patch' })); + expect(result.bump).toBe('patch'); + }); + + test('all none types without fallback → throws NO_CONVENTIONAL_COMMITS', async () => { + const log = logEntry(HASH_A, 'chore: update deps') + + logEntry(HASH_B, 'docs: update readme'); + const executor = executorWithTagAndLog('v1.0.0', log); + await expect( + analyzeBump(makeDeps(executor)), + ).rejects.toThrow(VersioningsError); + + try { + await analyzeBump(makeDeps(executor)); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.NO_CONVENTIONAL_COMMITS); + } + }); + + test('no conventional commits (all invalid) without fallback → NO_CONVENTIONAL_COMMITS', async () => { + const log = logEntry(HASH_A, 'random commit message') + + logEntry(HASH_B, 'another non-conventional message'); + const executor = executorWithTagAndLog('v1.0.0', log); + await expect( + analyzeBump(makeDeps(executor)), + ).rejects.toThrow(VersioningsError); + + try { + await analyzeBump(makeDeps(executor)); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.NO_CONVENTIONAL_COMMITS); + } + }); + + test('no conventional commits with fallback → uses fallbackBump', async () => { + const log = logEntry(HASH_A, 'random commit message'); + const executor = executorWithTagAndLog('v1.0.0', log); + const result = await analyzeBump(makeDeps(executor, { fallbackBump: 'minor' })); + expect(result.bump).toBe('minor'); + }); + + // ── No tags → beginning of history ───────────────────────────────────── + + test('no tags → uses all commits from beginning of history', async () => { + const rootSha = 'abcd' + '0'.repeat(36); + const log = logEntry(HASH_A, 'feat: initial feature'); + const executor = createMockExecutor({ + 'git tag --list': ok(''), + 'git rev-list --max-parents=0': ok(rootSha), + 'git log': ok(log), + }); + const result = await analyzeBump(makeDeps(executor)); + expect(result.bump).toBe('minor'); + expect(result.commits).toHaveLength(1); + }); + + // ── commitsByType counters ───────────────────────────────────────────── + + test('commitsByType counts each type correctly', async () => { + const log = + logEntry(HASH_A, 'feat: feature one') + + logEntry(HASH_B, 'feat: feature two') + + logEntry(HASH_C, 'fix: bug fix') + + logEntry(HASH_D, 'chore: cleanup'); + const executor = executorWithTagAndLog('v1.0.0', log); + const result = await analyzeBump(makeDeps(executor)); + expect(result.commitsByType).toEqual({ feat: 2, fix: 1, chore: 1 }); + }); + + // ── breakingChanges subset ───────────────────────────────────────────── + + test('breakingChanges contains only breaking commits', async () => { + const log = + logEntry(HASH_A, 'feat: normal feature') + + logEntry(HASH_B, 'feat!: breaking feature') + + logEntry(HASH_C, 'fix: normal fix'); + const executor = executorWithTagAndLog('v1.0.0', log); + const result = await analyzeBump(makeDeps(executor)); + expect(result.breakingChanges).toHaveLength(1); + expect(result.breakingChanges[0].description).toBe('breaking feature'); + expect(result.breakingChanges[0].breaking).toBe(true); + }); + + test('conventionalCommits contains only valid commits', async () => { + const log = + logEntry(HASH_A, 'feat: valid feature') + + logEntry(HASH_B, 'not a conventional commit') + + logEntry(HASH_C, 'fix: valid fix'); + const executor = executorWithTagAndLog('v1.0.0', log); + const result = await analyzeBump(makeDeps(executor)); + expect(result.conventionalCommits).toHaveLength(2); + expect(result.commits).toHaveLength(3); + }); + + // ── Range information ────────────────────────────────────────────────── + + test('range reflects tag and HEAD', async () => { + const log = logEntry(HASH_A, 'feat: feature'); + const executor = executorWithTagAndLog('v1.2.3', log); + const result = await analyzeBump(makeDeps(executor)); + expect(result.range.from).toBe('v1.2.3'); + expect(result.range.to).toBe('HEAD'); + }); + + // ── Git log error → COMMAND_FAILED ───────────────────────────────────── + + test('git log error propagates as COMMAND_FAILED', async () => { + const executor = createMockExecutor({ + 'git tag --list': ok('v1.0.0'), + 'git log': new VersioningsError( + EXIT_CODES.COMMAND_FAILED, + 'fatal: bad revision', + ), + }); + await expect(analyzeBump(makeDeps(executor))).rejects.toThrow(VersioningsError); + + try { + await analyzeBump(makeDeps(executor)); + } catch (err: any) { + expect(err.code).toBe(EXIT_CODES.COMMAND_FAILED); + } + }); +}); + +// ── DEFAULT_BUMP_POLICY ───────────────────────────────────────────────────── + +describe('DEFAULT_BUMP_POLICY', () => { + test('feat maps to minor', () => { + expect(DEFAULT_BUMP_POLICY.feat).toBe('minor'); + }); + + test('fix maps to patch', () => { + expect(DEFAULT_BUMP_POLICY.fix).toBe('patch'); + }); + + test('perf maps to patch', () => { + expect(DEFAULT_BUMP_POLICY.perf).toBe('patch'); + }); + + test('revert maps to patch', () => { + expect(DEFAULT_BUMP_POLICY.revert).toBe('patch'); + }); + + test('chore, docs, style, refactor, test, build, ci map to none', () => { + const noneTypes = ['chore', 'docs', 'style', 'refactor', 'test', 'build', 'ci']; + for (const t of noneTypes) { + expect(DEFAULT_BUMP_POLICY[t]).toBe('none'); + } + }); +}); diff --git a/__tests__/unit/versioning/commit.parser.test.ts b/__tests__/unit/versioning/commit.parser.test.ts new file mode 100644 index 0000000..24d3a54 --- /dev/null +++ b/__tests__/unit/versioning/commit.parser.test.ts @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { + parseCommit, + parseGitLog, + CONVENTIONAL_TYPES, + COMMIT_SEPARATOR, + GIT_LOG_FORMAT, +} from '../../../src/versioning/commit.parser'; +import type { ConventionalCommit } from '../../../src/versioning/commit.parser'; + +describe('parseCommit', () => { + test('parses feat(scope): description', () => { + const result = parseCommit('feat(parser): add new feature'); + expect(result.valid).toBe(true); + const c = result as ConventionalCommit; + expect(c.type).toBe('feat'); + expect(c.scope).toBe('parser'); + expect(c.description).toBe('add new feature'); + expect(c.breaking).toBe(false); + expect(c.body).toBeNull(); + expect(c.footers).toEqual([]); + }); + + test('parses fix!: breaking change via bang', () => { + const result = parseCommit('fix!: breaking change'); + expect(result.valid).toBe(true); + const c = result as ConventionalCommit; + expect(c.type).toBe('fix'); + expect(c.scope).toBeNull(); + expect(c.description).toBe('breaking change'); + expect(c.breaking).toBe(true); + }); + + test('parses chore: routine', () => { + const result = parseCommit('chore: routine maintenance'); + expect(result.valid).toBe(true); + const c = result as ConventionalCommit; + expect(c.type).toBe('chore'); + expect(c.scope).toBeNull(); + expect(c.description).toBe('routine maintenance'); + expect(c.breaking).toBe(false); + }); + + test('parses multiline body with footers', () => { + const msg = [ + 'feat(api): add user endpoint', + '', + 'This adds a new REST endpoint for user management.', + 'It supports CRUD operations.', + '', + 'Reviewed-by: Alice', + 'Refs: #42', + ].join('\n'); + + const result = parseCommit(msg); + expect(result.valid).toBe(true); + const c = result as ConventionalCommit; + expect(c.type).toBe('feat'); + expect(c.scope).toBe('api'); + expect(c.description).toBe('add user endpoint'); + expect(c.body).toBe( + 'This adds a new REST endpoint for user management.\nIt supports CRUD operations.', + ); + expect(c.footers).toEqual([ + { token: 'Reviewed-by', value: 'Alice' }, + { token: 'Refs', value: '#42' }, + ]); + expect(c.breaking).toBe(false); + }); + + test('parses footer BREAKING CHANGE: desc', () => { + const msg = [ + 'refactor: change API surface', + '', + 'BREAKING CHANGE: removed deprecated endpoints', + ].join('\n'); + + const result = parseCommit(msg); + expect(result.valid).toBe(true); + const c = result as ConventionalCommit; + expect(c.type).toBe('refactor'); + expect(c.breaking).toBe(true); + expect(c.footers).toEqual([ + { token: 'BREAKING CHANGE', value: 'removed deprecated endpoints' }, + ]); + }); + + test('parses footer with # (Fixes #123)', () => { + const msg = [ + 'fix(core): resolve crash on startup', + '', + 'Fixes #123', + ].join('\n'); + + const result = parseCommit(msg); + expect(result.valid).toBe(true); + const c = result as ConventionalCommit; + expect(c.type).toBe('fix'); + expect(c.footers).toEqual([ + { token: 'Fixes', value: '123' }, + ]); + }); + + test('returns valid: false for invalid message', () => { + const result = parseCommit('this is not a conventional commit'); + expect(result.valid).toBe(false); + expect(result.rawMessage).toBe('this is not a conventional commit'); + }); + + test('returns valid: false for empty string', () => { + const result = parseCommit(''); + expect(result.valid).toBe(false); + expect(result.rawMessage).toBe(''); + }); + + test('parses BREAKING-CHANGE footer variant', () => { + const msg = [ + 'feat: new feature', + '', + 'BREAKING-CHANGE: old API removed', + ].join('\n'); + + const result = parseCommit(msg); + expect(result.valid).toBe(true); + const c = result as ConventionalCommit; + expect(c.breaking).toBe(true); + expect(c.footers).toEqual([ + { token: 'BREAKING-CHANGE', value: 'old API removed' }, + ]); + }); + + test('handles Windows-style CRLF newlines', () => { + const msg = 'feat(ui): add button\r\n\r\nNew button component.\r\n\r\nFixes #10'; + const result = parseCommit(msg); + expect(result.valid).toBe(true); + const c = result as ConventionalCommit; + expect(c.body).toBe('New button component.'); + expect(c.footers).toEqual([{ token: 'Fixes', value: '10' }]); + }); +}); + +describe('parseGitLog', () => { + test('parses multiple commits from git log output', () => { + const log = [ + 'abc1234567890123456789012345678901234567', + 'feat(core): first feature', + '', + COMMIT_SEPARATOR, + 'def1234567890123456789012345678901234567', + 'fix: second fix', + '', + COMMIT_SEPARATOR, + 'aaa1234567890123456789012345678901234567', + 'not a conventional commit', + '', + COMMIT_SEPARATOR, + ].join('\n'); + + const results = parseGitLog(log); + expect(results).toHaveLength(3); + + expect(results[0].hash).toBe('abc1234567890123456789012345678901234567'); + expect(results[0].parsed.valid).toBe(true); + expect((results[0].parsed as ConventionalCommit).type).toBe('feat'); + + expect(results[1].hash).toBe('def1234567890123456789012345678901234567'); + expect(results[1].parsed.valid).toBe(true); + expect((results[1].parsed as ConventionalCommit).type).toBe('fix'); + + expect(results[2].hash).toBe('aaa1234567890123456789012345678901234567'); + expect(results[2].parsed.valid).toBe(false); + }); + + test('returns empty array for empty input', () => { + expect(parseGitLog('')).toEqual([]); + }); + + test('skips chunks with invalid hash', () => { + const log = [ + 'not-a-hash', + 'feat: something', + '', + COMMIT_SEPARATOR, + ].join('\n'); + + expect(parseGitLog(log)).toEqual([]); + }); +}); + +describe('exports', () => { + test('CONVENTIONAL_TYPES contains standard types', () => { + expect(CONVENTIONAL_TYPES).toContain('feat'); + expect(CONVENTIONAL_TYPES).toContain('fix'); + expect(CONVENTIONAL_TYPES).toContain('chore'); + expect(CONVENTIONAL_TYPES).toContain('docs'); + expect(CONVENTIONAL_TYPES).toContain('refactor'); + expect(CONVENTIONAL_TYPES).toContain('perf'); + expect(CONVENTIONAL_TYPES).toContain('test'); + expect(CONVENTIONAL_TYPES).toContain('build'); + expect(CONVENTIONAL_TYPES).toContain('ci'); + expect(CONVENTIONAL_TYPES).toContain('revert'); + }); + + test('COMMIT_SEPARATOR is a non-empty string', () => { + expect(typeof COMMIT_SEPARATOR).toBe('string'); + expect(COMMIT_SEPARATOR.length).toBeGreaterThan(0); + }); + + test('GIT_LOG_FORMAT contains COMMIT_SEPARATOR', () => { + expect(GIT_LOG_FORMAT).toContain(COMMIT_SEPARATOR); + }); +}); diff --git a/__tests__/unit/versioning/commit.printer.test.ts b/__tests__/unit/versioning/commit.printer.test.ts new file mode 100644 index 0000000..b96a1e0 --- /dev/null +++ b/__tests__/unit/versioning/commit.printer.test.ts @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +import { printCommit } from '../../../src/versioning/commit.printer'; +import { parseCommit } from '../../../src/versioning/commit.parser'; +import type { ConventionalCommit } from '../../../src/versioning/commit.parser'; + +/** Helper to build a valid ConventionalCommit object */ +function makeCommit(overrides: Partial = {}): ConventionalCommit { + return { + valid: true, + type: 'feat', + scope: null, + description: 'add feature', + body: null, + footers: [], + breaking: false, + rawMessage: '', + ...overrides, + }; +} + +describe('printCommit', () => { + test('prints commit with scope', () => { + const result = printCommit(makeCommit({ type: 'feat', scope: 'parser', description: 'add new feature' })); + expect(result).toBe('feat(parser): add new feature'); + }); + + test('prints commit without scope', () => { + const result = printCommit(makeCommit({ type: 'fix', description: 'resolve crash' })); + expect(result).toBe('fix: resolve crash'); + }); + + test('prints commit with breaking ! when no BREAKING CHANGE footer', () => { + const result = printCommit(makeCommit({ type: 'fix', breaking: true, description: 'drop old API' })); + expect(result).toBe('fix!: drop old API'); + }); + + test('omits ! when breaking is true but BREAKING CHANGE footer exists', () => { + const result = printCommit(makeCommit({ + type: 'refactor', + breaking: true, + description: 'change API surface', + footers: [{ token: 'BREAKING CHANGE', value: 'removed deprecated endpoints' }], + })); + expect(result).not.toContain('!:'); + expect(result).toContain('BREAKING CHANGE: removed deprecated endpoints'); + }); + + test('omits ! when breaking is true but BREAKING-CHANGE footer exists', () => { + const result = printCommit(makeCommit({ + type: 'feat', + breaking: true, + description: 'new feature', + footers: [{ token: 'BREAKING-CHANGE', value: 'old API removed' }], + })); + expect(result).not.toContain('!:'); + expect(result).toContain('BREAKING-CHANGE: old API removed'); + }); + + test('prints commit with footers', () => { + const result = printCommit(makeCommit({ + type: 'feat', + scope: 'api', + description: 'add user endpoint', + footers: [ + { token: 'Reviewed-by', value: 'Alice' }, + { token: 'Refs', value: '#42' }, + ], + })); + expect(result).toBe( + 'feat(api): add user endpoint\n\nReviewed-by: Alice\nRefs: #42', + ); + }); + + test('prints commit with body', () => { + const result = printCommit(makeCommit({ + type: 'feat', + description: 'add feature', + body: 'This is a detailed body.\nWith multiple lines.', + })); + expect(result).toBe( + 'feat: add feature\n\nThis is a detailed body.\nWith multiple lines.', + ); + }); + + test('prints commit with body and footers separated by blank lines', () => { + const result = printCommit(makeCommit({ + type: 'feat', + scope: 'core', + description: 'add feature', + body: 'Detailed body text.', + footers: [{ token: 'Fixes', value: '#99' }], + })); + expect(result).toBe( + 'feat(core): add feature\n\nDetailed body text.\n\nFixes: #99', + ); + }); +}); + +describe('printCommit → parseCommit round-trip', () => { + test('round-trip preserves fields for simple commit', () => { + const original = makeCommit({ type: 'fix', description: 'resolve crash' }); + const printed = printCommit(original); + const reparsed = parseCommit(printed); + + expect(reparsed.valid).toBe(true); + const c = reparsed as ConventionalCommit; + expect(c.type).toBe(original.type); + expect(c.scope).toBe(original.scope); + expect(c.description).toBe(original.description); + expect(c.body).toBe(original.body); + expect(c.footers).toEqual(original.footers); + expect(c.breaking).toBe(original.breaking); + }); + + test('round-trip preserves fields for commit with scope, body, and footers', () => { + const original = makeCommit({ + type: 'feat', + scope: 'api', + description: 'add user endpoint', + body: 'This adds a new REST endpoint.\nIt supports CRUD.', + footers: [ + { token: 'Reviewed-by', value: 'Alice' }, + { token: 'Refs', value: '#42' }, + ], + }); + const printed = printCommit(original); + const reparsed = parseCommit(printed); + + expect(reparsed.valid).toBe(true); + const c = reparsed as ConventionalCommit; + expect(c.type).toBe(original.type); + expect(c.scope).toBe(original.scope); + expect(c.description).toBe(original.description); + expect(c.body).toBe(original.body); + expect(c.footers).toEqual(original.footers); + expect(c.breaking).toBe(original.breaking); + }); + + test('round-trip preserves breaking via ! indicator', () => { + const original = makeCommit({ + type: 'fix', + breaking: true, + description: 'drop old API', + }); + const printed = printCommit(original); + const reparsed = parseCommit(printed); + + expect(reparsed.valid).toBe(true); + const c = reparsed as ConventionalCommit; + expect(c.breaking).toBe(true); + expect(c.type).toBe('fix'); + expect(c.description).toBe('drop old API'); + }); + + test('round-trip preserves breaking via BREAKING CHANGE footer', () => { + const original = makeCommit({ + type: 'refactor', + breaking: true, + description: 'change API surface', + footers: [{ token: 'BREAKING CHANGE', value: 'removed deprecated endpoints' }], + }); + const printed = printCommit(original); + const reparsed = parseCommit(printed); + + expect(reparsed.valid).toBe(true); + const c = reparsed as ConventionalCommit; + expect(c.breaking).toBe(true); + expect(c.footers).toEqual([{ token: 'BREAKING CHANGE', value: 'removed deprecated endpoints' }]); + }); +}); diff --git a/__tests__/unit/version.utils.test.ts b/__tests__/unit/versioning/version.utils.test.ts similarity index 94% rename from __tests__/unit/version.utils.test.ts rename to __tests__/unit/versioning/version.utils.test.ts index 9e05e39..098a325 100644 --- a/__tests__/unit/version.utils.test.ts +++ b/__tests__/unit/versioning/version.utils.test.ts @@ -8,8 +8,8 @@ import { preidParam, semverMessage, generatePullRequestUrl, -} from '../../version.utils'; -import type { VersioningsConfig } from '../../config.validator'; +} from '../../../src/versioning/version.utils'; +import type { VersioningsConfig } from '../../../src/config/config.validator'; const mockConfig = { git: { @@ -81,12 +81,12 @@ describe('composeVersionTagName()', () => { }); describe('AVAILABLE_SEMVERS', () => { - test('contains all 7 semver types', () => { - expect(AVAILABLE_SEMVERS).toHaveLength(7); + test('contains all 8 semver types including auto', () => { + expect(AVAILABLE_SEMVERS).toHaveLength(8); }); test('includes each expected semver type', () => { - const expected = ['patch', 'prepatch', 'minor', 'preminor', 'premajor', 'prerelease', 'major']; + const expected = ['patch', 'prepatch', 'minor', 'preminor', 'premajor', 'prerelease', 'major', 'auto']; expected.forEach((type) => { expect(AVAILABLE_SEMVERS).toContain(type); }); diff --git a/build.js b/build.js index a9b02e4..5a7f0ed 100644 --- a/build.js +++ b/build.js @@ -1,13 +1,16 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2018-present Raman Marozau -const esbuild = require('esbuild'); -esbuild.buildSync({ - entryPoints: ['version.ts'], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outdir: 'dist', - banner: { js: '#!/usr/bin/env node' }, - external: ['yargs', 'open'], + +/** + * Backward-compatibility shim. + * Delegates to esbuild.config.mjs (the production build configuration). + * + * All E2E tests call `node build.js` in beforeAll — this shim ensures + * they continue to work without modification. + */ +const { execSync } = require('child_process'); + +execSync(`${process.execPath} esbuild.config.mjs`, { + cwd: __dirname, + stdio: 'inherit', }); diff --git a/config.validator.ts b/config.validator.ts deleted file mode 100644 index 7c1184e..0000000 --- a/config.validator.ts +++ /dev/null @@ -1,237 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2018-present Raman Marozau - -import * as fs from 'fs'; -import Ajv from 'ajv'; - -import { EXIT_CODES, VersioningsError } from './errors'; - -const schema = require('./version.schema.json'); - -const AVAILABLE_GIT_PLATFORMS: string[] = ['github', 'bitbucket']; - -interface GitPrConfig { - target: string; -} - -interface GitLimitsConfig { - branchMaxCommentLength: number; -} - -interface GitCommitSemverMessages { - prepatch: string; - patch: string; - preminor: string; - minor: string; - premajor: string; - major: string; - prerelease: string; -} - -interface GitCommitConfig { - message: { - semver: GitCommitSemverMessages; - }; -} - -interface GitBranchTypeConfig { - version: string; -} - -interface GitConfig { - platform: string | undefined; - url: string | undefined; - branchType: GitBranchTypeConfig; - pr: GitPrConfig; - limits: GitLimitsConfig; - remote: string; - commit: GitCommitConfig; -} - -interface PackageSemverConfig { - patch: string; - prepatch: string; - minor: string; - preminor: string; - premajor: string; - prerelease: string; - major: string; -} - -interface CommonMessages { - versionConfigDoesNotExist: string; - undefinedGitRepositoryUrl: string; - unavailableVersioningDirectory: string; - unavailableSemanticVersion: string; - undefinedVersionBranchName: string; - incorrectVersionBranchNameLength: string; - incorrectVersionBranchNameCharactersDashes: string; - versionBranchAlreadyExists: string; - untrackedGitFiles: string; - unavailableGitPlatform: string; - unavailableGitTargetBranch: string; - versionAlreadyExists: string; - versionAlreadyExistsTag: string; - versionAlreadyExistsBranch: string; - incorrectGitRemote: string; -} - -export interface VersioningsConfig { - git: GitConfig; - package: { - semver: PackageSemverConfig; - }; - common: { - messages: CommonMessages; - }; -} - -const defaultConfig: VersioningsConfig = { - git: { - platform: void 0, - url: void 0, - branchType: { - version: 'version', - }, - pr: { - target: 'master', - }, - limits: { - branchMaxCommentLength: 96, - }, - remote: 'origin', - commit: { - message: { - semver: { - prepatch: 'Patch version is preparing now: v%s.', - patch: 'Patch: v%s. You SHOULD consider changes.', - preminor: 'Minor version is preparing now: v%s.', - minor: 'Minor: v%s. You MUST consider changes.', - premajor: 'Release is preparing now: v%s.', - major: 'Release: v%s.', - prerelease: 'Preparing: v%s.', - }, - }, - }, - }, - package: { - semver: { - patch: 'patch', - prepatch: 'prepatch', - minor: 'minor', - preminor: 'preminor', - premajor: 'premajor', - prerelease: 'prerelease', - major: 'major', - }, - }, - common: { - messages: { - versionConfigDoesNotExist: 'Version configuration DOES NOT exist. Define ./version.json file.', - undefinedGitRepositoryUrl: 'Git repository URL is undefined. Define correct git.url in ./version.json file.', - unavailableVersioningDirectory: 'Get back to the root directory that contains project package.json.', - unavailableSemanticVersion: 'Semantic version is unavailable. Define correct --semver CLI parameter.', - undefinedVersionBranchName: 'Version branch name is undefined. Define correct --branch CLI parameter.', - incorrectVersionBranchNameLength: 'Correct --branch CLI parameter MUST have length less', - incorrectVersionBranchNameCharactersDashes: 'Correct --branch CLI parameter MUST NOT contain multi dashes, "--".', - versionBranchAlreadyExists: 'Version branch already exists.', - untrackedGitFiles: 'You have untracked git files. Commit changes and try again.', - unavailableGitPlatform: `Git platform is unavailable. Define correct git.platform in ./version.json file. Available platforms: ${AVAILABLE_GIT_PLATFORMS.join(', ')}.`, - unavailableGitTargetBranch: 'Git target branch is unavailable. Define correct git.pr.target in ./version.json file.', - versionAlreadyExists: 'Version number already exists.', - versionAlreadyExistsTag: 'Version number already exists. Pay attention to git version tags.', - versionAlreadyExistsBranch: 'Version number already exists. Pay attention to git version branches.', - incorrectGitRemote: 'Git remote is unavailable. Define correct config git.url, local Git remote.', - }, - }, -}; - -/** - * Loads, validates, and merges version.json configuration. - * - * @param configPath — path to version.json - * @returns validated and merged config - * @throws VersioningsError — EXIT_CODES.CONFIG_ERROR - */ -export function loadAndValidateConfig(configPath: string): VersioningsConfig { - // 1. Check file existence - if (!fs.existsSync(configPath)) { - throw new VersioningsError( - EXIT_CODES.CONFIG_ERROR, - 'Version configuration does not exist.', - { expectedPath: configPath } - ); - } - - // 2. Parse JSON - let raw: string; - try { - raw = fs.readFileSync(configPath, 'utf8'); - } catch (err: any) { - throw new VersioningsError( - EXIT_CODES.CONFIG_ERROR, - `Cannot read configuration file: ${err.message}`, - { expectedPath: configPath } - ); - } - - let versionConfig: any; - try { - versionConfig = JSON.parse(raw); - } catch (err: any) { - const details: Record = { parseError: err.message }; - if (typeof err.message === 'string') { - const posMatch = err.message.match(/position\s+(\d+)/i); - if (posMatch) { - const position = Number(posMatch[1]); - details.position = position; - const prefix = raw.substring(0, position); - details.line = prefix.split('\n').length; - } - } - throw new VersioningsError( - EXIT_CODES.CONFIG_ERROR, - 'Invalid JSON in configuration file.', - details - ); - } - - // 3. Validate against JSON Schema using ajv - const ajv = new Ajv({ allErrors: true }); - const validate = ajv.compile(schema); - const valid = validate(versionConfig); - - if (!valid) { - const errors = validate.errors!.map((err) => ({ - path: err.instancePath || '/', - message: err.message, - params: err.params, - })); - throw new VersioningsError( - EXIT_CODES.CONFIG_ERROR, - 'Configuration does not match schema.', - { validationErrors: errors } - ); - } - - // 4. Merge with defaultConfig and return - const gitConfig = versionConfig.git || {}; - const prConfig = gitConfig.pr || {}; - - const config: VersioningsConfig = { - ...defaultConfig, - git: { - ...defaultConfig.git, - url: gitConfig.url !== undefined ? gitConfig.url : defaultConfig.git.url, - platform: gitConfig.platform !== undefined ? gitConfig.platform : defaultConfig.git.platform, - pr: { - ...defaultConfig.git.pr, - target: prConfig.target !== undefined ? prConfig.target : defaultConfig.git.pr.target, - }, - }, - }; - - return config; -} - -export { schema }; diff --git a/docs/branch-strategy-cookbook.md b/docs/branch-strategy-cookbook.md new file mode 100644 index 0000000..55dffe8 --- /dev/null +++ b/docs/branch-strategy-cookbook.md @@ -0,0 +1,523 @@ +> **Navigation:** [Documentation Index](./index.md) · [Configuration Reference](./configuration-reference.md) + +# Branch Strategy Cookbook + +Versionings supports six branching strategies out of the box. Each strategy defines how version branches, tags, and commit messages are composed during a release. This guide covers every strategy with configuration examples, CLI usage, and branching diagrams. + +> **Note:** Strategy names used in configuration must exactly match the registry keys: `default`, `trunk-based`, `git-flow`, `release-branch`, `hotfix`, `maintenance`. + +## Strategy Comparison + +| Strategy | Creates Branch | Allowed Semver Types | Source Branch | Branch Format | Tag Format | +|----------|---------------|---------------------|---------------|---------------|------------| +| `default` | Yes | All | Any | `{branchType}/{semver}/{version}/{comment}` | `{version}--{comment}` | +| `trunk-based` | No | All | main / master | — (commits on main) | `v{version}` | +| `git-flow` | Yes | minor, major, preminor, premajor, prerelease → `release/*`; patch, prepatch → `hotfix/*` | develop (release) / main (hotfix) | `release/{version}` or `hotfix/{version}` | `v{version}` | +| `hotfix` | Yes | patch only | main / master | `hotfix/{version}` | `v{version}` | +| `release-branch` | Yes (reuse for patch) | All (patch gets reuse behavior) | Any | `release/{version}` | `v{version}` | +| `maintenance` | Yes (reuse if patch > 0) | patch only | support/* or main / master | `support/{major}.{minor}` | `v{version}` | + +## Choosing a Strategy + +**Solo developer or small team with a single release line:** +Use `trunk-based`. All changes land on main, tags mark releases. Minimal overhead. + +**Small team with feature branches and PR workflow:** +Use `default`. Each release gets its own branch with a descriptive comment. Good for teams that review version bumps via PRs. + +**Team following the git-flow model:** +Use `git-flow`. Release branches come from develop, hotfix branches from main. Familiar to teams already using git-flow conventions. + +**Product with long-lived release lines (e.g., v2.1.x, v2.2.x):** +Use `release-branch`. Patch releases reuse the existing release branch. Good for products that maintain multiple minor versions simultaneously. + +**Emergency production fixes only:** +Use `hotfix`. Restricted to patch releases from main. Use alongside another strategy for regular releases. + +**Maintaining older major/minor versions in parallel (LTS):** +Use `maintenance`. Long-lived support branches for older release lines. Only patch releases allowed. + +## Default Strategy + +The default strategy creates a unique branch for every release, encoding the semver type, version, and a descriptive comment in the branch name. This is the original Versionings behavior. + +**Workflow:** +1. Run release from any branch +2. A new branch is created: `version/{semverType}/{version}/{comment}` +3. Version bump, commit, and tag happen on the new branch +4. Push and open PR to merge back + +**Validation rules:** +- No source branch restriction — release can be initiated from any branch +- All semver types are allowed + +```text + any-branch + │ + ├── version/patch/1.2.3/fix-login + │ └── tag: 1.2.3--fix-login + │ + ├── version/minor/1.3.0/add-search + │ └── tag: 1.3.0--add-search + │ + └── version/major/2.0.0/breaking-api + └── tag: 2.0.0--breaking-api +``` + +**JSON configuration:** + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/owner/repo.git", + "branching": { + "strategy": "default" + } + } +} +``` + +**YAML configuration:** + +```yaml +git: + platform: github + url: "https://github.com/owner/repo.git" + branching: + strategy: default +``` + +**CLI example:** + +```bash +versionings plan --semver=patch --branch=fix-login +versionings release --semver=patch --branch=fix-login +``` + +**Expected results:** +- Branch: `version/patch/1.2.3/fix-login` +- Tag: `1.2.3--fix-login` + +## Trunk-Based Strategy + +All changes land directly on the main branch. No version branches are created — only tags mark each release. Ideal for teams practicing continuous delivery with short-lived feature branches. + +**Workflow:** +1. Ensure you are on main (or master) +2. Run release — version bump and commit happen on main +3. A tag is created: `v{version}` +4. Push the tag + +**Validation rules:** +- Current branch must be main or master (or the configured `mainBranch`) +- All semver types are allowed + +```text + main ─────●─────●─────●─────●───── + │ │ │ │ + v1.0.0 v1.0.1 v1.1.0 v2.0.0 +``` + +**JSON configuration:** + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/owner/repo.git", + "branching": { + "strategy": "trunk-based" + } + } +} +``` + +**YAML configuration:** + +```yaml +git: + platform: github + url: "https://github.com/owner/repo.git" + branching: + strategy: trunk-based +``` + +**CLI example:** + +```bash +versionings plan --semver=minor +versionings release --semver=minor +``` + +**Expected results:** +- Branch: none (commit on main) +- Tag: `v1.1.0` + +## Git-Flow Strategy + +Implements the git-flow branching model. Release branches are created from develop for minor/major releases. Hotfix branches are created from main for patch releases. + +**Workflow (release):** +1. Switch to develop +2. Run release with minor or major — creates `release/{version}` from develop +3. Tag `v{version}` is created +4. Merge release branch back to main and develop + +**Workflow (hotfix):** +1. Switch to main +2. Run release with patch — creates `hotfix/{version}` from main +3. Tag `v{version}` is created +4. Merge hotfix branch back to main and develop + +**Semver routing:** +- `minor`, `major`, `preminor`, `premajor`, `prerelease` → `release/{version}` from develop +- `patch`, `prepatch` → `hotfix/{version}` from main + +**Validation rules:** +- Release types require current branch to be develop (or configured `developBranch`) +- Hotfix types require current branch to be main or master (or configured `mainBranch`) + +```text + main ─────────────────●──────────●─────── + │ │ + develop ──●──────●───────┤──────────┤─────── + \ / \ / \ + release/1.1.0 hotfix/1.1.1 + tag: v1.1.0 tag: v1.1.1 +``` + +**JSON configuration:** + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/owner/repo.git", + "branching": { + "strategy": "git-flow", + "mainBranch": "main", + "developBranch": "develop" + } + } +} +``` + +**YAML configuration:** + +```yaml +git: + platform: github + url: "https://github.com/owner/repo.git" + branching: + strategy: git-flow + mainBranch: main + developBranch: develop +``` + +**CLI example (release):** + +```bash +git checkout develop +versionings plan --semver=minor +versionings release --semver=minor +``` + +**CLI example (hotfix):** + +```bash +git checkout main +versionings plan --semver=patch --branch=urgent-fix +versionings release --semver=patch --branch=urgent-fix +``` + +**Expected results (minor release):** +- Branch: `release/1.1.0` +- Tag: `v1.1.0` + +**Expected results (patch hotfix):** +- Branch: `hotfix/1.1.1` +- Tag: `v1.1.1` + +## Hotfix Strategy + +Dedicated strategy for emergency fixes from the main branch. Only patch releases are allowed. Creates a `hotfix/{version}` branch from main. + +**Workflow:** +1. Switch to main +2. Run release with patch — creates `hotfix/{version}` +3. Apply the fix, tag `v{version}` +4. Merge back to main + +**Validation rules:** +- Only `patch` semver type is allowed — other types are rejected +- Current branch must be main or master (or configured `mainBranch`) + +```text + main ──────●──────────────●────── + \ / + hotfix/1.2.1 + tag: v1.2.1 +``` + +**JSON configuration:** + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/owner/repo.git", + "branching": { + "strategy": "hotfix" + } + } +} +``` + +**YAML configuration:** + +```yaml +git: + platform: github + url: "https://github.com/owner/repo.git" + branching: + strategy: hotfix +``` + +**CLI example:** + +```bash +git checkout main +versionings plan --semver=patch --branch=critical-fix +versionings release --semver=patch --branch=critical-fix +``` + +**Expected results:** +- Branch: `hotfix/1.2.1` +- Tag: `v1.2.1` + +## Release Branch Strategy + +Long-lived release branches that are reused for patch releases. When a minor or major release is created, a new `release/{version}` branch is made. Subsequent patch releases reuse the existing `release/{major}.{minor}.0` branch. + +**Workflow (new release):** +1. Run release with minor — creates `release/1.2.0` +2. Tag `v1.2.0` is created + +**Workflow (patch on existing release):** +1. Run release with patch — reuses `release/1.2.0` (switches to existing branch) +2. Tag `v1.2.1` is created on the same branch + +**Validation rules:** +- No source branch restriction +- All semver types are allowed +- Patch releases with patch > 0 trigger branch reuse (`reuseBranch: true`) + +```text + main ──────●────────────────────────── + \ + release/1.2.0 + ├── tag: v1.2.0 + ├── tag: v1.2.1 (patch reuse) + └── tag: v1.2.2 (patch reuse) +``` + +**JSON configuration:** + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/owner/repo.git", + "branching": { + "strategy": "release-branch" + } + } +} +``` + +**YAML configuration:** + +```yaml +git: + platform: github + url: "https://github.com/owner/repo.git" + branching: + strategy: release-branch +``` + +**CLI example (new minor release):** + +```bash +versionings plan --semver=minor +versionings release --semver=minor +``` + +**CLI example (patch on existing release branch):** + +```bash +versionings plan --semver=patch +versionings release --semver=patch +``` + +**Expected results (minor):** +- Branch: `release/1.2.0` (new) +- Tag: `v1.2.0` + +**Expected results (patch):** +- Branch: `release/1.2.0` (reused) +- Tag: `v1.2.1` + +## Maintenance Strategy + +Long-term support branches for maintaining older release lines in parallel. Uses `support/{major}.{minor}` branches. Only patch releases are allowed. The branch is reused when the patch component is greater than 0. + +**Workflow (first patch on a new support line):** +1. Switch to main (or an existing support branch) +2. Run release with patch — creates `support/{major}.{minor}` +3. Tag `v{version}` is created + +**Workflow (subsequent patches):** +1. Switch to the existing `support/{major}.{minor}` branch +2. Run release with patch — reuses the branch +3. Tag `v{version}` is created + +**Validation rules:** +- Only `patch` semver type is allowed — other types are rejected +- Current branch must be a `support/*` branch, main, or master + +```text + main ──────●──────●────────────────── + \ \ + \ support/2.0 + \ ├── tag: v2.0.1 + \ └── tag: v2.0.2 (reuse) + \ + support/1.5 + ├── tag: v1.5.1 + └── tag: v1.5.2 (reuse) +``` + +**JSON configuration:** + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/owner/repo.git", + "branching": { + "strategy": "maintenance" + } + } +} +``` + +**YAML configuration:** + +```yaml +git: + platform: github + url: "https://github.com/owner/repo.git" + branching: + strategy: maintenance +``` + +**CLI example:** + +```bash +git checkout main +versionings plan --semver=patch +versionings release --semver=patch +``` + +**Expected results (first patch):** +- Branch: `support/1.5` (new) +- Tag: `v1.5.1` + +**Expected results (subsequent patch):** +- Branch: `support/1.5` (reused) +- Tag: `v1.5.2` + +## Custom Naming Templates + +All strategies support custom branch and tag naming via `git.branching.branchTemplate` and `git.branching.tagTemplate`. Templates use variable substitution with curly braces. + +**Available variables:** + +| Variable | Description | Example | +|----------|-------------|---------| +| `{version}` | Full semver version | `1.2.3` | +| `{major}` | Major version component | `1` | +| `{minor}` | Minor version component | `2` | +| `{patch}` | Patch version component | `3` | +| `{semver}` | Semver type (resolved via config) | `patch` | +| `{comment}` | Branch comment from `--branch` | `fix-login` | +| `{branchType}` | Value of `git.branchType.version` | `version` | + +**Example: custom branch and tag templates** + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/owner/repo.git", + "branching": { + "strategy": "default", + "branchTemplate": "v/{major}.{minor}/{comment}", + "tagTemplate": "release-{version}" + } + } +} +``` + +```yaml +git: + platform: github + url: "https://github.com/owner/repo.git" + branching: + strategy: default + branchTemplate: "v/{major}.{minor}/{comment}" + tagTemplate: "release-{version}" +``` + +With version `1.2.3` and comment `fix-login`: +- Branch: `v/1.2/fix-login` +- Tag: `release-1.2.3` + +**Example: prefix tags with project name** + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/owner/repo.git", + "branching": { + "strategy": "trunk-based", + "tagTemplate": "myapp-v{version}" + } + } +} +``` + +With version `2.0.0`: +- Tag: `myapp-v2.0.0` + +**Example: include semver type in branch name** + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/owner/repo.git", + "branching": { + "strategy": "default", + "branchTemplate": "release/{semver}/{version}" + } + } +} +``` + +With a minor release to version `1.3.0`: +- Branch: `release/minor/1.3.0` + +> For the full list of `git.branching` fields and their defaults, see the [Configuration Reference](./configuration-reference.md). diff --git a/docs/changelog-format-guide.md b/docs/changelog-format-guide.md new file mode 100644 index 0000000..d8bc68e --- /dev/null +++ b/docs/changelog-format-guide.md @@ -0,0 +1,268 @@ +> **Navigation:** [Documentation Index](./index.md) · [Configuration Reference](./configuration-reference.md) · [CLI Reference](./cli-reference.md) + +# Changelog Format Guide + +Versionings supports Conventional Commits for automatic version bump detection and structured changelog generation. This guide covers the commit message format, bump policy configuration, auto-bump behavior, and changelog output options. + +## Conventional Commits Format + +Versionings follows the [Conventional Commits 1.0.0](https://www.conventionalcommits.org/) specification. Each commit message follows this structure: + +```text +[()][!]: +``` + +| Component | Required | Description | +|-----------|----------|-------------| +| `type` | Yes | Category of the change (e.g., `feat`, `fix`). Determines the default version bump level. | +| `scope` | No | Parenthesized string identifying the section of the codebase affected (e.g., `auth`, `cli`). | +| `!` | No | Breaking change indicator. Placed after the scope (or type if no scope). Forces a `major` bump. | +| `description` | Yes | Short summary of the change. Follows the colon and space after the type/scope. | + +Examples: + +```text +feat: add search functionality +fix(auth): resolve token refresh on expiry +feat!: redesign public API endpoints +refactor(cli): simplify command routing +docs: update setup guide +feat(parser)!: change AST node format +``` + +A commit may also indicate a breaking change via a `BREAKING CHANGE` footer in the commit body, which has the same effect as the `!` indicator. + +## Commit Types + +Standard commit types recognized by Versionings and their default bump levels: + +| Type | Description | Default Bump | +|------|-------------|-------------| +| `feat` | New feature | `minor` | +| `fix` | Bug fix | `patch` | +| `perf` | Performance improvement | `patch` | +| `revert` | Revert a previous commit | `patch` | +| `chore` | Maintenance task | `none` | +| `docs` | Documentation change | `none` | +| `style` | Code style change | `none` | +| `refactor` | Code refactoring | `none` | +| `test` | Test addition or modification | `none` | +| `build` | Build system change | `none` | +| `ci` | CI configuration change | `none` | + +Types mapped to `none` do not trigger a version bump on their own and are excluded from the changelog by default. + +Breaking changes (indicated by `!` or a `BREAKING CHANGE` footer) always trigger a `major` bump regardless of the commit type. + +## Bump Policy + +The `conventionalCommits.types` configuration field controls the mapping from commit type to bump level. You can override default mappings or add custom types. + +Override `refactor` to trigger a `patch` bump and add a custom `deps` type: + +```json +{ + "conventionalCommits": { + "enabled": true, + "types": { + "refactor": "patch", + "deps": "patch" + } + } +} +``` + +YAML equivalent: + +```yaml +conventionalCommits: + enabled: true + types: + refactor: patch + deps: patch +``` + +Custom types are merged with the built-in defaults. Only the types you specify are overridden; all other types retain their default bump levels. + +Valid bump levels: `major`, `minor`, `patch`, `none`. + +See [Configuration Reference — conventionalCommits](./configuration-reference.md) for the full field specification. + +## Auto Bump + +When you pass `--semver=auto`, Versionings analyzes the commit history since the last version tag and determines the appropriate bump level automatically. + +How it works: + +1. Versionings finds the most recent version tag in the repository. +2. All commits between that tag and `HEAD` are parsed as Conventional Commits. +3. Each commit's type is mapped to a bump level using the configured bump policy. +4. Breaking changes (`!` or `BREAKING CHANGE` footer) are mapped to `major`. +5. The highest bump wins: `major` > `minor` > `patch`. + +```bash +versionings release --semver=auto --branch=next-release --ci +``` + +### Fallback Behavior + +When no conventional commits are found in the range (all commits are non-conventional), the `conventionalCommits.fallbackBump` setting determines what happens. + +Set fallback to `patch` — a patch bump is applied when no conventional commits are found: + +```json +{ + "conventionalCommits": { + "enabled": true, + "fallbackBump": "patch" + } +} +``` + +Set fallback to `null` (default) — the command fails with exit code 11 (`NO_CONVENTIONAL_COMMITS`) when no conventional commits are found: + +```json +{ + "conventionalCommits": { + "enabled": true, + "fallbackBump": null + } +} +``` + +Valid fallback values: `"patch"`, `"minor"`, `"major"`, `null`. + +When `fallbackBump` is `null` and no conventional commits exist, `--semver=auto` exits with code 11. See [Failure Matrix](./failure-matrix.md) for troubleshooting this exit code. + +## Changelog Configuration + +The `changelog` section in your configuration controls how changelogs are generated and formatted. + +### `changelog.file` + +Path to the changelog file for automatic updates during release. When set, the generated changelog is prepended to this file during `versionings release`. + +```json +{ + "changelog": { + "file": "CHANGELOG.md" + } +} +``` + +### `changelog.groupTitles` + +Maps commit types to section headings in the generated changelog. Default titles are provided for common types. + +```json +{ + "changelog": { + "groupTitles": { + "breaking": "BREAKING CHANGES", + "feat": "Features", + "fix": "Bug Fixes", + "perf": "Performance Improvements", + "revert": "Reverts", + "deps": "Dependency Updates" + } + } +} +``` + +### `changelog.excludeTypes` + +Array of commit types to exclude from the changelog. Types mapped to `none` in the bump policy are excluded automatically. + +```json +{ + "changelog": { + "excludeTypes": ["chore", "ci"] + } +} +``` + +### `changelog.includeNonConventional` + +Boolean flag controlling whether commits that do not follow the Conventional Commits format are included in the changelog under an "Other Changes" group. Defaults to `false`. + +```json +{ + "changelog": { + "includeNonConventional": true + } +} +``` + +## Generating Changelog + +The `versionings changelog` subcommand generates a changelog from commit history without modifying the repository. + +### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--from` | string | Last version tag | Starting point (tag or commit SHA) | +| `--to` | string | `HEAD` | Ending point (tag, branch, or commit SHA) | +| `--output` | string | — | Write changelog to a file instead of stdout | +| `--format` | string | `markdown` | Output format: `markdown` or `plain` | +| `--json` | boolean | `false` | Output structured JSON with groups and metadata | + +### Examples + +Generate changelog to stdout (from last tag to HEAD): + +```bash +versionings changelog +``` + +Write changelog to a file: + +```bash +versionings changelog --output CHANGELOG.md +``` + +Generate changelog for a specific range: + +```bash +versionings changelog --from v1.2.0 --to v1.3.0 +``` + +Generate plain text output: + +```bash +versionings changelog --format plain +``` + +Generate structured JSON output: + +```bash +versionings changelog --json +``` + +When writing to an existing file with `--output`, the new changelog section is inserted after the `# Changelog` header, preserving previous entries. + +See [CLI Reference — changelog](./cli-reference.md) for the full command specification. + +## Automatic Changelog on Release + +When `changelog.file` is set in your configuration, Versionings automatically generates and prepends a changelog entry to the specified file during `versionings release`. + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/user/repo" + }, + "changelog": { + "file": "CHANGELOG.md" + } +} +``` + +During release, the generated section is prepended to the file content. If the file does not exist, it is created. The changelog entry includes the new version number and the release date. + +This happens as part of the release workflow — no separate `versionings changelog` invocation is needed. + +--- + +See [Configuration Reference](./configuration-reference.md) for the full specification of `conventionalCommits` and `changelog` fields. See [CLI Reference](./cli-reference.md) for all `changelog` subcommand parameters and global flags. diff --git a/docs/ci-examples.md b/docs/ci-examples.md new file mode 100644 index 0000000..5d4674a --- /dev/null +++ b/docs/ci-examples.md @@ -0,0 +1,164 @@ +> **Navigation:** [Documentation Index](./index.md) · [SCM Provider Guide](./scm-provider-guide.md) · [Failure Matrix](./failure-matrix.md) · [Changelog Format Guide](./changelog-format-guide.md) + +# CI/CD Examples + +Ready-to-use CI/CD configurations for automating Versionings in your pipeline. Each example includes: repository checkout, Node.js setup, global install of `versionings`, a `versionings validate` step to verify configuration before release, and `versionings release` with `--ci` and `--json` flags for non-interactive, machine-readable output. Authentication tokens are passed via platform-specific secrets. + +## GitHub Actions + +Full workflow for GitHub Actions. The token is provided via `GITHUB_TOKEN` (built-in) or a custom `VERSIONINGS_TOKEN` secret for API-based PR creation. + +```yaml +name: Versionings Release + +on: + push: + branches: + - main + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Install Versionings + run: npm install -g versionings + + - name: Validate configuration + run: versionings validate --json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Release + run: versionings release --semver=patch --branch=ci-release --push --ci --json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +To use a custom token (e.g. for cross-repo PR creation), replace the `env` block: + +```yaml + - name: Release + run: versionings release --semver=patch --branch=ci-release --push --ci --json + env: + VERSIONINGS_TOKEN: ${{ secrets.VERSIONINGS_TOKEN }} +``` + +## GitLab CI + +Full `.gitlab-ci.yml` configuration. The token is provided via the `GITLAB_TOKEN` CI/CD variable configured in project settings. + +```yaml +image: node:18 + +stages: + - validate + - release + +validate: + stage: validate + script: + - npm install -g versionings + - versionings validate --json + variables: + GITLAB_TOKEN: $GITLAB_TOKEN + +release: + stage: release + script: + - npm install -g versionings + - versionings release --semver=patch --branch=ci-release --push --ci --json + variables: + GITLAB_TOKEN: $GITLAB_TOKEN + only: + - main +``` + +## Azure Pipelines + +Full `azure-pipelines.yml` configuration. The token is provided via the `AZURE_DEVOPS_TOKEN` pipeline variable configured in the pipeline settings or a variable group. + +```yaml +trigger: + branches: + include: + - main + +pool: + vmImage: ubuntu-latest + +steps: + - task: NodeTool@0 + inputs: + versionSpec: '18.x' + displayName: 'Setup Node.js' + + - script: npm install -g versionings + displayName: 'Install Versionings' + + - script: versionings validate --json + displayName: 'Validate configuration' + env: + AZURE_DEVOPS_TOKEN: $(AZURE_DEVOPS_TOKEN) + + - script: versionings release --semver=patch --branch=ci-release --push --ci --json + displayName: 'Release' + env: + AZURE_DEVOPS_TOKEN: $(AZURE_DEVOPS_TOKEN) +``` + +## Bitbucket Pipelines + +Full `bitbucket-pipelines.yml` configuration. The token is provided via the `BITBUCKET_TOKEN` repository variable configured in repository settings. + +```yaml +image: node:18 + +pipelines: + branches: + main: + - step: + name: Validate and Release + script: + - npm install -g versionings + - versionings validate --json + - versionings release --semver=patch --branch=ci-release --push --ci --json +``` + +The `BITBUCKET_TOKEN` variable must be configured as a secured repository variable in Bitbucket settings. Versionings resolves it automatically from the environment. + +## Auto-Bump with Conventional Commits + +Use `--semver=auto` to let Versionings determine the bump level from your commit history. This works with any CI platform. The example below uses GitHub Actions: + +```yaml + - name: Auto Release + run: versionings release --semver=auto --branch=auto-release --push --ci --json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +When using `--semver=auto`, Versionings analyzes commits since the last tag using the [Conventional Commits](https://www.conventionalcommits.org/) specification: + +- `feat:` → minor bump +- `fix:` → patch bump +- `feat!:` or `BREAKING CHANGE` footer → major bump + +If no conventional commits are found, the `conventionalCommits.fallbackBump` configuration determines the behavior. Set it to `"patch"`, `"minor"`, or `"major"` for a default bump, or `null` to exit with code 11 (`NO_CONVENTIONAL_COMMITS`). See the [Changelog Format Guide](./changelog-format-guide.md) for full configuration details. + +--- + +## See Also + +- [SCM Provider Guide](./scm-provider-guide.md) — platform-specific authentication setup and token scopes +- [Failure Matrix](./failure-matrix.md) — exit code reference for handling CI failures +- [Changelog Format Guide](./changelog-format-guide.md) — Conventional Commits configuration and bump policy diff --git a/docs/cli-reference.md b/docs/cli-reference.md new file mode 100644 index 0000000..239e0e1 --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,371 @@ +> **Navigation:** [Documentation Index](./index.md) · [Failure Matrix](./failure-matrix.md) · [Configuration Reference](./configuration-reference.md) + +# CLI Reference + +Complete reference for all Versionings CLI commands, flags, and output formats. + +## Global Flags + +These flags are available on every command. + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--json` | boolean | `false` | Output in JSON format | +| `--verbose` | boolean | `false` | Enable verbose output | +| `--ci` | boolean | `false` | Run in CI mode (non-interactive, no prompts) | +| `--non-interactive` | boolean | `false` | Disable interactive prompts | +| `--yes` / `-y` | boolean | `false` | Auto-confirm all prompts | +| `--strict` | boolean | `false` | Treat unknown config fields as errors | +| `--print-config` | boolean | `false` | Print resolved config with provenance and exit | + +## Commands + +### init + +Generate a configuration file. + +```bash +versionings init [--format=] +``` + +#### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--format` | string | No | — | Output format for the config file. Choices: `json`, `yaml` | + +#### Examples + +Local development — interactive config wizard: + +```bash +versionings init +``` + +Generate a YAML config file: + +```bash +versionings init --format=yaml +``` + +CI — generate JSON config non-interactively: + +```bash +versionings init --format=json --non-interactive +``` + +--- + +### validate + +Validate configuration and environment. This command does not modify the repository. + +```bash +versionings validate +``` + +#### Parameters + +No command-specific parameters. Use [global flags](#global-flags) as needed. + +#### Examples + +Local development — check config and git setup: + +```bash +versionings validate +``` + +CI — JSON output for automated checks: + +```bash +versionings validate --json +``` + +CI — fail on unknown config fields: + +```bash +versionings validate --strict +``` + +--- + +### plan + +Show execution plan without making changes (dry-run). This command does not modify the repository. + +```bash +versionings plan --semver= --branch= [options] +``` + +#### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--semver` | string | Yes | — | Semantic version type. Choices: `patch`, `prepatch`, `minor`, `preminor`, `premajor`, `prerelease`, `major`, `auto` | +| `--branch` | string | Yes | — | Branch comment / description | +| `--push` | boolean | No | `false` | Include push step in plan | +| `--preid` | string | No | — | Prerelease identifier | +| `--pr-mode` | string | No | `auto` | PR creation mode. Choices: `auto`, `api`, `url` | +| `--no-pr` | boolean | No | `false` | Skip PR/MR creation | + +#### Examples + +Local development — preview a patch release: + +```bash +versionings plan --semver=patch --branch=fix-login +``` + +CI — JSON plan for automated processing: + +```bash +versionings plan --semver=minor --branch=new-feature --json +``` + +--- + +### release + +Execute the versioning workflow. This command modifies the repository (creates branches, tags, bumps version). + +```bash +versionings release --semver= --branch= [options] +``` + +#### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--semver` | string | Yes | — | Semantic version type. Choices: `patch`, `prepatch`, `minor`, `preminor`, `premajor`, `prerelease`, `major`, `auto` | +| `--branch` | string | Yes | — | Branch comment / description | +| `--push` | boolean | No | `false` | Push branch and tags to remote | +| `--preid` | string | No | — | Prerelease identifier | +| `--dry-run` | boolean | No | `false` | Show plan without executing (equivalent to `plan`) | +| `--pr-mode` | string | No | `auto` | PR creation mode. Choices: `auto`, `api`, `url` | +| `--no-pr` | boolean | No | `false` | Skip PR/MR creation | + +#### Examples + +Local development — interactive patch release: + +```bash +versionings release --semver=patch --branch=fix-login +``` + +Push and auto-confirm — minor release without prompts: + +```bash +versionings release --semver=minor --branch=feat --push --yes +``` + +CI — fully non-interactive release: + +```bash +versionings release --semver=patch --branch=fix --ci +``` + +--- + +### rollback + +Rollback the last versioning operation. Reverses completed mutation steps in LIFO order. + +```bash +versionings rollback [--from=] +``` + +#### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--from` | string | No | — | Path to a specific operation log file | + +#### Examples + +Local development — rollback the most recent operation: + +```bash +versionings rollback +``` + +Auto-confirm rollback without prompts: + +```bash +versionings rollback --yes +``` + +Rollback a specific operation by log file: + +```bash +versionings rollback --from=.versionings/operations/2025-01-15.json +``` + +--- + +### doctor + +Diagnose environment and configuration. This command does not modify the repository. + +```bash +versionings doctor +``` + +#### Parameters + +No command-specific parameters. Use [global flags](#global-flags) as needed. + +#### Examples + +Local development — run all diagnostics: + +```bash +versionings doctor +``` + +CI — JSON diagnostics for automated processing: + +```bash +versionings doctor --json +``` + +--- + +### changelog + +Generate changelog from commit history. This command does not modify the repository unless `--output` is specified. + +```bash +versionings changelog [options] +``` + +#### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `--from` | string | No | — | Start tag or commit SHA | +| `--to` | string | No | `HEAD` | End tag or commit SHA | +| `--output` | string | No | — | Write changelog to file | +| `--format` | string | No | `markdown` | Output format. Choices: `markdown`, `plain` | + +#### Examples + +Generate changelog to stdout: + +```bash +versionings changelog +``` + +Write changelog to a file: + +```bash +versionings changelog --output=CHANGELOG.md +``` + +Generate changelog for a specific range: + +```bash +versionings changelog --from=v1.0.0 --to=v2.0.0 +``` + +CI — structured JSON output: + +```bash +versionings changelog --json +``` + +## Backward Compatibility + +For backward compatibility, invoking `versionings` without a subcommand but with `--semver` and `--branch` flags is equivalent to `versionings release`: + +```bash +# Legacy syntax (still supported) +versionings --semver=patch --branch=fix-login + +# Equivalent modern syntax +versionings release --semver=patch --branch=fix-login +``` + +The CLI automatically prepends the `release` subcommand when `--semver` and `--branch` are present without a recognized subcommand. + +## Interactive and Non-Interactive Modes + +Versionings supports two interaction modes that control how prompts and confirmations are handled. + +### Interactive Mode (default) + +When running in a terminal (TTY), Versionings operates in interactive mode by default. In this mode: + +- Confirmation prompts are displayed before mutation operations (release, rollback) +- The `init` command runs an interactive configuration wizard +- Progress output is displayed with formatting + +### Non-Interactive Mode + +Non-interactive mode disables all prompts and is activated by any of the following flags: + +- `--ci` — designed for CI/CD pipelines; implies non-interactive behavior and suppresses all prompts +- `--non-interactive` — explicitly disables interactive prompts +- `--yes` / `-y` — auto-confirms all prompts (operations proceed without user confirmation) + +In non-interactive mode, operations that require confirmation proceed automatically. Combine with `--json` for machine-readable output in CI pipelines: + +```bash +versionings release --semver=patch --branch=fix --ci --json +``` + +## Exit Codes + +| Code | Name | Category | Description | +|------|------|----------|-------------| +| 0 | SUCCESS | Success | Successful completion | +| 1 | CONFIG_ERROR | User/Config | Configuration error | +| 2 | DIRTY_TREE | User/Config | Uncommitted changes in working tree | +| 3 | INVALID_ARGS | User/Config | Invalid CLI arguments | +| 4 | ARTIFACT_CONFLICT | User/Config | Branch or tag already exists | +| 5 | COMMAND_FAILED | Runtime | Git/npm command failed | +| 6 | NETWORK_ERROR | Runtime | Network error | +| 7 | INCOMPLETE_ROLLBACK | Runtime | Rollback could not complete all steps | +| 8 | NO_OPERATION | User/Config | Nothing to rollback | +| 9 | USER_CANCELLED | User/Config | User cancelled the operation | +| 10 | POLICY_VIOLATION | User/Config | Branch protection policy violated | +| 11 | NO_CONVENTIONAL_COMMITS | User/Config | No conventional commits found for auto-bump | + +For detailed causes and remediation steps, see the [Failure Matrix](./failure-matrix.md). + +## JSON Output Format + +When `--json` is passed, Versionings outputs a single JSON object. No ANSI codes or progress text are included, making it safe for CI script consumption. + +### Success Output + +```json +{ + "ok": true, + "code": 0, + "message": "Release completed successfully", + "data": { + "version": "1.2.3", + "branch": "version/patch/1.2.3/fix-login", + "tag": "1.2.3--fix-login", + "semver": "patch", + "pushed": false + } +} +``` + +### Error Output + +```json +{ + "ok": false, + "code": 2, + "message": "Working tree has uncommitted changes", + "errors": [ + { + "field": "git.status", + "message": "Uncommitted changes detected: 3 modified files" + } + ] +} +``` diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md new file mode 100644 index 0000000..331b4db --- /dev/null +++ b/docs/configuration-reference.md @@ -0,0 +1,574 @@ +> **Navigation:** [Documentation Index](./index.md) · [CLI Reference](./cli-reference.md) · [Branch Strategy Cookbook](./branch-strategy-cookbook.md) · [SCM Provider Guide](./scm-provider-guide.md) · [Changelog Format Guide](./changelog-format-guide.md) + +# Configuration Reference + +Versionings loads configuration from multiple sources, merges them with a well-defined priority order, and validates the result against a JSON Schema. This document describes every configuration field, all supported sources, environment variable mappings, and advanced features like Config Provenance and strict mode. + +## Configuration Sources + +Configuration is collected from six sources. When the same field appears in more than one source, the higher-priority source wins. Sources are listed below from lowest to highest priority: + +| Priority | Source | Description | +|----------|--------|-------------| +| 1 (lowest) | Built-in defaults | Hard-coded values shipped with the CLI | +| 2 | `version.json` | Project-level JSON config file in the repository root | +| 3 | RC files | `.versioningsrc`, `.versioningsrc.json`, `.versioningsrc.yml`, or `.versioningsrc.yaml` in the repository root | +| 4 | `package.json` | The `"versionings"` key inside `package.json` | +| 5 | Environment variables | Variables prefixed with `VERSIONINGS_` | +| 6 (highest) | CLI arguments | Flags and options passed on the command line | + +If multiple RC files are present, only the first one found (in the order listed above) is used; the rest are ignored with a warning. + +## Configuration Fields + +The table below lists every configuration field. Dot-notation paths correspond to nested JSON keys (e.g., `git.pr.target` means `{ "git": { "pr": { "target": "..." } } }`). + +| Path | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `git.platform` | string (enum) | — | Yes | SCM platform: `github`, `github-enterprise`, `bitbucket`, `bitbucket-server`, `gitlab`, `azure-devops` | +| `git.url` | string | — | Yes | Repository URL (HTTPS or SSH) | +| `git.apiUrl` | string | — | Conditional | API base URL for self-hosted platforms. Required when `platform` is `github-enterprise` or `bitbucket-server` | +| `git.remote` | string | `origin` | No | Git remote name | +| `git.branchType.version` | string | `version` | No | Branch type prefix used in branch naming | +| `git.pr.target` | string | `master` | No | Default target branch for pull requests | +| `git.pr.reviewers` | string[] | — | No | List of reviewer usernames for PRs | +| `git.pr.labels` | string[] | — | No | Labels to apply to PRs | +| `git.pr.draft` | boolean | `false` | No | Create PR as draft | +| `git.pr.template` | string | — | No | Path to PR body template file | +| `git.pr.milestone` | string | — | No | Milestone to associate with the PR | +| `git.pr.linkedIssues` | string[] | — | No | Issue identifiers to link to the PR | +| `git.auth.token` | string | — | No | Authentication token for SCM API calls | +| `git.auth.method` | string (enum) | `token` | No | Auth method: `token` or `bearer` | +| `git.api.timeout` | integer | `30000` | No | API request timeout in milliseconds (1000–120000) | +| `git.limits.branchMaxCommentLength` | integer | `96` | No | Maximum length of the branch comment segment | +| `git.commit.message.semver.patch` | string | `Patch: v%s. You SHOULD consider changes.` | No | Commit message template for patch bumps | +| `git.commit.message.semver.minor` | string | `Minor: v%s. You MUST consider changes.` | No | Commit message template for minor bumps | +| `git.commit.message.semver.major` | string | `Release: v%s.` | No | Commit message template for major bumps | +| `git.commit.message.semver.prepatch` | string | `Patch version is preparing now: v%s.` | No | Commit message template for prepatch bumps | +| `git.commit.message.semver.preminor` | string | `Minor version is preparing now: v%s.` | No | Commit message template for preminor bumps | +| `git.commit.message.semver.premajor` | string | `Release is preparing now: v%s.` | No | Commit message template for premajor bumps | +| `git.commit.message.semver.prerelease` | string | `Preparing: v%s.` | No | Commit message template for prerelease bumps | +| `git.branching.strategy` | string (enum) | `default` | No | Branching strategy: `default`, `trunk-based`, `git-flow`, `release-branch`, `hotfix`, `maintenance` | +| `git.branching.branchTemplate` | string | — | No | Custom branch naming template | +| `git.branching.tagTemplate` | string | — | No | Custom tag naming template | +| `git.branching.mainBranch` | string | `master` | No | Main branch name | +| `git.branching.developBranch` | string | `develop` | No | Development branch name (used by git-flow) | +| `conventionalCommits.enabled` | boolean | `true` | No | Enable Conventional Commits parsing | +| `conventionalCommits.types` | object | See below | No | Mapping of commit type to bump level | +| `conventionalCommits.fallbackBump` | string \| null | `null` | No | Bump level when no conventional commits found: `patch`, `minor`, `major`, or `null` | +| `changelog.template` | string | — | No | Custom changelog template | +| `changelog.groupTitles` | object | — | No | Mapping of commit type to changelog group heading | +| `changelog.excludeTypes` | string[] | — | No | Commit types to exclude from changelog | +| `changelog.includeNonConventional` | boolean | `false` | No | Include non-conventional commits in changelog | +| `changelog.file` | string | — | No | Path to changelog file for automatic updates on release | +| `logLevel` | string (enum) | `warn` | No | Log verbosity: `debug`, `info`, `warn`, `error` | +| `lockTimeoutMs` | integer | `300000` | No | Lock acquisition timeout in milliseconds | + +## Section: git + +The `git` section contains all repository and SCM platform settings. + +### git.platform + +SCM platform identifier. Determines which API client is used for PR creation and remote operations. + +| Value | Platform | +|-------|----------| +| `github` | GitHub Cloud (github.com) | +| `github-enterprise` | GitHub Enterprise Server (self-hosted) | +| `bitbucket` | Bitbucket Cloud (bitbucket.org) | +| `bitbucket-server` | Bitbucket Server / Data Center (self-hosted) | +| `gitlab` | GitLab (Cloud or self-hosted) | +| `azure-devops` | Azure DevOps Services / Server | + +### git.url + +Repository URL in HTTPS or SSH format. Used to identify the repository and construct API endpoints. + +```bash +# HTTPS +https://github.com/my-org/my-repo.git + +# SSH +git@github.com:my-org/my-repo.git +``` + +### git.apiUrl + +API base URL for self-hosted platforms. Required when `git.platform` is `github-enterprise` or `bitbucket-server`. Must start with `http://` or `https://`. + +```json +{ + "git": { + "platform": "github-enterprise", + "url": "https://github.example.com/my-org/my-repo.git", + "apiUrl": "https://github.example.com/api/v3" + } +} +``` + +### git.remote + +Name of the Git remote. Default: `origin`. + +### git.branchType.version + +Branch type prefix used in the default branch naming pattern `{branchType}/{semver}/{version}/{comment}`. Default: `version`. + +### git.pr + +Pull request configuration fields: + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `target` | string | `master` | Target branch for PRs | +| `reviewers` | string[] | — | Reviewer usernames | +| `labels` | string[] | — | PR labels | +| `draft` | boolean | `false` | Create as draft PR | +| `template` | string | — | Path to PR body template file | +| `milestone` | string | — | Milestone name or ID | +| `linkedIssues` | string[] | — | Issue references to link | + +### git.auth + +Authentication settings for SCM API calls: + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `token` | string | — | Authentication token | +| `method` | enum | `token` | Auth method: `token` (header-based) or `bearer` (Authorization: Bearer) | + +See the [SCM Provider Guide](./scm-provider-guide.md) for platform-specific token setup. + +### git.api + +API client settings: + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `timeout` | integer | `30000` | Request timeout in milliseconds. Valid range: 1000–120000 | + +### git.limits + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `branchMaxCommentLength` | integer | `96` | Maximum character length for the branch comment segment | + +### git.commit.message.semver + +Commit message templates for each semver bump type. Use `%s` as a placeholder for the version number. + +| Field | Default | +|-------|---------| +| `patch` | `Patch: v%s. You SHOULD consider changes.` | +| `minor` | `Minor: v%s. You MUST consider changes.` | +| `major` | `Release: v%s.` | +| `prepatch` | `Patch version is preparing now: v%s.` | +| `preminor` | `Minor version is preparing now: v%s.` | +| `premajor` | `Release is preparing now: v%s.` | +| `prerelease` | `Preparing: v%s.` | + +## Section: git.branching + +The `git.branching` section controls which branching strategy is used and how branches and tags are named. See the [Branch Strategy Cookbook](./branch-strategy-cookbook.md) for detailed workflow descriptions and examples for each strategy. + +### git.branching.strategy + +Branching strategy to use. Each strategy defines its own rules for branch creation, allowed semver types, and naming conventions. + +| Value | Description | +|-------|-------------| +| `default` | Standard version branch workflow (default) | +| `trunk-based` | All changes land on the main branch | +| `git-flow` | Feature/develop/release/hotfix branch model | +| `release-branch` | Long-lived release branches | +| `hotfix` | Emergency fix workflow from main branch | +| `maintenance` | Parallel maintenance of older release lines | + +### git.branching.branchTemplate + +Custom branch naming template. Overrides the strategy's default branch name format. Available variables: `{version}`, `{major}`, `{minor}`, `{patch}`, `{semver}`, `{comment}`, `{branchType}`. + +```json +{ + "git": { + "branching": { + "branchTemplate": "release/{version}" + } + } +} +``` + +### git.branching.tagTemplate + +Custom tag naming template. Overrides the strategy's default tag format. Available variables: `{version}`, `{major}`, `{minor}`, `{patch}`, `{semver}`, `{comment}`, `{branchType}`. + +```json +{ + "git": { + "branching": { + "tagTemplate": "v{version}" + } + } +} +``` + +### git.branching.mainBranch + +Name of the main (production) branch. Default: `master`. + +### git.branching.developBranch + +Name of the development branch, used by the `git-flow` strategy. Default: `develop`. + +## Section: conventionalCommits + +The `conventionalCommits` section controls how commit messages are parsed and how version bumps are determined from commit history. + +### conventionalCommits.enabled + +Enable or disable Conventional Commits parsing. When enabled, commit messages following the `[()][!]: ` format are analyzed to determine the appropriate version bump. Default: `true`. + +### conventionalCommits.types + +Object mapping commit type strings to bump levels. Each key is a commit type (e.g., `feat`, `fix`) and each value is one of `major`, `minor`, `patch`, or `none`. + +Default mapping: + +| Type | Bump Level | +|------|-----------| +| `feat` | `minor` | +| `fix` | `patch` | +| `perf` | `patch` | +| `revert` | `patch` | +| `chore` | `none` | +| `docs` | `none` | +| `style` | `none` | +| `refactor` | `none` | +| `test` | `none` | +| `build` | `none` | +| `ci` | `none` | + +You can override individual types or add new ones: + +```json +{ + "conventionalCommits": { + "types": { + "refactor": "patch", + "deps": "patch" + } + } +} +``` + +### conventionalCommits.fallbackBump + +Bump level to use when `--semver=auto` finds no conventional commits in the range. Accepts `patch`, `minor`, `major`, or `null`. When set to `null`, the operation exits with code 11 (`NO_CONVENTIONAL_COMMITS`) if no conventional commits are found. Default: `null`. + +See the [Changelog Format Guide](./changelog-format-guide.md) for more on Conventional Commits configuration. + +## Section: changelog + +The `changelog` section controls changelog generation behavior. + +### changelog.template + +Custom template string for changelog output. When not set, the built-in template is used. + +### changelog.groupTitles + +Object mapping commit types to human-readable group headings in the changelog. + +```json +{ + "changelog": { + "groupTitles": { + "feat": "Features", + "fix": "Bug Fixes", + "perf": "Performance Improvements", + "revert": "Reverts", + "docs": "Documentation", + "refactor": "Code Refactoring" + } + } +} +``` + +### changelog.excludeTypes + +Array of commit types to exclude from the generated changelog. + +```json +{ + "changelog": { + "excludeTypes": ["chore", "ci", "test", "build", "style"] + } +} +``` + +### changelog.includeNonConventional + +When `true`, commits that do not follow the Conventional Commits format are included in the changelog under a separate group. Default: `false`. + +### changelog.file + +Path to a changelog file that is automatically updated during `release`. When set, the generated changelog is prepended to this file. + +```json +{ + "changelog": { + "file": "CHANGELOG.md" + } +} +``` + +See the [Changelog Format Guide](./changelog-format-guide.md) for detailed changelog configuration examples. + +## Environment Variables + +Environment variables with the `VERSIONINGS_` prefix map to configuration fields. They have priority 5 (above file-based sources, below CLI arguments). + +| Environment Variable | Config Path | +|---|---| +| `VERSIONINGS_GIT_PLATFORM` | `git.platform` | +| `VERSIONINGS_GIT_URL` | `git.url` | +| `VERSIONINGS_GIT_PR_TARGET` | `git.pr.target` | +| `VERSIONINGS_GIT_REMOTE` | `git.remote` | +| `VERSIONINGS_GIT_BRANCH_TYPE_VERSION` | `git.branchType.version` | +| `VERSIONINGS_GIT_API_URL` | `git.apiUrl` | +| `VERSIONINGS_GIT_AUTH_TOKEN` | `git.auth.token` | +| `VERSIONINGS_GIT_API_TIMEOUT` | `git.api.timeout` | +| `VERSIONINGS_GIT_BRANCHING_STRATEGY` | `git.branching.strategy` | +| `VERSIONINGS_GIT_BRANCHING_MAIN_BRANCH` | `git.branching.mainBranch` | +| `VERSIONINGS_GIT_BRANCHING_DEVELOP_BRANCH` | `git.branching.developBranch` | +| `VERSIONINGS_CONVENTIONAL_COMMITS_ENABLED` | `conventionalCommits.enabled` | +| `VERSIONINGS_CONVENTIONAL_COMMITS_FALLBACK_BUMP` | `conventionalCommits.fallbackBump` | +| `VERSIONINGS_LOG_LEVEL` | `logLevel` | +| `VERSIONINGS_LOCK_TIMEOUT_MS` | `lockTimeoutMs` | + +Boolean variables accept `true` or `false` (case-insensitive). The special value `null` for `VERSIONINGS_CONVENTIONAL_COMMITS_FALLBACK_BUMP` is passed as the literal string `null`. Numeric variables (e.g., `VERSIONINGS_LOCK_TIMEOUT_MS`) are automatically coerced to integers. + +Only the variables listed above are recognized. Unknown `VERSIONINGS_*` variables are ignored in default mode and produce errors in strict mode (see [Unknown Keys Policy](#unknown-keys-policy)). + +## Full Configuration Example + +### JSON + +A complete `version.json` configuration: + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/my-org/my-repo.git", + "remote": "origin", + "pr": { + "target": "main", + "reviewers": ["alice", "bob"], + "labels": ["release"], + "draft": false + }, + "branching": { + "strategy": "git-flow", + "mainBranch": "main", + "developBranch": "develop" + }, + "commit": { + "message": { + "semver": { + "patch": "fix: bump to v%s", + "minor": "feat: bump to v%s", + "major": "release: v%s", + "prepatch": "pre-patch: v%s", + "preminor": "pre-minor: v%s", + "premajor": "pre-major: v%s", + "prerelease": "prerelease: v%s" + } + } + } + }, + "conventionalCommits": { + "enabled": true, + "types": { + "feat": "minor", + "fix": "patch", + "perf": "patch", + "revert": "patch", + "chore": "none", + "docs": "none", + "style": "none", + "refactor": "none", + "test": "none", + "build": "none", + "ci": "none" + }, + "fallbackBump": "patch" + }, + "changelog": { + "file": "CHANGELOG.md", + "groupTitles": { + "feat": "Features", + "fix": "Bug Fixes", + "perf": "Performance" + }, + "excludeTypes": ["chore", "ci", "test"], + "includeNonConventional": false + } +} +``` + +### YAML + +The equivalent configuration as `.versioningsrc.yml`: + +```yaml +git: + platform: github + url: https://github.com/my-org/my-repo.git + remote: origin + pr: + target: main + reviewers: + - alice + - bob + labels: + - release + draft: false + branching: + strategy: git-flow + mainBranch: main + developBranch: develop + commit: + message: + semver: + patch: "fix: bump to v%s" + minor: "feat: bump to v%s" + major: "release: v%s" + prepatch: "pre-patch: v%s" + preminor: "pre-minor: v%s" + premajor: "pre-major: v%s" + prerelease: "prerelease: v%s" + +conventionalCommits: + enabled: true + types: + feat: minor + fix: patch + perf: patch + revert: patch + chore: none + docs: none + style: none + refactor: none + test: none + build: none + ci: none + fallbackBump: patch + +changelog: + file: CHANGELOG.md + groupTitles: + feat: Features + fix: Bug Fixes + perf: Performance + excludeTypes: + - chore + - ci + - test + includeNonConventional: false +``` + +## Config Provenance + +Use the `--print-config` flag to display the fully resolved configuration along with the source of each field. This is useful for debugging which source is providing a particular value. + +```bash +versionings release --print-config +``` + +Example output: + +```json +{ + "git.platform": { + "value": "github", + "source": "version.json" + }, + "git.url": { + "value": "https://github.com/my-org/my-repo.git", + "source": "version.json" + }, + "git.remote": { + "value": "origin", + "source": "defaults" + }, + "git.pr.target": { + "value": "main", + "source": "env" + }, + "git.branching.strategy": { + "value": "git-flow", + "source": "cli" + } +} +``` + +Each field shows its resolved `value` and the `source` that provided it. Possible source names: + +| Source | Meaning | +|--------|---------| +| `defaults` | Built-in default value | +| `version.json` | Loaded from `version.json` | +| `.versioningsrc` | Loaded from an RC file (also `.versioningsrc.json`, `.versioningsrc.yml`, `.versioningsrc.yaml`) | +| `package.json#versionings` | Loaded from the `"versionings"` key in `package.json` | +| `env` | Set via a `VERSIONINGS_*` environment variable | +| `cli` | Passed as a CLI argument | + +See the [CLI Reference](./cli-reference.md) for details on the `--print-config` flag. + +## Unknown Keys Policy + +By default, unrecognized keys in configuration files produce a warning but do not prevent execution. This allows forward-compatible configs where newer fields are ignored by older CLI versions. + +In strict mode, unknown keys cause a validation error (exit code 1, `CONFIG_ERROR`). Strict mode is activated by either: + +- The `--strict` CLI flag: + +```bash +versionings validate --strict +``` + +- The `VERSIONINGS_STRICT` environment variable: + +```bash +VERSIONINGS_STRICT=true versionings validate +``` + +Strict mode is recommended for CI environments to catch configuration typos early. + +## JSON Schema + +The configuration is validated at runtime against `version.schema.json` using [Ajv](https://ajv.js.org/). You can use this schema in your IDE for autocompletion and inline validation. + +For VS Code, add to your `.vscode/settings.json`: + +```json +{ + "json.schemas": [ + { + "fileMatch": ["version.json", ".versioningsrc.json"], + "url": "./node_modules/versionings/version.schema.json" + } + ] +} +``` + +For JetBrains IDEs (WebStorm, IntelliJ), map the schema to `version.json` via Settings → Languages & Frameworks → Schemas and DTDs → JSON Schema Mappings. + +The schema enforces: +- Required fields: `git.platform` and `git.url` +- Enum constraints on `git.platform`, `git.branching.strategy`, `git.auth.method`, `conventionalCommits.fallbackBump`, and `logLevel` +- Conditional requirement: `git.apiUrl` is required when `git.platform` is `github-enterprise` or `bitbucket-server` +- Type constraints: integers for timeouts and limits, booleans for flags, arrays for lists +- No additional properties at the top level or within `conventionalCommits`, `changelog`, `git.auth`, `git.api`, and `git.branching` sections diff --git a/docs/failure-matrix.md b/docs/failure-matrix.md new file mode 100644 index 0000000..79b9f9e --- /dev/null +++ b/docs/failure-matrix.md @@ -0,0 +1,356 @@ +> **Navigation:** [Documentation Index](./index.md) · [CLI Reference](./cli-reference.md) + +# Failure Matrix + +Complete reference for all Versionings exit codes, their causes, and remediation steps. Use this document to diagnose errors in local development and CI pipelines. + +## Exit Codes Overview + +| Code | Name | Category | Description | +|------|------|----------|-------------| +| 0 | SUCCESS | Success | Successful completion | +| 1 | CONFIG_ERROR | User/Config | Configuration error | +| 2 | DIRTY_TREE | User/Config | Uncommitted changes in working tree | +| 3 | INVALID_ARGS | User/Config | Invalid CLI arguments | +| 4 | ARTIFACT_CONFLICT | User/Config | Branch or tag already exists | +| 5 | COMMAND_FAILED | Runtime | Git/npm command failed | +| 6 | NETWORK_ERROR | Runtime | Network error | +| 7 | INCOMPLETE_ROLLBACK | Runtime | Rollback could not complete all steps | +| 8 | NO_OPERATION | User/Config | Nothing to rollback | +| 9 | USER_CANCELLED | User/Config | User cancelled the operation | +| 10 | POLICY_VIOLATION | User/Config | Branch protection policy violated | +| 11 | NO_CONVENTIONAL_COMMITS | User/Config | No conventional commits found for auto-bump | + +## Exit Code 0: SUCCESS + +A zero exit code indicates the operation completed without errors. When `--json` is used, the output is a single JSON object on stdout: + +```json +{ + "success": true, + "version": "1.2.3", + "branch": "version/patch/1.2.3/fix-login", + "tag": "1.2.3--fix-login" +} +``` + +In non-JSON mode, a human-readable summary is printed to stdout. No action is required. + +## Exit Code 1: CONFIG_ERROR + +Configuration file is missing, unreadable, or fails schema validation. + +**Typical causes:** + +- No configuration file found (`version.json`, `.versioningsrc`, `.versioningsrc.json`, `.versioningsrc.yml`, `.versioningsrc.yaml`, or `versionings` key in `package.json`) +- Configuration file contains invalid JSON or YAML syntax +- A required field is missing (e.g., `git.platform` or `git.url`) +- A field value does not match the expected type or enum (e.g., `git.platform: "unsupported"`) + +**Example error message:** + +```text +Error [CONFIG_ERROR]: Configuration validation failed + - git.platform: must be one of github, github-enterprise, bitbucket, bitbucket-server, gitlab, azure-devops + - git.url: is required +``` + +**Remediation steps:** + +1. Run `versionings validate --json` to see all validation errors. +2. Run `versionings doctor` to check environment and config health. +3. Fix the reported fields in your configuration file. +4. If unsure about the schema, refer to `version.schema.json` or run `versionings init` to generate a valid config. + +**Retryable:** No — fix the configuration first. + +## Exit Code 2: DIRTY_TREE + +The working tree has uncommitted changes. Versionings requires a clean working tree before performing mutations to ensure rollback safety. + +**Typical causes:** + +- Unstaged or staged changes detected by `git status --porcelain` +- Untracked files that are not in `.gitignore` + +**Example error message:** + +```text +Error [DIRTY_TREE]: Working tree has uncommitted changes + M src/index.ts + ?? temp.log +``` + +**Remediation steps:** + +1. Commit your changes: `git add . && git commit -m "save work"`. +2. Or stash them: `git stash`. +3. Or discard them: `git checkout -- .` (use with caution). +4. Re-run the versionings command. + +**Retryable:** Yes — after committing or stashing changes. + +## Exit Code 3: INVALID_ARGS + +The CLI was invoked with invalid or missing arguments. + +**Typical causes:** + +- Unknown subcommand (e.g., `versionings deploy`) +- Missing required flags (e.g., `--semver` or `--branch` for `release`) +- Invalid value for `--semver` (e.g., `--semver=huge`) + +**Example error message:** + +```text +Error [INVALID_ARGS]: Unknown argument: deploy + Available commands: init, validate, plan, release, rollback, doctor, changelog +``` + +**Remediation steps:** + +1. Check the command syntax: `versionings --help`. +2. Verify that `--semver` uses a valid value: `patch`, `prepatch`, `minor`, `preminor`, `premajor`, `prerelease`, `major`, or `auto`. +3. Verify that all required flags are provided for the subcommand. + +**Retryable:** No — fix the arguments first. + +## Exit Code 4: ARTIFACT_CONFLICT + +A branch or tag that Versionings needs to create already exists locally or on the remote. + +**Typical causes:** + +- A version branch with the same name already exists (e.g., `version/patch/1.2.3/fix-login`) +- A tag with the same name already exists (e.g., `1.2.3--fix-login`) +- A previous release attempt was partially completed and not rolled back + +**Example error message:** + +```text +Error [ARTIFACT_CONFLICT]: Branch already exists: version/patch/1.2.3/fix-login + Local branch exists. Delete it or use a different branch name. +``` + +**Remediation steps:** + +1. Delete the conflicting branch: `git branch -D version/patch/1.2.3/fix-login`. +2. Delete the conflicting tag: `git tag -d 1.2.3--fix-login`. +3. If the artifact exists on the remote, delete it there too: `git push origin --delete version/patch/1.2.3/fix-login`. +4. Or use a different `--branch` comment to generate a unique name. +5. If a previous release was interrupted, run `versionings rollback` first. + +**Retryable:** No — delete the conflicting artifact or use a different name. + +## Exit Code 5: COMMAND_FAILED + +A git or npm command executed by Versionings returned a non-zero exit code. + +**Typical causes:** + +- `npm version` failed due to lifecycle script errors +- `git push` was rejected by the remote (e.g., force-push protection) +- `git tag` failed due to GPG signing issues +- File system permissions prevent writing + +**Example error message:** + +```text +Error [COMMAND_FAILED]: Command failed: git push origin version/patch/1.2.3/fix-login + remote: error: GH006: Protected branch update failed. + remote: error: Required status check is expected. +``` + +**Remediation steps:** + +1. Check the full error output (use `--verbose` for detailed command logs). +2. If `git push` was rejected, verify remote permissions and branch protection rules. +3. If `npm version` failed, check `package.json` lifecycle scripts (`preversion`, `version`, `postversion`). +4. Re-run the command after resolving the underlying issue. + +**Retryable:** Depends on the cause — transient issues (e.g., temporary remote unavailability) are retryable; permission or configuration issues are not. + +## Exit Code 6: NETWORK_ERROR + +A network operation failed. This typically occurs during remote git operations or SCM API calls. + +**Typical causes:** + +- Git remote is unreachable (DNS failure, firewall, VPN disconnected) +- SCM API request timed out (configurable via `git.api.timeout`) +- Authentication token is expired, revoked, or has insufficient permissions + +**Example error message:** + +```text +Error [NETWORK_ERROR]: Failed to create pull request via GitHub API + Request timed out after 30000ms + URL: https://api.github.com/repos/owner/repo/pulls +``` + +**Remediation steps:** + +1. Verify network connectivity: `git ls-remote origin`. +2. Check that your authentication token is valid and has the required scopes. +3. If the API timed out, increase `git.api.timeout` in your configuration. +4. If behind a proxy or firewall, ensure the SCM API endpoint is accessible. +5. Re-run the command. + +**Retryable:** Yes — network issues are typically transient. + +## Exit Code 7: INCOMPLETE_ROLLBACK + +A rollback operation could not complete all reversal steps. The repository may be in a partially rolled-back state requiring manual intervention. + +**Typical causes:** + +- A branch or tag was already deleted externally during rollback +- Network failure occurred mid-rollback while reverting remote changes +- File system error prevented reverting a local change + +**Example error message:** + +```text +Error [INCOMPLETE_ROLLBACK]: Rollback partially failed + ✓ Reverted: npm version (1.2.3 → 1.2.2) + ✓ Reverted: branch version/patch/1.2.3/fix-login deleted locally + ✗ Failed: could not delete remote branch version/patch/1.2.3/fix-login + Manual recovery required. See details above. +``` + +**Remediation steps:** + +1. Review the rollback output to identify which steps succeeded and which failed. +2. Manually complete the failed steps (e.g., `git push origin --delete `). +3. Verify the repository state: `git status`, `git branch -a`, `git tag -l`. +4. Run `versionings doctor` to confirm the environment is healthy. + +**Retryable:** No — manual intervention is required to resolve the partial state. + +## Exit Code 8: NO_OPERATION + +There is nothing to rollback. This occurs when `versionings rollback` is invoked but no previous operation log exists. + +**Typical causes:** + +- No previous `release` operation was performed in this repository +- The operation log file was deleted or is not accessible +- A rollback was already completed for the last operation + +**Example error message:** + +```text +Error [NO_OPERATION]: Nothing to rollback + No operation log found. Run a release first. +``` + +**Remediation steps:** + +1. Verify that a release was previously executed in this repository. +2. Check that the operation log file exists and is readable. +3. If you need to undo changes manually, use standard git commands. + +**Retryable:** No — there is no operation to rollback. + +## Exit Code 9: USER_CANCELLED + +The user declined a confirmation prompt during an interactive session. + +**Typical causes:** + +- User answered "no" to the release confirmation prompt +- User pressed Ctrl+C during an interactive prompt +- An interactive prompt timed out waiting for input + +**Example error message:** + +```text +Error [USER_CANCELLED]: Operation cancelled by user + The release was not performed. No changes were made. +``` + +**Remediation steps:** + +1. Re-run the command and confirm the prompt. +2. To skip confirmation prompts, use the `--yes` (or `-y`) flag. +3. In CI environments, use `--ci` or `--non-interactive` to disable prompts entirely. + +**Retryable:** Yes — re-run the command with `--yes` to auto-confirm. + +## Exit Code 10: POLICY_VIOLATION + +A branch protection policy defined in the configuration was violated. + +**Typical causes:** + +- The generated branch name does not match the required naming convention +- The target branch is protected and the current operation is not allowed +- The branching strategy constraints are not satisfied (e.g., hotfix from wrong source branch) + +**Example error message:** + +```text +Error [POLICY_VIOLATION]: Branch protection policy violated + Branch "feature/1.2.3" does not match required pattern "version/{semver}/{version}/{comment}" + Strategy: git-flow +``` + +**Remediation steps:** + +1. Review the branch protection rules in your configuration (`git.branching` section). +2. Ensure the branching strategy is correctly configured for your workflow. +3. Use `versionings plan` to preview the branch name before executing a release. +4. Adjust the `branchTemplate` or `strategy` in your configuration if the policy is too restrictive. + +**Retryable:** No — fix the configuration or branch naming. + +## Exit Code 11: NO_CONVENTIONAL_COMMITS + +The `--semver=auto` flag was used but no conventional commits were found in the commit history, and `conventionalCommits.fallbackBump` is set to `null`. + +**Typical causes:** + +- No commits follow the Conventional Commits format (`[()][!]: `) +- The commit range being analyzed contains only non-conventional commit messages +- `conventionalCommits.fallbackBump` is explicitly set to `null` (no fallback) + +**Example error message:** + +```text +Error [NO_CONVENTIONAL_COMMITS]: No conventional commits found for auto-bump + Analyzed 12 commits between v1.2.2 and HEAD. + None matched the conventional commit format. + Set conventionalCommits.fallbackBump to "patch", "minor", or "major" to provide a default. +``` + +**Remediation steps:** + +1. Use conventional commit messages in your repository (e.g., `feat: add login`, `fix: resolve crash`). +2. Set `conventionalCommits.fallbackBump` to a default bump level (`"patch"`, `"minor"`, or `"major"`) in your configuration. +3. Or specify the bump level explicitly instead of using `--semver=auto` (e.g., `--semver=patch`). + +**Retryable:** No — add conventional commits or configure a fallback bump level. + +## CI Integration + +Handle exit codes in CI scripts to provide clear feedback and appropriate failure behavior: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +result=$(versionings release --semver=patch --branch=fix --ci --json 2>&1) || exit_code=$? +exit_code=${exit_code:-0} + +case $exit_code in + 0) echo "Release successful" ;; + 1|3) echo "Configuration or argument error — fix and re-run" ; exit 1 ;; + 2) echo "Dirty working tree — commit or stash changes" ; exit 1 ;; + 4) echo "Artifact conflict — branch or tag exists" ; exit 1 ;; + 5|6) echo "Runtime error — check logs and retry" ; exit 1 ;; + 7) echo "Incomplete rollback — manual intervention required" ; exit 1 ;; + *) echo "Unexpected exit code: $exit_code" ; exit 1 ;; +esac +``` + +For more CI integration examples, see the [CLI Reference](./cli-reference.md). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..b0ca9b9 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,25 @@ +# Versionings Documentation + +Versionings is a CLI tool that automates semantic versioning workflows for Git repositories. It bumps versions, creates branches and tags, and optionally opens pull requests on supported SCM platforms. + +## Getting Started + +- [Setup Guide](./setup-guide.md) — Step-by-step installation, configuration, and first release + +## Reference + +- [Configuration Reference](./configuration-reference.md) — All configuration fields, sources, and precedence +- [CLI Reference](./cli-reference.md) — Subcommands, parameters, global flags, and exit codes +- [Failure Matrix](./failure-matrix.md) — Exit codes with causes, error examples, and remediation steps + +## Guides + +- [Branch Strategy Cookbook](./branch-strategy-cookbook.md) — Six branching strategies with examples and diagrams +- [SCM Provider Guide](./scm-provider-guide.md) — Platform setup for GitHub, GitLab, Bitbucket, and Azure DevOps +- [CI/CD Examples](./ci-examples.md) — Ready-to-use CI/CD configurations for four platforms +- [Changelog Format Guide](./changelog-format-guide.md) — Conventional Commits, bump policy, and changelog templates +- [Operational Hardening Guide](./operational-hardening-guide.md) — Structured logging, operation IDs, actor metadata, and concurrency lock + +## Operations + +- [Migration Guide](./migration-guide.md) — Upgrading between versions and breaking changes diff --git a/docs/migration-guide.md b/docs/migration-guide.md new file mode 100644 index 0000000..3d2b1c3 --- /dev/null +++ b/docs/migration-guide.md @@ -0,0 +1,409 @@ +> **Navigation:** [Documentation Index](./index.md) · [Configuration Reference](./configuration-reference.md) · [CLI Reference](./cli-reference.md) · [Failure Matrix](./failure-matrix.md) + +# Migration Guide + +This guide covers upgrading Versionings across major feature phases. Each section describes what changed, lists new configuration fields and exit codes, and provides step-by-step migration instructions. + +## P0 → P1: Subcommands and Configuration Hierarchy + +P1 introduced a structured CLI with subcommands and a multi-source configuration system. + +### What Changed + +- **Subcommands**: The CLI now exposes dedicated subcommands: `init`, `validate`, `plan`, `release`, `rollback`, and `doctor`. Each subcommand has its own set of parameters and behavior. +- **Configuration hierarchy**: Configuration is loaded from 6 sources in order of ascending priority: + 1. Built-in defaults + 2. `version.json` + 3. RC files (`.versioningsrc`, `.versioningsrc.json`, `.versioningsrc.yml`, `.versioningsrc.yaml`) + 4. `"versionings"` key in `package.json` + 5. Environment variables with `VERSIONINGS_` prefix + 6. CLI flags +- **New exit codes**: + - `8` (`NO_OPERATION`) — the requested operation had nothing to do (e.g., version already at target) + - `9` (`USER_CANCELLED`) — the user cancelled an interactive prompt + +### Migration Steps + +1. Update any scripts that call `versionings` directly. The bare invocation `versionings --semver= --branch=` still works (it maps to `release`), but prefer explicit subcommands: + +```bash +# Before (P0) +versionings --semver=patch --branch=my-feature + +# After (P1) — explicit subcommand +versionings release --semver=patch --branch=my-feature +``` + +2. Review configuration sources. If you have settings in multiple places (e.g., `version.json` and environment variables), verify the [precedence order](./configuration-reference.md) produces the expected result: + +```bash +versionings validate --json +``` + +3. Update CI scripts to handle new exit codes `8` and `9` in addition to `0`–`7`. See the [Failure Matrix](./failure-matrix.md) for details on each code. + +4. Use `versionings doctor` to verify your environment is correctly configured: + +```bash +versionings doctor +``` + +## P1 → P2: SCM Providers and PR/MR Automation + +P2 added support for multiple SCM platforms and automated PR/MR creation via platform APIs. + +### What Changed + +- **New platforms**: In addition to `github` and `bitbucket`, Versionings now supports `github-enterprise`, `bitbucket-server`, `gitlab`, and `azure-devops`. +- **PR/MR creation via API**: The `--pr-mode` flag controls how pull requests are created: + - `auto` — API call with fallback to browser URL + - `api` — API only, fails if unavailable + - `url` — Opens browser with pre-filled PR URL +- **New configuration fields**: + - `git.apiUrl` — API endpoint for self-hosted platforms + - `git.auth.token` — authentication token + - `git.auth.method` — authentication method (`token` or `bearer`) + - `git.api.timeout` — API request timeout in milliseconds (default: `30000`) + - `git.pr.reviewers` — array of reviewer usernames + - `git.pr.labels` — array of labels to apply + - `git.pr.draft` — create PR as draft (default: `false`) + - `git.pr.template` — path to PR body template file + - `git.pr.milestone` — milestone to assign + - `git.pr.linkedIssues` — array of issue identifiers to link + +### Migration Steps + +1. Set `git.platform` to match your SCM provider. For example, to switch from the default GitHub Cloud to GitLab: + +```json +{ + "git": { + "platform": "gitlab", + "url": "https://gitlab.com/my-org/my-repo.git", + "apiUrl": "https://gitlab.com/api/v4" + } +} +``` + +2. Configure authentication. Provide a token via config, environment variable, or platform-specific variable: + +```bash +# Option 1: Environment variable (recommended for CI) +export VERSIONINGS_TOKEN="glpat-xxxxxxxxxxxxxxxxxxxx" + +# Option 2: Platform-specific variable +export GITLAB_TOKEN="glpat-xxxxxxxxxxxxxxxxxxxx" +``` + +3. If you use PR automation, configure the desired PR parameters: + +```json +{ + "git": { + "platform": "gitlab", + "url": "https://gitlab.com/my-org/my-repo.git", + "pr": { + "target": "main", + "reviewers": ["alice", "bob"], + "labels": ["release"], + "draft": false + } + } +} +``` + +4. Validate the new configuration: + +```bash +versionings validate --json +``` + +See the [SCM Provider Guide](./scm-provider-guide.md) for platform-specific setup instructions. + +## P2 → P3: Branching Strategies and Policy Checker + +P3 introduced pluggable branching strategies and a Policy Checker that validates branch names against strategy rules. + +### What Changed + +- **6 branching strategies**: `default`, `trunk-based`, `git-flow`, `release-branch`, `hotfix`, `maintenance`. Each strategy defines its own branch naming, tag naming, and allowed semver types. +- **New configuration section** `git.branching`: + - `git.branching.strategy` — one of the 6 strategies (default: `default`) + - `git.branching.branchTemplate` — custom branch name template + - `git.branching.tagTemplate` — custom tag name template + - `git.branching.mainBranch` — primary branch name (default: `master`) + - `git.branching.developBranch` — development branch name (default: `develop`) +- **Policy Checker**: Validates that branch and tag names conform to the selected strategy before any mutation occurs. +- **New exit code**: + - `10` (`POLICY_VIOLATION`) — a branch or tag name violates the active strategy's naming policy + +### Migration Steps + +1. Choose a branching strategy that matches your team's workflow. See the [Branch Strategy Cookbook](./branch-strategy-cookbook.md) for a comparison and examples. + +2. Add the `git.branching` section to your configuration: + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/my-org/my-repo.git", + "branching": { + "strategy": "git-flow", + "mainBranch": "main", + "developBranch": "develop" + } + } +} +``` + +Equivalent YAML (`.versioningsrc.yml`): + +```yaml +git: + platform: github + url: https://github.com/my-org/my-repo.git + branching: + strategy: git-flow + mainBranch: main + developBranch: develop +``` + +3. Run a dry-run to verify branch and tag naming under the new strategy: + +```bash +versionings plan --semver=minor --branch=my-feature +``` + +4. Update CI scripts to handle exit code `10` (`POLICY_VIOLATION`). See the [Failure Matrix](./failure-matrix.md) for troubleshooting. + +## P3 → P4: Conventional Commits and Changelog + +P4 added Conventional Commits parsing, automatic semver bump detection, and changelog generation. + +### What Changed + +- **New subcommand**: `changelog` — generates a changelog from commit history. +- **Conventional Commits parsing**: Commit messages following the `[()][!]: ` format are parsed to determine the semver bump level automatically. +- **`--semver=auto`**: Analyzes commit history since the last tag and determines the appropriate bump (`major`, `minor`, or `patch`) based on conventional commit types and breaking change indicators. +- **New configuration sections**: + - `conventionalCommits.enabled` — enable/disable parsing (default: `true`) + - `conventionalCommits.types` — custom mapping of commit types to bump levels + - `conventionalCommits.fallbackBump` — bump level when no conventional commits are found (default: `patch`) + - `changelog.template` — changelog entry template + - `changelog.groupTitles` — custom group headings by commit type + - `changelog.excludeTypes` — commit types to exclude from changelog + - `changelog.includeNonConventional` — include non-conventional commits (default: `false`) + - `changelog.file` — path to changelog file for automatic updates on release +- **New exit code**: + - `11` (`NO_CONVENTIONAL_COMMITS`) — `--semver=auto` was used but no conventional commits were found and `fallbackBump` is `null` + +### Migration Steps + +1. Enable Conventional Commits support (enabled by default). To customize the type-to-bump mapping: + +```json +{ + "conventionalCommits": { + "enabled": true, + "types": { + "feat": "minor", + "fix": "patch", + "perf": "patch", + "refactor": "patch" + }, + "fallbackBump": "patch" + } +} +``` + +2. Configure changelog generation if desired: + +```json +{ + "changelog": { + "file": "CHANGELOG.md", + "groupTitles": { + "feat": "Features", + "fix": "Bug Fixes", + "perf": "Performance" + }, + "excludeTypes": ["chore", "docs", "style"], + "includeNonConventional": false + } +} +``` + +3. Try automatic bump detection with a dry-run: + +```bash +versionings plan --semver=auto --branch=my-feature +``` + +4. Generate a changelog preview: + +```bash +versionings changelog --from=v1.0.0 --to=HEAD +``` + +5. Update CI scripts to handle exit code `11` (`NO_CONVENTIONAL_COMMITS`). See the [Failure Matrix](./failure-matrix.md) for details. + +See the [Changelog Format Guide](./changelog-format-guide.md) for full configuration options. + +## P4 → P5: Operational Hardening + +P5 added structured logging, operation IDs, actor metadata, action trace, and concurrency lock. + +### What Changed + +- **Structured logging**: All log output is now JSON-formatted and directed to stderr. The `--verbose` flag enables `debug`-level logging. A configurable `logLevel` field controls the minimum level. +- **Operation ID**: Every CLI invocation generates a UUID v4 that appears in all log messages and audit entries. +- **Actor metadata**: Git user name, email, hostname, and CI actor are captured and stored in the audit log. +- **Action trace**: Each pipeline step is timed. Trace data is stored in the audit log and `totalDurationMs` is included in JSON output. +- **Audit log v2**: Operation log entries now use `schemaVersion: 2` with additional fields: `operationId`, `actor`, `trace`, `environment`, `command`. Entries with `schemaVersion: 1` are read without errors. +- **Concurrency lock**: A lock file (`.versionings/lock`) prevents parallel releases. Stale locks are auto-detected via PID check (local) or timeout (CI). +- **New configuration fields**: + - `logLevel` — log verbosity: `debug`, `info`, `warn` (default), `error` + - `lockTimeoutMs` — lock timeout in milliseconds (default: `300000`) + +### Migration Steps + +1. No breaking changes. Existing configurations work without modification. + +2. To enable structured logging, set `logLevel` in your configuration: + +```json +{ + "logLevel": "info" +} +``` + +3. To adjust lock timeout for long-running CI pipelines: + +```json +{ + "lockTimeoutMs": 600000 +} +``` + +4. Add `.versionings/` to your `.gitignore` if not already present: + +```text +.versionings/ +``` + +5. Validate the updated configuration: + +```bash +versionings validate --json +``` + +See the [Operational Hardening Guide](./operational-hardening-guide.md) for full details. + +## P5 → P6: Project Restructure + +P6 reorganized the source code from a flat root structure into `src/` with domain subdirectories. This is a developer-facing change only — CLI behavior is unchanged. + +### What Changed + +- **Source structure**: All TypeScript source files moved from the project root into `src/` with 7 domain directories: `cli/`, `core/`, `config/`, `scm/`, `branching/`, `versioning/`, `utils/`. +- **Test structure**: Unit and property tests reorganized into mirrored domain subdirectories within `__tests__/unit/` and `__tests__/properties/`. +- **Build output**: Changed from `dist/version.js` to `out/dist/index.js`. The `bin` entry in `package.json` updated accordingly. +- **TypeScript config**: `rootDir` changed to `src`, `include` changed to `src/**/*.ts`. + +### Migration Steps (for contributors) + +1. Update any local scripts that reference source files by path. + +2. If you have custom IDE configurations pointing to root-level `.ts` files, update them to `src/`. + +3. Pull the latest changes and run: + +```bash +npm install +npm run build +npm test +``` + +### Migration Steps (for users) + +No action required. The CLI binary, configuration format, and all commands remain identical. + +## Backward Compatibility + +Versionings maintains backward compatibility across all phase transitions: + +### CLI Compatibility + +The legacy invocation without a subcommand continues to work and is treated as `release`: + +```bash +# Legacy syntax (still supported) +versionings --semver=patch --branch=my-feature + +# Equivalent explicit syntax +versionings release --semver=patch --branch=my-feature +``` + +### Configuration Compatibility + +A `version.json` file that only contains P0-era fields (e.g., `git.platform` and `git.url`) passes validation and works with default behavior. New configuration sections are optional: + +- Without `git.branching` — the `default` strategy is used +- Without `conventionalCommits` — conventional commit parsing is enabled with default type mappings +- Without `changelog` — no changelog file is written on release + +### Exit Code Compatibility + +Exit codes are additive. Codes `0`–`7` from P0 retain their original meaning across all phases: + +| Phase | Exit Codes | New Codes Added | +|-------|-----------|-----------------| +| P0 | 0–7 | — | +| P1 | 0–9 | 8 (`NO_OPERATION`), 9 (`USER_CANCELLED`) | +| P3 | 0–10 | 10 (`POLICY_VIOLATION`) | +| P4 | 0–11 | 11 (`NO_CONVENTIONAL_COMMITS`) | +| P5 | 0–11 | — (no new exit codes) | +| P6 | 0–11 | — (no new exit codes) | + +CI scripts that only check for exit code `0` (success) vs non-zero (failure) continue to work without changes. + +## Post-Update Verification + +After upgrading Versionings, run the following checks to verify everything works correctly: + +### 1. Check Environment + +```bash +versionings doctor +``` + +Confirms that Node.js, npm, Git, and the remote are properly configured. + +### 2. Validate Configuration + +```bash +versionings validate --json +``` + +Verifies that your configuration file is valid against the current schema and all required fields are present. + +### 3. Dry-Run a Release + +```bash +versionings plan --semver=patch --branch=test-migration +``` + +Runs the full release workflow without making any changes. Confirms that branching strategy, naming templates, and artifact checks work as expected. + +### Verification Checklist + +| Check | Command | Expected Outcome | +|-------|---------|-----------------| +| Environment health | `versionings doctor` | All checks pass | +| Config validity | `versionings validate --json` | Exit code `0`, no errors | +| Dry-run workflow | `versionings plan --semver=patch --branch=test` | Exit code `0`, plan output shown | +| Branch naming | Review `plan` output | Names match selected strategy | +| SCM authentication | `versionings validate --json` | No auth warnings | + +If any check fails, consult the [Failure Matrix](./failure-matrix.md) for the corresponding exit code and resolution steps. diff --git a/docs/operational-hardening-guide.md b/docs/operational-hardening-guide.md new file mode 100644 index 0000000..dd681f9 --- /dev/null +++ b/docs/operational-hardening-guide.md @@ -0,0 +1,186 @@ +> **Navigation:** [Documentation Index](./index.md) · [Configuration Reference](./configuration-reference.md) · [CLI Reference](./cli-reference.md) + +# Operational Hardening Guide + +Versionings includes observability and concurrency features designed for enterprise CI/CD environments. This guide covers structured logging, operation IDs, actor metadata, action trace, and the concurrency lock mechanism. + +## Structured Logging + +Versionings outputs structured JSON log messages to stderr. Logs are separate from the reporter output (stdout), so they can be captured independently by log aggregation systems (ELK, Datadog, CloudWatch). + +### Log Format + +Each log message is a single JSON object on one line: + +```json +{ + "timestamp": "2025-04-14T10:30:00.123Z", + "level": "info", + "message": "npm version bump completed", + "operationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "actor": "alice", + "ci": false, + "context": { + "step": "npm-version-bump", + "durationMs": 1234 + } +} +``` + +### Log Levels + +| Level | Description | When Shown | +|-------|-------------|------------| +| `debug` | Command execution details, stdout/stderr | `--verbose` or `logLevel: debug` | +| `info` | Key workflow steps (start, bump, branch, tag, push, PR) | `--verbose` or `logLevel: info` | +| `warn` | Non-blocking warnings (stale lock, policy warnings, PR fallback) | Always (default level) | +| `error` | Errors that stop execution (command failed, rollback, lock conflict) | Always | + +### Configuring Log Level + +Set the log level in your configuration or via environment variable: + +```json +{ + "logLevel": "info" +} +``` + +```bash +VERSIONINGS_LOG_LEVEL=debug versionings release --semver=patch --branch=fix +``` + +The `--verbose` flag overrides the configured level to `debug`. + +## Operation ID + +Every CLI invocation generates a unique Operation ID (UUID v4). This ID appears in every log message and in the audit log entry, enabling correlation of all events from a single run. + +In JSON output mode (`--json`), the Operation ID is included in the result: + +```json +{ + "success": true, + "operationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "version": "1.2.3", + "totalDurationMs": 4567 +} +``` + +## Actor Metadata + +Versionings captures information about who initiated each operation: + +| Field | Source | Fallback | +|-------|--------|----------| +| `gitUserName` | `git config user.name` | `unknown` | +| `gitUserEmail` | `git config user.email` | `unknown` | +| `hostname` | `os.hostname()` | `unknown` | +| `ciActor` | CI environment variables | `null` | + +CI actor detection supports: + +| CI Platform | Environment Variable | +|-------------|---------------------| +| GitHub Actions | `GITHUB_ACTOR` | +| GitLab CI | `GITLAB_USER_LOGIN` | +| Azure DevOps | `BUILD_REQUESTEDFOR` | +| Bitbucket Pipelines | `BITBUCKET_STEP_TRIGGERER_UUID` | + +Actor metadata is stored in the audit log and included in `info`-level log messages. + +## Action Trace + +The pipeline records timing for each step of the workflow. The trace is stored in the audit log and can be used to identify slow operations. + +Traced steps: `validate-input`, `check-git-status`, `check-remote`, `auto-bump`, `compute-version`, `policy-check`, `artifact-check`, `npm-version-bump`, `branch-create`, `tag-create`, `changelog-write`, `commit`, `push`, `pr-create`. + +Each entry includes: + +```json +{ + "step": "npm-version-bump", + "startedAt": "2025-04-14T10:30:00.000Z", + "endedAt": "2025-04-14T10:30:01.234Z", + "durationMs": 1234, + "status": "success" +} +``` + +The total duration is included in JSON output as `totalDurationMs`. + +## Audit Log + +The operation log (`.versionings/operations/`) stores extended audit entries (schemaVersion 2) with: + +- Operation ID +- Actor metadata +- Full action trace with timings +- Environment info (Node.js version, CLI version, OS, CI flag) +- Full CLI command (with tokens masked as `***`) + +Entries with schemaVersion 1 (from earlier versions) are read without errors — missing fields are filled with defaults. + +## Concurrency Lock + +Versionings prevents parallel releases on the same repository using a lock file at `.versionings/lock`. + +### How It Works + +1. Before mutation steps, the pipeline creates a lock file with PID, operation ID, timestamp, and hostname +2. If a lock already exists, Versionings checks whether it is stale +3. After the operation completes (success or failure), the lock is released +4. Signal handlers (SIGINT, SIGTERM) ensure cleanup on forced termination + +### Stale Lock Detection + +A lock is considered stale when: + +- The process with the recorded PID no longer exists (local development) +- The lock age exceeds `lockTimeoutMs` (default: 5 minutes) + +In CI environments, PID-based detection is disabled (PIDs are unreliable in containers). Only timeout-based detection is used. + +### Configuration + +```json +{ + "lockTimeoutMs": 300000 +} +``` + +```bash +VERSIONINGS_LOCK_TIMEOUT_MS=600000 versionings release --semver=patch --branch=fix +``` + +### Lock Scope + +| Command | Creates Lock | +|---------|-------------| +| `release` | Yes (unless `--dry-run`) | +| `rollback` | Yes | +| `plan` | No | +| `validate` | No | +| `doctor` | No | +| `init` | No | +| `changelog` | No | + +### Troubleshooting + +If you encounter a lock error: + +```text +Error [COMMAND_FAILED]: Another versionings process is running + PID: 12345, Operation: a1b2c3d4, Started: 2025-04-14T10:30:00Z +``` + +1. Check if the process is still running: `ps -p 12345` +2. If the process is gone, the lock will be auto-cleaned on the next run +3. To force removal: `rm .versionings/lock` +4. In CI, check for parallel jobs targeting the same repository + +## Related Documentation + +- [Configuration Reference](./configuration-reference.md) — `logLevel` and `lockTimeoutMs` fields +- [CLI Reference](./cli-reference.md) — `--verbose` flag and JSON output format +- [Failure Matrix](./failure-matrix.md) — exit code 5 for lock conflicts diff --git a/docs/scm-provider-guide.md b/docs/scm-provider-guide.md new file mode 100644 index 0000000..6b4d352 --- /dev/null +++ b/docs/scm-provider-guide.md @@ -0,0 +1,485 @@ +> **Navigation:** [Documentation Index](./index.md) · [CI/CD Examples](./ci-examples.md) · [Configuration Reference](./configuration-reference.md) + +# SCM Provider Guide + +Versionings supports six SCM platforms for repository hosting and PR/MR automation. Each platform requires a `git.platform` value in your configuration and an authentication token for API-based PR creation. + +This guide covers setup for every supported platform, authentication configuration, PR/MR creation modes, and per-platform parameter support. + +## GitHub Cloud + +**Platform value:** `github` + +GitHub Cloud is the default cloud-hosted GitHub service at `github.com`. + +### Required Configuration + +| Field | Required | Description | +|-------|----------|-------------| +| `git.platform` | Yes | Set to `github` | +| `git.url` | Yes | Repository URL | + +### Authentication Token + +Create a Personal Access Token (PAT) with the `repo` scope: + +1. Go to **Settings → Developer settings → Personal access tokens → Tokens (classic)** +2. Click **Generate new token (classic)** +3. Select the `repo` scope (full control of private repositories) +4. Copy the generated token + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `GITHUB_TOKEN` | Platform-specific token | +| `VERSIONINGS_TOKEN` | Cross-platform token (overrides `GITHUB_TOKEN`) | + +### Configuration Example + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/owner/repo.git", + "pr": { + "target": "main" + } + } +} +``` + +### URL Formats + +| Protocol | Format | +|----------|--------| +| HTTPS | `https://github.com/owner/repo.git` | +| SSH | `git@github.com:owner/repo.git` | + +--- + +## GitHub Enterprise + +**Platform value:** `github-enterprise` + +GitHub Enterprise is a self-hosted GitHub instance. Requires `git.apiUrl` pointing to your Enterprise API endpoint. + +### Required Configuration + +| Field | Required | Description | +|-------|----------|-------------| +| `git.platform` | Yes | Set to `github-enterprise` | +| `git.url` | Yes | Repository URL on your Enterprise instance | +| `git.apiUrl` | Yes | API endpoint (e.g., `https://github.example.com/api/v3`) | + +### Authentication Token + +Create a Personal Access Token on your Enterprise instance: + +1. Navigate to your Enterprise instance's **Settings → Developer settings → Personal access tokens** +2. Generate a token with the `repo` scope +3. Copy the generated token + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `GITHUB_TOKEN` | Platform-specific token (shared with GitHub Cloud) | +| `VERSIONINGS_TOKEN` | Cross-platform token (overrides `GITHUB_TOKEN`) | + +### Configuration Example + +```json +{ + "git": { + "platform": "github-enterprise", + "url": "https://github.example.com/org/repo.git", + "apiUrl": "https://github.example.com/api/v3", + "pr": { + "target": "main" + } + } +} +``` + +### URL Formats + +| Protocol | Format | +|----------|--------| +| HTTPS | `https://github.example.com/org/repo.git` | +| SSH | `git@github.example.com:org/repo.git` | + +--- + +## Bitbucket Cloud + +**Platform value:** `bitbucket` + +Bitbucket Cloud is Atlassian's cloud-hosted Git service at `bitbucket.org`. + +### Required Configuration + +| Field | Required | Description | +|-------|----------|-------------| +| `git.platform` | Yes | Set to `bitbucket` | +| `git.url` | Yes | Repository URL | + +### Authentication Token + +Create an App Password with repository write permissions: + +1. Go to **Personal settings → App passwords** +2. Click **Create app password** +3. Select permissions: **Repositories: Write** and **Pull requests: Write** +4. Copy the generated app password + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `BITBUCKET_TOKEN` | Platform-specific token | +| `VERSIONINGS_TOKEN` | Cross-platform token (overrides `BITBUCKET_TOKEN`) | + +### Configuration Example + +```json +{ + "git": { + "platform": "bitbucket", + "url": "https://bitbucket.org/workspace/repo.git", + "pr": { + "target": "main" + } + } +} +``` + +### URL Formats + +| Protocol | Format | +|----------|--------| +| HTTPS | `https://bitbucket.org/workspace/repo.git` | +| SSH | `git@bitbucket.org:workspace/repo.git` | + +--- + +## Bitbucket Server + +**Platform value:** `bitbucket-server` + +Bitbucket Server (formerly Stash) is Atlassian's self-hosted Git service. Requires `git.apiUrl` pointing to your server's REST API. + +### Required Configuration + +| Field | Required | Description | +|-------|----------|-------------| +| `git.platform` | Yes | Set to `bitbucket-server` | +| `git.url` | Yes | Repository URL on your server | +| `git.apiUrl` | Yes | REST API endpoint (e.g., `https://bitbucket.example.com/rest/api/1.0`) | + +### Authentication Token + +Create an HTTP Access Token on your Bitbucket Server instance: + +1. Navigate to **Manage account → HTTP access tokens** +2. Click **Create token** +3. Grant **Repository write** and **Pull request write** permissions +4. Copy the generated token + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `BITBUCKET_TOKEN` | Platform-specific token (shared with Bitbucket Cloud) | +| `VERSIONINGS_TOKEN` | Cross-platform token (overrides `BITBUCKET_TOKEN`) | + +### Configuration Example + +```json +{ + "git": { + "platform": "bitbucket-server", + "url": "https://bitbucket.example.com/scm/proj/repo.git", + "apiUrl": "https://bitbucket.example.com/rest/api/1.0", + "pr": { + "target": "main" + } + } +} +``` + +### URL Formats + +| Protocol | Format | +|----------|--------| +| HTTPS | `https://bitbucket.example.com/scm/proj/repo.git` | +| SSH | `ssh://git@bitbucket.example.com:7999/proj/repo.git` | + +--- + +## GitLab + +**Platform value:** `gitlab` + +GitLab supports both cloud (`gitlab.com`) and self-hosted instances. For self-hosted GitLab, set `git.apiUrl` to your instance's API endpoint. + +### Required Configuration + +| Field | Required | Description | +|-------|----------|-------------| +| `git.platform` | Yes | Set to `gitlab` | +| `git.url` | Yes | Repository URL | +| `git.apiUrl` | No (cloud) / Yes (self-hosted) | API endpoint (e.g., `https://gitlab.example.com/api/v4`) | + +### Authentication Token + +Create a Personal Access Token with the `api` scope: + +1. Go to **User Settings → Access Tokens** +2. Click **Add new token** +3. Select the `api` scope (grants complete read/write API access) +4. Copy the generated token + +For self-hosted instances, create the token on your GitLab instance using the same steps. + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `GITLAB_TOKEN` | Platform-specific token | +| `VERSIONINGS_TOKEN` | Cross-platform token (overrides `GITLAB_TOKEN`) | + +### Configuration Example + +Cloud: + +```json +{ + "git": { + "platform": "gitlab", + "url": "https://gitlab.com/group/repo.git", + "pr": { + "target": "main" + } + } +} +``` + +Self-hosted: + +```json +{ + "git": { + "platform": "gitlab", + "url": "https://gitlab.example.com/group/repo.git", + "apiUrl": "https://gitlab.example.com/api/v4", + "pr": { + "target": "main" + } + } +} +``` + +### URL Formats + +| Protocol | Format | +|----------|--------| +| HTTPS | `https://gitlab.com/group/repo.git` | +| SSH | `git@gitlab.com:group/repo.git` | + +--- + +## Azure DevOps + +**Platform value:** `azure-devops` + +Azure DevOps is Microsoft's cloud-hosted DevOps platform. + +### Required Configuration + +| Field | Required | Description | +|-------|----------|-------------| +| `git.platform` | Yes | Set to `azure-devops` | +| `git.url` | Yes | Repository URL | + +### Authentication Token + +Create a Personal Access Token with Code (Read & Write) scope: + +1. Go to **User Settings → Personal access tokens** +2. Click **New Token** +3. Select scope: **Code → Read & Write** +4. Copy the generated token + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `AZURE_DEVOPS_TOKEN` | Platform-specific token | +| `VERSIONINGS_TOKEN` | Cross-platform token (overrides `AZURE_DEVOPS_TOKEN`) | + +### Configuration Example + +```json +{ + "git": { + "platform": "azure-devops", + "url": "https://dev.azure.com/org/project/_git/repo", + "pr": { + "target": "main" + } + } +} +``` + +### URL Formats + +| Protocol | Format | +|----------|--------| +| HTTPS | `https://dev.azure.com/org/project/_git/repo` | + +> **Note:** Azure DevOps primarily uses HTTPS URLs. SSH access uses `git@ssh.dev.azure.com:v3/org/project/repo`. + +--- + +## Self-Hosted Platforms + +Three platforms support self-hosted deployments. For these, set `git.apiUrl` to point Versionings at your instance's API endpoint. + +| Platform | `git.platform` | `git.apiUrl` Example | +|----------|----------------|----------------------| +| GitHub Enterprise | `github-enterprise` | `https://github.example.com/api/v3` | +| Bitbucket Server | `bitbucket-server` | `https://bitbucket.example.com/rest/api/1.0` | +| GitLab (self-hosted) | `gitlab` | `https://gitlab.example.com/api/v4` | + +When `git.apiUrl` is set, Versionings directs all API calls (PR creation, branch operations) to that endpoint instead of the cloud default. The `git.url` should also point to your self-hosted instance. + +```json +{ + "git": { + "platform": "github-enterprise", + "url": "https://github.example.com/org/repo.git", + "apiUrl": "https://github.example.com/api/v3" + } +} +``` + +> **Tip:** Run `versionings doctor` after configuring a self-hosted platform to verify API connectivity. + +## Authentication + +Versionings resolves the authentication token from multiple sources in the following priority order (highest wins): + +| Priority | Source | Description | +|----------|--------|-------------| +| 1 (highest) | `git.auth.token` | Token set directly in the configuration file | +| 2 | `VERSIONINGS_TOKEN` | Cross-platform environment variable | +| 3 (lowest) | Platform-specific env var | `GITHUB_TOKEN`, `GITLAB_TOKEN`, `BITBUCKET_TOKEN`, `AZURE_DEVOPS_TOKEN` | + +Platform-specific environment variable mapping: + +| Platform | `git.platform` | Environment Variable | +|----------|----------------|---------------------| +| GitHub Cloud | `github` | `GITHUB_TOKEN` | +| GitHub Enterprise | `github-enterprise` | `GITHUB_TOKEN` | +| Bitbucket Cloud | `bitbucket` | `BITBUCKET_TOKEN` | +| Bitbucket Server | `bitbucket-server` | `BITBUCKET_TOKEN` | +| GitLab | `gitlab` | `GITLAB_TOKEN` | +| Azure DevOps | `azure-devops` | `AZURE_DEVOPS_TOKEN` | + +> **Note:** GitHub Enterprise shares `GITHUB_TOKEN` with GitHub Cloud. Bitbucket Server shares `BITBUCKET_TOKEN` with Bitbucket Cloud. If you use both cloud and self-hosted variants, use `git.auth.token` in the config file to avoid conflicts. + +### Security Recommendations + +- Never commit tokens to version control. Use environment variables or CI secrets. +- In CI environments, pass tokens via platform-specific secrets (see [CI/CD Examples](./ci-examples.md)). +- Use the minimum required scope for each token. +- Rotate tokens regularly according to your organization's security policy. + +## PR/MR Modes + +Versionings supports three modes for PR/MR creation, controlled by the `--pr-mode` flag or configuration: + +### `auto` (default) + +Attempts to create a PR/MR via the platform API. If the API call fails (network error, missing token, insufficient permissions), falls back to generating a browser URL with pre-filled PR parameters. + +```bash +versionings release --semver=patch --branch=fix-login --push --pr-mode=auto +``` + +### `api` + +Creates a PR/MR exclusively via the platform API. Fails with exit code 1 (`CONFIG_ERROR`) if no authentication token is available or the API call fails. Use this mode in CI pipelines where you need guaranteed API-based PR creation. + +```bash +versionings release --semver=minor --branch=add-feature --push --pr-mode=api --ci --json +``` + +### `url` + +Generates a browser URL with pre-filled PR parameters and opens it in the default browser. No API call is made. Useful for local development when you prefer to review the PR in the browser before submitting. + +```bash +versionings release --semver=patch --branch=quick-fix --push --pr-mode=url +``` + +| Mode | API Call | Fallback | Requires Token | Best For | +|------|----------|----------|----------------|----------| +| `auto` | Yes | URL on failure | No (falls back) | General use | +| `api` | Yes | None (fails) | Yes | CI pipelines | +| `url` | No | — | No | Local development | + +## PR/MR Parameters + +Configure PR/MR parameters in the `git.pr` section of your configuration. Not all parameters are supported on every platform. + +### Available Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `reviewers` | `string[]` | Usernames of requested reviewers | +| `labels` | `string[]` | Labels to apply to the PR/MR | +| `draft` | `boolean` | Create as draft PR/MR | +| `template` | `string` | Path to PR/MR body template file | +| `milestone` | `string` | Milestone to associate with the PR/MR | +| `linkedIssues` | `string[]` | Issue identifiers to link to the PR/MR | + +### Platform Support Matrix + +| Parameter | GitHub | GitHub Enterprise | Bitbucket | Bitbucket Server | GitLab | Azure DevOps | +|-----------|--------|-------------------|-----------|------------------|--------|--------------| +| reviewers | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| labels | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | +| draft | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | +| template | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | +| milestone | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | +| linkedIssues | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | + +### Configuration Example + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/owner/repo.git", + "pr": { + "target": "main", + "reviewers": ["alice", "bob"], + "labels": ["release", "automated"], + "draft": false, + "template": ".github/PULL_REQUEST_TEMPLATE.md", + "milestone": "v2.0", + "linkedIssues": ["#42", "#108"] + } + } +} +``` + +> **Note:** Unsupported parameters for a given platform are silently ignored. No error is raised. + +## Related Documentation + +- [CI/CD Examples](./ci-examples.md) — ready-to-use CI configurations with token setup for each platform +- [Configuration Reference](./configuration-reference.md) — full description of all `git.*` configuration fields +- [Failure Matrix](./failure-matrix.md) — exit codes related to authentication and network errors (`CONFIG_ERROR`, `NETWORK_ERROR`) diff --git a/docs/setup-guide.md b/docs/setup-guide.md new file mode 100644 index 0000000..c0c0750 --- /dev/null +++ b/docs/setup-guide.md @@ -0,0 +1,118 @@ +> **Navigation:** [Documentation Index](./index.md) · [Configuration Reference](./configuration-reference.md) · [CLI Reference](./cli-reference.md) + +# Setup Guide + +This guide walks you through installing Versionings, creating a configuration file, and running your first release. + +## Prerequisites + +Before you begin, make sure you have the following installed: + +- **Node.js** >= 18 — verify with `node -v` +- **npm** — verify with `npm -v` +- **Git** — verify with `git --version` +- An initialized Git repository with at least one remote configured (`git remote -v`) + +## Installation + +Install Versionings globally via npm: + +```bash +npm install --global versionings +``` + +Verify the installation: + +```bash +versionings doctor +``` + +## Configuration + +Versionings requires a configuration file that specifies your Git platform and repository URL. You can create one interactively or manually. + +### Interactive wizard + +Run the init command to launch the interactive wizard: + +```bash +versionings init +``` + +The wizard guides you through four steps: + +1. **Git platform** — choose your SCM platform (e.g. `github`, `bitbucket`). +2. **Repository URL** — enter the HTTPS or SSH URL of your repository. If a remote named `origin` is detected, it is offered as the default. +3. **PR target branch** — specify the branch that pull requests should target (default: `main`). +4. **Config format** — choose between `json` (writes `version.json`) and `yaml` (writes `.versioningsrc.yml`). + +If a configuration file already exists, the wizard asks for confirmation before overwriting. + +To skip the format prompt, pass `--format`: + +```bash +versionings init --format=yaml +``` + +For a full list of init parameters, see the [CLI Reference](./cli-reference.md). + +### Manual configuration (JSON) + +Create a `version.json` file in your project root with the minimum required fields: + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/your-org/your-repo.git" + } +} +``` + +### Manual configuration (YAML) + +Alternatively, create a `.versioningsrc.yml` file: + +```yaml +git: + platform: github + url: https://github.com/your-org/your-repo.git +``` + +Both formats support the same fields. See the [Configuration Reference](./configuration-reference.md) for the complete list of options, default values, and environment variable overrides. + +## First Release + +### Preview the plan + +Before making any changes, preview what Versionings will do: + +```bash +versionings plan --semver=patch --branch=initial-setup +``` + +The `plan` command performs a dry run — it shows the version bump, branch name, tag name, and all steps that would be executed, without modifying your repository. + +### Execute the release + +When you are satisfied with the plan, run the release: + +```bash +versionings release --semver=patch --branch=initial-setup +``` + +This bumps the version, creates a branch and tag, and commits the changes. Add `--push` to push to the remote and optionally open a pull request. + +For the full list of release parameters and flags, see the [CLI Reference](./cli-reference.md). + +## Verification + +Run the doctor command to verify that your environment and configuration are healthy: + +```bash +versionings doctor +``` + +A successful run confirms that Git is available, the repository has a valid remote, and the configuration file passes schema validation. If any issues are found, the doctor reports them with suggested fixes. + +For details on exit codes and error handling, see the [Failure Matrix](./failure-matrix.md). diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..e5dc04d --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2018-present Raman Marozau + +/** + * versionings — esbuild build configuration + * + * Single bundled CJS output for the CLI binary. + * + * Entry: src/cli/version.ts → dist/version.js + * All internal modules bundled. External deps resolved at runtime. + */ + +import { build } from 'esbuild'; +import { builtinModules } from 'node:module'; + +// ─── Externals ────────────────────────────────────────────────── + +const nodeBuiltins = builtinModules.flatMap(m => [m, `node:${m}`]); + +const runtimeExternals = [ + 'yargs', + 'open', + 'js-yaml', + 'ajv', +]; + +const external = [...nodeBuiltins, ...runtimeExternals]; + +// ─── Build targets ────────────────────────────────────────────── + +const targets = { + cli: { + label: 'CLI Bundle', + entryPoints: ['src/cli/version.ts'], + outfile: 'out/dist/index.js', + bundle: true, + platform: 'node', + target: ['node18'], + format: 'cjs', + treeShaking: true, + minify: true, + sourcemap: false, + external, + banner: { js: '#!/usr/bin/env node' }, + logLevel: 'info', + }, +}; + +// ─── Runner ───────────────────────────────────────────────────── + +const targetFilter = process.argv[2]; + +for (const [name, config] of Object.entries(targets)) { + if (targetFilter && name !== targetFilter) continue; + + const { label, ...buildConfig } = config; + const startMs = Date.now(); + + await build(buildConfig); + + const elapsed = Date.now() - startMs; + console.log(` ✓ ${label ?? name} → ${buildConfig.outfile ?? buildConfig.outdir} (${elapsed}ms)`); +} + +console.log('\nesbuild: build complete'); diff --git a/examples/01-express-api/.github/workflows/release.yml b/examples/01-express-api/.github/workflows/release.yml new file mode 100644 index 0000000..ab51e9b --- /dev/null +++ b/examples/01-express-api/.github/workflows/release.yml @@ -0,0 +1,27 @@ +name: Versionings Release + +on: + push: + branches: [main] + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 18 + + - run: npm install + + - name: Validate configuration + run: npx versionings validate + + - name: Release + run: npx versionings release --semver=patch --branch=ci-release --push --ci --json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/examples/01-express-api/.gitignore b/examples/01-express-api/.gitignore new file mode 100644 index 0000000..16bd254 --- /dev/null +++ b/examples/01-express-api/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.versionings/ +*.log diff --git a/examples/01-express-api/README.md b/examples/01-express-api/README.md new file mode 100644 index 0000000..fed0ace --- /dev/null +++ b/examples/01-express-api/README.md @@ -0,0 +1,191 @@ +# 01-express-api — Express REST API + +Minimal versionings integration example. Demonstrates the smallest possible configuration and a complete release workflow — from initialization to push with automatic PR creation. + +## What This Example Demonstrates + +- A two-field `version.json` that is enough to run the full versionings pipeline +- The default branching strategy with GitHub as the SCM platform +- A five-step quickstart workflow: `init` → `validate` → `plan` → `release` → `release --push` +- A GitHub Actions CI pipeline that automates releases on push to `main` + +## Tech Stack + +| Component | Version / Tool | +|----------------|----------------------| +| Runtime | Node.js ≥ 18 | +| Framework | Express 4 | +| Versioning | versionings CLI | + +## Branching Strategy and Platform + +| Parameter | Value | +|---------------------|----------------| +| SCM platform | GitHub | +| Branching strategy | `default` | +| Config format | `version.json` | + +The `default` strategy is the simplest strategy versionings offers. On release it creates a branch named `version///` and an annotated tag. It suits projects with a straightforward workflow and no strict branching policies. + +GitHub is used as the SCM platform. When `--push` is passed, versionings pushes the branch and tag to the remote and creates a Pull Request via the GitHub API. + +## Configuration + +`version.json` contains the minimal required configuration — only two fields: + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/your-org/01-express-api.git" + } +} +``` + +| Field | Description | +|----------------|-----------------------------------------------------------------------------| +| `git.platform` | SCM platform (`github`, `gitlab`, `bitbucket`, `azure-devops`, and others) | +| `git.url` | Git repository URL. Used for remote validation and PR creation | + +All other parameters (strategy, branch naming, PR options) use their defaults. This makes `01-express-api` the ideal starting point for learning versionings. + +## Prerequisites + +- Node.js ≥ 18 and npm +- Git with a configured remote +- versionings installed globally: `npm install --global versionings` +- Replace `your-org` in `version.json` with your actual repository URL before using `--push` + +## Quickstart Workflow + +Full release cycle — from initialization to push. + +### 1. Initialize the project + +If you do not have a `version.json` yet, run the interactive wizard: + +```bash +npx versionings init +``` + +The wizard prompts for platform, repository URL, and branching strategy, then generates `version.json`. In this example the file is already provided with a minimal configuration. + +### 2. Validate configuration + +Verify that the configuration is valid and the environment is ready: + +```bash +npm run validate +# or directly: +npx versionings validate +``` + +Versionings checks: +- Presence and validity of `version.json` +- Git remote matches the configured URL +- Platform reachability (when a token is available) + +### 3. Create a release plan + +Preview the release plan without making any changes (dry-run): + +```bash +npm run plan +# or directly: +npx versionings plan --semver=patch --branch=release +``` + +Output includes: +- Current and next version +- Branch and tag names that will be created +- Step-by-step execution plan that `release` will follow + +### 4. Release locally + +Execute a release without pushing to the remote: + +```bash +npx versionings release --semver=patch --branch=release +``` + +Versionings will: +- Bump the version via `npm version` +- Create branch `version/patch//release` +- Create an annotated tag + +### 5. Release with push + +Execute a release, push to the remote, and create a Pull Request: + +```bash +npm run release +# or directly: +npx versionings release --semver=patch --branch=release --push +``` + +A token is required for push and PR creation. Set the environment variable: + +```bash +export GITHUB_TOKEN=ghp_your_token_here +``` + +## CI Pipeline + +`.github/workflows/release.yml` automates the release on every push to `main`. + +### Pipeline steps + +1. **Checkout** with `fetch-depth: 0` — full commit history is required for tag and branch analysis. +2. **Node.js 18** — runtime setup. +3. **npm install** — install dependencies (including versionings). +4. **Validate** — verify configuration before releasing. +5. **Release** — run the release with flags: + - `--semver=patch` — version bump type + - `--branch=ci-release` — branch name for CI releases + - `--push` — push to remote and create a PR + - `--ci` — non-interactive mode (no prompts) + - `--json` — structured JSON output for CI consumption + +### Token + +`GITHUB_TOKEN` is provided via GitHub Actions secrets (`${{ secrets.GITHUB_TOKEN }}`). The built-in GitHub Actions token has permissions to push and create PRs within the repository. + +```yaml +- name: Release + run: npx versionings release --semver=patch --branch=ci-release --push --ci --json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +## Project Structure + +``` +01-express-api/ +├── package.json # npm package, validate/plan/release scripts +├── version.json # Minimal versionings configuration +├── .gitignore # node_modules/, dist/, .versionings/ +├── README.md # This file +├── src/ +│ ├── index.js # Express app, middleware and route setup +│ ├── routes/ +│ │ ├── health.js # GET /health → { status, version, uptime } +│ │ └── users.js # GET /users, GET /users/:id +│ └── middleware/ +│ └── logger.js # Request logging (method, url, status, duration) +└── .github/ + └── workflows/ + └── release.yml # GitHub Actions: validate → release +``` + +## Expected Output + +Running `release --semver=patch` from version `1.0.0` produces: + +- **Version:** `1.0.0` → `1.0.1` +- **Branch:** `version/patch/1.0.1/release` +- **Tag:** `1.0.1--release` +- **Exit code:** `0` (success) + +With `--push`, additionally: +- Branch and tag are pushed to the remote +- A Pull Request is created on GitHub diff --git a/examples/01-express-api/package.json b/examples/01-express-api/package.json new file mode 100644 index 0000000..7714c18 --- /dev/null +++ b/examples/01-express-api/package.json @@ -0,0 +1,18 @@ +{ + "name": "01-express-api", + "version": "1.0.0", + "description": "Express REST API — minimal versionings integration example", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "validate": "versionings validate", + "plan": "versionings plan --semver=patch --branch=release", + "release": "versionings release --semver=patch --branch=release --push" + }, + "dependencies": { + "express": "^4.21.0" + }, + "devDependencies": { + "versionings": "^0.1.0" + } +} \ No newline at end of file diff --git a/examples/01-express-api/src/index.js b/examples/01-express-api/src/index.js new file mode 100644 index 0000000..3ab8f49 --- /dev/null +++ b/examples/01-express-api/src/index.js @@ -0,0 +1,19 @@ +const express = require('express'); +const logger = require('./middleware/logger'); +const healthRoutes = require('./routes/health'); +const usersRoutes = require('./routes/users'); + +const app = express(); +const port = process.env.PORT || 3000; + +app.use(express.json()); +app.use(logger); + +app.use('/health', healthRoutes); +app.use('/users', usersRoutes); + +app.listen(port, () => { + console.log(`Server running on port ${port}`); +}); + +module.exports = app; diff --git a/examples/01-express-api/src/middleware/logger.js b/examples/01-express-api/src/middleware/logger.js new file mode 100644 index 0000000..282864b --- /dev/null +++ b/examples/01-express-api/src/middleware/logger.js @@ -0,0 +1,12 @@ +const logger = (req, res, next) => { + const start = Date.now(); + + res.on('finish', () => { + const duration = Date.now() - start; + console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`); + }); + + next(); +}; + +module.exports = logger; diff --git a/examples/01-express-api/src/routes/health.js b/examples/01-express-api/src/routes/health.js new file mode 100644 index 0000000..3a679ca --- /dev/null +++ b/examples/01-express-api/src/routes/health.js @@ -0,0 +1,14 @@ +const { Router } = require('express'); +const pkg = require('../../package.json'); + +const router = Router(); + +router.get('/', (req, res) => { + res.json({ + status: 'ok', + version: pkg.version, + uptime: Math.floor(process.uptime()) + }); +}); + +module.exports = router; diff --git a/examples/01-express-api/src/routes/users.js b/examples/01-express-api/src/routes/users.js new file mode 100644 index 0000000..ea68e4a --- /dev/null +++ b/examples/01-express-api/src/routes/users.js @@ -0,0 +1,32 @@ +const { Router } = require('express'); + +const router = Router(); + +const users = [ + { id: 1, name: 'Alice Johnson', email: 'alice@example.com', role: 'admin' }, + { id: 2, name: 'Bob Smith', email: 'bob@example.com', role: 'editor' }, + { id: 3, name: 'Charlie Brown', email: 'charlie@example.com', role: 'viewer' }, + { id: 4, name: 'Diana Prince', email: 'diana@example.com', role: 'editor' } +]; + +router.get('/', (req, res) => { + res.json(users); +}); + +router.get('/:id', (req, res) => { + const id = Number(req.params.id); + + if (Number.isNaN(id)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + + const user = users.find((u) => u.id === id); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json(user); +}); + +module.exports = router; diff --git a/examples/01-express-api/version.json b/examples/01-express-api/version.json new file mode 100644 index 0000000..dc3fac5 --- /dev/null +++ b/examples/01-express-api/version.json @@ -0,0 +1,6 @@ +{ + "git": { + "platform": "github", + "url": "https://github.com/your-org/01-express-api.git" + } +} \ No newline at end of file diff --git a/examples/02-react-component-library/.github/workflows/release.yml b/examples/02-react-component-library/.github/workflows/release.yml new file mode 100644 index 0000000..f6964cb --- /dev/null +++ b/examples/02-react-component-library/.github/workflows/release.yml @@ -0,0 +1,27 @@ +name: Auto Release + +on: + push: + branches: [main] + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 18 + + - run: npm install + + - name: Validate + run: npx versionings validate + + - name: Auto Release + run: npx versionings release --semver=auto --branch=auto-release --push --ci --json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/examples/02-react-component-library/.gitignore b/examples/02-react-component-library/.gitignore new file mode 100644 index 0000000..16bd254 --- /dev/null +++ b/examples/02-react-component-library/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.versionings/ +*.log diff --git a/examples/02-react-component-library/CHANGELOG.md b/examples/02-react-component-library/CHANGELOG.md new file mode 100644 index 0000000..6361e43 --- /dev/null +++ b/examples/02-react-component-library/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to this project will be documented in this file. diff --git a/examples/02-react-component-library/README.md b/examples/02-react-component-library/README.md new file mode 100644 index 0000000..253b4f4 --- /dev/null +++ b/examples/02-react-component-library/README.md @@ -0,0 +1,192 @@ +# 02-react-component-library + +React UI component library with a trunk-based workflow, conventional commits, and automatic changelog generation via versionings. + +## Components + +The library ships three React components: + +- **Button** — supports variants (`primary`, `secondary`, `danger`), sizes (`sm`, `md`, `lg`), and a `disabled` state +- **Card** — renders a title, children content, and an optional footer +- **Modal** — overlay dialog with a close button and Escape key handling + +A `classnames` utility merges strings and `{ className: boolean }` objects into a single CSS class string. + +```js +const { Button, Card, Modal } = require('02-react-component-library'); +``` + +## Branching Strategy: Trunk-Based + +The project follows a **trunk-based** strategy — all commits land directly on `main`. Release versions are recorded as tags; dedicated release branches are not used. + +This model suits libraries with frequent releases: every merge to `main` can automatically produce a new version based on conventional commits. + +## SCM Platform: GitHub + +The repository is hosted on GitHub. The CI pipeline uses GitHub Actions to trigger an automatic release on every push to `main`. + +## Conventional Commits + +The project uses [Conventional Commits](https://www.conventionalcommits.org/) to determine the version bump level automatically (`--semver=auto`). + +### Message Format + +``` +(): + +[body] + +[footer] +``` + +### Commit Type to Bump Level Mapping + +| Commit Type | Bump Level | Description | +|-------------|------------|----------------------------| +| `feat` | minor | New feature | +| `fix` | patch | Bug fix | +| `perf` | patch | Performance improvement | +| `revert` | patch | Revert a previous change | +| `refactor` | patch | Code refactoring | +| `deps` | patch | Dependency update | + +If a commit type is not found in the mapping, `fallbackBump: "patch"` is applied. + +### Commit Examples + +```bash +git commit -m "feat(button): add ghost variant" +# → minor bump: 1.0.0 → 1.1.0 + +git commit -m "fix(modal): fix close on Escape" +# → patch bump: 1.1.0 → 1.1.1 + +git commit -m "deps: update react to 18.3.1" +# → patch bump: 1.1.1 → 1.1.2 +``` + +## Automatic Changelog + +On every release, versionings automatically updates `CHANGELOG.md`. + +### Section Grouping (groupTitles) + +Commits are grouped by type with custom section headings: + +| Type | Changelog Heading | +|------------|----------------------| +| `feat` | ✨ Features | +| `fix` | 🐛 Bug Fixes | +| `perf` | ⚡ Performance | +| `refactor` | ♻️ Refactoring | +| `deps` | 📦 Dependencies | + +### Excluded Types (excludeTypes) + +The following commit types are omitted from the changelog: `chore`, `ci`, `test`, `build`, `style`, `docs`. + +## Auto-Bump Workflow + +The `--semver=auto` flag analyzes conventional commits since the last tag and determines the bump level: + +1. If at least one `feat` commit exists → **minor** +2. If `fix`, `perf`, `refactor`, `revert`, or `deps` commits exist → **patch** +3. If no conventional commits are found → `fallbackBump` is used (patch) + +### Release Workflow + +```bash +# 1. Validate configuration +npm run validate + +# 2. Preview the plan (dry-run) +npm run plan + +# 3. Execute the release and push +npm run release +``` + +Scripts from `package.json`: + +```json +{ + "validate": "versionings validate", + "plan": "versionings plan --semver=auto --branch=release", + "release": "versionings release --semver=auto --branch=release --push" +} +``` + +## version.json Configuration + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/your-org/02-react-component-library.git", + "branching": { + "strategy": "trunk-based", + "mainBranch": "main" + } + }, + "conventionalCommits": { + "enabled": true, + "types": { ... }, + "fallbackBump": "patch" + }, + "changelog": { + "file": "CHANGELOG.md", + "groupTitles": { ... }, + "excludeTypes": ["chore", "ci", "test", "build", "style", "docs"] + } +} +``` + +### Field Descriptions + +| Field | Description | +|----------------------------------|------------------------------------------------------| +| `git.platform` | SCM platform — `github` | +| `git.url` | Repository URL | +| `git.branching.strategy` | Branching strategy — `trunk-based` | +| `git.branching.mainBranch` | Primary branch — `main` | +| `conventionalCommits.enabled` | Enable conventional commit analysis | +| `conventionalCommits.types` | Map of commit types to version bump levels | +| `conventionalCommits.fallbackBump` | Default bump level when the type is unrecognized | +| `changelog.file` | Path to the changelog file | +| `changelog.groupTitles` | Custom section headings for the changelog | +| `changelog.excludeTypes` | Commit types excluded from the changelog | + +## CI Pipeline: Auto-Release + +A GitHub Actions workflow (`.github/workflows/release.yml`) runs an automatic release on every push to `main`: + +1. **Checkout** with full history (`fetch-depth: 0`) — required for commit analysis +2. **Setup Node.js 18** +3. **Install dependencies** +4. **Validate** — verify the versionings configuration +5. **Auto Release** — `--semver=auto` determines the bump level from commits; `--ci --json` enables non-interactive mode with machine-readable output + +The `GITHUB_TOKEN` is provided through the GitHub Actions secrets mechanism. + +## Project Structure + +``` +02-react-component-library/ +├── package.json # npm package with react in dependencies +├── version.json # versionings configuration +├── CHANGELOG.md # Automatically updated on release +├── .gitignore +├── README.md +├── src/ +│ ├── index.js # Barrel export: { Button, Card, Modal } +│ ├── components/ +│ │ ├── Button.jsx # Button with variant, size, disabled +│ │ ├── Card.jsx # Card with title, children, footer +│ │ └── Modal.jsx # Modal dialog with overlay and Escape +│ └── utils/ +│ └── classnames.js # CSS class merging utility +└── .github/ + └── workflows/ + └── release.yml # GitHub Actions auto-release +``` diff --git a/examples/02-react-component-library/package.json b/examples/02-react-component-library/package.json new file mode 100644 index 0000000..19ce712 --- /dev/null +++ b/examples/02-react-component-library/package.json @@ -0,0 +1,22 @@ +{ + "name": "02-react-component-library", + "version": "1.0.0", + "description": "React UI component library — trunk-based workflow with conventional commits", + "main": "src/index.js", + "scripts": { + "validate": "versionings validate", + "plan": "versionings plan --semver=auto --branch=release", + "release": "versionings release --semver=auto --branch=release --push" + }, + "dependencies": { + "react": "^18.3.0", + "react-dom": "^18.3.0" + }, + "devDependencies": { + "versionings": "^0.1.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } +} \ No newline at end of file diff --git a/examples/02-react-component-library/src/components/Button.jsx b/examples/02-react-component-library/src/components/Button.jsx new file mode 100644 index 0000000..19480ef --- /dev/null +++ b/examples/02-react-component-library/src/components/Button.jsx @@ -0,0 +1,31 @@ +const React = require('react'); +const { classnames } = require('../utils/classnames'); + +/** + * Button component with variant and size support. + * + * @param {Object} props + * @param {'primary'|'secondary'|'danger'} [props.variant='primary'] + * @param {'sm'|'md'|'lg'} [props.size='md'] + * @param {boolean} [props.disabled=false] + * @param {Function} [props.onClick] + * @param {React.ReactNode} props.children + */ +function Button({ variant = 'primary', size = 'md', disabled = false, onClick, children }) { + const className = classnames('btn', `btn-${variant}`, `btn-${size}`, { + 'btn-disabled': disabled, + }); + + return React.createElement( + 'button', + { + className, + disabled, + onClick: disabled ? undefined : onClick, + type: 'button', + }, + children + ); +} + +module.exports = { Button }; diff --git a/examples/02-react-component-library/src/components/Card.jsx b/examples/02-react-component-library/src/components/Card.jsx new file mode 100644 index 0000000..de794d1 --- /dev/null +++ b/examples/02-react-component-library/src/components/Card.jsx @@ -0,0 +1,25 @@ +const React = require('react'); + +/** + * Card component with a title, content area, and optional footer. + * + * @param {Object} props + * @param {string} [props.title] + * @param {React.ReactNode} props.children + * @param {React.ReactNode} [props.footer] + */ +function Card({ title, children, footer }) { + return React.createElement('div', { className: 'card' }, + title + ? React.createElement('div', { className: 'card-header' }, + React.createElement('h3', { className: 'card-title' }, title) + ) + : null, + React.createElement('div', { className: 'card-body' }, children), + footer + ? React.createElement('div', { className: 'card-footer' }, footer) + : null + ); +} + +module.exports = { Card }; diff --git a/examples/02-react-component-library/src/components/Modal.jsx b/examples/02-react-component-library/src/components/Modal.jsx new file mode 100644 index 0000000..385e5c7 --- /dev/null +++ b/examples/02-react-component-library/src/components/Modal.jsx @@ -0,0 +1,65 @@ +const React = require('react'); +const { classnames } = require('../utils/classnames'); + +/** + * Modal dialog with overlay and close handling. + * + * @param {Object} props + * @param {boolean} props.isOpen + * @param {Function} props.onClose + * @param {string} [props.title] + * @param {React.ReactNode} props.children + */ +function Modal({ isOpen, onClose, title, children }) { + React.useEffect(() => { + if (!isOpen) return; + + function handleKeyDown(event) { + if (event.key === 'Escape') { + onClose(); + } + } + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + function handleOverlayClick(event) { + if (event.target === event.currentTarget) { + onClose(); + } + } + + return React.createElement( + 'div', + { + className: 'modal-overlay', + onClick: handleOverlayClick, + role: 'dialog', + 'aria-modal': 'true', + 'aria-label': title || 'Modal', + }, + React.createElement('div', { className: 'modal-dialog' }, + React.createElement('div', { className: 'modal-header' }, + title + ? React.createElement('h2', { className: 'modal-title' }, title) + : null, + React.createElement( + 'button', + { + className: 'modal-close', + onClick: onClose, + type: 'button', + 'aria-label': 'Close', + }, + '\u00D7' + ) + ), + React.createElement('div', { className: 'modal-body' }, children) + ) + ); +} + +module.exports = { Modal }; diff --git a/examples/02-react-component-library/src/index.js b/examples/02-react-component-library/src/index.js new file mode 100644 index 0000000..737d2fa --- /dev/null +++ b/examples/02-react-component-library/src/index.js @@ -0,0 +1,5 @@ +const { Button } = require('./components/Button'); +const { Card } = require('./components/Card'); +const { Modal } = require('./components/Modal'); + +module.exports = { Button, Card, Modal }; diff --git a/examples/02-react-component-library/src/utils/classnames.js b/examples/02-react-component-library/src/utils/classnames.js new file mode 100644 index 0000000..0c11e02 --- /dev/null +++ b/examples/02-react-component-library/src/utils/classnames.js @@ -0,0 +1,31 @@ +/** + * Merges strings and { className: boolean } objects into a CSS class string. + * + * @param {...(string|Object)} args + * @returns {string} + * + * @example + * classnames('btn', { 'btn-primary': true, 'btn-disabled': false }, 'extra') + * // => 'btn btn-primary extra' + */ +function classnames(...args) { + const classes = []; + + for (const arg of args) { + if (!arg) continue; + + if (typeof arg === 'string') { + classes.push(arg); + } else if (typeof arg === 'object' && !Array.isArray(arg)) { + for (const [key, value] of Object.entries(arg)) { + if (value) { + classes.push(key); + } + } + } + } + + return classes.join(' '); +} + +module.exports = { classnames }; diff --git a/examples/02-react-component-library/version.json b/examples/02-react-component-library/version.json new file mode 100644 index 0000000..d986644 --- /dev/null +++ b/examples/02-react-component-library/version.json @@ -0,0 +1,40 @@ +{ + "git": { + "platform": "github", + "url": "https://github.com/your-org/02-react-component-library.git", + "branching": { + "strategy": "trunk-based", + "mainBranch": "main" + } + }, + "conventionalCommits": { + "enabled": true, + "types": { + "feat": "minor", + "fix": "patch", + "perf": "patch", + "revert": "patch", + "refactor": "patch", + "deps": "patch" + }, + "fallbackBump": "patch" + }, + "changelog": { + "file": "CHANGELOG.md", + "groupTitles": { + "feat": "✨ Features", + "fix": "🐛 Bug Fixes", + "perf": "⚡ Performance", + "refactor": "♻️ Refactoring", + "deps": "📦 Dependencies" + }, + "excludeTypes": [ + "chore", + "ci", + "test", + "build", + "style", + "docs" + ] + } +} \ No newline at end of file diff --git a/examples/03-nestjs-microservice/.gitignore b/examples/03-nestjs-microservice/.gitignore new file mode 100644 index 0000000..16bd254 --- /dev/null +++ b/examples/03-nestjs-microservice/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.versionings/ +*.log diff --git a/examples/03-nestjs-microservice/.gitlab-ci.yml b/examples/03-nestjs-microservice/.gitlab-ci.yml new file mode 100644 index 0000000..01555c9 --- /dev/null +++ b/examples/03-nestjs-microservice/.gitlab-ci.yml @@ -0,0 +1,23 @@ +image: node:18 + +stages: + - validate + - release + +validate: + stage: validate + script: + - npm install + - npx versionings validate --json + variables: + GITLAB_TOKEN: $GITLAB_TOKEN + +release: + stage: release + script: + - npm install + - npx versionings release --semver=minor --branch=ci-release --push --ci --json + variables: + GITLAB_TOKEN: $GITLAB_TOKEN + only: + - develop diff --git a/examples/03-nestjs-microservice/README.md b/examples/03-nestjs-microservice/README.md new file mode 100644 index 0000000..1248b62 --- /dev/null +++ b/examples/03-nestjs-microservice/README.md @@ -0,0 +1,172 @@ +# 03-nestjs-microservice + +NestJS task-management microservice with git-flow branching and GitLab Merge Request automation via [versionings](../../README.md). + +## What This Example Demonstrates + +- NestJS modular architecture (Module → Controller → Service) +- Git-flow branching strategy with `main` and `develop` branches +- GitLab as the SCM platform for automated Merge Requests +- Release workflow from `develop` (minor/major) and hotfix workflow from `main` (patch) +- CI pipeline with `validate` and `release` stages + +## NestJS Architecture + +The project follows the standard NestJS modular pattern: + +``` +Module → Controller → Service +``` + +- **AppModule** — root module that imports feature modules +- **TasksModule** — feature module encapsulating task management logic +- **TasksController** — REST controller handling `GET`, `POST`, `PATCH`, `DELETE /tasks` +- **TasksService** — business logic service with in-memory storage and CRUD operations + +## Branching Strategy: git-flow + +This project uses **git-flow** — a branching model suited for services with scheduled releases and parallel development. + +- **mainBranch**: `main` — stable branch containing only released versions +- **developBranch**: `develop` — integration branch where feature branches are merged + +## SCM Platform: GitLab + +The project targets **GitLab**. When `release --push` is executed, versionings automatically creates a Merge Request against the configured target branch. + +## Git-flow Workflow + +### Release from develop (minor/major) + +Standard releases originate from the `develop` branch: + +1. Ensure all feature branches are merged into `develop`. +2. Switch to `develop`: + ```bash + git checkout develop + ``` +3. Validate the configuration: + ```bash + npm run validate + ``` +4. Preview the release plan: + ```bash + npm run plan + ``` +5. Execute the release: + ```bash + npm run release + ``` + +Versionings creates a version branch, an annotated tag, and a Merge Request targeting `main`. + +### Hotfix from main (patch) + +Emergency fixes originate from the `main` branch: + +1. Switch to `main`: + ```bash + git checkout main + ``` +2. Run a hotfix release: + ```bash + npx versionings release --semver=patch --branch=hotfix --push + ``` +3. After the hotfix is merged into `main`, back-merge into `develop`: + ```bash + git checkout develop + git merge main + ``` + +## GitLab MR Automation + +When `release --push` runs, versionings creates a Merge Request in GitLab with preconfigured parameters: + +- **reviewers**: `lead-dev`, `qa-engineer` — assigned automatically +- **labels**: `release`, `microservice` — applied automatically + +This ensures a consistent review process across all releases. + +## version.json Configuration + +```json +{ + "git": { + "platform": "gitlab", + "url": "https://gitlab.com/your-org/03-nestjs-microservice.git", + "branching": { + "strategy": "git-flow", + "mainBranch": "main", + "developBranch": "develop" + }, + "pr": { + "target": "main", + "reviewers": ["lead-dev", "qa-engineer"], + "labels": ["release", "microservice"] + } + } +} +``` + +| Field | Description | +|-------|-------------| +| `git.platform` | SCM platform — `gitlab` | +| `git.url` | GitLab repository URL | +| `git.branching.strategy` | Branching strategy — `git-flow` | +| `git.branching.mainBranch` | Production branch — `main` | +| `git.branching.developBranch` | Integration branch — `develop` | +| `git.pr.target` | MR target branch — `main` | +| `git.pr.reviewers` | Reviewers assigned automatically to each MR | +| `git.pr.labels` | Labels applied automatically to each MR | + +## CI Pipeline (.gitlab-ci.yml) + +The pipeline consists of two stages: + +### validate + +Runs on every push. Checks that the versionings configuration and environment are valid: + +```yaml +validate: + stage: validate + script: + - npm install + - npx versionings validate --json +``` + +### release + +Runs only on pushes to `develop`. Performs an automated release: + +```yaml +release: + stage: release + script: + - npm install + - npx versionings release --semver=minor --branch=ci-release --push --ci --json + only: + - develop +``` + +The `GITLAB_TOKEN` is provided via GitLab CI/CD Variables (Settings → CI/CD → Variables). + +## Project Structure + +``` +03-nestjs-microservice/ +├── package.json # Dependencies and npm scripts +├── version.json # Versionings config (GitLab, git-flow) +├── tsconfig.json # TypeScript configuration for NestJS +├── .gitignore # Git ignore rules +├── .gitlab-ci.yml # GitLab CI pipeline +├── README.md # Project documentation +└── src/ + ├── main.ts # Entry point — NestJS bootstrap + ├── app.module.ts # Root module + └── tasks/ + ├── tasks.module.ts # Tasks feature module + ├── tasks.controller.ts # REST controller for /tasks + ├── tasks.service.ts # Business logic service + └── task.model.ts # Task interface and TaskStatus enum +``` diff --git a/examples/03-nestjs-microservice/package.json b/examples/03-nestjs-microservice/package.json new file mode 100644 index 0000000..680c01a --- /dev/null +++ b/examples/03-nestjs-microservice/package.json @@ -0,0 +1,24 @@ +{ + "name": "03-nestjs-microservice", + "version": "1.0.0", + "description": "NestJS microservice — git-flow workflow with GitLab MR automation", + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "validate": "versionings validate", + "plan": "versionings plan --semver=minor --branch=release", + "release": "versionings release --semver=minor --branch=release --push" + }, + "dependencies": { + "@nestjs/core": "^10.4.0", + "@nestjs/common": "^10.4.0", + "@nestjs/platform-express": "^10.4.0", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.0" + }, + "devDependencies": { + "typescript": "^5.5.0", + "versionings": "^0.1.0" + } +} \ No newline at end of file diff --git a/examples/03-nestjs-microservice/src/app.module.ts b/examples/03-nestjs-microservice/src/app.module.ts new file mode 100644 index 0000000..99c709f --- /dev/null +++ b/examples/03-nestjs-microservice/src/app.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { TasksModule } from './tasks/tasks.module'; + +@Module({ + imports: [TasksModule], +}) +export class AppModule { } diff --git a/examples/03-nestjs-microservice/src/main.ts b/examples/03-nestjs-microservice/src/main.ts new file mode 100644 index 0000000..76eaa32 --- /dev/null +++ b/examples/03-nestjs-microservice/src/main.ts @@ -0,0 +1,11 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const port = process.env.PORT || 3000; + await app.listen(port); + console.log(`Application is running on port ${port}`); +} + +bootstrap(); diff --git a/examples/03-nestjs-microservice/src/tasks/task.model.ts b/examples/03-nestjs-microservice/src/tasks/task.model.ts new file mode 100644 index 0000000..3c11e97 --- /dev/null +++ b/examples/03-nestjs-microservice/src/tasks/task.model.ts @@ -0,0 +1,13 @@ +export enum TaskStatus { + OPEN = 'OPEN', + IN_PROGRESS = 'IN_PROGRESS', + DONE = 'DONE', +} + +export interface Task { + id: string; + title: string; + description: string; + status: TaskStatus; + createdAt: Date; +} diff --git a/examples/03-nestjs-microservice/src/tasks/tasks.controller.ts b/examples/03-nestjs-microservice/src/tasks/tasks.controller.ts new file mode 100644 index 0000000..c11487a --- /dev/null +++ b/examples/03-nestjs-microservice/src/tasks/tasks.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Post, Patch, Delete, Param, Body } from '@nestjs/common'; +import { TasksService } from './tasks.service'; +import { Task, TaskStatus } from './task.model'; + +@Controller('tasks') +export class TasksController { + constructor(private readonly tasksService: TasksService) { } + + @Get() + findAll(): Task[] { + return this.tasksService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string): Task { + return this.tasksService.findOne(id); + } + + @Post() + create(@Body() body: { title: string; description: string }): Task { + return this.tasksService.create(body); + } + + @Patch(':id') + update( + @Param('id') id: string, + @Body() body: { title?: string; description?: string; status?: TaskStatus }, + ): Task { + return this.tasksService.update(id, body); + } + + @Delete(':id') + remove(@Param('id') id: string): void { + this.tasksService.remove(id); + } +} diff --git a/examples/03-nestjs-microservice/src/tasks/tasks.module.ts b/examples/03-nestjs-microservice/src/tasks/tasks.module.ts new file mode 100644 index 0000000..6e4d06e --- /dev/null +++ b/examples/03-nestjs-microservice/src/tasks/tasks.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TasksController } from './tasks.controller'; +import { TasksService } from './tasks.service'; + +@Module({ + controllers: [TasksController], + providers: [TasksService], +}) +export class TasksModule { } diff --git a/examples/03-nestjs-microservice/src/tasks/tasks.service.ts b/examples/03-nestjs-microservice/src/tasks/tasks.service.ts new file mode 100644 index 0000000..c0bd77c --- /dev/null +++ b/examples/03-nestjs-microservice/src/tasks/tasks.service.ts @@ -0,0 +1,55 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Task, TaskStatus } from './task.model'; +import { randomUUID } from 'crypto'; + +@Injectable() +export class TasksService { + private readonly tasks = new Map(); + + findAll(): Task[] { + return Array.from(this.tasks.values()); + } + + findOne(id: string): Task { + const task = this.tasks.get(id); + if (!task) { + throw new NotFoundException(`Task with id "${id}" not found`); + } + return task; + } + + create(dto: { title: string; description: string }): Task { + const task: Task = { + id: randomUUID(), + title: dto.title, + description: dto.description, + status: TaskStatus.OPEN, + createdAt: new Date(), + }; + this.tasks.set(task.id, task); + return task; + } + + update(id: string, dto: { title?: string; description?: string; status?: TaskStatus }): Task { + const task = this.findOne(id); + if (dto.title !== undefined) { + task.title = dto.title; + } + if (dto.description !== undefined) { + task.description = dto.description; + } + if (dto.status !== undefined) { + task.status = dto.status; + } + this.tasks.set(id, task); + return task; + } + + remove(id: string): void { + const task = this.tasks.get(id); + if (!task) { + throw new NotFoundException(`Task with id "${id}" not found`); + } + this.tasks.delete(id); + } +} diff --git a/examples/03-nestjs-microservice/tsconfig.json b/examples/03-nestjs-microservice/tsconfig.json new file mode 100644 index 0000000..e285336 --- /dev/null +++ b/examples/03-nestjs-microservice/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false + }, + "include": [ + "src/**/*" + ] +} \ No newline at end of file diff --git a/examples/03-nestjs-microservice/version.json b/examples/03-nestjs-microservice/version.json new file mode 100644 index 0000000..ea0342f --- /dev/null +++ b/examples/03-nestjs-microservice/version.json @@ -0,0 +1,22 @@ +{ + "git": { + "platform": "gitlab", + "url": "https://gitlab.com/your-org/03-nestjs-microservice.git", + "branching": { + "strategy": "git-flow", + "mainBranch": "main", + "developBranch": "develop" + }, + "pr": { + "target": "main", + "reviewers": [ + "lead-dev", + "qa-engineer" + ], + "labels": [ + "release", + "microservice" + ] + } + } +} \ No newline at end of file diff --git a/examples/04-cli-tool/.github/PULL_REQUEST_TEMPLATE.md b/examples/04-cli-tool/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..9a2dbfc --- /dev/null +++ b/examples/04-cli-tool/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## Release Summary + +**Version:** +**Type:** + +## Changes + +- [ ] Describe the changes included in this release + +## Checklist + +- [ ] Tests pass +- [ ] Documentation updated +- [ ] CHANGELOG updated diff --git a/examples/04-cli-tool/.github/workflows/release.yml b/examples/04-cli-tool/.github/workflows/release.yml new file mode 100644 index 0000000..d3bd329 --- /dev/null +++ b/examples/04-cli-tool/.github/workflows/release.yml @@ -0,0 +1,27 @@ +name: Release Branch + +on: + push: + branches: [main] + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 18 + + - run: npm install + + - name: Validate + run: npx versionings validate + + - name: Release + run: npx versionings release --semver=minor --branch=ci-release --push --ci --json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/examples/04-cli-tool/.gitignore b/examples/04-cli-tool/.gitignore new file mode 100644 index 0000000..16bd254 --- /dev/null +++ b/examples/04-cli-tool/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.versionings/ +*.log diff --git a/examples/04-cli-tool/README.md b/examples/04-cli-tool/README.md new file mode 100644 index 0000000..30c617f --- /dev/null +++ b/examples/04-cli-tool/README.md @@ -0,0 +1,265 @@ +# 04-cli-tool — CLI Tool + +A CLI tool built with **yargs** featuring `greet` and `count` subcommands. +Demonstrates the **release-branch** strategy, **draft PR** mode, and +**PR templates** via versionings. + +## Stack + +- **Runtime:** Node.js ≥ 18 +- **CLI framework:** yargs 17 +- **Versioning:** versionings CLI + +## CLI Tool and Subcommands + +The `mytool` utility exposes two subcommands: + +### greet + +Greet a user in a selected language. + +```bash +mytool greet --name Alice --lang en +# Hello, Alice! + +mytool greet --name John +# Hello, John! + +mytool greet --name Hans --lang de +# Hallo, Hans! +``` + +| Option | Type | Required | Default | Description | +|--------|------|:--------:|:-------:|-------------| +| `--name`, `-n` | string | yes | — | Name to greet | +| `--lang`, `-l` | en / ru / de | no | en | Greeting language | + +### count + +Count words, lines, and characters in a file. + +```bash +mytool count README.md +# All metrics: lines, words, chars + +mytool count README.md --words +# Word count only + +mytool count README.md --lines --chars +# Lines and characters +``` + +| Option | Type | Description | +|--------|------|-------------| +| `` | positional | Path to the file | +| `--words`, `-w` | boolean | Count words | +| `--lines`, `-l` | boolean | Count lines | +| `--chars`, `-c` | boolean | Count characters | + +When no flags are provided, all three metrics are printed. + +## Branching Strategy: release-branch + +| Parameter | Value | +|-----------|-------| +| SCM platform | GitHub | +| Branching strategy | `release-branch` | +| Config format | `version.json` | + +The **release-branch** strategy creates a dedicated `release/{version}` branch for +each release. Its key feature is **patch reuse**: if a branch `release/1.1.x` already +exists, subsequent patch releases (1.1.1, 1.1.2, …) reuse it instead of creating a +new one. This is well-suited for CLI tools where minor releases add subcommands and +patch releases fix bugs within the same release branch. + +### How release-branch Works + +1. `--semver=minor` creates branch `release/1.1.x` +2. Version is bumped to `1.1.0`; tag `1.1.0` is created +3. Next `--semver=patch` reuses branch `release/1.1.x` +4. Version is bumped to `1.1.1`; tag `1.1.1` is created +5. A new `--semver=minor` creates `release/1.2.x` + +## SCM Platform: GitHub + +GitHub is the hosting platform for this example. When `--push` is specified, +versionings automatically creates a Pull Request via the GitHub API. In this +example the PR is created in **draft** mode using a PR template. + +## Draft PR Mode and PR Templates + +The `version.json` configuration includes two PR-related settings: + +```json +{ + "git": { + "pr": { + "target": "main", + "draft": true, + "template": ".github/PULL_REQUEST_TEMPLATE.md" + } + } +} +``` + +| Parameter | Value | Description | +|-----------|-------|-------------| +| `target` | `main` | Target branch for the PR | +| `draft` | `true` | PR is created as a draft | +| `template` | `.github/PULL_REQUEST_TEMPLATE.md` | Path to the PR template | + +**Draft mode** is useful for CLI tools: the PR is created automatically but +requires manual review and approval before merging. This gives the team time to +verify the changelog, test the binary, and confirm the release is correct. + +The **PR template** contains Release Summary, Changes, and Checklist sections. +Versionings auto-fills the version and type fields when creating the PR. + +## Release Examples + +### Initial release (minor) + +First release of the CLI tool — a minor version with the initial set of subcommands: + +```bash +# 1. Validate configuration +npm run validate + +# 2. Preview the execution plan +npm run plan + +# 3. Release +npm run release +# or directly: +npx versionings release --semver=minor --branch=release --push +``` + +Result: +- **Version:** `1.0.0` → `1.1.0` +- **Branch:** `release/1.1.x` +- **Tag:** `1.1.0` +- **PR:** Draft PR targeting `main` with template + +### Subsequent patch release + +Bug fix in the `count` subcommand — a patch within the existing release branch: + +```bash +npx versionings release --semver=patch --branch=release --push +``` + +Result: +- **Version:** `1.1.0` → `1.1.1` +- **Branch:** `release/1.1.x` (reused) +- **Tag:** `1.1.1` +- **PR:** Draft PR targeting `main` with template + +### Next minor release + +Adding a new subcommand — a new minor release: + +```bash +npx versionings release --semver=minor --branch=release --push +``` + +Result: +- **Version:** `1.1.1` → `1.2.0` +- **Branch:** `release/1.2.x` (new) +- **Tag:** `1.2.0` + +## version.json Configuration + +```json +{ + "git": { + "platform": "github", + "url": "https://github.com/your-org/04-cli-tool.git", + "branching": { + "strategy": "release-branch" + }, + "pr": { + "target": "main", + "draft": true, + "template": ".github/PULL_REQUEST_TEMPLATE.md" + } + } +} +``` + +| Field | Description | +|-------|-------------| +| `git.platform` | SCM platform — `github` | +| `git.url` | Git repository URL | +| `git.branching.strategy` | Branching strategy — `release-branch` | +| `git.pr.target` | Target branch for Pull Requests | +| `git.pr.draft` | Create PRs as drafts | +| `git.pr.template` | Path to the PR template file | + +## Prerequisites + +- Node.js ≥ 18 and npm +- Git with a configured remote +- versionings installed: `npm install --global versionings` +- Replace `your-org` in `version.json` with your actual repository URL before using `--push` + +## CI Pipeline + +The `.github/workflows/release.yml` file automates releases on push to `main`. + +### Pipeline Steps + +1. **Checkout** with `fetch-depth: 0` — full commit history is required for + versionings to analyze tags and branches +2. **Node.js 18** — runtime setup +3. **npm install** — install dependencies (including versionings) +4. **Validate** — verify configuration before releasing +5. **Release** — execute the release with flags: + - `--semver=minor` — version bump type + - `--branch=ci-release` — branch name for CI + - `--push` — push to remote and create a draft PR + - `--ci` — non-interactive mode (no prompts) + - `--json` — structured JSON output for CI parsing + +### Token + +`GITHUB_TOKEN` is passed via GitHub Actions secrets (`${{ secrets.GITHUB_TOKEN }}`). +The built-in GitHub Actions token has permissions to push and create PRs within the +repository. + +```yaml +- name: Release + run: npx versionings release --semver=minor --branch=ci-release --push --ci --json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +## Project Structure + +``` +04-cli-tool/ +├── package.json # npm package, bin field, validate/plan/release scripts +├── version.json # versionings config: release-branch, draft PR +├── .gitignore # node_modules/, dist/, .versionings/ +├── README.md # This file +├── .github/ +│ ├── PULL_REQUEST_TEMPLATE.md # PR template: Release Summary, Changes, Checklist +│ └── workflows/ +│ └── release.yml # GitHub Actions: validate → release +└── src/ + ├── index.js # yargs setup: scriptName, strictCommands, subcommands + ├── commands/ + │ ├── greet.js # greet subcommand: --name, --lang (en/ru/de) + │ └── count.js # count subcommand: --words/--lines/--chars + └── utils/ + └── format.js # formatTable, formatJson, formatPlain +``` + +## Expected Result + +Running `release --semver=minor` from version `1.0.0`: + +- **Version:** `1.0.0` → `1.1.0` +- **Branch:** `release/1.1.x` +- **Tag:** `1.1.0` +- **PR:** Draft Pull Request targeting `main` with template from `.github/PULL_REQUEST_TEMPLATE.md` +- **Exit code:** `0` (success) diff --git a/examples/04-cli-tool/package.json b/examples/04-cli-tool/package.json new file mode 100644 index 0000000..f086552 --- /dev/null +++ b/examples/04-cli-tool/package.json @@ -0,0 +1,20 @@ +{ + "name": "04-cli-tool", + "version": "1.0.0", + "description": "CLI tool — release-branch strategy with PR templates", + "bin": { + "mytool": "./src/index.js" + }, + "scripts": { + "start": "node src/index.js", + "validate": "versionings validate", + "plan": "versionings plan --semver=minor --branch=release", + "release": "versionings release --semver=minor --branch=release --push" + }, + "dependencies": { + "yargs": "^17.7.0" + }, + "devDependencies": { + "versionings": "^0.1.0" + } +} \ No newline at end of file diff --git a/examples/04-cli-tool/src/commands/count.js b/examples/04-cli-tool/src/commands/count.js new file mode 100644 index 0000000..d4c4ab8 --- /dev/null +++ b/examples/04-cli-tool/src/commands/count.js @@ -0,0 +1,56 @@ +const fs = require('fs'); +const { formatTable, formatPlain } = require('../utils/format'); + +module.exports = { + command: 'count ', + describe: 'Count words, lines, and characters in a file', + builder: (yargs) => + yargs + .positional('file', { + type: 'string', + describe: 'Path to the file', + }) + .option('words', { + alias: 'w', + type: 'boolean', + default: false, + describe: 'Count words', + }) + .option('lines', { + alias: 'l', + type: 'boolean', + default: false, + describe: 'Count lines', + }) + .option('chars', { + alias: 'c', + type: 'boolean', + default: false, + describe: 'Count characters', + }), + handler: (argv) => { + const filePath = argv.file; + + if (!fs.existsSync(filePath)) { + console.error(`File not found: ${filePath}`); + process.exit(1); + } + + const content = fs.readFileSync(filePath, 'utf-8'); + const showAll = !argv.words && !argv.lines && !argv.chars; + + const results = { file: filePath }; + + if (showAll || argv.lines) { + results.lines = content.split('\n').length; + } + if (showAll || argv.words) { + results.words = content.split(/\s+/).filter(Boolean).length; + } + if (showAll || argv.chars) { + results.chars = content.length; + } + + console.log(formatTable(results)); + }, +}; diff --git a/examples/04-cli-tool/src/commands/greet.js b/examples/04-cli-tool/src/commands/greet.js new file mode 100644 index 0000000..219b755 --- /dev/null +++ b/examples/04-cli-tool/src/commands/greet.js @@ -0,0 +1,31 @@ +const { formatPlain } = require('../utils/format'); + +const greetings = { + en: (name) => `Hello, ${name}!`, + ru: (name) => `Привет, ${name}!`, + de: (name) => `Hallo, ${name}!`, +}; + +module.exports = { + command: 'greet', + describe: 'Greet a user in a selected language', + builder: (yargs) => + yargs + .option('name', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Name to greet', + }) + .option('lang', { + alias: 'l', + type: 'string', + choices: ['en', 'ru', 'de'], + default: 'en', + describe: 'Greeting language', + }), + handler: (argv) => { + const greeting = greetings[argv.lang](argv.name); + console.log(formatPlain({ message: greeting })); + }, +}; diff --git a/examples/04-cli-tool/src/index.js b/examples/04-cli-tool/src/index.js new file mode 100644 index 0000000..582c3ea --- /dev/null +++ b/examples/04-cli-tool/src/index.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +const yargs = require('yargs'); +const { hideBin } = require('yargs/helpers'); +const greetCommand = require('./commands/greet'); +const countCommand = require('./commands/count'); + +yargs(hideBin(process.argv)) + .scriptName('mytool') + .usage('$0 [options]') + .command(greetCommand) + .command(countCommand) + .strictCommands() + .demandCommand(1, 'Please specify a command. Use --help to see available commands.') + .alias('h', 'help') + .alias('v', 'version') + .parse(); diff --git a/examples/04-cli-tool/src/utils/format.js b/examples/04-cli-tool/src/utils/format.js new file mode 100644 index 0000000..a87952d --- /dev/null +++ b/examples/04-cli-tool/src/utils/format.js @@ -0,0 +1,23 @@ +function formatTable(data) { + const entries = Object.entries(data); + const maxKeyLen = Math.max(...entries.map(([k]) => k.length)); + + const separator = '-'.repeat(maxKeyLen + 20); + const rows = entries.map( + ([key, value]) => `${key.padEnd(maxKeyLen)} │ ${value}` + ); + + return [separator, ...rows, separator].join('\n'); +} + +function formatJson(data) { + return JSON.stringify(data, null, 2); +} + +function formatPlain(data) { + return Object.entries(data) + .map(([key, value]) => `${key}: ${value}`) + .join('\n'); +} + +module.exports = { formatTable, formatJson, formatPlain }; diff --git a/examples/04-cli-tool/version.json b/examples/04-cli-tool/version.json new file mode 100644 index 0000000..40e4d79 --- /dev/null +++ b/examples/04-cli-tool/version.json @@ -0,0 +1,14 @@ +{ + "git": { + "platform": "github", + "url": "https://github.com/your-org/04-cli-tool.git", + "branching": { + "strategy": "release-branch" + }, + "pr": { + "target": "main", + "draft": true, + "template": ".github/PULL_REQUEST_TEMPLATE.md" + } + } +} \ No newline at end of file diff --git a/examples/05-monorepo-packages/.gitignore b/examples/05-monorepo-packages/.gitignore new file mode 100644 index 0000000..16bd254 --- /dev/null +++ b/examples/05-monorepo-packages/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.versionings/ +*.log diff --git a/examples/05-monorepo-packages/.versioningsrc.yml b/examples/05-monorepo-packages/.versioningsrc.yml new file mode 100644 index 0000000..783d60a --- /dev/null +++ b/examples/05-monorepo-packages/.versioningsrc.yml @@ -0,0 +1,7 @@ +git: + platform: bitbucket + url: https://bitbucket.org/your-workspace/05-monorepo-packages.git + branching: + strategy: default + pr: + target: main diff --git a/examples/05-monorepo-packages/README.md b/examples/05-monorepo-packages/README.md new file mode 100644 index 0000000..ad81e2e --- /dev/null +++ b/examples/05-monorepo-packages/README.md @@ -0,0 +1,156 @@ +# 05-monorepo-packages + +Monorepo project with two packages demonstrating versionings integration with Bitbucket, YAML-based configuration, and the config hierarchy mechanism. + +## Overview + +The project is organized as a monorepo using npm workspaces. It contains two packages: + +- **@monorepo/core** — object utility library: `deepMerge` (recursive merge), `cloneDeep` (deep clone via JSON serialization), `isEqual` (deep equality comparison). +- **@monorepo/logger** — structured JSON logger. The `createLogger(options)` factory returns an object with `info`, `warn`, `error`, and `debug` methods. Each method writes a JSON line containing `timestamp`, `level`, `message`, and optional `context` fields. + +## Branching Strategy + +Uses the **default** strategy — the simplest strategy available in versionings. On release, a branch `version/patch//` is created from the current branch with no restrictions on the source branch. Suitable for monorepo projects where releases are performed from the main branch. + +## SCM Platform + +The project targets **Bitbucket** (`git.platform: bitbucket`). Pull requests are opened against the `main` branch. The `BITBUCKET_TOKEN` must be configured as a secured repository variable under Bitbucket Settings → Repository variables. + +## YAML Configuration (.versioningsrc.yml) + +The primary versionings configuration lives in `.versioningsrc.yml` at the project root: + +```yaml +git: + platform: bitbucket + url: https://bitbucket.org/your-workspace/05-monorepo-packages.git + branching: + strategy: default + pr: + target: main +``` + +Fields: +- `git.platform` — SCM platform (`bitbucket`) +- `git.url` — repository URL +- `git.branching.strategy` — branching strategy (`default`) +- `git.pr.target` — pull request target branch (`main`) + +## Config Hierarchy + +Versionings loads configuration from multiple sources. Priority (highest to lowest): + +| Priority | Source | Description | +|----------|--------|-------------| +| 1 (highest) | CLI flags | Command-line flags (`--semver`, `--branch`, etc.) | +| 2 | Environment variables | `VERSIONINGS_*` environment variables | +| 3 | `version.json` | Primary configuration file | +| 4 | `.versioningsrc` / `.versioningsrc.yml` | Alternative configuration file | +| 5 (lowest) | `package.json#versionings` | `"versionings"` section in package.json | + +Higher-priority sources overwrite values from lower-priority sources. Nested objects are merged recursively. + +### package.json#versionings Override Example + +In this project, the `@monorepo/core` package declares a `"versionings"` section in its `package.json`: + +```json +{ + "name": "@monorepo/core", + "version": "1.0.0", + "main": "src/index.js", + "versionings": { + "git": { + "pr": { + "target": "develop" + } + } + } +} +``` + +The root `.versioningsrc.yml` sets `git.pr.target: main` (priority 4). The core package's `package.json#versionings` attempts to override it with `develop` (priority 5 — lower). Because `.versioningsrc.yml` has higher priority, the resolved value of `git.pr.target` remains `main`. + +To override a value from `.versioningsrc.yml`, use a higher-priority source — CLI flags or environment variables. + +## Environment Variable Overrides (VERSIONINGS_*) + +Any configuration field can be overridden via environment variables prefixed with `VERSIONINGS_`. Nesting is expressed with `_`: + +| Variable | Config equivalent | +|----------|-------------------| +| `VERSIONINGS_GIT_PLATFORM` | `git.platform` | +| `VERSIONINGS_GIT_PR_TARGET` | `git.pr.target` | +| `VERSIONINGS_GIT_BRANCHING_STRATEGY` | `git.branching.strategy` | + +Example usage in CI: + +```bash +VERSIONINGS_GIT_PR_TARGET=develop npx versionings validate --json +``` + +Environment variables have priority 2 (higher than `version.json`, `.versioningsrc.yml`, and `package.json#versionings`), so they are guaranteed to overwrite values from configuration files. + +## Bitbucket Pipelines CI + +The CI pipeline is defined in `bitbucket-pipelines.yml`. It triggers on pushes to the `main` branch: + +```yaml +image: node:18 + +pipelines: + branches: + main: + - step: + name: Validate and Release + script: + - npm install + - npx versionings validate --json + - npx versionings release --semver=patch --branch=ci-release --push --ci --json + caches: + - node +``` + +Flags: +- `--ci` — non-interactive mode, suppresses all interactive prompts +- `--json` — structured JSON output for CI script consumption +- `--push` — automatically pushes changes and creates a pull request + +The `BITBUCKET_TOKEN` must be configured in Bitbucket Settings → Repository settings → Repository variables as a secured variable. + +## Release Workflow + +```bash +# 1. Install dependencies +npm install + +# 2. Validate configuration +npm run validate + +# 3. Preview the release plan (dry-run) +npm run plan + +# 4. Execute the release +npm run release +``` + +## Project Structure + +``` +05-monorepo-packages/ +├── package.json # Root package (workspaces) +├── .versioningsrc.yml # Versionings configuration (YAML) +├── .gitignore +├── README.md +├── bitbucket-pipelines.yml # Bitbucket Pipelines CI +└── packages/ + ├── core/ + │ ├── package.json # Includes "versionings" section (config hierarchy) + │ └── src/ + │ └── index.js # deepMerge, cloneDeep, isEqual + └── logger/ + ├── package.json + └── src/ + └── index.js # createLogger → info, warn, error, debug +``` diff --git a/examples/05-monorepo-packages/bitbucket-pipelines.yml b/examples/05-monorepo-packages/bitbucket-pipelines.yml new file mode 100644 index 0000000..7608739 --- /dev/null +++ b/examples/05-monorepo-packages/bitbucket-pipelines.yml @@ -0,0 +1,13 @@ +image: node:18 + +pipelines: + branches: + main: + - step: + name: Validate and Release + script: + - npm install + - npx versionings validate --json + - npx versionings release --semver=patch --branch=ci-release --push --ci --json + caches: + - node diff --git a/examples/05-monorepo-packages/package.json b/examples/05-monorepo-packages/package.json new file mode 100644 index 0000000..c207977 --- /dev/null +++ b/examples/05-monorepo-packages/package.json @@ -0,0 +1,17 @@ +{ + "name": "05-monorepo-packages", + "version": "1.0.0", + "private": true, + "description": "Monorepo — Bitbucket, YAML configuration, config hierarchy", + "workspaces": [ + "packages/*" + ], + "scripts": { + "validate": "versionings validate", + "plan": "versionings plan --semver=patch --branch=release", + "release": "versionings release --semver=patch --branch=release --push" + }, + "devDependencies": { + "versionings": "^0.1.0" + } +} \ No newline at end of file diff --git a/examples/05-monorepo-packages/packages/core/package.json b/examples/05-monorepo-packages/packages/core/package.json new file mode 100644 index 0000000..e18aeb9 --- /dev/null +++ b/examples/05-monorepo-packages/packages/core/package.json @@ -0,0 +1,12 @@ +{ + "name": "@monorepo/core", + "version": "1.0.0", + "main": "src/index.js", + "versionings": { + "git": { + "pr": { + "target": "develop" + } + } + } +} \ No newline at end of file diff --git a/examples/05-monorepo-packages/packages/core/src/index.js b/examples/05-monorepo-packages/packages/core/src/index.js new file mode 100644 index 0000000..cf09fd5 --- /dev/null +++ b/examples/05-monorepo-packages/packages/core/src/index.js @@ -0,0 +1,110 @@ +'use strict'; + +/** + * Recursively merges two objects. + * Properties from source overwrite properties in target. + * Nested objects are merged recursively; arrays are replaced entirely. + * + * @param {Object} target - the target object + * @param {Object} source - the source object + * @returns {Object} a new object containing the merged result + */ +function deepMerge(target, source) { + const result = {}; + + const allKeys = new Set([ + ...Object.keys(target), + ...Object.keys(source), + ]); + + for (const key of allKeys) { + const targetVal = target[key]; + const sourceVal = source[key]; + + if (key in source && key in target) { + if ( + isPlainObject(targetVal) && + isPlainObject(sourceVal) + ) { + result[key] = deepMerge(targetVal, sourceVal); + } else { + result[key] = cloneDeep(sourceVal); + } + } else if (key in source) { + result[key] = cloneDeep(sourceVal); + } else { + result[key] = cloneDeep(targetVal); + } + } + + return result; +} + +/** + * Deep-clones a value via JSON serialization. + * Does not support functions, undefined, Date, RegExp, or circular references. + * + * @param {*} obj - the value to clone + * @returns {*} a deep copy of the value + */ +function cloneDeep(obj) { + if (obj === null || typeof obj !== 'object') { + return obj; + } + return JSON.parse(JSON.stringify(obj)); +} + +/** + * Performs a deep equality comparison of two values. + * Supports primitives, arrays, and plain objects. + * + * @param {*} a - the first value + * @param {*} b - the second value + * @returns {boolean} true if the values are deeply equal + */ +function isEqual(a, b) { + if (a === b) { + return true; + } + + if ( + a === null || b === null || + typeof a !== 'object' || typeof b !== 'object' + ) { + return false; + } + + if (Array.isArray(a) !== Array.isArray(b)) { + return false; + } + + if (Array.isArray(a)) { + if (a.length !== b.length) { + return false; + } + return a.every((item, index) => isEqual(item, b[index])); + } + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) { + return false; + } + + return keysA.every( + (key) => Object.prototype.hasOwnProperty.call(b, key) && isEqual(a[key], b[key]) + ); +} + +/** + * Checks whether a value is a plain object (not an array, not null). + * + * @param {*} val + * @returns {boolean} + */ +function isPlainObject(val) { + return val !== null && typeof val === 'object' && !Array.isArray(val); +} + +module.exports = { deepMerge, cloneDeep, isEqual }; diff --git a/examples/05-monorepo-packages/packages/logger/package.json b/examples/05-monorepo-packages/packages/logger/package.json new file mode 100644 index 0000000..90c2852 --- /dev/null +++ b/examples/05-monorepo-packages/packages/logger/package.json @@ -0,0 +1,5 @@ +{ + "name": "@monorepo/logger", + "version": "1.0.0", + "main": "src/index.js" +} \ No newline at end of file diff --git a/examples/05-monorepo-packages/packages/logger/src/index.js b/examples/05-monorepo-packages/packages/logger/src/index.js new file mode 100644 index 0000000..a37a2d0 --- /dev/null +++ b/examples/05-monorepo-packages/packages/logger/src/index.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Creates a structured logger with JSON output. + * + * @param {Object} [options={}] + * @param {string} [options.name] - logger name (included in every log entry) + * @param {string} [options.level='info'] - minimum log level + * @returns {{ info: Function, warn: Function, error: Function, debug: Function }} + */ +function createLogger(options) { + const opts = options || {}; + const name = opts.name || undefined; + const minLevel = opts.level || 'info'; + + const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; + const minLevelValue = LEVELS[minLevel] !== undefined ? LEVELS[minLevel] : LEVELS.info; + + function formatEntry(level, message, context) { + const entry = { + timestamp: new Date().toISOString(), + level: level, + message: message, + }; + + if (name) { + entry.name = name; + } + + if (context !== undefined && context !== null) { + entry.context = context; + } + + return JSON.stringify(entry); + } + + function log(level, message, context) { + if (LEVELS[level] < minLevelValue) { + return; + } + + const output = formatEntry(level, message, context); + + if (level === 'error') { + process.stderr.write(output + '\n'); + } else { + process.stdout.write(output + '\n'); + } + } + + return { + info: function info(msg, ctx) { + log('info', msg, ctx); + }, + warn: function warn(msg, ctx) { + log('warn', msg, ctx); + }, + error: function error(msg, ctx) { + log('error', msg, ctx); + }, + debug: function debug(msg, ctx) { + log('debug', msg, ctx); + }, + }; +} + +module.exports = { createLogger }; diff --git a/examples/06-fastify-service/.gitignore b/examples/06-fastify-service/.gitignore new file mode 100644 index 0000000..16bd254 --- /dev/null +++ b/examples/06-fastify-service/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.versionings/ +*.log diff --git a/examples/06-fastify-service/README.md b/examples/06-fastify-service/README.md new file mode 100644 index 0000000..5050dbf --- /dev/null +++ b/examples/06-fastify-service/README.md @@ -0,0 +1,287 @@ +# 06-fastify-service — Fastify HTTP Service + +HTTP service built on **Fastify** with a plugin architecture, integrated with **Azure DevOps** +and a fully non-interactive CI workflow via `--ci --json` flags. + +## Stack + +- **Runtime:** Node.js ≥ 18 +- **Framework:** Fastify 5 +- **Versioning:** versionings CLI + +## Service Overview + +The service exposes two Fastify plugins: + +- **health** — `GET /health` returns service status, the version from `package.json`, + and the current timestamp. Intended for health checks in orchestrators (Kubernetes, Azure App Service). +- **items** — CRUD operations for items backed by an in-memory store (`Map`). + Routes: `GET /items`, `POST /items`, `PUT /items/:id`, `DELETE /items/:id`. + Input is validated through Fastify's built-in JSON Schema mechanism. + +Fastify starts with `logger: true` — the built-in Pino logger emits structured JSON logs, +which simplifies parsing in CI pipelines and monitoring systems. + +## Branching Strategy and Platform + +| Parameter | Value | +|-----------|-------| +| SCM platform | Azure DevOps | +| Branching strategy | `trunk-based` | +| Config format | `version.json` | + +**Trunk-based strategy** — all changes are committed directly to `main`. On release, +versionings creates a version branch and tag from `main`. This suits services with +continuous delivery where `main` is always in a deployable state. + +**Azure DevOps** — Microsoft's CI/CD platform. Versionings supports Azure DevOps for +push operations, Pull Request creation, and remote validation. The token is supplied +via the pipeline variable `AZURE_DEVOPS_TOKEN`. + +## Azure DevOps Integration + +### Token Setup + +1. In Azure DevOps, open **User Settings → Personal Access Tokens**. +2. Create a token with the following scopes: + - **Code:** Read & Write + - **Pull Request Threads:** Read & Write +3. In the pipeline settings, add a variable: + - **Name:** `AZURE_DEVOPS_TOKEN` + - **Value:** your PAT + - **Keep this value secret:** ✓ + +Versionings uses this token to push branches/tags and create Pull Requests +via the Azure DevOps REST API. + +### Pipeline Variables vs Secrets + +In Azure Pipelines, variables are referenced with the `$(VARIABLE_NAME)` syntax: + +```yaml +env: + AZURE_DEVOPS_TOKEN: $(AZURE_DEVOPS_TOKEN) +``` + +Secret variables are masked in pipeline output and are not available in fork pipelines. + +## Non-Interactive Mode + +This example demonstrates a fully non-interactive CI workflow. All npm scripts +include the `--ci` and `--json` flags. + +### The `--ci` Flag + +The `--ci` flag switches versionings to non-interactive mode: + +- All interactive prompts are disabled (confirmations, option selection). +- Operations execute without waiting for user input. +- Equivalent to the combination `--non-interactive --yes`. +- Required in CI/CD pipelines where stdin is unavailable. + +```bash +# Without --ci: versionings may prompt for confirmation +npx versionings release --semver=patch --branch=release --push + +# With --ci: runs without prompts +npx versionings release --semver=patch --branch=release --push --ci +``` + +### The `--json` Flag + +The `--json` flag switches output to structured JSON format: + +- All output is a single JSON object on stdout (success) or stderr (error). +- No ANSI codes, colors, or progress bars. +- Stable contract: JSON fields do not change between versions. +- Safe for parsing in CI scripts. + +Successful output example: + +```json +{ + "success": true, + "version": "1.0.1", + "previousVersion": "1.0.0", + "branch": "version/patch/1.0.1/release", + "tag": "1.0.1--release" +} +``` + +Error output example (stderr): + +```json +{ + "success": false, + "error": "ARTIFACT_CONFLICT", + "message": "Branch version/patch/1.0.1/release already exists", + "exitCode": 4 +} +``` + +## Parsing JSON Output in CI Scripts + +JSON output is convenient for extracting data in subsequent pipeline steps. + +### Azure Pipelines: Capturing the Version + +```yaml +- script: | + OUTPUT=$(npx versionings release --semver=patch --branch=ci-release --push --ci --json) + VERSION=$(echo "$OUTPUT" | node -e "process.stdin.on('data',d=>console.log(JSON.parse(d).version))") + echo "##vso[task.setvariable variable=RELEASE_VERSION]$VERSION" + displayName: 'Release and capture version' + env: + AZURE_DEVOPS_TOKEN: $(AZURE_DEVOPS_TOKEN) +``` + +### Checking Status via JSON + +```bash +OUTPUT=$(npx versionings validate --ci --json) +SUCCESS=$(echo "$OUTPUT" | node -e "process.stdin.on('data',d=>console.log(JSON.parse(d).success))") +if [ "$SUCCESS" != "true" ]; then + echo "Validation failed" + exit 1 +fi +``` + +### Combining `--ci --json` + +Always use both flags together in CI environments: + +| Flag | Without `--json` | With `--json` | +|------|------------------|---------------| +| Without `--ci` | Interactive, text | Interactive, JSON | +| With `--ci` | Non-interactive, text | Non-interactive, JSON ✓ | + +The `--ci --json` combination is the only mode that guarantees predictable +behavior and parseable output in a CI environment. + +## Configuration + +File `version.json`: + +```json +{ + "git": { + "platform": "azure-devops", + "url": "https://dev.azure.com/your-org/project/_git/06-fastify-service", + "branching": { + "strategy": "trunk-based", + "mainBranch": "main" + } + } +} +``` + +| Field | Description | +|-------|-------------| +| `git.platform` | `azure-devops` — Microsoft's Git hosting platform | +| `git.url` | Repository URL in Azure DevOps format: `https://dev.azure.com/{org}/{project}/_git/{repo}` | +| `git.branching.strategy` | `trunk-based` — all releases branch from main | +| `git.branching.mainBranch` | `main` — the primary branch | + +Replace `your-org` and `project` with your actual Azure DevOps organization values. + +## Prerequisites + +- Node.js ≥ 18 and npm +- Git with a configured remote +- Versionings installed: `npm install --global versionings` +- Azure DevOps Personal Access Token with Code (Read & Write) scope + +## Release Workflow + +### 1. Validate + +```bash +npm run validate +# npx versionings validate --ci --json +``` + +Checks configuration, git remote, and Azure DevOps accessibility. +Output is a JSON object with the validation result. + +### 2. Plan + +```bash +npm run plan +# npx versionings plan --semver=patch --branch=release --ci --json +``` + +Dry-run: displays the release plan in JSON format without making any changes. + +### 3. Release + +```bash +npm run release +# npx versionings release --semver=patch --branch=release --push --ci --json +``` + +Executes the full cycle: version bump, branch and tag creation, push, and PR creation. +Output is a JSON object with details of the created artifacts. + +## CI Pipeline + +The `azure-pipelines.yml` file automates the release on push to `main`. + +### Pipeline Steps + +1. **NodeTool@0** — installs Node.js 18.x. +2. **npm install** — installs dependencies (including versionings). +3. **validate --ci --json** — checks configuration in non-interactive mode. +4. **release --ci --json** — executes the release with JSON output. + +All steps use `--ci --json` for predictable behavior in CI. + +### Token + +`AZURE_DEVOPS_TOKEN` is passed via a pipeline variable: + +```yaml +env: + AZURE_DEVOPS_TOKEN: $(AZURE_DEVOPS_TOKEN) +``` + +The variable must be configured as a secret in Azure Pipelines +(Pipeline → Edit → Variables → New variable → Keep this value secret). + +## Project Structure + +``` +06-fastify-service/ +├── package.json # npm package; scripts use --ci --json +├── version.json # Azure DevOps, trunk-based config +├── .gitignore # node_modules/, dist/, .versionings/ +├── README.md # This file +├── src/ +│ ├── index.js # Fastify app entry point, plugin registration +│ ├── plugins/ +│ │ ├── health.js # GET /health → { status, version, timestamp } +│ │ └── items.js # CRUD /items with JSON Schema validation +│ └── schemas/ +│ └── item.schema.js # JSON Schema for Item { name, description, price } +└── azure-pipelines.yml # Azure Pipelines: validate → release +``` + +## Expected Output + +Running `release --semver=patch` from version `1.0.0`: + +- **Version:** `1.0.0` → `1.0.1` +- **Branch:** `version/patch/1.0.1/release` +- **Tag:** `1.0.1--release` +- **Exit code:** `0` (success) + +JSON output: + +```json +{ + "success": true, + "version": "1.0.1", + "previousVersion": "1.0.0", + "branch": "version/patch/1.0.1/release", + "tag": "1.0.1--release" +} +``` diff --git a/examples/06-fastify-service/azure-pipelines.yml b/examples/06-fastify-service/azure-pipelines.yml new file mode 100644 index 0000000..8889f78 --- /dev/null +++ b/examples/06-fastify-service/azure-pipelines.yml @@ -0,0 +1,26 @@ +trigger: + branches: + include: + - main + +pool: + vmImage: ubuntu-latest + +steps: + - task: NodeTool@0 + inputs: + versionSpec: '18.x' + displayName: 'Setup Node.js' + + - script: npm install + displayName: 'Install dependencies' + + - script: npx versionings validate --ci --json + displayName: 'Validate configuration' + env: + AZURE_DEVOPS_TOKEN: $(AZURE_DEVOPS_TOKEN) + + - script: npx versionings release --semver=patch --branch=ci-release --push --ci --json + displayName: 'Release' + env: + AZURE_DEVOPS_TOKEN: $(AZURE_DEVOPS_TOKEN) diff --git a/examples/06-fastify-service/package.json b/examples/06-fastify-service/package.json new file mode 100644 index 0000000..93c1b80 --- /dev/null +++ b/examples/06-fastify-service/package.json @@ -0,0 +1,18 @@ +{ + "name": "06-fastify-service", + "version": "1.0.0", + "description": "Fastify HTTP service — Azure DevOps, trunk-based, --ci --json", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "validate": "versionings validate --ci --json", + "plan": "versionings plan --semver=patch --branch=release --ci --json", + "release": "versionings release --semver=patch --branch=release --push --ci --json" + }, + "dependencies": { + "fastify": "^5.0.0" + }, + "devDependencies": { + "versionings": "^0.1.0" + } +} \ No newline at end of file diff --git a/examples/06-fastify-service/src/index.js b/examples/06-fastify-service/src/index.js new file mode 100644 index 0000000..8426b13 --- /dev/null +++ b/examples/06-fastify-service/src/index.js @@ -0,0 +1,17 @@ +const fastify = require('fastify')({ logger: true }); +const healthPlugin = require('./plugins/health'); +const itemsPlugin = require('./plugins/items'); + +fastify.register(healthPlugin); +fastify.register(itemsPlugin); + +const port = process.env.PORT || 3000; + +fastify.listen({ port, host: '0.0.0.0' }, (err) => { + if (err) { + fastify.log.error(err); + process.exit(1); + } +}); + +module.exports = fastify; diff --git a/examples/06-fastify-service/src/plugins/health.js b/examples/06-fastify-service/src/plugins/health.js new file mode 100644 index 0000000..36a3763 --- /dev/null +++ b/examples/06-fastify-service/src/plugins/health.js @@ -0,0 +1,13 @@ +const pkg = require('../../package.json'); + +async function healthPlugin(fastify) { + fastify.get('/health', async () => { + return { + status: 'ok', + version: pkg.version, + timestamp: new Date().toISOString() + }; + }); +} + +module.exports = healthPlugin; diff --git a/examples/06-fastify-service/src/plugins/items.js b/examples/06-fastify-service/src/plugins/items.js new file mode 100644 index 0000000..2ec03d6 --- /dev/null +++ b/examples/06-fastify-service/src/plugins/items.js @@ -0,0 +1,50 @@ +const { itemSchema } = require('../schemas/item.schema'); + +async function itemsPlugin(fastify) { + const items = new Map(); + let nextId = 1; + + fastify.get('/items', async () => { + return Array.from(items.values()); + }); + + fastify.post('/items', { + schema: { + body: itemSchema + } + }, async (request, reply) => { + const id = String(nextId++); + const item = { id, ...request.body }; + items.set(id, item); + reply.code(201); + return item; + }); + + fastify.put('/items/:id', { + schema: { + body: itemSchema + } + }, async (request, reply) => { + const { id } = request.params; + if (!items.has(id)) { + reply.code(404); + return { error: 'Item not found' }; + } + const item = { id, ...request.body }; + items.set(id, item); + return item; + }); + + fastify.delete('/items/:id', async (request, reply) => { + const { id } = request.params; + if (!items.has(id)) { + reply.code(404); + return { error: 'Item not found' }; + } + items.delete(id); + reply.code(204); + return ''; + }); +} + +module.exports = itemsPlugin; diff --git a/examples/06-fastify-service/src/schemas/item.schema.js b/examples/06-fastify-service/src/schemas/item.schema.js new file mode 100644 index 0000000..ca441a3 --- /dev/null +++ b/examples/06-fastify-service/src/schemas/item.schema.js @@ -0,0 +1,12 @@ +const itemSchema = { + type: 'object', + required: ['name', 'description', 'price'], + properties: { + name: { type: 'string', minLength: 1 }, + description: { type: 'string', minLength: 1 }, + price: { type: 'number', minimum: 0 } + }, + additionalProperties: false +}; + +module.exports = { itemSchema }; diff --git a/examples/06-fastify-service/version.json b/examples/06-fastify-service/version.json new file mode 100644 index 0000000..ab4bf70 --- /dev/null +++ b/examples/06-fastify-service/version.json @@ -0,0 +1,10 @@ +{ + "git": { + "platform": "azure-devops", + "url": "https://dev.azure.com/your-org/project/_git/06-fastify-service", + "branching": { + "strategy": "trunk-based", + "mainBranch": "main" + } + } +} \ No newline at end of file diff --git a/examples/07-electron-desktop-app/.github/workflows/hotfix.yml b/examples/07-electron-desktop-app/.github/workflows/hotfix.yml new file mode 100644 index 0000000..e57b642 --- /dev/null +++ b/examples/07-electron-desktop-app/.github/workflows/hotfix.yml @@ -0,0 +1,27 @@ +name: Hotfix Release + +on: + push: + branches: [main] + +jobs: + hotfix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 18 + + - run: npm install + + - name: Validate + run: npx versionings validate + + - name: Hotfix Release + run: npx versionings release --semver=patch --branch=hotfix --push --ci --json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/examples/07-electron-desktop-app/.gitignore b/examples/07-electron-desktop-app/.gitignore new file mode 100644 index 0000000..16bd254 --- /dev/null +++ b/examples/07-electron-desktop-app/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.versionings/ +*.log diff --git a/examples/07-electron-desktop-app/README.md b/examples/07-electron-desktop-app/README.md new file mode 100644 index 0000000..cc16032 --- /dev/null +++ b/examples/07-electron-desktop-app/README.md @@ -0,0 +1,319 @@ +# 07-electron-desktop-app — Electron Desktop Application + +Desktop application built on **Electron** with a main/renderer process architecture and IPC communication, +integrated with **GitHub Enterprise** and a **hotfix** branching strategy for emergency patch releases. + +## Stack + +- **Runtime:** Node.js ≥ 18 +- **Framework:** Electron 32 +- **Versioning:** versionings CLI + +## Application Overview + +The application consists of two Electron processes connected via IPC: + +- **Main process** (`src/main.js`) — manages the application lifecycle, creates a + `BrowserWindow` with `contextIsolation` enabled and a `preload.js` script. Registers IPC handlers: + - `get-app-version` — returns the application version via `app.getVersion()` + - `get-system-info` — returns system details: platform, architecture, memory usage + +- **Renderer process** (`src/renderer/`) — an HTML page with buttons that invoke IPC calls. + The `app.js` script calls `window.electronAPI` (exposed through `contextBridge`) + and renders the returned data into the DOM. + +- **Preload script** (`src/preload.js`) — bridge between main and renderer. Uses + `contextBridge.exposeInMainWorld` to securely expose the IPC methods + `getVersion()` and `getSystemInfo()` to the renderer process. + +## Branching Strategy and SCM Platform + +| Parameter | Value | +|-----------|-------| +| SCM platform | GitHub Enterprise | +| Branching strategy | `hotfix` | +| Config format | `version.json` | + +**Hotfix strategy** — designed exclusively for emergency patch releases. Only `patch` bumps are +permitted. A hotfix branch is created from `main` and named `hotfix/{version}`. This is ideal for +desktop applications where critical production bugs require an immediate fix without waiting for +a full release cycle. + +**GitHub Enterprise** — a self-hosted GitHub instance with its own API endpoint. +Versionings supports GHE via the `apiUrl` parameter, which points to the REST API v3 +of your instance instead of `api.github.com`. + +## Hotfix Strategy + +### How It Works + +The hotfix strategy is restricted to emergency patch releases: + +- **Patch only** — only `--semver=patch` is allowed. Minor and major releases are rejected. +- **From main** — a hotfix is always created from the `main` branch. +- **hotfix/{version} branch** — versionings creates a branch named `hotfix/{version}`, + e.g., `hotfix/1.0.1` for a patch release from `1.0.0`. +- **Fast cycle** — minimal steps: bump → branch → tag → push → PR. + +### When to Use + +- Critical production bug requiring an immediate fix +- Security vulnerability discovered in the current release +- Post-release regression blocking end users + +### Constraints + +```bash +# Allowed — patch hotfix +npx versionings release --semver=patch --branch=hotfix --push + +# Error — minor is not permitted under the hotfix strategy +npx versionings release --semver=minor --branch=hotfix --push +# → Exit code 10: POLICY_VIOLATION +``` + +## Emergency Release Process + +Step-by-step procedure for an emergency hotfix: + +### 1. Identify the Issue + +A critical bug is discovered in production version `1.0.0`. + +### 2. Prepare the Fix + +```bash +# Ensure you are on an up-to-date main branch +git checkout main +git pull origin main + +# Apply the fix +# ... edit source files ... +git add . +git commit -m "fix: critical auth bypass in session handler" +``` + +### 3. Validate + +```bash +npm run validate +# npx versionings validate +``` + +Checks the configuration, git remote, and GitHub Enterprise API availability. + +### 4. Plan + +```bash +npm run plan +# npx versionings plan --semver=patch --branch=hotfix +``` + +Dry-run: displays the hotfix release plan without making any changes. +Expected result: version `1.0.0` → `1.0.1`, branch `hotfix/1.0.1`. + +### 5. Release + +```bash +npm run release +# npx versionings release --semver=patch --branch=hotfix --push +``` + +Executes the full cycle: version bump, `hotfix/1.0.1` branch creation, +tag creation, push to remote, and Pull Request creation on GitHub Enterprise. + +### 6. Review and Merge + +The Pull Request goes through an expedited review and is merged into `main`. + +## Merge-Back Procedure After Hotfix + +Once the hotfix is merged into `main`, propagate the changes to all active +development branches: + +### Merge into develop (if using git-flow) + +```bash +git checkout develop +git pull origin develop +git merge main +# Resolve conflicts if any +git push origin develop +``` + +### Merge into active feature branches + +```bash +git checkout feature/my-feature +git merge main +# Resolve conflicts if any +git push origin feature/my-feature +``` + +### Delete the hotfix branch + +After a successful merge the hotfix branch is no longer needed: + +```bash +git branch -d hotfix/1.0.1 +git push origin --delete hotfix/1.0.1 +``` + +### Merge-back checklist + +1. ✅ Hotfix merged into `main` +2. ✅ `main` merged into `develop` (if applicable) +3. ✅ Active feature branches updated from `main` +4. ✅ Hotfix branch deleted (local + remote) +5. ✅ CI passed on all updated branches + +## SCM Platform: GitHub Enterprise + +### Differences from GitHub.com + +GitHub Enterprise (GHE) is a self-hosted GitHub instance deployed within your +organization's infrastructure. The key difference for versionings is a custom +API endpoint instead of `api.github.com`. + +### Configuring apiUrl + +In `version.json`, the `apiUrl` field points to the REST API v3 of your GHE instance: + +```json +{ + "git": { + "apiUrl": "https://github.example.com/api/v3" + } +} +``` + +URL format: `https:///api/v3`. Versionings uses this endpoint +for all API calls: remote verification, PR creation, and API-based push. + +### Token Setup + +1. In GitHub Enterprise, navigate to **Settings → Developer settings → Personal access tokens**. +2. Create a token (classic) with the following scope: + - `repo` — full repository access +3. Add the token as a repository secret: + - **Name:** `GITHUB_TOKEN` + - **Value:** your Personal Access Token + +The token is passed via `secrets.GITHUB_TOKEN` in the GitHub Actions workflow. + +## Configuration + +File: `version.json` + +```json +{ + "git": { + "platform": "github-enterprise", + "url": "https://github.example.com/your-org/07-electron-desktop-app.git", + "apiUrl": "https://github.example.com/api/v3", + "branching": { + "strategy": "hotfix", + "mainBranch": "main" + } + } +} +``` + +| Field | Description | +|-------|-------------| +| `git.platform` | `github-enterprise` — self-hosted GitHub instance | +| `git.url` | Repository URL on your GHE instance | +| `git.apiUrl` | GHE REST API v3 endpoint: `https:///api/v3` | +| `git.branching.strategy` | `hotfix` — patch-only releases for emergency fixes | +| `git.branching.mainBranch` | `main` — the base branch from which hotfixes are created | + +Replace `github.example.com` and `your-org` with the actual values for your GHE instance. + +## Prerequisites + +- Node.js ≥ 18 and npm +- Git with a remote configured for GitHub Enterprise +- Versionings installed: `npm install --global versionings` +- GitHub Enterprise Personal Access Token with `repo` scope + +## Release Workflow + +### 1. Validate + +```bash +npm run validate +# npx versionings validate +``` + +Checks the configuration, git remote, and GitHub Enterprise API availability via `apiUrl`. + +### 2. Plan + +```bash +npm run plan +# npx versionings plan --semver=patch --branch=hotfix +``` + +Dry-run: displays the hotfix release plan without making any changes. + +### 3. Release + +```bash +npm run release +# npx versionings release --semver=patch --branch=hotfix --push +``` + +Executes the full cycle: version bump, hotfix branch and tag creation, push, and PR creation. + +## CI Pipeline + +The file `.github/workflows/hotfix.yml` automates the hotfix release on push to `main`. + +### Pipeline Steps + +1. **checkout** — clone with full history (`fetch-depth: 0`) +2. **setup-node** — install Node.js 18 +3. **npm install** — install dependencies (including versionings) +4. **validate** — verify configuration and GHE API availability +5. **release** — execute the hotfix release with `--ci --json` for non-interactive mode + +### Token + +`GITHUB_TOKEN` is provided through the GitHub Actions secrets mechanism: + +```yaml +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +For GitHub Enterprise, create a Personal Access Token and add it as a +repository secret (Settings → Secrets → Actions). + +## Project Structure + +``` +07-electron-desktop-app/ +├── package.json # npm package, Electron, hotfix scripts +├── version.json # GitHub Enterprise, hotfix strategy config +├── .gitignore # node_modules/, dist/, .versionings/ +├── README.md # This file +├── src/ +│ ├── main.js # Main process: BrowserWindow, IPC handlers +│ ├── preload.js # contextBridge: electronAPI +│ └── renderer/ +│ ├── index.html # Application HTML page +│ ├── app.js # UI logic: IPC calls, DOM rendering +│ └── styles.css # Application styles +└── .github/ + └── workflows/ + └── hotfix.yml # GitHub Actions: validate → hotfix release +``` + +## Expected Output + +Running `release --semver=patch` from version `1.0.0`: + +- **Version:** `1.0.0` → `1.0.1` +- **Branch:** `hotfix/1.0.1` +- **Tag:** `1.0.1--hotfix` +- **Exit code:** `0` (success) diff --git a/examples/07-electron-desktop-app/package.json b/examples/07-electron-desktop-app/package.json new file mode 100644 index 0000000..8ef81a5 --- /dev/null +++ b/examples/07-electron-desktop-app/package.json @@ -0,0 +1,18 @@ +{ + "name": "07-electron-desktop-app", + "version": "1.0.0", + "description": "Electron app — GitHub Enterprise, hotfix strategy", + "main": "src/main.js", + "scripts": { + "start": "electron .", + "validate": "versionings validate", + "plan": "versionings plan --semver=patch --branch=hotfix", + "release": "versionings release --semver=patch --branch=hotfix --push" + }, + "dependencies": { + "electron": "^32.0.0" + }, + "devDependencies": { + "versionings": "^0.1.0" + } +} \ No newline at end of file diff --git a/examples/07-electron-desktop-app/src/main.js b/examples/07-electron-desktop-app/src/main.js new file mode 100644 index 0000000..dd637ad --- /dev/null +++ b/examples/07-electron-desktop-app/src/main.js @@ -0,0 +1,54 @@ +const { app, BrowserWindow, ipcMain } = require('electron'); +const path = require('path'); +const os = require('os'); + +let mainWindow; + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 800, + height: 600, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false + } + }); + + mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); + + mainWindow.on('closed', () => { + mainWindow = null; + }); +} + +ipcMain.handle('get-app-version', () => { + return app.getVersion(); +}); + +ipcMain.handle('get-system-info', () => { + return { + platform: os.platform(), + arch: os.arch(), + memory: { + total: os.totalmem(), + free: os.freemem() + } + }; +}); + +app.whenReady().then(() => { + createWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); diff --git a/examples/07-electron-desktop-app/src/preload.js b/examples/07-electron-desktop-app/src/preload.js new file mode 100644 index 0000000..a59b76a --- /dev/null +++ b/examples/07-electron-desktop-app/src/preload.js @@ -0,0 +1,6 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('electronAPI', { + getVersion: () => ipcRenderer.invoke('get-app-version'), + getSystemInfo: () => ipcRenderer.invoke('get-system-info') +}); diff --git a/examples/07-electron-desktop-app/src/renderer/app.js b/examples/07-electron-desktop-app/src/renderer/app.js new file mode 100644 index 0000000..f283871 --- /dev/null +++ b/examples/07-electron-desktop-app/src/renderer/app.js @@ -0,0 +1,26 @@ +function formatBytes(bytes) { + const units = ['B', 'KB', 'MB', 'GB']; + let value = bytes; + let unitIndex = 0; + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex++; + } + + return value.toFixed(1) + ' ' + units[unitIndex]; +} + +document.getElementById('btn-version').addEventListener('click', async () => { + const version = await window.electronAPI.getVersion(); + document.getElementById('app-version').textContent = 'v' + version; +}); + +document.getElementById('btn-sysinfo').addEventListener('click', async () => { + const info = await window.electronAPI.getSystemInfo(); + + document.getElementById('info-platform').textContent = info.platform; + document.getElementById('info-arch').textContent = info.arch; + document.getElementById('info-mem-total').textContent = formatBytes(info.memory.total); + document.getElementById('info-mem-free').textContent = formatBytes(info.memory.free); +}); diff --git a/examples/07-electron-desktop-app/src/renderer/index.html b/examples/07-electron-desktop-app/src/renderer/index.html new file mode 100644 index 0000000..cc54924 --- /dev/null +++ b/examples/07-electron-desktop-app/src/renderer/index.html @@ -0,0 +1,42 @@ + + + + + + + + Electron Desktop App + + + + +
+

Electron Desktop App

+

GitHub Enterprise · Hotfix Strategy

+ +
+

Application Version

+

+ +
+ +
+

System Information

+
+
Platform
+
+
Architecture
+
+
Memory (Total)
+
+
Memory (Free)
+
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/examples/07-electron-desktop-app/src/renderer/styles.css b/examples/07-electron-desktop-app/src/renderer/styles.css new file mode 100644 index 0000000..1835ff6 --- /dev/null +++ b/examples/07-electron-desktop-app/src/renderer/styles.css @@ -0,0 +1,88 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + background: #1e1e2e; + color: #cdd6f4; + line-height: 1.6; +} + +.container { + max-width: 640px; + margin: 0 auto; + padding: 40px 24px; +} + +h1 { + font-size: 28px; + font-weight: 700; + color: #cba6f7; + margin-bottom: 4px; +} + +.subtitle { + font-size: 14px; + color: #6c7086; + margin-bottom: 32px; +} + +.info-block { + background: #313244; + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; +} + +.info-block h2 { + font-size: 16px; + font-weight: 600; + color: #89b4fa; + margin-bottom: 12px; +} + +.info-block p { + font-size: 20px; + font-weight: 500; + margin-bottom: 12px; +} + +dl { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 16px; + margin-bottom: 12px; +} + +dt { + font-size: 13px; + color: #6c7086; +} + +dd { + font-size: 14px; + font-weight: 500; +} + +button { + background: #89b4fa; + color: #1e1e2e; + border: none; + border-radius: 6px; + padding: 8px 16px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} + +button:hover { + background: #74c7ec; +} + +button:active { + background: #94e2d5; +} diff --git a/examples/07-electron-desktop-app/version.json b/examples/07-electron-desktop-app/version.json new file mode 100644 index 0000000..56a3437 --- /dev/null +++ b/examples/07-electron-desktop-app/version.json @@ -0,0 +1,11 @@ +{ + "git": { + "platform": "github-enterprise", + "url": "https://github.example.com/your-org/07-electron-desktop-app.git", + "apiUrl": "https://github.example.com/api/v3", + "branching": { + "strategy": "hotfix", + "mainBranch": "main" + } + } +} \ No newline at end of file diff --git a/examples/08-graphql-server/.gitignore b/examples/08-graphql-server/.gitignore new file mode 100644 index 0000000..16bd254 --- /dev/null +++ b/examples/08-graphql-server/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.versionings/ +*.log diff --git a/examples/08-graphql-server/.gitlab-ci.yml b/examples/08-graphql-server/.gitlab-ci.yml new file mode 100644 index 0000000..6413726 --- /dev/null +++ b/examples/08-graphql-server/.gitlab-ci.yml @@ -0,0 +1,24 @@ +image: node:18 + +stages: + - validate + - release + +validate: + stage: validate + script: + - npm install + - npx versionings validate --json + variables: + GITLAB_TOKEN: $GITLAB_TOKEN + +release: + stage: release + script: + - npm install + - npx versionings release --semver=patch --branch=maintenance --push --ci --json + variables: + GITLAB_TOKEN: $GITLAB_TOKEN + only: + - main + - /^support\/.*$/ diff --git a/examples/08-graphql-server/.versioningsrc.yml b/examples/08-graphql-server/.versioningsrc.yml new file mode 100644 index 0000000..e026693 --- /dev/null +++ b/examples/08-graphql-server/.versioningsrc.yml @@ -0,0 +1,9 @@ +git: + platform: gitlab + url: https://gitlab.example.com/your-org/08-graphql-server.git + apiUrl: https://gitlab.example.com/api/v4 + branching: + strategy: maintenance + mainBranch: main + pr: + target: main diff --git a/examples/08-graphql-server/README.md b/examples/08-graphql-server/README.md new file mode 100644 index 0000000..2c37cd6 --- /dev/null +++ b/examples/08-graphql-server/README.md @@ -0,0 +1,308 @@ +# 08-graphql-server — Apollo GraphQL Server + +GraphQL server built on **Apollo Server 4** with a typed schema and an in-memory data source, +integrated with **self-hosted GitLab** and the **maintenance** branching strategy for parallel +LTS version support. + +## Stack + +- **Runtime:** Node.js ≥ 18 +- **Framework:** Apollo Server 4 (standalone) +- **Query language:** GraphQL 16 +- **Versioning:** versionings CLI + +## GraphQL Server and Schema + +The server exposes a CRUD API for managing a book collection via GraphQL. + +### Types + +```graphql +type Book { + id: ID! + title: String! + author: String! + year: Int + isbn: String +} +``` + +### Queries + +- `books: [Book!]!` — retrieve all books +- `book(id: ID!): Book` — retrieve a book by ID (returns `null` if not found) + +### Mutations + +- `addBook(title: String!, author: String!, year: Int, isbn: String): Book!` — add a book +- `removeBook(id: ID!): Boolean!` — remove a book by ID (`true` if removed, `false` if not found) + +### Architecture + +- **`src/index.js`** — creates an `ApolloServer` instance with `typeDefs` and `resolvers`, + starts a standalone server on `process.env.PORT || 4000`. On startup it initializes + `BooksDataSource` and injects it into the request context. + +- **`src/schema/typeDefs.js`** — GraphQL schema definition: `Book` type, `books` and `book` + queries, `addBook` and `removeBook` mutations. + +- **`src/schema/resolvers.js`** — resolvers for Query and Mutation. Each resolver delegates + execution to `BooksDataSource` via `context.dataSources`. + +- **`src/datasources/books.js`** — `BooksDataSource` class with an in-memory array of books. + Methods: `getAll()`, `getById(id)`, `add(data)`, `remove(id)`. Seed data contains three + classic novels. + +## Branching Strategy and Platform + +| Parameter | Value | +|-----------|-------| +| SCM platform | GitLab (self-hosted) | +| Branching strategy | `maintenance` | +| Config format | `.versioningsrc.yml` | + +### Maintenance Strategy + +The **maintenance** strategy targets projects that support multiple LTS versions in parallel. +Each major.minor version gets its own support branch where only patch releases are published. + +Key characteristics: + +- **Support branches** — named `support/{major}.{minor}` (e.g., `support/2.0`, `support/2.1`) +- **Patch-only** — only patch releases are permitted on support branches +- **Branch reuse** — a support branch is reused for subsequent patch releases of the same + major.minor version +- **Parallel LTS support** — multiple support branches can coexist, each receiving independent + patch updates + +### When to Use + +- The project has consumers on different major or minor versions +- Security patches must be shipped for older versions +- Multiple LTS lines need to be maintained simultaneously + +## SCM Platform: GitLab (Self-Hosted) + +### Difference from GitLab.com + +A self-hosted GitLab instance is deployed within the organization's infrastructure. +The key difference for versionings is a custom API endpoint instead of `gitlab.com/api/v4`. + +### Configuring apiUrl + +In `.versioningsrc.yml`, the `apiUrl` field points to the REST API v4 of your GitLab instance: + +```yaml +git: + apiUrl: https://gitlab.example.com/api/v4 +``` + +URL format: `https:///api/v4`. Versionings uses this endpoint for all API calls: +remote verification, Merge Request creation, and API-based push. + +### Token Setup + +1. In GitLab, navigate to **User Settings → Access Tokens**. +2. Create a Personal Access Token with the following scopes: + - `api` — full API access + - `write_repository` — push access +3. In the project's CI/CD settings, add a variable: + - **Key:** `GITLAB_TOKEN` + - **Value:** your Personal Access Token + - **Protected:** enabled (available only on protected branches) + +## Maintenance Workflow + +### Creating a Support Branch + +When the current version is `2.1.0` and you need to start maintaining the `2.1.x` line: + +```bash +# Create a support branch from the current state of main +git checkout main +git pull origin main +git checkout -b support/2.1 +git push origin support/2.1 +``` + +### Patch Release on a Support Branch + +```bash +# Switch to the support branch +git checkout support/2.1 +git pull origin support/2.1 + +# Apply the fix +# ... edit code ... +git add . +git commit -m "fix: resolve query timeout for large datasets" + +# Validate +npm run validate + +# Plan +npm run plan +# → 2.1.0 → 2.1.1, branch support/2.1 + +# Release +npm run release +# → bump, tag, push, MR into main +``` + +### Parallel LTS Support + +Example: maintaining `support/2.0` and `support/2.1` simultaneously. + +``` +main (3.0.0-dev) +│ +├── support/2.1 (2.1.0 → 2.1.1 → 2.1.2) +│ └── patch releases for consumers on 2.1.x +│ +└── support/2.0 (2.0.0 → 2.0.1 → 2.0.2 → 2.0.3) + └── patch releases for consumers on 2.0.x +``` + +Each support branch lives independently. Patch releases on `support/2.0` do not affect +`support/2.1` and vice versa. Versionings reuses the existing support branch for subsequent +patch releases (branch reuse). + +### Support Branch Lifecycle + +1. **Creation** — when a new minor version ships, create `support/{major}.{minor}` +2. **Active maintenance** — publish patch releases as needed +3. **End of Life** — when support for the version ends, archive the branch + +```bash +# Archive a support branch (EOL) +git push origin --delete support/2.0 +``` + +## Configuration + +File: `.versioningsrc.yml` + +```yaml +git: + platform: gitlab + url: https://gitlab.example.com/your-org/08-graphql-server.git + apiUrl: https://gitlab.example.com/api/v4 + branching: + strategy: maintenance + mainBranch: main + pr: + target: main +``` + +| Field | Description | +|-------|-------------| +| `git.platform` | `gitlab` — GitLab as the SCM platform | +| `git.url` | Repository URL on the self-hosted GitLab instance | +| `git.apiUrl` | REST API v4 endpoint: `https:///api/v4` | +| `git.branching.strategy` | `maintenance` — parallel LTS version support | +| `git.branching.mainBranch` | `main` — primary development branch | +| `git.pr.target` | `main` — target branch for Merge Requests | + +Replace `gitlab.example.com` and `your-org` with the actual values for your GitLab instance. + +The project version `2.1.0` indicates it is on the `support/2.1` maintenance branch and ready +for patch releases. + +## Prerequisites + +- Node.js ≥ 18 and npm +- Git with a remote configured for self-hosted GitLab +- versionings installed: `npm install --global versionings` +- GitLab Personal Access Token with `api` and `write_repository` scopes + +## Release Workflow + +### 1. Validate + +```bash +npm run validate +# npx versionings validate +``` + +Checks configuration, git remote, and GitLab API availability at the configured `apiUrl`. + +### 2. Plan + +```bash +npm run plan +# npx versionings plan --semver=patch --branch=maintenance +``` + +Dry-run: displays the maintenance release plan without making any changes. + +### 3. Release + +```bash +npm run release +# npx versionings release --semver=patch --branch=maintenance --push +``` + +Executes the full cycle: version bump, tag creation, push to the support branch, +and Merge Request creation into `main` on GitLab. + +## CI Pipeline + +The `.gitlab-ci.yml` file automates maintenance releases on pushes to `main` and +support branches (`support/*`). + +### Pipeline Stages + +1. **validate** — install dependencies, verify configuration (`--json` for machine-readable output) +2. **release** — execute the maintenance release with `--ci --json` for non-interactive mode + +### Triggers + +The pipeline runs on pushes to: +- `main` — primary branch +- `support/*` — all support branches (e.g., `support/2.0`, `support/2.1`) + +```yaml +only: + - main + - /^support\/.*$/ +``` + +### Token + +`GITLAB_TOKEN` is passed via GitLab CI/CD Variables: + +```yaml +variables: + GITLAB_TOKEN: $GITLAB_TOKEN +``` + +In the project settings: **Settings → CI/CD → Variables** → add `GITLAB_TOKEN` with your +Personal Access Token. Enable the **Protected** flag to restrict access to protected branches only. + +## Project Structure + +``` +08-graphql-server/ +├── package.json # npm package, Apollo Server, maintenance scripts +├── .versioningsrc.yml # GitLab self-hosted, maintenance strategy +├── .gitignore # node_modules/, dist/, .versionings/ +├── README.md # This file +├── src/ +│ ├── index.js # Apollo Server bootstrap, standalone on port 4000 +│ ├── schema/ +│ │ ├── typeDefs.js # GraphQL schema: Book, Query, Mutation +│ │ └── resolvers.js # Resolvers: delegation to BooksDataSource +│ └── datasources/ +│ └── books.js # BooksDataSource: in-memory CRUD for books +└── .gitlab-ci.yml # GitLab CI: validate → maintenance release +``` + +## Expected Output + +Running `release --semver=patch` from version `2.1.0` on the `support/2.1` branch: + +- **Version:** `2.1.0` → `2.1.1` +- **Branch:** `support/2.1` (reused) +- **Tag:** `2.1.1--maintenance` +- **MR:** into `main` on self-hosted GitLab +- **Exit code:** `0` (success) diff --git a/examples/08-graphql-server/package.json b/examples/08-graphql-server/package.json new file mode 100644 index 0000000..d87fee3 --- /dev/null +++ b/examples/08-graphql-server/package.json @@ -0,0 +1,19 @@ +{ + "name": "08-graphql-server", + "version": "2.1.0", + "description": "Apollo GraphQL server — maintenance strategy, self-hosted GitLab", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "validate": "versionings validate", + "plan": "versionings plan --semver=patch --branch=maintenance", + "release": "versionings release --semver=patch --branch=maintenance --push" + }, + "dependencies": { + "@apollo/server": "^4.11.0", + "graphql": "^16.9.0" + }, + "devDependencies": { + "versionings": "^0.1.0" + } +} \ No newline at end of file diff --git a/examples/08-graphql-server/src/datasources/books.js b/examples/08-graphql-server/src/datasources/books.js new file mode 100644 index 0000000..478277f --- /dev/null +++ b/examples/08-graphql-server/src/datasources/books.js @@ -0,0 +1,62 @@ +const crypto = require('crypto'); + +const initialBooks = [ + { + id: '1', + title: 'War and Peace', + author: 'Leo Tolstoy', + year: 1869, + isbn: '978-0-14-044793-4' + }, + { + id: '2', + title: 'Crime and Punishment', + author: 'Fyodor Dostoevsky', + year: 1866, + isbn: '978-0-14-044913-6' + }, + { + id: '3', + title: 'The Master and Margarita', + author: 'Mikhail Bulgakov', + year: 1967, + isbn: '978-0-14-118014-1' + } +]; + +class BooksDataSource { + constructor() { + this.books = [...initialBooks]; + } + + getAll() { + return this.books; + } + + getById(id) { + return this.books.find((book) => book.id === id) || null; + } + + add(data) { + const book = { + id: crypto.randomUUID(), + title: data.title, + author: data.author, + year: data.year || null, + isbn: data.isbn || null + }; + this.books.push(book); + return book; + } + + remove(id) { + const index = this.books.findIndex((book) => book.id === id); + if (index === -1) { + return false; + } + this.books.splice(index, 1); + return true; + } +} + +module.exports = { BooksDataSource }; diff --git a/examples/08-graphql-server/src/index.js b/examples/08-graphql-server/src/index.js new file mode 100644 index 0000000..88b4f5f --- /dev/null +++ b/examples/08-graphql-server/src/index.js @@ -0,0 +1,27 @@ +const { ApolloServer } = require('@apollo/server'); +const { startStandaloneServer } = require('@apollo/server/standalone'); +const { typeDefs } = require('./schema/typeDefs'); +const { resolvers } = require('./schema/resolvers'); +const { BooksDataSource } = require('./datasources/books'); + +async function startServer() { + const server = new ApolloServer({ + typeDefs, + resolvers + }); + + const booksDataSource = new BooksDataSource(); + + const { url } = await startStandaloneServer(server, { + listen: { port: Number(process.env.PORT) || 4000 }, + context: async () => ({ + dataSources: { + books: booksDataSource + } + }) + }); + + console.log(`GraphQL server ready at ${url}`); +} + +startServer(); diff --git a/examples/08-graphql-server/src/schema/resolvers.js b/examples/08-graphql-server/src/schema/resolvers.js new file mode 100644 index 0000000..c94a759 --- /dev/null +++ b/examples/08-graphql-server/src/schema/resolvers.js @@ -0,0 +1,20 @@ +const resolvers = { + Query: { + books: (_parent, _args, { dataSources }) => { + return dataSources.books.getAll(); + }, + book: (_parent, { id }, { dataSources }) => { + return dataSources.books.getById(id); + } + }, + Mutation: { + addBook: (_parent, args, { dataSources }) => { + return dataSources.books.add(args); + }, + removeBook: (_parent, { id }, { dataSources }) => { + return dataSources.books.remove(id); + } + } +}; + +module.exports = { resolvers }; diff --git a/examples/08-graphql-server/src/schema/typeDefs.js b/examples/08-graphql-server/src/schema/typeDefs.js new file mode 100644 index 0000000..ce451a3 --- /dev/null +++ b/examples/08-graphql-server/src/schema/typeDefs.js @@ -0,0 +1,21 @@ +const typeDefs = `#graphql + type Book { + id: ID! + title: String! + author: String! + year: Int + isbn: String + } + + type Query { + books: [Book!]! + book(id: ID!): Book + } + + type Mutation { + addBook(title: String!, author: String!, year: Int, isbn: String): Book! + removeBook(id: ID!): Boolean! + } +`; + +module.exports = { typeDefs }; diff --git a/examples/09-next-webapp/.gitignore b/examples/09-next-webapp/.gitignore new file mode 100644 index 0000000..c8c20e3 --- /dev/null +++ b/examples/09-next-webapp/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.versionings/ +*.log +.next/ diff --git a/examples/09-next-webapp/CHANGELOG.md b/examples/09-next-webapp/CHANGELOG.md new file mode 100644 index 0000000..6361e43 --- /dev/null +++ b/examples/09-next-webapp/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to this project will be documented in this file. diff --git a/examples/09-next-webapp/README.md b/examples/09-next-webapp/README.md new file mode 100644 index 0000000..15e9c0e --- /dev/null +++ b/examples/09-next-webapp/README.md @@ -0,0 +1,285 @@ +# 09-next-webapp — Next.js Web Application + +A Next.js application with server-side rendering, API routes, and a component-based +architecture, integrated with **Bitbucket Server** using the **release-branch** strategy +for release management. Demonstrates extended PR parameters (`milestone`, `linkedIssues`) +and automatic changelog generation. + +## Stack + +- **Runtime:** Node.js ≥ 18 +- **Framework:** Next.js 14 +- **UI:** React 18 +- **Versioning:** versionings CLI + +## Application Overview + +### Pages + +- **`src/pages/index.js`** — Blog home page. Uses `getServerSideProps` to fetch + the post list from the internal `/api/posts` endpoint on every request. + Renders a collection of `PostCard` components. + +- **`src/pages/about.js`** — Static "About" page describing the technology stack + and displaying the current application version from `package.json`. + +### API Routes + +- **`src/pages/api/posts.js`** — Server-side `GET /api/posts` endpoint. Returns an + in-memory array of posts, each containing `id`, `title`, `excerpt`, `date`, and + `author` fields. Only the GET method is supported; all others return 405. + +### Components + +- **`src/components/Layout.jsx`** — Application layout component. Contains a `
` + with navigation links (Home, About), a `
` content area, and a `