diff --git a/.changeset/smooth-cows-doubt.md b/.changeset/smooth-cows-doubt.md new file mode 100644 index 0000000..1e24ef9 --- /dev/null +++ b/.changeset/smooth-cows-doubt.md @@ -0,0 +1,27 @@ +--- +"@noxify/gitlab-ci-builder": minor +--- + +Added pipeline simulation feature and improved remote extends handling + +**Simulation Features:** + +- Added `simulate` CLI command to simulate GitLab CI pipeline execution +- Added `PipelineSimulator` class for rule evaluation and job filtering +- Support for branch, tag, and merge request pipeline simulation +- Multiple output formats: summary, table, JSON, YAML, and text +- Predefined CI variables automatically set based on context +- Comprehensive integration and E2E test coverage + +**Remote Extends Improvements:** + +- Added `mergeRemoteExtends` global option to control remote extends resolution +- Fixed remote extends resolution for accurate pipeline simulation +- Better handling of remote includes and templates + +**Documentation & Testing:** + +- Enhanced CLI documentation with predefined variables table +- Added error handling documentation for remote includes +- Added "Common Pitfalls & Best Practices" section +- Improved JSDoc coverage for all public APIs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7f21c2a..a8471c8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -30,8 +30,7 @@ 1. Types first 2. React/Next.js/Expo (if applicable) 3. Third-party modules - 4. @vorsteh-queue packages - 5. Relative imports (~/,../, ./) + 4. Relative imports (~/,../, ./) ## Code Generation Guidelines diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8967c87..b03be2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,9 @@ jobs: - name: Integration Tests run: pnpm test:integration + - name: E2E Tests + run: pnpm test:e2e + pkg-new-release: needs: [lint, format, typecheck] if: github.event_name == 'pull_request' diff --git a/README.md b/README.md index 4444841..f45e57f 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,145 @@ gitlab-ci-builder visualize pipeline.yml \ - Resolving `remote:` includes to private URLs - Accessing GitLab CI/CD templates from private instances +### Simulate Command + +Simulate GitLab CI pipeline execution to see which jobs would run based on variables and rules: + +```bash +# Simulate a local pipeline +gitlab-ci-builder simulate .gitlab-ci.yml + +# Simulate for a specific branch +gitlab-ci-builder simulate .gitlab-ci.yml -b main + +# Simulate with custom variables +gitlab-ci-builder simulate .gitlab-ci.yml -v CI_COMMIT_BRANCH=main -v DEPLOY_ENV=production + +# Simulate a merge request pipeline +gitlab-ci-builder simulate .gitlab-ci.yml --mr -b feature-123 + +# Output as JSON for further processing +gitlab-ci-builder simulate .gitlab-ci.yml -f json > simulation.json + +# Show skipped jobs and verbose rule evaluation +gitlab-ci-builder simulate .gitlab-ci.yml -b develop --show-skipped --verbose +``` + +**Options:** + +- `-v, --variable ` - Set pipeline variables (can be used multiple times) +- `-b, --branch ` - Simulate for specific branch (sets `CI_COMMIT_BRANCH`) +- `--tag ` - Simulate for specific tag (sets `CI_COMMIT_TAG`) +- `--mr` - Simulate merge request pipeline (sets `CI_MERGE_REQUEST_ID`) +- `--mr-labels ` - Merge request labels (comma-separated) +- `-f, --format ` - Output format: `summary`, `text`, `json`, `yaml`, `table` (default: `summary`) +- `--show-skipped` - Show skipped jobs in output (default: `false`) +- `--verbose` - Verbose output with detailed rule evaluation (default: `false`) +- `-t, --token ` - Authentication token for private repositories (or use `GITLAB_TOKEN` env var) +- `--host ` - GitLab host for project/template includes (default: `gitlab.com`, or use `GITLAB_HOST` env var) +- `-h, --help` - Display help information + +**Predefined Variables:** + +The simulator automatically sets GitLab CI predefined variables based on the provided options. These can be overridden using the `-v` flag: + +| Variable | Set by | Default Value | Description | +| ------------------------- | ------------------------- | ----------------------------------- | ----------------------------- | +| `CI_COMMIT_BRANCH` | `-b, --branch` | `undefined` | Branch name being built | +| `CI_COMMIT_REF_NAME` | `-b, --branch` or `--tag` | `undefined` | Branch or tag name | +| `CI_COMMIT_REF_SLUG` | `-b, --branch` or `--tag` | Slugified ref name | URL-safe version of ref name | +| `CI_COMMIT_TAG` | `--tag` | `undefined` | Tag name if building a tag | +| `CI_MERGE_REQUEST_ID` | `--mr` | `"1"` | Merge request ID | +| `CI_MERGE_REQUEST_IID` | `--mr` | `"1"` | Project-level MR ID | +| `CI_MERGE_REQUEST_LABELS` | `--mr-labels` | `""` | Comma-separated MR labels | +| `CI_PIPELINE_SOURCE` | `--mr` | `"merge_request_event"` or `"push"` | What triggered the pipeline | +| `CI_DEFAULT_BRANCH` | Always set | `"main"` | Default branch of the project | + +**Examples:** + +```bash +# Branch simulation - sets CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG +gitlab-ci-builder simulate .gitlab-ci.yml -b develop + +# Override default branch +gitlab-ci-builder simulate .gitlab-ci.yml -b main -v CI_DEFAULT_BRANCH=master + +# Tag simulation - sets CI_COMMIT_TAG, CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG +gitlab-ci-builder simulate .gitlab-ci.yml --tag v1.0.0 + +# MR simulation - sets CI_MERGE_REQUEST_ID, CI_MERGE_REQUEST_IID, CI_PIPELINE_SOURCE +gitlab-ci-builder simulate .gitlab-ci.yml --mr -b feature-branch + +# MR with labels - sets CI_MERGE_REQUEST_LABELS +gitlab-ci-builder simulate .gitlab-ci.yml --mr --mr-labels "bug,critical" + +# Override predefined variables +gitlab-ci-builder simulate .gitlab-ci.yml -b main \ + -v CI_COMMIT_BRANCH=custom-branch \ + -v CI_PIPELINE_SOURCE=web +``` + +**Note:** Variables set via `-v` always take precedence over automatically set values. This allows you to test edge cases or override defaults. + +**Additional Examples:** + +```bash +# Custom variables alongside automatic ones +gitlab-ci-builder simulate .gitlab-ci.yml -b main \ + -v DEPLOY_ENV=staging \ + -v AWS_REGION=eu-central-1 + +# Test specific rule conditions +gitlab-ci-builder simulate .gitlab-ci.yml \ + -v CI_COMMIT_BRANCH=main \ + -v CI_PIPELINE_SOURCE=schedule + +# Table format with skipped jobs +gitlab-ci-builder simulate .gitlab-ci.yml -f table \ + -b develop \ + --show-skipped + +# Remote pipeline with authentication +gitlab-ci-builder simulate https://gitlab.com/org/repo/-/raw/main/.gitlab-ci.yml \ + -t glpat-xxxx \ + -b main +``` + +**Output Formats:** + +- `summary` (default): Clean overview with job counts and stage breakdown +- `text`: Detailed text format with full job information +- `table`: ASCII table with job status and stage information +- `json`: Machine-readable JSON for further processing +- `yaml`: YAML format compatible with GitLab CI syntax + +**Example Output:** + +```bash +$ gitlab-ci-builder simulate .gitlab-ci.yml -b main + +📊 Pipeline Simulation Result + +════════════════════════════════════════════════════════════ +Total Jobs: 5 +Will Run: 3 + - Automatic: 2 + - Manual: 1 +Will Skip: 2 + +📋 Stages: +──────────────────────────────────────────────────────────── + build: 1 job(s) + test: 1 job(s) + deploy: 1 job(s) + +🔧 Jobs: +──────────────────────────────────────────────────────────── + ▶ build-app (build) + ▶ test-app (test) + ⚙ deploy-prod (deploy) - Manual +``` + All visualization formats show: - Job inheritance chains (extends relationships) @@ -165,6 +304,56 @@ All visualization formats show: - Remote job/template indicators (🌐) - Template markers ([T]) +### Error Handling & Remote Includes + +**Remote Include Failures:** + +When remote or project includes cannot be resolved, the behavior differs between `visualize` and `simulate`: + +- **`visualize` command**: Logs a warning to stderr and continues without the failed include + + ```bash + ⚠️ Could not fetch remote include: https://example.com/missing.yml (404 Not Found) + ``` + + The visualization will show jobs from successfully resolved includes. Missing remote templates will appear as broken references. + +- **`simulate` command**: Logs a warning and continues simulation with available jobs + + ```bash + ⚠️ Could not fetch remote include: https://example.com/templates/base.yml (Network error) + ``` + + Jobs extending missing remote templates may have incomplete configurations. + +- **`project:` includes**: Throw an error and halt execution if authentication fails or path is invalid + ```bash + Error: Failed to fetch project include: https://gitlab.com/org/project/-/raw/main/templates/ci.yml + ``` + +**Authentication Tips:** + +- Use `GITLAB_TOKEN` environment variable to avoid exposing tokens in command history +- For self-hosted GitLab, always set `--host` or `GITLAB_HOST` +- Project includes require `PRIVATE-TOKEN` header (automatically set with `-t`) +- Remote includes use `Authorization: Bearer` header + +**Example with error handling:** + +```bash +# Set token via environment variable (recommended) +export GITLAB_TOKEN=glpat-xxxxxxxxxxxx +export GITLAB_HOST=gitlab.company.com + +# Visualize with potential remote include failures +gitlab-ci-builder visualize pipeline.yml 2> errors.log + +# Check if any includes failed +if grep -q "Could not fetch" errors.log; then + echo "⚠️ Some remote includes failed to load" +fi +``` + ## Import & Export ### Exporting to YAML @@ -935,6 +1124,173 @@ config.job( - **Remote jobs/templates**: Set `remote: true` on individual jobs or templates to exclude them from merging and output. This is only available at the job/template level. Use this for jobs/templates defined in external includes or that should not be processed locally. - **Shadow-overrides for remote jobs/templates**: If a job or template is marked as `remote: true`, it will be ignored during merging and output. However, you can locally define a job/template with the same name (without `remote: true`) to override or "shadow" the remote definition. This allows you to selectively replace or extend remote jobs/templates in your local pipeline configuration. +## Common Pitfalls & Best Practices + +### Stage References + +Jobs must reference stages that exist in the pipeline. Undefined stages will cause validation errors: + +```ts +// ❌ Bad: Job references non-existent stage +const config = new ConfigBuilder() +config.job("build", { stage: "build", script: ["npm run build"] }) +// Error: Stage "build" not defined + +// ✅ Good: Define stages first +const config = new ConfigBuilder() + .stages("build", "test", "deploy") + .job("build", { stage: "build", script: ["npm run build"] }) +``` + +### Template Naming + +Templates must start with a dot (`.`). Without it, they're treated as regular jobs: + +```ts +// ❌ Bad: Template without leading dot +config.job("base", { image: "node:22" }) // This is a regular job! + +// ✅ Good: Use template() or add dot manually +config.template(".base", { image: "node:22" }) +// or +config.job(".base", { image: "node:22" }) +``` + +### Extends Resolution Order + +The builder resolves extends topologically. Circular dependencies are detected and will throw an error: + +```ts +// ❌ Bad: Circular dependency +config.template(".a", { extends: ".b" }) +config.template(".b", { extends: ".a" }) +// Error: Circular dependency detected: .a → .b → .a + +// ✅ Good: Linear inheritance chain +config.template(".base", { image: "node:22" }) +config.template(".with-cache", { extends: ".base", cache: { paths: ["node_modules"] } }) +config.job("build", { extends: ".with-cache", script: ["npm run build"] }) +``` + +### Variable Precedence + +Variables are merged with child values overriding parent values: + +```ts +config.template(".base", { + variables: { NODE_ENV: "test", DEBUG: "false" }, +}) + +config.job("build", { + extends: ".base", + variables: { NODE_ENV: "production" }, // Overrides NODE_ENV, keeps DEBUG +}) +// Result: { NODE_ENV: "production", DEBUG: "false" } +``` + +### Script Array Concatenation + +Unlike most properties, scripts are concatenated (not replaced) during merge: + +```ts +config.template(".base", { + script: ["npm ci"], +}) + +config.job("build", { + extends: ".base", + script: ["npm run build"], +}) +// Result: script: ["npm ci", "npm run build"] ← Both scripts! +``` + +To replace instead of concatenate, use `mergeExisting: false`: + +```ts +config.job( + "build", + { + extends: ".base", + script: ["npm run build"], + }, + { mergeExisting: false }, +) +// Result: script: ["npm run build"] ← Only new script +``` + +### YAML Anchors vs Extends + +When importing YAML with anchors, they're resolved and inlined. Use GitLab's `extends` for clearer TypeScript code: + +```yaml +# ❌ Anchors are resolved during import +.base: &base + image: node:22 + +build: + <<: *base # Values get inlined + script: [build] +``` + +```yaml +# ✅ Extends are preserved +.base: + image: node:22 + +build: + extends: .base # Reference preserved in TypeScript + script: [build] +``` + +### Remote Include Behavior + +Jobs/templates from remote includes can be marked as `remote: true` to exclude them from output: + +```ts +// Mark remote template as remote (won't appear in output) +config.template(".remote-base", { image: "alpine" }, { remote: true }) + +// Local job can still extend it +config.job("local-job", { + extends: ".remote-base", + script: ["echo 'hello'"], +}) +// Output only includes "local-job" with merged properties +``` + +### Child Pipeline Paths + +Child pipeline `outputPath` must be relative to the parent pipeline location: + +```ts +// ❌ Bad: Absolute path +config.childPipeline("scan", (child) => { ... }, { + outputPath: "/absolute/path/scan.yml" // Won't work with GitLab +}) + +// ✅ Good: Relative path +config.childPipeline("scan", (child) => { ... }, { + outputPath: "pipelines/scan.yml" // Relative to parent +}) +``` + +### Performance with Large Pipelines + +For pipelines with 100+ jobs, consider: + +- Use `performanceMode: true` in global options to skip expensive validation +- Break into child pipelines for parallel execution +- Use `resolveTemplatesOnly: true` to reduce merge operations +- Avoid deep extends chains (>5 levels) + +```ts +// For large pipelines +const config = new ConfigBuilder() + .globalOptions({ performanceMode: true }) + .stages("build", "test", "deploy") +// ... add many jobs ... +``` + ## API Reference This reference summarizes the primary `ConfigBuilder` API surface. Method signatures reflect diff --git a/package.json b/package.json index 01afcc5..b73a95d 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "lint:fix": "eslint . --fix", "test": "vitest --run", "test:coverage": "vitest --coverage", + "test:e2e": "vitest --run --project e2e", "test:integration": "vitest --run --project integration", "test:ui": "vitest --ui", "test:unit": "vitest --run --project unit", @@ -71,11 +72,11 @@ "commander": "^14.0.2", "deepmerge": "4.3.1", "js-yaml": "4.1.1", - "oo-ascii-tree": "^1.121.0", + "oo-ascii-tree": "^1.124.0", "read-pkg": "^10.0.0", "tinyglobby": "0.2.15", "typescript": "5.9.3", - "zod": "4.1.13" + "zod": "4.3.4" }, "devDependencies": { "@changesets/cli": "2.29.8", @@ -85,22 +86,24 @@ "@types/js-yaml": "4.0.9", "@types/node": "24.10.4", "@vitest/coverage-v8": "4.0.16", - "dedent": "^1.7.1", + "dedent": "1.7.1", "eslint": "9.39.2", - "eslint-config-turbo": "2.6.3", + "eslint-config-turbo": "2.7.2", "eslint-plugin-import": "2.32.0", - "eslint-plugin-package-json": "0.85.0", + "eslint-plugin-package-json": "0.87.1", "json-schema-to-typescript": "15.0.4", "jsonc-eslint-parser": "2.4.2", "memfs": "4.51.1", - "msw": "^2.12.4", + "msw": "^2.12.7", + "node-pty": "1.1.0", "prettier": "3.7.4", - "tsdown": "0.18.1", + "tsdown": "0.18.4", "tsx": "4.21.0", - "typescript-eslint": "8.50.0", + "tuistory": "0.0.6", + "typescript-eslint": "8.51.0", "vitest": "4.0.16" }, - "packageManager": "pnpm@10.26.0", + "packageManager": "pnpm@10.27.0", "engines": { "node": ">=22" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a6f963..9e1ccf3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: 4.1.1 version: 4.1.1 oo-ascii-tree: - specifier: ^1.121.0 - version: 1.121.0 + specifier: ^1.124.0 + version: 1.124.0 read-pkg: specifier: ^10.0.0 version: 10.0.0 @@ -36,8 +36,8 @@ importers: specifier: 5.9.3 version: 5.9.3 zod: - specifier: 4.1.13 - version: 4.1.13 + specifier: 4.3.4 + version: 4.3.4 devDependencies: '@changesets/cli': specifier: 2.29.8 @@ -59,22 +59,22 @@ importers: version: 24.10.4 '@vitest/coverage-v8': specifier: 4.0.16 - version: 4.0.16(vitest@4.0.16(@types/node@24.10.4)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.1)) + version: 4.0.16(vitest@4.0.16(@types/node@24.10.4)(msw@2.12.7(@types/node@24.10.4)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.1)) dedent: - specifier: ^1.7.1 + specifier: 1.7.1 version: 1.7.1 eslint: specifier: 9.39.2 version: 9.39.2 eslint-config-turbo: - specifier: 2.6.3 - version: 2.6.3(eslint@9.39.2)(turbo@2.6.1) + specifier: 2.7.2 + version: 2.7.2(eslint@9.39.2)(turbo@2.6.1) eslint-plugin-import: specifier: 2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2) + version: 2.32.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2) eslint-plugin-package-json: - specifier: 0.85.0 - version: 0.85.0(@types/estree@1.0.8)(eslint@9.39.2)(jsonc-eslint-parser@2.4.2) + specifier: 0.87.1 + version: 0.87.1(@types/estree@1.0.8)(eslint@9.39.2)(jsonc-eslint-parser@2.4.2) json-schema-to-typescript: specifier: 15.0.4 version: 15.0.4 @@ -85,23 +85,29 @@ importers: specifier: 4.51.1 version: 4.51.1 msw: - specifier: ^2.12.4 - version: 2.12.4(@types/node@24.10.4)(typescript@5.9.3) + specifier: ^2.12.7 + version: 2.12.7(@types/node@24.10.4)(typescript@5.9.3) + node-pty: + specifier: 1.1.0 + version: 1.1.0 prettier: specifier: 3.7.4 version: 3.7.4 tsdown: - specifier: 0.18.1 - version: 0.18.1(typescript@5.9.3) + specifier: 0.18.4 + version: 0.18.4(typescript@5.9.3) tsx: specifier: 4.21.0 version: 4.21.0 + tuistory: + specifier: 0.0.6 + version: 0.0.6 typescript-eslint: - specifier: 8.50.0 - version: 8.50.0(eslint@9.39.2)(typescript@5.9.3) + specifier: 8.51.0 + version: 8.51.0(eslint@9.39.2)(typescript@5.9.3) vitest: specifier: 4.0.16 - version: 4.0.16(@types/node@24.10.4)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.1) + version: 4.0.16(@types/node@24.10.4)(msw@2.12.7(@types/node@24.10.4)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.1) packages: @@ -732,6 +738,9 @@ packages: '@napi-rs/wasm-runtime@1.1.0': resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -756,88 +765,171 @@ packages: '@oxc-project/types@0.103.0': resolution: {integrity: sha512-bkiYX5kaXWwUessFRSoXFkGIQTmc6dLGdxuRTrC+h8PSnIdZyuXHHlLAeTmOue5Br/a0/a7dHH0Gca6eXn9MKg==} + '@oxc-project/types@0.106.0': + resolution: {integrity: sha512-QdsH3rZq480VnOHSHgPYOhjL8O8LBdcnSjM408BpPCCUc0JYYZPG9Gafl9i3OcGk/7137o+gweb4cCv3WAUykg==} + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} - '@rolldown/binding-android-arm64@1.0.0-beta.55': - resolution: {integrity: sha512-5cPpHdO+zp+klznZnIHRO1bMHDq5hS9cqXodEKAaa/dQTPDjnE91OwAsy3o1gT2x4QaY8NzdBXAvutYdaw0WeA==} + '@rolldown/binding-android-arm64@1.0.0-beta.57': + resolution: {integrity: sha512-GoOVDy8bjw9z1K30Oo803nSzXJS/vWhFijFsW3kzvZCO8IZwFnNa6pGctmbbJstKl3Fv6UBwyjJQN6msejW0IQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-android-arm64@1.0.0-beta.58': + resolution: {integrity: sha512-mWj5eE4Qc8TbPdGGaaLvBb9XfDPvE1EmZkJQgiGKwchkWH4oAJcRAKMTw7ZHnb1L+t7Ah41sBkAecaIsuUgsug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-beta.55': - resolution: {integrity: sha512-l0887CGU2SXZr0UJmeEcXSvtDCOhDTTYXuoWbhrEJ58YQhQk24EVhDhHMTyjJb1PBRniUgNc1G0T51eF8z+TWw==} + '@rolldown/binding-darwin-arm64@1.0.0-beta.57': + resolution: {integrity: sha512-9c4FOhRGpl+PX7zBK5p17c5efpF9aSpTPgyigv57hXf5NjQUaJOOiejPLAtFiKNBIfm5Uu6yFkvLKzOafNvlTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-beta.55': - resolution: {integrity: sha512-d7qP2AVYzN0tYIP4vJ7nmr26xvmlwdkLD/jWIc9Z9dqh5y0UGPigO3m5eHoHq9BNazmwdD9WzDHbQZyXFZjgtA==} + '@rolldown/binding-darwin-arm64@1.0.0-beta.58': + resolution: {integrity: sha512-wFxUymI/5R8bH8qZFYDfAxAN9CyISEIYke+95oZPiv6EWo88aa5rskjVcCpKA532R+klFmdqjbbaD56GNmTF4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-beta.57': + resolution: {integrity: sha512-6RsB8Qy4LnGqNGJJC/8uWeLWGOvbRL/KG5aJ8XXpSEupg/KQtlBEiFaYU/Ma5Usj1s+bt3ItkqZYAI50kSplBA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-beta.55': - resolution: {integrity: sha512-j311E4NOB0VMmXHoDDZhrWidUf7L/Sa6bu/+i2cskvHKU40zcUNPSYeD2YiO2MX+hhDFa5bJwhliYfs+bTrSZw==} + '@rolldown/binding-darwin-x64@1.0.0-beta.58': + resolution: {integrity: sha512-ybp3MkPj23VDV9PhtRwdU5qrGhlViWRV5BjKwO6epaSlUD5lW0WyY+roN3ZAzbma/9RrMTgZ/a/gtQq8YXOcqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-beta.57': + resolution: {integrity: sha512-uA9kG7+MYkHTbqwv67Tx+5GV5YcKd33HCJIi0311iYBd25yuwyIqvJfBdt1VVB8tdOlyTb9cPAgfCki8nhwTQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-freebsd-x64@1.0.0-beta.58': + resolution: {integrity: sha512-Evxj3yh7FWvyklUYZa0qTVT9N2zX9TPDqGF056hl8hlCZ9/ndQ2xMv6uw9PD1VlLpukbsqL+/C6M0qwipL0QMg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.55': - resolution: {integrity: sha512-lAsaYWhfNTW2A/9O7zCpb5eIJBrFeNEatOS/DDOZ5V/95NHy50g4b/5ViCqchfyFqRb7MKUR18/+xWkIcDkeIw==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.57': + resolution: {integrity: sha512-3KkS0cHsllT2T+Te+VZMKHNw6FPQihYsQh+8J4jkzwgvAQpbsbXmrqhkw3YU/QGRrD8qgcOvBr6z5y6Jid+rmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.58': + resolution: {integrity: sha512-tYeXprDOrEgVHUbPXH6MPso4cM/c6RTkmJNICMQlYdki4hGMh92aj3yU6CKs+4X5gfG0yj5kVUw/L4M685SYag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.55': - resolution: {integrity: sha512-2x6ffiVLZrQv7Xii9+JdtyT1U3bQhKj59K3eRnYlrXsKyjkjfmiDUVx2n+zSyijisUqD62fcegmx2oLLfeTkCA==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.57': + resolution: {integrity: sha512-A3/wu1RgsHhqP3rVH2+sM81bpk+Qd2XaHTl8LtX5/1LNR7QVBFBCpAoiXwjTdGnI5cMdBVi7Z1pi52euW760Fw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.58': + resolution: {integrity: sha512-N78vmZzP6zG967Ohr+MasCjmKtis0geZ1SOVmxrA0/bklTQSzH5kHEjW5Qn+i1taFno6GEre1E40v0wuWsNOQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.57': + resolution: {integrity: sha512-d0kIVezTQtazpyWjiJIn5to8JlwfKITDqwsFv0Xc6s31N16CD2PC/Pl2OtKgS7n8WLOJbfqgIp5ixYzTAxCqMg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.55': - resolution: {integrity: sha512-QbNncvqAXziya5wleI+OJvmceEE15vE4yn4qfbI/hwT/+8ZcqxyfRZOOh62KjisXxp4D0h3JZspycXYejxAU3w==} + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.58': + resolution: {integrity: sha512-l+p4QVtG72C7wI2SIkNQw/KQtSjuYwS3rV6AKcWrRBF62ClsFUcif5vLaZIEbPrCXu5OFRXigXFJnxYsVVZqdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.55': - resolution: {integrity: sha512-YZCTZZM+rujxwVc6A+QZaNMJXVtmabmFYLG2VGQTKaBfYGvBKUgtbMEttnp/oZ88BMi2DzadBVhOmfQV8SuHhw==} + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.57': + resolution: {integrity: sha512-E199LPijo98yrLjPCmETx8EF43sZf9t3guSrLee/ej1rCCc3zDVTR4xFfN9BRAapGVl7/8hYqbbiQPTkv73kUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-beta.55': - resolution: {integrity: sha512-28q9OQ/DDpFh2keS4BVAlc3N65/wiqKbk5K1pgLdu/uWbKa8hgUJofhXxqO+a+Ya2HVTUuYHneWsI2u+eu3N5Q==} + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.58': + resolution: {integrity: sha512-urzJX0HrXxIh0FfxwWRjfPCMeInU9qsImLQxHBgLp5ivji1EEUnOfux8KxPPnRQthJyneBrN2LeqUix9DYrNaQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.0.0-beta.55': - resolution: {integrity: sha512-LiCA4BjCnm49B+j1lFzUtlC+4ZphBv0d0g5VqrEJua/uyv9Ey1v9tiaMql1C8c0TVSNDUmrkfHQ71vuQC7YfpQ==} + '@rolldown/binding-linux-x64-musl@1.0.0-beta.57': + resolution: {integrity: sha512-++EQDpk/UJ33kY/BNsh7A7/P1sr/jbMuQ8cE554ZIy+tCUWCivo9zfyjDUoiMdnxqX6HLJEqqGnbGQOvzm2OMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.58': + resolution: {integrity: sha512-7ijfVK3GISnXIwq/1FZo+KyAUJjL3kWPJ7rViAL6MWeEBhEgRzJ0yEd9I8N9aut8Y8ab+EKFJyRNMWZuUBwQ0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.57': + resolution: {integrity: sha512-voDEBcNqxbUv/GeXKFtxXVWA+H45P/8Dec4Ii/SbyJyGvCqV1j+nNHfnFUIiRQ2Q40DwPe/djvgYBs9PpETiMA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-beta.55': - resolution: {integrity: sha512-nZ76tY7T0Oe8vamz5Cv5CBJvrqeQxwj1WaJ2GxX8Msqs0zsQMMcvoyxOf0glnJlxxgKjtoBxAOxaAU8ERbW6Tg==} + '@rolldown/binding-openharmony-arm64@1.0.0-beta.58': + resolution: {integrity: sha512-/m7sKZCS+cUULbzyJTIlv8JbjNohxbpAOA6cM+lgWgqVzPee3U6jpwydrib328JFN/gF9A99IZEnuGYqEDJdww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.57': + resolution: {integrity: sha512-bRhcF7NLlCnpkzLVlVhrDEd0KH22VbTPkPTbMjlYvqhSmarxNIq5vtlQS8qmV7LkPKHrNLWyJW/V/sOyFba26Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.58': + resolution: {integrity: sha512-6SZk7zMgv+y3wFFQ9qE5P9NnRHcRsptL1ypmudD26PDY+PvFCvfHRkJNfclWnvacVGxjowr7JOL3a9fd1wWhUw==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.55': - resolution: {integrity: sha512-TFVVfLfhL1G+pWspYAgPK/FSqjiBtRKYX9hixfs508QVEZPQlubYAepHPA7kEa6lZXYj5ntzF87KC6RNhxo+ew==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.57': + resolution: {integrity: sha512-rnDVGRks2FQ2hgJ2g15pHtfxqkGFGjJQUDWzYznEkE8Ra2+Vag9OffxdbJMZqBWXHVM0iS4dv8qSiEn7bO+n1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.55': - resolution: {integrity: sha512-j1WBlk0p+ISgLzMIgl0xHp1aBGXenoK2+qWYc/wil2Vse7kVOdFq9aeQ8ahK6/oxX2teQ5+eDvgjdywqTL+daA==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.58': + resolution: {integrity: sha512-sFqfYPnBZ6xBhMkadB7UD0yjEDRvs7ipR3nCggblN+N4ODCXY6qhg/bKL39+W+dgQybL7ErD4EGERVbW9DAWvg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.57': + resolution: {integrity: sha512-OqIUyNid1M4xTj6VRXp/Lht/qIP8fo25QyAZlCP+p6D2ATCEhyW4ZIFLnC9zAGN/HMbXoCzvwfa8Jjg/8J4YEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.58': + resolution: {integrity: sha512-AnFWJdAqB8+IDPcGrATYs67Kik/6tnndNJV2jGRmwlbeNiQQ8GhRJU8ETRlINfII0pqi9k4WWLnb00p1QCxw/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-beta.55': - resolution: {integrity: sha512-vajw/B3qoi7aYnnD4BQ4VoCcXQWnF0roSwE2iynbNxgW4l9mFwtLmLmUhpDdcTBfKyZm1p/T0D13qG94XBLohA==} + '@rolldown/pluginutils@1.0.0-beta.57': + resolution: {integrity: sha512-aQNelgx14tGA+n2tNSa9x6/jeoCL9fkDeCei7nOKnHx0fEFRRMu5ReiITo+zZD5TzWDGGRjbSYCs93IfRIyTuQ==} + + '@rolldown/pluginutils@1.0.0-beta.58': + resolution: {integrity: sha512-qWhDs6yFGR5xDfdrwiSa3CWGIHxD597uGE/A9xGqytBjANvh4rLCTTkq7szhMV4+Ygh+PMS90KVJ8xWG/TkX4w==} '@rollup/rollup-android-arm-eabi@4.53.2': resolution: {integrity: sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==} @@ -991,63 +1083,63 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} - '@typescript-eslint/eslint-plugin@8.50.0': - resolution: {integrity: sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==} + '@typescript-eslint/eslint-plugin@8.51.0': + resolution: {integrity: sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.50.0 + '@typescript-eslint/parser': ^8.51.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.50.0': - resolution: {integrity: sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==} + '@typescript-eslint/parser@8.51.0': + resolution: {integrity: sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.50.0': - resolution: {integrity: sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==} + '@typescript-eslint/project-service@8.51.0': + resolution: {integrity: sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.50.0': - resolution: {integrity: sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==} + '@typescript-eslint/scope-manager@8.51.0': + resolution: {integrity: sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.50.0': - resolution: {integrity: sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==} + '@typescript-eslint/tsconfig-utils@8.51.0': + resolution: {integrity: sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.50.0': - resolution: {integrity: sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==} + '@typescript-eslint/type-utils@8.51.0': + resolution: {integrity: sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.50.0': - resolution: {integrity: sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==} + '@typescript-eslint/types@8.51.0': + resolution: {integrity: sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.50.0': - resolution: {integrity: sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==} + '@typescript-eslint/typescript-estree@8.51.0': + resolution: {integrity: sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.50.0': - resolution: {integrity: sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==} + '@typescript-eslint/utils@8.51.0': + resolution: {integrity: sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.50.0': - resolution: {integrity: sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==} + '@typescript-eslint/visitor-keys@8.51.0': + resolution: {integrity: sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitest/coverage-v8@4.0.16': @@ -1198,6 +1290,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + bun-pty@0.4.3: + resolution: {integrity: sha512-h6/9ykSnEHj/RGtS1tc1pwFWen9RxehU+7xM0IggphUID3h5bKoEGAsk+e+FUfbkwKbeQIG5q3dOoGCc4es06g==} + engines: {bun: '>=1.0.0'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1429,8 +1525,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-turbo@2.6.3: - resolution: {integrity: sha512-HS6aanr+Cg4X1Ss8AObgdsa9LvSi1rHAU7sHWqD4MWKe+/4uPt0Zqt6VqX1QMIJI6bles+QxSpMPnahMN9hPLg==} + eslint-config-turbo@2.7.2: + resolution: {integrity: sha512-Tj8P1kJFVNFZxH+BaQO9sowg11N5PkpD34aWZ87PImqkWo7Qk8yRGRpshCeqGwdO5YKX631ZyTQfcDvs4bqBMQ==} peerDependencies: eslint: '>6.6.0' turbo: '>2.0.0' @@ -1479,15 +1575,15 @@ packages: '@typescript-eslint/parser': optional: true - eslint-plugin-package-json@0.85.0: - resolution: {integrity: sha512-MrOxFvhbqLuk4FIPG9v3u9Amn0n137J8LKILHvgfxK3rRyAHEVzuZM0CtpXFTx7cx4LzmAzONtlpjbM0UFNuTA==} + eslint-plugin-package-json@0.87.1: + resolution: {integrity: sha512-aHOUGdpHrPDji64xwAsspXvfjwgeksARxM2SqzhusSEUCyLr0GX0oNe9hDDPMQ2CKPlKjTPKFhzmsQNIO0aWiQ==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: eslint: '>=8.0.0' jsonc-eslint-parser: ^2.0.0 - eslint-plugin-turbo@2.6.3: - resolution: {integrity: sha512-91WZ+suhT/pk+qNS0/rqT43xLUlUblsa3a8jKmAStGhkJCmR2uX0oWo/e0Edb+It8MdnteXuYpCkvsK4Vw8FtA==} + eslint-plugin-turbo@2.7.2: + resolution: {integrity: sha512-rZs+l0vQcFo/37OiCWDcTIcksrVfvSBwS6/CI41wc3hA/hWxGOAbT1Diy9/+PBrh2VJts0SzBXb80SqGgVFFPQ==} peerDependencies: eslint: '>6.6.0' turbo: '>2.0.0' @@ -1655,6 +1751,14 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + ghostty-opentui@1.3.11: + resolution: {integrity: sha512-taKOhQD65dip/GBi2eDicyS6Z+m7T6CAWyUcFUVP3nX+JKTCiOwfncGqhnqtSa8VE3mG6VHaPzIimDVN7pdD6w==} + peerDependencies: + '@opentui/core': '*' + peerDependenciesMeta: + '@opentui/core': + optional: true + git-hooks-list@4.1.1: resolution: {integrity: sha512-cmP497iLq54AZnv4YRAEMnEyQ1eIn4tGKbmswqwmFV4GBnAqE8NLtWxxdXa++AalfgL5EBH4IxTPyquEuGY/jA==} @@ -1725,8 +1829,8 @@ packages: headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} - hookable@5.5.3: - resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hookable@6.0.1: + resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==} hosted-git-info@9.0.2: resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==} @@ -1759,8 +1863,8 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - import-without-cache@0.2.4: - resolution: {integrity: sha512-b/Ke0y4n26ffQhkLvgBxV/NVO/QEE6AZlrMj8DYuxBWNAAu4iMQWZTFWzKcCTEmv7VQ0ae0j8KwrlGzSy8sYQQ==} + import-without-cache@0.2.5: + resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} engines: {node: '>=20.19.0'} imurmurhash@0.1.4: @@ -2030,8 +2134,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.12.4: - resolution: {integrity: sha512-rHNiVfTyKhzc0EjoXUBVGteNKBevdjOlVC6GlIRXpy+/3LHEIGRovnB5WPjcvmNODVQ1TNFnoa7wsGbd0V3epg==} + msw@2.12.7: + resolution: {integrity: sha512-retd5i3xCZDVWMYjHEVuKTmhqY8lSsxujjVrZiGbbdoxxIBg5S7rCuYy/YQpfrTYIxpd/o0Kyb/3H+1udBMoYg==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -2044,6 +2148,9 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + nan@2.24.0: + resolution: {integrity: sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2052,6 +2159,15 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-pty@1.0.0: + resolution: {integrity: sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==} + + node-pty@1.1.0: + resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} + normalize-package-data@8.0.0: resolution: {integrity: sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==} engines: {node: ^20.17.0 || >=22.9.0} @@ -2083,8 +2199,8 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - oo-ascii-tree@1.121.0: - resolution: {integrity: sha512-Dwzge50NT4bUxynVLtn/eFnl5Vv+8thNDVhw2MFZf6t5DmtIWKCDdQGUrIhN6PMEloDXVvPIW//oZtooSkp79g==} + oo-ascii-tree@1.124.0: + resolution: {integrity: sha512-IcG/yYqPbz/R0UmVuHauQKbkZtVvOk442tiYAEJR5VS8a3VWfKEQlu4Hxaip6v4rTWv1EoNIomiF7hkHK4cR0A==} engines: {node: '>= 14.17.0'} optionator@0.9.4: @@ -2259,15 +2375,15 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown-plugin-dts@0.19.1: - resolution: {integrity: sha512-6z501zDTGq6ZrIEdk57qNUwq7kBRGzv3I3SAN2HMJ2KFYjHLnAuPYOmvfiwdxbRZMJ0iMdkV9rYdC3GjurT2cg==} + rolldown-plugin-dts@0.20.0: + resolution: {integrity: sha512-cLAY1kN2ilTYMfZcFlGWbXnu6Nb+8uwUBsi+Mjbh4uIx7IN8uMOmJ7RxrrRgPsO4H7eSz3E+JwGoL1gyugiyUA==} engines: {node: '>=20.19.0'} peerDependencies: '@ts-macro/tsc': ^0.3.6 '@typescript/native-preview': '>=7.0.0-dev.20250601.1' - rolldown: ^1.0.0-beta.55 + rolldown: ^1.0.0-beta.57 typescript: ^5.0.0 - vue-tsc: ~3.1.0 + vue-tsc: ~3.2.0 peerDependenciesMeta: '@ts-macro/tsc': optional: true @@ -2278,8 +2394,13 @@ packages: vue-tsc: optional: true - rolldown@1.0.0-beta.55: - resolution: {integrity: sha512-r8Ws43aYCnfO07ao0SvQRz4TBAtZJjGWNvScRBOHuiNHvjfECOJBIqJv0nUkL1GYcltjvvHswRilDF1ocsC0+g==} + rolldown@1.0.0-beta.57: + resolution: {integrity: sha512-lMMxcNN71GMsSko8RyeTaFoATHkCh4IWU7pYF73ziMYjhHZWfVesC6GQ+iaJCvZmVjvgSks9Ks1aaqEkBd8udg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rolldown@1.0.0-beta.58: + resolution: {integrity: sha512-v1FCjMZCan7f+xGAHBi+mqiE4MlH7I+SXEHSQSJoMOGNNB2UYtvMiejsq9YuUOiZjNeUeV/a21nSFbrUR+4ZCQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -2510,8 +2631,8 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -2519,8 +2640,8 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - tsdown@0.18.1: - resolution: {integrity: sha512-na4MdVA8QS9Zw++0KovGpjvw1BY5WvoCWcE4Aw0dyfff9nWK8BPzniQEVs+apGUg3DHaYMDfs+XiFaDDgqDDzQ==} + tsdown@0.18.4: + resolution: {integrity: sha512-J/tRS6hsZTkvqmt4+xdELUCkQYDuUCXgBv0fw3ImV09WPGbEKfsPD65E+WUjSu3E7Z6tji9XZ1iWs8rbGqB/ZA==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: @@ -2552,6 +2673,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tuistory@0.0.6: + resolution: {integrity: sha512-leSmvRuBFk9rk9pLYrgQNHIsmV7BS+hzEvRBU/6iYJZTHwO0yFedxf/jkz+Szqa1ZZkRcAy5g0i3kC/6dlCkEg==} + turbo-darwin-64@2.6.1: resolution: {integrity: sha512-Dm0HwhyZF4J0uLqkhUyCVJvKM9Rw7M03v3J9A7drHDQW0qAbIGBrUijQ8g4Q9Cciw/BXRRd8Uzkc3oue+qn+ZQ==} cpu: [x64] @@ -2614,8 +2738,8 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript-eslint@8.50.0: - resolution: {integrity: sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==} + typescript-eslint@8.51.0: + resolution: {integrity: sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -2644,8 +2768,8 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} - unrun@0.2.20: - resolution: {integrity: sha512-YhobStTk93HYRN/4iBs3q3/sd7knvju1XrzwwrVVfRujyTG1K88hGONIxCoJN0PWBuO+BX7fFiHH0sVDfE3MWw==} + unrun@0.2.22: + resolution: {integrity: sha512-vlQce4gTLNyCZxGylEQXGG+fSrrEFWiM/L8aghtp+t6j8xXh+lmsBtQJknG7ZSvv7P+/MRgbQtHWHBWk981uTg==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: @@ -2816,8 +2940,8 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} - zod@4.1.13: - resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + zod@4.3.4: + resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==} snapshots: @@ -3397,6 +3521,13 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3420,52 +3551,97 @@ snapshots: '@oxc-project/types@0.103.0': {} + '@oxc-project/types@0.106.0': {} + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 - '@rolldown/binding-android-arm64@1.0.0-beta.55': + '@rolldown/binding-android-arm64@1.0.0-beta.57': + optional: true + + '@rolldown/binding-android-arm64@1.0.0-beta.58': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.57': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.58': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.57': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.58': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.57': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.58': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.57': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-beta.55': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.58': optional: true - '@rolldown/binding-darwin-x64@1.0.0-beta.55': + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.57': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-beta.55': + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.58': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.55': + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.57': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.55': + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.58': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.55': + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.57': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.55': + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.58': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-beta.55': + '@rolldown/binding-linux-x64-musl@1.0.0-beta.57': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-beta.55': + '@rolldown/binding-linux-x64-musl@1.0.0-beta.58': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.55': + '@rolldown/binding-openharmony-arm64@1.0.0-beta.57': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.58': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.57': dependencies: '@napi-rs/wasm-runtime': 1.1.0 optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.55': + '@rolldown/binding-wasm32-wasi@1.0.0-beta.58': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.57': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.55': + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.58': optional: true - '@rolldown/pluginutils@1.0.0-beta.55': {} + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.57': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.58': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.57': {} + + '@rolldown/pluginutils@1.0.0-beta.58': {} '@rollup/rollup-android-arm-eabi@4.53.2': optional: true @@ -3569,98 +3745,98 @@ snapshots: '@types/statuses@2.0.6': {} - '@typescript-eslint/eslint-plugin@8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.50.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.50.0 - '@typescript-eslint/type-utils': 8.50.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.50.0 + '@typescript-eslint/parser': 8.51.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.51.0 + '@typescript-eslint/type-utils': 8.51.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.51.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.51.0 eslint: 9.39.2 ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.50.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/parser@8.51.0(eslint@9.39.2)(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.50.0 - '@typescript-eslint/types': 8.50.0 - '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.50.0 + '@typescript-eslint/scope-manager': 8.51.0 + '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.51.0 debug: 4.4.3 eslint: 9.39.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.50.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.51.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3) - '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3) + '@typescript-eslint/types': 8.51.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.50.0': + '@typescript-eslint/scope-manager@8.51.0': dependencies: - '@typescript-eslint/types': 8.50.0 - '@typescript-eslint/visitor-keys': 8.50.0 + '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/visitor-keys': 8.51.0 - '@typescript-eslint/tsconfig-utils@8.50.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.51.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.50.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.51.0(eslint@9.39.2)(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.50.0 - '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.51.0(eslint@9.39.2)(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2 - ts-api-utils: 2.1.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.50.0': {} + '@typescript-eslint/types@8.51.0': {} - '@typescript-eslint/typescript-estree@8.50.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.51.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.50.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3) - '@typescript-eslint/types': 8.50.0 - '@typescript-eslint/visitor-keys': 8.50.0 + '@typescript-eslint/project-service': 8.51.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3) + '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/visitor-keys': 8.51.0 debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.3 tinyglobby: 0.2.15 - ts-api-utils: 2.1.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.50.0(eslint@9.39.2)(typescript@5.9.3)': + '@typescript-eslint/utils@8.51.0(eslint@9.39.2)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2) - '@typescript-eslint/scope-manager': 8.50.0 - '@typescript-eslint/types': 8.50.0 - '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.51.0 + '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) eslint: 9.39.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.50.0': + '@typescript-eslint/visitor-keys@8.51.0': dependencies: - '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/types': 8.51.0 eslint-visitor-keys: 4.2.1 - '@vitest/coverage-v8@4.0.16(vitest@4.0.16(@types/node@24.10.4)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.1))': + '@vitest/coverage-v8@4.0.16(vitest@4.0.16(@types/node@24.10.4)(msw@2.12.7(@types/node@24.10.4)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.16 @@ -3673,7 +3849,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@24.10.4)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.1) + vitest: 4.0.16(@types/node@24.10.4)(msw@2.12.7(@types/node@24.10.4)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -3686,13 +3862,13 @@ snapshots: chai: 6.2.1 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.2.2(@types/node@24.10.4)(tsx@4.21.0)(yaml@2.8.1))': + '@vitest/mocker@4.0.16(msw@2.12.7(@types/node@24.10.4)(typescript@5.9.3))(vite@7.2.2(@types/node@24.10.4)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.16 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.12.4(@types/node@24.10.4)(typescript@5.9.3) + msw: 2.12.7(@types/node@24.10.4)(typescript@5.9.3) vite: 7.2.2(@types/node@24.10.4)(tsx@4.21.0)(yaml@2.8.1) '@vitest/pretty-format@4.0.16': @@ -3842,6 +4018,9 @@ snapshots: dependencies: fill-range: 7.1.1 + bun-pty@0.4.3: + optional: true + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -4137,10 +4316,10 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-turbo@2.6.3(eslint@9.39.2)(turbo@2.6.1): + eslint-config-turbo@2.7.2(eslint@9.39.2)(turbo@2.6.1): dependencies: eslint: 9.39.2 - eslint-plugin-turbo: 2.6.3(eslint@9.39.2)(turbo@2.6.1) + eslint-plugin-turbo: 2.7.2(eslint@9.39.2)(turbo@2.6.1) turbo: 2.6.1 eslint-fix-utils@0.4.0(@types/estree@1.0.8)(eslint@9.39.2): @@ -4157,17 +4336,17 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.51.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.50.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.51.0(eslint@9.39.2)(typescript@5.9.3) eslint: 9.39.2 eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -4178,7 +4357,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.51.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -4190,13 +4369,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.50.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.51.0(eslint@9.39.2)(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-package-json@0.85.0(@types/estree@1.0.8)(eslint@9.39.2)(jsonc-eslint-parser@2.4.2): + eslint-plugin-package-json@0.87.1(@types/estree@1.0.8)(eslint@9.39.2)(jsonc-eslint-parser@2.4.2): dependencies: '@altano/repository-tools': 2.0.1 change-case: 5.4.4 @@ -4213,7 +4392,7 @@ snapshots: transitivePeerDependencies: - '@types/estree' - eslint-plugin-turbo@2.6.3(eslint@9.39.2)(turbo@2.6.1): + eslint-plugin-turbo@2.7.2(eslint@9.39.2)(turbo@2.6.1): dependencies: dotenv: 16.0.3 eslint: 9.39.2 @@ -4414,6 +4593,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + ghostty-opentui@1.3.11: + dependencies: + strip-ansi: 7.1.2 + git-hooks-list@4.1.1: {} glob-parent@5.1.2: @@ -4474,7 +4657,7 @@ snapshots: headers-polyfill@4.0.3: {} - hookable@5.5.3: {} + hookable@6.0.1: {} hosted-git-info@9.0.2: dependencies: @@ -4499,7 +4682,7 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-without-cache@0.2.4: {} + import-without-cache@0.2.5: {} imurmurhash@0.1.4: {} @@ -4775,7 +4958,7 @@ snapshots: ms@2.1.3: {} - msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3): + msw@2.12.7(@types/node@24.10.4)(typescript@5.9.3): dependencies: '@inquirer/confirm': 5.1.21(@types/node@24.10.4) '@mswjs/interceptors': 0.40.0 @@ -4802,10 +4985,24 @@ snapshots: mute-stream@2.0.0: {} + nan@2.24.0: + optional: true + nanoid@3.3.11: {} natural-compare@1.4.0: {} + node-addon-api@7.1.1: {} + + node-pty@1.0.0: + dependencies: + nan: 2.24.0 + optional: true + + node-pty@1.1.0: + dependencies: + node-addon-api: 7.1.1 + normalize-package-data@8.0.0: dependencies: hosted-git-info: 9.0.2 @@ -4847,7 +5044,7 @@ snapshots: obug@2.1.1: {} - oo-ascii-tree@1.121.0: {} + oo-ascii-tree@1.124.0: {} optionator@0.9.4: dependencies: @@ -5008,7 +5205,7 @@ snapshots: reusify@1.1.0: {} - rolldown-plugin-dts@0.19.1(rolldown@1.0.0-beta.55)(typescript@5.9.3): + rolldown-plugin-dts@0.20.0(rolldown@1.0.0-beta.57)(typescript@5.9.3): dependencies: '@babel/generator': 7.28.5 '@babel/parser': 7.28.5 @@ -5018,30 +5215,49 @@ snapshots: dts-resolver: 2.1.3 get-tsconfig: 4.13.0 obug: 2.1.1 - rolldown: 1.0.0-beta.55 + rolldown: 1.0.0-beta.57 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver - rolldown@1.0.0-beta.55: + rolldown@1.0.0-beta.57: dependencies: '@oxc-project/types': 0.103.0 - '@rolldown/pluginutils': 1.0.0-beta.55 + '@rolldown/pluginutils': 1.0.0-beta.57 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-beta.55 - '@rolldown/binding-darwin-arm64': 1.0.0-beta.55 - '@rolldown/binding-darwin-x64': 1.0.0-beta.55 - '@rolldown/binding-freebsd-x64': 1.0.0-beta.55 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.55 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.55 - '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.55 - '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.55 - '@rolldown/binding-linux-x64-musl': 1.0.0-beta.55 - '@rolldown/binding-openharmony-arm64': 1.0.0-beta.55 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.55 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.55 - '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.55 + '@rolldown/binding-android-arm64': 1.0.0-beta.57 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.57 + '@rolldown/binding-darwin-x64': 1.0.0-beta.57 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.57 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.57 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.57 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.57 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.57 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.57 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.57 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.57 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.57 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.57 + + rolldown@1.0.0-beta.58: + dependencies: + '@oxc-project/types': 0.106.0 + '@rolldown/pluginutils': 1.0.0-beta.58 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.58 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.58 + '@rolldown/binding-darwin-x64': 1.0.0-beta.58 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.58 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.58 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.58 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.58 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.58 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.58 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.58 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.58 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.58 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.58 rollup@4.53.2: dependencies: @@ -5304,7 +5520,7 @@ snapshots: tree-kill@1.2.2: {} - ts-api-utils@2.1.0(typescript@5.9.3): + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -5315,24 +5531,24 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tsdown@0.18.1(typescript@5.9.3): + tsdown@0.18.4(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 defu: 6.1.4 empathic: 2.0.0 - hookable: 5.5.3 - import-without-cache: 0.2.4 + hookable: 6.0.1 + import-without-cache: 0.2.5 obug: 2.1.1 picomatch: 4.0.3 - rolldown: 1.0.0-beta.55 - rolldown-plugin-dts: 0.19.1(rolldown@1.0.0-beta.55)(typescript@5.9.3) + rolldown: 1.0.0-beta.57 + rolldown-plugin-dts: 0.20.0(rolldown@1.0.0-beta.57)(typescript@5.9.3) semver: 7.7.3 tinyexec: 1.0.2 tinyglobby: 0.2.15 tree-kill: 1.2.2 unconfig-core: 7.4.2 - unrun: 0.2.20 + unrun: 0.2.22 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -5351,6 +5567,15 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tuistory@0.0.6: + dependencies: + ghostty-opentui: 1.3.11 + optionalDependencies: + bun-pty: 0.4.3 + node-pty: 1.0.0 + transitivePeerDependencies: + - '@opentui/core' + turbo-darwin-64@2.6.1: optional: true @@ -5421,12 +5646,12 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.50.0(eslint@9.39.2)(typescript@5.9.3): + typescript-eslint@8.51.0(eslint@9.39.2)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/parser': 8.50.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.51.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.51.0(eslint@9.39.2)(typescript@5.9.3) eslint: 9.39.2 typescript: 5.9.3 transitivePeerDependencies: @@ -5452,9 +5677,9 @@ snapshots: universalify@0.1.2: {} - unrun@0.2.20: + unrun@0.2.22: dependencies: - rolldown: 1.0.0-beta.55 + rolldown: 1.0.0-beta.58 until-async@3.0.2: {} @@ -5483,10 +5708,10 @@ snapshots: tsx: 4.21.0 yaml: 2.8.1 - vitest@4.0.16(@types/node@24.10.4)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.1): + vitest@4.0.16(@types/node@24.10.4)(msw@2.12.7(@types/node@24.10.4)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.2.2(@types/node@24.10.4)(tsx@4.21.0)(yaml@2.8.1)) + '@vitest/mocker': 4.0.16(msw@2.12.7(@types/node@24.10.4)(typescript@5.9.3))(vite@7.2.2(@types/node@24.10.4)(tsx@4.21.0)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.16 '@vitest/runner': 4.0.16 '@vitest/snapshot': 4.0.16 @@ -5622,4 +5847,4 @@ snapshots: yoctocolors-cjs@2.1.3: {} - zod@4.1.13: {} + zod@4.3.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3116a5f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: + - node-pty diff --git a/src/builder/ConfigBuilder.ts b/src/builder/ConfigBuilder.ts index 1f1f49b..b3edb83 100644 --- a/src/builder/ConfigBuilder.ts +++ b/src/builder/ConfigBuilder.ts @@ -463,7 +463,30 @@ export class ConfigBuilder { } // Parse and normalize (extends is automatically normalized to array) - const normalized = JobDefinitionParseSchema.parse(definition) + // Use safeParse for ALL templates (not just remote) because: + // - Templates are often incomplete (partial definitions) + // - Templates may contain !reference tags that are resolved later + // - Templates are never executed directly, only extended by jobs + const parseResult = JobDefinitionParseSchema.safeParse(definition) + + if (!parseResult.success) { + // Template has validation errors (likely unresolved !reference tags or incomplete definition) + // Store the raw definition without validation + let normalized = definition as JobDefinitionNormalized + + // Manually normalize extends field if present + if (normalized.extends && typeof normalized.extends === "string") { + normalized = { + ...normalized, + extends: [normalized.extends], + } + } + + this.state.setTemplate(templateName, normalized, templateOptions) + return this + } + + const normalized = parseResult.data // Check if template exists const existing = this.state.getJob(templateName) @@ -591,7 +614,29 @@ export class ConfigBuilder { } // Parse and normalize (extends is automatically normalized to array) - const normalized = JobDefinitionParseSchema.parse(definition) + // For remote jobs, use safeParse to be lenient with validation + let normalized: JobDefinitionNormalized + if (options.remote) { + const result = JobDefinitionParseSchema.safeParse(definition) + if (!result.success) { + // Silently use raw definition for remote jobs with validation errors + // This allows us to work with complex GitLab CI features we don't fully support yet + normalized = definition as JobDefinitionNormalized + + // BUGFIX: Manually normalize extends field if validation failed + // If extends is a string, convert it to an array to prevent string iteration bugs + if (normalized.extends && typeof normalized.extends === "string") { + normalized = { + ...normalized, + extends: [normalized.extends], + } + } + } else { + normalized = result.data + } + } else { + normalized = JobDefinitionParseSchema.parse(definition) + } // Check if it should be treated as template if (name.startsWith(".") || options.hidden) { @@ -606,7 +651,11 @@ export class ConfigBuilder { if (existing && mergeExisting !== false) { // Merge with existing - const merged = mergeJobDefinitions(existing, normalized) + // For remote jobs, reverse merge order: remote job is base, local job overrides + // For local jobs, normal order: existing is base, new definition overrides + const merged = options.remote + ? mergeJobDefinitions(normalized, existing) + : mergeJobDefinitions(existing, normalized) this.state.setJob(name, merged, options) } else { this.state.setJob(name, normalized, options) diff --git a/src/cli/commands/simulate.ts b/src/cli/commands/simulate.ts new file mode 100644 index 0000000..7281906 --- /dev/null +++ b/src/cli/commands/simulate.ts @@ -0,0 +1,428 @@ +import { readFile } from "fs/promises" +import { dirname, resolve } from "node:path" +import { Command } from "@commander-js/extra-typings" +import { ClimtTable } from "climt" +import { dump as yamlDump } from "js-yaml" + +import type { IncludeInput, Workflow } from "../../schema" +import type { RuleContext, SimulationResult } from "../../simulation" +import { ConfigBuilder } from "../../builder/ConfigBuilder" +import { parseYaml } from "../../importer/parser" +import { resolveIncludes } from "../../resolver/cli" +import { PipelineSimulator } from "../../simulation" + +export default function simulateCommand() { + const program = new Command() + + program + .name("simulate") + .description("Simulate GitLab CI pipeline execution based on variables and rules") + .argument("", "Path to .gitlab-ci.yml file or remote URL") + .option("-v, --variable ", "Set pipeline variables (can be used multiple times)") + .option("-b, --branch ", "Simulate for specific branch") + .option("--tag ", "Simulate for specific tag") + .option("--mr", "Simulate merge request pipeline") + .option("--mr-labels ", "Merge request labels (comma-separated)") + .option("-f, --format ", "Output format: text, json, yaml, table, summary", "summary") + .option("--show-skipped", "Show skipped jobs in output", false) + .option("--verbose", "Verbose output with detailed rule evaluation", false) + .option( + "-t, --token ", + "Authentication token for private repositories (or use GITLAB_TOKEN env var)", + ) + .option( + "--host ", + "GitLab host for project/template includes (or use GITLAB_HOST env var)", + "gitlab.com", + ) + .addHelpText( + "after", + ` +Examples: + $ gitlab-ci-builder simulate .gitlab-ci.yml -b main + $ gitlab-ci-builder simulate .gitlab-ci.yml -v CI_COMMIT_BRANCH=main -v JOB_DISABLED=true + $ gitlab-ci-builder simulate pipeline.yml --branch develop --mr + $ gitlab-ci-builder simulate .gitlab-ci.yml -f table --show-skipped + $ gitlab-ci-builder simulate https://gitlab.com/org/repo/-/raw/main/.gitlab-ci.yml -t + $ gitlab-ci-builder simulate .gitlab-ci.yml -f json > simulation.json +`, + ) + .action(async (input, options) => { + const format = options.format as "text" | "json" | "yaml" | "table" | "summary" + + if (!["text", "json", "yaml", "table", "summary"].includes(format)) { + // eslint-disable-next-line no-console + console.error(`Invalid format: ${format}. Must be one of: text, json, yaml, table, summary`) + process.exit(1) + } + + try { + let yamlContent: string + let basePath: string | undefined + + // Get token and host from CLI option or environment variable + const token = options.token ?? process.env.GITLAB_TOKEN + const host = + options.host !== "gitlab.com" ? options.host : (process.env.GITLAB_HOST ?? "gitlab.com") + const gitlabUrl = host === "gitlab.com" ? "https://gitlab.com" : `https://${host}` + + // Check if it's a URL + if (input.startsWith("http://") || input.startsWith("https://")) { + const headers: Record = {} + if (token) { + headers.Authorization = `Bearer ${token}` + } + + const response = await fetch(input, { headers }) + if (!response.ok) { + throw new Error(`Failed to fetch ${input}: ${response.statusText}`) + } + yamlContent = await response.text() + // No basePath for remote URLs - cannot resolve file paths + } else { + // Local YAML file + yamlContent = await readFile(input, "utf-8") + // Set basePath to the directory containing the config file + basePath = dirname(resolve(process.cwd(), input)) + } + + // Parse variables from CLI + const variables: Record = {} + if (options.variable) { + for (const varOption of options.variable) { + const [key, ...valueParts] = varOption.split("=") + if (key) { + variables[key] = valueParts.join("=") || "" + } + } + } + + // Add branch/tag to variables + if (options.branch) { + variables.CI_COMMIT_BRANCH = options.branch + // Also set CI_DEFAULT_BRANCH if not explicitly set + // GitLab sets this automatically - needed for rules like: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (!variables.CI_DEFAULT_BRANCH) { + variables.CI_DEFAULT_BRANCH = options.branch + } + + // Set additional branch-related variables that GitLab provides automatically + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (!variables.CI_COMMIT_REF_NAME) { + variables.CI_COMMIT_REF_NAME = options.branch + } + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (!variables.CI_COMMIT_REF_SLUG) { + variables.CI_COMMIT_REF_SLUG = options.branch.toLowerCase().replace(/[^a-z0-9-]/g, "-") + } + } + if (options.tag) { + variables.CI_COMMIT_TAG = options.tag + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (!variables.CI_COMMIT_REF_NAME) { + variables.CI_COMMIT_REF_NAME = options.tag + } + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (!variables.CI_COMMIT_REF_SLUG) { + variables.CI_COMMIT_REF_SLUG = options.tag.toLowerCase().replace(/[^a-z0-9-]/g, "-") + } + } + + // Set CI_PIPELINE_SOURCE if not explicitly set (default to push for non-MR pipelines) + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (!variables.CI_PIPELINE_SOURCE) { + variables.CI_PIPELINE_SOURCE = "push" + } + + if (options.mr) { + variables.CI_MERGE_REQUEST_ID = "1" + variables.CI_PIPELINE_SOURCE = "merge_request_event" + } + + // Set default commit message if not provided + // This is used in rules like: $CI_COMMIT_MESSAGE =~ /changeset-release/ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + variables.CI_COMMIT_MESSAGE ??= "" + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + variables.CI_COMMIT_TITLE ??= "" + + // Parse merge request labels + const mrLabels = options.mrLabels + ? options.mrLabels.flatMap((label) => label.split(",").map((l) => l.trim())) + : undefined + + // Create context + const context: RuleContext = { + variables, + branch: options.branch, + tag: options.tag, + mergeRequestLabels: mrLabels, + basePath, + } + + // Parse YAML first (handles !reference tags) + const parsed = parseYaml(yamlContent) + const config = new ConfigBuilder() + + // Add stages first + if (parsed.stages) { + config.stages( + ...(Array.isArray(parsed.stages) + ? (parsed.stages as string[]) + : ([parsed.stages] as string[])), + ) + } + + // Add variables and workflow from local YAML + if (parsed.variables && typeof parsed.variables === "object") { + config.variables(parsed.variables as Record) + } + + if (parsed.workflow && typeof parsed.workflow === "object") { + try { + config.workflow(parsed.workflow as Workflow) + } catch { + // Ignore validation errors for simulation + } + } + + // Add all local jobs and templates FIRST (before resolving includes) + // This way, local jobs with extends will properly inherit from remote includes + for (const [name, definition] of Object.entries(parsed)) { + if ( + typeof definition === "object" && + definition !== null && + !["stages", "variables", "workflow", "include", "default", "spec"].includes(name) + ) { + try { + if (name.startsWith(".")) { + config.template(name, definition) + } else { + config.job(name, definition) + } + } catch (error) { + // Silently skip jobs with validation errors + if (options.verbose) { + // eslint-disable-next-line no-console + console.warn( + `⚠️ Skipped job '${name}': ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + } + } + + // Add includes reference (needed for resolveIncludes) + if (parsed.include) { + config.include(parsed.include as IncludeInput | IncludeInput[]) + } + + // Resolve includes AFTER adding local jobs + // Remote jobs will be merged with local ones using mergeExisting + let failedIncludes: string[] = [] + if (parsed.include) { + const result = await resolveIncludes(config, { + resolveReferences: true, + basePath: process.cwd(), + gitlabToken: token, + gitlabUrl, + verbose: options.verbose, + }) + failedIncludes = result.failedIncludes + } + + // Simulate pipeline + const simulator = new PipelineSimulator() + const result = simulator.simulate(config, context) + + // Warn about failed includes + if (failedIncludes.length > 0) { + // eslint-disable-next-line no-console + console.error("\n⚠️ WARNING: Failed to load remote includes") + // eslint-disable-next-line no-console + console.error("═".repeat(60)) + // eslint-disable-next-line no-console + console.error("The following includes could not be fetched:\n") + for (const inc of failedIncludes) { + // eslint-disable-next-line no-console + console.error(` ❌ ${inc}`) + } + // eslint-disable-next-line no-console + console.error( + "\nJobs that depend on templates from these includes may not run correctly.", + ) + // eslint-disable-next-line no-console + console.error( + "The simulation results may be incomplete. Consider downloading these files locally.\n", + ) + } + + // Output results + if (format === "json") { + // eslint-disable-next-line no-console + console.log(JSON.stringify(result, null, 2)) + } else if (format === "yaml") { + // eslint-disable-next-line no-console + console.log(yamlDump(result)) + } else if (format === "table") { + printTableOutput(result, options) + } else if (format === "summary") { + printSummaryOutput(result, options) + } else { + // Text format (legacy, same as summary) + printSummaryOutput(result, options) + } + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error:", error) + process.exit(1) + } + }) + + return program +} + +function printSummaryOutput( + result: SimulationResult, + options: { showSkipped: boolean; verbose: boolean }, +) { + // eslint-disable-next-line no-console + console.log("\n📊 Pipeline Simulation Result\n") + // eslint-disable-next-line no-console + console.log("═".repeat(60)) + + // Job statistics + const runningJobs = result.jobs.filter((j) => j.shouldRun) + const skippedJobs = result.jobs.filter((j) => !j.shouldRun) + const manualJobs = runningJobs.filter((j) => j.when === "manual") + + // eslint-disable-next-line no-console + console.log(`Total Jobs: ${result.totalJobs}`) + // eslint-disable-next-line no-console + console.log(`Will Run: ${runningJobs.length}`) + // eslint-disable-next-line no-console + console.log(` - Automatic: ${runningJobs.length - manualJobs.length}`) + // eslint-disable-next-line no-console + console.log(` - Manual: ${manualJobs.length}`) + // eslint-disable-next-line no-console + console.log(`Will Skip: ${skippedJobs.length}`) + // eslint-disable-next-line no-console + console.log() + + // Collect stages from jobs + const stages = [...new Set(result.jobs.map((j) => j.stage))] + + // eslint-disable-next-line no-console + console.log("📋 Stages:") + // eslint-disable-next-line no-console + console.log("─".repeat(60)) + + for (const stage of stages) { + const stageJobs = runningJobs.filter((j) => j.stage === stage) + // eslint-disable-next-line no-console + console.log(` ${stage}: ${stageJobs.length} job(s)`) + } + + // eslint-disable-next-line no-console + console.log() + // eslint-disable-next-line no-console + console.log("🔧 Jobs:") + // eslint-disable-next-line no-console + console.log("─".repeat(60)) + + // Running jobs + for (const job of runningJobs) { + const icon = job.when === "manual" ? "⏸️" : "▶️" + const whenInfo = job.when === "manual" ? " [MANUAL]" : "" + // eslint-disable-next-line no-console + console.log(` ${icon} ${job.name}${whenInfo} (${job.stage})`) + if (options.verbose && job.reason) { + // eslint-disable-next-line no-console + console.log(` → ${job.reason}`) + } + } + + // Skipped jobs (if requested) + if (options.showSkipped && skippedJobs.length > 0) { + // eslint-disable-next-line no-console + console.log() + // eslint-disable-next-line no-console + console.log("⏭️ Skipped Jobs:") + for (const job of skippedJobs) { + // eslint-disable-next-line no-console + console.log(` ⊘ ${job.name} (${job.stage})`) + if (options.verbose && job.reason) { + // eslint-disable-next-line no-console + console.log(` → ${job.reason}`) + } + } + } + + // eslint-disable-next-line no-console + console.log() +} + +function printTableOutput( + result: SimulationResult, + options: { verbose: boolean; showSkipped: boolean }, +) { + const table = new ClimtTable() + table.column("Status", "status", { + width: 8, + maxWidth: 8, + overflow: "truncate", + align: "center", + }) + table.column("Job", "name", { width: 0, maxWidth: 50, overflow: "truncate", align: "left" }) + table.column("Stage", "stage", { width: 0, maxWidth: 30, overflow: "truncate", align: "left" }) + table.column("When", "when", { width: 12, maxWidth: 12, overflow: "truncate", align: "left" }) + if (options.verbose) { + table.column("Reason", "reason", { + width: 0, + maxWidth: 40, + overflow: "truncate", + align: "left", + }) + } + + // Filter and sort jobs + let jobs = options.showSkipped ? result.jobs : result.jobs.filter((j) => j.shouldRun) + + // Sort by stage order from config, then by name + const stageOrder: string[] = result.stages + jobs = jobs.sort((a, b) => { + const stageIndexA: number = stageOrder.indexOf(a.stage) + const stageIndexB: number = stageOrder.indexOf(b.stage) + + const isAInConfig = stageIndexA !== -1 + const isBInConfig = stageIndexB !== -1 + + // Both stages in config: sort by config order + if (isAInConfig && isBInConfig) { + if (stageIndexA !== stageIndexB) return stageIndexA - stageIndexB + return a.name.localeCompare(b.name) + } + + // Only A in config: A comes first + if (isAInConfig && !isBInConfig) return -1 + + // Only B in config: B comes first + if (!isAInConfig && isBInConfig) return 1 + + // Neither in config: sort by stage name, then job name + const stageCompare = a.stage.localeCompare(b.stage) + if (stageCompare !== 0) return stageCompare + return a.name.localeCompare(b.name) + }) + + const tableData = jobs.map((job) => ({ + status: job.shouldRun ? "✓" : "⊘", + name: job.name, + stage: job.stage, + when: job.when, + reason: options.verbose ? (job.reason ?? "") : undefined, + })) + + table.render(tableData) +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 372a0b3..8a3b348 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,6 +2,7 @@ import { Command } from "@commander-js/extra-typings" import { readPackage } from "read-pkg" +import simulateCommand from "./commands/simulate" import visualizeCommand from "./commands/visualize" async function main() { @@ -11,6 +12,7 @@ async function main() { .name("gitlab-ci-builder") .description("GitLab CI Pipeline Builder and Visualizer") .addCommand(visualizeCommand()) + .addCommand(simulateCommand()) .version(pkg.version) diff --git a/src/generated/types.ts b/src/generated/types.ts index 03110d4..211af59 100644 --- a/src/generated/types.ts +++ b/src/generated/types.ts @@ -111,7 +111,7 @@ export interface Artifacts { /** * @see https://docs.gitlab.com/ci/yaml/artifacts_reports/#artifactsreportsannotations */ - annotations?: string + annotations?: string | string[] /** * @see https://docs.gitlab.com/ci/pipelines/job_artifacts.html#artifactsreportsjunit */ @@ -386,6 +386,29 @@ export interface BaseJob { | ("always" | "never" | "if-not-present")[] variables?: Record } + | ( + | string + | { + name: string + alias?: string + entrypoint?: string[] + command?: string[] + docker?: { + platform?: string + user?: string + } + kubernetes?: { + user?: string | number + } + /** + * @see https://docs.gitlab.com/ci/yaml/#imagepull_policy + */ + pull_policy?: + | ("always" | "never" | "if-not-present") + | ("always" | "never" | "if-not-present")[] + variables?: Record + } + )[] )[] /** * @see https://docs.gitlab.com/ci/yaml/#tags @@ -503,7 +526,7 @@ export interface BaseJob { /** * @see https://docs.gitlab.com/ci/yaml/artifacts_reports/#artifactsreportsannotations */ - annotations?: string + annotations?: string | string[] /** * @see https://docs.gitlab.com/ci/pipelines/job_artifacts.html#artifactsreportsjunit */ @@ -1599,6 +1622,29 @@ export interface Defaults { | ("always" | "never" | "if-not-present")[] variables?: Record } + | ( + | string + | { + name: string + alias?: string + entrypoint?: string[] + command?: string[] + docker?: { + platform?: string + user?: string + } + kubernetes?: { + user?: string | number + } + /** + * @see https://docs.gitlab.com/ci/yaml/#imagepull_policy + */ + pull_policy?: + | ("always" | "never" | "if-not-present") + | ("always" | "never" | "if-not-present")[] + variables?: Record + } + )[] )[] /** * @see https://docs.gitlab.com/ci/yaml/#before_script @@ -1656,7 +1702,7 @@ export interface Defaults { /** * @see https://docs.gitlab.com/ci/yaml/artifacts_reports/#artifactsreportsannotations */ - annotations?: string + annotations?: string | string[] /** * @see https://docs.gitlab.com/ci/pipelines/job_artifacts.html#artifactsreportsjunit */ @@ -1974,6 +2020,29 @@ export type Service = | ("always" | "never" | "if-not-present")[] variables?: Record } + | ( + | string + | { + name: string + alias?: string + entrypoint?: string[] + command?: string[] + docker?: { + platform?: string + user?: string + } + kubernetes?: { + user?: string | number + } + /** + * @see https://docs.gitlab.com/ci/yaml/#imagepull_policy + */ + pull_policy?: + | ("always" | "never" | "if-not-present") + | ("always" | "never" | "if-not-present")[] + variables?: Record + } + )[] /** * @see https://docs.gitlab.com/ci/yaml/#tags diff --git a/src/importer/ts-factory/object-formatter.ts b/src/importer/ts-factory/object-formatter.ts index b29b41f..027f3d0 100644 --- a/src/importer/ts-factory/object-formatter.ts +++ b/src/importer/ts-factory/object-formatter.ts @@ -1,12 +1,8 @@ import ts from "typescript" import { valueToExpression } from "./ast-helpers" -import { formatScriptProperty, SCRIPT_PROPERTIES } from "./script-formatter" - -/** - * Properties that should remain as single values if array has only one element - */ -const SINGLE_VALUE_PROPERTIES = ["extends", "image", "needs", "annotations", "dotenv"] as const +import { formatScriptProperty } from "./script-formatter" +import { SCRIPT_PROPERTIES, SINGLE_VALUE_PROPERTIES } from "./utils" /** * Check if a value is a plain object. diff --git a/src/importer/ts-factory/script-formatter.ts b/src/importer/ts-factory/script-formatter.ts index 22379fd..3950fc7 100644 --- a/src/importer/ts-factory/script-formatter.ts +++ b/src/importer/ts-factory/script-formatter.ts @@ -3,11 +3,6 @@ import ts from "typescript" import { createStringArray, createTemplateLiteral, valueToExpression } from "./ast-helpers" import { hasControlStructures, hasShellOperators } from "./utils" -/** - * Script properties that need special formatting - */ -export const SCRIPT_PROPERTIES = ["script", "before_script", "after_script"] as const - /** * Format a script value as AST expression. * @@ -61,7 +56,7 @@ export function formatScriptValue(value: unknown): ts.Expression { * Processes arrays of script commands, expanding multi-line strings into * individual commands when appropriate. * - * @param items - Array of script items (strings or other values) + * @param items - Array of script items to flatten * @returns TypeScript ArrayLiteralExpression AST node * * @example diff --git a/src/importer/ts-factory/utils.ts b/src/importer/ts-factory/utils.ts index a72b93c..72196cc 100644 --- a/src/importer/ts-factory/utils.ts +++ b/src/importer/ts-factory/utils.ts @@ -23,7 +23,7 @@ const SCRIPT_PROPERTIES = ["script", "before_script", "after_script"] as const /** * Properties that accept string | string[] but single values are more common */ -const SINGLE_VALUE_PROPERTIES = ["extends", "annotations", "dotenv"] as const +const SINGLE_VALUE_PROPERTIES = ["extends", "image", "needs", "annotations", "dotenv"] as const /** * Shell operator patterns that indicate script should stay as single string diff --git a/src/index.ts b/src/index.ts index c77b205..074dfb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,4 +6,5 @@ export * from "./builder" export * from "./serializer" export * from "./importer" export * from "./resolver" +export * from "./simulation" export { ConfigBuilder } from "./builder/ConfigBuilder" diff --git a/src/merge/rules.ts b/src/merge/rules.ts index b86467e..9cdd7f2 100644 --- a/src/merge/rules.ts +++ b/src/merge/rules.ts @@ -229,17 +229,37 @@ function mergeServices( if (!parent) return child if (!child) return parent + // Helper to get service name, handling nested arrays + const getServiceName = (service: (typeof parent)[number]): string => { + if (typeof service === "string") return service + if (Array.isArray(service)) { + // Nested array: extract first element + const first = service[0] as unknown + if (typeof first === "string") return first + if (typeof first === "object" && first && "name" in first) { + return String((first as { name: string }).name) + } + return "unknown" + } + // Handle objects without name property (from unvalidated remote jobs) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!service || typeof service !== "object" || !("name" in service)) { + return "unknown" + } + return service.name + } + const serviceMap = new Map() // Add parent services for (const service of parent) { - const name = typeof service === "string" ? service : service.name + const name = getServiceName(service) serviceMap.set(name, service) } // Add/override with child services for (const service of child) { - const name = typeof service === "string" ? service : service.name + const name = getServiceName(service) serviceMap.set(name, service) } diff --git a/src/model/pipeline.ts b/src/model/pipeline.ts index 7808937..9038edd 100644 --- a/src/model/pipeline.ts +++ b/src/model/pipeline.ts @@ -64,6 +64,7 @@ export class PipelineState { mergeExtends: true, mergeExisting: true, resolveTemplatesOnly: true, + mergeRemoteExtends: true, performanceMode: false, missingExtendsPolicy: "warn", ...globalOptions, diff --git a/src/resolution/visualization.ts b/src/resolution/visualization.ts index 4a88595..96087f6 100644 --- a/src/resolution/visualization.ts +++ b/src/resolution/visualization.ts @@ -138,11 +138,18 @@ export async function visualizeYaml( // Resolve includes BEFORE adding jobs/templates if (parsed.include) { - await resolveIncludes(config, { + const { failedIncludes } = await resolveIncludes(config, { gitlabToken: options.gitlabToken, gitlabUrl: options.gitlabUrl, resolveReferences: true, // Enable !reference resolution for visualization }) + + if (failedIncludes.length > 0) { + // eslint-disable-next-line no-console + console.warn( + `⚠️ Warning: ${failedIncludes.length} include(s) could not be loaded. Visualization may be incomplete.`, + ) + } } // Add variables if present diff --git a/src/resolver/builder.ts b/src/resolver/builder.ts index d77ce6c..d245925 100644 --- a/src/resolver/builder.ts +++ b/src/resolver/builder.ts @@ -97,6 +97,7 @@ export function resolveExtends( const jobOpts = context.jobOptions[name] const mergeExtends = jobOpts?.mergeExtends ?? globalOptions.mergeExtends const resolveTemplatesOnly = jobOpts?.resolveTemplatesOnly ?? globalOptions.resolveTemplatesOnly + const mergeRemoteExtends = globalOptions.mergeRemoteExtends // Start with empty definition let mergedDef: JobDefinitionNormalized = {} @@ -123,8 +124,8 @@ export function resolveExtends( if (!keptExtends.includes(nestedExtend)) { keptExtends.push(nestedExtend) } - } else if (nestedNode.isRemote) { - // Remote extend, keep it + } else if (nestedNode.isRemote && !mergeRemoteExtends) { + // Remote extend when mergeRemoteExtends is false, keep it if (!keptExtends.includes(nestedExtend)) { keptExtends.push(nestedExtend) } @@ -154,12 +155,11 @@ export function resolveExtends( } // Check if we should merge this extend - const shouldMerge = resolveTemplatesOnly ? targetName.startsWith(".") : true + let shouldMerge = resolveTemplatesOnly ? targetName.startsWith(".") : true - // Skip remote extends from merging - if (targetNode.isRemote) { - keptExtends.push(extendName) - continue + // Check if remote extends should be merged + if (targetNode.isRemote && !mergeRemoteExtends) { + shouldMerge = false } if (shouldMerge) { diff --git a/src/resolver/cli.ts b/src/resolver/cli.ts index 6b2b488..7c655b1 100644 --- a/src/resolver/cli.ts +++ b/src/resolver/cli.ts @@ -54,6 +54,8 @@ export interface ResolverOptions { basePath?: string /** Resolve !reference tags in YAML (needed for visualization, default: false) */ resolveReferences?: boolean + /** Show warnings for failed includes (default: false) */ + verbose?: boolean } /** @@ -99,11 +101,12 @@ interface ResolutionContext { export async function resolveIncludes( config: ConfigBuilder, options: ResolverOptions = {}, -): Promise { +): Promise<{ failedIncludes: string[] }> { const maxDepth = options.maxDepth ?? 10 const visited = new Set() const basePath = options.basePath ?? process.cwd() const context: ResolutionContext = { remoteItems: new Set() } + const failedIncludes: string[] = [] async function resolveRecursive(currentConfig: ConfigBuilder, depth = 0): Promise { if (depth >= maxDepth) { @@ -117,12 +120,22 @@ export async function resolveIncludes( const includes = Array.isArray(plain.include) ? plain.include : [plain.include] for (const include of includes) { - const content = await resolveInclude(include, basePath, options, visited) - if (!content) continue + const result = await resolveInclude(include, basePath, options, visited) + + // Track failed includes + const identifier = getIncludeIdentifier(include) + if (result === null && identifier && !visited.has(identifier)) { + if (!failedIncludes.includes(identifier)) { + failedIncludes.push(identifier) + } + } + + if (!result) continue // Parse the included YAML - const includedConfig = convertYamlToConfig(content, { + const includedConfig = convertYamlToConfig(result, { resolveReferences: options.resolveReferences, + verbose: options.verbose, }) // Recursively resolve nested includes first @@ -137,6 +150,22 @@ export async function resolveIncludes( // Mark all collected remote items after resolution is complete markRemoteItems(config, context.remoteItems) + + return { failedIncludes } +} + +/** + * Get a unique identifier for an include entry + */ +function getIncludeIdentifier(include: IncludeEntry): string | null { + if ("local" in include && include.local) return include.local + if ("remote" in include && include.remote) return include.remote + if ("project" in include && include.project && "file" in include && include.file) { + const file = Array.isArray(include.file) ? include.file[0] : include.file + return `${include.project}/${file}` + } + if ("template" in include && include.template) return include.template + return null } /** @@ -215,13 +244,21 @@ async function resolveInclude( const response = await fetch(include.remote, { headers }) if (!response.ok) { - throw new Error(`Failed to fetch remote include: ${include.remote}`) + // Always warn about failed remote includes + // eslint-disable-next-line no-console + console.warn( + `⚠️ Could not fetch remote include: ${include.remote} (${response.status} ${response.statusText})`, + ) + return null } return await response.text() } catch (error) { - throw new Error( - `Failed to fetch remote include: ${include.remote} - ${error instanceof Error ? error.message : String(error)}`, + // Always warn about failed remote includes + // eslint-disable-next-line no-console + console.warn( + `⚠️ Could not fetch remote include: ${include.remote} - ${error instanceof Error ? error.message : "Unknown error"}`, ) + return null } } @@ -313,9 +350,9 @@ async function resolveInclude( * // Returns: { stages: ['build', 'test'], jobs: { 'build-job': {...} } } * ``` */ -function convertYamlToConfig( +export function convertYamlToConfig( yamlContent: string, - options?: { resolveReferences?: boolean }, + options?: { resolveReferences?: boolean; verbose?: boolean }, ): ConfigBuilder { const parsed = parseYamlResolvable(yamlContent) @@ -354,6 +391,7 @@ function convertYamlToConfig( } // Add jobs and templates (mark as remote for visualization) + // For remote includes, be lenient with validation - just store the raw definitions for (const [name, definition] of Object.entries(resolved)) { if ( typeof definition === "object" && @@ -366,8 +404,11 @@ function convertYamlToConfig( } else { config.job(name, definition, { remote: true }) } - } catch { - // Silently skip jobs with validation errors during include resolution + } catch (error) { + // Skip remote jobs with validation errors - they may have complex schemas + // The { remote: true } option already uses relaxed validation via safeParse + // eslint-disable-next-line no-console + console.warn(`⚠️ Skipped remote job '${name}':`, error) } } } @@ -448,9 +489,14 @@ function mergeConfigs( } // Merge variables + // Remote variables should NOT override local variables + // Get current target variables first, then merge source variables underneath if (sourcePlain.variables) { + const targetPlain = target.getPlainObject({ skipValidation: true }) + const currentVars = targetPlain.variables ?? {} + // Merge order: source variables as base, target variables override // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument - target.variables(sourcePlain.variables as any) + target.variables({ ...sourcePlain.variables, ...currentVars } as any) } // Merge workflow @@ -464,14 +510,23 @@ function mergeConfigs( } // Merge jobs and templates from the jobs object - // Track remote items in context without marking during merge to allow proper resolution + // For remote jobs, use try-catch to be lenient with validation if (sourcePlain.jobs) { for (const [name, job] of Object.entries(sourcePlain.jobs)) { context.remoteItems.add(name) - if (name.startsWith(".")) { - target.template(name, job) - } else { - target.job(name, job) + try { + if (name.startsWith(".")) { + target.template(name, job, { remote: true }) + } else { + target.job(name, job, { remote: true }) + } + } catch (error) { + // Skip jobs with validation errors - they may have complex schemas + // we don't fully support yet (e.g., complex needs arrays) + // eslint-disable-next-line no-console + console.error(`❌ Skipping ${name}:`) + // eslint-disable-next-line no-console + console.error(error) } } } diff --git a/src/resolver/reference-resolver.ts b/src/resolver/reference-resolver.ts index 308f7b7..6b8f76a 100644 --- a/src/resolver/reference-resolver.ts +++ b/src/resolver/reference-resolver.ts @@ -95,7 +95,12 @@ function resolveReferencesInValue( ): unknown { // Handle arrays if (Array.isArray(value)) { - return value.map((item) => resolveReferencesInValue(item, parsed, visited)) + return value.flatMap((item) => { + const resolved = resolveReferencesInValue(item, parsed, visited) + // Flatten arrays from !reference tags (GitLab behavior) + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Array.isArray(resolved) ? resolved : [resolved] + }) } // Handle !reference tags diff --git a/src/schema/base.ts b/src/schema/base.ts index 4e1679f..5fa472b 100644 --- a/src/schema/base.ts +++ b/src/schema/base.ts @@ -177,7 +177,7 @@ export type Image = z.infer /** * Services definition with extended options */ -export const ServiceSchema = z +export const ServiceSchema: z.ZodType = z .union([ z.string(), z.object({ @@ -212,9 +212,32 @@ export const ServiceSchema = z }).optional(), variables: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), }), + z.lazy(() => z.array(z.union([z.string(), ServiceObjectSchema]))), ]) .meta({ description: "@see https://docs.gitlab.com/ci/yaml/#services" }) +export interface ServiceObject { + name: string + alias?: string + entrypoint?: string[] + command?: string[] + docker?: { platform?: string; user?: string } + kubernetes?: { user?: string | number } + pull_policy?: z.infer + variables?: Record +} + +export const ServiceObjectSchema = z.object({ + name: z.string(), + alias: z.string().optional(), + entrypoint: z.array(z.string()).optional(), + command: z.array(z.string()).optional(), + docker: z.object({ platform: z.string().optional(), user: z.string().optional() }).optional(), + kubernetes: z.object({ user: z.union([z.string(), z.number()]).optional() }).optional(), + pull_policy: PullPolicySchema.optional(), + variables: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), +}) + export const ServicesSchema = z.array(ServiceSchema) export type Service = z.infer diff --git a/src/schema/job.ts b/src/schema/job.ts index 02de669..fab8bf8 100644 --- a/src/schema/job.ts +++ b/src/schema/job.ts @@ -131,7 +131,7 @@ export const ArtifactsSchema = z .object({ accessibility: z.string().optional(), annotations: z - .string() + .union([z.string(), z.array(z.string())]) .meta({ description: "@see https://docs.gitlab.com/ci/yaml/artifacts_reports/#artifactsreportsannotations", @@ -347,12 +347,16 @@ export const BaseJobSchema = z.object({ ref: z.string(), artifacts: z.boolean().optional(), }), + // Fallback for complex needs definitions we don't fully support yet + z.record(z.string(), z.unknown()), ]), ), z.object({ pipeline: z.string(), optional: z.boolean().optional(), }), + // Fallback for any other needs format + z.record(z.string(), z.unknown()), ]) .meta({ description: "@see https://docs.gitlab.com/ci/yaml/#needs" }) .optional(), diff --git a/src/schema/policies.ts b/src/schema/policies.ts index a193276..36c255c 100644 --- a/src/schema/policies.ts +++ b/src/schema/policies.ts @@ -20,6 +20,9 @@ export const GlobalOptionsSchema = z.object({ /** If true, only merge templates (names starting with .). Default: true */ resolveTemplatesOnly: z.boolean().optional().default(true), + /** If true, merge remote extends (from remote includes). Default: true (resolve remote extends) */ + mergeRemoteExtends: z.boolean().optional().default(true), + /** If true, skip expensive validation checks (cycle detection, deep path scans). Default: false */ performanceMode: z.boolean().optional().default(false), diff --git a/src/serializer/yaml.ts b/src/serializer/yaml.ts index 2a10615..b42faf8 100644 --- a/src/serializer/yaml.ts +++ b/src/serializer/yaml.ts @@ -171,7 +171,7 @@ function postProcessReferences(yamlString: string): string { const line = lines[i] // Case 1: Check if this line contains a multiline !reference tag in an array - if (line && line.trim() === "- !reference") { + if (line?.trim() === "- !reference") { // Next two lines should contain the array elements const nextLine1 = lines[i + 1] const nextLine2 = lines[i + 2] @@ -290,10 +290,55 @@ function addSectionSeparators(yamlString: string): string { } /** - * Serialize a pipeline configuration to YAML string + * Serialize a pipeline configuration to a GitLab CI YAML string. + * + * This function converts a PipelineOutput object into a properly formatted + * GitLab CI YAML configuration string with: + * - Canonical key ordering (workflow, include, default, variables, stages, jobs) + * - Templates listed before regular jobs + * - Proper !reference tag formatting + * - Blank lines between sections for readability + * - Empty sections automatically omitted * * @param pipeline - The pipeline configuration to serialize - * @returns YAML string representation + * @returns Formatted GitLab CI YAML string + * + * @example + * ```ts + * import { serializeToYaml } from '@noxify/gitlab-ci-builder' + * + * const pipeline = { + * stages: ['build', 'test'], + * variables: { NODE_ENV: 'production' }, + * jobs: { + * '.base': { image: 'node:22' }, + * 'build': { + * extends: '.base', + * stage: 'build', + * script: ['npm run build'] + * } + * } + * } + * + * const yaml = serializeToYaml(pipeline) + * console.log(yaml) + * // Output: + * // stages: + * // - build + * // - test + * // + * // variables: + * // NODE_ENV: production + * // + * // .base: + * // image: node:22 + * // + * // build: + * // extends: .base + * // stage: build + * // script: + * // - npm run build + * ``` */ export function serializeToYaml(pipeline: PipelineOutput): string { // Process references before serialization diff --git a/src/simulation/index.ts b/src/simulation/index.ts new file mode 100644 index 0000000..201aa3e --- /dev/null +++ b/src/simulation/index.ts @@ -0,0 +1,2 @@ +export * from "./pipeline-simulator" +export * from "./rule-evaluator" diff --git a/src/simulation/pipeline-simulator.ts b/src/simulation/pipeline-simulator.ts new file mode 100644 index 0000000..4332c87 --- /dev/null +++ b/src/simulation/pipeline-simulator.ts @@ -0,0 +1,318 @@ +import type { ConfigBuilder } from "../builder/ConfigBuilder" +import type { JobDefinitionNormalized } from "../schema" +import type { RuleContext } from "./rule-evaluator" +import { resolveExtends } from "../resolver/builder" +import { RuleEvaluator } from "./rule-evaluator" + +/** + * Result of a pipeline simulation + */ +export interface SimulationResult { + jobs: JobSimulation[] + totalJobs: number + jobsToRun: number + jobsSkipped: number + stages: string[] +} + +/** + * Result of simulating a single job + */ +export interface JobSimulation { + name: string + stage: string + shouldRun: boolean + when: string + reason?: string +} + +/** + * Simulates a GitLab CI pipeline based on rules and context + */ +export class PipelineSimulator { + private readonly ruleEvaluator: RuleEvaluator + + constructor() { + this.ruleEvaluator = new RuleEvaluator() + } + + /** + * Simulate a GitLab CI pipeline execution with rule evaluation. + * + * This method evaluates which jobs would run in a pipeline based on the provided + * context (branch, variables, merge request status). It resolves all job extends, + * merges configurations, and evaluates job rules to determine execution status. + * + * @param config - The ConfigBuilder instance containing the pipeline definition + * @param context - The execution context with variables, branch, tags, and MR info + * @param context.variables - Pipeline variables (CI_* and custom variables) + * @param context.branch - Branch name (sets CI_COMMIT_BRANCH) + * @param context.tag - Tag name (sets CI_COMMIT_TAG) + * @param context.mergeRequestId - MR ID (sets CI_MERGE_REQUEST_ID) + * @param context.mergeRequestLabels - MR labels array + * @returns Simulation result with job execution status, stages, and skipped jobs + * + * @example + * ```ts + * import { ConfigBuilder, PipelineSimulator } from '@noxify/gitlab-ci-builder' + * + * const config = new ConfigBuilder() + * .stages('build', 'test', 'deploy') + * .job('build', { stage: 'build', script: ['npm run build'] }) + * .job('deploy', { + * stage: 'deploy', + * script: ['deploy.sh'], + * rules: [{ if: '$CI_COMMIT_BRANCH == "main"' }] + * }) + * + * const simulator = new PipelineSimulator() + * const result = simulator.simulate(config, { + * variables: { CI_COMMIT_BRANCH: 'main' }, + * branch: 'main' + * }) + * + * console.log(result.jobs) // Shows which jobs will run + * console.log(result.stages) // Stage execution summary + * ``` + */ + simulate(config: ConfigBuilder, context: RuleContext): SimulationResult { + const plain = config.getPlainObject({ skipValidation: true }) + const allJobs = plain.jobs ?? {} + const stages = plain.stages ?? ["test"] + + // Merge global pipeline variables into context + const globalVariables: Record = {} + if (plain.variables && typeof plain.variables === "object") { + for (const [key, val] of Object.entries(plain.variables)) { + if (val && typeof val === "object" && "value" in val) { + globalVariables[key] = String(val.value) + } else { + globalVariables[key] = String(val) + } + } + } + + // Global variables are available to all jobs + // Context variables (CI_* vars from command line) override global variables + const mergedContext: RuleContext = { + ...context, + variables: { + ...globalVariables, + ...context.variables, + }, + } + + // getPlainObject() resolves templates (resolveTemplatesOnly: true by default) + // but keeps job-to-job extends. For simulation, we need fully resolved jobs. + // Normalize extends back to arrays and resolve again with resolveTemplatesOnly: false + const jobs: Record = {} + const templates: Record = {} + + for (const [name, def] of Object.entries(allJobs)) { + const normalized = { ...def } as JobDefinitionNormalized + + // Normalize extends: string -> array for resolveExtends + if (normalized.extends && typeof normalized.extends === "string") { + normalized.extends = [normalized.extends] + } + + if (name.startsWith(".")) { + templates[name] = normalized + } else { + jobs[name] = normalized + } + } + + // Resolve job-to-job extends for complete job definitions + const { resolved: resolvedJobs } = resolveExtends( + jobs, + templates, + {}, // no job options + { + mergeExtends: true, + mergeExisting: true, + // IMPORTANT: resolve ALL extends, not just templates + resolveTemplatesOnly: false, + // IMPORTANT: merge remote extends for complete simulation + mergeRemoteExtends: true, + performanceMode: false, + missingExtendsPolicy: "ignore", + }, + ) + + const simulations: JobSimulation[] = [] + + // Helper to check if a job is a template (starts with .) + const isTemplate = (name: string): boolean => name.startsWith(".") + + // Helper to check if a job should be included in simulation + const shouldIncludeJob = (job: JobDefinitionNormalized): boolean => { + // A job must have at least one of these to be executable: + // - script or run (actual commands to execute) + // - trigger (child pipeline or multi-project pipeline) + // - needs with pipeline keyword (parent-child pipeline trigger) + // - release (create a GitLab release) + // - pages (GitLab Pages deployment) + if (job.script ?? job.run) return true + if (job.trigger) return true + if (job.release) return true + if (job.pages) return true + + // Check if this is a child pipeline trigger via needs + if (job.needs && Array.isArray(job.needs)) { + const hasPipelineTrigger = job.needs.some((need) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (typeof need === "object" && need !== null) { + return "pipeline" in need + } + return false + }) + if (hasPipelineTrigger) return true + } + + // Special case: Jobs that have a stage defined (not using default 'test') + // are likely real jobs where the script comes from remote includes + // Include them if they have any content beyond just the stage + if (job.stage && job.stage !== "test") { + // If the job has variables, rules, or other config, it's likely a real job + // being configured locally with the actual implementation in a remote include + const hasConfig = + (job.variables && Object.keys(job.variables).length > 0) ?? + (job.rules && job.rules.length > 0) ?? + job.image ?? + job.before_script ?? + job.after_script ?? + job.tags ?? + job.only ?? + job.except + if (hasConfig) return true + } + + // Jobs with only variables/stage/tags/etc and no other content + // are pure template jobs that are meant to be extended + return false + } + + // Process jobs in stage order + for (const stage of stages) { + const stageJobs = Object.entries(resolvedJobs) + .filter(([name, _job]) => !isTemplate(name)) + .filter(([_name, job]) => job.stage === stage) + .filter(([_name, job]) => shouldIncludeJob(job as JobDefinitionNormalized)) + + for (const [name, job] of stageJobs) { + const simulation = this.simulateJob(name, job as JobDefinitionNormalized, mergedContext) + simulations.push(simulation) + } + } + + // Add jobs without explicit stage + const jobsWithoutStage = Object.entries(resolvedJobs) + .filter(([name, _job]) => !isTemplate(name)) + .filter(([_name, job]) => !job.stage) + .filter(([_name, job]) => shouldIncludeJob(job as JobDefinitionNormalized)) + + for (const [name, job] of jobsWithoutStage) { + const simulation = this.simulateJob(name, job as JobDefinitionNormalized, mergedContext) + simulations.push(simulation) + } + + const jobsToRun = simulations.filter((s) => s.shouldRun).length + const jobsSkipped = simulations.length - jobsToRun + + return { + jobs: simulations, + totalJobs: simulations.length, + jobsToRun, + jobsSkipped, + stages, + } + } + + /** + * Simulate a single job + */ + private simulateJob( + name: string, + job: JobDefinitionNormalized, + context: RuleContext, + ): JobSimulation { + const stage = job.stage ?? "test" + + // Check if job has no script/run (incomplete job from includes) + const hasScript = Boolean(job.script ?? job.run) + if (!hasScript) { + return { + name, + stage, + shouldRun: false, + when: "never", + reason: "Job has no script (incomplete include merge)", + } + } + + // Merge job variables with global context variables + // Job variables override global variables (GitLab behavior) + const jobVariables: Record = {} + if (job.variables && typeof job.variables === "object") { + for (const [key, val] of Object.entries(job.variables)) { + // JobVariable can be string | number | boolean | { value: string, expand?: boolean } + if (val && typeof val === "object" && "value" in val) { + jobVariables[key] = String(val.value) + } else { + jobVariables[key] = String(val) + } + } + } + + const jobContext: RuleContext = { + ...context, + variables: { + ...context.variables, + ...jobVariables, + }, + } + + // Evaluate rules if present + if (job.rules && Array.isArray(job.rules)) { + const result = this.ruleEvaluator.evaluateRules(job.rules, jobContext) + return { + name, + stage, + shouldRun: result.shouldRun, + when: result.when, + reason: result.shouldRun ? undefined : "Rules didn't match", + } + } + + // Check only/except (legacy) + if (job.only) { + // Simplified: assume only doesn't match in simulation + return { + name, + stage, + shouldRun: false, + when: "never", + reason: "only: not supported in simulation", + } + } + + if (job.except) { + // Simplified: assume except doesn't match in simulation + return { + name, + stage, + shouldRun: true, + when: "on_success", + } + } + + // No rules - job runs by default + return { + name, + stage, + shouldRun: true, + when: "on_success", + } + } +} diff --git a/src/simulation/rule-evaluator.ts b/src/simulation/rule-evaluator.ts new file mode 100644 index 0000000..0f5423a --- /dev/null +++ b/src/simulation/rule-evaluator.ts @@ -0,0 +1,297 @@ +import { existsSync } from "node:fs" +import { resolve } from "node:path" + +import type { Rule } from "../schema/job" + +/** + * Context for rule evaluation + */ +export interface RuleContext { + variables: Record + changes?: string[] + mergeRequestLabels?: string[] + branch?: string + tag?: string + /** Base directory for resolving file paths in exists rules */ + basePath?: string +} + +/** + * Result of rule evaluation + * - match: Rule matched, job should run with specified when + * - no_match: Rule was evaluated but didn't match, try next rule + * - skip_unevaluable: Rule cannot be evaluated (e.g., exists without basePath, changes without git) + */ +export type RuleResult = + | { type: "match"; when?: string } + | { type: "no_match" } + | { type: "skip_unevaluable" } + +/** + * Evaluates GitLab CI rules against a given context + */ +export class RuleEvaluator { + /** + * Evaluate a single rule against the context + * + * @returns RuleResult with one of three types: + * - `match`: Rule matched, job should run with specified when clause + * - `no_match`: Rule was evaluated but conditions didn't match, try next rule + * - `skip_unevaluable`: Rule cannot be evaluated (e.g., exists without basePath, changes without git) + */ + evaluateRule(rule: Rule, context: RuleContext): RuleResult { + // Check exists condition - evaluate if basePath is provided + if (rule.exists) { + if (!context.basePath) { + // No basePath provided, cannot evaluate filesystem - skip this rule + return { type: "skip_unevaluable" } + } + + // Handle different exists formats + let paths: string[] + if (typeof rule.exists === "string") { + paths = [rule.exists] + } else if (Array.isArray(rule.exists)) { + paths = rule.exists + } else { + // Object format with paths, project, ref + paths = rule.exists.paths + } + + // Check if at least one file exists + const anyExists = paths.some((pattern) => { + const interpolatedPath = this.interpolateVariables(pattern, context.variables) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const absolutePath = resolve(context.basePath!, interpolatedPath) + return existsSync(absolutePath) + }) + + // If no files exist, this rule doesn't match - try next rule + if (!anyExists) { + return { type: "no_match" } + } + + // Files exist - now check if there's an if condition + if (!rule.if) { + // No if condition, just exists - rule matches + return { type: "match", when: rule.when } + } + + // Fall through to check if condition below + } + + // Check changes condition - skip rule in simulation (we don't have git context) + if (rule.changes) { + // In simulation, we cannot determine which files changed + // Skip this rule and try the next one + return { type: "skip_unevaluable" } + } + + // If no if condition and no exists, rule matches + if (!rule.if) { + return { type: "match", when: rule.when } + } + + // Simple variable check: $VAR_NAME == "value" or $VAR_NAME =~ /pattern/ + const ifCondition = rule.if.trim() + + // Handle negation + const isNegated = ifCondition.startsWith("!") + const condition = isNegated ? ifCondition.slice(1).trim() : ifCondition + + let matches = this.evaluateCondition(condition, context) + + if (isNegated) { + matches = !matches + } + + // If condition matches, rule matches with its when clause + // If condition doesn't match, try next rule + if (matches) { + return { type: "match", when: rule.when } + } + + return { type: "no_match" } + } + + /** + * Interpolate variables in a string (e.g., $VAR_NAME or ${VAR_NAME}) + */ + private interpolateVariables(str: string, variables: Record): string { + // Replace ${VAR_NAME} and $VAR_NAME with actual values + return str.replace(/\$\{?(\w+)\}?/g, (_match, varName) => { + return variables[varName as string] ?? "" + }) + } + + /** + * Evaluate if condition string + */ + private evaluateCondition(condition: string, context: RuleContext): boolean { + // Handle && operator (AND logic) + if (condition.includes(" && ")) { + const parts = condition.split(" && ") + return parts.every((part) => this.evaluateCondition(part.trim(), context)) + } + + // Handle || operator (OR logic) + if (condition.includes(" || ")) { + const parts = condition.split(" || ") + return parts.some((part) => this.evaluateCondition(part.trim(), context)) + } + + // $VAR_NAME == $OTHER_VAR (variable-to-variable comparison) + const varToVarMatchRegex = /\$(\w+)\s*==\s*\$(\w+)/ + const varToVarMatch = varToVarMatchRegex.exec(condition) + if (varToVarMatch) { + const var1 = varToVarMatch[1] + const var2 = varToVarMatch[2] + if (!var1 || !var2) return false + return context.variables[var1] === context.variables[var2] + } + + // $VAR_NAME != $OTHER_VAR (variable-to-variable not equal) + const varToVarNotMatchRegex = /\$(\w+)\s*!=\s*\$(\w+)/ + const varToVarNotMatch = varToVarNotMatchRegex.exec(condition) + if (varToVarNotMatch) { + const var1 = varToVarNotMatch[1] + const var2 = varToVarNotMatch[2] + if (!var1 || !var2) return false + return context.variables[var1] !== context.variables[var2] + } + + // $VAR_NAME == "value" + const exactMatchRegex = /\$(\w+)\s*==\s*["'](.+?)["']/ + const exactMatch = exactMatchRegex.exec(condition) + if (exactMatch) { + const varName = exactMatch[1] + const value = exactMatch[2] + if (!varName || !value) return false + return context.variables[varName] === value + } + + // $VAR_NAME != "value" + const notMatchRegex = /\$(\w+)\s*!=\s*["'](.+?)["']/ + const notMatch = notMatchRegex.exec(condition) + if (notMatch) { + const varName = notMatch[1] + const value = notMatch[2] + if (!varName || !value) return false + return context.variables[varName] !== value + } + + // $VAR_NAME =~ /pattern/i (regex match, case insensitive) + const regexMatchRegex = /\$(\w+)\s*=~\s*\/(.+?)\/([i]?)/ + const regexMatch = regexMatchRegex.exec(condition) + if (regexMatch) { + const varName = regexMatch[1] + const pattern = regexMatch[2] + const flags = regexMatch[3] + if (!varName || !pattern) return false + const value = context.variables[varName] ?? "" + const regex = new RegExp(pattern, flags) + return regex.test(value) + } + + // $VAR_NAME !~ /pattern/i (regex not match) + const regexNotMatchRegex = /\$(\w+)\s*!~\s*\/(.+?)\/([i]?)/ + const regexNotMatch = regexNotMatchRegex.exec(condition) + if (regexNotMatch) { + const varName = regexNotMatch[1] + const pattern = regexNotMatch[2] + const flags = regexNotMatch[3] + if (!varName || !pattern) return false + const value = context.variables[varName] ?? "" + const regex = new RegExp(pattern, flags) + return !regex.test(value) + } + + // $VAR_NAME (variable exists and is truthy) + const varExistsRegex = /^\$(\w+)$/ + const varExists = varExistsRegex.exec(condition) + if (varExists) { + const varName = varExists[1] + if (!varName) return false + const value = context.variables[varName] + return value !== undefined && value !== "" && value !== "false" && value !== "0" + } + + // $CI_COMMIT_BRANCH (special variables) + if (condition === "$CI_COMMIT_BRANCH" && context.branch) { + return true + } + + if (condition === "$CI_COMMIT_TAG" && context.tag) { + return true + } + + // $CI_MERGE_REQUEST_ID (merge request check) + if (condition === "$CI_MERGE_REQUEST_ID") { + return !!context.mergeRequestLabels + } + + // $CI_PIPELINE_SOURCE == "merge_request_event" + const pipelineSourceRegex = /\$CI_PIPELINE_SOURCE\s*==\s*["'](.+?)["']/ + const pipelineSource = pipelineSourceRegex.exec(condition) + if (pipelineSource) { + const [, source] = pipelineSource + if (source === "merge_request_event") { + return !!context.mergeRequestLabels + } + // For other sources, assume they don't match in simulation + return false + } + + // Default: assume condition doesn't match + return false + } + + /** + * Evaluate all rules and return the final when clause + */ + evaluateRules( + rules: Rule[] | undefined, + context: RuleContext, + ): { shouldRun: boolean; when: string } { + if (!rules || rules.length === 0) { + return { shouldRun: true, when: "on_success" } + } + + let hasEvaluableRules = false + + for (const rule of rules) { + const result = this.evaluateRule(rule, context) + + // Skip rules that cannot be evaluated (exists without basePath, changes without git) + if (result.type === "skip_unevaluable") { + continue + } + + // We found at least one evaluable rule (either matched or didn't match) + hasEvaluableRules = true + + // Rule didn't match, try next rule + if (result.type === "no_match") { + continue + } + + // Rule matched + const when = result.when ?? "on_success" + + if (when === "never") { + return { shouldRun: false, when: "never" } + } + + return { shouldRun: true, when } + } + + // If all rules were unevaluable (exists/changes), assume job should run + // This is the GitLab default behavior when rules can't be checked + if (!hasEvaluableRules) { + return { shouldRun: true, when: "on_success" } + } + + // All evaluable rules were checked but none matched - job doesn't run + return { shouldRun: false, when: "never" } + } +} diff --git a/tests/e2e/cli-simulate.test.ts b/tests/e2e/cli-simulate.test.ts new file mode 100644 index 0000000..c522ad1 --- /dev/null +++ b/tests/e2e/cli-simulate.test.ts @@ -0,0 +1,791 @@ +import { mkdir, rm, writeFile } from "fs/promises" +import { join } from "path" +import type { Session } from "tuistory" +import dedent from "dedent" +import { launchTerminal } from "tuistory" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +const CLI_PATH = join(process.cwd(), "dist", "cli", "index.mjs") +const TEST_DIR = join(process.cwd(), ".test-tmp") + +// Helper to clean up terminal output by removing excessive trailing newlines +function cleanOutput(text: string): string { + return text.trimEnd() + "\n" +} + +describe("CLI simulate command - E2E Tests", () => { + let session: Session | undefined + + beforeEach(async () => { + // Create temp directory for test files + await mkdir(TEST_DIR, { recursive: true }) + }) + + afterEach(async () => { + // Clean up session + if (session) { + session.close() + session = undefined + } + + // Clean up test files + await rm(TEST_DIR, { recursive: true, force: true }) + }) + + describe("Basic Command Usage", () => { + it("should display help text", async () => { + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", "--help"], + cols: 120, + rows: 30, + }) + + await session.waitForText("Simulate GitLab CI pipeline execution", { timeout: 5000 }) + + const output = cleanOutput(await session.text()) + + expect(output).toMatchInlineSnapshot(` + " + Usage: gitlab-ci-builder simulate [options] + + Simulate GitLab CI pipeline execution based on variables and rules + + Arguments: + path-or-url Path to .gitlab-ci.yml file or remote URL + + Options: + -v, --variable Set pipeline variables (can be used multiple times) + -b, --branch Simulate for specific branch + --tag Simulate for specific tag + --mr Simulate merge request pipeline + --mr-labels Merge request labels (comma-separated) + -f, --format Output format: text, json, yaml, table, summary (default: "summary") + --show-skipped Show skipped jobs in output (default: false) + --verbose Verbose output with detailed rule evaluation (default: false) + -t, --token Authentication token for private repositories (or use GITLAB_TOKEN env var) + --host GitLab host for project/template includes (or use GITLAB_HOST env var) (default: + "gitlab.com") + -h, --help display help for command + + Examples: + $ gitlab-ci-builder simulate .gitlab-ci.yml -b main + $ gitlab-ci-builder simulate .gitlab-ci.yml -v CI_COMMIT_BRANCH=main -v JOB_DISABLED=true + $ gitlab-ci-builder simulate pipeline.yml --branch develop --mr + $ gitlab-ci-builder simulate .gitlab-ci.yml -f table --show-skipped + $ gitlab-ci-builder simulate https://gitlab.com/org/repo/-/raw/main/.gitlab-ci.yml -t + $ gitlab-ci-builder simulate .gitlab-ci.yml -f json > simulation.json + " + `) + }) + + it("should show error for missing file", async () => { + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", "nonexistent.yml"], + cols: 120, + rows: 30, + }) + + await session.waitForText("Error", { timeout: 5000 }) + + const output = cleanOutput(await session.text()) + + // Check for error details without hardcoded paths + expect(output).toContain("Error") + expect(output).toContain("ENOENT") + expect(output).toContain("no such file or directory") + expect(output).toContain("nonexistent.yml") + expect(output).toContain("errno: -2") + expect(output).toContain("code: 'ENOENT'") + expect(output).toContain("syscall: 'open'") + }) + }) + + describe("Simple Pipeline Simulation", () => { + it("should simulate simple pipeline and show summary", async () => { + const yamlContent = dedent` + stages: + - build + - test + - deploy + + build-job: + stage: build + script: + - echo "Building..." + + test-job: + stage: test + script: + - echo "Testing..." + + deploy-job: + stage: deploy + script: + - echo "Deploying..." + ` + + const yamlPath = join(TEST_DIR, "simple-pipeline.yml") + await writeFile(yamlPath, yamlContent, "utf-8") + + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", yamlPath], + cols: 120, + rows: 30, + }) + + await session.waitForText("Pipeline Simulation Result", { timeout: 5000 }) + + const output = cleanOutput(await session.text()) + + expect(output).toMatchInlineSnapshot(` + " + + 📊 Pipeline Simulation Result + + ════════════════════════════════════════════════════════════ + Total Jobs: 3 + Will Run: 3 + - Automatic: 3 + - Manual: 0 + Will Skip: 0 + + 📋 Stages: + ──────────────────────────────────────────────────────────── + build: 1 job(s) + test: 1 job(s) + deploy: 1 job(s) + + 🔧 Jobs: + ──────────────────────────────────────────────────────────── + ▶ build-job (build) + ▶ test-job (test) + ▶ deploy-job (deploy) + " + `) + }) + + it("should output JSON format", async () => { + const yamlContent = dedent` + stages: + - build + + build-job: + stage: build + script: + - echo "Building..." + ` + + const yamlPath = join(TEST_DIR, "json-format.yml") + await writeFile(yamlPath, yamlContent, "utf-8") + + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", yamlPath, "-f", "json"], + cols: 120, + rows: 30, + }) + + await session.waitForText('"jobs"', { timeout: 5000 }) + + const output = await session.text() + + // Verify it's valid JSON + expect(output).toContain('"jobs"') + expect(output).toContain('"totalJobs"') + expect(output).toContain('"jobsToRun"') + expect(output).toContain('"jobsSkipped"') + + // Try to parse as JSON (should not throw) + const jsonMatch = /\{[\s\S]*\}/.exec(output) + expect(jsonMatch).toBeTruthy() + if (jsonMatch) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const parsed = JSON.parse(jsonMatch[0]) + expect(parsed).toHaveProperty("jobs") + expect(parsed).toHaveProperty("totalJobs") + } + }) + + it("should output table format", async () => { + const yamlContent = dedent` + stages: + - build + - test + + build-job: + stage: build + script: + - echo "Building..." + + test-job: + stage: test + script: + - echo "Testing..." + ` + + const yamlPath = join(TEST_DIR, "table-format.yml") + await writeFile(yamlPath, yamlContent, "utf-8") + + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", yamlPath, "-f", "table"], + cols: 120, + rows: 30, + }) + + await session.waitForText("build-job", { timeout: 5000 }) + + const output = cleanOutput(await session.text()) + + expect(output).toMatchInlineSnapshot(` + " + + Status | Job | Stage | When + --------+-----------+-------+------------ + ✓ | build-job | build | on_success + ✓ | test-job | test | on_success + " + `) + }) + }) + + describe("Branch-based Rules", () => { + it("should simulate with branch variable", async () => { + const yamlContent = dedent` + stages: + - build + - deploy + + build-main: + stage: build + script: + - echo "Building for main..." + rules: + - if: $CI_COMMIT_BRANCH == "main" + + build-develop: + stage: build + script: + - echo "Building for develop..." + rules: + - if: $CI_COMMIT_BRANCH == "develop" + + deploy: + stage: deploy + script: + - echo "Deploying..." + rules: + - if: $CI_COMMIT_BRANCH == "main" + ` + + const yamlPath = join(TEST_DIR, "branch-rules.yml") + await writeFile(yamlPath, yamlContent, "utf-8") + + // Simulate on main branch + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", yamlPath, "-b", "main"], + cols: 120, + rows: 30, + }) + + await session.waitForText("Pipeline Simulation Result", { timeout: 5000 }) + + const mainOutput = cleanOutput(await session.text()) + + expect(mainOutput).toMatchInlineSnapshot(` + " + + 📊 Pipeline Simulation Result + + ════════════════════════════════════════════════════════════ + Total Jobs: 3 + Will Run: 2 + - Automatic: 2 + - Manual: 0 + Will Skip: 1 + + 📋 Stages: + ──────────────────────────────────────────────────────────── + build: 1 job(s) + deploy: 1 job(s) + + 🔧 Jobs: + ──────────────────────────────────────────────────────────── + ▶ build-main (build) + ▶ deploy (deploy) + " + `) + + session.close() + + // Simulate on develop branch + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", yamlPath, "-b", "develop"], + cols: 120, + rows: 30, + }) + + await session.waitForText("Pipeline Simulation Result", { timeout: 5000 }) + + const developOutput = cleanOutput(await session.text()) + + expect(developOutput).toMatchInlineSnapshot(` + " + + 📊 Pipeline Simulation Result + + ════════════════════════════════════════════════════════════ + Total Jobs: 3 + Will Run: 1 + - Automatic: 1 + - Manual: 0 + Will Skip: 2 + + 📋 Stages: + ──────────────────────────────────────────────────────────── + build: 1 job(s) + deploy: 0 job(s) + + 🔧 Jobs: + ──────────────────────────────────────────────────────────── + ▶ build-develop (build) + " + `) + }) + + it("should simulate with custom variables", async () => { + const yamlContent = dedent` + stages: + - build + + build-enabled: + stage: build + script: + - echo "Building..." + rules: + - if: $BUILD_ENABLED == "true" + + build-disabled: + stage: build + script: + - echo "This should not run..." + rules: + - if: $BUILD_ENABLED == "false" + ` + + const yamlPath = join(TEST_DIR, "custom-vars.yml") + await writeFile(yamlPath, yamlContent, "utf-8") + + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", yamlPath, "-v", "BUILD_ENABLED=true"], + cols: 120, + rows: 30, + }) + + await session.waitForText("Pipeline Simulation Result", { timeout: 5000 }) + + const output = cleanOutput(await session.text()) + + expect(output).toMatchInlineSnapshot(` + " + + 📊 Pipeline Simulation Result + + ════════════════════════════════════════════════════════════ + Total Jobs: 2 + Will Run: 1 + - Automatic: 1 + - Manual: 0 + Will Skip: 1 + + 📋 Stages: + ──────────────────────────────────────────────────────────── + build: 1 job(s) + + 🔧 Jobs: + ──────────────────────────────────────────────────────────── + ▶ build-enabled (build) + " + `) + }) + + it("should simulate merge request pipeline", async () => { + const yamlContent = dedent` + stages: + - test + + mr-only-job: + stage: test + script: + - echo "MR tests..." + rules: + - if: $CI_MERGE_REQUEST_ID + ` + + const yamlPath = join(TEST_DIR, "mr-pipeline.yml") + await writeFile(yamlPath, yamlContent, "utf-8") + + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", yamlPath, "--mr"], + cols: 120, + rows: 30, + }) + + await session.waitForText("Pipeline Simulation Result", { timeout: 5000 }) + + const output = cleanOutput(await session.text()) + + expect(output).toMatchInlineSnapshot(` + " + + 📊 Pipeline Simulation Result + + ════════════════════════════════════════════════════════════ + Total Jobs: 1 + Will Run: 1 + - Automatic: 1 + - Manual: 0 + Will Skip: 0 + + 📋 Stages: + ──────────────────────────────────────────────────────────── + test: 1 job(s) + + 🔧 Jobs: + ──────────────────────────────────────────────────────────── + ▶ mr-only-job (test) + " + `) + }) + }) + + describe("Output Options", () => { + it("should show skipped jobs with --show-skipped flag", async () => { + const yamlContent = dedent` + stages: + - build + + build-main: + stage: build + script: + - echo "Building..." + rules: + - if: $CI_COMMIT_BRANCH == "main" + + build-other: + stage: build + script: + - echo "Other build..." + rules: + - if: $CI_COMMIT_BRANCH == "other" + ` + + const yamlPath = join(TEST_DIR, "show-skipped.yml") + await writeFile(yamlPath, yamlContent, "utf-8") + + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", yamlPath, "-b", "main", "--show-skipped"], + cols: 120, + rows: 30, + }) + + await session.waitForText("Skipped Jobs", { timeout: 5000 }) + + const output = cleanOutput(await session.text()) + + expect(output).toMatchInlineSnapshot(` + " + + 📊 Pipeline Simulation Result + + ════════════════════════════════════════════════════════════ + Total Jobs: 2 + Will Run: 1 + - Automatic: 1 + - Manual: 0 + Will Skip: 1 + + 📋 Stages: + ──────────────────────────────────────────────────────────── + build: 1 job(s) + + 🔧 Jobs: + ──────────────────────────────────────────────────────────── + ▶ build-main (build) + + ⏭ Skipped Jobs: + ⊘ build-other (build) + " + `) + }) + + it("should show verbose output with rule evaluation", async () => { + const yamlContent = dedent` + stages: + - build + + build-job: + stage: build + script: + - echo "Building..." + rules: + - if: $CI_COMMIT_BRANCH == "main" + when: always + ` + + const yamlPath = join(TEST_DIR, "verbose.yml") + await writeFile(yamlPath, yamlContent, "utf-8") + + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", yamlPath, "-b", "main", "--verbose"], + cols: 120, + rows: 30, + }) + + await session.waitForText("Pipeline Simulation Result", { timeout: 5000 }) + + const output = cleanOutput(await session.text()) + + expect(output).toMatchInlineSnapshot(` + " + + 📊 Pipeline Simulation Result + + ════════════════════════════════════════════════════════════ + Total Jobs: 1 + Will Run: 1 + - Automatic: 1 + - Manual: 0 + Will Skip: 0 + + 📋 Stages: + ──────────────────────────────────────────────────────────── + build: 1 job(s) + + 🔧 Jobs: + ──────────────────────────────────────────────────────────── + ▶ build-job (build) + " + `) + }) + }) + + describe("Complex Scenarios", () => { + it("should handle manual jobs", async () => { + const yamlContent = dedent` + stages: + - deploy + + deploy-prod: + stage: deploy + script: + - echo "Deploying to production..." + rules: + - if: $CI_COMMIT_BRANCH == "main" + when: manual + ` + + const yamlPath = join(TEST_DIR, "manual-job.yml") + await writeFile(yamlPath, yamlContent, "utf-8") + + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", yamlPath, "-b", "main"], + cols: 120, + rows: 30, + }) + + await session.waitForText("Pipeline Simulation Result", { timeout: 5000 }) + + const output = cleanOutput(await session.text()) + + expect(output).toMatchInlineSnapshot(` + " + + 📊 Pipeline Simulation Result + + ════════════════════════════════════════════════════════════ + Total Jobs: 1 + Will Run: 1 + - Automatic: 0 + - Manual: 1 + Will Skip: 0 + + 📋 Stages: + ──────────────────────────────────────────────────────────── + deploy: 1 job(s) + + 🔧 Jobs: + ──────────────────────────────────────────────────────────── + ⏸ deploy-prod [MANUAL] (deploy) + " + `) + }) + + it("should handle regex patterns in rules", async () => { + const yamlContent = dedent` + stages: + - build + + feature-build: + stage: build + script: + - echo "Feature build..." + rules: + - if: $CI_COMMIT_BRANCH =~ /^feature-.+/ + ` + + const yamlPath = join(TEST_DIR, "regex-rules.yml") + await writeFile(yamlPath, yamlContent, "utf-8") + + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", yamlPath, "-b", "feature-new-ui"], + cols: 120, + rows: 30, + }) + + await session.waitForText("Pipeline Simulation Result", { timeout: 5000 }) + + const output = cleanOutput(await session.text()) + + expect(output).toMatchInlineSnapshot(` + " + + 📊 Pipeline Simulation Result + + ════════════════════════════════════════════════════════════ + Total Jobs: 1 + Will Run: 1 + - Automatic: 1 + - Manual: 0 + Will Skip: 0 + + 📋 Stages: + ──────────────────────────────────────────────────────────── + build: 1 job(s) + + 🔧 Jobs: + ──────────────────────────────────────────────────────────── + ▶ feature-build (build) + " + `) + }) + + it("should handle tag-based pipelines", async () => { + const yamlContent = dedent` + stages: + - release + + release-job: + stage: release + script: + - echo "Creating release..." + rules: + - if: $CI_COMMIT_TAG + ` + + const yamlPath = join(TEST_DIR, "tag-pipeline.yml") + await writeFile(yamlPath, yamlContent, "utf-8") + + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", yamlPath, "--tag", "v1.0.0"], + cols: 120, + rows: 30, + }) + + await session.waitForText("Pipeline Simulation Result", { timeout: 5000 }) + + const output = cleanOutput(await session.text()) + + expect(output).toMatchInlineSnapshot(` + " + + 📊 Pipeline Simulation Result + + ════════════════════════════════════════════════════════════ + Total Jobs: 1 + Will Run: 1 + - Automatic: 1 + - Manual: 0 + Will Skip: 0 + + 📋 Stages: + ──────────────────────────────────────────────────────────── + release: 1 job(s) + + 🔧 Jobs: + ──────────────────────────────────────────────────────────── + ▶ release-job (release) + " + `) + }) + }) + + describe("Error Handling", () => { + it("should handle invalid YAML gracefully", async () => { + const invalidYaml = dedent` + stages: + - build + invalid yaml content [[[ + ` + + const yamlPath = join(TEST_DIR, "invalid.yml") + await writeFile(yamlPath, invalidYaml, "utf-8") + + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", yamlPath], + cols: 120, + rows: 30, + }) + + await session.waitForText("Error", { timeout: 5000 }) + + const output = await session.text() + expect(output).toContain("Error") + }) + + it("should handle invalid format option", async () => { + const yamlContent = dedent` + stages: + - build + + build-job: + stage: build + script: + - echo "Building..." + ` + + const yamlPath = join(TEST_DIR, "invalid-format.yml") + await writeFile(yamlPath, yamlContent, "utf-8") + + session = await launchTerminal({ + command: "node", + args: [CLI_PATH, "simulate", yamlPath, "-f", "invalid-format"], + cols: 120, + rows: 30, + }) + + await session.waitForText("Invalid format", { timeout: 5000 }) + + const output = cleanOutput(await session.text()) + + expect(output).toMatchInlineSnapshot(` + " + Invalid format: invalid-format. Must be one of: text, json, yaml, table, summary + " + `) + }) + }) +}) diff --git a/tests/integration/cli/simulate.test.ts b/tests/integration/cli/simulate.test.ts new file mode 100644 index 0000000..fd8a8e3 --- /dev/null +++ b/tests/integration/cli/simulate.test.ts @@ -0,0 +1,1002 @@ +import dedent from "dedent" +import { http, HttpResponse } from "msw" +import { setupServer } from "msw/node" +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" + +import type { RuleContext } from "../../../src/simulation" +import { ConfigBuilder, convertYamlToConfig, resolveIncludes } from "../../../src" +import { PipelineSimulator } from "../../../src/simulation" + +const restHandlers = [ + // Level 1: Main remote template with nested includes + http.get("https://example.com/ci/level1.yml", () => { + return HttpResponse.text(dedent` + include: + - remote: https://example.com/ci/level2.yml + + .level1-base: + image: node:20 + variables: + LEVEL: "1" + `) + }), + + // Level 2: Nested template with further includes + http.get("https://example.com/ci/level2.yml", () => { + return HttpResponse.text(dedent` + include: + - remote: https://example.com/ci/level3.yml + + .level2-build: + stage: build + extends: .level1-base + script: + - npm run build + rules: + - if: $CI_COMMIT_BRANCH == "main" + `) + }), + + // Level 3: Deep nested template + http.get("https://example.com/ci/level3.yml", () => { + return HttpResponse.text(dedent` + include: + - remote: https://example.com/ci/level4.yml + + .level3-test: + stage: test + script: + - npm test + rules: + - if: $CI_COMMIT_BRANCH =~ /^(main|develop)$/ + `) + }), + + // Level 4: Deepest level + http.get("https://example.com/ci/level4.yml", () => { + return HttpResponse.text(dedent` + .level4-deploy: + stage: deploy + script: + - echo "Deploying..." + rules: + - if: $CI_COMMIT_BRANCH == "main" + - if: $CI_COMMIT_TAG + needs: + - build-app + `) + }), + + // Additional shared templates + http.get("https://example.com/ci/docker.yml", () => { + return HttpResponse.text(dedent` + .docker-base: + image: docker:latest + services: + - docker:dind + variables: + DOCKER_TLS_CERTDIR: "/certs" + before_script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + `) + }), + + http.get("https://example.com/ci/security.yml", () => { + return HttpResponse.text(dedent` + .security-scan: + stage: security + image: aquasec/trivy:latest + script: + - trivy image $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA + rules: + - if: $CI_MERGE_REQUEST_ID + - if: $CI_COMMIT_BRANCH == "main" + `) + }), +] + +const server = setupServer(...restHandlers) + +describe("Pipeline Simulation - Integration Tests", () => { + beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }) + }) + + afterEach(() => { + server.resetHandlers() + }) + + afterAll(() => { + server.close() + }) + + describe("Simple Pipeline", () => { + it("should simulate simple pipeline with three sequential stages", () => { + const yaml = dedent` + stages: + - build + - test + - deploy + + build-app: + stage: build + script: + - echo "Building application..." + - npm run build + + test-app: + stage: test + script: + - echo "Testing application..." + - npm test + + deploy-app: + stage: deploy + script: + - echo "Deploying application..." + - ./deploy.sh + ` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + + const context: RuleContext = { + variables: {}, + } + + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(3) + expect(result.jobsToRun).toBe(3) + expect(result.jobsSkipped).toBe(0) + expect(result.stages).toEqual(["build", "test", "deploy"]) + + const buildJob = result.jobs.find((j) => j.name === "build-app") + expect(buildJob).toBeDefined() + expect(buildJob?.shouldRun).toBe(true) + expect(buildJob?.stage).toBe("build") + + const testJob = result.jobs.find((j) => j.name === "test-app") + expect(testJob).toBeDefined() + expect(testJob?.shouldRun).toBe(true) + expect(testJob?.stage).toBe("test") + + const deployJob = result.jobs.find((j) => j.name === "deploy-app") + expect(deployJob).toBeDefined() + expect(deployJob?.shouldRun).toBe(true) + expect(deployJob?.stage).toBe("deploy") + + // Verify stage order + const jobNames = result.jobs.map((j) => j.name) + expect(jobNames).toEqual(["build-app", "test-app", "deploy-app"]) + }) + + it("should simulate pipeline with parallel jobs in same stage", () => { + const yaml = dedent` + stages: + - test + - deploy + + unit-tests: + stage: test + script: + - npm run test:unit + + integration-tests: + stage: test + script: + - npm run test:integration + + e2e-tests: + stage: test + script: + - npm run test:e2e + + deploy: + stage: deploy + script: + - echo "Deploying..." + ` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + + const context: RuleContext = { + variables: {}, + } + + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(4) + expect(result.jobsToRun).toBe(4) + expect(result.stages).toEqual(["test", "deploy"]) + + const testJobs = result.jobs.filter((j) => j.stage === "test") + expect(testJobs).toHaveLength(3) + testJobs.forEach((job) => { + expect(job.shouldRun).toBe(true) + }) + }) + }) + + describe("Advanced Pipeline with Rules", () => { + it("should simulate pipeline with branch-based rules", () => { + const yaml = dedent` + workflow: + rules: + - if: $CI_MERGE_REQUEST_ID + - if: $CI_COMMIT_BRANCH + + stages: + - build + - test + - deploy + + build-main: + stage: build + script: + - npm run build + rules: + - if: $CI_COMMIT_BRANCH == "main" + + build-develop: + stage: build + script: + - npm run build:dev + rules: + - if: $CI_COMMIT_BRANCH == "develop" + + test: + stage: test + script: + - npm test + rules: + - if: $CI_COMMIT_BRANCH =~ /^(main|develop)$/ + + deploy-production: + stage: deploy + script: + - ./deploy.sh production + rules: + - if: $CI_COMMIT_BRANCH == "main" + + deploy-staging: + stage: deploy + script: + - ./deploy.sh staging + rules: + - if: $CI_COMMIT_BRANCH == "develop" + ` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + + // Simulate on main branch + const mainContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "main", + }, + branch: "main", + } + + const mainResult = simulator.simulate(config, mainContext) + + expect(mainResult.totalJobs).toBe(5) + expect(mainResult.jobsToRun).toBe(3) // build-main, test, deploy-production + + const buildMain = mainResult.jobs.find((j) => j.name === "build-main") + expect(buildMain?.shouldRun).toBe(true) + + const buildDevelop = mainResult.jobs.find((j) => j.name === "build-develop") + expect(buildDevelop?.shouldRun).toBe(false) + + const test = mainResult.jobs.find((j) => j.name === "test") + expect(test?.shouldRun).toBe(true) + + const deployProd = mainResult.jobs.find((j) => j.name === "deploy-production") + expect(deployProd?.shouldRun).toBe(true) + + const deployStaging = mainResult.jobs.find((j) => j.name === "deploy-staging") + expect(deployStaging?.shouldRun).toBe(false) + + // Simulate on develop branch + const developContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "develop", + }, + branch: "develop", + } + + const developResult = simulator.simulate(config, developContext) + + expect(developResult.jobsToRun).toBe(3) // build-develop, test, deploy-staging + + const buildDevelopDev = developResult.jobs.find((j) => j.name === "build-develop") + expect(buildDevelopDev?.shouldRun).toBe(true) + + const deployStagingDev = developResult.jobs.find((j) => j.name === "deploy-staging") + expect(deployStagingDev?.shouldRun).toBe(true) + }) + + it("should respect workflow rules for merge requests", () => { + const yaml = dedent` + workflow: + rules: + - if: $CI_MERGE_REQUEST_ID + - if: $CI_COMMIT_BRANCH == "main" + - when: never + + stages: + - build + - test + + build: + stage: build + script: + - npm run build + + test: + stage: test + script: + - npm test + ` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + + // With merge request - pipeline should run + const mrContext: RuleContext = { + variables: { + CI_MERGE_REQUEST_ID: "123", + CI_COMMIT_BRANCH: "feature-branch", + }, + branch: "feature-branch", + } + + const mrResult = simulator.simulate(config, mrContext) + expect(mrResult.jobsToRun).toBe(2) + + // Without merge request on feature branch - should not run + // Note: Workflow rules are evaluated at pipeline level, not by simulator + // This test documents expected behavior when workflow conditions are met + const featureContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "feature-branch", + }, + branch: "feature-branch", + } + + const featureResult = simulator.simulate(config, featureContext) + // Jobs are still evaluated, but workflow would prevent pipeline execution + expect(featureResult.totalJobs).toBe(2) + }) + + it("should handle complex branch pattern rules", () => { + const yaml = dedent` + stages: + - build + - test + + build-feature: + stage: build + script: + - npm run build + rules: + - if: $CI_COMMIT_BRANCH =~ /^feature-.+/ + + build-hotfix: + stage: build + script: + - npm run build + rules: + - if: $CI_COMMIT_BRANCH =~ /^hotfix-/ + + test-all: + stage: test + script: + - npm test + rules: + - if: $CI_COMMIT_BRANCH =~ /^(feature|hotfix|main|develop)/ + ` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + + // Feature branch + const featureContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "feature-new-ui", + }, + branch: "feature-new-ui", + } + + const featureResult = simulator.simulate(config, featureContext) + expect(featureResult.jobsToRun).toBeGreaterThanOrEqual(1) // At least build-feature + + const buildFeature = featureResult.jobs.find((j) => j.name === "build-feature") + expect(buildFeature?.shouldRun).toBe(true) + + // Hotfix branch + const hotfixContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "hotfix-urgent-fix", + }, + branch: "hotfix-urgent-fix", + } + + const hotfixResult = simulator.simulate(config, hotfixContext) + expect(hotfixResult.jobsToRun).toBeGreaterThanOrEqual(1) // At least build-hotfix or test-all + + const buildHotfix = hotfixResult.jobs.find((j) => j.name === "build-hotfix") + expect(buildHotfix?.shouldRun).toBe(true) + }) + + it("should handle manual jobs with rules", () => { + const yaml = dedent` + stages: + - build + - deploy + + build: + stage: build + script: + - npm run build + + deploy-staging: + stage: deploy + script: + - ./deploy.sh staging + rules: + - if: $CI_COMMIT_BRANCH == "develop" + when: manual + + deploy-production: + stage: deploy + script: + - ./deploy.sh production + rules: + - if: $CI_COMMIT_BRANCH == "main" + when: manual + ` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + + const mainContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "main", + }, + branch: "main", + } + + const result = simulator.simulate(config, mainContext) + + const deployProd = result.jobs.find((j) => j.name === "deploy-production") + expect(deployProd?.shouldRun).toBe(true) + expect(deployProd?.when).toBe("manual") + + const deployStaging = result.jobs.find((j) => j.name === "deploy-staging") + expect(deployStaging?.shouldRun).toBe(false) + }) + }) + + describe("Complex Pipeline with Remote Includes", () => { + it("should simulate pipeline with nested remote includes (4 levels)", async () => { + const yaml = dedent` + include: + - remote: https://example.com/ci/level1.yml + + stages: + - build + - test + - deploy + + build-app: + stage: build + extends: .level2-build + script: + - npm run build + rules: + - if: $CI_COMMIT_BRANCH == "main" + + test-app: + stage: test + extends: .level3-test + script: + - npm test + rules: + - if: $CI_COMMIT_BRANCH =~ /^(main|develop)$/ + + deploy-app: + stage: deploy + extends: .level4-deploy + script: + - echo "Deploying..." + rules: + - if: $CI_COMMIT_BRANCH == "main" + - if: $CI_COMMIT_TAG + needs: + - build-app + ` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + await resolveIncludes(config, { + resolveReferences: true, + basePath: process.cwd(), + }) + + const simulator = new PipelineSimulator() + + // Simulate on main branch + const mainContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "main", + }, + branch: "main", + } + + const mainResult = simulator.simulate(config, mainContext) + + // All jobs should be resolved and have scripts from templates + expect(mainResult.totalJobs).toBeGreaterThanOrEqual(3) + expect(mainResult.jobsToRun).toBeGreaterThanOrEqual(2) // At least build and test on main + + const buildApp = mainResult.jobs.find((j) => j.name === "build-app") + if (buildApp) { + expect(buildApp.shouldRun).toBe(true) + expect(buildApp.stage).toBe("build") + } + + const testApp = mainResult.jobs.find((j) => j.name === "test-app") + if (testApp) { + expect(testApp.shouldRun).toBe(true) + expect(testApp.stage).toBe("test") + } + + // Simulate on develop branch + const developContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "develop", + }, + branch: "develop", + } + + const developResult = simulator.simulate(config, developContext) + + // On develop: build-app should be skipped (rule: main only), test-app should run (rule: main|develop) + const buildAppDev = developResult.jobs.find((j) => j.name === "build-app") + expect(buildAppDev).toBeDefined() + expect(buildAppDev?.shouldRun).toBe(false) + + const testAppDev = developResult.jobs.find((j) => j.name === "test-app") + expect(testAppDev).toBeDefined() + expect(testAppDev?.shouldRun).toBe(true) + }) + + it("should simulate complex pipeline with multiple remote includes and job dependencies", async () => { + const yaml = dedent` + include: + - remote: https://example.com/ci/level1.yml + - remote: https://example.com/ci/docker.yml + - remote: https://example.com/ci/security.yml + + stages: + - build + - test + - security + - deploy + + build-docker-image: + extends: .docker-base + stage: build + script: + - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . + - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA + rules: + - if: $CI_COMMIT_BRANCH == "main" + - if: $CI_MERGE_REQUEST_ID + + test-app: + extends: .level3-test + needs: + - build-docker-image + + security-scan: + extends: .security-scan + needs: + - build-docker-image + + deploy-production: + extends: .level4-deploy + needs: + - test-app + - security-scan + ` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + await resolveIncludes(config, { + resolveReferences: true, + basePath: process.cwd(), + }) + + const simulator = new PipelineSimulator() + + // Simulate on main branch + const mainContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "main", + CI_REGISTRY_IMAGE: "registry.example.com/app", + CI_COMMIT_SHA: "abc123", + }, + branch: "main", + } + + const mainResult = simulator.simulate(config, mainContext) + + expect(mainResult.jobsToRun).toBeGreaterThan(0) + + const buildDocker = mainResult.jobs.find((j) => j.name === "build-docker-image") + expect(buildDocker?.shouldRun).toBe(true) + + // Security scan should run on main (rule: CI_MERGE_REQUEST_ID or main) + const securityScan = mainResult.jobs.find((j) => j.name === "security-scan") + if (securityScan) { + expect(securityScan.shouldRun).toBe(true) + } + + // Simulate with merge request + const mrContext: RuleContext = { + variables: { + CI_MERGE_REQUEST_ID: "456", + CI_COMMIT_BRANCH: "feature-branch", + CI_REGISTRY_IMAGE: "registry.example.com/app", + CI_COMMIT_SHA: "def456", + }, + branch: "feature-branch", + } + + const mrResult = simulator.simulate(config, mrContext) + + const buildDockerMr = mrResult.jobs.find((j) => j.name === "build-docker-image") + expect(buildDockerMr?.shouldRun).toBe(true) + + const securityScanMr = mrResult.jobs.find((j) => j.name === "security-scan") + if (securityScanMr) { + expect(securityScanMr.shouldRun).toBe(true) + } + }) + + it("should handle deeply nested includes with template inheritance", async () => { + const yaml = dedent` + include: + - remote: https://example.com/ci/level1.yml + + stages: + - build + - test + - deploy + + # Job extending level2 template which extends level1 + build-with-inheritance: + extends: .level2-build + script: + - echo "Custom build step" + - npm run build + variables: + CUSTOM_VAR: "custom-value" + + # Job extending level3 template + test-with-custom-rules: + extends: .level3-test + rules: + - if: $CI_COMMIT_BRANCH =~ /^(main|develop|feature-.+)$/ + when: always + - when: never + + # Job extending level4 template + deploy-with-needs: + extends: .level4-deploy + needs: + - build-with-inheritance + - test-with-custom-rules + ` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + await resolveIncludes(config, { + resolveReferences: true, + basePath: process.cwd(), + }) + + const simulator = new PipelineSimulator() + + // Test on main branch + const mainContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "main", + }, + branch: "main", + } + + const mainResult = simulator.simulate(config, mainContext) + + const build = mainResult.jobs.find((j) => j.name === "build-with-inheritance") + if (build) { + expect(build.shouldRun).toBe(true) + } + + const test = mainResult.jobs.find((j) => j.name === "test-with-custom-rules") + if (test) { + expect(test.shouldRun).toBe(true) + } + + const deploy = mainResult.jobs.find((j) => j.name === "deploy-with-needs") + if (deploy) { + expect(deploy.shouldRun).toBe(true) + } + + // Test on feature branch + const featureContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "feature-new-feature", + }, + branch: "feature-new-feature", + } + + const featureResult = simulator.simulate(config, featureContext) + + const buildFeature = featureResult.jobs.find((j) => j.name === "build-with-inheritance") + if (buildFeature) { + // build-with-inheritance extends .level2-build which has rules: if branch == "main" + // On feature branch, rules don't match, so job should not run + expect(buildFeature.shouldRun).toBe(false) + } + + const testFeature = featureResult.jobs.find((j) => j.name === "test-with-custom-rules") + if (testFeature) { + expect(testFeature.shouldRun).toBe(true) // Custom rule matches feature branches + } + + const deployFeature = featureResult.jobs.find((j) => j.name === "deploy-with-needs") + if (deployFeature) { + expect(deployFeature.shouldRun).toBe(false) // Level4 rule: only main or tag + } + }) + + it("should simulate pipeline with remote includes and variable overrides", async () => { + const yaml = dedent` + include: + - remote: https://example.com/ci/level1.yml + + variables: + GLOBAL_VAR: "global-value" + OVERRIDE_VAR: "overridden" + + stages: + - build + - test + + build: + extends: .level1-base + stage: build + script: + - echo "Building with LEVEL=$LEVEL" + - npm run build + variables: + JOB_VAR: "job-value" + rules: + - if: $CI_COMMIT_BRANCH + + test: + extends: .level3-test + variables: + TEST_VAR: "test-value" + ` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + await resolveIncludes(config, { + resolveReferences: true, + basePath: process.cwd(), + }) + + const simulator = new PipelineSimulator() + + const context: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "main", + }, + branch: "main", + } + + const result = simulator.simulate(config, context) + + expect(result.jobsToRun).toBeGreaterThan(0) + + const buildJob = result.jobs.find((j) => j.name === "build") + expect(buildJob?.shouldRun).toBe(true) + }) + }) + + describe("Edge Cases", () => { + it("should handle empty rules array", () => { + const yaml = dedent` + stages: + - build + + build: + stage: build + script: + - echo "Building" + rules: [] + ` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + + const result = simulator.simulate(config, { + variables: {}, + }) + + const buildJob = result.jobs.find((j) => j.name === "build") + expect(buildJob?.shouldRun).toBe(true) // Empty rules allows job to run by default + }) + + it("should handle jobs with only rules: [when: never]", () => { + const yaml = dedent` + stages: + - build + - test + + build: + stage: build + script: + - npm run build + + test-disabled: + stage: test + script: + - npm test + rules: + - when: never + ` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + + const result = simulator.simulate(config, { + variables: {}, + }) + + expect(result.totalJobs).toBe(2) + expect(result.jobsToRun).toBe(1) + + const testJob = result.jobs.find((j) => j.name === "test-disabled") + expect(testJob?.shouldRun).toBe(false) + }) + + it("should handle multiple rules with different outcomes", () => { + const yaml = dedent` + stages: + - deploy + + deploy: + stage: deploy + script: + - ./deploy.sh + rules: + - if: $CI_COMMIT_TAG + when: always + - if: $CI_COMMIT_BRANCH == "main" + when: manual + - when: never + ` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + + // With tag - should run automatically + const tagContext: RuleContext = { + variables: { + CI_COMMIT_TAG: "v1.0.0", + }, + tag: "v1.0.0", + } + + const tagResult = simulator.simulate(config, tagContext) + const deployTag = tagResult.jobs.find((j) => j.name === "deploy") + expect(deployTag?.shouldRun).toBe(true) + expect(deployTag?.when).toBe("always") + + // On main - should be manual + const mainContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "main", + }, + branch: "main", + } + + const mainResult = simulator.simulate(config, mainContext) + const deployMain = mainResult.jobs.find((j) => j.name === "deploy") + expect(deployMain?.shouldRun).toBe(true) + expect(deployMain?.when).toBe("manual") + + // Other branch - should not run + const otherContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "feature", + }, + branch: "feature", + } + + const otherResult = simulator.simulate(config, otherContext) + const deployOther = otherResult.jobs.find((j) => j.name === "deploy") + expect(deployOther?.shouldRun).toBe(false) + }) + + it("should evaluate exists rules with filesystem checks", () => { + const config = new ConfigBuilder() + .stages("build", "test") + .job("build-with-package", { + stage: "build", + script: "npm run build", + rules: [{ exists: ["package.json"] }], + }) + .job("build-with-dockerfile", { + stage: "build", + script: "docker build .", + rules: [{ exists: ["Dockerfile"] }, { when: "never" }], + }) + .job("test", { + stage: "test", + script: "npm test", + }) + + const simulator = new PipelineSimulator() + + const context: RuleContext = { + variables: {}, + basePath: process.cwd(), // Project root contains package.json but no Dockerfile + } + + const result = simulator.simulate(config, context) + + // build-with-package should run (package.json exists) + const buildPackage = result.jobs.find((j) => j.name === "build-with-package") + expect(buildPackage?.shouldRun).toBe(true) + + // build-with-dockerfile should not run (Dockerfile doesn't exist, falls to when: never) + const buildDocker = result.jobs.find((j) => j.name === "build-with-dockerfile") + expect(buildDocker?.shouldRun).toBe(false) + + // test should run (no rules) + const test = result.jobs.find((j) => j.name === "test") + expect(test?.shouldRun).toBe(true) + }) + + it("should interpolate variables in exists paths", () => { + const config = new ConfigBuilder() + .stages("build") + .variables({ PROJECT_DIR: "src", FILE_NAME: "index.ts" }) + .job("build-app", { + stage: "build", + script: "build.sh", + rules: [{ exists: ["$PROJECT_DIR/$FILE_NAME"] }], + }) + + const simulator = new PipelineSimulator() + + const context: RuleContext = { + variables: { + PROJECT_DIR: "src", + FILE_NAME: "index.ts", + }, + basePath: process.cwd(), + } + + const result = simulator.simulate(config, context) + + // Should evaluate src/index.ts + const job = result.jobs[0] + expect(job?.shouldRun).toBe(true) + }) + }) +}) diff --git a/tests/unit/import.test.ts b/tests/unit/import.test.ts index 7c79a9e..b3c9d02 100644 --- a/tests/unit/import.test.ts +++ b/tests/unit/import.test.ts @@ -309,10 +309,10 @@ describe("YAML Import", () => { deploy: script: - | - if [ "$MANUAL_PROD_DEPLOYMENT" = "true" ]; then - echo "🚨 MANUAL PRODUCTION DEPLOYMENT TRIGGERED 🚨" + if [ "$MANUAL_DEPLOYMENT" = "true" ]; then + echo "🚨 MANUAL DEPLOYMENT TRIGGERED 🚨" else - echo "📦 Automated production deployment via changeset release" + echo "📦 Automated deployment via release" fi ` @@ -750,3 +750,105 @@ describe("YAML Import", () => { }) }) }) + +describe("Import - Nested Script Arrays", () => { + it("should flatten nested script arrays from !reference expansions", () => { + const yaml = dedent` + test-job: + script: + - echo "start" + - - echo "nested 1" + - echo "nested 2" + - echo "end" + ` + + const result = fromYaml(yaml) + + // Should flatten nested array - check for all strings being present + expect(result).toContain('echo \\"start\\"') + expect(result).toContain('echo \\"nested 1\\"') + expect(result).toContain('echo \\"nested 2\\"') + expect(result).toContain('echo \\"end\\"') + // Should NOT contain nested array syntax + expect(result).not.toContain("[[") + }) + + it("should handle deeply nested script arrays", () => { + const yaml = dedent` + test-job: + script: + - echo "level 0" + - - echo "level 1a" + - - echo "level 2" + - echo "level 2b" + - echo "level 1b" + - echo "level 0 end" + ` + + const result = fromYaml(yaml) + + // Should flatten all levels + expect(result).toContain('echo \\"level 0\\"') + expect(result).toContain('echo \\"level 1a\\"') + expect(result).toContain('echo \\"level 2\\"') + expect(result).toContain('echo \\"level 2b\\"') + expect(result).toContain('echo \\"level 1b\\"') + expect(result).toContain('echo \\"level 0 end\\"') + // Should NOT contain nested array syntax + expect(result).not.toContain("[[") + }) + + it("should flatten nested arrays in before_script", () => { + const yaml = dedent` + test-job: + before_script: + - echo "prep" + - - echo "nested prep 1" + - echo "nested prep 2" + script: + - echo "main" + ` + + const result = fromYaml(yaml) + + expect(result).toContain('echo \\"prep\\"') + expect(result).toContain('echo \\"nested prep 1\\"') + expect(result).toContain('echo \\"nested prep 2\\"') + expect(result).toContain('echo \\"main\\"') + }) + + it("should flatten nested arrays in after_script", () => { + const yaml = dedent` + test-job: + script: + - echo "main" + after_script: + - echo "cleanup" + - - echo "nested cleanup 1" + - echo "nested cleanup 2" + ` + + const result = fromYaml(yaml) + + expect(result).toContain('echo \\"main\\"') + expect(result).toContain('echo \\"cleanup\\"') + expect(result).toContain('echo \\"nested cleanup 1\\"') + expect(result).toContain('echo \\"nested cleanup 2\\"') + }) + + it("should handle empty nested arrays gracefully", () => { + const yaml = dedent` + test-job: + script: + - echo "start" + - [] + - echo "end" + ` + + const result = fromYaml(yaml) + + // Should skip empty nested array + expect(result).toContain('echo \\"start\\"') + expect(result).toContain('echo \\"end\\"') + }) +}) diff --git a/tests/unit/job-options.test.ts b/tests/unit/job-options.test.ts index e7ffb08..2a14125 100644 --- a/tests/unit/job-options.test.ts +++ b/tests/unit/job-options.test.ts @@ -18,18 +18,20 @@ describe("Job Options", () => { }) }) - it("should ignore remote templates when merging", () => { + it("should merge remote templates (changed behavior for !reference support)", () => { const config = new ConfigBuilder() config.template(".remote", { script: ["remote template"] }, { remote: true }) config.template(".base", { script: ["base"] }) config.job("child", { extends: [".remote", ".base"], stage: "test" }) const result = config.getPlainObject() + // Remote templates are now merged to support !reference tags expect(result.jobs?.child).toMatchObject({ - script: ["base"], + script: ["remote template", "base"], stage: "test", - extends: ".remote", }) + // extends should be resolved (no longer present) + expect(result.jobs?.child?.extends).toBeUndefined() }) }) describe("resolveTemplatesOnly option", () => { diff --git a/tests/unit/merge-rules.test.ts b/tests/unit/merge-rules.test.ts index ba6f6d0..20c80f7 100644 --- a/tests/unit/merge-rules.test.ts +++ b/tests/unit/merge-rules.test.ts @@ -47,7 +47,11 @@ describe("Merge Rules", () => { expect(job?.services).toHaveLength(3) // redis should be overridden by child const redis = job?.services?.find((s) => - typeof s === "string" ? s === "redis" : s.name === "redis", + typeof s === "string" + ? s === "redis" + : typeof s === "object" && !Array.isArray(s) + ? s.name === "redis" + : false, ) expect(redis).toEqual({ name: "redis", alias: "redis-cache" }) }) diff --git a/tests/unit/pipeline-simulator.test.ts b/tests/unit/pipeline-simulator.test.ts new file mode 100644 index 0000000..c96b93c --- /dev/null +++ b/tests/unit/pipeline-simulator.test.ts @@ -0,0 +1,327 @@ +import { vol } from "memfs" +import { beforeEach, describe, expect, test } from "vitest" + +import type { RuleContext } from "../../src/simulation" +import { ConfigBuilder } from "../../src" +import { PipelineSimulator } from "../../src/simulation" + +describe("Pipeline Simulator - Edge Cases", () => { + beforeEach(() => { + // Reset virtual filesystem + vol.reset() + }) + + describe("Job Detection", () => { + test("should include jobs with trigger (child pipeline)", () => { + const config = new ConfigBuilder().stages("deploy").job("trigger-child", { + stage: "deploy", + trigger: { + include: "child-pipeline.yml", + }, + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(1) + expect(result.jobs[0]?.name).toBe("trigger-child") + expect(result.jobs[0]?.shouldRun).toBe(false) // No rules means default behavior + }) + + test("should include jobs with release", () => { + const config = new ConfigBuilder().stages("release").job("create-release", { + stage: "release", + release: { + tag_name: "v1.0.0", + description: "Release notes", + }, + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(1) + expect(result.jobs[0]?.name).toBe("create-release") + }) + + test("should include jobs with pages", () => { + const config = new ConfigBuilder().stages("deploy").job("pages", { + stage: "deploy", + pages: { + path_prefix: "/docs", + }, + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(1) + expect(result.jobs[0]?.name).toBe("pages") + }) + + test("should include jobs with needs containing pipeline trigger", () => { + const config = new ConfigBuilder().stages("test").job("downstream-test", { + stage: "test", + script: "test.sh", + needs: [ + { + pipeline: "parent-pipeline", + }, + ], + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(1) + expect(result.jobs[0]?.name).toBe("downstream-test") + }) + + test("should exclude template jobs without script or content", () => { + const config = new ConfigBuilder().stages("test").job(".template", { + stage: "test", + tags: ["docker"], + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(0) + }) + + test("should include jobs with custom stage and configuration", () => { + const config = new ConfigBuilder().stages("custom").job("custom-job", { + stage: "custom", + variables: { + CUSTOM_VAR: "value", + }, + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(1) + expect(result.jobs[0]?.name).toBe("custom-job") + }) + + test("should include jobs with rules even without script", () => { + const config = new ConfigBuilder().stages("deploy").job("deploy-job", { + stage: "deploy", + rules: [{ if: '$CI_COMMIT_BRANCH == "main"' }], + image: "alpine:latest", + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { + variables: { CI_COMMIT_BRANCH: "main" }, + } + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(1) + expect(result.jobs[0]?.shouldRun).toBe(false) // Rules without script don't auto-run + }) + + test("should include jobs with image configuration", () => { + const config = new ConfigBuilder().stages("build").job("test-alpine", { + stage: "build", // Use non-default stage + image: "alpine:latest", + tags: ["docker"], + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(1) + }) + + test("should include jobs with before_script", () => { + const config = new ConfigBuilder().stages("build").job("build-with-setup", { + stage: "build", + before_script: ["echo setup"], + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(1) + }) + + test("should include jobs with after_script", () => { + const config = new ConfigBuilder().stages("deploy").job("test-with-cleanup", { + stage: "deploy", // Use non-default stage + after_script: ["echo cleanup"], + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(1) + }) + + test("should include jobs with only clause", () => { + const config = new ConfigBuilder().stages("deploy").job("deploy-prod", { + stage: "deploy", + only: ["main"], + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(1) + }) + + test("should include jobs with except clause", () => { + const config = new ConfigBuilder().stages("build").job("test-not-main", { + stage: "build", // Use non-default stage + except: ["main"], + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(1) + }) + }) + + describe("Stage Processing", () => { + test("should process jobs in stage order", () => { + const config = new ConfigBuilder() + .stages("build", "test", "deploy") + .job("deploy-job", { + stage: "deploy", + script: "deploy.sh", + }) + .job("build-job", { + stage: "build", + script: "build.sh", + }) + .job("test-job", { + stage: "test", + script: "test.sh", + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + const result = simulator.simulate(config, context) + + // Jobs should be ordered by stage + expect(result.jobs[0]?.name).toBe("build-job") + expect(result.jobs[0]?.stage).toBe("build") + expect(result.jobs[1]?.name).toBe("test-job") + expect(result.jobs[1]?.stage).toBe("test") + expect(result.jobs[2]?.name).toBe("deploy-job") + expect(result.jobs[2]?.stage).toBe("deploy") + }) + + test("should group jobs by stage in summary", () => { + const config = new ConfigBuilder() + .stages("build", "test") + .job("build-1", { + stage: "build", + script: "build.sh", + }) + .job("build-2", { + stage: "build", + script: "build2.sh", + }) + .job("test-1", { + stage: "test", + script: "test.sh", + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + const result = simulator.simulate(config, context) + + expect(result.stages).toEqual(["build", "test"]) + expect(result.totalJobs).toBe(3) + }) + }) + + describe("Job Variables", () => { + test("should merge job variables with context variables", () => { + const config = new ConfigBuilder().stages("test").job("test-vars", { + stage: "test", + script: "test.sh", + variables: { + JOB_VAR: "job-value", + }, + rules: [{ if: "$JOB_VAR" }], + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { + variables: { CONTEXT_VAR: "context-value" }, + } + const result = simulator.simulate(config, context) + + // Job should run because JOB_VAR is set + expect(result.jobs[0]?.shouldRun).toBe(true) + }) + + test("should prioritize job variables over context variables", () => { + const config = new ConfigBuilder().stages("test").job("test-priority", { + stage: "test", + script: "test.sh", + variables: { + SHARED_VAR: "job-override", + }, + rules: [{ if: '$SHARED_VAR == "job-override"' }], + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { + variables: { SHARED_VAR: "context-value" }, + } + const result = simulator.simulate(config, context) + + // Job should run because job variable overrides context + expect(result.jobs[0]?.shouldRun).toBe(true) + }) + }) + + describe("Empty Pipeline", () => { + test("should handle pipeline with no jobs", () => { + const config = new ConfigBuilder().stages("test") + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(0) + expect(result.jobsToRun).toBe(0) + expect(result.jobsSkipped).toBe(0) + expect(result.jobs).toEqual([]) + }) + + test("should handle pipeline with only template jobs", () => { + const config = new ConfigBuilder() + .stages("test") + .job(".template-1", { + stage: "test", + }) + .job(".template-2", { + stage: "test", + tags: ["docker"], + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + const result = simulator.simulate(config, context) + + // Templates without content should not be included + expect(result.totalJobs).toBe(0) + }) + }) +}) diff --git a/tests/unit/reference-array-flattening.test.ts b/tests/unit/reference-array-flattening.test.ts new file mode 100644 index 0000000..b867d39 --- /dev/null +++ b/tests/unit/reference-array-flattening.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest" + +import { convertYamlToConfig } from "../../src/resolver/cli" + +describe("Reference Array Flattening", () => { + it("should flatten arrays from !reference tags", () => { + const yaml = ` +.base_script: + script: + - echo 'first' + - echo 'second' + +test_job: + script: + - !reference [.base_script, script] + - echo 'third' +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const plain = config.getPlainObject({ skipValidation: true }) + const testJob = plain.jobs?.test_job + + expect(testJob).toBeDefined() + expect(testJob?.script).toBeDefined() + + // The script should be flattened, not nested + if (Array.isArray(testJob?.script)) { + expect(testJob.script).toEqual(["echo 'first'", "echo 'second'", "echo 'third'"]) + // Should NOT be: [["echo 'first'", "echo 'second'"], "echo 'third'"] + } else { + throw new Error("Script should be an array") + } + }) + + it("should flatten multiple !reference tags in the same array", () => { + const yaml = ` +.before: + script: + - echo 'before' + +.after: + script: + - echo 'after' + +test_job: + script: + - !reference [.before, script] + - echo 'middle' + - !reference [.after, script] +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const plain = config.getPlainObject({ skipValidation: true }) + const testJob = plain.jobs?.test_job + + expect(testJob).toBeDefined() + if (Array.isArray(testJob?.script)) { + expect(testJob.script).toEqual(["echo 'before'", "echo 'middle'", "echo 'after'"]) + } else { + throw new Error("Script should be an array") + } + }) + + it("should flatten complex before_script and after_script references", () => { + const yaml = ` +.secret_management: + before_script: + - echo 'load secrets' + - echo 'configure auth' + +.cleanup: + after_script: + - echo 'cleanup started' + +test_job: + before_script: + - !reference [.secret_management, before_script] + - echo 'custom setup' + script: + - echo 'main script' + after_script: + - echo 'custom cleanup' + - !reference [.cleanup, after_script] +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const plain = config.getPlainObject({ skipValidation: true }) + const testJob = plain.jobs?.test_job + + expect(testJob).toBeDefined() + + if (Array.isArray(testJob?.before_script)) { + expect(testJob.before_script).toEqual([ + "echo 'load secrets'", + "echo 'configure auth'", + "echo 'custom setup'", + ]) + } else { + throw new Error("before_script should be an array") + } + + if (Array.isArray(testJob.after_script)) { + expect(testJob.after_script).toEqual(["echo 'custom cleanup'", "echo 'cleanup started'"]) + } else { + throw new Error("after_script should be an array") + } + }) +}) diff --git a/tests/unit/reference-resolver.test.ts b/tests/unit/reference-resolver.test.ts new file mode 100644 index 0000000..4935e3c --- /dev/null +++ b/tests/unit/reference-resolver.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, test } from "vitest" + +import type { RuleContext } from "../../src/simulation" +import { convertYamlToConfig } from "../../src" +import { PipelineSimulator } from "../../src/simulation" + +describe("Reference Resolver - Edge Cases", () => { + describe("Invalid reference paths", () => { + test("should handle reference to non-existent path", () => { + const yaml = ` +.template: + script: + - echo "hello" + +job: + script: !reference [.nonexistent, script] +` + + // Should not crash + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + const result = simulator.simulate(config, {} as RuleContext) + + // Should successfully parse even with non-existent reference + expect(result.totalJobs).toBeGreaterThanOrEqual(0) + }) + + test("should handle reference to null value", () => { + const yaml = ` +.template: + value: null + +job: + script: !reference [.template, value] +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + const result = simulator.simulate(config, {} as RuleContext) + + expect(result.totalJobs).toBeGreaterThanOrEqual(0) + }) + + test("should handle reference with valid path", () => { + const yaml = ` +.template: + script: + - echo "test" + +job: + script: !reference [.template, script] +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + const result = simulator.simulate(config, {} as RuleContext) + + const job = result.jobs.find((j) => j.name === "job") + expect(job).toBeDefined() + // Reference successfully resolved (no crash) + expect(result.totalJobs).toBeGreaterThanOrEqual(1) + }) + + test("should handle reference to primitive when object expected", () => { + const yaml = ` +.template: + value: "string" + +job: + script: !reference [.template, value, nested] +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + const result = simulator.simulate(config, {} as RuleContext) + + // Should not crash, reference through primitive returns undefined + expect(result.totalJobs).toBeGreaterThanOrEqual(0) + }) + }) + + describe("Circular references", () => { + test("should detect circular reference between two jobs", () => { + const yaml = ` +.job-a: + script: !reference [.job-b, script] + +.job-b: + script: !reference [.job-a, script] + +test: + script: !reference [.job-a, script] +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + const result = simulator.simulate(config, {} as RuleContext) + + // Circular reference should be caught and not cause infinite loop + expect(result.totalJobs).toBeGreaterThanOrEqual(0) + }) + + test("should detect self-referencing job", () => { + const yaml = ` +.job: + script: !reference [.job, script] + +test: + script: !reference [.job, script] +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + const result = simulator.simulate(config, {} as RuleContext) + + // Self-reference should be caught + expect(result.totalJobs).toBeGreaterThanOrEqual(0) + }) + + test("should detect circular reference chain of three", () => { + const yaml = ` +.job-a: + script: !reference [.job-b, script] + +.job-b: + script: !reference [.job-c, script] + +.job-c: + script: !reference [.job-a, script] + +test: + script: !reference [.job-a, script] +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + const result = simulator.simulate(config, {} as RuleContext) + + // Three-way circular reference should be caught + expect(result.totalJobs).toBeGreaterThanOrEqual(0) + }) + + test("should handle circular reference in nested structures", () => { + const yaml = ` +.template: + before_script: + - !reference [.template, before_script] + +job: + before_script: !reference [.template, before_script] +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + const result = simulator.simulate(config, {} as RuleContext) + + // Circular reference in array should be detected + expect(result.totalJobs).toBeGreaterThanOrEqual(0) + }) + }) + + describe("Complex nested references", () => { + test("should resolve reference to deeply nested value", () => { + const yaml = ` +.template: + config: + deep: + nested: + value: "found" + +job: + variables: + RESULT: !reference [.template, config, deep, nested, value] + script: + - echo "test" +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + const result = simulator.simulate(config, {} as RuleContext) + + const job = result.jobs.find((j) => j.name === "job") + expect(job).toBeDefined() + }) + + test("should resolve multiple references in array", () => { + const yaml = ` +.base-before: + before_script: + - echo "base setup" + +.extra-before: + before_script: + - echo "extra setup" + +job: + before_script: + - !reference [.base-before, before_script] + - !reference [.extra-before, before_script] + - echo "job setup" + script: + - echo "main" +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + const result = simulator.simulate(config, {} as RuleContext) + + const job = result.jobs.find((j) => j.name === "job") + expect(job).toBeDefined() + // Multiple references successfully resolved (no crash) + expect(result.totalJobs).toBeGreaterThanOrEqual(1) + }) + + test("should resolve reference that itself contains references", () => { + const yaml = ` +.base: + script: + - echo "base" + +.extends-base: + script: !reference [.base, script] + +job: + script: !reference [.extends-base, script] +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + const result = simulator.simulate(config, {} as RuleContext) + + const job = result.jobs.find((j) => j.name === "job") + expect(job).toBeDefined() + // Nested references successfully resolved (no crash) + expect(result.totalJobs).toBeGreaterThanOrEqual(1) + }) + }) + + describe("Edge cases with null and undefined", () => { + test("should handle reference path through null object", () => { + const yaml = ` +.template: + config: null + +job: + value: !reference [.template, config, nested] + script: + - echo "test" +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + const result = simulator.simulate(config, {} as RuleContext) + + expect(result.totalJobs).toBeGreaterThanOrEqual(0) + }) + + test("should handle reference to array containing nulls", () => { + const yaml = ` +.template: + items: + - value1 + - null + - value2 + +job: + variables: + ITEMS: !reference [.template, items] + script: + - echo "test" +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const simulator = new PipelineSimulator() + const result = simulator.simulate(config, {} as RuleContext) + + expect(result.totalJobs).toBeGreaterThanOrEqual(0) + }) + }) +}) diff --git a/tests/unit/remote-extends-normalization.test.ts b/tests/unit/remote-extends-normalization.test.ts new file mode 100644 index 0000000..7d2de51 --- /dev/null +++ b/tests/unit/remote-extends-normalization.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest" + +import { ConfigBuilder } from "../../src" + +describe("ConfigBuilder - remote job extends normalization", () => { + it("should normalize string extends to array when safeParse fails for remote jobs", () => { + const config = new ConfigBuilder() + + // Add a remote job with string extends that fails validation + // (e.g., due to other validation errors unrelated to extends) + const remoteJobWithStringExtends = { + extends: ".template", + script: "echo test", + // Add an invalid property that causes validation to fail + invalidProperty: "this will cause safeParse to fail", + } + + // Add as remote job - this should handle string extends gracefully + config.job("remote-job", remoteJobWithStringExtends, { remote: true }) + + // Get the plain object + const plain = config.getPlainObject({ skipValidation: true }) + + // The extends should not be split into character array + expect(plain.jobs?.["remote-job"]?.extends).toBe(".template") + }) + + it("should not split string extends into character array during extends resolution", () => { + const config = new ConfigBuilder({ mergeExtends: false }) // Disable extends resolution + .template(".base", { + script: ["echo base"], + }) + .job( + "job-with-extends", + { + extends: ".base", + script: ["echo test"], + // Add invalid property to trigger safeParse failure + // @ts-expect-error - intentional invalid property + invalidProperty: "invalid", + }, + { remote: true, mergeExtends: false }, + ) + + const plain = config.getPlainObject({ skipValidation: true }) + + // Verify extends is not split into characters like ['.', 'b', 'a', 's', 'e'] + const extendsValue = plain.jobs?.["job-with-extends"]?.extends + expect(extendsValue).toBe(".base") + expect(typeof extendsValue).toBe("string") + + // Additional check: if it were wrongly split, it would be an array with length 5 + if (Array.isArray(extendsValue)) { + expect(extendsValue).not.toHaveLength(5) + expect(extendsValue).not.toEqual([".", "b", "a", "s", "e"]) + } + }) +}) diff --git a/tests/unit/remote-variables-override.test.ts b/tests/unit/remote-variables-override.test.ts new file mode 100644 index 0000000..48a8688 --- /dev/null +++ b/tests/unit/remote-variables-override.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest" + +import { convertYamlToConfig } from "../../src/resolver/cli" + +describe("Variable and Job Merge Order", () => { + it("should allow child definitions to override parent definitions in extends", () => { + const yaml = ` +.base: + variables: + SHARED_VAR: "parent-value" + PARENT_ONLY: "parent" + script: + - echo "parent script" + +child_job: + extends: .base + variables: + SHARED_VAR: "child-override" + CHILD_ONLY: "child" + script: + - echo "child script" +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const plain = config.getPlainObject({ skipValidation: true }) + + expect(plain.jobs?.child_job).toBeDefined() + + // Child variables should override parent variables + expect(plain.jobs?.child_job?.variables?.SHARED_VAR).toBe("child-override") + expect(plain.jobs?.child_job?.variables?.PARENT_ONLY).toBe("parent") + expect(plain.jobs?.child_job?.variables?.CHILD_ONLY).toBe("child") + + // Scripts should be concatenated (parent first, child appended) + expect(plain.jobs?.child_job?.script).toEqual(['echo "parent script"', 'echo "child script"']) + }) + + it("should handle multi-level extends with proper override order", () => { + const yaml = ` +.base: + variables: + VAR: "base" + +.middle: + extends: .base + variables: + VAR: "middle" + +.top: + extends: .middle + variables: + VAR: "top" + +final_job: + extends: .top + script: echo "final" +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const plain = config.getPlainObject({ skipValidation: true }) + + // Most specific definition should win for variables + expect(plain.jobs?.final_job?.variables?.VAR).toBe("top") + expect(plain.jobs?.final_job?.script).toEqual(['echo "final"']) + }) + + it("should handle array extends with proper merge order", () => { + const yaml = ` +.base1: + variables: + VAR1: "from-base1" + SHARED: "base1" + +.base2: + variables: + VAR2: "from-base2" + SHARED: "base2" + +job: + extends: + - .base1 + - .base2 + variables: + SHARED: "job-override" +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const plain = config.getPlainObject({ skipValidation: true }) + + // Job's own definition should override all extends + expect(plain.jobs?.job?.variables?.SHARED).toBe("job-override") + // Variables from both base templates should be present + expect(plain.jobs?.job?.variables?.VAR1).toBe("from-base1") + expect(plain.jobs?.job?.variables?.VAR2).toBe("from-base2") + }) + + it("should properly merge rules with replace strategy", () => { + const yaml = ` +.base: + rules: + - if: $CI_COMMIT_BRANCH == "develop" + when: always + +job_with_override: + extends: .base + rules: + - if: $CI_COMMIT_BRANCH == "main" + when: on_success + script: echo "test" + +job_without_override: + extends: .base + script: echo "test" +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const plain = config.getPlainObject({ skipValidation: true }) + + // Job with rules should completely replace parent rules + expect(plain.jobs?.job_with_override?.rules).toEqual([ + { + if: '$CI_COMMIT_BRANCH == "main"', + when: "on_success", + }, + ]) + + // Job without rules should inherit parent rules + expect(plain.jobs?.job_without_override?.rules).toEqual([ + { + if: '$CI_COMMIT_BRANCH == "develop"', + when: "always", + }, + ]) + }) + + it("should concatenate scripts but replace rules", () => { + const yaml = ` +.base: + script: + - echo "setup" + rules: + - when: never + +job: + extends: .base + script: + - echo "main" + rules: + - when: always +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const plain = config.getPlainObject({ skipValidation: true }) + + // Scripts concatenated + expect(plain.jobs?.job?.script).toEqual(['echo "setup"', 'echo "main"']) + + // Rules replaced + expect(plain.jobs?.job?.rules).toEqual([{ when: "always" }]) + }) +}) diff --git a/tests/unit/rule-evaluator-and.test.ts b/tests/unit/rule-evaluator-and.test.ts new file mode 100644 index 0000000..27a2414 --- /dev/null +++ b/tests/unit/rule-evaluator-and.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest" + +import { RuleEvaluator } from "../../src/simulation/rule-evaluator" + +describe("RuleEvaluator - && Operator", () => { + const evaluator = new RuleEvaluator() + + it("should evaluate AND operator - both true", () => { + const result = evaluator.evaluateRule( + { + if: '$VAR1 == "a" && $VAR2 == "b"', + when: "always", + }, + { + variables: { VAR1: "a", VAR2: "b" }, + branch: "main", + }, + ) + + expect(result).toEqual({ type: "match", when: "always" }) + }) + + it("should evaluate AND operator - first false", () => { + const result = evaluator.evaluateRule( + { + if: '$VAR1 == "x" && $VAR2 == "b"', + when: "always", + }, + { + variables: { VAR1: "a", VAR2: "b" }, + branch: "main", + }, + ) + + expect(result).toEqual({ type: "no_match" }) + }) + + it("should evaluate AND operator - second false", () => { + const result = evaluator.evaluateRule( + { + if: '$VAR1 == "a" && $VAR2 == "x"', + when: "always", + }, + { + variables: { VAR1: "a", VAR2: "b" }, + branch: "main", + }, + ) + + expect(result).toEqual({ type: "no_match" }) + }) + + it("should evaluate variable comparison AND regex pattern", () => { + const result = evaluator.evaluateRule( + { + if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_MESSAGE =~ /release/", + when: "always", + }, + { + variables: { + CI_COMMIT_BRANCH: "main", + CI_DEFAULT_BRANCH: "main", + CI_COMMIT_MESSAGE: "", + }, + branch: "main", + }, + ) + + // Should NOT match because CI_COMMIT_MESSAGE doesn't contain "release" + expect(result).toEqual({ type: "no_match" }) + }) + + it("should evaluate variable-to-variable comparison", () => { + const result = evaluator.evaluateRule( + { + if: "$CI_COMMIT_REF_SLUG == $CI_DEFAULT_BRANCH", + }, + { + variables: { + CI_COMMIT_REF_SLUG: "main", + CI_DEFAULT_BRANCH: "main", + }, + branch: "main", + }, + ) + + // Should match with implicit when: on_success + expect(result).toEqual({ type: "match", when: undefined }) + }) +}) diff --git a/tests/unit/rule-evaluator-not-equal.test.ts b/tests/unit/rule-evaluator-not-equal.test.ts new file mode 100644 index 0000000..c37bd34 --- /dev/null +++ b/tests/unit/rule-evaluator-not-equal.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest" + +import { RuleEvaluator } from "../../src/simulation/rule-evaluator" + +describe("RuleEvaluator - != operator", () => { + const evaluator = new RuleEvaluator() + + it("should evaluate != operator - not equal", () => { + const result = evaluator.evaluateRule( + { + if: "$CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH", + when: "never", + }, + { + variables: { + CI_COMMIT_BRANCH: "main", + CI_DEFAULT_BRANCH: "develop", + }, + branch: "main", + }, + ) + + // Should match because "main" != "develop" + expect(result).toEqual({ type: "match", when: "never" }) + }) + + it("should evaluate != operator - equal (no match)", () => { + const result = evaluator.evaluateRule( + { + if: "$CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH", + when: "never", + }, + { + variables: { + CI_COMMIT_BRANCH: "main", + CI_DEFAULT_BRANCH: "main", + }, + branch: "main", + }, + ) + + // Should NOT match because "main" == "main" + expect(result).toEqual({ type: "no_match" }) + }) + + it("should evaluate rules sequence with multiple operators", () => { + const rules = [ + { if: "$JOB_DISABLED =~ /true/i", when: "never" as const }, + { if: "$CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH", when: "never" as const }, + { if: "$CI_COMMIT_REF_SLUG == $CI_DEFAULT_BRANCH" }, // no when = on_success + ] + + const context = { + variables: { + JOB_DISABLED: "false", + CI_COMMIT_BRANCH: "main", + CI_DEFAULT_BRANCH: "main", + CI_COMMIT_REF_SLUG: "main", + }, + branch: "main" as string | undefined, + } + + const result = evaluator.evaluateRules(rules, context) + + // Should run with on_success + expect(result).toEqual({ shouldRun: true, when: "on_success" }) + }) +}) diff --git a/tests/unit/simulation.test.ts b/tests/unit/simulation.test.ts new file mode 100644 index 0000000..648cea3 --- /dev/null +++ b/tests/unit/simulation.test.ts @@ -0,0 +1,666 @@ +import { vol } from "memfs" +import { beforeEach, describe, expect, test } from "vitest" + +import type { RuleContext } from "../../src/simulation" +import { ConfigBuilder } from "../../src" +import { PipelineSimulator } from "../../src/simulation" + +describe("Pipeline Simulation", () => { + beforeEach(() => { + // Reset virtual filesystem and create test files + vol.reset() + vol.fromJSON({ + "/project/src/index.ts": "export {}", + "/project/package.json": "{}", + "/project/Dockerfile": "FROM node:20", + }) + }) + + test("should simulate jobs with rules", () => { + const config = new ConfigBuilder() + .stages("build", "test") + .job("build-job", { + stage: "build", + script: "npm run build", + rules: [{ if: '$CI_COMMIT_BRANCH == "main"', when: "always" }], + }) + .job("test-job", { + stage: "test", + script: "npm test", + rules: [{ if: '$CI_COMMIT_BRANCH == "develop"', when: "always" }], + }) + + const simulator = new PipelineSimulator() + + // Simulate on main branch + const mainContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "main", + }, + branch: "main", + } + + const mainResult = simulator.simulate(config, mainContext) + + expect(mainResult.totalJobs).toBe(2) + expect(mainResult.jobsToRun).toBe(1) + expect(mainResult.jobsSkipped).toBe(1) + + const buildJob = mainResult.jobs.find((j) => j.name === "build-job") + expect(buildJob?.shouldRun).toBe(true) + expect(buildJob?.when).toBe("always") + + const testJob = mainResult.jobs.find((j) => j.name === "test-job") + expect(testJob?.shouldRun).toBe(false) + expect(testJob?.when).toBe("never") + + // Simulate on develop branch + const developContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "develop", + }, + branch: "develop", + } + + const developResult = simulator.simulate(config, developContext) + + expect(developResult.jobsToRun).toBe(1) + const buildJobDevelop = developResult.jobs.find((j) => j.name === "build-job") + expect(buildJobDevelop?.shouldRun).toBe(false) + + const testJobDevelop = developResult.jobs.find((j) => j.name === "test-job") + expect(testJobDevelop?.shouldRun).toBe(true) + }) + + test("should simulate jobs with regex rules", () => { + const config = new ConfigBuilder().job("deploy-staging", { + script: "deploy.sh", + rules: [{ if: "$CI_COMMIT_BRANCH =~ /^feature-.+/", when: "manual" }], + }) + + const simulator = new PipelineSimulator() + + // Feature branch - should match + const featureContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "feature-new-feature", + }, + branch: "feature-new-feature", + } + + const featureResult = simulator.simulate(config, featureContext) + const featureJob = featureResult.jobs[0] + expect(featureJob?.shouldRun).toBe(true) + expect(featureJob?.when).toBe("manual") + + // Main branch - should not match + const mainContext: RuleContext = { + variables: { + CI_COMMIT_BRANCH: "main", + }, + branch: "main", + } + + const mainResult = simulator.simulate(config, mainContext) + const mainJob = mainResult.jobs[0] + expect(mainJob?.shouldRun).toBe(false) + }) + + test("should simulate jobs without rules (default behavior)", () => { + const config = new ConfigBuilder().job("simple-job", { + script: "echo hello", + }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + + const result = simulator.simulate(config, context) + + expect(result.totalJobs).toBe(1) + expect(result.jobsToRun).toBe(1) + + const job = result.jobs[0] + expect(job?.shouldRun).toBe(true) + expect(job?.when).toBe("on_success") + }) + + test("should handle when: never rule", () => { + const config = new ConfigBuilder().job("disabled-job", { + script: "echo disabled", + rules: [{ if: '$DISABLED == "true"', when: "never" }, { when: "always" }], + }) + + const simulator = new PipelineSimulator() + + // Disabled + const disabledContext: RuleContext = { + variables: { + DISABLED: "true", + }, + } + + const disabledResult = simulator.simulate(config, disabledContext) + const disabledJob = disabledResult.jobs[0] + expect(disabledJob?.shouldRun).toBe(false) + expect(disabledJob?.when).toBe("never") + + // Enabled + const enabledContext: RuleContext = { + variables: { + DISABLED: "false", + }, + } + + const enabledResult = simulator.simulate(config, enabledContext) + const enabledJob = enabledResult.jobs[0] + expect(enabledJob?.shouldRun).toBe(true) + expect(enabledJob?.when).toBe("always") + }) + + test("should respect stage order", () => { + const config = new ConfigBuilder() + .stages("build", "test", "deploy") + .job("deploy-job", { stage: "deploy", script: "deploy.sh" }) + .job("test-job", { stage: "test", script: "test.sh" }) + .job("build-job", { stage: "build", script: "build.sh" }) + + const simulator = new PipelineSimulator() + const context: RuleContext = { variables: {} } + + const result = simulator.simulate(config, context) + + expect(result.jobs[0]?.name).toBe("build-job") + expect(result.jobs[1]?.name).toBe("test-job") + expect(result.jobs[2]?.name).toBe("deploy-job") + }) + + test("should simulate complex rules with multiple conditions", () => { + const config = new ConfigBuilder().job("complex-job", { + script: "complex.sh", + rules: [ + { if: "$JOB_DISABLED =~ /true/i", when: "never" }, + { if: "$CI_MERGE_REQUEST_LABELS =~ /disable-job/i", when: "never" }, + { when: "always" }, + ], + }) + + const simulator = new PipelineSimulator() + + // Not disabled + const enabledContext: RuleContext = { + variables: { + JOB_DISABLED: "false", + }, + } + + const enabledResult = simulator.simulate(config, enabledContext) + expect(enabledResult.jobs[0]?.shouldRun).toBe(true) + + const disabledContext: RuleContext = { + variables: { + JOB_DISABLED: "true", + }, + } + + const disabledResult = simulator.simulate(config, disabledContext) + expect(disabledResult.jobs[0]?.shouldRun).toBe(false) + }) + + test("should merge job variables with context variables", () => { + const config = new ConfigBuilder().job("test-job", { + script: "test.sh", + variables: { + JOB_DISABLED: "true", + OTHER_VAR: "job-value", + }, + rules: [{ if: "$JOB_DISABLED =~ /true/i", when: "never" }, { when: "always" }], + }) + + const simulator = new PipelineSimulator() + + // Job variable should override context variable + const context: RuleContext = { + variables: { + JOB_DISABLED: "false", // This should be overridden by job variable + OTHER_VAR: "context-value", + }, + } + + const result = simulator.simulate(config, context) + + // Job should be skipped because job variable JOB_DISABLED="true" overrides context + expect(result.jobs[0]?.shouldRun).toBe(false) + expect(result.jobs[0]?.when).toBe("never") + }) + + test("should evaluate exists rule when file exists", () => { + const config = new ConfigBuilder().stages("build").job("build-with-source", { + stage: "build", + script: "build .", + rules: [{ exists: ["src/index.ts"] }, { when: "never" }], + }) + + const simulator = new PipelineSimulator() + + const context: RuleContext = { + variables: {}, + basePath: "/project", + } + + const result = simulator.simulate(config, context) + + // Job should run because src/index.ts exists + const job = result.jobs[0] + expect(job?.shouldRun).toBe(true) + expect(job?.name).toBe("build-with-source") + }) + + test("should skip job when exists rule file does not exist", () => { + const config = new ConfigBuilder().stages("build").job("build-with-dockerfile", { + stage: "build", + script: "docker build .", + rules: [{ exists: ["nonexistent-file.txt"] }, { when: "never" }], + }) + + const simulator = new PipelineSimulator() + + const context: RuleContext = { + variables: {}, + basePath: "/project", + } + + const result = simulator.simulate(config, context) + + // Job should not run because file doesn't exist and second rule is when: never + const job = result.jobs[0] + expect(job?.shouldRun).toBe(false) + expect(job?.when).toBe("never") + }) + + test("should interpolate variables in exists paths", () => { + const config = new ConfigBuilder().stages("build").job("build-app", { + stage: "build", + script: "build.sh", + variables: { + APP_DIR: "src", + APP_FILE: "index.ts", + }, + rules: [{ exists: ["$APP_DIR/$APP_FILE"] }, { when: "never" }], + }) + + const simulator = new PipelineSimulator() + + const context: RuleContext = { + variables: {}, + basePath: "/project", + } + + const result = simulator.simulate(config, context) + + // Job should run because src/index.ts exists + const job = result.jobs[0] + expect(job?.shouldRun).toBe(true) + }) + + test("should handle exists with multiple file patterns", () => { + const config = new ConfigBuilder().stages("build").job("build-any", { + stage: "build", + script: "build.sh", + rules: [{ exists: ["package.json", "nonexistent.txt"] }, { when: "never" }], + }) + + const simulator = new PipelineSimulator() + + const context: RuleContext = { + variables: {}, + basePath: "/project", + } + + const result = simulator.simulate(config, context) + + // Job should run because at least one file (package.json) exists + const job = result.jobs[0] + expect(job?.shouldRun).toBe(true) + }) + + test("should skip exists rule when no basePath provided", () => { + const config = new ConfigBuilder().stages("build").job("build-with-dockerfile", { + stage: "build", + script: "docker build .", + rules: [{ exists: ["package.json"] }, { when: "always" }], + }) + + const simulator = new PipelineSimulator() + + // No basePath provided - cannot evaluate filesystem + const context: RuleContext = { + variables: {}, + } + + const result = simulator.simulate(config, context) + + // Should fall through to second rule (when: always) + const job = result.jobs[0] + expect(job?.shouldRun).toBe(true) + expect(job?.when).toBe("always") + }) + + test("should combine exists with if condition", () => { + const config = new ConfigBuilder().stages("build").job("build-main", { + stage: "build", + script: "docker build .", + rules: [{ exists: ["Dockerfile"], if: '$CI_COMMIT_BRANCH == "main"' }, { when: "never" }], + }) + + const simulator = new PipelineSimulator() + + // File exists but wrong branch + const developContext: RuleContext = { + variables: { CI_COMMIT_BRANCH: "develop" }, + basePath: "/project", + } + + const developResult = simulator.simulate(config, developContext) + expect(developResult.jobs[0]?.shouldRun).toBe(false) + + // File exists and correct branch + const mainContext: RuleContext = { + variables: { CI_COMMIT_BRANCH: "main" }, + basePath: "/project", + } + + const mainResult = simulator.simulate(config, mainContext) + // Should be true because Dockerfile exists and branch is main + expect(mainResult.jobs[0]?.shouldRun).toBe(true) + }) + + test("should handle != (not equals) operator in rules", () => { + const config = new ConfigBuilder().stages("deploy").job("deploy-prod", { + stage: "deploy", + script: "deploy.sh", + rules: [{ if: '$ENVIRONMENT != "development"' }], + }) + + const simulator = new PipelineSimulator() + + // Should skip when ENVIRONMENT is development + const devContext: RuleContext = { + variables: { ENVIRONMENT: "development" }, + } + const devResult = simulator.simulate(config, devContext) + expect(devResult.jobs[0]?.shouldRun).toBe(false) + + // Should run when ENVIRONMENT is production + const prodContext: RuleContext = { + variables: { ENVIRONMENT: "production" }, + } + const prodResult = simulator.simulate(config, prodContext) + expect(prodResult.jobs[0]?.shouldRun).toBe(true) + }) + + test("should handle !~ (regex not match) operator in rules", () => { + const config = new ConfigBuilder().stages("test").job("test-prod", { + stage: "test", + script: "test.sh", + rules: [{ if: "$CI_COMMIT_BRANCH !~ /^feature-.+/" }], + }) + + const simulator = new PipelineSimulator() + + // Should skip when branch matches feature- pattern + const featureContext: RuleContext = { + variables: { CI_COMMIT_BRANCH: "feature-new-ui" }, + } + const featureResult = simulator.simulate(config, featureContext) + expect(featureResult.jobs[0]?.shouldRun).toBe(false) + + // Should run when branch doesn't match + const mainContext: RuleContext = { + variables: { CI_COMMIT_BRANCH: "main" }, + } + const mainResult = simulator.simulate(config, mainContext) + expect(mainResult.jobs[0]?.shouldRun).toBe(true) + }) + + test("should handle variable existence check in rules", () => { + const config = new ConfigBuilder().stages("build").job("build-custom", { + stage: "build", + script: "build.sh", + rules: [{ if: "$CUSTOM_BUILD" }], + }) + + const simulator = new PipelineSimulator() + + // Should skip when variable doesn't exist + const noVarContext: RuleContext = { + variables: {}, + } + const noVarResult = simulator.simulate(config, noVarContext) + expect(noVarResult.jobs[0]?.shouldRun).toBe(false) + + // Should skip when variable is empty string + const emptyContext: RuleContext = { + variables: { CUSTOM_BUILD: "" }, + } + const emptyResult = simulator.simulate(config, emptyContext) + expect(emptyResult.jobs[0]?.shouldRun).toBe(false) + + // Should skip when variable is "false" + const falseContext: RuleContext = { + variables: { CUSTOM_BUILD: "false" }, + } + const falseResult = simulator.simulate(config, falseContext) + expect(falseResult.jobs[0]?.shouldRun).toBe(false) + + // Should skip when variable is "0" + const zeroContext: RuleContext = { + variables: { CUSTOM_BUILD: "0" }, + } + const zeroResult = simulator.simulate(config, zeroContext) + expect(zeroResult.jobs[0]?.shouldRun).toBe(false) + + // Should run when variable has truthy value + const truthyContext: RuleContext = { + variables: { CUSTOM_BUILD: "true" }, + } + const truthyResult = simulator.simulate(config, truthyContext) + expect(truthyResult.jobs[0]?.shouldRun).toBe(true) + }) + + test("should handle $CI_COMMIT_BRANCH special variable", () => { + const config = new ConfigBuilder().stages("build").job("build-branch", { + stage: "build", + script: "build.sh", + rules: [{ if: '$CI_COMMIT_BRANCH == "main"' }], + }) + + const simulator = new PipelineSimulator() + + // Should skip when no branch + const noBranchContext: RuleContext = { + variables: {}, + } + const noBranchResult = simulator.simulate(config, noBranchContext) + expect(noBranchResult.jobs[0]?.shouldRun).toBe(false) + + // Should run when branch is main + const branchContext: RuleContext = { + variables: { CI_COMMIT_BRANCH: "main" }, + branch: "main", + } + const branchResult = simulator.simulate(config, branchContext) + expect(branchResult.jobs[0]?.shouldRun).toBe(true) + }) + + test("should handle $CI_COMMIT_TAG special variable", () => { + const config = new ConfigBuilder().stages("release").job("release-tag", { + stage: "release", + script: "release.sh", + rules: [{ if: "$CI_COMMIT_TAG =~ /^v[0-9]+/" }], + }) + + const simulator = new PipelineSimulator() + + // Should skip when no tag + const noTagContext: RuleContext = { + variables: {}, + } + const noTagResult = simulator.simulate(config, noTagContext) + expect(noTagResult.jobs[0]?.shouldRun).toBe(false) + + // Should run when tag matches pattern + const tagContext: RuleContext = { + variables: { CI_COMMIT_TAG: "v1.0.0" }, + tag: "v1.0.0", + } + const tagResult = simulator.simulate(config, tagContext) + expect(tagResult.jobs[0]?.shouldRun).toBe(true) + }) + + test("should handle $CI_MERGE_REQUEST_ID special variable", () => { + const config = new ConfigBuilder().stages("test").job("test-mr", { + stage: "test", + script: "test.sh", + rules: [{ if: "$CI_MERGE_REQUEST_ID" }], + }) + + const simulator = new PipelineSimulator() + + // Should skip when no MR + const noMrContext: RuleContext = { + variables: {}, + } + const noMrResult = simulator.simulate(config, noMrContext) + expect(noMrResult.jobs[0]?.shouldRun).toBe(false) + + // Should run when MR ID variable is set + const mrContext: RuleContext = { + variables: { CI_MERGE_REQUEST_ID: "123" }, + mergeRequestLabels: ["enhancement"], + } + const mrResult = simulator.simulate(config, mrContext) + expect(mrResult.jobs[0]?.shouldRun).toBe(true) + }) + + test("should handle $CI_PIPELINE_SOURCE with merge_request_event", () => { + const config = new ConfigBuilder().stages("test").job("test-mr-pipeline", { + stage: "test", + script: "test.sh", + rules: [{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }], + }) + + const simulator = new PipelineSimulator() + + // Should skip when no MR + const noMrContext: RuleContext = { + variables: {}, + } + const noMrResult = simulator.simulate(config, noMrContext) + expect(noMrResult.jobs[0]?.shouldRun).toBe(false) + + // Should run when MR context is present + const mrContext: RuleContext = { + variables: { CI_PIPELINE_SOURCE: "merge_request_event" }, + mergeRequestLabels: ["enhancement"], + } + const mrResult = simulator.simulate(config, mrContext) + expect(mrResult.jobs[0]?.shouldRun).toBe(true) + }) + + test("should handle $CI_PIPELINE_SOURCE with other values", () => { + const config = new ConfigBuilder().stages("deploy").job("deploy-push", { + stage: "deploy", + script: "deploy.sh", + rules: [{ if: '$CI_PIPELINE_SOURCE == "push"' }], + }) + + const simulator = new PipelineSimulator() + + // Should skip for unsupported pipeline sources + const context: RuleContext = { + variables: {}, + } + const result = simulator.simulate(config, context) + expect(result.jobs[0]?.shouldRun).toBe(false) + }) + + test("should handle case-insensitive regex flags", () => { + const config = new ConfigBuilder().stages("build").job("build-feature", { + stage: "build", + script: "build.sh", + rules: [{ if: "$CI_COMMIT_BRANCH =~ /^FEATURE-.+/i" }], + }) + + const simulator = new PipelineSimulator() + + // Should match with case-insensitive flag + const context: RuleContext = { + variables: { CI_COMMIT_BRANCH: "feature-new-ui" }, + } + const result = simulator.simulate(config, context) + expect(result.jobs[0]?.shouldRun).toBe(true) + }) + + test("should handle undefined/empty condition edge cases", () => { + const config = new ConfigBuilder().stages("build").job("build-fallback", { + stage: "build", + script: "build.sh", + rules: [ + { if: '$UNDEFINED_VAR == "value"' }, + { when: "always" }, // Fallback + ], + }) + + const simulator = new PipelineSimulator() + + // Should fall through to second rule when first doesn't match + const context: RuleContext = { + variables: {}, + } + const result = simulator.simulate(config, context) + expect(result.jobs[0]?.shouldRun).toBe(true) + expect(result.jobs[0]?.when).toBe("always") + }) + + test("should handle negation operator in rules", () => { + const config = new ConfigBuilder().stages("test").job("test-prod", { + stage: "test", + script: "test.sh", + rules: [{ if: '!($CI_COMMIT_BRANCH == "develop")' }], + }) + + const simulator = new PipelineSimulator() + + // Should skip on develop (negated) + const developContext: RuleContext = { + variables: { CI_COMMIT_BRANCH: "develop" }, + } + const developResult = simulator.simulate(config, developContext) + expect(developResult.jobs[0]?.shouldRun).toBe(false) + + // Should run on other branches + const mainContext: RuleContext = { + variables: { CI_COMMIT_BRANCH: "main" }, + } + const mainResult = simulator.simulate(config, mainContext) + expect(mainResult.jobs[0]?.shouldRun).toBe(true) + }) + + test("should handle when: manual in rules", () => { + const config = new ConfigBuilder().stages("deploy").job("deploy-manual", { + stage: "deploy", + script: "deploy.sh", + rules: [{ if: '$CI_COMMIT_BRANCH == "main"', when: "manual" }], + }) + + const simulator = new PipelineSimulator() + + const context: RuleContext = { + variables: { CI_COMMIT_BRANCH: "main" }, + } + const result = simulator.simulate(config, context) + expect(result.jobs[0]?.shouldRun).toBe(true) + expect(result.jobs[0]?.when).toBe("manual") + expect(result.jobsToRun).toBe(1) + }) +}) diff --git a/tests/unit/template-extends-chain.test.ts b/tests/unit/template-extends-chain.test.ts index 45059bb..e582f6e 100644 --- a/tests/unit/template-extends-chain.test.ts +++ b/tests/unit/template-extends-chain.test.ts @@ -7,37 +7,37 @@ describe("ConfigBuilder - template extends chain resolution", () => { const config = new ConfigBuilder() // Base template with cache and variables - config.template(".nodejs:cache", { + config.template(".node:cache", { cache: [ { - key: "${NPM_CACHE_KEY}-${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}", + key: "${CACHE_KEY}-${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}", paths: ["${APP_DIR}/node_modules", "${APP_DIR}/.pnpm-store"], policy: "pull", }, ], variables: { APP_DIR: ".", - NPM_CACHE_KEY: "default", + CACHE_KEY: "default", }, }) // Template with tags - config.template(".review_tags", { - tags: ["openshift", "test"], + config.template(".env_tags", { + tags: ["docker", "test"], }) - // Template extending .nodejs:cache - config.template(".deploy_rds", { - extends: ".nodejs:cache", + // Template extending .node:cache + config.template(".deploy_job", { + extends: ".node:cache", image: "node:20", script: ["echo 'deploy'"], }) - // Job extending both .deploy_rds and .review_tags - config.extends([".deploy_rds", ".review_tags"], "deploy_rds_review", { - stage: "review", + // Job extending both .deploy_job and .env_tags + config.extends([".deploy_job", ".env_tags"], "deploy_to_env", { + stage: "deploy", variables: { - AWS_ACCOUNT_ID: "$AWS_ACCOUNT_ID_TEST", + DEPLOYMENT_ID: "$ENV_DEPLOYMENT_ID", }, }) @@ -47,35 +47,35 @@ describe("ConfigBuilder - template extends chain resolution", () => { expect(result.errors).toHaveLength(0) expect(result.warnings).toHaveLength(0) - const job = pipeline.jobs?.deploy_rds_review + const job = pipeline.jobs?.deploy_to_env expect(job).toBeDefined() - // Should have cache from .nodejs:cache (through .deploy_rds) + // Should have cache from .node:cache (through .deploy_job) expect(job?.cache).toBeDefined() expect(job?.cache).toEqual([ { - key: "${NPM_CACHE_KEY}-${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}", + key: "${CACHE_KEY}-${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}", paths: ["${APP_DIR}/node_modules", "${APP_DIR}/.pnpm-store"], policy: "pull", }, ]) - // Should have base variables from .nodejs:cache AND job-specific variables + // Should have base variables from .node:cache AND job-specific variables expect(job?.variables).toEqual({ APP_DIR: ".", - NPM_CACHE_KEY: "default", - AWS_ACCOUNT_ID: "$AWS_ACCOUNT_ID_TEST", + CACHE_KEY: "default", + DEPLOYMENT_ID: "$ENV_DEPLOYMENT_ID", }) - // Should have tags from .review_tags - expect(job?.tags).toEqual(["openshift", "test"]) + // Should have tags from .env_tags + expect(job?.tags).toEqual(["docker", "test"]) - // Should have image and script from .deploy_rds + // Should have image and script from .deploy_job expect(job?.image).toBe("node:20") expect(job?.script).toEqual(["echo 'deploy'"]) // Should have stage from job definition - expect(job?.stage).toBe("review") + expect(job?.stage).toBe("deploy") // Templates are resolved, extends removed expect(job?.extends).toBeUndefined() diff --git a/tests/unit/template-lenient-validation.test.ts b/tests/unit/template-lenient-validation.test.ts new file mode 100644 index 0000000..09c16b7 --- /dev/null +++ b/tests/unit/template-lenient-validation.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest" + +import { convertYamlToConfig } from "../../src/resolver/cli" + +describe("Template Validation", () => { + it("should accept templates with incomplete definitions in YAML", () => { + const yaml = ` +.incomplete_template: + stage: test + # No script - but this should be OK for templates! + +test_job: + extends: .incomplete_template + script: echo 'test' +` + + // Should not throw + expect(() => { + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + config.getPlainObject({ skipValidation: true }) + }).not.toThrow() + }) + + it("should accept templates with !reference tags", () => { + const yaml = ` +.other: + script: echo 'base' + image: node:20 + +.template_with_ref: + script: + - !reference [.other, script] + - echo 'additional' + image: !reference [.other, image] + +test_job: + extends: .template_with_ref +` + + expect(() => { + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const plain = config.getPlainObject({ skipValidation: true }) + + // Job should have resolved references + expect(plain.jobs?.test_job).toBeDefined() + expect(plain.jobs?.test_job?.image).toBe("node:20") + }).not.toThrow() + }) + + it("should accept remote templates with incomplete definitions", () => { + const yaml = ` +.remote_template: + image: node:20 + services: + - redis:latest + # No script - will be added by jobs that extend this template + +production_job: + extends: .remote_template + script: echo 'deploy' +` + + expect(() => { + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const plain = config.getPlainObject({ skipValidation: true }) + + expect(plain.jobs?.production_job).toBeDefined() + expect(plain.jobs?.production_job?.image).toBe("node:20") + }).not.toThrow() + }) + + it("should handle templates with complex structures", () => { + const yaml = ` +.complex_template: + image: + name: docker:latest + entrypoint: [/bin/sh] + services: + - name: postgres:14 + alias: db + variables: + POSTGRES_DB: test + before_script: + - echo 'setup' + after_script: + - echo 'cleanup' + +test_job: + extends: .complex_template + script: echo 'test' +` + + expect(() => { + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const plain = config.getPlainObject({ skipValidation: true }) + + expect(plain.jobs?.test_job).toBeDefined() + expect(plain.jobs?.test_job?.variables?.POSTGRES_DB).toBe("test") + }).not.toThrow() + }) + + it("should successfully extend templates even when template has no script", () => { + const yaml = ` +.base: + image: node:20 + variables: + NODE_ENV: production + +.extended_base: + extends: .base + tags: + - kubernetes + +deploy: + extends: .extended_base + script: echo 'deploying' +` + + const config = convertYamlToConfig(yaml, { resolveReferences: true }) + const plain = config.getPlainObject({ skipValidation: true }) + + expect(plain.jobs?.deploy).toBeDefined() + expect(plain.jobs?.deploy?.image).toBe("node:20") + expect(plain.jobs?.deploy?.variables?.NODE_ENV).toBe("production") + expect(plain.jobs?.deploy?.tags).toEqual(["kubernetes"]) + expect(plain.jobs?.deploy?.script).toEqual(["echo 'deploying'"]) + }) +}) diff --git a/tests/unit/validation.test.ts b/tests/unit/validation.test.ts index 56fa7d5..4ee805a 100644 --- a/tests/unit/validation.test.ts +++ b/tests/unit/validation.test.ts @@ -89,13 +89,16 @@ describe("ConfigBuilder - Zod Validation", () => { ).not.toThrow() }) - it("should reject invalid template", () => { + it("should accept templates with validation errors (lenient validation)", () => { const builder = new ConfigBuilder() + // Templates should accept invalid definitions because: + // - They may contain !reference tags resolved later + // - They are partial definitions extended by jobs expect(() => builder.template("build", { script: 123 as never, }), - ).toThrow(ZodError) + ).not.toThrow() }) }) diff --git a/tests/unit/visualization.test.ts b/tests/unit/visualization.test.ts index ac0cb59..7aa1243 100644 --- a/tests/unit/visualization.test.ts +++ b/tests/unit/visualization.test.ts @@ -1344,12 +1344,13 @@ describe("Graph Visualization", () => { - echo "testing" ` - // Should not crash - await expect( - visualizeYaml(yaml, { - format: "ascii", - }), - ).rejects.toThrow() + // Should not crash - missing includes are now handled gracefully + const result = await visualizeYaml(yaml, { + format: "ascii", + }) + + // Should still render the local job even if remote include fails + expect(result.ascii).toContain("test") }) it("should handle nested remote includes (remote file includes another remote file)", async () => { diff --git a/vitest.config.ts b/vitest.config.ts index 6ded5c3..734de08 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,3 @@ -import { resolve } from "node:path" import { defineConfig } from "vitest/config" export default defineConfig({ @@ -26,6 +25,13 @@ export default defineConfig({ exclude: ["**/.generated/**"], }, }, + { + test: { + name: "e2e", + include: ["tests/e2e/**/*.test.ts"], + testTimeout: 30000, // E2E tests may take longer + }, + }, ], }, })