diff --git a/.github/tests/crowdin-workflow.test.ts b/.github/tests/crowdin-workflow.test.ts index fa2929cfe..1d2060421 100644 --- a/.github/tests/crowdin-workflow.test.ts +++ b/.github/tests/crowdin-workflow.test.ts @@ -16,6 +16,8 @@ interface CrowdinConfig { const workflowPath = fileURLToPath(new URL('../workflows/i18n-crowdin.yml', import.meta.url)); const crowdinConfigPath = fileURLToPath(new URL('../../crowdin.yml', import.meta.url)); const crowdinActionRef = 'crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f'; +const integrationBranchCheckoutStepName = + 'Check out the integration branch so l10n_crowdin forks from it'; function loadCrowdinWorkflowStep(): WorkflowStep { const workflow = loadWorkflow(workflowPath); @@ -41,6 +43,33 @@ test('Crowdin action runs as workspace owner and surfaces sync failures', () => expect(step['continue-on-error']).toBeUndefined(); }); +test('Crowdin checks out the resolved integration branch before creating its branch', () => { + const steps = loadWorkflow(workflowPath).jobs?.sync?.steps ?? []; + const baseStepIndex = steps.findIndex((step) => step.id === 'base'); + const checkoutStepIndex = steps.findIndex( + (step) => step.name === integrationBranchCheckoutStepName, + ); + const crowdinStepIndex = steps.findIndex((step) => + step.uses?.startsWith('crowdin/github-action@'), + ); + const checkoutStep = steps[checkoutStepIndex]; + + expect(baseStepIndex).toBeGreaterThanOrEqual(0); + expect(checkoutStepIndex).toBeGreaterThan(baseStepIndex); + expect(crowdinStepIndex).toBeGreaterThan(checkoutStepIndex); + + expect(checkoutStep?.env?.BASE).toBe('${{ steps.base.outputs.name }}'); + expect(checkoutStep?.run).not.toContain('${{ steps.base.outputs.name }}'); + + const fetchCommand = 'git fetch origin "refs/heads/${BASE}:refs/remotes/origin/${BASE}"'; + const checkoutCommand = 'git checkout -B "${BASE}" "refs/remotes/origin/${BASE}"'; + expect(checkoutStep?.run).toContain(fetchCommand); + expect(checkoutStep?.run).toContain(checkoutCommand); + expect(checkoutStep?.run?.indexOf(fetchCommand)).toBeLessThan( + checkoutStep?.run?.indexOf(checkoutCommand) ?? -1, + ); +}); + test('Crowdin workflow lets crowdin.yml own the target language list', () => { const step = loadCrowdinWorkflowStep(); const config = yaml.parse(readFileSync(crowdinConfigPath, 'utf8')) as CrowdinConfig; diff --git a/.github/tests/harden-runner-workflow.test.ts b/.github/tests/harden-runner-workflow.test.ts index 46b04961d..d5e5babfa 100644 --- a/.github/tests/harden-runner-workflow.test.ts +++ b/.github/tests/harden-runner-workflow.test.ts @@ -7,8 +7,8 @@ import yaml from 'yaml'; import type { WorkflowDefinition } from './workflow-test-utils'; const workflowsDir = fileURLToPath(new URL('../workflows', import.meta.url)); -const hardenRunnerRef = 'step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411'; -const hardenRunnerVersion = 'v2.19.4'; +const hardenRunnerRef = 'step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920'; +const hardenRunnerVersion = 'v2.20.0'; function loadWorkflowFiles(): Array<{ file: string; diff --git a/.github/tests/release-cut-ga-promotion.test.ts b/.github/tests/release-cut-ga-promotion.test.ts new file mode 100644 index 000000000..0a0c0b762 --- /dev/null +++ b/.github/tests/release-cut-ga-promotion.test.ts @@ -0,0 +1,144 @@ +import { fileURLToPath } from 'node:url'; + +import { loadWorkflow, type WorkflowStep } from './workflow-test-utils'; + +const workflowPath = fileURLToPath(new URL('../workflows/release-cut.yml', import.meta.url)); + +const prereleaseOnlySignSteps = [ + 'Sign release artifact', + 'Verify release artifact signature', + 'Attest release artifact provenance', + 'Export release provenance asset', + 'Verify release artifact provenance attestation', +]; + +const gaPromotionSteps = [ + 'Download candidate release artifact for promotion', + 'Verify downloaded candidate artifact checksum', + 'Verify candidate artifact provenance attestation', + 'Verify candidate artifact signature', + 'Verify candidate artifact is reproducible from source SHA', + 'Promote candidate artifact to GA release filenames', +]; + +const assetSuffixes = [ + 'tar.gz', + 'tar.gz.sha256', + 'tar.gz.bundle', + 'tar.gz.sig', + 'tar.gz.pem', + 'tar.gz.intoto.jsonl', +]; + +function loadReleaseSteps(): WorkflowStep[] { + const workflow = loadWorkflow(workflowPath); + return workflow.jobs?.release?.steps ?? []; +} + +function getStep(name: string): WorkflowStep | undefined { + return loadReleaseSteps().find((step) => step.name === name); +} + +test('release-cut captures a CHANGELOG snapshot from the target SHA', () => { + const step = getStep('Capture CHANGELOG snapshot from target SHA'); + + expect(step?.id).toBe('target_changelog'); + expect(step?.run).toContain('git show "${TARGET_SHA}:CHANGELOG.md"'); + // The extractor must be snapshotted as the whole scripts/ tree, not a single + // file: it imports relative siblings (./lib/parse-args.mjs), which Node + // resolves against the script's own location. + expect(step?.run).toContain('git archive "${TARGET_SHA}" scripts | tar -x -C'); + expect(step?.run).not.toContain('git show "${TARGET_SHA}:scripts/'); +}); + +test('release-cut reads CHANGELOG from the target-sha snapshot, not the checked-out tree', () => { + const validateStep = getStep('Validate CHANGELOG entry for release tag'); + const notesStep = getStep('Generate release notes from changelog'); + + for (const step of [validateStep, notesStep]) { + expect(step?.env).toMatchObject({ + CHANGELOG_PATH: '${{ steps.target_changelog.outputs.path }}', + }); + expect(step?.run).toContain('--file "${CHANGELOG_PATH}"'); + expect(step?.run).not.toContain('--file CHANGELOG.md'); + } +}); + +test('release-cut gates artifact sign/attest/verify steps to prereleases only', () => { + for (const stepName of prereleaseOnlySignSteps) { + const step = getStep(stepName); + + expect(step?.if).toBe("steps.tag.outputs.is_prerelease == 'true'"); + } +}); + +test('release-cut promotes the candidate artifact at GA in a fixed step order, each GA-gated', () => { + const steps = loadReleaseSteps(); + const indexOf = (name: string) => steps.findIndex((step) => step.name === name); + + for (const stepName of gaPromotionSteps) { + const step = getStep(stepName); + + expect(step, `expected step "${stepName}" to exist`).toBeDefined(); + expect(step?.if).toBe("steps.tag.outputs.is_prerelease == 'false'"); + } + + const indices = gaPromotionSteps.map(indexOf); + for (let i = 1; i < indices.length; i += 1) { + expect(indices[i]).toBeGreaterThan(indices[i - 1]); + } +}); + +test('release-cut downloads and promotes exactly the six candidate asset suffixes', () => { + const downloadStep = getStep('Download candidate release artifact for promotion'); + const promoteStep = getStep('Promote candidate artifact to GA release filenames'); + + // The download runs under the pinned retry wrapper (transient GitHub API / + // asset-CDN failures must not abort a GA run), so its script lives in + // `with.command`, not `run`. + expect(downloadStep?.uses).toContain('nick-fields/retry@'); + const downloadCommand = String(downloadStep?.with?.command ?? ''); + for (const suffix of assetSuffixes) { + expect(downloadCommand).toContain(`--pattern "drydock-\${CANDIDATE_TAG}.${suffix}"`); + } + + expect(promoteStep?.run).toContain( + 'for ext in tar.gz tar.gz.sha256 tar.gz.bundle tar.gz.sig tar.gz.pem tar.gz.intoto.jsonl', + ); +}); + +test('release-cut verifies candidate provenance against SOURCE_SHA, keeping TARGET_SHA for prereleases', () => { + const candidateStep = getStep('Verify candidate artifact provenance attestation'); + const prereleaseStep = getStep('Verify release artifact provenance attestation'); + + expect(candidateStep?.env).toMatchObject({ + SOURCE_SHA: '${{ steps.source.outputs.source_sha }}', + }); + expect(candidateStep?.run).toContain('--source-digest "${SOURCE_SHA}"'); + expect(candidateStep?.run).not.toContain('--source-digest "${TARGET_SHA}"'); + + expect(prereleaseStep?.env).toMatchObject({ + TARGET_SHA: '${{ steps.target.outputs.sha }}', + }); + expect(prereleaseStep?.run).toContain('--source-digest "${TARGET_SHA}"'); +}); + +test('release-cut compares decompressed tar streams for reproducibility, not raw gzip bytes', () => { + const reproStep = getStep('Verify candidate artifact is reproducible from source SHA'); + + expect(reproStep?.run).toContain('gzip -dc "${rebuilt}"'); + expect(reproStep?.run).toContain('gzip -dc "${downloaded}"'); +}); + +test('release-cut builds the GA-day release artifact under the candidate tag prefix', () => { + const buildStep = getStep('Build release artifact'); + + expect(buildStep?.env).toMatchObject({ + CANDIDATE_TAG: '${{ inputs.candidate_tag }}', + IS_PRERELEASE: '${{ steps.tag.outputs.is_prerelease }}', + }); + expect(buildStep?.run).toContain('if [ "${IS_PRERELEASE}" = "true" ]; then'); + expect(buildStep?.run).toContain('archive_tag="${RELEASE_TAG}"'); + expect(buildStep?.run).toContain('archive_tag="${CANDIDATE_TAG}"'); + expect(buildStep?.run).toContain('artifact="dist/drydock-${archive_tag}.tar.gz"'); +}); diff --git a/.github/tests/release-cut-retry-workflow.test.ts b/.github/tests/release-cut-retry-workflow.test.ts index 944c85385..3fc653f62 100644 --- a/.github/tests/release-cut-retry-workflow.test.ts +++ b/.github/tests/release-cut-retry-workflow.test.ts @@ -12,7 +12,7 @@ const changelogExtractorPath = fileURLToPath( const changelogPath = fileURLToPath(new URL('../../CHANGELOG.md', import.meta.url)); const gitignorePath = fileURLToPath(new URL('../../.gitignore', import.meta.url)); const retryAction = 'nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60'; -const metadataAction = 'docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9'; +const metadataAction = 'docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302'; const transientRetryStepNames = [ 'Retry GHCR login', 'Retry Docker Hub login', diff --git a/.github/workflows/ci-verify.yml b/.github/workflows/ci-verify.yml index e71b7a998..a972b3a70 100644 --- a/.github/workflows/ci-verify.yml +++ b/.github/workflows/ci-verify.yml @@ -62,7 +62,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -88,7 +88,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -132,7 +132,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -144,7 +144,7 @@ jobs: - name: Filter paths id: filter - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 with: # On push events, diff against the ref's previous SHA so we get # per-push changes. dorny/paths-filter's default on non-default @@ -177,7 +177,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -187,16 +187,16 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/codeql-config.yml - name: Autobuild - uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: /language:${{ matrix.language }} @@ -212,7 +212,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -343,7 +343,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -367,7 +367,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -392,7 +392,7 @@ jobs: run: npx biome check . - name: Setup Qlty - uses: qltysh/qlty-action/install@fd52dc852530a708d68c3b7342f8d33d1df4cd55 # v2.2.1 + uses: qltysh/qlty-action/install@08a0a862c159eae9b9003081da6663d96efef637 # v2.3.0 - name: Qlty check (all plugins, enforced) run: ./scripts/qlty-check-gate.sh all @@ -424,7 +424,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -507,7 +507,7 @@ jobs: # in the app/ui test steps above are the real gate. if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} continue-on-error: true - uses: qltysh/qlty-action/coverage@fd52dc852530a708d68c3b7342f8d33d1df4cd55 # v2.2.1 + uses: qltysh/qlty-action/coverage@08a0a862c159eae9b9003081da6663d96efef637 # v2.3.0 with: oidc: true files: coverage/codecov-app.lcov.info,coverage/codecov-ui.lcov.info @@ -528,7 +528,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -573,7 +573,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -613,10 +613,10 @@ jobs: working-directory: apps/demo - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Docker build (QA image + smoke test) - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . push: false @@ -631,10 +631,10 @@ jobs: cache-to: type=gha,mode=max,scope=drydock,ignore-error=true - name: Set up QEMU (multi-arch smoke build) - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - name: Docker build (multi-arch smoke) - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . push: false @@ -678,7 +678,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -776,7 +776,7 @@ jobs: - name: Upload ZAP SARIF if: always() && hashFiles('artifacts/zap/zap-baseline.sarif') != '' - uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: sarif_file: artifacts/zap/zap-baseline.sarif category: zap-baseline @@ -984,7 +984,7 @@ jobs: - name: Upload Nuclei SARIF to code scanning if: always() && steps.nuclei_sarif.outputs.upload == 'true' - uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: sarif_file: artifacts/dast/nuclei-report.sarif category: nuclei @@ -1078,7 +1078,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -1212,7 +1212,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -1228,7 +1228,7 @@ jobs: package-manager-cache: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Install e2e dependencies uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 @@ -1334,7 +1334,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -1350,7 +1350,7 @@ jobs: package-manager-cache: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Install e2e dependencies uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 diff --git a/.github/workflows/e2e-playwright.yml b/.github/workflows/e2e-playwright.yml index b6117515f..7300a8a14 100644 --- a/.github/workflows/e2e-playwright.yml +++ b/.github/workflows/e2e-playwright.yml @@ -60,7 +60,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -72,7 +72,7 @@ jobs: - name: Filter paths id: filter - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 with: base: ${{ github.event_name == 'push' && github.event.before || github.event_name == 'workflow_dispatch' && 'main' || '' }} # Shared with ci-verify.yml so Cucumber and Playwright short-circuit @@ -99,7 +99,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -144,12 +144,12 @@ jobs: working-directory: ui - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 # Shares the `drydock` GHA cache scope with ci-verify's build job, # so the second workflow to run hits a warm cache. - name: Docker build (QA image) - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . push: false diff --git a/.github/workflows/i18n-crowdin.yml b/.github/workflows/i18n-crowdin.yml index aea54f288..47a88a1be 100644 --- a/.github/workflows/i18n-crowdin.yml +++ b/.github/workflows/i18n-crowdin.yml @@ -31,7 +31,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -68,6 +68,19 @@ jobs: env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + - name: Check out the integration branch so l10n_crowdin forks from it + env: + BASE: ${{ steps.base.outputs.name }} + run: | + set -euo pipefail + # The checkout above pinned HEAD to the triggering ref, which is main + # whenever a sync merge touches locale files. The crowdin action forks + # l10n_crowdin from HEAD, so without this switch the translation PR + # carries main's squash history and shows as permanently conflicting + # against the dev base (bit #601 and #618). + git fetch origin "refs/heads/${BASE}:refs/remotes/origin/${BASE}" + git checkout -B "${BASE}" "refs/remotes/origin/${BASE}" + - uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3 with: user: auto diff --git a/.github/workflows/quality-mutation-monthly.yml b/.github/workflows/quality-mutation-monthly.yml index e5c705411..73ea08837 100644 --- a/.github/workflows/quality-mutation-monthly.yml +++ b/.github/workflows/quality-mutation-monthly.yml @@ -183,7 +183,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -279,7 +279,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index d54ea5521..f650f6d5f 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -61,7 +61,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -144,6 +144,54 @@ jobs: echo "Target SHA: ${sha}" echo "Repository (lowercase): ${repo_lower}" + - name: Capture CHANGELOG snapshot from target SHA + id: target_changelog + env: + TARGET_SHA: ${{ steps.target.outputs.sha }} + run: | + set -euo pipefail + # Defect: GA promotes a seven-day-old RC candidate whose checked-out + # tree (after "Checkout exact release source" below) can only ever + # contain "## [X.Y.Z-rc.N] - ..." headings — each RC promotes + # [Unreleased] to its own rc heading at prep time, and the GA heading + # "## [X.Y.Z] - ..." is not added to CHANGELOG.md until GA day, on + # main, AFTER the candidate was already tagged. Reading CHANGELOG.md + # out of the detached candidate checkout therefore can never find the + # GA entry — both changelog-reading steps below would deterministically + # fail on every possible GA dispatch. They must instead read + # CHANGELOG.md as it exists at TARGET_SHA (this run's dispatch-time + # main HEAD), which is where the GA heading actually lives. + # + # For prereleases SOURCE_SHA == TARGET_SHA, so this is behavior-neutral + # there — same file, same content, just read one commit earlier than + # the detached checkout that follows. + # + # `git show SHA:path` reads the blob straight out of the object + # database and needs no checkout of that SHA, so this is safe to run + # here (before "Checkout exact release source") or after — the + # earlier checkout step fetched full history (fetch-depth: 0), so the + # TARGET_SHA tree's objects are already present either way. + changelog_path="${RUNNER_TEMP}/target-sha-changelog.md" + git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_path}" + echo "path=${changelog_path}" >> "$GITHUB_OUTPUT" + # Snapshot the extractor script too, for the same reason: at GA the + # working tree is the candidate's seven-day-old checkout, so running + # its scripts/extract-changelog-entry.mjs against main's CHANGELOG + # would pair main's data with stale parser code. Pin both to + # TARGET_SHA so a parser fix that lands on main during the soak is + # the code that actually parses main's file. + # + # The whole scripts/ tree is extracted (not a single-file `git show`) + # because the extractor imports relative siblings — e.g. + # ./lib/parse-args.mjs — and Node resolves those against the script's + # own location, so a lone copied file dies with ERR_MODULE_NOT_FOUND. + scripts_snapshot_dir="${RUNNER_TEMP}/target-sha-scripts" + mkdir -p "${scripts_snapshot_dir}" + git archive "${TARGET_SHA}" scripts | tar -x -C "${scripts_snapshot_dir}" + extract_script_path="${scripts_snapshot_dir}/scripts/extract-changelog-entry.mjs" + test -f "${extract_script_path}" + echo "extract_script=${extract_script_path}" >> "$GITHUB_OUTPUT" + - name: Resolve release tag from input id: next env: @@ -311,12 +359,18 @@ jobs: - name: Validate CHANGELOG entry for release tag env: + CHANGELOG_PATH: ${{ steps.target_changelog.outputs.path }} + EXTRACT_SCRIPT: ${{ steps.target_changelog.outputs.extract_script }} RELEASE_TAG: ${{ steps.next.outputs.release_tag }} run: | set -euo pipefail entry_file="$(mktemp)" - if ! node scripts/extract-changelog-entry.mjs --version "${RELEASE_TAG}" --file CHANGELOG.md > "${entry_file}" 2>/dev/null; then + # Read from the TARGET_SHA snapshots captured above (both the + # CHANGELOG and the extractor script), not the checked-out SOURCE_SHA + # tree — see the rationale in "Capture CHANGELOG snapshot from + # target SHA". + if ! node "${EXTRACT_SCRIPT}" --version "${RELEASE_TAG}" --file "${CHANGELOG_PATH}" > "${entry_file}" 2>/dev/null; then echo "::error::CHANGELOG entry for ${RELEASE_TAG} missing. Add heading '## [${RELEASE_TAG#v}] - YYYY-MM-DD' and retry." exit 1 fi @@ -329,15 +383,15 @@ jobs: echo "Validated CHANGELOG entry for ${RELEASE_TAG}." - name: Set up QEMU - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to GHCR id: login_ghcr continue-on-error: true - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -346,7 +400,7 @@ jobs: - name: Log in to Docker Hub id: login_dockerhub continue-on-error: true - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -354,7 +408,7 @@ jobs: - name: Log in to Quay.io id: login_quay continue-on-error: true - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} @@ -404,7 +458,7 @@ jobs: - name: Docker metadata id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: | ghcr.io/${{ steps.target.outputs.repo_lower }} @@ -422,7 +476,7 @@ jobs: - name: Docker staging metadata id: staging_meta if: steps.tag.outputs.is_prerelease == 'true' - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: | ghcr.io/${{ steps.target.outputs.repo_lower }} @@ -462,7 +516,7 @@ jobs: id: build if: steps.tag.outputs.is_prerelease == 'true' continue-on-error: true # allow manifest retry path on transient push failures - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . push: true @@ -613,7 +667,7 @@ jobs: - name: Attest container build provenance id: attest_container_image if: steps.tag.outputs.is_prerelease == 'true' - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-name: ghcr.io/${{ steps.target.outputs.repo_lower }} subject-digest: ${{ steps.digest.outputs.value }} @@ -661,7 +715,7 @@ jobs: - name: Attest container SBOM id: attest_container_sbom if: steps.tag.outputs.is_prerelease == 'true' - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 with: subject-name: ghcr.io/${{ steps.target.outputs.repo_lower }} subject-digest: ${{ steps.digest.outputs.value }} @@ -687,16 +741,199 @@ jobs: - name: Build release artifact env: + CANDIDATE_TAG: ${{ inputs.candidate_tag }} + IS_PRERELEASE: ${{ steps.tag.outputs.is_prerelease }} RELEASE_TAG: ${{ steps.next.outputs.release_tag }} SOURCE_SHA: ${{ steps.source.outputs.source_sha }} run: | set -euo pipefail mkdir -p dist - artifact="dist/drydock-${RELEASE_TAG}.tar.gz" - git archive --format=tar.gz --prefix="drydock-${RELEASE_TAG}/" --output="${artifact}" "${SOURCE_SHA}" + # At GA, SOURCE_SHA is the RC candidate's commit, not this run's own + # tag. Archive it under the prefix the RC build itself used + # (CANDIDATE_TAG), not RELEASE_TAG's GA-versioned prefix. git archive + # embeds no build-time timestamps (each entry's mtime is that blob's + # committer date) and the tree + prefix are its only other inputs, so + # identical tree + identical prefix + same git version reproduces the + # RC artifact byte-for-byte. That equality is what "Verify candidate + # artifact is reproducible from source SHA" below relies on — using + # RELEASE_TAG's own prefix here would make this rebuild diverge from + # the RC artifact by construction and always fail that check. This + # rebuild is never published directly at GA; see the promotion steps + # below for why (Defect 2: attestation provenance). + if [ "${IS_PRERELEASE}" = "true" ]; then + archive_tag="${RELEASE_TAG}" + else + archive_tag="${CANDIDATE_TAG}" + fi + artifact="dist/drydock-${archive_tag}.tar.gz" + git archive --format=tar.gz --prefix="drydock-${archive_tag}/" --output="${artifact}" "${SOURCE_SHA}" sha256sum "${artifact}" > "${artifact}.sha256" + # ──────────────────────────────────────────────────────────────────── + # Defect 2 — artifact provenance at GA: design decision + # + # actions/attest-build-provenance always records the WORKFLOW RUN's own + # checkout commit (github.sha) as the attested subject's build source. + # At GA that is TARGET_SHA (today's main HEAD), never SOURCE_SHA (the + # seven-day-soaked candidate commit the tarball is actually built from). + # Rebuilding and re-attesting inside the GA run — option (b): keep + # rebuilding, keep attesting, verify with TARGET_SHA since that is what + # the attestation truthfully records — would produce an attestation + # that verifies cleanly while asserting a false claim: "this tarball + # was built from TARGET_SHA" when its tree is actually SOURCE_SHA's. + # That is silently wrong provenance, worse than a hard failure, and it + # also breaks the promotion model everywhere else in this workflow: + # the container image is never rebuilt at GA either — it reuses the + # exact RC digest and re-verifies the RC-time attestation against + # SOURCE_SHA (see "Verify container build provenance attestation" and + # "Verify container SBOM attestation" above, both intentionally + # SOURCE_SHA-keyed and correct as-is). + # + # Chosen instead — option (a), mirroring that container-image pattern: + # at GA, do not rebuild or re-attest the release artifact at all. + # Download the candidate's already-published tar.gz and its + # .intoto.jsonl bundle from the RC's GitHub release, re-verify that + # existing attestation against SOURCE_SHA (honest at RC time, since + # SOURCE_SHA == TARGET_SHA when the RC was cut), prove by a fresh + # git-archive rebuild that the downloaded bytes still match SOURCE_SHA's + # tree today, and republish those same soaked bytes under the GA tag. + # The GA artifact IS the promoted RC artifact with its original honest + # provenance — true promotion semantics, not a rebuild with mislabeled + # provenance. The signing/attestation steps below therefore only run + # for prereleases, where SOURCE_SHA == TARGET_SHA and rebuild-then-attest + # is correct exactly as it was before this fix. + # ──────────────────────────────────────────────────────────────────── + + - name: Download candidate release artifact for promotion + if: steps.tag.outputs.is_prerelease == 'false' + env: + CANDIDATE_TAG: ${{ inputs.candidate_tag }} + GH_TOKEN: ${{ github.token }} + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + timeout_minutes: 5 + max_attempts: 3 + retry_wait_seconds: 10 + command: | + set -euo pipefail + mkdir -p dist/candidate + gh release download "${CANDIDATE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --dir dist/candidate \ + --clobber \ + --pattern "drydock-${CANDIDATE_TAG}.tar.gz" \ + --pattern "drydock-${CANDIDATE_TAG}.tar.gz.sha256" \ + --pattern "drydock-${CANDIDATE_TAG}.tar.gz.bundle" \ + --pattern "drydock-${CANDIDATE_TAG}.tar.gz.sig" \ + --pattern "drydock-${CANDIDATE_TAG}.tar.gz.pem" \ + --pattern "drydock-${CANDIDATE_TAG}.tar.gz.intoto.jsonl" + + - name: Verify downloaded candidate artifact checksum + if: steps.tag.outputs.is_prerelease == 'false' + env: + CANDIDATE_TAG: ${{ inputs.candidate_tag }} + run: | + set -euo pipefail + # Not `sha256sum -c`: the .sha256 file was written as + # `sha256sum dist/drydock-.tar.gz > ...` at repo-root cwd during + # the RC build, so it embeds the path "dist/drydock-.tar.gz". + # The download step above lands the file flat in dist/candidate/, so + # -c's embedded-path lookup would miss it regardless of directory — + # compare the recorded hash value directly instead. + artifact="dist/candidate/drydock-${CANDIDATE_TAG}.tar.gz" + recorded_hash="$(awk '{print $1}' "${artifact}.sha256")" + actual_hash="$(sha256sum "${artifact}" | awk '{print $1}')" + if [ "${recorded_hash}" != "${actual_hash}" ]; then + echo "::error::Downloaded ${artifact} (sha256=${actual_hash}) does not match its published checksum (${recorded_hash})." + exit 1 + fi + + - name: Verify candidate artifact provenance attestation + if: steps.tag.outputs.is_prerelease == 'false' + env: + CANDIDATE_TAG: ${{ inputs.candidate_tag }} + GH_TOKEN: ${{ github.token }} + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} + run: | + set -euo pipefail + # This re-verifies the attestation actions/attest-build-provenance + # produced during the RC run, which honestly recorded SOURCE_SHA as + # its build source (SOURCE_SHA == TARGET_SHA at RC-cut time). + artifact="dist/candidate/drydock-${CANDIDATE_TAG}.tar.gz" + gh attestation verify "${artifact}" \ + --repo "${GITHUB_REPOSITORY}" \ + --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release-cut.yml" \ + --source-ref refs/heads/main \ + --source-digest "${SOURCE_SHA}" \ + --bundle "${artifact}.intoto.jsonl" >/dev/null + + - name: Verify candidate artifact signature + if: steps.tag.outputs.is_prerelease == 'false' + env: + CANDIDATE_TAG: ${{ inputs.candidate_tag }} + run: | + set -euo pipefail + artifact="dist/candidate/drydock-${CANDIDATE_TAG}.tar.gz" + identity_regex="^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release-cut.yml@refs/heads/main$" + issuer="https://token.actions.githubusercontent.com" + + cosign verify-blob \ + --bundle "${artifact}.bundle" \ + --certificate-identity-regexp "${identity_regex}" \ + --certificate-oidc-issuer "${issuer}" \ + "${artifact}" >/dev/null + + - name: Verify candidate artifact is reproducible from source SHA + if: steps.tag.outputs.is_prerelease == 'false' + env: + CANDIDATE_TAG: ${{ inputs.candidate_tag }} + run: | + set -euo pipefail + # "Build release artifact" above already rebuilt SOURCE_SHA's tree + # under CANDIDATE_TAG's own prefix for exactly this comparison. This + # proves the downloaded RC bytes still carry what SOURCE_SHA's tree + # contains today before they get promoted to the GA release below. + # + # Compare DECOMPRESSED tar streams, not gzip bytes: gzip encoding can + # drift between the git versions on the RC-cut and GA runners, which + # would fail a byte-compare on identical trees and hard-block a + # legitimate GA. Tamper detection is not this step's job — the + # signature and attestation verifies above already pin the downloaded + # artifact's exact original bytes. This step only asserts tree + # identity, which the tar stream captures fully (entry mtimes are + # commit dates, no build-time inputs). + rebuilt="dist/drydock-${CANDIDATE_TAG}.tar.gz" + downloaded="dist/candidate/drydock-${CANDIDATE_TAG}.tar.gz" + rebuilt_sha="$(gzip -dc "${rebuilt}" | sha256sum | cut -d ' ' -f 1)" + downloaded_sha="$(gzip -dc "${downloaded}" | sha256sum | cut -d ' ' -f 1)" + if [ "${rebuilt_sha}" != "${downloaded_sha}" ]; then + echo "::error::Candidate artifact ${downloaded} (content sha256=${downloaded_sha}) does not match a fresh git archive of ${CANDIDATE_TAG}'s source SHA (content sha256=${rebuilt_sha}). Refusing to promote a non-reproducible artifact." + exit 1 + fi + echo "Candidate artifact is reproducible from source SHA." + + - name: Promote candidate artifact to GA release filenames + if: steps.tag.outputs.is_prerelease == 'false' + env: + CANDIDATE_TAG: ${{ inputs.candidate_tag }} + RELEASE_TAG: ${{ steps.next.outputs.release_tag }} + run: | + set -euo pipefail + # The promoted asset keeps the RC build's internal tar prefix + # (drydock-${CANDIDATE_TAG}/) — renaming the release-asset *filename* + # to the GA tag is cosmetic, not a content change, so the RC's + # existing cosign signature and provenance attestation (both computed + # over file bytes, never over the filename) stay valid without being + # redone. + for ext in tar.gz tar.gz.sha256 tar.gz.bundle tar.gz.sig tar.gz.pem tar.gz.intoto.jsonl; do + cp "dist/candidate/drydock-${CANDIDATE_TAG}.${ext}" "dist/drydock-${RELEASE_TAG}.${ext}" + done + # Regenerate the checksum file so its embedded filename matches the + # promoted name; the hash itself is unchanged since the bytes are. + (cd dist && sha256sum "drydock-${RELEASE_TAG}.tar.gz" > "drydock-${RELEASE_TAG}.tar.gz.sha256") + - name: Sign release artifact + if: steps.tag.outputs.is_prerelease == 'true' env: RELEASE_TAG: ${{ steps.next.outputs.release_tag }} uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 @@ -731,6 +968,7 @@ jobs: fi - name: Verify release artifact signature + if: steps.tag.outputs.is_prerelease == 'true' env: RELEASE_TAG: ${{ steps.next.outputs.release_tag }} run: | @@ -747,11 +985,13 @@ jobs: - name: Attest release artifact provenance id: attest_release_artifact - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + if: steps.tag.outputs.is_prerelease == 'true' + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: dist/drydock-${{ steps.next.outputs.release_tag }}.tar.gz - name: Export release provenance asset + if: steps.tag.outputs.is_prerelease == 'true' env: RELEASE_TAG: ${{ steps.next.outputs.release_tag }} BUNDLE_PATH: ${{ steps.attest_release_artifact.outputs.bundle-path }} @@ -761,12 +1001,16 @@ jobs: cp "${BUNDLE_PATH}" "${artifact}.intoto.jsonl" - name: Verify release artifact provenance attestation + if: steps.tag.outputs.is_prerelease == 'true' env: RELEASE_TAG: ${{ steps.next.outputs.release_tag }} GH_TOKEN: ${{ github.token }} TARGET_SHA: ${{ steps.target.outputs.sha }} run: | set -euo pipefail + # Prerelease only: SOURCE_SHA == TARGET_SHA here, so asserting + # TARGET_SHA is equivalent to asserting SOURCE_SHA and matches what + # "Attest release artifact provenance" actually just recorded. artifact="dist/drydock-${RELEASE_TAG}.tar.gz" gh attestation verify "${artifact}" \ --repo "${GITHUB_REPOSITORY}" \ @@ -778,6 +1022,8 @@ jobs: - name: Generate release notes from changelog id: release_notes env: + CHANGELOG_PATH: ${{ steps.target_changelog.outputs.path }} + EXTRACT_SCRIPT: ${{ steps.target_changelog.outputs.extract_script }} RELEASE_TAG: ${{ steps.next.outputs.release_tag }} REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} @@ -787,7 +1033,10 @@ jobs: entry_path="$(mktemp)" missing_heading="## [${RELEASE_TAG#v}] - YYYY-MM-DD" - if ! node scripts/extract-changelog-entry.mjs --version "${RELEASE_TAG}" --file CHANGELOG.md > "${entry_path}"; then + # Same TARGET_SHA snapshots as the validation step above — the GA + # heading (and the parser that reads it) live on main, never in the + # detached SOURCE_SHA checkout. + if ! node "${EXTRACT_SCRIPT}" --version "${RELEASE_TAG}" --file "${CHANGELOG_PATH}" > "${entry_path}"; then rm -f "${entry_path}" "${notes_path}" echo "::error::Release notes generation failed: CHANGELOG entry missing for ${RELEASE_TAG}. Add heading '${missing_heading}' and retry release." { diff --git a/.github/workflows/security-grype.yml b/.github/workflows/security-grype.yml index 17a4f2578..913524a90 100644 --- a/.github/workflows/security-grype.yml +++ b/.github/workflows/security-grype.yml @@ -39,7 +39,7 @@ jobs: security-events: write steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -66,7 +66,7 @@ jobs: # Code scanning uploads need a public repo or GHAS. Drydock is public, # so this runs; the guard keeps the job green on a fork/private mirror. if: always() && github.event.repository.visibility == 'public' - uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: sarif_file: ${{ steps.grype-deps.outputs.sarif }} category: grype-deps @@ -93,7 +93,7 @@ jobs: security-events: write steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -103,10 +103,10 @@ jobs: persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Build image for scanning - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . push: false @@ -139,7 +139,7 @@ jobs: - name: Upload Grype SARIF to code scanning if: always() && github.event.repository.visibility == 'public' - uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: sarif_file: ${{ steps.grype.outputs.sarif }} category: grype-image diff --git a/.github/workflows/security-scorecard.yml b/.github/workflows/security-scorecard.yml index a03f51029..88f83e823 100644 --- a/.github/workflows/security-scorecard.yml +++ b/.github/workflows/security-scorecard.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit @@ -57,6 +57,6 @@ jobs: publish_results: true - name: Upload to code-scanning - uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: sarif_file: results.sarif diff --git a/.gitleaksignore b/.gitleaksignore index e682f3920..40812f8ec 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -243,7 +243,7 @@ ab7027d791b5aac9d59436c20a782d34f67859fa:app/registries/providers/custom/Custom. ab7027d791b5aac9d59436c20a782d34f67859fa:website/app/page.tsx:generic-api-key:168 app/agent/components/Agent.test.ts:private-key:152 app/api/portwing-ws.test.ts:generic-api-key:583 -app/authentications/providers/oidc/Oidc.test.ts:generic-api-key:610 +app/authentications/providers/oidc/Oidc.test.ts:generic-api-key:613 app/debug/redact.test.ts:generic-api-key:74 app/registries/BaseRegistry.test.ts:generic-api-key:3549 app/registries/BaseRegistry.test.ts:generic-api-key:3550 diff --git a/CHANGELOG.md b/CHANGELOG.md index bb5e19335..8e151b987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.6.0-rc.8] — 2026-07-28 + +### Fixed + +- **Auto-update no longer stops when the update-available notification rule is scoped to specific channels** ([#623](https://github.com/CodesWhat/drydock/issues/623)). Action triggers (`docker`, `dockercompose`, `command`) were gated by the same trigger allow-list on the `update-available` notification rule that routes messages to notification channels — but action-trigger ids are deliberately barred from that list by the API validator, the UI picker, and the documented rule model, so the moment any notification trigger was assigned to the rule, every action trigger (local and agent-hosted alike) silently failed the membership check with `excluded-from-allow-list` and auto-update stopped fleet-wide, with only a debug log as evidence. Action-category triggers are now exempt from the allow-list membership check in `getUpdateAvailableAutoTriggerDispatchDecision` (`app/triggers/providers/Trigger.ts`), mirroring the exemption the lifecycle-notification path has always had; disabling the rule itself still acts as the global kill switch. Present since the rule allow-list landed in v1.6.0-rc.1. +- **Controller-set update policy overrides no longer vanish from agent-managed containers** ([#565](https://github.com/CodesWhat/drydock/issues/565)). Remote agents resolve their own declarative (env/label) policy but never learn controller-side runtime overrides, so every container report they send carries an explicit empty override layer. The controller persisted that layer verbatim, and `updateContainer` (`app/store/container.ts`) treated any present `updatePolicyOverrides` key as authoritative — clearing maturity mode/min-age days, skip lists, and snoozes on every periodic agent sync or manual recheck. This was the settings-deletion mechanism behind #565, distinct from the soak-clock resets fixed in [#568](https://github.com/CodesWhat/drydock/pull/568) and rc.7. The controller now reapplies its stored overrides when ingesting agent reports, and the store itself encodes the rule the recreate path has had since [#497](https://github.com/CodesWhat/drydock/pull/497): an empty incoming override layer carries no controller intent and only clears stored overrides when the update-policy PATCH handler marks the write as authoritative, so deliberate clears from the UI still stick. + ## [1.6.0-rc.7] — 2026-07-26 ### Changed @@ -2257,7 +2264,8 @@ Remaining upstream-only changes (not ported — not applicable to drydock): | Fix codeberg tests | Covered by drydock's own tests | | Update changelog | Upstream-specific | -[Unreleased]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.7...HEAD +[Unreleased]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.8...HEAD +[1.6.0-rc.8]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.7...v1.6.0-rc.8 [1.6.0-rc.7]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.6...v1.6.0-rc.7 [1.6.0-rc.6]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.5...v1.6.0-rc.6 [1.6.0-rc.5]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.4...v1.6.0-rc.5 diff --git a/README.md b/README.md index dfcc614f4..f7cc5bd29 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@

- Version + Version Multi-arch License AGPL-3.0
@@ -178,7 +178,7 @@ See the [Quick Start guide](https://getdrydock.com/docs/quickstart) for Docker C

🆕 Recent Updates

-v1.6.0-rc.7 highlights +v1.6.0-rc.8 highlights - **Notifications** — Per-rule/per-provider title and body templates with live preview, plus audit-backed in-app bell categories and update severity thresholds. - **Dashboard** — Zero-dependency CSS Grid replacement with mouse/touch reorder, bounded resize, responsive layouts, widget visibility, reset, and optional cross-device preference sync. diff --git a/app/agent/AgentClient.test.ts b/app/agent/AgentClient.test.ts index 03c4204a5..ad84155c3 100644 --- a/app/agent/AgentClient.test.ts +++ b/app/agent/AgentClient.test.ts @@ -3768,6 +3768,159 @@ describe('AgentClient', () => { expect(result).toBe(report); }); + test('preserves controller-owned maturity overrides when an agent recheck reports an empty override layer (#565)', async () => { + const controllerOverrides = { + maturityMode: 'mature', + maturityMinAgeDays: 5, + }; + storeContainer.getContainer.mockReturnValue({ + id: 'c1', + name: 'test', + updateAvailable: false, + updatePolicy: controllerOverrides, + updatePolicyDeclarative: { env: {}, label: {} }, + updatePolicyOverrides: controllerOverrides, + updatePolicySources: { + maturityMode: 'override', + maturityMinAgeDays: 'override', + }, + }); + storeContainer.updateContainer.mockImplementation((container) => container); + const report = { + container: { + id: 'c1', + name: 'test', + updateAvailable: false, + updatePolicy: undefined, + updatePolicyDeclarative: { env: {}, label: {} }, + updatePolicyOverrides: {}, + updatePolicySources: {}, + }, + }; + axios.post.mockResolvedValue({ data: report }); + + const result = await client.watchContainer('docker', 'local', { + id: 'c1', + name: 'test', + }); + + expect(storeContainer.updateContainer).toHaveBeenCalledWith( + expect.objectContaining({ + updatePolicy: controllerOverrides, + updatePolicyOverrides: controllerOverrides, + updatePolicySources: { + maturityMode: 'override', + maturityMinAgeDays: 'override', + }, + }), + ); + expect(result.container).toMatchObject({ + updatePolicy: controllerOverrides, + updatePolicyOverrides: controllerOverrides, + }); + }); + + test('preserves controller-owned skip and snooze overrides when an agent report has an empty override layer (#565)', async () => { + const controllerOverrides = { + skipTags: ['beta'], + skipDigests: ['sha256:abc'], + snoozeUntil: '2099-01-01T00:00:00.000Z', + }; + storeContainer.getContainer.mockReturnValue({ + id: 'c1', + name: 'test', + updateAvailable: false, + updatePolicy: controllerOverrides, + updatePolicyDeclarative: { env: {}, label: {} }, + updatePolicyOverrides: controllerOverrides, + updatePolicySources: { + skipTags: 'override', + skipDigests: 'override', + }, + }); + storeContainer.updateContainer.mockImplementation((container) => container); + axios.post.mockResolvedValue({ + data: { + container: { + id: 'c1', + name: 'test', + updateAvailable: false, + updatePolicy: undefined, + updatePolicyDeclarative: { env: {}, label: {} }, + updatePolicyOverrides: {}, + updatePolicySources: {}, + }, + }, + }); + + const result = await client.watchContainer('docker', 'local', { + id: 'c1', + name: 'test', + }); + + expect(storeContainer.updateContainer).toHaveBeenCalledWith( + expect.objectContaining({ + updatePolicy: controllerOverrides, + updatePolicyOverrides: controllerOverrides, + updatePolicySources: { + skipTags: 'override', + skipDigests: 'override', + }, + }), + ); + expect(result.container).toMatchObject({ + updatePolicy: controllerOverrides, + updatePolicyOverrides: controllerOverrides, + }); + }); + + test('keeps cleared controller overrides empty when an agent report carries stale values (#565)', async () => { + const staleOverrides = { + skipTags: ['beta'], + snoozeUntil: '2099-01-01T00:00:00.000Z', + }; + storeContainer.getContainer.mockReturnValue({ + id: 'c1', + name: 'test', + updateAvailable: false, + updatePolicy: { skipTags: ['stable'] }, + updatePolicyDeclarative: { env: { skipTags: ['stable'] }, label: {} }, + updatePolicyOverrides: {}, + updatePolicySources: { skipTags: 'env' }, + }); + storeContainer.updateContainer.mockImplementation((container) => container); + axios.post.mockResolvedValue({ + data: { + container: { + id: 'c1', + name: 'test', + updateAvailable: false, + updatePolicy: staleOverrides, + updatePolicyDeclarative: { env: { skipTags: ['stable'] }, label: {} }, + updatePolicyOverrides: staleOverrides, + updatePolicySources: { skipTags: 'override' }, + }, + }, + }); + + const result = await client.watchContainer('docker', 'local', { + id: 'c1', + name: 'test', + }); + + expect(storeContainer.updateContainer).toHaveBeenCalledWith( + expect.objectContaining({ + updatePolicy: { skipTags: ['stable'] }, + updatePolicyOverrides: {}, + updatePolicySources: { skipTags: 'env' }, + }), + ); + expect(result.container).toMatchObject({ + updatePolicy: { skipTags: ['stable'] }, + updatePolicyOverrides: {}, + }); + }); + test('should throw on failure', async () => { axios.post.mockRejectedValue(new Error('watch failed')); await expect( diff --git a/app/agent/AgentClient.ts b/app/agent/AgentClient.ts index f1e73a4ac..045d227d1 100644 --- a/app/agent/AgentClient.ts +++ b/app/agent/AgentClient.ts @@ -43,6 +43,7 @@ import { isTerminalContainerUpdateOperationStatus, type TerminalContainerUpdateOperationStatus, } from '../model/container-update-operation.js'; +import { applyUpdatePolicyOverrides, getUpdatePolicyOverrides } from '../model/update-policy.js'; import * as registry from '../registry/index.js'; import { resolveConfiguredPath } from '../runtime/paths.js'; import { createConfiguredSbomStorage } from '../security/configured-sbom-storage.js'; @@ -629,6 +630,12 @@ export class AgentClient { // Save to store logic with Change Detection const existing = storeContainer.getContainer(container.id); + if (existing && container.updatePolicyDeclarative !== undefined) { + // The controller owns runtime overrides. Agent watcher normalization always + // contributes an override layer (often `{}`), but that layer only reflects + // the agent's local store and must not clear controller-set policy on ingest. + applyUpdatePolicyOverrides(container, getUpdatePolicyOverrides(existing)); + } const containerReport = { container: container, changed: false, diff --git a/app/agent/api/index.test.ts b/app/agent/api/index.test.ts index 2857abe83..131de0b21 100644 --- a/app/agent/api/index.test.ts +++ b/app/agent/api/index.test.ts @@ -495,17 +495,20 @@ describe('Agent API index', () => { test.each([ ['level', 123, 'Invalid level query parameter'], ['component', ['docker'], 'Invalid component query parameter'], - ])('should return 400 when %s query parameter is not a string', async (param, value, error) => { - const { getEntries } = await import('../../log/buffer.js'); - const req = { query: { [param]: value } }; - const res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; - - logEntriesHandler(req, res); - - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error }); - expect(getEntries).not.toHaveBeenCalled(); - }); + ])( + 'should return 400 when %s query parameter is not a string', + async (param, value, error) => { + const { getEntries } = await import('../../log/buffer.js'); + const req = { query: { [param]: value } }; + const res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; + + logEntriesHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error }); + expect(getEntries).not.toHaveBeenCalled(); + }, + ); test('should pass level=null to getEntries when no level query param (undefined, not null)', async () => { const { getEntries } = await import('../../log/buffer.js'); @@ -518,22 +521,18 @@ describe('Agent API index', () => { ); }); - test.each([ - 'trace', - 'debug', - 'info', - 'warn', - 'error', - 'fatal', - ])('should accept log level %s', async (level) => { - const { getEntries } = await import('../../log/buffer.js'); - getEntries.mockReturnValue([]); - const req = { query: { level } }; - const res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; - logEntriesHandler(req, res); - expect(res.status).toHaveBeenCalledWith(200); - expect(getEntries).toHaveBeenCalledWith(expect.objectContaining({ level })); - }); + test.each(['trace', 'debug', 'info', 'warn', 'error', 'fatal'])( + 'should accept log level %s', + async (level) => { + const { getEntries } = await import('../../log/buffer.js'); + getEntries.mockReturnValue([]); + const req = { query: { level } }; + const res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; + logEntriesHandler(req, res); + expect(res.status).toHaveBeenCalledWith(200); + expect(getEntries).toHaveBeenCalledWith(expect.objectContaining({ level })); + }, + ); test('should normalize level to lowercase', async () => { const { getEntries } = await import('../../log/buffer.js'); diff --git a/app/api/agent.test.ts b/app/api/agent.test.ts index c507a76fe..3f8003452 100644 --- a/app/api/agent.test.ts +++ b/app/api/agent.test.ts @@ -590,25 +590,28 @@ describe('Agent Log Entries Route', () => { test.each([ ['level', 123, 'Invalid level query parameter'], ['component', ['docker'], 'Invalid component query parameter'], - ])('should return 400 when %s query parameter is not a string', async (param, value, expectedError) => { - const getLogEntries = vi.fn().mockResolvedValue([]); - mockGetAgent.mockReturnValue({ - isConnected: true, - getLogEntries, - }); + ])( + 'should return 400 when %s query parameter is not a string', + async (param, value, expectedError) => { + const getLogEntries = vi.fn().mockResolvedValue([]); + mockGetAgent.mockReturnValue({ + isConnected: true, + getLogEntries, + }); - const req = createMockRequest({ - params: { name: 'agent-1' }, - query: { [param]: value }, - }); - const res = createResponse(); + const req = createMockRequest({ + params: { name: 'agent-1' }, + query: { [param]: value }, + }); + const res = createResponse(); - await handler(req, res); + await handler(req, res); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: expectedError }); - expect(getLogEntries).not.toHaveBeenCalled(); - }); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: expectedError }); + expect(getLogEntries).not.toHaveBeenCalled(); + }, + ); test('should return 400 when component query parameter contains unsafe characters', async () => { const getLogEntries = vi.fn().mockResolvedValue([]); diff --git a/app/api/container.test.ts b/app/api/container.test.ts index ea168b5b9..425ea145d 100644 --- a/app/api/container.test.ts +++ b/app/api/container.test.ts @@ -2523,14 +2523,14 @@ describe('Container Router', () => { expect(res.json).toHaveBeenCalledWith({ error: 'Container not found' }); }); - test.each([ - 'docker', - 'dockercompose', - ])('should return 400 for local %s trigger on remote container', async (triggerType) => { - storeContainer.getContainer.mockReturnValue({ id: 'c1', agent: 'remote' }); - const res = await callRunTrigger({ id: 'c1', triggerType, triggerName: 'restart' }); - expect(res.status).toHaveBeenCalledWith(400); - }); + test.each(['docker', 'dockercompose'])( + 'should return 400 for local %s trigger on remote container', + async (triggerType) => { + storeContainer.getContainer.mockReturnValue({ id: 'c1', agent: 'remote' }); + const res = await callRunTrigger({ id: 'c1', triggerType, triggerName: 'restart' }); + expect(res.status).toHaveBeenCalledWith(400); + }, + ); test('should return 404 when trigger not found', async () => { storeContainer.getContainer.mockReturnValue({ id: 'c1' }); diff --git a/app/api/container.ts b/app/api/container.ts index 74b188cde..44d3dea44 100644 --- a/app/api/container.ts +++ b/app/api/container.ts @@ -238,7 +238,7 @@ const bulkSecurityHandlers = createBulkSecurityHandlers({ getAllContainers: () => storeContainer.getContainers({}), getContainer: (id) => storeContainer.getContainer(id), getContainerRaw: (id) => storeContainer.getContainerRaw(id), - updateContainer: (c) => storeContainer.updateContainer(c), + updateContainer: (c, options?) => storeContainer.updateContainer(c, options), }, getSecurityConfiguration, scanImageForVulnerabilities, diff --git a/app/api/container/crud.test.ts b/app/api/container/crud.test.ts index 6f10748a2..f4fc04c55 100644 --- a/app/api/container/crud.test.ts +++ b/app/api/container/crud.test.ts @@ -1094,29 +1094,24 @@ describe('api/container/crud', () => { ); }); - test.each([ - 'running', - 'stopped', - 'exited', - 'paused', - 'restarting', - 'dead', - 'created', - ])('accepts Docker runtime status=%s as a valid filter', (runtimeStatus) => { - const harness = createHarness({ - containers: [createContainer({ id: 'c1', status: runtimeStatus })], - }); + test.each(['running', 'stopped', 'exited', 'paused', 'restarting', 'dead', 'created'])( + 'accepts Docker runtime status=%s as a valid filter', + (runtimeStatus) => { + const harness = createHarness({ + containers: [createContainer({ id: 'c1', status: runtimeStatus })], + }); - const res = callGetContainers(harness.handlers, { - status: runtimeStatus, - }); + const res = callGetContainers(harness.handlers, { + status: runtimeStatus, + }); - expect(res.status).toHaveBeenCalledWith(200); - expect(harness.deps.getContainersFromStore).toHaveBeenCalledWith( - buildVisibleContainersStoreQuery({ status: runtimeStatus }), - { limit: 0, offset: 0 }, - ); - }); + expect(res.status).toHaveBeenCalledWith(200); + expect(harness.deps.getContainersFromStore).toHaveBeenCalledWith( + buildVisibleContainersStoreQuery({ status: runtimeStatus }), + { limit: 0, offset: 0 }, + ); + }, + ); test('maps kind=digest to updateKind.kind filter', () => { const harness = createHarness({ diff --git a/app/api/container/handlers/list.test.ts b/app/api/container/handlers/list.test.ts index 803f379fa..e47cd1109 100644 --- a/app/api/container/handlers/list.test.ts +++ b/app/api/container/handlers/list.test.ts @@ -1303,7 +1303,7 @@ describe('buildContainerListResponse', () => { // The underlying store container must not be mutated expect((container.security as any).sbom).toBe(sbomDoc); expect((container.security as any).signature).toBeDefined(); - expect((container.security?.scan as any).vulnerabilities).toEqual(['v1']); + expect((container.security as any).scan.vulnerabilities).toEqual(['v1']); }); }); diff --git a/app/api/container/security.test.ts b/app/api/container/security.test.ts index 308cacff7..bd584e159 100644 --- a/app/api/container/security.test.ts +++ b/app/api/container/security.test.ts @@ -481,25 +481,25 @@ describe('api/container/security', () => { }); }); - test.each([ - {}, - undefined, - ])('returns 500 when sbom generation succeeds without requested document (%j)', async (documents) => { - const harness = createHarness(); - harness.deps.generateImageSbom.mockResolvedValueOnce( - createSbomResult(CURRENT_IMAGE, { - status: 'generated', - documents, - }), - ); + test.each([{}, undefined])( + 'returns 500 when sbom generation succeeds without requested document (%j)', + async (documents) => { + const harness = createHarness(); + harness.deps.generateImageSbom.mockResolvedValueOnce( + createSbomResult(CURRENT_IMAGE, { + status: 'generated', + documents, + }), + ); - const res = await callGetContainerSbom(harness.handlers, { format: 'spdx-json' }); + const res = await callGetContainerSbom(harness.handlers, { format: 'spdx-json' }); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ - error: 'Error generating SBOM', - }); - }); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: 'Error generating SBOM', + }); + }, + ); test('returns 500 when sbom generation throws', async () => { const harness = createHarness(); @@ -1046,33 +1046,35 @@ describe('api/container/security', () => { expect(harness.deps.updateDigestScanCache).not.toHaveBeenCalled(); }); - test.each([ - 'grype', - 'both', - ])('does not populate the Trivy digest cache for %s on-demand scans', async (scanner) => { - const harness = createHarness({ - container: createContainer({ - image: { - registry: { name: 'hub', url: 'my-registry' }, - name: 'test/app', - tag: { value: '1.2.3' }, - digest: { watch: true, value: 'sha256:abc123' }, + test.each(['grype', 'both'])( + 'does not populate the Trivy digest cache for %s on-demand scans', + async (scanner) => { + const harness = createHarness({ + container: createContainer({ + image: { + registry: { name: 'hub', url: 'my-registry' }, + name: 'test/app', + tag: { value: '1.2.3' }, + digest: { watch: true, value: 'sha256:abc123' }, + }, + }), + securityConfiguration: { + enabled: true, + scanner, + signature: { verify: false }, + sbom: { enabled: false, formats: [] }, }, - }), - securityConfiguration: { - enabled: true, - scanner, - signature: { verify: false }, - sbom: { enabled: false, formats: [] }, - }, - }); - harness.deps.scanImageForVulnerabilities.mockResolvedValueOnce(createScanResult({ scanner })); + }); + harness.deps.scanImageForVulnerabilities.mockResolvedValueOnce( + createScanResult({ scanner }), + ); - await callScanContainer(harness.handlers); + await callScanContainer(harness.handlers); - expect(harness.deps.getTrivyDatabaseStatus).not.toHaveBeenCalled(); - expect(harness.deps.updateDigestScanCache).not.toHaveBeenCalled(); - }); + expect(harness.deps.getTrivyDatabaseStatus).not.toHaveBeenCalled(); + expect(harness.deps.updateDigestScanCache).not.toHaveBeenCalled(); + }, + ); }); describe('concurrent write-back safety', () => { diff --git a/app/api/container/update-policy.test.ts b/app/api/container/update-policy.test.ts index 108285419..c1228ce96 100644 --- a/app/api/container/update-policy.test.ts +++ b/app/api/container/update-policy.test.ts @@ -498,23 +498,26 @@ describe('api/container/update-policy', () => { test.each([ ['tag', '2.0.0', 'skipTags'], ['digest', 'sha256:new', 'skipDigests'], - ] as const)('skips the current %s using the effective list as the override base', (kind, value, field) => { - const harness = createLayeredHarness({ - updateKind: { kind, remoteValue: value }, - updatePolicy: { - maturityMode: 'mature', - maturityMinAgeDays: 7, - [field]: ['existing'], - }, - }); + ] as const)( + 'skips the current %s using the effective list as the override base', + (kind, value, field) => { + const harness = createLayeredHarness({ + updateKind: { kind, remoteValue: value }, + updatePolicy: { + maturityMode: 'mature', + maturityMinAgeDays: 7, + [field]: ['existing'], + }, + }); - const res = callPatchContainerUpdatePolicy(harness.handlers, { action: 'skip-current' }); + const res = callPatchContainerUpdatePolicy(harness.handlers, { action: 'skip-current' }); - expect(res.status).toHaveBeenCalledWith(200); - expect( - harness.storeContainer.updateContainer.mock.calls[0][0].updatePolicyOverrides[field], - ).toEqual(['existing', value]); - }); + expect(res.status).toHaveBeenCalledWith(200); + expect( + harness.storeContainer.updateContainer.mock.calls[0][0].updatePolicyOverrides[field], + ).toEqual(['existing', value]); + }, + ); test('skips the current value when neither effective nor override skip lists exist', () => { const harness = createLayeredHarness({ @@ -639,6 +642,24 @@ describe('api/container/update-policy', () => { }); }); + test('passes authoritative empty override intent when an action clears the last override', () => { + const harness = createLayeredHarness({ + updatePolicy: { + maturityMode: 'mature', + maturityMinAgeDays: 7, + snoozeUntil: '2030-01-01T00:00:00.000Z', + }, + updatePolicyOverrides: { snoozeUntil: '2030-01-01T00:00:00.000Z' }, + }); + + callPatchContainerUpdatePolicy(harness.handlers, { action: 'unsnooze' }); + + expect(harness.storeContainer.updateContainer).toHaveBeenCalledWith( + expect.objectContaining({ updatePolicyOverrides: {} }), + { authoritativeEmptyOverrides: true }, + ); + }); + test('supports layered clear, snooze, unsnooze, maturity-clear, and whole revert actions', () => { const clearSkips = createLayeredHarness(); callPatchContainerUpdatePolicy(clearSkips.handlers, { action: 'clear-skips' }); diff --git a/app/api/container/update-policy.ts b/app/api/container/update-policy.ts index dccb8a31e..5c97a3f20 100644 --- a/app/api/container/update-policy.ts +++ b/app/api/container/update-policy.ts @@ -18,7 +18,10 @@ import { getPathParamValue } from './request-helpers.js'; interface UpdatePolicyStoreContainerApi { getContainer: (id: string) => Container | undefined; - updateContainer: (container: Container) => Container; + updateContainer: ( + container: Container, + options?: { authoritativeEmptyOverrides?: boolean }, + ) => Container; } interface UpdatePolicyHandlerDependencies { @@ -450,7 +453,9 @@ function createPatchContainerUpdatePolicy({ container.updatePolicy = Object.keys(normalizedPolicy).length > 0 ? normalizedPolicy : undefined; } - const containerUpdated = storeContainer.updateContainer(container); + const containerUpdated = storeContainer.updateContainer(container, { + authoritativeEmptyOverrides: true, + }); if (previousOverrides) { recordOverrideAuditEvents( recordAuditEvent, diff --git a/app/api/group.test.ts b/app/api/group.test.ts index ac8ac8ab5..8a4400abe 100644 --- a/app/api/group.test.ts +++ b/app/api/group.test.ts @@ -313,34 +313,37 @@ describe('Group Router', () => { ['dd.group', 'constructor'], ['com.docker.compose.project', 'hasOwnProperty'], ['com.docker.stack.namespace', '__proto__'], - ])('should group %s value %s without colliding with object prototype keys', (label, groupName) => { - mockGetContainers.mockReturnValue([ - makeContainer('c1', 'service', { [label]: groupName }, true), - ]); - - const handler = getHandler('get', '/groups'); - const req = createMockRequest(); - const res = createMockResponse(); - - expect(() => handler(req, res)).not.toThrow(); - - expect(res.status).toHaveBeenCalledWith(200); - const { data: groups, total } = getGroupsPayload(res); - expect(total).toBe(1); - expect(groups).toHaveLength(1); - expect(groups[0]).toEqual({ - name: groupName, - containers: [ - { - id: 'c1', - name: 'service', - displayName: 'service', - updateAvailable: true, - }, - ], - containerCount: 1, - updatesAvailable: 1, - }); - }); + ])( + 'should group %s value %s without colliding with object prototype keys', + (label, groupName) => { + mockGetContainers.mockReturnValue([ + makeContainer('c1', 'service', { [label]: groupName }, true), + ]); + + const handler = getHandler('get', '/groups'); + const req = createMockRequest(); + const res = createMockResponse(); + + expect(() => handler(req, res)).not.toThrow(); + + expect(res.status).toHaveBeenCalledWith(200); + const { data: groups, total } = getGroupsPayload(res); + expect(total).toBe(1); + expect(groups).toHaveLength(1); + expect(groups[0]).toEqual({ + name: groupName, + containers: [ + { + id: 'c1', + name: 'service', + displayName: 'service', + updateAvailable: true, + }, + ], + containerCount: 1, + updatesAvailable: 1, + }); + }, + ); }); }); diff --git a/app/api/portwing-ws.test.ts b/app/api/portwing-ws.test.ts index 01ff0496e..2760daa29 100644 --- a/app/api/portwing-ws.test.ts +++ b/app/api/portwing-ws.test.ts @@ -1344,15 +1344,18 @@ describe('hello verification — agentName type validation (Bug 1 regression)', ['boolean', true], ['array', ['a', 'b']], ['object', { evil: true }], - ])('rejects a %s agentName with invalid-agent-name instead of throwing', async (_label, badValue) => { - const ws = sendHelloWithAgentName(badValue); - await new Promise((r) => setTimeout(r, 0)); - - const errorFrame = JSON.parse(ws.sentMessages[0]) as { type: string; data: { code: string } }; - expect(errorFrame.type).toBe('error'); - expect(errorFrame.data.code).toBe('invalid-agent-name'); - expect(ws.close).toHaveBeenCalledWith(1008, 'invalid-agent-name'); - }); + ])( + 'rejects a %s agentName with invalid-agent-name instead of throwing', + async (_label, badValue) => { + const ws = sendHelloWithAgentName(badValue); + await new Promise((r) => setTimeout(r, 0)); + + const errorFrame = JSON.parse(ws.sentMessages[0]) as { type: string; data: { code: string } }; + expect(errorFrame.type).toBe('error'); + expect(errorFrame.data.code).toBe('invalid-agent-name'); + expect(ws.close).toHaveBeenCalledWith(1008, 'invalid-agent-name'); + }, + ); test('rejects an agentName exceeding the maximum input length', async () => { // MAX_AGENT_NAME_INPUT_LENGTH in portwing-ws.ts is 256; 257 chars must be rejected. diff --git a/app/api/preferences.test.ts b/app/api/preferences.test.ts index 343574eee..3dbf1eeec 100644 --- a/app/api/preferences.test.ts +++ b/app/api/preferences.test.ts @@ -84,16 +84,15 @@ describe('preferences router', () => { assertContract('get', res); }); - it.each([ - undefined, - { username: 'anonymous' }, - { username: ' ' }, - ])('rejects anonymous GET before store access', (user) => { - const res = createMockResponse(); - handler('get')({ user }, res); - expect(res.status).toHaveBeenCalledWith(403); - expect(getStored).not.toHaveBeenCalled(); - }); + it.each([undefined, { username: 'anonymous' }, { username: ' ' }])( + 'rejects anonymous GET before store access', + (user) => { + const res = createMockResponse(); + handler('get')({ user }, res); + expect(res.status).toHaveBeenCalledWith(403); + expect(getStored).not.toHaveBeenCalled(); + }, + ); it('replaces preferences, broadcasts, and returns an OpenAPI-valid response', () => { const res = createMockResponse(); @@ -127,19 +126,18 @@ describe('preferences router', () => { expect(broadcast).not.toHaveBeenCalled(); }); - it.each([ - undefined, - {}, - { apiVersion: 1, schemaVersion: 11 }, - ])('returns 400 for malformed payload %#', (body) => { - const res = createMockResponse(); - handler('patch')({ user: { username: 'alice' }, body }, res); - expect(res.status).toHaveBeenCalledWith(body?.apiVersion === undefined ? 409 : 400); - if (body?.apiVersion !== undefined) { - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid request parameters' }); - } - expect(replaceStored).not.toHaveBeenCalled(); - }); + it.each([undefined, {}, { apiVersion: 1, schemaVersion: 11 }])( + 'returns 400 for malformed payload %#', + (body) => { + const res = createMockResponse(); + handler('patch')({ user: { username: 'alice' }, body }, res); + expect(res.status).toHaveBeenCalledWith(body?.apiVersion === undefined ? 409 : 400); + if (body?.apiVersion !== undefined) { + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid request parameters' }); + } + expect(replaceStored).not.toHaveBeenCalled(); + }, + ); it('normalizes a falsy request body before Joi validation', () => { Object.defineProperty(Number.prototype, 'apiVersion', { diff --git a/app/api/preview-errors.test.ts b/app/api/preview-errors.test.ts index a9cfe4ee5..2d9c1dcc2 100644 --- a/app/api/preview-errors.test.ts +++ b/app/api/preview-errors.test.ts @@ -49,20 +49,20 @@ describe('preview errors', () => { ); }); - test.each([ - 'registry-manager-unsupported', - 'registry-manager-misconfigured', - ])('classifies %s as missing registry configuration', (code) => { - const result = classifyPreviewError( - Object.assign(new Error('bad registry'), { code }), - container(), - ); - expect(result.status).toBe(422); - expect(result.payload).toMatchObject({ - code: 'registry-not-found', - message: 'No matching registry configured for ghcr.io/private/web', - }); - }); + test.each(['registry-manager-unsupported', 'registry-manager-misconfigured'])( + 'classifies %s as missing registry configuration', + (code) => { + const result = classifyPreviewError( + Object.assign(new Error('bad registry'), { code }), + container(), + ); + expect(result.status).toBe(422); + expect(result.payload).toMatchObject({ + code: 'registry-not-found', + message: 'No matching registry configured for ghcr.io/private/web', + }); + }, + ); test('does not duplicate a registry host already present in the image name', () => { const result = classifyPreviewError( @@ -113,15 +113,14 @@ describe('preview errors', () => { expect(result.payload.details?.registry).toBe('registry.example'); }); - test.each([ - 'ECONNABORTED', - 'ECONNRESET', - 'ENETUNREACH', - ])('classifies a network message containing %s without a structured code', (code) => { - const result = classifyPreviewError(new Error(`request failed: ${code}`), container()); - expect(result.status).toBe(503); - expect(result.payload.code).toBe('registry-network-error'); - }); + test.each(['ECONNABORTED', 'ECONNRESET', 'ENETUNREACH'])( + 'classifies a network message containing %s without a structured code', + (code) => { + const result = classifyPreviewError(new Error(`request failed: ${code}`), container()); + expect(result.status).toBe(503); + expect(result.payload.code).toBe('registry-network-error'); + }, + ); test('uses container-runtime language when a network failure has no registry', () => { const result = classifyPreviewError( @@ -154,17 +153,16 @@ describe('preview errors', () => { expect(result.payload.details?.registry).toBe('[invalid'); }); - test.each([ - new Error('runtime exploded'), - 'runtime string', - null, - ])('falls back to a typed runtime error for %s', (error) => { - const result = classifyPreviewError(error, container({ registry: undefined })); - expect(result.status).toBe(500); - expect(result.payload.code).toBe('preview-runtime-error'); - expect(result.payload.message).toBe('Unable to prepare this update preview'); - expect(result.payload.details).toBeUndefined(); - }); + test.each([new Error('runtime exploded'), 'runtime string', null])( + 'falls back to a typed runtime error for %s', + (error) => { + const result = classifyPreviewError(error, container({ registry: undefined })); + expect(result.status).toBe(500); + expect(result.payload.code).toBe('preview-runtime-error'); + expect(result.payload.message).toBe('Unable to prepare this update preview'); + expect(result.payload.details).toBeUndefined(); + }, + ); test('sends an exact typed payload', () => { const json = vi.fn(); diff --git a/app/authentications/providers/basic/Basic.test.ts b/app/authentications/providers/basic/Basic.test.ts index 7bd2a7b96..1f7492123 100644 --- a/app/authentications/providers/basic/Basic.test.ts +++ b/app/authentications/providers/basic/Basic.test.ts @@ -454,22 +454,21 @@ describe('Basic Authentication', () => { }); }); - test.each([ - ['m=65536,t=3,p=4'], - ['t=3,p=4,m=65536'], - ['p=4,m=65536,t=3'], - ])('should accept PHC argon2id hashes with reordered parameters (%s)', (parameterSegment) => { - const hash = `$argon2id$v=19$${parameterSegment}$${VALID_SALT_BASE64URL}$${VALID_HASH_BASE64URL}`; - expect( - basic.validateConfiguration({ + test.each([['m=65536,t=3,p=4'], ['t=3,p=4,m=65536'], ['p=4,m=65536,t=3']])( + 'should accept PHC argon2id hashes with reordered parameters (%s)', + (parameterSegment) => { + const hash = `$argon2id$v=19$${parameterSegment}$${VALID_SALT_BASE64URL}$${VALID_HASH_BASE64URL}`; + expect( + basic.validateConfiguration({ + user: 'testuser', + hash, + }), + ).toEqual({ user: 'testuser', hash, - }), - ).toEqual({ - user: 'testuser', - hash, - }); - }); + }); + }, + ); test('should accept PHC argon2id hashes with padded base64url segments', async () => { const hash = createPhcArgon2Hash('password', { paddedSegments: true }); diff --git a/app/authentications/providers/oidc/Oidc.test.ts b/app/authentications/providers/oidc/Oidc.test.ts index 06ed2ac6a..41a5a3526 100644 --- a/app/authentications/providers/oidc/Oidc.test.ts +++ b/app/authentications/providers/oidc/Oidc.test.ts @@ -585,12 +585,15 @@ test.each([ { username: 'sub-abc-service-account' }, ], ['email and sub both absent', {}, { username: 'unknown' }], -])('getUserFromAccessToken should return correct user when %s', async (_label, mockUserInfo, expected) => { - openidClientMock.fetchUserInfo = vi.fn().mockResolvedValue(mockUserInfo); - - const user = await oidc.getUserFromAccessToken('token'); - expect(user).toEqual(expected); -}); +])( + 'getUserFromAccessToken should return correct user when %s', + async (_label, mockUserInfo, expected) => { + openidClientMock.fetchUserInfo = vi.fn().mockResolvedValue(mockUserInfo); + + const user = await oidc.getUserFromAccessToken('token'); + expect(user).toEqual(expected); + }, +); test('getUserFromAccessToken should pass skipSubjectCheck when no expectedSubject is provided (bearer path)', async () => { openidClientMock.fetchUserInfo = vi.fn().mockResolvedValue({ email: 'user@example.com' }); diff --git a/app/configuration/index.test.ts b/app/configuration/index.test.ts index fd80abf41..cc34df3ee 100644 --- a/app/configuration/index.test.ts +++ b/app/configuration/index.test.ts @@ -50,17 +50,16 @@ test('getAuditUpdateAvailableDedupeMs should accept a non-negative integer', () delete configuration.ddEnvVars.DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS; }); -test.each([ - '-1', - '1.5', - 'not-a-number', -])('getAuditUpdateAvailableDedupeMs should reject invalid values (%s)', (value) => { - configuration.ddEnvVars.DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS = value; - expect(() => configuration.getAuditUpdateAvailableDedupeMs()).toThrow( - 'DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS must be a non-negative integer', - ); - delete configuration.ddEnvVars.DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS; -}); +test.each(['-1', '1.5', 'not-a-number'])( + 'getAuditUpdateAvailableDedupeMs should reject invalid values (%s)', + (value) => { + configuration.ddEnvVars.DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS = value; + expect(() => configuration.getAuditUpdateAvailableDedupeMs()).toThrow( + 'DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS must be a non-negative integer', + ); + delete configuration.ddEnvVars.DD_AUDIT_UPDATE_AVAILABLE_DEDUPE_MS; + }, +); test('getLogLevel should return debug when overridden', async () => { configuration.ddEnvVars.DD_LOG_LEVEL = 'debug'; @@ -607,17 +606,17 @@ test('getServerConfiguration should allow overriding the outer API rate-limit ma }); }); -test.each([ - '0', - '1.5', -])('getServerConfiguration should reject invalid outer API rate-limit maximum %s', (value) => { - configuration.ddEnvVars.DD_SERVER_RATELIMIT_MAX = value; - try { - expect(() => configuration.getServerConfiguration()).toThrow('ratelimit.max'); - } finally { - delete configuration.ddEnvVars.DD_SERVER_RATELIMIT_MAX; - } -}); +test.each(['0', '1.5'])( + 'getServerConfiguration should reject invalid outer API rate-limit maximum %s', + (value) => { + configuration.ddEnvVars.DD_SERVER_RATELIMIT_MAX = value; + try { + expect(() => configuration.getServerConfiguration()).toThrow('ratelimit.max'); + } finally { + delete configuration.ddEnvVars.DD_SERVER_RATELIMIT_MAX; + } + }, +); test('getPrometheusConfiguration should result in enabled by default', async () => { delete configuration.ddEnvVars.DD_PROMETHEUS_ENABLED; diff --git a/app/debug/dump.test.ts b/app/debug/dump.test.ts index 98028e6cd..4747f41dc 100644 --- a/app/debug/dump.test.ts +++ b/app/debug/dump.test.ts @@ -430,18 +430,18 @@ describe('debug dump utilities', () => { expectedMinutes: MAX_RECENT_EVENT_MINUTES, }, { label: 'truncation', options: { recentMinutes: 12.9 }, expectedMinutes: 12 }, - ])('collectDebugDump normalizes recent minutes ($label)', async ({ - options, - expectedMinutes, - }) => { - configureFixture(); + ])( + 'collectDebugDump normalizes recent minutes ($label)', + async ({ options, expectedMinutes }) => { + configureFixture(); - const dump = await collectDebugDump(options); + const dump = await collectDebugDump(options); - expect(dump.metadata.recentMinutes).toBe(expectedMinutes); - expect(dump.metadata.generatedAt).toBe(BASE_TIME.toISOString()); - expect(dump.metadata.generatedAtWindowStart).toBe(minutesAgoIso(expectedMinutes)); - }); + expect(dump.metadata.recentMinutes).toBe(expectedMinutes); + expect(dump.metadata.generatedAt).toBe(BASE_TIME.toISOString()); + expect(dump.metadata.generatedAtWindowStart).toBe(minutesAgoIso(expectedMinutes)); + }, + ); test('collectDebugDump composes debug data from watchers, triggers, store, and environment', async () => { const fixture = configureFixture(); diff --git a/app/event/index.test.ts b/app/event/index.test.ts index 40a40d922..be7f4b0f0 100644 --- a/app/event/index.test.ts +++ b/app/event/index.test.ts @@ -53,23 +53,21 @@ const eventTestCases = [ register: event.registerMaturityGateCleared, }, ]; -test.each( - eventTestCases, -)('the registered $register.name function must execute the handler when the $emitter.name emitter function is called', async ({ - register, - emitter, -}) => { - // Register an handler - const handlerMock = vi.fn((item) => item); - register(handlerMock); - - // Emit the event - const emitResult = await emitter(); - - // Ensure handler is called - expect([undefined, true, false]).toContain(emitResult); - expect(handlerMock).toHaveBeenCalledTimes(1); -}); +test.each(eventTestCases)( + 'the registered $register.name function must execute the handler when the $emitter.name emitter function is called', + async ({ register, emitter }) => { + // Register an handler + const handlerMock = vi.fn((item) => item); + register(handlerMock); + + // Emit the event + const emitResult = await emitter(); + + // Ensure handler is called + expect([undefined, true, false]).toContain(emitResult); + expect(handlerMock).toHaveBeenCalledTimes(1); + }, +); test('deregistration of container added handler should work', () => { const handler = vi.fn(); diff --git a/app/model/container.test.ts b/app/model/container.test.ts index a8765dac6..40710d04a 100644 --- a/app/model/container.test.ts +++ b/app/model/container.test.ts @@ -824,36 +824,36 @@ test('model should migrate legacy lookupUrl only when lookupImage is absent', () expect(preserved.image.registry.lookupUrl).toBeUndefined(); }); -test.each([ - 'specific', - 'floating', -] as const)('model should accept image.tag.tagPrecision=%s', (tagPrecision) => { - const containerValidated = container.validate({ - id: `container-tag-precision-${tagPrecision}`, - name: 'test', - watcher: 'test', - image: { - id: `image-tag-precision-${tagPrecision}`, - registry: { - name: 'hub', - url: 'https://hub', - }, - name: 'organization/image', - tag: { - value: tagPrecision === 'specific' ? '1.2.3' : 'latest', - semver: tagPrecision === 'specific', - tagPrecision, - }, - digest: { - watch: false, +test.each(['specific', 'floating'] as const)( + 'model should accept image.tag.tagPrecision=%s', + (tagPrecision) => { + const containerValidated = container.validate({ + id: `container-tag-precision-${tagPrecision}`, + name: 'test', + watcher: 'test', + image: { + id: `image-tag-precision-${tagPrecision}`, + registry: { + name: 'hub', + url: 'https://hub', + }, + name: 'organization/image', + tag: { + value: tagPrecision === 'specific' ? '1.2.3' : 'latest', + semver: tagPrecision === 'specific', + tagPrecision, + }, + digest: { + watch: false, + }, + architecture: 'arch', + os: 'os', }, - architecture: 'arch', - os: 'os', - }, - }); + }); - expect(containerValidated.image.tag.tagPrecision).toBe(tagPrecision); -}); + expect(containerValidated.image.tag.tagPrecision).toBe(tagPrecision); + }, +); test('model should flag numeric version aliases as tagPinned even when tagPrecision is floating', () => { const containerValidated = container.validate({ diff --git a/app/registries/providers/ecr/Ecr.test.ts b/app/registries/providers/ecr/Ecr.test.ts index bd16eded1..685c5b1b1 100644 --- a/app/registries/providers/ecr/Ecr.test.ts +++ b/app/registries/providers/ecr/Ecr.test.ts @@ -676,17 +676,20 @@ test('getAuthPull should throw when private ECR authorization token is missing', test.each([ Buffer.from(':password-only').toString('base64'), Buffer.from('username-only:').toString('base64'), -])('getAuthPull should reject decoded credentials with a missing token segment (%s)', async (token) => { - const ecrPrivate = new Ecr(); - ecrPrivate.configuration = { - accesskeyid: 'accesskeyid', - secretaccesskey: 'secretaccesskey', - region: 'region', - }; - ecrPrivate.fetchPrivateEcrAuthToken = vi.fn().mockResolvedValue(token); +])( + 'getAuthPull should reject decoded credentials with a missing token segment (%s)', + async (token) => { + const ecrPrivate = new Ecr(); + ecrPrivate.configuration = { + accesskeyid: 'accesskeyid', + secretaccesskey: 'secretaccesskey', + region: 'region', + }; + ecrPrivate.fetchPrivateEcrAuthToken = vi.fn().mockResolvedValue(token); - await expect(ecrPrivate.getAuthPull()).rejects.toThrow('ECR authorization token is malformed'); -}); + await expect(ecrPrivate.getAuthPull()).rejects.toThrow('ECR authorization token is malformed'); + }, +); test('match should return true for public ECR gallery', async () => { expect( diff --git a/app/registries/providers/shared/basicAuthProviderErrorPaths.test.ts b/app/registries/providers/shared/basicAuthProviderErrorPaths.test.ts index 699d7085c..6db51a7f5 100644 --- a/app/registries/providers/shared/basicAuthProviderErrorPaths.test.ts +++ b/app/registries/providers/shared/basicAuthProviderErrorPaths.test.ts @@ -239,60 +239,52 @@ beforeEach(() => { vi.clearAllMocks(); }); -test.each( - basicAuthProviderCases, -)('$providerName authenticate should add Basic auth without remote token request', async ({ - createRegistry, - image, - expectedAuthorization, -}) => { - const registry = createRegistry(); +test.each(basicAuthProviderCases)( + '$providerName authenticate should add Basic auth without remote token request', + async ({ createRegistry, image, expectedAuthorization }) => { + const registry = createRegistry(); - await expect( - registry.authenticate(image, { - url: `${image.registry.url}/${image.name}/tags/list`, - headers: { Accept: 'application/json' }, - }), - ).resolves.toEqual( - expect.objectContaining({ - headers: expect.objectContaining({ - Accept: 'application/json', - Authorization: expectedAuthorization, + await expect( + registry.authenticate(image, { + url: `${image.registry.url}/${image.name}/tags/list`, + headers: { Accept: 'application/json' }, }), - }), - ); - expect(axios).not.toHaveBeenCalled(); -}); + ).resolves.toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ + Accept: 'application/json', + Authorization: expectedAuthorization, + }), + }), + ); + expect(axios).not.toHaveBeenCalled(); + }, +); -test.each( - registryFailureCases, -)('$providerName should propagate $failureName after Basic authenticate', async ({ - createRegistry, - image, - expectedAuthorization, - createError, - expectedMessage, -}) => { - const registry = createRegistry(); - const requestUrl = `${image.registry.url}/${image.name}/tags/list`; - axios.mockRejectedValueOnce(createError()); +test.each(registryFailureCases)( + '$providerName should propagate $failureName after Basic authenticate', + async ({ createRegistry, image, expectedAuthorization, createError, expectedMessage }) => { + const registry = createRegistry(); + const requestUrl = `${image.registry.url}/${image.name}/tags/list`; + axios.mockRejectedValueOnce(createError()); - await expect( - registry.callRegistry({ - image, - url: requestUrl, - method: 'get', - }), - ).rejects.toThrow(expectedMessage); + await expect( + registry.callRegistry({ + image, + url: requestUrl, + method: 'get', + }), + ).rejects.toThrow(expectedMessage); - expect(axios).toHaveBeenCalledWith( - expect.objectContaining({ - url: requestUrl, - method: 'get', - headers: expect.objectContaining({ - Accept: 'application/json', - Authorization: expectedAuthorization, + expect(axios).toHaveBeenCalledWith( + expect.objectContaining({ + url: requestUrl, + method: 'get', + headers: expect.objectContaining({ + Accept: 'application/json', + Authorization: expectedAuthorization, + }), }), - }), - ); -}); + ); + }, +); diff --git a/app/registries/token-bucket.test.ts b/app/registries/token-bucket.test.ts index 91eb972aa..0b3cf6e6a 100644 --- a/app/registries/token-bucket.test.ts +++ b/app/registries/token-bucket.test.ts @@ -25,12 +25,15 @@ describe('getBucketForUrl', () => { ['https://api.github.com/repos/acme/svc/releases/tags/v1', 'api.github.com', 1, 3], ['https://registry.example.com/v2/img/tags/list', 'registry.example.com', 5, 10], ['https://quay.io/v2/acme/img/tags/list', 'quay.io', 5, 10], - ])('maps %s to host=%s ratePerSec=%d burst=%d', (url, expectedKey, expectedRate, expectedBurst) => { - const bucket = getBucketForUrl(url); - expect(bucket.key).toBe(expectedKey); - expect(bucket.ratePerSec).toBe(expectedRate); - expect(bucket.burst).toBe(expectedBurst); - }); + ])( + 'maps %s to host=%s ratePerSec=%d burst=%d', + (url, expectedKey, expectedRate, expectedBurst) => { + const bucket = getBucketForUrl(url); + expect(bucket.key).toBe(expectedKey); + expect(bucket.ratePerSec).toBe(expectedRate); + expect(bucket.burst).toBe(expectedBurst); + }, + ); }); describe('getOrCreateBucket guard', () => { diff --git a/app/registry/index.test.ts b/app/registry/index.test.ts index dfeaa51d7..da03670d1 100644 --- a/app/registry/index.test.ts +++ b/app/registry/index.test.ts @@ -281,35 +281,35 @@ test('registerRegistries should reject removed public token-only compatibility c expect(spyLog).not.toHaveBeenCalledWith(expect.stringContaining('Falling back to anonymous')); }); -test.each([ - 'hub', - 'dhi', -])('registerRegistries should not fallback %s.public when auth-only credentials are valid', async (provider) => { - const spyLog = vi.spyOn(registry.testable_log, 'warn'); - registries = { - [provider]: { - public: { - auth: 'valid-auth-token', +test.each(['hub', 'dhi'])( + 'registerRegistries should not fallback %s.public when auth-only credentials are valid', + async (provider) => { + const spyLog = vi.spyOn(registry.testable_log, 'warn'); + registries = { + [provider]: { + public: { + auth: 'valid-auth-token', + }, }, - }, - }; + }; - await registry.testable_registerRegistries(); + await registry.testable_registerRegistries(); - expect(Object.keys(registry.getState().registry)).not.toContain(`${provider}.public`); - expect( - spyLog.mock.calls.some(([message]) => - `${message}`.includes( - `Detected incompatible DD_REGISTRY_${provider.toUpperCase()}_PUBLIC_* token-auth credentials`, + expect(Object.keys(registry.getState().registry)).not.toContain(`${provider}.public`); + expect( + spyLog.mock.calls.some(([message]) => + `${message}`.includes( + `Detected incompatible DD_REGISTRY_${provider.toUpperCase()}_PUBLIC_* token-auth credentials`, + ), ), - ), - ).toBe(false); - expect( - spyLog.mock.calls.some(([message]) => - `${message}`.includes('Some registries failed to register'), - ), - ).toBe(true); -}); + ).toBe(false); + expect( + spyLog.mock.calls.some(([message]) => + `${message}`.includes('Some registries failed to register'), + ), + ).toBe(true); + }, +); test('registerRegistries should register defaults when registry configuration is undefined', async () => { const originalGetRegistryConfigurations = mockGetRegistryConfigurations.getMockImplementation(); @@ -433,67 +433,67 @@ test('registerRegistries should keep fail-closed behavior for incomplete hub.pri ); }); -test.each([ - 'hub', - 'dhi', -])('registerRegistries should not fallback %s.public when login/token auth is valid', async (provider) => { - const spyLog = vi.spyOn(registry.testable_log, 'warn'); - registries = { - [provider]: { - public: { - login: 'valid-user', - token: 'valid-token', +test.each(['hub', 'dhi'])( + 'registerRegistries should not fallback %s.public when login/token auth is valid', + async (provider) => { + const spyLog = vi.spyOn(registry.testable_log, 'warn'); + registries = { + [provider]: { + public: { + login: 'valid-user', + token: 'valid-token', + }, }, - }, - }; + }; - await registry.testable_registerRegistries(); + await registry.testable_registerRegistries(); - expect(Object.keys(registry.getState().registry)).toContain(`${provider}.public`); - expect( - spyLog.mock.calls.some(([message]) => - `${message}`.includes( - `Detected incompatible DD_REGISTRY_${provider.toUpperCase()}_PUBLIC_* token-auth credentials`, + expect(Object.keys(registry.getState().registry)).toContain(`${provider}.public`); + expect( + spyLog.mock.calls.some(([message]) => + `${message}`.includes( + `Detected incompatible DD_REGISTRY_${provider.toUpperCase()}_PUBLIC_* token-auth credentials`, + ), ), - ), - ).toBe(false); - expect( - spyLog.mock.calls.some(([message]) => - `${message}`.includes('Some registries failed to register'), - ), - ).toBe(false); -}); - -test.each([ - 'hub', - 'dhi', -])('registerRegistries should not fallback %s.public when login/password auth is valid', async (provider) => { - const spyLog = vi.spyOn(registry.testable_log, 'warn'); - registries = { - [provider]: { - public: { - login: 'valid-user', - password: 'valid-password', + ).toBe(false); + expect( + spyLog.mock.calls.some(([message]) => + `${message}`.includes('Some registries failed to register'), + ), + ).toBe(false); + }, +); + +test.each(['hub', 'dhi'])( + 'registerRegistries should not fallback %s.public when login/password auth is valid', + async (provider) => { + const spyLog = vi.spyOn(registry.testable_log, 'warn'); + registries = { + [provider]: { + public: { + login: 'valid-user', + password: 'valid-password', + }, }, - }, - }; + }; - await registry.testable_registerRegistries(); + await registry.testable_registerRegistries(); - expect(Object.keys(registry.getState().registry)).toContain(`${provider}.public`); - expect( - spyLog.mock.calls.some(([message]) => - `${message}`.includes( - `Detected incompatible DD_REGISTRY_${provider.toUpperCase()}_PUBLIC_* token-auth credentials`, + expect(Object.keys(registry.getState().registry)).toContain(`${provider}.public`); + expect( + spyLog.mock.calls.some(([message]) => + `${message}`.includes( + `Detected incompatible DD_REGISTRY_${provider.toUpperCase()}_PUBLIC_* token-auth credentials`, + ), ), - ), - ).toBe(false); - expect( - spyLog.mock.calls.some(([message]) => - `${message}`.includes('Some registries failed to register'), - ), - ).toBe(false); -}); + ).toBe(false); + expect( + spyLog.mock.calls.some(([message]) => + `${message}`.includes('Some registries failed to register'), + ), + ).toBe(false); + }, +); test('registerTriggers should register all triggers', async () => { triggers = { diff --git a/app/security/backends/docker.test.ts b/app/security/backends/docker.test.ts index 378bf7ce5..4c4c20ae1 100644 --- a/app/security/backends/docker.test.ts +++ b/app/security/backends/docker.test.ts @@ -75,17 +75,15 @@ describe('createDockerScannerBackend', () => { ).toThrow('requires a client'); }); - test.each([ - 'scanner-cache', - '/', - '/bad\0cache', - '/var/run/docker.sock', - ])('rejects unsafe cache directory %j', (cacheDir) => { - const { client } = createHarness(); - expect(() => createDockerScannerBackend({ client, cacheDir })).toThrow( - 'safe absolute provider cache directory', - ); - }); + test.each(['scanner-cache', '/', '/bad\0cache', '/var/run/docker.sock'])( + 'rejects unsafe cache directory %j', + (cacheDir) => { + const { client } = createHarness(); + expect(() => createDockerScannerBackend({ client, cacheDir })).toThrow( + 'safe absolute provider cache directory', + ); + }, + ); test.each([ { hardening: { cacheTarget: 'cache' }, message: 'cacheTarget' }, @@ -755,27 +753,27 @@ describe('createDockerScannerBackend', () => { expect(container.remove).toHaveBeenCalledWith({ force: true }); }); - test.each([ - new Error('stream failed'), - 'stream failed', - ])('reports attached stream failures and removes the worker', async (streamFailure) => { - const { backend, container, demuxStream, stream } = createHarness({ - wait: () => new Promise(() => undefined), - }); - demuxStream.mockImplementationOnce(() => { - queueMicrotask(() => stream.emit('error', streamFailure)); - }); + test.each([new Error('stream failed'), 'stream failed'])( + 'reports attached stream failures and removes the worker', + async (streamFailure) => { + const { backend, container, demuxStream, stream } = createHarness({ + wait: () => new Promise(() => undefined), + }); + demuxStream.mockImplementationOnce(() => { + queueMicrotask(() => stream.emit('error', streamFailure)); + }); - await expect( - backend.run({ - image: PINNED_IMAGE, - args: ['scan'], - timeoutMs: 1_000, - maxOutputBytes: 1_024, - }), - ).rejects.toThrow('stream failed'); - expect(container.remove).toHaveBeenCalledWith({ force: true }); - }); + await expect( + backend.run({ + image: PINNED_IMAGE, + args: ['scan'], + timeoutMs: 1_000, + maxOutputBytes: 1_024, + }), + ).rejects.toThrow('stream failed'); + expect(container.remove).toHaveBeenCalledWith({ force: true }); + }, + ); test('rejects an invalid worker exit status', async () => { const { backend } = createHarness({ wait: async () => ({ StatusCode: Number.NaN }) }); diff --git a/app/security/runtime.test.ts b/app/security/runtime.test.ts index 01dce1981..2b1eb4509 100644 --- a/app/security/runtime.test.ts +++ b/app/security/runtime.test.ts @@ -220,33 +220,33 @@ test('getSecurityRuntimeStatus should report missing trivy command', async () => ]); }); -test.each([ - 'EACCES', - 'EPERM', -])('getSecurityRuntimeStatus should report scanner command as unavailable when exec returns %s', async (errorCode) => { - mockGetSecurityConfiguration.mockReturnValue({ - ...createEnabledConfiguration(), - signature: { - ...createEnabledConfiguration().signature, - verify: false, - }, - }); - const execFileMock = vi.fn((_command, _args, _options, callback) => { - const error = new Error('permission denied') as NodeJS.ErrnoException; - error.code = errorCode; - callback(error, '', ''); - return { exitCode: 1 }; - }); - childProcessControl.execFileImpl = execFileMock; +test.each(['EACCES', 'EPERM'])( + 'getSecurityRuntimeStatus should report scanner command as unavailable when exec returns %s', + async (errorCode) => { + mockGetSecurityConfiguration.mockReturnValue({ + ...createEnabledConfiguration(), + signature: { + ...createEnabledConfiguration().signature, + verify: false, + }, + }); + const execFileMock = vi.fn((_command, _args, _options, callback) => { + const error = new Error('permission denied') as NodeJS.ErrnoException; + error.code = errorCode; + callback(error, '', ''); + return { exitCode: 1 }; + }); + childProcessControl.execFileImpl = execFileMock; - const status = await getSecurityRuntimeStatus(); + const status = await getSecurityRuntimeStatus(); - expect(status.ready).toBe(false); - expect(status.scanner.status).toBe('missing'); - expect(status.scanner.commandAvailable).toBe(false); - expect(status.scanner.message).toContain('not available'); - expect(status.requirements).toContain('Install trivy (configured command: "trivy")'); -}); + expect(status.ready).toBe(false); + expect(status.scanner.status).toBe('missing'); + expect(status.scanner.commandAvailable).toBe(false); + expect(status.scanner.message).toContain('not available'); + expect(status.requirements).toContain('Install trivy (configured command: "trivy")'); + }, +); test('getSecurityRuntimeStatus should report disabled scanner when not configured', async () => { mockGetSecurityConfiguration.mockReturnValue({ @@ -451,24 +451,27 @@ test('getSecurityRuntimeStatus explains missing Docker scanner and SBOM assets', test.each([ [new Error('Docker daemon unavailable'), 'Docker daemon unavailable'], ['socket closed', 'Docker scanner runtime is unavailable'], -])('getSecurityRuntimeStatus normalizes Docker runtime status failures', async (failure, message) => { - mockGetSecurityConfiguration.mockReturnValue({ - ...createEnabledConfiguration(), - scanner: 'grype', - backend: 'docker', - signature: { ...createEnabledConfiguration().signature, verify: false }, - sbom: { enabled: true, formats: ['spdx-json'], generator: 'syft' }, - }); - mockScannerAssetsStatus.mockRejectedValue(failure); +])( + 'getSecurityRuntimeStatus normalizes Docker runtime status failures', + async (failure, message) => { + mockGetSecurityConfiguration.mockReturnValue({ + ...createEnabledConfiguration(), + scanner: 'grype', + backend: 'docker', + signature: { ...createEnabledConfiguration().signature, verify: false }, + sbom: { enabled: true, formats: ['spdx-json'], generator: 'syft' }, + }); + mockScannerAssetsStatus.mockRejectedValue(failure); - const status = await getSecurityRuntimeStatus(); + const status = await getSecurityRuntimeStatus(); - expect(status.ready).toBe(false); - expect(status.providers).toEqual([ - expect.objectContaining({ provider: 'grype', role: 'scanner', message }), - expect.objectContaining({ provider: 'syft', role: 'sbom', message }), - ]); -}); + expect(status.ready).toBe(false); + expect(status.providers).toEqual([ + expect.objectContaining({ provider: 'grype', role: 'scanner', message }), + expect.objectContaining({ provider: 'syft', role: 'sbom', message }), + ]); + }, +); test('getScannerAssetManager returns the default runtime manager', () => { expect(getScannerAssetManager().status).toBe(mockScannerAssetsStatus); @@ -719,30 +722,30 @@ test('getSecurityRuntimeStatus should reject relative signature command paths', expect(status.signature.message).toContain('invalid'); }); -test.each([ - 'EACCES', - 'EPERM', -])('getSecurityRuntimeStatus should report signature command as unavailable when exec returns %s', async (errorCode) => { - const execFileMock = vi.fn((command, _args, _options, callback) => { - if (command === 'trivy') { - callback(null, 'trivy 0.1.0', ''); - return { exitCode: 0 }; - } - const error = new Error('permission denied') as NodeJS.ErrnoException; - error.code = errorCode; - callback(error, '', ''); - return { exitCode: 1 }; - }); - childProcessControl.execFileImpl = execFileMock; +test.each(['EACCES', 'EPERM'])( + 'getSecurityRuntimeStatus should report signature command as unavailable when exec returns %s', + async (errorCode) => { + const execFileMock = vi.fn((command, _args, _options, callback) => { + if (command === 'trivy') { + callback(null, 'trivy 0.1.0', ''); + return { exitCode: 0 }; + } + const error = new Error('permission denied') as NodeJS.ErrnoException; + error.code = errorCode; + callback(error, '', ''); + return { exitCode: 1 }; + }); + childProcessControl.execFileImpl = execFileMock; - const status = await getSecurityRuntimeStatus(); + const status = await getSecurityRuntimeStatus(); - expect(status.ready).toBe(false); - expect(status.signature.status).toBe('missing'); - expect(status.signature.commandAvailable).toBe(false); - expect(status.signature.message).toContain('not available'); - expect(status.requirements).toContain('Install cosign (configured command: "cosign")'); -}); + expect(status.ready).toBe(false); + expect(status.signature.status).toBe('missing'); + expect(status.signature.commandAvailable).toBe(false); + expect(status.signature.message).toContain('not available'); + expect(status.requirements).toContain('Install cosign (configured command: "cosign")'); + }, +); test('getSecurityRuntimeStatus should report scanner command as unavailable when exec returns ETIMEDOUT', async () => { mockGetSecurityConfiguration.mockReturnValue({ diff --git a/app/security/sbom-storage.test.ts b/app/security/sbom-storage.test.ts index 006ea6eb5..ed199875b 100644 --- a/app/security/sbom-storage.test.ts +++ b/app/security/sbom-storage.test.ts @@ -170,25 +170,21 @@ describe('SBOM storage', () => { ).rejects.toThrow('Invalid SBOM JSON document'); }); - test.each([ - '../spdx-json', - 'SPDX-JSON', - 'json', - '', - 'spdx-json\\escape', - 'spdx-json\0', - ])('rejects unsupported write format %j', async (format) => { - const storage = createSbomStorage({ rootDir }); - - await expect( - storage.writeDocument({ - subjectDigest: VALID_DIGEST, - image: 'registry.example/app:latest', - format: format as never, - document: {}, - }), - ).rejects.toThrow('Unsupported SBOM format'); - }); + test.each(['../spdx-json', 'SPDX-JSON', 'json', '', 'spdx-json\\escape', 'spdx-json\0'])( + 'rejects unsupported write format %j', + async (format) => { + const storage = createSbomStorage({ rootDir }); + + await expect( + storage.writeDocument({ + subjectDigest: VALID_DIGEST, + image: 'registry.example/app:latest', + format: format as never, + document: {}, + }), + ).rejects.toThrow('Unsupported SBOM format'); + }, + ); test.each([ '/tmp/document.json', @@ -237,16 +233,14 @@ describe('SBOM storage', () => { ); }); - test.each([ - 0, - -1, - 1.5, - Number.MAX_SAFE_INTEGER + 1, - ])('rejects invalid maxDocumentBytes %j', (maxDocumentBytes) => { - expect(() => createSbomStorage({ rootDir, maxDocumentBytes })).toThrow( - 'maxDocumentBytes must be a positive integer', - ); - }); + test.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid maxDocumentBytes %j', + (maxDocumentBytes) => { + expect(() => createSbomStorage({ rootDir, maxDocumentBytes })).toThrow( + 'maxDocumentBytes must be a positive integer', + ); + }, + ); test('rejects oversized writes without replacing the existing valid document', async () => { const storage = createSbomStorage({ rootDir, maxDocumentBytes: 32 }); diff --git a/app/security/scan.test.ts b/app/security/scan.test.ts index cfe676406..32d3bc70f 100644 --- a/app/security/scan.test.ts +++ b/app/security/scan.test.ts @@ -1248,26 +1248,29 @@ test.each([ 'net/http: TLS handshake timeout', 'dial tcp 10.0.0.2:443: connect: connection refused', 'lookup registry.example.com: no such host', -])('scanImageForVulnerabilities should retry realistic transient Trivy stderr: %s', async (stderr) => { - let invocations = 0; - childProcessControl.execFileImpl = (_command, _args, _options, callback) => { - invocations += 1; - const child = { exitCode: invocations === 1 ? 1 : 0 }; - if (invocations === 1) { - const error = new Error('Trivy failed') as NodeJS.ErrnoException; - error.code = '1'; - setTimeout(() => callback(error, '', stderr), 0); - } else { - setTimeout(() => callback(null, JSON.stringify({ Results: [] }), ''), 0); - } - return child; - }; +])( + 'scanImageForVulnerabilities should retry realistic transient Trivy stderr: %s', + async (stderr) => { + let invocations = 0; + childProcessControl.execFileImpl = (_command, _args, _options, callback) => { + invocations += 1; + const child = { exitCode: invocations === 1 ? 1 : 0 }; + if (invocations === 1) { + const error = new Error('Trivy failed') as NodeJS.ErrnoException; + error.code = '1'; + setTimeout(() => callback(error, '', stderr), 0); + } else { + setTimeout(() => callback(null, JSON.stringify({ Results: [] }), ''), 0); + } + return child; + }; - const result = await scanImageForVulnerabilities({ image: 'img:test', retryTransient: true }); + const result = await scanImageForVulnerabilities({ image: 'img:test', retryTransient: true }); - expect(result.status).toBe('passed'); - expect(invocations).toBe(2); -}); + expect(result.status).toBe('passed'); + expect(invocations).toBe(2); + }, +); test('scanImageForVulnerabilities should stop after two transient failures', async () => { let invocations = 0; diff --git a/app/store/container.test.ts b/app/store/container.test.ts index 237bd9bd2..de49cae69 100644 --- a/app/store/container.test.ts +++ b/app/store/container.test.ts @@ -384,7 +384,60 @@ test('updateContainer should keep overrides when a declarative label is removed' expect(updated.updatePolicySources).toEqual({ skipTags: 'override' }); }); -test('updateContainer should honor an explicit empty override layer', () => { +test('updateContainer should preserve existing overrides for a non-authoritative empty override layer', () => { + const existingContainer = { + data: createContainerFixture({ + updatePolicy: { maturityMode: 'all' }, + updatePolicyDeclarative: { env: { maturityMode: 'mature' }, label: {} }, + updatePolicyOverrides: { maturityMode: 'all' }, + updatePolicySources: { maturityMode: 'override' }, + }), + }; + const collection = { findOne: () => existingContainer, update: vi.fn() }; + container.createCollections({ getCollection: () => collection, addCollection: () => null }); + + const updated = container.updateContainer( + createContainerFixture({ + updatePolicy: { maturityMode: 'mature' }, + updatePolicyDeclarative: { env: { maturityMode: 'mature' }, label: {} }, + updatePolicyOverrides: {}, + updatePolicySources: { maturityMode: 'env' }, + }), + ); + + expect(updated.updatePolicyOverrides).toEqual({ maturityMode: 'all' }); + expect(updated.updatePolicy).toEqual({ maturityMode: 'all' }); + expect(updated.updatePolicySources).toEqual({ maturityMode: 'override' }); +}); + +test('updateContainer should clear overrides for an authoritative empty override layer', () => { + const existingContainer = { + data: createContainerFixture({ + updatePolicy: { maturityMode: 'all' }, + updatePolicyDeclarative: { env: { maturityMode: 'mature' }, label: {} }, + updatePolicyOverrides: { maturityMode: 'all' }, + updatePolicySources: { maturityMode: 'override' }, + }), + }; + const collection = { findOne: () => existingContainer, update: vi.fn() }; + container.createCollections({ getCollection: () => collection, addCollection: () => null }); + + const updated = container.updateContainer( + createContainerFixture({ + updatePolicy: { maturityMode: 'mature' }, + updatePolicyDeclarative: { env: { maturityMode: 'mature' }, label: {} }, + updatePolicyOverrides: {}, + updatePolicySources: { maturityMode: 'env' }, + }), + { authoritativeEmptyOverrides: true }, + ); + + expect(updated.updatePolicyOverrides).toEqual({}); + expect(updated.updatePolicy).toEqual({ maturityMode: 'mature' }); + expect(updated.updatePolicySources).toEqual({ maturityMode: 'env' }); +}); + +test('updateContainer should normalize an authoritative undefined override layer to empty', () => { const existingContainer = { data: createContainerFixture({ updatePolicy: { maturityMode: 'all' }, @@ -403,10 +456,98 @@ test('updateContainer should honor an explicit empty override layer', () => { updatePolicyOverrides: undefined, updatePolicySources: { maturityMode: 'env' }, }), + { authoritativeEmptyOverrides: true }, ); expect(updated.updatePolicyOverrides).toEqual({}); expect(updated.updatePolicy).toEqual({ maturityMode: 'mature' }); + expect(updated.updatePolicySources).toEqual({ maturityMode: 'env' }); +}); + +test('updateContainer should honor a non-empty incoming override layer without an authority flag', () => { + const existingContainer = { + data: createContainerFixture({ + updatePolicy: { maturityMode: 'all', maturityMinAgeDays: 7 }, + updatePolicyDeclarative: { + env: { maturityMode: 'mature', maturityMinAgeDays: 7 }, + label: {}, + }, + updatePolicyOverrides: { maturityMode: 'all' }, + updatePolicySources: { + maturityMode: 'override', + maturityMinAgeDays: 'env', + }, + }), + }; + const collection = { findOne: () => existingContainer, update: vi.fn() }; + container.createCollections({ getCollection: () => collection, addCollection: () => null }); + + const updated = container.updateContainer( + createContainerFixture({ + updatePolicy: { maturityMode: 'mature', maturityMinAgeDays: 21 }, + updatePolicyDeclarative: { + env: { maturityMode: 'mature', maturityMinAgeDays: 7 }, + label: {}, + }, + updatePolicyOverrides: { maturityMinAgeDays: 21 }, + updatePolicySources: { + maturityMode: 'env', + maturityMinAgeDays: 'override', + }, + }), + ); + + expect(updated.updatePolicyOverrides).toEqual({ maturityMinAgeDays: 21 }); + expect(updated.updatePolicy).toEqual({ maturityMode: 'mature', maturityMinAgeDays: 21 }); + expect(updated.updatePolicySources).toEqual({ + maturityMode: 'env', + maturityMinAgeDays: 'override', + }); +}); + +test('updateContainer should preserve overrides when an empty layer omits declarative policy', () => { + const existingContainer = { + data: createContainerFixture({ + updatePolicy: { maturityMode: 'all' }, + updatePolicyDeclarative: { env: { maturityMode: 'mature' }, label: {} }, + updatePolicyOverrides: { maturityMode: 'all' }, + updatePolicySources: { maturityMode: 'override' }, + }), + }; + const collection = { findOne: () => existingContainer, update: vi.fn() }; + container.createCollections({ getCollection: () => collection, addCollection: () => null }); + + const updated = container.updateContainer( + createContainerFixture({ + updatePolicyOverrides: {}, + }), + ); + + expect(updated.updatePolicyDeclarative).toEqual({ + env: { maturityMode: 'mature' }, + label: {}, + }); + expect(updated.updatePolicyOverrides).toEqual({ maturityMode: 'all' }); + expect(updated.updatePolicy).toEqual({ maturityMode: 'all' }); + expect(updated.updatePolicySources).toEqual({ maturityMode: 'override' }); +}); + +test('updateContainer should keep an empty override layer on the first write', () => { + const collection = createFilterableCollection([]); + container.createCollections({ getCollection: () => collection, addCollection: () => null }); + + const updated = container.updateContainer( + createContainerFixture({ + updatePolicy: { maturityMode: 'mature' }, + updatePolicyDeclarative: { env: { maturityMode: 'mature' }, label: {} }, + updatePolicyOverrides: {}, + updatePolicySources: { maturityMode: 'env' }, + }), + ); + + expect(updated.updatePolicyOverrides).toEqual({}); + expect(updated.updatePolicy).toEqual({ maturityMode: 'mature' }); + expect(updated.updatePolicySources).toEqual({ maturityMode: 'env' }); }); test('updateContainer should resolve a declarative policy without a current stored container', () => { @@ -2375,7 +2516,7 @@ test('getContainersForStats should return projected stat fields only', async () }); // Heavy fields are NOT present on the projection - expect((projection.security?.scan as Record).vulnerabilities).toBeUndefined(); + expect((projection.security!.scan as Record).vulnerabilities).toBeUndefined(); expect((projection as Record).details).toBeUndefined(); expect((projection as Record).labels).toBeUndefined(); expect((projection as Record).result).toBeUndefined(); @@ -4192,21 +4333,20 @@ describe('container unhealthy transition emission', () => { { previous: 'unhealthy', incoming: 'unhealthy', change: { status: 'stopped' } }, { previous: 'unhealthy', incoming: 'healthy', change: {} }, { previous: 'unhealthy', incoming: undefined, change: {} }, - ])('does not emit for previous=$previous incoming=$incoming', ({ - previous, - incoming, - change, - }) => { - const existing = healthFixture({ - health: previous, - status: 'running', - details: { startedAt: '2026-01-01T00:00:00.000Z' }, - }); - initialize(existing); - const emitted = vi.spyOn(event, 'emitContainerHealthTransition'); - container.updateContainer({ ...existing, ...change, health: incoming }); - expect(emitted).not.toHaveBeenCalled(); - }); + ])( + 'does not emit for previous=$previous incoming=$incoming', + ({ previous, incoming, change }) => { + const existing = healthFixture({ + health: previous, + status: 'running', + details: { startedAt: '2026-01-01T00:00:00.000Z' }, + }); + initialize(existing); + const emitted = vi.spyOn(event, 'emitContainerHealthTransition'); + container.updateContainer({ ...existing, ...change, health: incoming }); + expect(emitted).not.toHaveBeenCalled(); + }, + ); test('consecutive unhealthy observations emit again when startedAt changes', () => { const existing = healthFixture({ diff --git a/app/store/container.ts b/app/store/container.ts index 61986eb62..c4004a176 100644 --- a/app/store/container.ts +++ b/app/store/container.ts @@ -1021,6 +1021,16 @@ function stashUpdatePolicyForReplacement(containerRaw) { } } +/** + * #565: a present-but-empty updatePolicyOverrides layer is watcher/agent normalization, not + * controller intent, and must never replace stored overrides. Only a non-empty layer — or an + * empty one from an explicitly authoritative caller (the update-policy PATCH handler clearing + * the last override) — carries intent. + */ +function isAuthoritativeOverrideLayer(overrides, authoritativeEmptyOverrides = false) { + return authoritativeEmptyOverrides || Object.keys(overrides ?? {}).length > 0; +} + /** * #496: restore a retained updatePolicy onto a replacement container. The entry is consumed * either way, so a stale policy can never attach to a later, unrelated container. @@ -1038,13 +1048,9 @@ function restoreRetainedUpdatePolicy(container) { if (entry.expiresAt <= Date.now()) { return; } - // A non-empty incoming controller layer is authoritative. Watcher normalization also stamps - // updatePolicyOverrides={} on fresh declarative data; that empty layer carries no controller - // intent and must not discard the retained overrides from the container being replaced. - if ( - container.updatePolicyOverrides !== undefined && - Object.keys(container.updatePolicyOverrides).length > 0 - ) { + // A non-empty incoming controller layer is authoritative and must not be replaced; an empty + // one must not discard the retained overrides from the container being replaced. + if (isAuthoritativeOverrideLayer(container.updatePolicyOverrides)) { return; } if (container.updatePolicyDeclarative !== undefined) { @@ -1133,10 +1139,21 @@ export function insertContainer(container) { * Update existing container. * @param container */ -export function updateContainer(container) { +export function updateContainer( + container, + options: { authoritativeEmptyOverrides?: boolean } = {}, +) { const hasUpdatePolicy = Object.hasOwn(container, 'updatePolicy'); const hasUpdatePolicyDeclarative = Object.hasOwn(container, 'updatePolicyDeclarative'); - const hasUpdatePolicyOverrides = Object.hasOwn(container, 'updatePolicyOverrides'); + // #565: an incoming override layer only participates in the merge when it carries controller + // intent (see isAuthoritativeOverrideLayer); the PATCH handler signals a deliberate clear via + // options.authoritativeEmptyOverrides. + const hasUpdatePolicyOverrides = + Object.hasOwn(container, 'updatePolicyOverrides') && + isAuthoritativeOverrideLayer( + container.updatePolicyOverrides, + options.authoritativeEmptyOverrides === true, + ); const hasUpdateRollback = Object.hasOwn(container, 'updateRollback'); const hasSecurity = Object.hasOwn(container, 'security'); const hasDetails = Object.hasOwn(container, 'details'); diff --git a/app/store/notification.ts b/app/store/notification.ts index 3186b0ed4..144532e8e 100644 --- a/app/store/notification.ts +++ b/app/store/notification.ts @@ -60,7 +60,8 @@ export type NotificationRuleDispatchReason = | 'allow-all-when-empty' | 'empty-trigger-list' | 'matched-allow-list' - | 'excluded-from-allow-list'; + | 'excluded-from-allow-list' + | 'action-trigger-exempt-from-allow-list'; export interface NotificationRuleDispatchDecision { enabled: boolean; diff --git a/app/tag/index.test.ts b/app/tag/index.test.ts index c7daf601c..66185a6b5 100644 --- a/app/tag/index.test.ts +++ b/app/tag/index.test.ts @@ -161,13 +161,12 @@ describe('isGreater', () => { }, ]; - test.each(comparisonTests)('should handle $desc: $v1 >= $v2 = $expected', ({ - v1, - v2, - expected, - }) => { - expect(semver.isGreater(v1, v2)).toBe(expected); - }); + test.each(comparisonTests)( + 'should handle $desc: $v1 >= $v2 = $expected', + ({ v1, v2, expected }) => { + expect(semver.isGreater(v1, v2)).toBe(expected); + }, + ); }); describe('diff', () => { @@ -235,13 +234,12 @@ describe('diff', () => { }, ]; - test.each(diffTests)('should detect $desc: diff($v1, $v2) = $expected', ({ - v1, - v2, - expected, - }) => { - expect(semver.diff(v1, v2)).toBe(expected); - }); + test.each(diffTests)( + 'should detect $desc: diff($v1, $v2) = $expected', + ({ v1, v2, expected }) => { + expect(semver.diff(v1, v2)).toBe(expected); + }, + ); }); describe('transform', () => { diff --git a/app/triggers/providers/Trigger.test.ts b/app/triggers/providers/Trigger.test.ts index 3c895e827..9ecf954ee 100644 --- a/app/triggers/providers/Trigger.test.ts +++ b/app/triggers/providers/Trigger.test.ts @@ -557,88 +557,89 @@ const handleContainerReportTestCases = [ }, ]; -test.each( - handleContainerReportTestCases, -)('handleContainerReport should call trigger? ($shouldTrigger) when changed=$changed and updateAvailable=$updateAvailable and threshold=$threshold', async (item) => { - trigger.configuration = { - threshold: item.threshold, - once: item.once, - mode: 'simple', - }; - await trigger.init(); +test.each(handleContainerReportTestCases)( + 'handleContainerReport should call trigger? ($shouldTrigger) when changed=$changed and updateAvailable=$updateAvailable and threshold=$threshold', + async (item) => { + trigger.configuration = { + threshold: item.threshold, + once: item.once, + mode: 'simple', + }; + await trigger.init(); - const spy = vi.spyOn(trigger, 'trigger'); - await trigger.handleContainerReport({ - changed: item.changed, - container: { - name: 'container1', - updateAvailable: item.updateAvailable, - updateKind: { - kind: item.kind, - semverDiff: item.semverDiff, - }, - }, - }); - if (item.shouldTrigger) { - expect(spy).toHaveBeenCalledWith({ - name: 'container1', - updateAvailable: item.updateAvailable, - updateKind: { - kind: item.kind, - semverDiff: item.semverDiff, + const spy = vi.spyOn(trigger, 'trigger'); + await trigger.handleContainerReport({ + changed: item.changed, + container: { + name: 'container1', + updateAvailable: item.updateAvailable, + updateKind: { + kind: item.kind, + semverDiff: item.semverDiff, + }, }, }); - } else { - expect(spy).not.toHaveBeenCalled(); - } -}); + if (item.shouldTrigger) { + expect(spy).toHaveBeenCalledWith({ + name: 'container1', + updateAvailable: item.updateAvailable, + updateKind: { + kind: item.kind, + semverDiff: item.semverDiff, + }, + }); + } else { + expect(spy).not.toHaveBeenCalled(); + } + }, +); -test.each([ - 'manual', - 'notify', -] as const)('%s mode suppresses automatic action triggers without suppressing notification triggers', async (updateMode) => { - mockGetUpdateMode.mockReturnValue(updateMode); - const report = { - changed: true, - container: { - id: 'c1', - name: 'container1', - updateAvailable: true, - updateKind: { kind: 'tag', semverDiff: 'major' }, - }, - } as any; +test.each(['manual', 'notify'] as const)( + '%s mode suppresses automatic action triggers without suppressing notification triggers', + async (updateMode) => { + mockGetUpdateMode.mockReturnValue(updateMode); + const report = { + changed: true, + container: { + id: 'c1', + name: 'container1', + updateAvailable: true, + updateKind: { kind: 'tag', semverDiff: 'major' }, + }, + } as any; - trigger.type = 'docker'; - const actionSpy = vi.spyOn(trigger, 'trigger').mockResolvedValue(undefined); - await trigger.handleContainerReport(report); - expect(actionSpy).not.toHaveBeenCalled(); + trigger.type = 'docker'; + const actionSpy = vi.spyOn(trigger, 'trigger').mockResolvedValue(undefined); + await trigger.handleContainerReport(report); + expect(actionSpy).not.toHaveBeenCalled(); - trigger.type = 'slack'; - const notificationSpy = vi.spyOn(trigger, 'trigger').mockResolvedValue(undefined); - await trigger.handleContainerReport(report); - expect(notificationSpy).toHaveBeenCalledWith(report.container); -}); + trigger.type = 'slack'; + const notificationSpy = vi.spyOn(trigger, 'trigger').mockResolvedValue(undefined); + await trigger.handleContainerReport(report); + expect(notificationSpy).toHaveBeenCalledWith(report.container); + }, +); -test.each([ - 'manual', - 'notify', -] as const)('%s mode suppresses automatic Command triggers in simple mode', async (updateMode) => { - mockGetUpdateMode.mockReturnValue(updateMode); - trigger.type = 'command'; - const commandSpy = vi.spyOn(trigger, 'trigger').mockResolvedValue(undefined); +test.each(['manual', 'notify'] as const)( + '%s mode suppresses automatic Command triggers in simple mode', + async (updateMode) => { + mockGetUpdateMode.mockReturnValue(updateMode); + trigger.type = 'command'; + const commandSpy = vi.spyOn(trigger, 'trigger').mockResolvedValue(undefined); - await trigger.handleContainerReport({ - changed: true, - container: { - id: 'c1', - name: 'container1', - updateAvailable: true, - updateKind: { kind: 'tag', semverDiff: 'major' }, - }, - } as any); + await trigger.handleContainerReport({ + changed: true, + container: { + id: 'c1', + name: 'container1', + updateAvailable: true, + updateKind: { kind: 'tag', semverDiff: 'major' }, + }, + } as any); - expect(commandSpy).not.toHaveBeenCalled(); -}); + expect(commandSpy).not.toHaveBeenCalled(); + }, +); test('simple action dispatch rechecks mode before enqueueing when mode changes mid-report', async () => { mockGetUpdateMode.mockReturnValueOnce('auto').mockReturnValueOnce('manual'); @@ -901,45 +902,46 @@ const handleContainerReportsTestCases = [ }, ]; -test.each( - handleContainerReportsTestCases, -)('handleContainerReports should call triggerBatch? ($shouldTrigger) when changed=$changed and updateAvailable=$updateAvailable and threshold=$threshold', async (item) => { - trigger.configuration = { - threshold: item.threshold, - once: item.once, - mode: 'simple', - }; - await trigger.init(); +test.each(handleContainerReportsTestCases)( + 'handleContainerReports should call triggerBatch? ($shouldTrigger) when changed=$changed and updateAvailable=$updateAvailable and threshold=$threshold', + async (item) => { + trigger.configuration = { + threshold: item.threshold, + once: item.once, + mode: 'simple', + }; + await trigger.init(); - const spy = vi.spyOn(trigger, 'triggerBatch'); - await trigger.handleContainerReports([ - { - changed: item.changed, - container: { - name: 'container1', - updateAvailable: item.updateAvailable, - updateKind: { - kind: 'tag', - semverDiff: item.semverDiff, - }, - }, - }, - ]); - if (item.shouldTrigger) { - expect(spy).toHaveBeenCalledWith([ + const spy = vi.spyOn(trigger, 'triggerBatch'); + await trigger.handleContainerReports([ { - name: 'container1', - updateAvailable: item.updateAvailable, - updateKind: { - kind: 'tag', - semverDiff: item.semverDiff, + changed: item.changed, + container: { + name: 'container1', + updateAvailable: item.updateAvailable, + updateKind: { + kind: 'tag', + semverDiff: item.semverDiff, + }, }, }, ]); - } else { - expect(spy).not.toHaveBeenCalled(); - } -}); + if (item.shouldTrigger) { + expect(spy).toHaveBeenCalledWith([ + { + name: 'container1', + updateAvailable: item.updateAvailable, + updateKind: { + kind: 'tag', + semverDiff: item.semverDiff, + }, + }, + ]); + } else { + expect(spy).not.toHaveBeenCalled(); + } + }, +); const isThresholdReachedTestCases = [ { @@ -1070,24 +1072,25 @@ const isThresholdReachedTestCases = [ }, ]; -test.each( - isThresholdReachedTestCases, -)('isThresholdReached should return $result when threshold is $threshold and change is $change', (item) => { - trigger.configuration = { - threshold: item.threshold, - }; - expect( - Trigger.isThresholdReached( - { - updateKind: { - kind: item.kind, - semverDiff: item.change, +test.each(isThresholdReachedTestCases)( + 'isThresholdReached should return $result when threshold is $threshold and change is $change', + (item) => { + trigger.configuration = { + threshold: item.threshold, + }; + expect( + Trigger.isThresholdReached( + { + updateKind: { + kind: item.kind, + semverDiff: item.change, + }, }, - }, - trigger.configuration.threshold, - ), - ).toEqual(item.result); -}); + trigger.configuration.threshold, + ), + ).toEqual(item.result); + }, +); test('isThresholdReached should return true when there is no semverDiff regardless of the threshold', async () => { trigger.configuration = { @@ -3271,6 +3274,116 @@ test('handleContainerReport should debug log when update-available rule suppress ); }); +describe('bug #623: update-available allow-list routing for action triggers', () => { + const notificationOnlyAllowList = ['slack.ops']; + + function mockUpdateAvailableRule(enabled = true) { + notificationStore.getTriggerDispatchDecisionForRule.mockImplementation((_ruleId, triggerId) => { + if (!enabled) { + return { enabled: false, reason: 'rule-disabled' }; + } + return notificationOnlyAllowList.includes(triggerId) + ? { enabled: true, reason: 'matched-allow-list' } + : { enabled: false, reason: 'excluded-from-allow-list' }; + }); + } + + function updateAvailableReport(id: string, agent?: string) { + return { + changed: true, + container: { + id, + agent, + watcher: 'local', + name: 'container1', + updateAvailable: true, + updateKind: { kind: 'tag', semverDiff: 'major' }, + }, + }; + } + + test('docker action dispatches when absent from a notification-only allow-list', async () => { + mockUpdateAvailableRule(); + trigger.type = 'docker'; + trigger.name = 'update'; + const triggerSpy = vi.spyOn(trigger, 'trigger').mockResolvedValue(undefined); + + await trigger.handleContainerReport(updateAvailableReport('issue-623-docker')); + + expect(triggerSpy).toHaveBeenCalled(); + expect(notificationStore.getTriggerDispatchDecisionForRule).toHaveBeenCalledWith( + 'update-available', + 'docker.update', + expect.objectContaining({ allowAllWhenNoTriggers: true, defaultWhenRuleMissing: true }), + ); + }); + + test('command action dispatches when absent from a notification-only allow-list', async () => { + mockUpdateAvailableRule(); + trigger.type = 'command'; + trigger.name = 'update'; + const triggerSpy = vi.spyOn(trigger, 'trigger').mockResolvedValue(undefined); + + await trigger.handleContainerReport(updateAvailableReport('issue-623-command')); + + expect(triggerSpy).toHaveBeenCalled(); + expect(notificationStore.getTriggerDispatchDecisionForRule).toHaveBeenCalledWith( + 'update-available', + 'command.update', + expect.objectContaining({ allowAllWhenNoTriggers: true, defaultWhenRuleMissing: true }), + ); + }); + + test('agent-prefixed docker action dispatches based on category, not trigger id shape', async () => { + mockUpdateAvailableRule(); + trigger.agent = 'agentname'; + trigger.type = 'docker'; + trigger.name = 'update'; + const triggerSpy = vi.spyOn(trigger, 'trigger').mockResolvedValue(undefined); + + await trigger.handleContainerReport( + updateAvailableReport('issue-623-agent-docker', 'agentname'), + ); + + expect(triggerSpy).toHaveBeenCalled(); + expect(notificationStore.getTriggerDispatchDecisionForRule).toHaveBeenCalledWith( + 'update-available', + 'agentname.docker.update', + expect.objectContaining({ allowAllWhenNoTriggers: true, defaultWhenRuleMissing: true }), + ); + }); + + test('disabled update-available rule still blocks an action trigger', async () => { + mockUpdateAvailableRule(false); + trigger.type = 'docker'; + trigger.name = 'update'; + const triggerSpy = vi.spyOn(trigger, 'trigger').mockResolvedValue(undefined); + const debugSpy = vi.spyOn(log, 'debug'); + + await trigger.handleContainerReport(updateAvailableReport('issue-623-disabled')); + + expect(triggerSpy).not.toHaveBeenCalled(); + expect(debugSpy).toHaveBeenCalledWith( + 'Skipping update-available notification for local_container1 (rule-disabled)', + ); + }); + + test('notification trigger absent from a non-empty allow-list remains excluded', async () => { + mockUpdateAvailableRule(); + trigger.type = 'slack'; + trigger.name = 'other'; + const triggerSpy = vi.spyOn(trigger, 'trigger').mockResolvedValue(undefined); + const debugSpy = vi.spyOn(log, 'debug'); + + await trigger.handleContainerReport(updateAvailableReport('issue-623-slack')); + + expect(triggerSpy).not.toHaveBeenCalled(); + expect(debugSpy).toHaveBeenCalledWith( + 'Skipping update-available notification for local_container1 (excluded-from-allow-list)', + ); + }); +}); + test('handleContainerReport should debug log when simple mode skips an already-notified update', async () => { await trigger.register('trigger', 'test', 'trigger1', configurationValid); trigger.init(); @@ -7072,52 +7185,52 @@ describe('digest mode', () => { expect((trigger as any).digestBuffer.size).toBe(0); }); - test.each([ - 'manual', - 'notify', - ] as const)('Command digest reports are not buffered in %s mode', async (updateMode) => { - mockGetUpdateMode.mockReturnValue(updateMode); - trigger.type = 'command'; - trigger.configuration.mode = 'digest'; + test.each(['manual', 'notify'] as const)( + 'Command digest reports are not buffered in %s mode', + async (updateMode) => { + mockGetUpdateMode.mockReturnValue(updateMode); + trigger.type = 'command'; + trigger.configuration.mode = 'digest'; - await trigger.handleContainerReportDigest({ - changed: true, - container: { + await trigger.handleContainerReportDigest({ + changed: true, + container: { + id: 'c1', + name: 'app', + watcher: 'test', + updateAvailable: true, + updateKind: { kind: 'tag', localValue: '1.0', remoteValue: '2.0' }, + }, + } as any); + + expect((trigger as any).digestBuffer.size).toBe(0); + }, + ); + + test.each(['manual', 'notify'] as const)( + 'Command digest flush preserves buffered updates in %s mode', + async (updateMode) => { + mockGetUpdateMode.mockReturnValue(updateMode); + trigger.type = 'command'; + trigger.configuration.mode = 'digest'; + const container = { id: 'c1', name: 'app', watcher: 'test', updateAvailable: true, updateKind: { kind: 'tag', localValue: '1.0', remoteValue: '2.0' }, - }, - } as any); - - expect((trigger as any).digestBuffer.size).toBe(0); - }); - - test.each([ - 'manual', - 'notify', - ] as const)('Command digest flush preserves buffered updates in %s mode', async (updateMode) => { - mockGetUpdateMode.mockReturnValue(updateMode); - trigger.type = 'command'; - trigger.configuration.mode = 'digest'; - const container = { - id: 'c1', - name: 'app', - watcher: 'test', - updateAvailable: true, - updateKind: { kind: 'tag', localValue: '1.0', remoteValue: '2.0' }, - }; - (trigger as any).digestBuffer.set(container.id, container); - storeContainer.getContainersRaw.mockReturnValue([container]); - const triggerBatchSpy = vi.spyOn(trigger, 'triggerBatch').mockResolvedValue(undefined); + }; + (trigger as any).digestBuffer.set(container.id, container); + storeContainer.getContainersRaw.mockReturnValue([container]); + const triggerBatchSpy = vi.spyOn(trigger, 'triggerBatch').mockResolvedValue(undefined); - await trigger.flushDigestBuffer(); + await trigger.flushDigestBuffer(); - expect(triggerBatchSpy).not.toHaveBeenCalled(); - expect((trigger as any).digestBuffer.size).toBe(1); - expect(notificationHistoryStore.recordNotification).not.toHaveBeenCalled(); - }); + expect(triggerBatchSpy).not.toHaveBeenCalled(); + expect((trigger as any).digestBuffer.size).toBe(1); + expect(notificationHistoryStore.recordNotification).not.toHaveBeenCalled(); + }, + ); test('handleContainerReports should use the accepted update batch path for action triggers', async () => { trigger.configuration.mode = 'batch'; @@ -7142,57 +7255,57 @@ describe('digest mode', () => { expect(runAcceptedUpdateBatchSpy).toHaveBeenCalledWith([expect.objectContaining({ id: 'c1' })]); }); - test.each([ - 'manual', - 'notify', - ] as const)('handleContainerReports should not dispatch or record automatic action batches in %s mode', async (updateMode) => { - mockGetUpdateMode.mockReturnValue(updateMode); - trigger.type = 'docker'; - trigger.configuration.mode = 'batch'; - const runAcceptedUpdateBatchSpy = vi.spyOn(trigger as any, 'runAcceptedUpdateBatch'); + test.each(['manual', 'notify'] as const)( + 'handleContainerReports should not dispatch or record automatic action batches in %s mode', + async (updateMode) => { + mockGetUpdateMode.mockReturnValue(updateMode); + trigger.type = 'docker'; + trigger.configuration.mode = 'batch'; + const runAcceptedUpdateBatchSpy = vi.spyOn(trigger as any, 'runAcceptedUpdateBatch'); - await trigger.handleContainerReports([ - { - container: { - id: 'c1', - name: 'app', - watcher: 'test', - updateAvailable: true, - updateKind: { kind: 'tag', localValue: '1.0', remoteValue: '2.0' }, - }, - changed: true, - } as any, - ]); + await trigger.handleContainerReports([ + { + container: { + id: 'c1', + name: 'app', + watcher: 'test', + updateAvailable: true, + updateKind: { kind: 'tag', localValue: '1.0', remoteValue: '2.0' }, + }, + changed: true, + } as any, + ]); - expect(runAcceptedUpdateBatchSpy).not.toHaveBeenCalled(); - expect(notificationHistoryStore.recordNotification).not.toHaveBeenCalled(); - }); + expect(runAcceptedUpdateBatchSpy).not.toHaveBeenCalled(); + expect(notificationHistoryStore.recordNotification).not.toHaveBeenCalled(); + }, + ); - test.each([ - 'manual', - 'notify', - ] as const)('Command batches are not dispatched or recorded in %s mode', async (updateMode) => { - mockGetUpdateMode.mockReturnValue(updateMode); - trigger.type = 'command'; - trigger.configuration.mode = 'batch'; - const triggerBatchSpy = vi.spyOn(trigger, 'triggerBatch').mockResolvedValue(undefined); + test.each(['manual', 'notify'] as const)( + 'Command batches are not dispatched or recorded in %s mode', + async (updateMode) => { + mockGetUpdateMode.mockReturnValue(updateMode); + trigger.type = 'command'; + trigger.configuration.mode = 'batch'; + const triggerBatchSpy = vi.spyOn(trigger, 'triggerBatch').mockResolvedValue(undefined); - await trigger.handleContainerReports([ - { - container: { - id: 'c1', - name: 'app', - watcher: 'test', - updateAvailable: true, - updateKind: { kind: 'tag', localValue: '1.0', remoteValue: '2.0' }, - }, - changed: true, - } as any, - ]); + await trigger.handleContainerReports([ + { + container: { + id: 'c1', + name: 'app', + watcher: 'test', + updateAvailable: true, + updateKind: { kind: 'tag', localValue: '1.0', remoteValue: '2.0' }, + }, + changed: true, + } as any, + ]); - expect(triggerBatchSpy).not.toHaveBeenCalled(); - expect(notificationHistoryStore.recordNotification).not.toHaveBeenCalled(); - }); + expect(triggerBatchSpy).not.toHaveBeenCalled(); + expect(notificationHistoryStore.recordNotification).not.toHaveBeenCalled(); + }, + ); test('runAcceptedUpdateBatch should fail closed when mode changes before admission', async () => { mockGetUpdateMode.mockReturnValue('manual'); @@ -9664,7 +9777,7 @@ describe('security digest templates (6.7)', () => { expect(callArgs?.[1]).toMatchObject({ eventKind: 'security-alert-digest', }); - expect((callArgs?.[1] as any).title).toContain('1 container with findings'); + expect((callArgs![1] as any).title).toContain('1 container with findings'); }); test('renderSecurityDigestTemplate does not execute arbitrary code via process.env', async () => { diff --git a/app/triggers/providers/Trigger.ts b/app/triggers/providers/Trigger.ts index 2f74d3fcd..d7b459a40 100644 --- a/app/triggers/providers/Trigger.ts +++ b/app/triggers/providers/Trigger.ts @@ -934,6 +934,20 @@ class Trigger< defaultWhenRuleMissing: true, }, ); + if ( + !dispatchDecision.enabled && + dispatchDecision.reason === 'excluded-from-allow-list' && + getTriggerCategoryForType(this.type) === 'action' + ) { + // #623: the update-available rule's trigger allow-list can only ever contain + // notification triggers (the API validator and the UI picker both bar action + // types), so membership can never be granted to an action trigger. Treat + // non-membership as exempt; rule.enabled stays authoritative as the kill switch. + return { + enabled: true, + reason: 'action-trigger-exempt-from-allow-list', + }; + } this.warnIfDigestRoutingIsSuppressed(dispatchDecision); return dispatchDecision; } diff --git a/app/triggers/providers/docker/Docker.configuration-container-ops.test.ts b/app/triggers/providers/docker/Docker.configuration-container-ops.test.ts index 399feb0d8..1c85220c1 100644 --- a/app/triggers/providers/docker/Docker.configuration-container-ops.test.ts +++ b/app/triggers/providers/docker/Docker.configuration-container-ops.test.ts @@ -350,34 +350,37 @@ test('getSecurityGate should keep inline SBOMs for memory-only stores', async () test.each([ [{ path: '/custom/store' }, '/custom/store'], [{}, '/store'], -])('getSecurityGate should offload SBOMs for persistent store configuration %j', async (config, rootDir) => { - const sbom = { - image: 'ghcr.io/acme/web:2.0.0', - formats: ['spdx-json'], - documents: { 'spdx-json': { SPDXID: 'SPDXRef-DOCUMENT' } }, - }; - const offloadedSbom = { - ...sbom, - documents: undefined, - documentRefs: { 'spdx-json': { key: 'ref' } }, - }; - mockIsMemoryStore.mockReturnValue(false); - mockGetStoreConfiguration.mockReturnValue(config); - mockResolveConfiguredPath.mockImplementation((value: string) => value); - mockOffloadSbomDocuments.mockResolvedValue(offloadedSbom); - (docker as any).securityGate = undefined; - - const gate = docker.getSecurityGate(); - - await expect(gate.offloadSbom(sbom as any, 'sha256:abc123')).resolves.toBe(offloadedSbom); - expect(mockResolveConfiguredPath).toHaveBeenCalledWith(rootDir, { label: 'DD_STORE_PATH' }); - expect(mockCreateSbomStorage).toHaveBeenCalledWith({ rootDir }); - expect(mockOffloadSbomDocuments).toHaveBeenCalledWith({ - sbom, - subjectDigest: 'sha256:abc123', - storage: mockSbomStorage, - }); -}); +])( + 'getSecurityGate should offload SBOMs for persistent store configuration %j', + async (config, rootDir) => { + const sbom = { + image: 'ghcr.io/acme/web:2.0.0', + formats: ['spdx-json'], + documents: { 'spdx-json': { SPDXID: 'SPDXRef-DOCUMENT' } }, + }; + const offloadedSbom = { + ...sbom, + documents: undefined, + documentRefs: { 'spdx-json': { key: 'ref' } }, + }; + mockIsMemoryStore.mockReturnValue(false); + mockGetStoreConfiguration.mockReturnValue(config); + mockResolveConfiguredPath.mockImplementation((value: string) => value); + mockOffloadSbomDocuments.mockResolvedValue(offloadedSbom); + (docker as any).securityGate = undefined; + + const gate = docker.getSecurityGate(); + + await expect(gate.offloadSbom(sbom as any, 'sha256:abc123')).resolves.toBe(offloadedSbom); + expect(mockResolveConfiguredPath).toHaveBeenCalledWith(rootDir, { label: 'DD_STORE_PATH' }); + expect(mockCreateSbomStorage).toHaveBeenCalledWith({ rootDir }); + expect(mockOffloadSbomDocuments).toHaveBeenCalledWith({ + sbom, + subjectDigest: 'sha256:abc123', + storage: mockSbomStorage, + }); + }, +); test('maybeScanAndGateUpdate should delegate to the security gate', async () => { (docker as any).securityGate = undefined; diff --git a/app/triggers/providers/docker/self-update-finalize-entrypoint.test.ts b/app/triggers/providers/docker/self-update-finalize-entrypoint.test.ts index 7ab96fda1..ab536106e 100644 --- a/app/triggers/providers/docker/self-update-finalize-entrypoint.test.ts +++ b/app/triggers/providers/docker/self-update-finalize-entrypoint.test.ts @@ -85,7 +85,7 @@ describe('self-update-finalize entrypoint', () => { expect(mockHttpsRequest).not.toHaveBeenCalled(); expect(mockHttpRequest).toHaveBeenCalledTimes(1); expect( - (capturedRequestOptions?.headers as Record)[ + (capturedRequestOptions!.headers as Record)[ SELF_UPDATE_FINALIZE_SECRET_HEADER ], ).toBe('self-update-finalize-secret'); @@ -346,7 +346,7 @@ describe('self-update-finalize entrypoint', () => { await new Promise((resolve) => setImmediate(resolve)); expect( - (capturedRequestOptions?.headers as Record)[ + (capturedRequestOptions!.headers as Record)[ SELF_UPDATE_FINALIZE_SECRET_HEADER ], ).toBe('self-update-finalize-secret'); diff --git a/app/triggers/providers/dockercompose/Dockercompose.test.ts b/app/triggers/providers/dockercompose/Dockercompose.test.ts index 79e928899..5dcc00a7a 100644 --- a/app/triggers/providers/dockercompose/Dockercompose.test.ts +++ b/app/triggers/providers/dockercompose/Dockercompose.test.ts @@ -4147,35 +4147,35 @@ describe('Dockercompose Trigger', () => { ); }); - test.each([ - undefined, - 'op-123', - ])('runRuntimeUpdatesForComposeMappings should ignore non-object requested runtime context (%p)', async (runtimeContext) => { - const container = makeContainer({ - labels: { 'com.docker.compose.service': 'nginx' }, - }); - const runContainerUpdateLifecycleSpy = vi - .spyOn(trigger, 'runContainerUpdateLifecycle') - .mockResolvedValue(); - - await (trigger as any).runRuntimeUpdatesForComposeMappings( - '/opt/drydock/test/stack.yml', - ['/opt/drydock/test/stack.yml'], - makeCompose({ - nginx: { image: 'nginx:1.0.0' }, - }), - [{ container, service: 'nginx' }], - runtimeContext, - ); + test.each([undefined, 'op-123'])( + 'runRuntimeUpdatesForComposeMappings should ignore non-object requested runtime context (%p)', + async (runtimeContext) => { + const container = makeContainer({ + labels: { 'com.docker.compose.service': 'nginx' }, + }); + const runContainerUpdateLifecycleSpy = vi + .spyOn(trigger, 'runContainerUpdateLifecycle') + .mockResolvedValue(); + + await (trigger as any).runRuntimeUpdatesForComposeMappings( + '/opt/drydock/test/stack.yml', + ['/opt/drydock/test/stack.yml'], + makeCompose({ + nginx: { image: 'nginx:1.0.0' }, + }), + [{ container, service: 'nginx' }], + runtimeContext, + ); - expect(runContainerUpdateLifecycleSpy).toHaveBeenCalledWith( - container, - expect.objectContaining({ - service: 'nginx', - runtimeContext: undefined, - }), - ); - }); + expect(runContainerUpdateLifecycleSpy).toHaveBeenCalledWith( + container, + expect.objectContaining({ + service: 'nginx', + runtimeContext: undefined, + }), + ); + }, + ); test('runRuntimeUpdatesForComposeMappings should preserve requested runtime context when compose-file-once context is absent', async () => { const container = makeContainer({ diff --git a/app/triggers/providers/kafka/Kafka.test.ts b/app/triggers/providers/kafka/Kafka.test.ts index 47b030ff2..527036aa9 100644 --- a/app/triggers/providers/kafka/Kafka.test.ts +++ b/app/triggers/providers/kafka/Kafka.test.ts @@ -91,25 +91,25 @@ test('validateConfiguration should reject removed clientId alias', () => { ).toThrow(/clientId.*not allowed/i); }); -test.each([ - 'SCRAM-SHA-256', - 'SCRAM-SHA-512', -])('validateConfiguration should accept %s authentication', (authType) => { - const validatedConfiguration = kafka.validateConfiguration({ - brokers: 'broker1:9000, broker2:9000', - authentication: { +test.each(['SCRAM-SHA-256', 'SCRAM-SHA-512'])( + 'validateConfiguration should accept %s authentication', + (authType) => { + const validatedConfiguration = kafka.validateConfiguration({ + brokers: 'broker1:9000, broker2:9000', + authentication: { + user: 'user', + password: 'password', + type: authType, + }, + }); + + expect(validatedConfiguration.authentication).toStrictEqual({ user: 'user', password: 'password', type: authType, - }, - }); - - expect(validatedConfiguration.authentication).toStrictEqual({ - user: 'user', - password: 'password', - type: authType, - }); -}); + }); + }, +); test('validateConfiguration should reject unsupported authentication type', async () => { expect(() => { diff --git a/app/triggers/providers/mqtt/Hass.test.ts b/app/triggers/providers/mqtt/Hass.test.ts index 9b47338bf..3f6319ceb 100644 --- a/app/triggers/providers/mqtt/Hass.test.ts +++ b/app/triggers/providers/mqtt/Hass.test.ts @@ -307,20 +307,20 @@ test.each([ expectedPicture: 'https://raw.githubusercontent.com/CodesWhat/drydock/main/docs/assets/whale-logo.png', }, -])('addContainerSensor should map $displayIcon to entity_picture URL', async ({ - displayIcon, - expectedPicture, -}) => { - await hass.addContainerSensor({ - name: 'container-name', - watcher: 'watcher-name', - displayIcon, - }); +])( + 'addContainerSensor should map $displayIcon to entity_picture URL', + async ({ displayIcon, expectedPicture }) => { + await hass.addContainerSensor({ + name: 'container-name', + watcher: 'watcher-name', + displayIcon, + }); - const discoveryCall = mqttClientMock.publish.mock.calls[0]; - const discoveryPayload = JSON.parse(discoveryCall[1]); - expect(discoveryPayload.entity_picture).toBe(expectedPicture); -}); + const discoveryCall = mqttClientMock.publish.mock.calls[0]; + const discoveryPayload = JSON.parse(discoveryCall[1]); + expect(discoveryPayload.entity_picture).toBe(expectedPicture); + }, +); test('addContainerSensor should use direct URL icon as entity_picture', async () => { await hass.addContainerSensor({ @@ -474,215 +474,213 @@ test('addContainerSensor should not warn when a watcher name has only one distin expect(logWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Multiple agents share')); }); -test.each( - containerData, -)('removeContainerSensor must publish sensor discovery message expected by HA', async ({ - containerName, - data, -}) => { - await hass.removeContainerSensor({ - name: containerName, - watcher: 'watcher-name', - displayIcon: 'mdi:docker', - }); - expect(mqttClientMock.publish).toHaveBeenCalledWith(data.discoveryTopic, '', { - retain: true, - }); -}); - -test.each(containerData)('updateContainerSensors must publish all sensors expected by HA', async ({ - containerName, - data, -}) => { - await hass.updateContainerSensors({ - name: containerName, - watcher: 'watcher-name', - displayIcon: 'mdi:docker', - }); - expect(mqttClientMock.publish).toHaveBeenCalledTimes(15); +test.each(containerData)( + 'removeContainerSensor must publish sensor discovery message expected by HA', + async ({ containerName, data }) => { + await hass.removeContainerSensor({ + name: containerName, + watcher: 'watcher-name', + displayIcon: 'mdi:docker', + }); + expect(mqttClientMock.publish).toHaveBeenCalledWith(data.discoveryTopic, '', { + retain: true, + }); + }, +); - expect(mqttClientMock.publish).toHaveBeenNthCalledWith( - 1, - 'homeassistant/sensor/topic_total_count/config', - JSON.stringify({ - unique_id: 'topic_total_count', - default_entity_id: 'sensor.topic_total_count', - name: 'Total container count', - device: { - identifiers: ['drydock'], - manufacturer: 'drydock', - model: 'drydock', - name: 'drydock', - sw_version: MOCK_VERSION, - }, - icon: 'mdi:docker', - entity_picture: - 'https://raw.githubusercontent.com/CodesWhat/drydock/main/docs/assets/whale-logo.png', - state_topic: 'topic/total_count', - }), - { retain: true }, - ); +test.each(containerData)( + 'updateContainerSensors must publish all sensors expected by HA', + async ({ containerName, data }) => { + await hass.updateContainerSensors({ + name: containerName, + watcher: 'watcher-name', + displayIcon: 'mdi:docker', + }); + expect(mqttClientMock.publish).toHaveBeenCalledTimes(15); + + expect(mqttClientMock.publish).toHaveBeenNthCalledWith( + 1, + 'homeassistant/sensor/topic_total_count/config', + JSON.stringify({ + unique_id: 'topic_total_count', + default_entity_id: 'sensor.topic_total_count', + name: 'Total container count', + device: { + identifiers: ['drydock'], + manufacturer: 'drydock', + model: 'drydock', + name: 'drydock', + sw_version: MOCK_VERSION, + }, + icon: 'mdi:docker', + entity_picture: + 'https://raw.githubusercontent.com/CodesWhat/drydock/main/docs/assets/whale-logo.png', + state_topic: 'topic/total_count', + }), + { retain: true }, + ); - expect(mqttClientMock.publish).toHaveBeenNthCalledWith( - 2, - 'homeassistant/sensor/topic_update_count/config', - JSON.stringify({ - unique_id: 'topic_update_count', - default_entity_id: 'sensor.topic_update_count', - name: 'Total container update count', - device: { - identifiers: ['drydock'], - manufacturer: 'drydock', - model: 'drydock', - name: 'drydock', - sw_version: MOCK_VERSION, - }, - icon: 'mdi:docker', - entity_picture: - 'https://raw.githubusercontent.com/CodesWhat/drydock/main/docs/assets/whale-logo.png', - state_topic: 'topic/update_count', - }), - { retain: true }, - ); + expect(mqttClientMock.publish).toHaveBeenNthCalledWith( + 2, + 'homeassistant/sensor/topic_update_count/config', + JSON.stringify({ + unique_id: 'topic_update_count', + default_entity_id: 'sensor.topic_update_count', + name: 'Total container update count', + device: { + identifiers: ['drydock'], + manufacturer: 'drydock', + model: 'drydock', + name: 'drydock', + sw_version: MOCK_VERSION, + }, + icon: 'mdi:docker', + entity_picture: + 'https://raw.githubusercontent.com/CodesWhat/drydock/main/docs/assets/whale-logo.png', + state_topic: 'topic/update_count', + }), + { retain: true }, + ); - expect(mqttClientMock.publish).toHaveBeenNthCalledWith( - 3, - 'homeassistant/binary_sensor/topic_update_status/config', - JSON.stringify({ - unique_id: 'topic_update_status', - default_entity_id: 'binary_sensor.topic_update_status', - name: 'Total container update status', - device: { - identifiers: ['drydock'], - manufacturer: 'drydock', - model: 'drydock', - name: 'drydock', - sw_version: MOCK_VERSION, - }, - icon: 'mdi:docker', - entity_picture: - 'https://raw.githubusercontent.com/CodesWhat/drydock/main/docs/assets/whale-logo.png', - state_topic: 'topic/update_status', - payload_on: 'true', - payload_off: 'false', - }), - { retain: true }, - ); + expect(mqttClientMock.publish).toHaveBeenNthCalledWith( + 3, + 'homeassistant/binary_sensor/topic_update_status/config', + JSON.stringify({ + unique_id: 'topic_update_status', + default_entity_id: 'binary_sensor.topic_update_status', + name: 'Total container update status', + device: { + identifiers: ['drydock'], + manufacturer: 'drydock', + model: 'drydock', + name: 'drydock', + sw_version: MOCK_VERSION, + }, + icon: 'mdi:docker', + entity_picture: + 'https://raw.githubusercontent.com/CodesWhat/drydock/main/docs/assets/whale-logo.png', + state_topic: 'topic/update_status', + payload_on: 'true', + payload_off: 'false', + }), + { retain: true }, + ); - expect(mqttClientMock.publish).toHaveBeenNthCalledWith( - 4, - 'homeassistant/sensor/topic_watcher-name_total_count/config', - JSON.stringify({ - unique_id: 'topic_watcher-name_total_count', - default_entity_id: 'sensor.topic_watcher-name_total_count', - name: 'Watcher watcher-name container count', - device: { - identifiers: ['drydock'], - manufacturer: 'drydock', - model: 'drydock', - name: 'drydock', - sw_version: MOCK_VERSION, - }, - icon: 'mdi:docker', - entity_picture: - 'https://raw.githubusercontent.com/CodesWhat/drydock/main/docs/assets/whale-logo.png', - state_topic: 'topic/watcher-name/total_count', - }), - { retain: true }, - ); + expect(mqttClientMock.publish).toHaveBeenNthCalledWith( + 4, + 'homeassistant/sensor/topic_watcher-name_total_count/config', + JSON.stringify({ + unique_id: 'topic_watcher-name_total_count', + default_entity_id: 'sensor.topic_watcher-name_total_count', + name: 'Watcher watcher-name container count', + device: { + identifiers: ['drydock'], + manufacturer: 'drydock', + model: 'drydock', + name: 'drydock', + sw_version: MOCK_VERSION, + }, + icon: 'mdi:docker', + entity_picture: + 'https://raw.githubusercontent.com/CodesWhat/drydock/main/docs/assets/whale-logo.png', + state_topic: 'topic/watcher-name/total_count', + }), + { retain: true }, + ); - expect(mqttClientMock.publish).toHaveBeenNthCalledWith( - 5, - 'homeassistant/sensor/topic_watcher-name_update_count/config', - JSON.stringify({ - unique_id: 'topic_watcher-name_update_count', - default_entity_id: 'sensor.topic_watcher-name_update_count', - name: 'Watcher watcher-name container update count', - device: { - identifiers: ['drydock'], - manufacturer: 'drydock', - model: 'drydock', - name: 'drydock', - sw_version: MOCK_VERSION, - }, - icon: 'mdi:docker', - entity_picture: - 'https://raw.githubusercontent.com/CodesWhat/drydock/main/docs/assets/whale-logo.png', - state_topic: 'topic/watcher-name/update_count', - }), - { retain: true }, - ); + expect(mqttClientMock.publish).toHaveBeenNthCalledWith( + 5, + 'homeassistant/sensor/topic_watcher-name_update_count/config', + JSON.stringify({ + unique_id: 'topic_watcher-name_update_count', + default_entity_id: 'sensor.topic_watcher-name_update_count', + name: 'Watcher watcher-name container update count', + device: { + identifiers: ['drydock'], + manufacturer: 'drydock', + model: 'drydock', + name: 'drydock', + sw_version: MOCK_VERSION, + }, + icon: 'mdi:docker', + entity_picture: + 'https://raw.githubusercontent.com/CodesWhat/drydock/main/docs/assets/whale-logo.png', + state_topic: 'topic/watcher-name/update_count', + }), + { retain: true }, + ); - expect(mqttClientMock.publish).toHaveBeenNthCalledWith( - 6, - 'homeassistant/binary_sensor/topic_watcher-name_update_status/config', - JSON.stringify({ - unique_id: 'topic_watcher-name_update_status', - default_entity_id: 'binary_sensor.topic_watcher-name_update_status', - name: 'Watcher watcher-name container update status', - device: { - identifiers: ['drydock'], - manufacturer: 'drydock', - model: 'drydock', - name: 'drydock', - sw_version: MOCK_VERSION, - }, - icon: 'mdi:docker', - entity_picture: - 'https://raw.githubusercontent.com/CodesWhat/drydock/main/docs/assets/whale-logo.png', - state_topic: 'topic/watcher-name/update_status', - payload_on: 'true', - payload_off: 'false', - }), - { retain: true }, - ); + expect(mqttClientMock.publish).toHaveBeenNthCalledWith( + 6, + 'homeassistant/binary_sensor/topic_watcher-name_update_status/config', + JSON.stringify({ + unique_id: 'topic_watcher-name_update_status', + default_entity_id: 'binary_sensor.topic_watcher-name_update_status', + name: 'Watcher watcher-name container update status', + device: { + identifiers: ['drydock'], + manufacturer: 'drydock', + model: 'drydock', + name: 'drydock', + sw_version: MOCK_VERSION, + }, + icon: 'mdi:docker', + entity_picture: + 'https://raw.githubusercontent.com/CodesWhat/drydock/main/docs/assets/whale-logo.png', + state_topic: 'topic/watcher-name/update_status', + payload_on: 'true', + payload_off: 'false', + }), + { retain: true }, + ); - expect(mqttClientMock.publish).toHaveBeenNthCalledWith(7, 'topic/total_count', '0', { - retain: true, - }); - expect(mqttClientMock.publish).toHaveBeenNthCalledWith(8, 'topic/update_count', '0', { - retain: true, - }); - expect(mqttClientMock.publish).toHaveBeenNthCalledWith(9, 'topic/update_status', 'false', { - retain: true, - }); - expect(mqttClientMock.publish).toHaveBeenNthCalledWith( - 10, - 'topic/watcher-name/total_count', - '0', - { retain: true }, - ); - expect(mqttClientMock.publish).toHaveBeenNthCalledWith( - 11, - 'topic/watcher-name/update_count', - '0', - { retain: true }, - ); - expect(mqttClientMock.publish).toHaveBeenNthCalledWith( - 12, - 'topic/watcher-name/update_status', - 'false', - { retain: true }, - ); - expect(mqttClientMock.publish).toHaveBeenNthCalledWith( - 13, - 'homeassistant/sensor/topic_watcher-name_total_count/config', - '', - { retain: true }, - ); - expect(mqttClientMock.publish).toHaveBeenNthCalledWith( - 14, - 'homeassistant/sensor/topic_watcher-name_update_count/config', - '', - { retain: true }, - ); - expect(mqttClientMock.publish).toHaveBeenNthCalledWith( - 15, - 'homeassistant/binary_sensor/topic_watcher-name_update_status/config', - '', - { retain: true }, - ); -}); + expect(mqttClientMock.publish).toHaveBeenNthCalledWith(7, 'topic/total_count', '0', { + retain: true, + }); + expect(mqttClientMock.publish).toHaveBeenNthCalledWith(8, 'topic/update_count', '0', { + retain: true, + }); + expect(mqttClientMock.publish).toHaveBeenNthCalledWith(9, 'topic/update_status', 'false', { + retain: true, + }); + expect(mqttClientMock.publish).toHaveBeenNthCalledWith( + 10, + 'topic/watcher-name/total_count', + '0', + { retain: true }, + ); + expect(mqttClientMock.publish).toHaveBeenNthCalledWith( + 11, + 'topic/watcher-name/update_count', + '0', + { retain: true }, + ); + expect(mqttClientMock.publish).toHaveBeenNthCalledWith( + 12, + 'topic/watcher-name/update_status', + 'false', + { retain: true }, + ); + expect(mqttClientMock.publish).toHaveBeenNthCalledWith( + 13, + 'homeassistant/sensor/topic_watcher-name_total_count/config', + '', + { retain: true }, + ); + expect(mqttClientMock.publish).toHaveBeenNthCalledWith( + 14, + 'homeassistant/sensor/topic_watcher-name_update_count/config', + '', + { retain: true }, + ); + expect(mqttClientMock.publish).toHaveBeenNthCalledWith( + 15, + 'homeassistant/binary_sensor/topic_watcher-name_update_status/config', + '', + { retain: true }, + ); + }, +); test('updateContainerSensors should use container count queries instead of full list cloning', async () => { const getContainersSpy = vi.spyOn(containerStore, 'getContainers'); @@ -704,21 +702,19 @@ test('updateContainerSensors should use container count queries instead of full expect(getContainersSpy).not.toHaveBeenCalled(); }); -test.each( - containerData, -)('removeContainerSensor must publish all sensor removal messages expected by HA', async ({ - containerName, - data, -}) => { - await hass.removeContainerSensor({ - name: containerName, - watcher: 'watcher-name', - displayIcon: 'mdi:docker', - }); - expect(mqttClientMock.publish).toHaveBeenCalledWith(data.discoveryTopic, '', { - retain: true, - }); -}); +test.each(containerData)( + 'removeContainerSensor must publish all sensor removal messages expected by HA', + async ({ containerName, data }) => { + await hass.removeContainerSensor({ + name: containerName, + watcher: 'watcher-name', + displayIcon: 'mdi:docker', + }); + expect(mqttClientMock.publish).toHaveBeenCalledWith(data.discoveryTopic, '', { + retain: true, + }); + }, +); test('updateWatcherSensors must publish all watcher sensor messages expected by HA', async () => { await hass.updateWatcherSensors({ @@ -2384,21 +2380,19 @@ describe('hass install commands (#210)', () => { expect(mockRecordAuditEvent).not.toHaveBeenCalled(); }); - test.each([ - '', - 'ON', - '{"install":true}', - 'Install', - ])('unexpected payload %j is dropped independently, debug-logged, no call', async (payload) => { - const container = seedContainer(); - const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}); - await fireCommandMessage(commandClientMock, commandTopicFor(container), payload, { - retain: false, - }); - expect(requestUpdateModule.requestContainerUpdate).not.toHaveBeenCalled(); - expect(mockRecordAuditEvent).not.toHaveBeenCalled(); - expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('unexpected payload')); - }); + test.each(['', 'ON', '{"install":true}', 'Install'])( + 'unexpected payload %j is dropped independently, debug-logged, no call', + async (payload) => { + const container = seedContainer(); + const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}); + await fireCommandMessage(commandClientMock, commandTopicFor(container), payload, { + retain: false, + }); + expect(requestUpdateModule.requestContainerUpdate).not.toHaveBeenCalled(); + expect(mockRecordAuditEvent).not.toHaveBeenCalled(); + expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('unexpected payload')); + }, + ); // ── Reverse lookup (Gotcha B) ───────────────────────────────────────── diff --git a/app/triggers/providers/mqtt/Mqtt.test.ts b/app/triggers/providers/mqtt/Mqtt.test.ts index b90cdd6eb..770f6414b 100644 --- a/app/triggers/providers/mqtt/Mqtt.test.ts +++ b/app/triggers/providers/mqtt/Mqtt.test.ts @@ -191,53 +191,57 @@ test('initTrigger should init Mqtt client', async () => { }); }); -test.each(containerData)('trigger should format json message payload as expected', async ({ - containerName, - data, -}) => { - mqtt.configuration = { - topic: 'dd/container', - exclude: '', - hass: { - attributes: 'full', - filter: { - include: '', - exclude: '', +test.each(containerData)( + 'trigger should format json message payload as expected', + async ({ containerName, data }) => { + mqtt.configuration = { + topic: 'dd/container', + exclude: '', + hass: { + attributes: 'full', + filter: { + include: '', + exclude: '', + }, }, - }, - }; - const container = { - id: '31a61a8305ef1fc9a71fa4f20a68d7ec88b28e32303bbc4a5f192e851165b816', - name: containerName, - watcher: 'local', - includeTags: '^\\d+\\.\\d+.\\d+$', - image: { - id: 'sha256:d4a6fafb7d4da37495e5c9be3242590be24a87d7edcc4f79761098889c54fca6', - registry: { - url: '123456789.dkr.ecr.eu-west-1.amazonaws.com', + }; + const container = { + id: '31a61a8305ef1fc9a71fa4f20a68d7ec88b28e32303bbc4a5f192e851165b816', + name: containerName, + watcher: 'local', + includeTags: '^\\d+\\.\\d+.\\d+$', + image: { + id: 'sha256:d4a6fafb7d4da37495e5c9be3242590be24a87d7edcc4f79761098889c54fca6', + registry: { + url: '123456789.dkr.ecr.eu-west-1.amazonaws.com', + }, + name: 'test', + tag: { + value: '2021.6.4', + semver: true, + }, + digest: { + watch: false, + repo: 'sha256:ca0edc3fb0b4647963629bdfccbb3ccfa352184b45a9b4145832000c2878dd72', + }, + architecture: 'amd64', + os: 'linux', + created: '2021-06-12T05:33:38.440Z', }, - name: 'test', - tag: { - value: '2021.6.4', - semver: true, + result: { + tag: '2021.6.5', }, - digest: { - watch: false, - repo: 'sha256:ca0edc3fb0b4647963629bdfccbb3ccfa352184b45a9b4145832000c2878dd72', + }; + await mqtt.trigger(container); + expect(mqtt.client.publish).toHaveBeenCalledWith( + data.topic, + JSON.stringify(flatten(container)), + { + retain: true, }, - architecture: 'amd64', - os: 'linux', - created: '2021-06-12T05:33:38.440Z', - }, - result: { - tag: '2021.6.5', - }, - }; - await mqtt.trigger(container); - expect(mqtt.client.publish).toHaveBeenCalledWith(data.topic, JSON.stringify(flatten(container)), { - retain: true, - }); -}); + ); + }, +); // Regression guard for #491: the HA latest_version_template reads result_tag / // result_digest / image_tag_value from the flattened MQTT state payload. Lock the diff --git a/app/triggers/providers/mqtt/hass-commands.test.ts b/app/triggers/providers/mqtt/hass-commands.test.ts index 50847b496..09c0a114e 100644 --- a/app/triggers/providers/mqtt/hass-commands.test.ts +++ b/app/triggers/providers/mqtt/hass-commands.test.ts @@ -42,14 +42,12 @@ describe('isHassInstallPayload', () => { expect(isHassInstallPayload(Buffer.from('install'))).toBe(true); }); - test.each([ - 'update', - '', - 'INSTALL', - 'installer', - ])('returns false for a non-matching payload %j', (payload) => { - expect(isHassInstallPayload(payload)).toBe(false); - }); + test.each(['update', '', 'INSTALL', 'installer'])( + 'returns false for a non-matching payload %j', + (payload) => { + expect(isHassInstallPayload(payload)).toBe(false); + }, + ); test('trims surrounding whitespace/newlines before comparing', () => { expect(isHassInstallPayload(' install\n')).toBe(true); diff --git a/app/triggers/providers/smtp/Smtp.test.ts b/app/triggers/providers/smtp/Smtp.test.ts index 550015af7..f230e3798 100644 --- a/app/triggers/providers/smtp/Smtp.test.ts +++ b/app/triggers/providers/smtp/Smtp.test.ts @@ -95,27 +95,27 @@ test.each([ fromValue: 'Multiline\nSender ', expectedResult: null, }, -])("smtp from value should normalize to '$expectedResult' when configuration is '$fromValue'", async ({ - fromValue, - expectedResult, -}) => { - const config = { - ...configurationValid, - from: fromValue, - }; - - if (expectedResult) { - let validatedConfiguration; - expect(() => { - validatedConfiguration = smtp.validateConfiguration(config); - }).not.toThrow(joi.ValidationError); - expect(validatedConfiguration.from).toStrictEqual(expectedResult); - } else { - expect(() => { - smtp.validateConfiguration(config); - }).toThrow(joi.ValidationError); - } -}); +])( + "smtp from value should normalize to '$expectedResult' when configuration is '$fromValue'", + async ({ fromValue, expectedResult }) => { + const config = { + ...configurationValid, + from: fromValue, + }; + + if (expectedResult) { + let validatedConfiguration; + expect(() => { + validatedConfiguration = smtp.validateConfiguration(config); + }).not.toThrow(joi.ValidationError); + expect(validatedConfiguration.from).toStrictEqual(expectedResult); + } else { + expect(() => { + smtp.validateConfiguration(config); + }).toThrow(joi.ValidationError); + } + }, +); test.each([ { allowCustomTld: true, field: 'from' }, @@ -124,27 +124,27 @@ test.each([ { allowCustomTld: false, field: 'to' }, { allowCustomTld: true, field: 'both' }, { allowCustomTld: false, field: 'both' }, -])('trigger should $allowCustomTld allow custom tld for $field field', async ({ - allowCustomTld, - field, -}) => { - const config = { - ...configurationValid, - allowcustomtld: allowCustomTld, - from: field === 'from' || field === 'both' ? 'user@domain.lan' : configurationValid.from, - to: field === 'to' || field === 'both' ? 'user@domain.lan' : configurationValid.to, - }; - - if (allowCustomTld) { - expect(() => { - smtp.validateConfiguration(config); - }).not.toThrow(joi.ValidationError); - } else { - expect(() => { - smtp.validateConfiguration(config); - }).toThrow(joi.ValidationError); - } -}); +])( + 'trigger should $allowCustomTld allow custom tld for $field field', + async ({ allowCustomTld, field }) => { + const config = { + ...configurationValid, + allowcustomtld: allowCustomTld, + from: field === 'from' || field === 'both' ? 'user@domain.lan' : configurationValid.from, + to: field === 'to' || field === 'both' ? 'user@domain.lan' : configurationValid.to, + }; + + if (allowCustomTld) { + expect(() => { + smtp.validateConfiguration(config); + }).not.toThrow(joi.ValidationError); + } else { + expect(() => { + smtp.validateConfiguration(config); + }).toThrow(joi.ValidationError); + } + }, +); test('validateConfiguration should throw error when invalid', async () => { const configuration = { diff --git a/app/triggers/trigger-category.test.ts b/app/triggers/trigger-category.test.ts index 5bdc7495e..4f7e02f03 100644 --- a/app/triggers/trigger-category.test.ts +++ b/app/triggers/trigger-category.test.ts @@ -9,24 +9,19 @@ function buildContainer(overrides: Partial = {}): Container { } describe('getTriggerCategoryForType', () => { - test.each([ - 'docker', - 'dockercompose', - 'command', - ])('classifies %s as an action trigger', (type) => { - expect(getTriggerCategoryForType(type)).toBe('action'); - }); + test.each(['docker', 'dockercompose', 'command'])( + 'classifies %s as an action trigger', + (type) => { + expect(getTriggerCategoryForType(type)).toBe('action'); + }, + ); - test.each([ - 'slack', - 'smtp', - 'ntfy', - 'mqtt', - 'discord', - 'http', - ])('classifies %s as a notification trigger', (type) => { - expect(getTriggerCategoryForType(type)).toBe('notification'); - }); + test.each(['slack', 'smtp', 'ntfy', 'mqtt', 'discord', 'http'])( + 'classifies %s as a notification trigger', + (type) => { + expect(getTriggerCategoryForType(type)).toBe('notification'); + }, + ); test('is case insensitive', () => { expect(getTriggerCategoryForType('DockerCompose')).toBe('action'); diff --git a/app/watchers/providers/docker/Docker.containers.labels-version-finding.test.ts b/app/watchers/providers/docker/Docker.containers.labels-version-finding.test.ts index 9c0e7fbf2..01a631b76 100644 --- a/app/watchers/providers/docker/Docker.containers.labels-version-finding.test.ts +++ b/app/watchers/providers/docker/Docker.containers.labels-version-finding.test.ts @@ -109,23 +109,25 @@ describe('Docker Watcher', () => { ['dd.rollback.interval', 'wud.rollback.interval'], ]; - test.each( - labelPairs, - )('should use %s and ignore %s when both are present', (ddKey, wudKey) => { - const labels = { [ddKey]: 'dd-value', [wudKey]: 'wud-value' }; - expect(testable_getLabel(labels, ddKey)).toBe('dd-value'); - }); + test.each(labelPairs)( + 'should use %s and ignore %s when both are present', + (ddKey, wudKey) => { + const labels = { [ddKey]: 'dd-value', [wudKey]: 'wud-value' }; + expect(testable_getLabel(labels, ddKey)).toBe('dd-value'); + }, + ); test.each(labelPairs)('should ignore %s when %s is absent', (ddKey, wudKey) => { const labels = { [wudKey]: 'legacy-value' }; expect(testable_getLabel(labels, ddKey)).toBeUndefined(); }); - test.each( - labelPairs, - )('should return undefined when neither %s nor %s is set', (ddKey, wudKey) => { - expect(testable_getLabel({}, ddKey)).toBeUndefined(); - }); + test.each(labelPairs)( + 'should return undefined when neither %s nor %s is set', + (ddKey, wudKey) => { + expect(testable_getLabel({}, ddKey)).toBeUndefined(); + }, + ); }); }); diff --git a/app/watchers/providers/docker/Docker.containers.test.ts b/app/watchers/providers/docker/Docker.containers.test.ts index d723b52d7..e8d9196e2 100644 --- a/app/watchers/providers/docker/Docker.containers.test.ts +++ b/app/watchers/providers/docker/Docker.containers.test.ts @@ -936,23 +936,25 @@ describe('Docker Watcher', () => { ['dd.rollback.interval', 'wud.rollback.interval'], ]; - test.each( - labelPairs, - )('should use %s and ignore %s when both are present', (ddKey, wudKey) => { - const labels = { [ddKey]: 'dd-value', [wudKey]: 'wud-value' }; - expect(testable_getLabel(labels, ddKey)).toBe('dd-value'); - }); + test.each(labelPairs)( + 'should use %s and ignore %s when both are present', + (ddKey, wudKey) => { + const labels = { [ddKey]: 'dd-value', [wudKey]: 'wud-value' }; + expect(testable_getLabel(labels, ddKey)).toBe('dd-value'); + }, + ); test.each(labelPairs)('should ignore %s when %s is absent', (ddKey, wudKey) => { const labels = { [wudKey]: 'legacy-value' }; expect(testable_getLabel(labels, ddKey)).toBeUndefined(); }); - test.each( - labelPairs, - )('should return undefined when neither %s nor %s is set', (ddKey, wudKey) => { - expect(testable_getLabel({}, ddKey)).toBeUndefined(); - }); + test.each(labelPairs)( + 'should return undefined when neither %s nor %s is set', + (ddKey, wudKey) => { + expect(testable_getLabel({}, ddKey)).toBeUndefined(); + }, + ); }); }); diff --git a/app/watchers/providers/docker/Docker.test.ts b/app/watchers/providers/docker/Docker.test.ts index f9d44fc8a..1ca751a73 100644 --- a/app/watchers/providers/docker/Docker.test.ts +++ b/app/watchers/providers/docker/Docker.test.ts @@ -3113,11 +3113,14 @@ describe('isDigestToWatch Logic', () => { ['true', 'my.registry', 'latest', false, true, 'label=true, non-semver'], ['false', 'my.registry', '1.0.0', true, false, 'label=false, semver'], ['false', 'my.registry', 'latest', false, false, 'label=false, non-semver'], - ])('should respect explicit dd.watch.digest=%s (%s)', async (labelValue, domain, tag, isSemver, expected) => { - const container = await setupTest({ 'dd.watch.digest': labelValue }, domain, tag, isSemver); - const result = await docker.addImageDetailsToContainer(container); - expect(result.image.digest.watch).toBe(expected); - }); + ])( + 'should respect explicit dd.watch.digest=%s (%s)', + async (labelValue, domain, tag, isSemver, expected) => { + const container = await setupTest({ 'dd.watch.digest': labelValue }, domain, tag, isSemver); + const result = await docker.addImageDetailsToContainer(container); + expect(result.image.digest.watch).toBe(expected); + }, + ); // Case 2: Pinned specific semver (no label) -> default true, so same-tag // rebuilds are detected by digest (#498). diff --git a/app/watchers/providers/docker/docker-image-details-orchestration.test.ts b/app/watchers/providers/docker/docker-image-details-orchestration.test.ts index 2c11e0dd4..9c4e926c8 100644 --- a/app/watchers/providers/docker/docker-image-details-orchestration.test.ts +++ b/app/watchers/providers/docker/docker-image-details-orchestration.test.ts @@ -414,40 +414,40 @@ describe('docker image details orchestration module', () => { expect(result?.health).toBeUndefined(); }); - test.each([ - true, - false, - ])('already-stored containers inspect and refresh health with watchevents=%s', async (watchevents) => { - const stored = { - id: 'container-1', - name: 'service', - displayName: 'service', - status: 'running', - health: 'healthy', - details: { ports: [], volumes: [], env: [] }, - image: { - id: 'image-old', - name: 'acme/service', - registry: { name: 'ghcr', url: 'ghcr.io' }, - tag: { value: 'latest', semver: false }, - digest: { repo: 'sha256:old', value: 'sha256:old', watch: false }, - created: '2025-01-01T00:00:00.000Z', - }, - }; - vi.spyOn(storeContainer, 'getContainer').mockReturnValue(stored as any); - const { watcher, inspectContainer } = createWatcher({ configuration: { watchevents } }); - inspectContainer.mockResolvedValue({ State: { Health: { Status: 'unhealthy' } } }); + test.each([true, false])( + 'already-stored containers inspect and refresh health with watchevents=%s', + async (watchevents) => { + const stored = { + id: 'container-1', + name: 'service', + displayName: 'service', + status: 'running', + health: 'healthy', + details: { ports: [], volumes: [], env: [] }, + image: { + id: 'image-old', + name: 'acme/service', + registry: { name: 'ghcr', url: 'ghcr.io' }, + tag: { value: 'latest', semver: false }, + digest: { repo: 'sha256:old', value: 'sha256:old', watch: false }, + created: '2025-01-01T00:00:00.000Z', + }, + }; + vi.spyOn(storeContainer, 'getContainer').mockReturnValue(stored as any); + const { watcher, inspectContainer } = createWatcher({ configuration: { watchevents } }); + inspectContainer.mockResolvedValue({ State: { Health: { Status: 'unhealthy' } } }); - const result = await addImageDetailsToContainerOrchestration( - watcher as any, - createDockerSummaryContainer(), - {}, - createHelpers() as any, - ); + const result = await addImageDetailsToContainerOrchestration( + watcher as any, + createDockerSummaryContainer(), + {}, + createHelpers() as any, + ); - expect(inspectContainer).toHaveBeenCalledTimes(1); - expect(result?.health).toBe('unhealthy'); - }); + expect(inspectContainer).toHaveBeenCalledTimes(1); + expect(result?.health).toBe('unhealthy'); + }, + ); test('failed stored-container inspect preserves the previous health value', async () => { const stored = { diff --git a/apps/demo/package-lock.json b/apps/demo/package-lock.json index dbba009ad..cd082a001 100644 --- a/apps/demo/package-lock.json +++ b/apps/demo/package-lock.json @@ -24,7 +24,7 @@ "@iconify-json/fa6-solid": "1.2.4", "@iconify-json/heroicons": "1.2.3", "@iconify-json/iconoir": "1.2.11", - "@iconify-json/lucide": "1.2.118", + "@iconify-json/lucide": "1.2.119", "@iconify-json/ph": "1.2.2", "@iconify-json/tabler": "1.2.37", "@tailwindcss/vite": "4.3.3", @@ -650,9 +650,9 @@ } }, "node_modules/@iconify-json/lucide": { - "version": "1.2.118", - "resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.2.118.tgz", - "integrity": "sha512-JBnK4YOq6K/lA0JP//27QxFxJ4120TjvfXAzGZZIGjCcXcRRRFxl1rcV7+IWdcVCe90KXdqVaAwLaLf6G3HELw==", + "version": "1.2.119", + "resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.2.119.tgz", + "integrity": "sha512-KbrC7fT5wPshV1KLRM0k91poP0OH4eKEVywEOH0FCczKb+qth8Pcq1Hy9kS2tXeF52s23qzLnG8AJsExPSupeA==", "dev": true, "license": "ISC", "dependencies": { @@ -2846,9 +2846,9 @@ } }, "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", diff --git a/apps/demo/package.json b/apps/demo/package.json index 0558f1ead..1e2834a31 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -27,7 +27,7 @@ "@iconify-json/fa6-solid": "1.2.4", "@iconify-json/heroicons": "1.2.3", "@iconify-json/iconoir": "1.2.11", - "@iconify-json/lucide": "1.2.118", + "@iconify-json/lucide": "1.2.119", "@iconify-json/ph": "1.2.2", "@iconify-json/tabler": "1.2.37", "@tailwindcss/vite": "4.3.3", @@ -38,7 +38,7 @@ "vitest": "4.1.10" }, "overrides": { - "postcss": "8.5.22", + "postcss": "8.5.23", "yaml": "2.9.0", "esbuild": "0.28.1" }, diff --git a/apps/demo/public/mockServiceWorker.js b/apps/demo/public/mockServiceWorker.js index e6c4efc0d..0c970efc9 100644 --- a/apps/demo/public/mockServiceWorker.js +++ b/apps/demo/public/mockServiceWorker.js @@ -7,42 +7,42 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.14.6'; -const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'; -const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); -const activeClientIds = new Set(); +const PACKAGE_VERSION = '2.15.0' +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() -addEventListener('install', () => { - self.skipWaiting(); -}); +addEventListener('install', function () { + self.skipWaiting() +}) -addEventListener('activate', (event) => { - event.waitUntil(self.clients.claim()); -}); +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) -addEventListener('message', async (event) => { - const clientId = Reflect.get(event.source || {}, 'id'); +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') if (!clientId || !self.clients) { - return; + return } - const client = await self.clients.get(clientId); + const client = await self.clients.get(clientId) if (!client) { - return; + return } const allClients = await self.clients.matchAll({ type: 'window', - }); + }) switch (event.data) { case 'KEEPALIVE_REQUEST': { sendToClient(client, { type: 'KEEPALIVE_RESPONSE', - }); - break; + }) + break } case 'INTEGRITY_CHECK_REQUEST': { @@ -52,12 +52,12 @@ addEventListener('message', async (event) => { packageVersion: PACKAGE_VERSION, checksum: INTEGRITY_CHECKSUM, }, - }); - break; + }) + break } case 'MOCK_ACTIVATE': { - activeClientIds.add(clientId); + activeClientIds.add(clientId) sendToClient(client, { type: 'MOCKING_ENABLED', @@ -67,51 +67,54 @@ addEventListener('message', async (event) => { frameType: client.frameType, }, }, - }); - break; + }) + break } case 'CLIENT_CLOSED': { - activeClientIds.delete(clientId); + activeClientIds.delete(clientId) const remainingClients = allClients.filter((client) => { - return client.id !== clientId; - }); + return client.id !== clientId + }) // Unregister itself when there are no more clients if (remainingClients.length === 0) { - self.registration.unregister(); + self.registration.unregister() } - break; + break } } -}); +}) -addEventListener('fetch', (event) => { - const requestInterceptedAt = Date.now(); +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() // Bypass navigation requests. if (event.request.mode === 'navigate') { - return; + return } // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. - if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') { - return; + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return } // Bypass all requests when there are no active clients. // Prevents the self-unregistered worked from handling requests // after it's been terminated (still remains active until the next reload). if (activeClientIds.size === 0) { - return; + return } - const requestId = crypto.randomUUID(); - event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); -}); + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) /** * @param {FetchEvent} event @@ -119,18 +122,33 @@ addEventListener('fetch', (event) => { * @param {number} requestInterceptedAt */ async function handleRequest(event, requestId, requestInterceptedAt) { - const client = await resolveMainClient(event); - const requestCloneForEvents = event.request.clone(); - const response = await getResponse(event, client, requestId, requestInterceptedAt); + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) // Send back the response clone for the "response:*" life-cycle events. // Ensure MSW is active and ready to handle the message, otherwise // this message will pend indefinitely. if (client && activeClientIds.has(client.id)) { - const serializedRequest = await serializeRequest(requestCloneForEvents); + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers + .get('content-type') + ?.toLowerCase() + .startsWith('text/event-stream') // Clone the response so both the client and the library could consume it. - const responseClone = response.clone(); + const responseClone = isEventStreamResponse ? null : response.clone() sendToClient( client, @@ -143,19 +161,21 @@ async function handleRequest(event, requestId, requestInterceptedAt) { ...serializedRequest, }, response: { - type: responseClone.type, - status: responseClone.status, - statusText: responseClone.statusText, - headers: Object.fromEntries(responseClone.headers.entries()), - body: responseClone.body, + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, }, }, }, - responseClone.body ? [serializedRequest.body, responseClone.body] : [], - ); + responseClone && responseClone.body + ? [serializedRequest.body, responseClone.body] + : [], + ) } - return response; + return response } /** @@ -167,30 +187,30 @@ async function handleRequest(event, requestId, requestInterceptedAt) { * @returns {Promise} */ async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId); + const client = await self.clients.get(event.clientId) if (activeClientIds.has(event.clientId)) { - return client; + return client } if (client?.frameType === 'top-level') { - return client; + return client } const allClients = await self.clients.matchAll({ type: 'window', - }); + }) return allClients .filter((client) => { // Get only those clients that are currently visible. - return client.visibilityState === 'visible'; + return client.visibilityState === 'visible' }) .find((client) => { // Find the client ID that's recorded in the // set of clients that have registered the worker. - return activeClientIds.has(client.id); - }); + return activeClientIds.has(client.id) + }) } /** @@ -203,34 +223,36 @@ async function resolveMainClient(event) { async function getResponse(event, client, requestId, requestInterceptedAt) { // Clone the request because it might've been already used // (i.e. its body has been read and sent to the client). - const requestClone = event.request.clone(); + const requestClone = event.request.clone() function passthrough() { // Cast the request headers to a new Headers instance // so the headers can be manipulated with. - const headers = new Headers(requestClone.headers); + const headers = new Headers(requestClone.headers) // Remove the "accept" header value that marked this request as passthrough. // This prevents request alteration and also keeps it compliant with the // user-defined CORS policies. - const acceptHeader = headers.get('accept'); + const acceptHeader = headers.get('accept') if (acceptHeader) { - const values = acceptHeader.split(',').map((value) => value.trim()); - const filteredValues = values.filter((value) => value !== 'msw/passthrough'); + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) if (filteredValues.length > 0) { - headers.set('accept', filteredValues.join(', ')); + headers.set('accept', filteredValues.join(', ')) } else { - headers.delete('accept'); + headers.delete('accept') } } - return fetch(requestClone, { headers }); + return fetch(requestClone, { headers }) } // Bypass mocking when the client is not active. if (!client) { - return passthrough(); + return passthrough() } // Bypass initial page load requests (i.e. static assets). @@ -238,11 +260,11 @@ async function getResponse(event, client, requestId, requestInterceptedAt) { // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet // and is not ready to handle requests. if (!activeClientIds.has(client.id)) { - return passthrough(); + return passthrough() } // Notify the client that a request has been intercepted. - const serializedRequest = await serializeRequest(event.request); + const serializedRequest = await serializeRequest(event.request) const clientMessage = await sendToClient( client, { @@ -254,19 +276,19 @@ async function getResponse(event, client, requestId, requestInterceptedAt) { }, }, [serializedRequest.body], - ); + ) switch (clientMessage.type) { case 'MOCK_RESPONSE': { - return respondWithMock(clientMessage.data); + return respondWithMock(clientMessage.data) } case 'PASSTHROUGH': { - return passthrough(); + return passthrough() } } - return passthrough(); + return passthrough() } /** @@ -277,18 +299,21 @@ async function getResponse(event, client, requestId, requestInterceptedAt) { */ function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { - const channel = new MessageChannel(); + const channel = new MessageChannel() channel.port1.onmessage = (event) => { if (event.data && event.data.error) { - return reject(event.data.error); + return reject(event.data.error) } - resolve(event.data); - }; + resolve(event.data) + } - client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]); - }); + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) } /** @@ -301,17 +326,17 @@ function respondWithMock(response) { // instance will have status code set to 0. Since it's not possible to create // a Response instance with status code 0, handle that use-case separately. if (response.status === 0) { - return Response.error(); + return Response.error() } - const mockedResponse = new Response(response.body, response); + const mockedResponse = new Response(response.body, response) Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { value: true, enumerable: true, - }); + }) - return mockedResponse; + return mockedResponse } /** @@ -332,5 +357,5 @@ async function serializeRequest(request) { referrerPolicy: request.referrerPolicy, body: await request.arrayBuffer(), keepalive: request.keepalive, - }; + } } diff --git a/apps/demo/src/mocks/data/agents.ts b/apps/demo/src/mocks/data/agents.ts index d00e939e7..b44736bfa 100644 --- a/apps/demo/src/mocks/data/agents.ts +++ b/apps/demo/src/mocks/data/agents.ts @@ -4,7 +4,7 @@ export const agents = [ host: '192.168.1.50', port: 3001, connected: true, - version: '1.6.0-rc.7', + version: '1.6.0-rc.8', os: 'linux', arch: 'amd64', cpus: 4, diff --git a/apps/demo/src/mocks/data/audit.ts b/apps/demo/src/mocks/data/audit.ts index 6d62dc9c0..36205c822 100644 --- a/apps/demo/src/mocks/data/audit.ts +++ b/apps/demo/src/mocks/data/audit.ts @@ -3,7 +3,7 @@ export const auditEntries = [ id: 'aud-001', timestamp: '2026-03-10T08:00:00.000Z', action: 'system:start', - details: 'Drydock v1.6.0-rc.7 started', + details: 'Drydock v1.6.0-rc.8 started', }, { id: 'aud-002', @@ -207,6 +207,6 @@ export const auditEntries = [ timestamp: '2026-03-03T18:00:00.000Z', action: 'container:watch', container: 'drydock', - details: 'Started watching ghcr.io/codeswhat/drydock:1.6.0-rc.7', + details: 'Started watching ghcr.io/codeswhat/drydock:1.6.0-rc.8', }, ]; diff --git a/apps/demo/src/mocks/data/containers.ts b/apps/demo/src/mocks/data/containers.ts index 66cbaa45d..c0c627bae 100644 --- a/apps/demo/src/mocks/data/containers.ts +++ b/apps/demo/src/mocks/data/containers.ts @@ -330,7 +330,7 @@ export const containers = [ displayName: 'Drydock', displayIcon: 'sh-drydock', image: 'codeswhat/drydock', - tag: '1.6.0-rc.7', + tag: '1.6.0-rc.8', registryType: 'ghcr', registryUrl: 'https://ghcr.io', scanStatus: 'scanned', diff --git a/apps/demo/src/mocks/data/server.ts b/apps/demo/src/mocks/data/server.ts index c6b83029f..9b9cbbcf5 100644 --- a/apps/demo/src/mocks/data/server.ts +++ b/apps/demo/src/mocks/data/server.ts @@ -1,5 +1,5 @@ export const serverInfo = { - version: '1.6.0-rc.7', + version: '1.6.0-rc.8', uptime: 864000, hostname: 'drydock-demo', platform: 'linux', diff --git a/apps/demo/src/mocks/handlers/app.ts b/apps/demo/src/mocks/handlers/app.ts index 1d458f243..bf78f8581 100644 --- a/apps/demo/src/mocks/handlers/app.ts +++ b/apps/demo/src/mocks/handlers/app.ts @@ -4,7 +4,7 @@ export const appHandlers = [ http.get('/api/v1/app', () => HttpResponse.json({ name: 'Drydock', - version: '1.6.0-rc.7', + version: '1.6.0-rc.8', description: 'Docker container update manager', repository: 'https://github.com/CodesWhat/drydock', documentation: 'https://getdrydock.com/docs', @@ -16,7 +16,7 @@ export const appHandlers = [ return HttpResponse.json( { generatedAt: new Date().toISOString(), - server: { version: '1.6.0-rc.7', mode: 'demo' }, + server: { version: '1.6.0-rc.8', mode: 'demo' }, summary: { containers: 25, watchers: 2, diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 288a8c298..61fb4e424 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -654,31 +654,31 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.11" + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.6" + "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", @@ -686,9 +686,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, "node_modules/@fuma-translate/react": { @@ -708,18 +708,14 @@ } }, "node_modules/@fumadocs/tailwind": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@fumadocs/tailwind/-/tailwind-0.0.5.tgz", - "integrity": "sha512-ENKPWUDRmriccsrUDE4bDBq3FNr/ms3BP2rWlsAEMV1yP23pcCaan+ceGfeBUsAQjw7sj9Q3R4Kl3g/TCStPzQ==", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@fumadocs/tailwind/-/tailwind-0.1.1.tgz", + "integrity": "sha512-BnPe52UxSaG8yKlHMKBxXw8h6GpK5qO55ci6+Qd5JnquTvIw6SpfbC1P+qAi82PuPWv1KZAWY8bxRk4+x9ctXw==", "license": "MIT", "peerDependencies": { - "@tailwindcss/oxide": "^4.0.0", "tailwindcss": "^4.0.0" }, "peerDependenciesMeta": { - "@tailwindcss/oxide": { - "optional": true - }, "tailwindcss": { "optional": true } @@ -1310,7 +1306,6 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -1517,32 +1512,32 @@ } }, "node_modules/@radix-ui/number": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", - "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", "license": "MIT" }, "node_modules/@radix-ui/primitive": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", - "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", "license": "MIT" }, "node_modules/@radix-ui/react-accordion": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.14.tgz", - "integrity": "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw==", + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.19.tgz", + "integrity": "sha512-ir4jA6TYdFonIC6n6ngyvaNQ6ReOMTqDg7MK/+D7U9KfWJtRoyoGLYwD1umh6LXGxc7QkKH1nwTOy7mewQz42w==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collapsible": "1.1.14", - "@radix-ui/react-collection": "1.1.10", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-use-controllable-state": "1.2.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collapsible": "1.1.19", + "@radix-ui/react-collection": "1.1.14", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-direction": "1.1.3", + "@radix-ui/react-id": "1.1.3", + "@radix-ui/react-primitive": "2.1.9", + "@radix-ui/react-use-controllable-state": "1.2.5" }, "peerDependencies": { "@types/react": "*", @@ -1560,12 +1555,12 @@ } }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz", - "integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.14.tgz", + "integrity": "sha512-5GcTjiRmUKHDdGz2Fwm+SbVfGDVg1DBCoxUsc1fUL5k84ZYpfwIOjmTLKwCjOw2ZcH3w47rWkkx/vY/VxKuslw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.6" + "@radix-ui/react-primitive": "2.1.9" }, "peerDependencies": { "@types/react": "*", @@ -1583,19 +1578,19 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.14.tgz", - "integrity": "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.19.tgz", + "integrity": "sha512-TbvE0YFHJDRQXk6UpczD72Jr0UCGkJtAz3IVHE6W5N10sfJ0ZYAfVbR3+GR0/2BLZWAjHnOAFNSpzLFjdpYJdQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-id": "1.1.3", + "@radix-ui/react-presence": "1.1.9", + "@radix-ui/react-primitive": "2.1.9", + "@radix-ui/react-use-controllable-state": "1.2.5", + "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -1613,15 +1608,15 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz", - "integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.14.tgz", + "integrity": "sha512-+n2VZftI+uAtyvmgn9mM33Gdc1gdvc9Fz6pjYm4qtQIXhvDR5oBQDYJCL/z053Q9nTIErpGEbt5H9K83W+mK1Q==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-primitive": "2.1.9", + "@radix-ui/react-slot": "1.3.2" }, "peerDependencies": { "@types/react": "*", @@ -1639,9 +1634,9 @@ } }, "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.4.tgz", + "integrity": "sha512-pWJo6lQAfR6uy1n7ii7PaCc9dLPwTXDYbQpORZU5B548Aqvl2pP1SM1vJGKyxIFqZMHRopRO4CQYX2iXAIB5jA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1654,9 +1649,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", - "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.1.tgz", + "integrity": "sha512-EraVbFjiIjibpLr6EjvEDmSCYJU2SlKDMiO+qEK/D9GOWnQoAQlpQo2occGYC1UM9MBeEx5Bek3UtW/Qi57vAg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1669,23 +1664,24 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", - "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.13", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.10", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.12", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.3", + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.22.tgz", + "integrity": "sha512-ZvXpCrhEH2OrVa1l8caiuWzrk6Qleq6PeZQ824lXxUxFu6mE4/XzRz1DliOvS18WuBl3A3clg2gHPNiPqz8BjA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-dismissable-layer": "1.1.18", + "@radix-ui/react-focus-guards": "1.1.5", + "@radix-ui/react-focus-scope": "1.1.15", + "@radix-ui/react-id": "1.1.3", + "@radix-ui/react-portal": "1.1.16", + "@radix-ui/react-presence": "1.1.9", + "@radix-ui/react-primitive": "2.1.9", + "@radix-ui/react-slot": "1.3.2", + "@radix-ui/react-use-controllable-state": "1.2.5", + "@radix-ui/react-use-layout-effect": "1.1.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -1705,9 +1701,9 @@ } }, "node_modules/@radix-ui/react-direction": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", - "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.3.tgz", + "integrity": "sha512-OdgAA/xb6WOQMKNn0mtbYoruMG6YMaBOnq+evWxSpMmiEMBdeFZawnIWRfPPeVXCRm+0lnN8jpJLjhD7/UIxWw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1720,16 +1716,16 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", - "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.18.tgz", + "integrity": "sha512-4tuTRonWG1Etcbc597++jlROMx7r34Otb0fATJhaK16Uq7XRotUS1aGyDX+yOBh668HQzhcpUpTLXFqMRVuFeA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-escape-keydown": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-primitive": "2.1.9", + "@radix-ui/react-use-callback-ref": "1.1.3", + "@radix-ui/react-use-effect-event": "0.0.4" }, "peerDependencies": { "@types/react": "*", @@ -1747,9 +1743,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", - "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.5.tgz", + "integrity": "sha512-UQvlB7L/BYh3P8MLvwZnQkH521EDos40Rwnbt5+Qpg4Vbk0z3xJjRUmR6+aka4aT1IQQXFdO5bNPoE7cvFl5xQ==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1762,14 +1758,14 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", - "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.15.tgz", + "integrity": "sha512-rKjyFmJRsUhrCg39GJ0DOMfrOxHbw8WaS/KMOUPhLLm80LfDffRQGzF8RdulfDP3QfOJPJj4Sma+EU3E/90ykw==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-use-callback-ref": "1.1.2" + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-primitive": "2.1.9", + "@radix-ui/react-use-callback-ref": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -1787,12 +1783,12 @@ } }, "node_modules/@radix-ui/react-id": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", - "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.3.tgz", + "integrity": "sha512-f/Wxm0ctyMymUJK0fqTSQlm85rbzdAkoNbPXJQ5+6caowVO8Yx+NWGjGz/oGhs/D+WIbbQpOrU0hU2Li2/42xQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -1805,25 +1801,25 @@ } }, "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.16.tgz", - "integrity": "sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.10", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.13", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.6" + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.21.tgz", + "integrity": "sha512-Y5SmS1Qd0SSG724Siw7ie5i+XL8+h3UgWLJcC/HaPKhuOnZz9sWP11gu5I7ZAvJL+rYu7X/FRZujRdf+s1QXTg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.14", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-direction": "1.1.3", + "@radix-ui/react-dismissable-layer": "1.1.18", + "@radix-ui/react-id": "1.1.3", + "@radix-ui/react-presence": "1.1.9", + "@radix-ui/react-primitive": "2.1.9", + "@radix-ui/react-use-callback-ref": "1.1.3", + "@radix-ui/react-use-controllable-state": "1.2.5", + "@radix-ui/react-use-layout-effect": "1.1.3", + "@radix-ui/react-use-previous": "1.1.3", + "@radix-ui/react-visually-hidden": "1.2.10" }, "peerDependencies": { "@types/react": "*", @@ -1841,24 +1837,25 @@ } }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.17.tgz", - "integrity": "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.13", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.10", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.1", - "@radix-ui/react-portal": "1.1.12", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.3", + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.22.tgz", + "integrity": "sha512-w5DCbDSP6TiQKmmfFPsZ+gNK/CN1OWx/vkTFFSH70aUPso6RbPNO+Rv08X4SYOblnLoN/x6N6jGsdEWfbeGLig==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-dismissable-layer": "1.1.18", + "@radix-ui/react-focus-guards": "1.1.5", + "@radix-ui/react-focus-scope": "1.1.15", + "@radix-ui/react-id": "1.1.3", + "@radix-ui/react-popper": "1.3.6", + "@radix-ui/react-portal": "1.1.16", + "@radix-ui/react-presence": "1.1.9", + "@radix-ui/react-primitive": "2.1.9", + "@radix-ui/react-slot": "1.3.2", + "@radix-ui/react-use-controllable-state": "1.2.5", + "@radix-ui/react-use-layout-effect": "1.1.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -1878,21 +1875,21 @@ } }, "node_modules/@radix-ui/react-popper": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", - "integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.6.tgz", + "integrity": "sha512-hMA05k4dWrsMLWey2njJmGOvBoRirwlilS1EaGF8zufJJUZiJ7dW96p48xVsIj1oJXPdtlcK7yG8W+JcDhFP7A==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.10", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-rect": "1.1.2", - "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/rect": "1.1.2" + "@radix-ui/react-arrow": "1.1.14", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-primitive": "2.1.9", + "@radix-ui/react-use-callback-ref": "1.1.3", + "@radix-ui/react-use-layout-effect": "1.1.3", + "@radix-ui/react-use-rect": "1.1.3", + "@radix-ui/react-use-size": "1.1.3", + "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -1910,13 +1907,13 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", - "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.16.tgz", + "integrity": "sha512-Lml0Z1vVXni7Sk29TTltPF2+SYTUrTJONrlrfQMTDGNKzEkXHIdHqYDQ/TkHEqsKT9Igy2jDWP7AmFeDeimd2w==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-primitive": "2.1.9", + "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -1934,12 +1931,12 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", - "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.9.tgz", + "integrity": "sha512-LTi1v05bprIb8/GSY/GWusI0jfsYjQ3CD3Nin8o7jVxnpHzVQfzjOQJoJTQkE9bdmOnsS7SFdhkXiBv8PrYnxw==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -1957,12 +1954,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", - "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.9.tgz", + "integrity": "sha512-aX5c84AYUD0EIodbQfpujB5XI0CbEaQ5U9du/wntNQK451i5iCJlE2JN6IN1LgQ3SnoBbnW/Jv1Z4rUowZV+hg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-slot": "1.3.2" }, "peerDependencies": { "@types/react": "*", @@ -1980,20 +1977,22 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz", - "integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.10", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.3" + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.18.tgz", + "integrity": "sha512-TLnxyyaccLuJV5p9PnApzswNNL5Zn9YEvYeDKKbO0dsvqq6Pg1vnExNzp4HnQ5MjhN8jwAOkq7HsDBZ9q66V0A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.14", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-direction": "1.1.3", + "@radix-ui/react-id": "1.1.3", + "@radix-ui/react-primitive": "2.1.9", + "@radix-ui/react-use-callback-ref": "1.1.3", + "@radix-ui/react-use-controllable-state": "1.2.5", + "@radix-ui/react-use-is-hydrated": "0.1.2", + "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2011,20 +2010,20 @@ } }, "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.12.tgz", - "integrity": "sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA==", + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.17.tgz", + "integrity": "sha512-LTyRyEyxDV8mMx8ef/N+AaigP/XuHggt59um/AgQdjO113VzCXOGzsBb6Jvh7el/Mz4XwZ/+UilkXFDSD/LGiw==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-direction": "1.1.3", + "@radix-ui/react-presence": "1.1.9", + "@radix-ui/react-primitive": "2.1.9", + "@radix-ui/react-use-callback-ref": "1.1.3", + "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2042,12 +2041,13 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", - "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.2.tgz", + "integrity": "sha512-nLBh0hpzgQA16ioKAWTSQvGjRj7QcI3pTpngtYZ12b4RP9N8eGSj7ziceHyHe04zrd0bAcLyMijO0ws0DLmDWw==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2060,19 +2060,19 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.15.tgz", - "integrity": "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.20.tgz", + "integrity": "sha512-/isB6Z/sIgMtnpGTig99pgMU0F8C4AmP7Dk36WN0PZ49H2mNjTpiCqmzIOOOZLez5F26DiZlQDFtPfQNMYb6Mg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-roving-focus": "1.1.13", - "@radix-ui/react-use-controllable-state": "1.2.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-direction": "1.1.3", + "@radix-ui/react-id": "1.1.3", + "@radix-ui/react-presence": "1.1.9", + "@radix-ui/react-primitive": "2.1.9", + "@radix-ui/react-roving-focus": "1.1.18", + "@radix-ui/react-use-controllable-state": "1.2.5" }, "peerDependencies": { "@types/react": "*", @@ -2090,9 +2090,9 @@ } }, "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.3.tgz", + "integrity": "sha512-AUS7HoBBAncIsGMLNG+CcpLuJ+JIBbZzmyM8Qdb1eIThX0AlhSSC6wn40xfBlPE+ypx/vSSiRWnklUAjy3U3UA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2105,13 +2105,14 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", - "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.5.tgz", + "integrity": "sha512-UB1dXpxvHjR48poyKdKdTm7jT0kp3elkUKdKQiOkirlbYumqXinSJtrjDsr9maXNPvL12bKI4CDSmydms/9Aeg==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.4", + "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2124,12 +2125,12 @@ } }, "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.4.tgz", + "integrity": "sha512-XYcfa6wlXDCwQtePuEiPmXLSAhGL4DWtedSyRgGbG3y10mw+OnrLp6SyeY1gJFMiYF0Dx0nMAX9InylKbLEFQQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2141,14 +2142,11 @@ } } }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", - "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.2.tgz", + "integrity": "sha512-2+gAVu9uaSLbopCTvvuWX9MIgEOlyqXC1Ok+KLCO7cZPSHLENs7dL0KbFQUGFIcFKmDW/bmaQRJtd8E3cnMRUw==", "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.2" - }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -2160,9 +2158,9 @@ } }, "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.3.tgz", + "integrity": "sha512-rDiah9wvtqihWtWz02XreeRKIxt2EJF8y5D9rtY9l5A2zxePAtcPiOMpDugNRw5bFHz+1/8viVoc7ZVKiJknCw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2175,9 +2173,9 @@ } }, "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", - "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.3.tgz", + "integrity": "sha512-kbefNKla5AGhgvMqrg/qS7mYVLXTEHQLqoFIS03nKN2dAfQQ5PK5Tp+2LxS7BV6a6l4HHv4jTg6rtV2Uajwe2w==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2190,12 +2188,12 @@ } }, "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", - "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.3.tgz", + "integrity": "sha512-W0GSYZFKEfi6raMiMEfJSngvVbFDIyxtW5JuVg5NoQBY59l0dVQVGLbhog/tOdwq0qtZ+TXuX1ikSNan4/IZXA==", "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.2" + "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2208,12 +2206,12 @@ } }, "node_modules/@radix-ui/react-use-size": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", - "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.3.tgz", + "integrity": "sha512-jJq6tQQLvO/z4uWbztjwPV+1a/+H4rCaWypasLB/ac8DEdd6+p7dIm7o3F6P2KZPGkPMqD4s5424EdAdoU+GLA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2226,12 +2224,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.6.tgz", - "integrity": "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.10.tgz", + "integrity": "sha512-j1q1Aw5zpNp/mlNSkXeQNsF4RhRd0g2KYgR/BeN20D+PGncx4NmWQ5sMriucY9hlYG3jVRW5jH9PCQShL+ZiDA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.6" + "@radix-ui/react-primitive": "2.1.9" }, "peerDependencies": { "@types/react": "*", @@ -2249,19 +2247,19 @@ } }, "node_modules/@radix-ui/rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", - "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, "node_modules/@shikijs/core": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.3.0.tgz", - "integrity": "sha512-EooU3i9F6IAE8kEu+AnGf9DFZWkQBZ+hJn3tLVbsH+61mtQiva5biai66fAA6nvFPXkLgvrh7BrR7YcJU83xQQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.3.1.tgz", + "integrity": "sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==", "license": "MIT", "dependencies": { - "@shikijs/primitive": "4.3.0", - "@shikijs/types": "4.3.0", + "@shikijs/primitive": "4.3.1", + "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" @@ -2271,12 +2269,12 @@ } }, "node_modules/@shikijs/engine-javascript": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.3.0.tgz", - "integrity": "sha512-hTv/KiFf2tpiqlACPiztGGurEARWIutB8YUhcrA1pUC7VzzwKO+g5crUocrLztrZ5ro5Z4hbXg7bYclETn3gSQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.3.1.tgz", + "integrity": "sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.0", + "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" }, @@ -2285,12 +2283,12 @@ } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.3.0.tgz", - "integrity": "sha512-1vMdN3gHfnKfLYwecUI2ITJI4RhHt96xEaJumVn7Heb0IlJ8WQMIH0Voak+2j22BpSNKdnOfB/pCTPnPm2gq7A==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.3.1.tgz", + "integrity": "sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.0", + "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2" }, "engines": { @@ -2298,24 +2296,24 @@ } }, "node_modules/@shikijs/langs": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.3.0.tgz", - "integrity": "sha512-rnlqFbBRSys9bT4gl/5rw9RnS0W/I84ZldXPkO7cvlEMoV85TyF/aU01N7/NbSR776RNLjrJKjfFUXJR6wN1Cg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.3.1.tgz", + "integrity": "sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.0" + "@shikijs/types": "4.3.1" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/primitive": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.3.0.tgz", - "integrity": "sha512-CPkz64PTa5diRW1ggzMZH9VM/du4RNChYgVtgqrFcgruvIybmCvySv8GkiHSczUHXYuuR8TdKEwFx+UnZMpgdg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.3.1.tgz", + "integrity": "sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.0", + "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" }, @@ -2324,21 +2322,21 @@ } }, "node_modules/@shikijs/themes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.3.0.tgz", - "integrity": "sha512-Avgt05YiT+Y3prjIc9lmQxhJzHBcCfR6cjiFW4OyaMBbt2A6trX5rfjUzx+Vj/mE9qpArYjatnqo9XPjQNW/AQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.3.1.tgz", + "integrity": "sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.0" + "@shikijs/types": "4.3.1" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/types": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.3.0.tgz", - "integrity": "sha512-oc8b9U2SYvofKZk8e/737nIX0qwf6eV2vHFATeObAu7r+mUVpLs8Re0BmVkIjAWAYgkmG/CzLNo7rzuBzRu/wQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.3.1.tgz", + "integrity": "sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", @@ -2389,7 +2387,7 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 20" @@ -2416,6 +2414,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2432,6 +2431,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2448,6 +2448,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2464,6 +2465,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2480,6 +2482,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2496,6 +2499,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -2515,6 +2519,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -2534,6 +2539,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -2553,6 +2559,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -2580,6 +2587,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2596,6 +2604,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { "version": "1.11.1", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -2606,6 +2615,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { "version": "1.11.1", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -2615,6 +2625,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.2", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -2624,6 +2635,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -2641,6 +2653,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { "version": "0.10.2", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -2650,6 +2663,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { "version": "2.8.1", + "dev": true, "inBundle": true, "license": "0BSD", "optional": true @@ -2661,6 +2675,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2677,6 +2692,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2876,6 +2892,162 @@ } } }, + "node_modules/@yuku-analyzer/binding-darwin-arm64": { + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/@yuku-analyzer/binding-darwin-arm64/-/binding-darwin-arm64-0.6.12.tgz", + "integrity": "sha512-9rpIP7IeybjyvWUf6WnU24h1qo+JdxIHr1o3yb06HoE8tM3S/Jh5RrUw9aw5P9BKSIvSPbLyVlItX7PcD3o5bQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-analyzer/binding-darwin-x64": { + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/@yuku-analyzer/binding-darwin-x64/-/binding-darwin-x64-0.6.12.tgz", + "integrity": "sha512-ELLhNT4FGnqY8yh0W3cSs9rGMSeUyhib1aYD84RupjlfsrDTrQRoDhWu01Dv6xCfYgASYaj1Abntk91A7njNag==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-analyzer/binding-freebsd-x64": { + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/@yuku-analyzer/binding-freebsd-x64/-/binding-freebsd-x64-0.6.12.tgz", + "integrity": "sha512-s76XocUMlK9liTyipALFb2K64ku35u/wg238A0NW8U5CUDsuIe/8tu5TzdLjJAGxnd0IV+gBneDt9cJJzLeFRQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@yuku-analyzer/binding-linux-arm-gnu": { + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/@yuku-analyzer/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.6.12.tgz", + "integrity": "sha512-hm8Tq0umop3RGu6dOMF61q69tYn1bDp1CeYD5ZjuGFQJclp0moVtjzY4z0bzusicKeZ9+k5LRroR0p5HWC2hDw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-analyzer/binding-linux-arm-musl": { + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/@yuku-analyzer/binding-linux-arm-musl/-/binding-linux-arm-musl-0.6.12.tgz", + "integrity": "sha512-CxtPKLddogHAB3ZHVWaUl+U8jx0pdriTSbQ1K/orlDqU0GDhg8LuIRyUscP7r2/62fGGMzkc119fE71I4Nl1Fg==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-analyzer/binding-linux-arm64-gnu": { + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/@yuku-analyzer/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.6.12.tgz", + "integrity": "sha512-EOyLcpAmF5qAVDKmKvV7xt8oBGeWQ92CqFI4s7h7TRlrF6TfGRrh8PwawGn92gFploNLAYj/1Z9Q1gVvwGgG9g==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-analyzer/binding-linux-arm64-musl": { + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/@yuku-analyzer/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.6.12.tgz", + "integrity": "sha512-T3eCYy6bMnVRMQEYAbDcpj08/XM93dBTtnn/DDocJN21RARe+KCzWKeL26J3yd3bOW3WVjVLq09BfdpAGB0buQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-analyzer/binding-linux-x64-gnu": { + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/@yuku-analyzer/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.6.12.tgz", + "integrity": "sha512-1Y+noIuvnDugIVsoIr5NduZqX7KuFTzICSkvG8RW3OKK9URVeTOicKK217i44ABZSSZJ7A0E7vzifapx0c9VDw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-analyzer/binding-linux-x64-musl": { + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/@yuku-analyzer/binding-linux-x64-musl/-/binding-linux-x64-musl-0.6.12.tgz", + "integrity": "sha512-woN/GuG95Fd6bp+ZQfmiFrZnoA2hdu3vfVSc89A8LElnYpzFaJM81sOZp8f3tVOVUJxbt7KAUiCLwSy34MJKqA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-analyzer/binding-win32-arm64": { + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/@yuku-analyzer/binding-win32-arm64/-/binding-win32-arm64-0.6.12.tgz", + "integrity": "sha512-8OVFnKbK+lgsL6MqILPLpzlsa00K4KiKsdbHH94hpGcrqaz1jv+k0Y7ujSaoYTWw5Bb7Lr9GJ3L1n1hT2sXoYA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-analyzer/binding-win32-x64": { + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/@yuku-analyzer/binding-win32-x64/-/binding-win32-x64-0.6.12.tgz", + "integrity": "sha512-3w8w1Xc5njwgbGTcn3JfDxWuQnFvtSll1D8gBlk4U8CI5v7ibKOMIdABucCXH8WtsRREG0ME5Vn0i422eX3zLQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-toolchain/types": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/@yuku-toolchain/types/-/types-0.6.11.tgz", + "integrity": "sha512-i1JYFNJaKNCgyJ/nVoR8GK7wvlXF+ShYzFHBauWcvg8IoiXInK7pVziHcgNz/MWLPNr/Mb/CtmXccrJMkKqSHQ==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2897,12 +3069,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3406,12 +3572,12 @@ } }, "node_modules/framer-motion": { - "version": "12.42.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.0.tgz", - "integrity": "sha512-wp7EJnfWaaEScVygKv3e20udoRz+LbtxScsuTkakAxfXmt+ReC6WyPW2nINRAGvd+hG9odwcjBLyOTPjH5pBRA==", + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", + "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", "license": "MIT", "dependencies": { - "motion-dom": "^12.42.0", + "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, @@ -3433,9 +3599,9 @@ } }, "node_modules/fumadocs-core": { - "version": "16.10.6", - "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.10.6.tgz", - "integrity": "sha512-vd/hidAsC1d8ldrfCvr/vb/H46AC/iRm0Zp4SOWWWfa1iNoFw1mFIRGHwaNDB9ETkZPZ7xUwhi3QkhlXPrYbVw==", + "version": "16.12.1", + "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.12.1.tgz", + "integrity": "sha512-6NnDxUqe0hIiShbWjqvLXvPYV0n0gi01UHmDAkDs5KVcfxfgOPz5bAbj45JDY0Ykq39Mr8z1xRt9h/HwIhe8fw==", "license": "MIT", "dependencies": { "@orama/orama": "^3.1.18", @@ -3443,18 +3609,19 @@ "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", - "js-yaml": "^5.1.0", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", + "npm-to-yarn": "3.1.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", - "shiki": "^4.2.0", + "shiki": "^4.3.1", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", - "vfile": "^6.0.3" + "vfile": "^6.0.3", + "yaml": "^2.9.0" }, "peerDependencies": { "@mdx-js/mdx": "*", @@ -3534,9 +3701,9 @@ } }, "node_modules/fumadocs-mdx": { - "version": "15.0.13", - "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.0.13.tgz", - "integrity": "sha512-VsGhCiLriXXMzm3WbgrVP7t6LvOthwh1BC+IGSI1ZW63UcSo1jE4aAiuUrTIF0jv1EGQkJG8cPsy0cOnf4sejA==", + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.2.0.tgz", + "integrity": "sha512-+yBP8QYw5wA9LF5eVdMhwbP7KT1OF4B/YfC6PZoD2jz0amZi1B+6QHTI6XoRRSTmhWrI4cL5LU1DspW0itk+NA==", "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.1.1", @@ -3544,22 +3711,26 @@ "chokidar": "^5.0.0", "esbuild": "^0.28.1", "estree-util-value-to-estree": "^3.5.0", - "js-yaml": "^5.1.0", + "github-slugger": "^2.0.0", + "magic-string": "^0.30.21", "mdast-util-mdx": "^3.0.0", "picocolors": "^1.1.1", - "picomatch": "^4.0.4", + "picomatch": "^4.0.5", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", + "yaml": "^2.9.0", + "yuku-analyzer": "^0.6.3", "zod": "^4.4.3" }, "bin": { "fumadocs-mdx": "bin.js" }, "peerDependencies": { + "@fumadocs/satteri": "0.x.x", "@types/mdast": "*", "@types/mdx": "*", "@types/react": "*", @@ -3568,9 +3739,13 @@ "next": "^15.3.0 || ^16.0.0", "react": "^19.2.0", "rolldown": "*", + "satteri": "^0.9.4", "vite": "7.x.x || 8.x.x" }, "peerDependenciesMeta": { + "@fumadocs/satteri": { + "optional": true + }, "@types/mdast": { "optional": true }, @@ -3592,53 +3767,53 @@ "rolldown": { "optional": true }, + "satteri": { + "optional": true + }, "vite": { "optional": true } } }, "node_modules/fumadocs-ui": { - "version": "16.10.6", - "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.10.6.tgz", - "integrity": "sha512-Sgp3r1FKNMnuG1AQlLUDsOcuE9kR3GU6RGuIUC5io3PFUNRc+IE4cjwGYv4i8vZi+VPV1Qqwm8oWztHCkwLvsg==", + "version": "16.12.1", + "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.12.1.tgz", + "integrity": "sha512-/YYERe99PJYw09RiYmCetdcu9uIjrUff+uoYk1EzgTLNtKlt2FNJJCcWYykG76wWlT28hXRs1bt8eFVq9dIU9w==", "license": "MIT", "dependencies": { "@fuma-translate/react": "^1.0.2", - "@fumadocs/tailwind": "0.0.5", - "@radix-ui/react-accordion": "^1.2.14", - "@radix-ui/react-collapsible": "^1.1.14", - "@radix-ui/react-dialog": "^1.1.17", + "@fumadocs/tailwind": "0.1.1", + "@radix-ui/react-accordion": "^1.2.17", + "@radix-ui/react-collapsible": "^1.1.17", + "@radix-ui/react-dialog": "^1.1.20", "@radix-ui/react-direction": "^1.1.2", - "@radix-ui/react-navigation-menu": "^1.2.16", - "@radix-ui/react-popover": "^1.1.17", - "@radix-ui/react-presence": "^1.1.6", - "@radix-ui/react-scroll-area": "^1.2.12", + "@radix-ui/react-navigation-menu": "^1.2.19", + "@radix-ui/react-popover": "^1.1.20", + "@radix-ui/react-presence": "^1.1.8", + "@radix-ui/react-scroll-area": "^1.2.15", "@radix-ui/react-slot": "^1.3.0", - "@radix-ui/react-tabs": "^1.1.15", + "@radix-ui/react-tabs": "^1.1.18", "class-variance-authority": "^0.7.1", "cnfast": "^0.0.8", - "lucide-react": "^1.21.0", - "motion": "^12.41.0", + "lucide-react": "^1.25.0", + "motion": "^12.42.2", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", "rehype-raw": "^7.0.0", "scroll-into-view-if-needed": "^3.1.0", - "shiki": "^4.2.0", + "shiki": "^4.3.1", "unist-util-visit": "^5.1.0" }, "peerDependencies": { - "@takumi-rs/image-response": "*", "@types/mdx": "*", "@types/react": "*", - "fumadocs-core": "16.10.6", + "fumadocs-core": "16.12.1", "next": "16.x.x", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "takumi-js": "*" }, "peerDependenciesMeta": { - "@takumi-rs/image-response": { - "optional": true - }, "@types/mdx": { "optional": true }, @@ -3647,6 +3822,9 @@ }, "next": { "optional": true + }, + "takumi-js": { + "optional": true } } }, @@ -3939,28 +4117,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/lefthook": { "version": "2.1.10", "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.10.tgz", @@ -4408,9 +4564,9 @@ } }, "node_modules/lucide-react": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", - "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.26.0.tgz", + "integrity": "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -4420,7 +4576,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -5468,12 +5623,12 @@ "license": "MIT" }, "node_modules/motion": { - "version": "12.42.0", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.0.tgz", - "integrity": "sha512-Qhwvu9sVl5/URSq5CNzwMCpSKK8Uhnrwb6VO977kZyj/wOCS7mWebJUnBoHx5cZU1Zv8a9BD5CSICWKAlrLJgA==", + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz", + "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==", "license": "MIT", "dependencies": { - "framer-motion": "^12.42.0", + "framer-motion": "^12.42.2", "tslib": "^2.4.0" }, "peerDependencies": { @@ -5494,9 +5649,9 @@ } }, "node_modules/motion-dom": { - "version": "12.42.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.0.tgz", - "integrity": "sha512-M63h4n8R+quJdNhBwuLlgxM+OLYa9+I/T2pzDRboB9fLXRdbou+Gw7Zury+SkpaCyACP1JHSjHgZ1EgTkBr30w==", + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", + "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", "license": "MIT", "dependencies": { "motion-utils": "^12.39.0" @@ -5595,6 +5750,18 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/npm-to-yarn": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/npm-to-yarn/-/npm-to-yarn-3.1.0.tgz", + "integrity": "sha512-9gNsO/JB3LeWOZXBX09cKMsCPwVcu1ExIf+GUuTN9G+0zZvLIK0nU9+lE9jue3MSKAxPdrh0rO072mWNvciqeQ==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/nebrelbug/npm-to-yarn?sponsor=1" + } + }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -5656,9 +5823,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -5668,9 +5835,9 @@ } }, "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -6115,17 +6282,17 @@ } }, "node_modules/shiki": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.3.0.tgz", - "integrity": "sha512-NKKjWzR6LIGL3sXBrWDw9sDS9cxx42/DkysaNqJEeOWE8Kix5gpak0bc00OfDVEO4oyXSyz8+aRaqKoBD1yo7A==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.3.1.tgz", + "integrity": "sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==", "license": "MIT", "dependencies": { - "@shikijs/core": "4.3.0", - "@shikijs/engine-javascript": "4.3.0", - "@shikijs/engine-oniguruma": "4.3.0", - "@shikijs/langs": "4.3.0", - "@shikijs/themes": "4.3.0", - "@shikijs/types": "4.3.0", + "@shikijs/core": "4.3.1", + "@shikijs/engine-javascript": "4.3.1", + "@shikijs/engine-oniguruma": "4.3.1", + "@shikijs/langs": "4.3.1", + "@shikijs/themes": "4.3.1", + "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" }, @@ -6528,6 +6695,59 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yuku-analyzer": { + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/yuku-analyzer/-/yuku-analyzer-0.6.12.tgz", + "integrity": "sha512-0zu/gwv6nKA3wm2GMjM1iczw9rbt77ijEyR5tXpPQ8AZcXIpXlll66BXOtMHgYudLn91bJx0ybhpARoJWm5/dw==", + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "0.6.11", + "yuku-ast": "0.6.11" + }, + "optionalDependencies": { + "@yuku-analyzer/binding-darwin-arm64": "0.6.12", + "@yuku-analyzer/binding-darwin-x64": "0.6.12", + "@yuku-analyzer/binding-freebsd-x64": "0.6.12", + "@yuku-analyzer/binding-linux-arm-gnu": "0.6.12", + "@yuku-analyzer/binding-linux-arm-musl": "0.6.12", + "@yuku-analyzer/binding-linux-arm64-gnu": "0.6.12", + "@yuku-analyzer/binding-linux-arm64-musl": "0.6.12", + "@yuku-analyzer/binding-linux-x64-gnu": "0.6.12", + "@yuku-analyzer/binding-linux-x64-musl": "0.6.12", + "@yuku-analyzer/binding-win32-arm64": "0.6.12", + "@yuku-analyzer/binding-win32-x64": "0.6.12" + } + }, + "node_modules/yuku-ast": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/yuku-ast/-/yuku-ast-0.6.11.tgz", + "integrity": "sha512-ZfXkFYVsDewS45+kv3WiA/qNB73CRfxFDEQwfnRMUAR4AD5zRI7PRqxmI2U3Jz/oG41GneTVW6mxDOQal0lgeA==", + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "0.6.8" + } + }, + "node_modules/yuku-ast/node_modules/@yuku-toolchain/types": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@yuku-toolchain/types/-/types-0.6.8.tgz", + "integrity": "sha512-AbUd1775RVkOxJkh8hkldIWoU6kRMTCsZFSZq8Ny53q7GkbaVe5UCfleNZ3RWCoz/ZKE8qwfeB7Cj0xqhLWsKA==", + "license": "MIT" + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index 2486baf1f..5171bf4f3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -50,7 +50,7 @@ "typescript": "6.0.3" }, "overrides": { - "postcss": "8.5.22", + "postcss": "8.5.23", "esbuild": "0.28.1", "js-yaml": "4.3.0", "sharp": "0.35.3", diff --git a/apps/web/src/lib/site-config.ts b/apps/web/src/lib/site-config.ts index a87872a1e..d5f22ddf0 100644 --- a/apps/web/src/lib/site-config.ts +++ b/apps/web/src/lib/site-config.ts @@ -15,7 +15,7 @@ export const SITE_CONFIG = { /** Brand name shown in the header, footer, and metadata. */ name: "Drydock", /** Current release version shown in the hero badge. */ - version: "1.6.0-rc.7", + version: "1.6.0-rc.8", /** Short product tagline used in page titles and OG metadata. */ tagline: "Container Update Monitoring", /** Default meta / OpenGraph / Twitter description. */ diff --git a/apps/web/src/lib/site-content.ts b/apps/web/src/lib/site-content.ts index 74c6c0d9f..cc96c6554 100644 --- a/apps/web/src/lib/site-content.ts +++ b/apps/web/src/lib/site-content.ts @@ -281,7 +281,7 @@ export const roadmap: Milestone[] = [ ], }, { - version: "v1.6.0-rc.7", + version: "v1.6.0-rc.8", title: "Notifications, Policy & Release Intel", emoji: "\u{1F4E8}", status: "next", diff --git a/biome.json b/biome.json index b7765ea4b..4b6b67b74 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.2/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -12,6 +12,7 @@ "!ui/public/index.html", "!apps/web/src/app/globals.css", "!apps/demo/public/favicon.svg", + "!apps/demo/public/mockServiceWorker.js", "!ui/public/favicon.svg", "!docs/assets/codeswhat-logo-dark.svg", "!docs/assets/codeswhat-logo-original.svg", diff --git a/content/docs/current/api/agent.mdx b/content/docs/current/api/agent.mdx index 453df6703..66cb07506 100644 --- a/content/docs/current/api/agent.mdx +++ b/content/docs/current/api/agent.mdx @@ -23,7 +23,7 @@ curl http://drydock:3000/api/v1/agents "host": "192.168.1.50", "port": 3000, "connected": true, - "version": "1.6.0-rc.7", + "version": "1.6.0-rc.8", "os": "linux", "arch": "amd64", "cpus": 4, @@ -153,7 +153,7 @@ Sent immediately upon connection to confirm the handshake. { "type": "dd:ack", "data": { - "version": "1.6.0-rc.7", + "version": "1.6.0-rc.8", "os": "linux", "arch": "amd64", "cpus": 4, diff --git a/content/docs/current/api/app.mdx b/content/docs/current/api/app.mdx index 55daf96a7..271706869 100644 --- a/content/docs/current/api/app.mdx +++ b/content/docs/current/api/app.mdx @@ -12,7 +12,7 @@ curl http://drydock:3000/api/v1/app { "name":"drydock", - "version":"1.6.0-rc.7" + "version":"1.6.0-rc.8" } ``` diff --git a/content/docs/current/api/portwing.mdx b/content/docs/current/api/portwing.mdx index 4573ff9c1..c30eab65e 100644 --- a/content/docs/current/api/portwing.mdx +++ b/content/docs/current/api/portwing.mdx @@ -162,7 +162,7 @@ A versioned alias `/api/v1/portwing/ws` is also accepted and is signature-equiva "agentId": "edge-host-01", "agentName": "edge-host-01", "protocol": "portwing/1.0", - "version": "1.6.0-rc.7", + "version": "1.6.0-rc.8", "pubKeyId": "3f8a1c2e9b047d56", "timestamp": 1780329600, "nonce": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", @@ -185,7 +185,7 @@ A name collision with an already-connected agent under the same key (or the in-f "data": { "pollInterval": 300, "config": { - "drydockVersion": "1.6.0-rc.7", + "drydockVersion": "1.6.0-rc.8", "supportedProtocols": "portwing/1.0", "serverCompatLevel": "1.4.0" } diff --git a/content/docs/current/configuration/triggers/index.mdx b/content/docs/current/configuration/triggers/index.mdx index 8661bd63c..f58b9fb14 100644 --- a/content/docs/current/configuration/triggers/index.mdx +++ b/content/docs/current/configuration/triggers/index.mdx @@ -181,7 +181,7 @@ Rules are managed via `GET /api/v1/notifications` and `PATCH /api/v1/notificatio Overrides are keyed by canonical trigger ID, so the same event can use different copy for Slack, SMTP, ntfy, or any other configured notification provider. They use the same sandboxed `${...}` syntax and variables documented in [Template variable reference](#template-variable-reference). The preview API is `POST /api/v1/notifications/:id/preview`; see the [Notification rules API](/docs/api#notification-rules) for its request shape. -Command, Docker, and Docker Compose triggers cannot be assigned to notification rules or template overrides. These are update-action triggers, not notification triggers. Only messaging/alerting triggers (Slack, SMTP, Discord, ntfy, etc.) can be assigned. +Command, Docker, and Docker Compose triggers cannot be assigned to notification rules or template overrides. These are update-action triggers, not notification triggers. Only messaging/alerting triggers (Slack, SMTP, Discord, ntfy, etc.) can be assigned. Because of this, a rule's trigger list only scopes which notification channels receive messages. Action triggers are exempt from that list and keep executing updates no matter how it's scoped, including when the trigger runs on a remote agent. Disabling the rule itself (not narrowing its trigger list) is what stops automatic update execution via action triggers. ## Event Types diff --git a/content/docs/current/quickstart/index.mdx b/content/docs/current/quickstart/index.mdx index 5dbdbe687..9829f01f3 100644 --- a/content/docs/current/quickstart/index.mdx +++ b/content/docs/current/quickstart/index.mdx @@ -108,7 +108,7 @@ Release tags use the same channel names in every registry: | Tag | Behavior | | --- | --- | -| `1.6.0-rc.7` | Immutable release candidate; best for reproducible testing | +| `1.6.0-rc.8` | Immutable release candidate; best for reproducible testing | | `1.6-rc` | Rolling release-candidate channel; moves to the newest `1.6.0-rc.N` | | `1.6` | Rolling stable minor channel; published only for GA releases | | `1` | Rolling stable major channel; published only for GA releases | diff --git a/content/docs/current/updates/index.mdx b/content/docs/current/updates/index.mdx index c823d26d1..a6cebb71b 100644 --- a/content/docs/current/updates/index.mdx +++ b/content/docs/current/updates/index.mdx @@ -3,6 +3,11 @@ title: "Updates" description: "Release update notes and feature highlights, with direct links to current configuration and API docs." --- +## v1.6.0-rc.8 Highlights — July 28, 2026 + +- **Agent-managed containers keep their update policy** — remote agents never learn controller-side runtime overrides, so every agent report carried an empty override layer that the controller persisted verbatim, wiping maturity mode, min-age days, skip lists, and snoozes on every sync or recheck. The controller now reapplies its stored overrides when ingesting agent reports, and the store only honors an empty override layer when the update-policy API marks the clear as deliberate — so settings finally survive agent syncs while UI clears still stick ([#565](https://github.com/CodesWhat/drydock/issues/565)). +- **Auto-update keeps running when update notifications are scoped to specific channels** — assigning any notification trigger to the update-available rule silently disabled every action trigger (Docker, Docker Compose, Command) fleet-wide, because action triggers were run through an allow-list they're structurally barred from joining. Action triggers are now exempt from the allow-list membership check; disabling the rule itself remains the kill switch ([#623](https://github.com/CodesWhat/drydock/issues/623)). + ## v1.6.0-rc.7 Highlights — July 26, 2026 - **Four identity-drift bugs fixed** — the maturity soak clock no longer resets when a container is recreated, notification dedup no longer double-fires a `once: true` notification on a manual recheck, a finishing security scan no longer reverts an update the watcher detected mid-scan, and containers with no available update no longer surface under maturity/age filters or sorts. All four traced back to inconsistent candidate-identity comparisons, now unified behind one shared helper. diff --git a/e2e/package-lock.json b/e2e/package-lock.json index b94e1491e..64ce3b209 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -9,13 +9,13 @@ "version": "1.6.0", "license": "AGPL-3.0-only", "dependencies": { - "@cucumber/cucumber": "12.7.0", - "socket.io-parser": "4.2.6" + "@cucumber/cucumber": "12.9.0", + "socket.io-parser": "4.2.7" }, "devDependencies": { - "@dotenvx/dotenvx": "1.57.1", + "@dotenvx/dotenvx": "2.17.4", "@playwright/test": "1.61.1", - "artillery": "2.0.32", + "artillery": "2.0.33", "lodash": "4.18.1" }, "engines": { @@ -23,9 +23,9 @@ } }, "node_modules/@artilleryio/int-commons": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/@artilleryio/int-commons/-/int-commons-2.23.0.tgz", - "integrity": "sha512-sYezLOhOlvfK5zzRA+SjOw5LX2etK9NPcZ4TpIK1qxHw2EuBEvE8u1Q9yN9lpe68X7k6AqL3jpT4/zNxwT2j6A==", + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/@artilleryio/int-commons/-/int-commons-2.24.0.tgz", + "integrity": "sha512-edCjaTcUQNWxGS7XD128zYV4wABnh4itGmK1bzRTUe78huYGN9d21/Sf/aHWgl/DbbufYvvvU1FX9PD1l0m7jg==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -40,13 +40,13 @@ } }, "node_modules/@artilleryio/int-core": { - "version": "2.27.0", - "resolved": "https://registry.npmjs.org/@artilleryio/int-core/-/int-core-2.27.0.tgz", - "integrity": "sha512-tVITMUdlpMIlSA0+Qs5MCLcAOGl2AyimIiZ4AjlyJpqmIqPFBzRR+6xIcaqsG32vyI699r4taFwqe0ARFvOY1Q==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@artilleryio/int-core/-/int-core-2.28.0.tgz", + "integrity": "sha512-l1T2LMc0+BpGRH1sSfwb+jfeJWyTq0xrtJggRm3M7pMNU8Il1xZZBxxM2j+o7i8tXQPHZ/uSxdMUGNRaS6ygPA==", "dev": true, "license": "MPL-2.0", "dependencies": { - "@artilleryio/int-commons": "2.23.0", + "@artilleryio/int-commons": "2.24.0", "@artilleryio/sketches-js": "^2.1.1", "agentkeepalive": "^4.6.0", "arrivals": "^2.1.2", @@ -1463,9 +1463,9 @@ "license": "MIT" }, "node_modules/@cucumber/cucumber": { - "version": "12.7.0", - "resolved": "https://registry.npmjs.org/@cucumber/cucumber/-/cucumber-12.7.0.tgz", - "integrity": "sha512-7A/9CJpJDxv1SQ7hAZU0zPn2yRxx6XMR+LO4T94Enm3cYNWsEEj+RGX38NLX4INT+H6w5raX3Csb/qs4vUBsOA==", + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber/-/cucumber-12.9.0.tgz", + "integrity": "sha512-QbgEo/DcKFMRGL+yULh8Kw6peEfdPJjhYjpKp0dYc+6Dv1Bmp6hvxIdTi2CIinYBCXhvCZzNO1Ct/n6Dk1yAtA==", "license": "MIT", "dependencies": { "@cucumber/ci-environment": "13.0.0", @@ -1473,10 +1473,10 @@ "@cucumber/gherkin": "38.0.0", "@cucumber/gherkin-streams": "6.0.0", "@cucumber/gherkin-utils": "11.0.0", - "@cucumber/html-formatter": "23.0.0", - "@cucumber/junit-xml-formatter": "0.9.0", - "@cucumber/message-streams": "4.0.1", - "@cucumber/messages": "32.0.1", + "@cucumber/html-formatter": "23.1.0", + "@cucumber/junit-xml-formatter": "0.13.3", + "@cucumber/message-streams": "4.1.1", + "@cucumber/messages": "32.3.1", "@cucumber/pretty-formatter": "1.0.1", "@cucumber/tag-expressions": "9.1.0", "assertion-error-formatter": "^3.0.0", @@ -1496,7 +1496,6 @@ "lodash.merge": "^4.6.2", "lodash.mergewith": "^4.6.2", "luxon": "3.7.2", - "mime": "^3.0.0", "mkdirp": "^3.0.0", "mz": "^2.7.0", "progress": "^2.0.3", @@ -1590,21 +1589,21 @@ } }, "node_modules/@cucumber/html-formatter": { - "version": "23.0.0", - "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-23.0.0.tgz", - "integrity": "sha512-WwcRzdM8Ixy4e53j+Frm3fKM5rNuIyWUfy4HajEN+Xk/YcjA6yW0ACGTFDReB++VDZz/iUtwYdTlPRY36NbqJg==", + "version": "23.1.0", + "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-23.1.0.tgz", + "integrity": "sha512-DcCSFoGs6jbwzXPgX1CwgJKEE+ZMcIEzq/0Memg0o24maNn9NJizBFHmoFWG4iv/OxHza+mvc+56cTHetfHndw==", "license": "MIT", "peerDependencies": { "@cucumber/messages": ">=18" } }, "node_modules/@cucumber/junit-xml-formatter": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@cucumber/junit-xml-formatter/-/junit-xml-formatter-0.9.0.tgz", - "integrity": "sha512-WF+A7pBaXpKMD1i7K59Nk5519zj4extxY4+4nSgv5XLsGXHDf1gJnb84BkLUzevNtp2o2QzMG0vWLwSm8V5blw==", + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@cucumber/junit-xml-formatter/-/junit-xml-formatter-0.13.3.tgz", + "integrity": "sha512-w9ujOxiuKDtU6fLzJz+wp4Sgp5Xu6ba7ls00LHJccVmQU0Ba7zs+AHnv3iIgPjKZAQe1w8x93dr8Gaubh7Vqkg==", "license": "MIT", "dependencies": { - "@cucumber/query": "^14.0.1", + "@cucumber/query": "^15.0.1", "@teppeis/multimaps": "^3.0.0", "luxon": "^3.5.0", "xmlbuilder": "^15.1.1" @@ -1614,18 +1613,21 @@ } }, "node_modules/@cucumber/message-streams": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@cucumber/message-streams/-/message-streams-4.0.1.tgz", - "integrity": "sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cucumber/message-streams/-/message-streams-4.1.1.tgz", + "integrity": "sha512-QCAntLajesWMyX+mZKrj63YghVAts7yKFlZe46XprLbdJZN0ddB+f/Mr9OnyWKC2DHhJ18jzCfKIFCaqpAmUxg==", "license": "MIT", + "dependencies": { + "mime": "^3.0.0" + }, "peerDependencies": { "@cucumber/messages": ">=17.1.1" } }, "node_modules/@cucumber/messages": { - "version": "32.0.1", - "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-32.0.1.tgz", - "integrity": "sha512-1OSoW+GQvFUNAl6tdP2CTBexTXMNJF0094goVUcvugtQeXtJ0K8sCP0xbq7GGoiezs/eJAAOD03+zAPT64orHQ==", + "version": "32.3.1", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-32.3.1.tgz", + "integrity": "sha512-yNQq1KoXRYaEKrWMFmpUQX7TdeQuU9jeGgJAZ3dArTsC/T4NpJ6DnqaJIIgwPnz/wtQIQTNX7/h0rOuF5xY4qQ==", "license": "MIT", "dependencies": { "class-transformer": "0.5.1", @@ -1649,9 +1651,9 @@ } }, "node_modules/@cucumber/query": { - "version": "14.7.0", - "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-14.7.0.tgz", - "integrity": "sha512-fiqZ4gMEgYjmbuWproF/YeCdD5y+gD2BqgBIGbpihOsx6UlNsyzoDSfO+Tny0q65DxfK+pHo2UkPyEl7dO7wmQ==", + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-15.0.1.tgz", + "integrity": "sha512-FMfT3orJblRsOxvU2doECBvQmauizYlj+5JsM8atAKKPbnQTj7v2/OrnuykvQpfZNBf19DYbRq1e832vllRP/g==", "license": "MIT", "dependencies": { "@teppeis/multimaps": "3.0.0", @@ -1688,21 +1690,15 @@ } }, "node_modules/@dotenvx/dotenvx": { - "version": "1.57.1", - "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.57.1.tgz", - "integrity": "sha512-iKXuo8Nes9Ft4zF3AZOT4FHkl6OV8bHqn61a67qHokkBzSEurnKZAlOkT0FYrRNVGvE6nCfZMtYswyjfXCR1MQ==", + "version": "2.17.4", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-2.17.4.tgz", + "integrity": "sha512-hzW9xsf9bOUoY1V7xXnl+vZfjqdsdUs0zSOWzm6Zqfcm55nq3YaV+lXmeSzT3ju7xQ6fdSlLjwDQFqrIsMV3wA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "commander": "^11.1.0", - "dotenv": "^17.2.1", - "eciesjs": "^0.4.10", - "execa": "^5.1.1", - "fdir": "^6.2.0", - "ignore": "^5.3.0", - "object-treeify": "1.1.33", - "picomatch": "^4.0.2", - "which": "^4.0.0" + "@dotenvx/primitives": "^2.1.0", + "@dotenvx/tooling": "^1.0.3", + "yocto-spinner": "^1.2.1" }, "bin": { "dotenvx": "src/cli/dotenvx.js" @@ -1711,29 +1707,35 @@ "url": "https://dotenvx.com" } }, - "node_modules/@dotenvx/dotenvx/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "node_modules/@dotenvx/primitives": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@dotenvx/primitives/-/primitives-2.1.0.tgz", + "integrity": "sha512-GIpMSSgjZk4URRCtAw12vBmobnw3VeSKL+p7/y8ypWvTHFW+StAMZvq4wCjdX+QKuw4alXu0IMGW6QPhKo6ufg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } + "license": "BSD-3-Clause" + }, + "node_modules/@dotenvx/tooling": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@dotenvx/tooling/-/tooling-1.0.3.tgz", + "integrity": "sha512-Gd6qQol/ICb4MPRWpiIH56y+pR9AVB7kPBkbhrLSGLtAiF4Qgy+k651/f2pze0Xbm3H5XewbPfIm4y9K+ic30Q==", + "dev": true, + "license": "BSD-3-Clause" }, - "node_modules/@ecies/ciphers": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.5.tgz", - "integrity": "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==", + "node_modules/@faker-js/faker": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.4.0.tgz", + "integrity": "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], "license": "MIT", "engines": { - "bun": ">=1", - "deno": ">=2", - "node": ">=16" - }, - "peerDependencies": { - "@noble/ciphers": "^1.0.0" + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" } }, "node_modules/@grpc/grpc-js": { @@ -2261,63 +2263,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@ngneat/falso": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@ngneat/falso/-/falso-8.0.2.tgz", - "integrity": "sha512-vhPtuoHoxE5JGWPSPBqEyTXcjI4MAn8GllR+Vs8FfpAQu2sQRd4PJc3e8kc9vdbdhYHx1C9HmbECgtGLK30z4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "seedrandom": "3.0.5", - "uuid": "8.3.2" - } - }, - "node_modules/@noble/ciphers": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", - "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", "dev": true, "funding": [ { @@ -2423,9 +2372,9 @@ } }, "node_modules/@opentelemetry/core": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", - "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", + "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2722,14 +2671,14 @@ } }, "node_modules/@playwright/browser-chromium": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.60.0.tgz", - "integrity": "sha512-0ND2pbNWKJYwhlA1LNaDC3DP2x7eguQkQF7Ga7XAlJV0AFieqYNRw/E+gaY9BpSFr1TYwfwXQv1bHq5AK9nbvA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.61.0.tgz", + "integrity": "sha512-bBGzN+jow5uQtYRDCnFv5cpyMjUYTF6WnsfRRrDXIwbaGDtdh0JdOPmlKdJH4OK7TbkrZ35cV7jhQ1zELoX5ew==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0" + "playwright-core": "1.61.0" }, "engines": { "node": ">=18" @@ -3256,6 +3205,19 @@ "node": ">= 8" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -3278,14 +3240,14 @@ } }, "node_modules/artillery": { - "version": "2.0.32", - "resolved": "https://registry.npmjs.org/artillery/-/artillery-2.0.32.tgz", - "integrity": "sha512-W632foeIGbBOpDFG5EPkx8N1JWdKOL40yaACs8wLIW5mFByebttbhblALmARQbF6/GMDAKnKX7BewBjcd6TKuQ==", + "version": "2.0.33", + "resolved": "https://registry.npmjs.org/artillery/-/artillery-2.0.33.tgz", + "integrity": "sha512-7CiejAL4/stcm9o4GJ8yq1xAKNR39pgVmy2hvb+s9IBCMRzmSYSaRk1Lv/3nWxRtzan0C1LK62lM09MZDKJsfA==", "dev": true, "license": "MPL-2.0", "dependencies": { - "@artilleryio/int-commons": "2.23.0", - "@artilleryio/int-core": "2.27.0", + "@artilleryio/int-commons": "2.24.0", + "@artilleryio/int-core": "2.28.0", "@aws-sdk/client-cloudwatch": "^3.1034.0", "@aws-sdk/client-cloudwatch-logs": "^3.1034.0", "@aws-sdk/client-ec2": "^3.1034.0", @@ -3306,14 +3268,14 @@ "@oclif/plugin-not-found": "^3.2.73", "@smithy/core": "^3.24.0", "@upstash/redis": "^1.36.1", - "artillery-engine-playwright": "1.29.0", - "artillery-plugin-apdex": "1.23.0", - "artillery-plugin-ensure": "1.26.0", - "artillery-plugin-expect": "2.26.0", - "artillery-plugin-fake-data": "1.23.0", - "artillery-plugin-metrics-by-endpoint": "1.26.0", - "artillery-plugin-publish-metrics": "2.37.0", - "artillery-plugin-slack": "1.21.0", + "artillery-engine-playwright": "1.30.0", + "artillery-plugin-apdex": "1.24.0", + "artillery-plugin-ensure": "1.27.0", + "artillery-plugin-expect": "2.27.0", + "artillery-plugin-fake-data": "1.24.0", + "artillery-plugin-metrics-by-endpoint": "1.27.0", + "artillery-plugin-publish-metrics": "2.38.0", + "artillery-plugin-slack": "1.22.0", "async": "^2.6.4", "chalk": "^2.4.2", "chokidar": "^3.6.0", @@ -3349,26 +3311,26 @@ } }, "node_modules/artillery-engine-playwright": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/artillery-engine-playwright/-/artillery-engine-playwright-1.29.0.tgz", - "integrity": "sha512-z03gWZGgap8/ryp1ZX4V3KPHFv7RIjxuIu8i/SsvNdtNJ4E7HmSXf4EJkBGKrd1lRQfP+FJ3LhID7PsX03p8VA==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/artillery-engine-playwright/-/artillery-engine-playwright-1.30.0.tgz", + "integrity": "sha512-Anc9SJUL/969XTUjEGzYiTZMCg7C39VNpVNLCDgkvZ8DmGdDqcqgXl6yTymwzyJpJE7leo3x5t/r5ADUYLPdDw==", "dev": true, "license": "MPL-2.0", "dependencies": { - "@playwright/browser-chromium": "1.60.0", - "@playwright/test": "1.60.0", + "@playwright/browser-chromium": "1.61.0", + "@playwright/test": "1.61.0", "debug": "^4.4.3", - "playwright": "1.60.0" + "playwright": "1.61.0" } }, "node_modules/artillery-engine-playwright/node_modules/@playwright/test": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", - "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.60.0" + "playwright": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -3378,16 +3340,16 @@ } }, "node_modules/artillery-plugin-apdex": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/artillery-plugin-apdex/-/artillery-plugin-apdex-1.23.0.tgz", - "integrity": "sha512-wghoFU5+6f72GJB1REbwCpaX7xlXBx1ibg8zu80tvPZ6A1fEQynTZH2xKDKlDhfhxQmYuAh2Ft2vf1jljzeODw==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/artillery-plugin-apdex/-/artillery-plugin-apdex-1.24.0.tgz", + "integrity": "sha512-66sHtp8mtvSBkWfDaehMkERAEMc1NaOI6/i5hJikFP5iSZk3UC3PWBEeeyl1NGd39J1YpuFEA1aEn8ua048BYw==", "dev": true, "license": "MPL-2.0" }, "node_modules/artillery-plugin-ensure": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/artillery-plugin-ensure/-/artillery-plugin-ensure-1.26.0.tgz", - "integrity": "sha512-uvWTYCUb+JqKW/suxbuihCRW4mXnbGCb+qPJHJo2QWhNEdc506otIFFMcRULRyud7nNbM25G7vOtKqW0Juvovg==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/artillery-plugin-ensure/-/artillery-plugin-ensure-1.27.0.tgz", + "integrity": "sha512-2ePl9SY/O8rrViqY+KLwMjCn5zVmgmAUo+30ATuisBsJcxZ+Z2g+NaMVZ0AVB8CZNGrqaIxTShbH42i/EPgVKA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -3465,9 +3427,9 @@ } }, "node_modules/artillery-plugin-expect": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/artillery-plugin-expect/-/artillery-plugin-expect-2.26.0.tgz", - "integrity": "sha512-c0Qsh70iNKJnFa97kxEVdrUzIdAJbAR2kg9nmzdhH3bndna2LivVDSSaOqKlYIrwuvM1qyXXpUi9mdFRy5WgKA==", + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/artillery-plugin-expect/-/artillery-plugin-expect-2.27.0.tgz", + "integrity": "sha512-ZdXoNRb5xvZCdZFQMzJsf8VY508WMUQ0ou3dkIqmQumAUD68lLNZMQM9j9yjaniG1ivSy4OCxWle52J5Et3WSA==", "dev": true, "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { @@ -3478,19 +3440,19 @@ } }, "node_modules/artillery-plugin-fake-data": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/artillery-plugin-fake-data/-/artillery-plugin-fake-data-1.23.0.tgz", - "integrity": "sha512-YnJX6AlNZ8/uUc0soXsBRc2qd8zVbYI3qrLDOLVokmbG8TwkVUN2bDn9BeIcjEdEsAvKU0am8OaZfXlb69KWWQ==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/artillery-plugin-fake-data/-/artillery-plugin-fake-data-1.24.0.tgz", + "integrity": "sha512-Ft7GWaFCxRKUzlR1wBpNo6mJgmTHTcN4F7HAye5vx+svJagsyD5UmlBHLYFatJ8DiFR75sJepQE/AMrhTpziHg==", "dev": true, "license": "MPL-2.0", "dependencies": { - "@ngneat/falso": "^8.0.2" + "@faker-js/faker": "10.4.0" } }, "node_modules/artillery-plugin-metrics-by-endpoint": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/artillery-plugin-metrics-by-endpoint/-/artillery-plugin-metrics-by-endpoint-1.26.0.tgz", - "integrity": "sha512-FK3/zknlfWK2aKblA2uH2GXzYLz2CYc5cE5nAkxFN5k8LW9EcrYEwaCenXaGquQ4LfLOrhjcDpEjeWAdI06Klg==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/artillery-plugin-metrics-by-endpoint/-/artillery-plugin-metrics-by-endpoint-1.27.0.tgz", + "integrity": "sha512-XQwWgDHyC59dxcONDTjVu9Q8s1HfVnaMGOQb19ll9Hovag3zH0k7M9LYxY9ViPkeJeckGbWoS1BsU3kHifRrzw==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -3498,9 +3460,9 @@ } }, "node_modules/artillery-plugin-publish-metrics": { - "version": "2.37.0", - "resolved": "https://registry.npmjs.org/artillery-plugin-publish-metrics/-/artillery-plugin-publish-metrics-2.37.0.tgz", - "integrity": "sha512-DFaewTAG1GfgGjW2tAl3aFi+PiWm4vIxVoWa5iY//c6+g9w+Pnc01CH0BfnEVB9gFXJPZaT+HJlJfDDf9MA2PA==", + "version": "2.38.0", + "resolved": "https://registry.npmjs.org/artillery-plugin-publish-metrics/-/artillery-plugin-publish-metrics-2.38.0.tgz", + "integrity": "sha512-XatDrJU16dJpdOnCQQ/j446BRIVmepCh3bXvuiwWa7FsJ51eMippSFjBLDjZGPzTjVh/YKovdgd0hWWXVvVmnw==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -3532,9 +3494,9 @@ } }, "node_modules/artillery-plugin-slack": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/artillery-plugin-slack/-/artillery-plugin-slack-1.21.0.tgz", - "integrity": "sha512-+/LS8pKsgilOlUd+E8la0FiSsqmPvaIUwJa7RA/kUPHvTs8VNdHFuWHOnONSclExjvK4T610sm3GY/wbqXpJyg==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/artillery-plugin-slack/-/artillery-plugin-slack-1.22.0.tgz", + "integrity": "sha512-Z7quNzgDeLEmOViDdM6tYmlMbXXlrDznG2hoJyXI6sO+/TFF8Ojsld/u0jEWNPmg01EIsqFncuT+ApfcCXG/eA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -3570,19 +3532,6 @@ "node": ">=4" } }, - "node_modules/artillery/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/artillery/node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -4526,9 +4475,9 @@ } }, "node_modules/dotenv": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", - "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -4573,24 +4522,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/eciesjs": { - "version": "0.4.18", - "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz", - "integrity": "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ecies/ciphers": "^0.2.5", - "@noble/ciphers": "^1.3.0", - "@noble/curves": "^1.9.7", - "@noble/hashes": "^1.8.0" - }, - "engines": { - "bun": ">=1", - "deno": ">=2", - "node": ">=16" - } - }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -4840,37 +4771,6 @@ "node": ">=0.8.x" } }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -4896,9 +4796,9 @@ } }, "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", "dev": true, "funding": [ { @@ -4908,14 +4808,14 @@ ], "license": "MIT", "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" } }, "node_modules/fast-xml-parser": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", - "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", "dev": true, "funding": [ { @@ -4925,10 +4825,12 @@ ], "license": "MIT", "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.7", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -5169,19 +5071,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -5493,16 +5382,6 @@ "node": ">= 6.0.0" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -5530,16 +5409,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -5723,6 +5592,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -5736,16 +5618,6 @@ "node": ">=8" } }, - "node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -5782,9 +5654,9 @@ } }, "node_modules/joi": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", - "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", + "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6186,13 +6058,6 @@ "node": ">= 0.4" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, "node_modules/mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -6252,12 +6117,12 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -6479,19 +6344,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -6514,16 +6366,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-treeify": { - "version": "1.1.33", - "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", - "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -6755,9 +6597,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", "dev": true, "funding": [ { @@ -6803,9 +6645,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -6816,13 +6658,13 @@ } }, "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0" + "playwright-core": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -6835,9 +6677,9 @@ } }, "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7170,13 +7012,6 @@ "integrity": "sha512-34EQV6AAHQGhoc0tn/96a9Fsi6v2xdqe/dMUwljGRaFOzR3EgRmECvD0O8vi8X+/uQ50LGHfkNu/Eue5TPKZkQ==", "license": "MIT" }, - "node_modules/seedrandom": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", - "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", - "dev": true, - "license": "MIT" - }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -7242,9 +7077,9 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", - "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", @@ -7383,16 +7218,6 @@ "node": ">=8" } }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -7404,9 +7229,9 @@ } }, "node_modules/strnum": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", - "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", "dev": true, "funding": [ { @@ -7414,7 +7239,10 @@ "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } }, "node_modules/supports-color": { "version": "8.1.1", @@ -7646,9 +7474,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -7732,9 +7560,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", - "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", "dev": true, "funding": [ "https://github.com/sponsors/broofa", @@ -7831,22 +7659,6 @@ "webidl-conversions": "^3.0.0" } }, - "node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } - }, "node_modules/widest-line": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", @@ -7915,9 +7727,9 @@ } }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, "license": "MIT", "engines": { @@ -7969,9 +7781,9 @@ } }, "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", "dev": true, "funding": [ { @@ -8013,9 +7825,9 @@ } }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -8066,6 +7878,35 @@ "node": ">=12" } }, + "node_modules/yocto-spinner": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.2.tgz", + "integrity": "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoctocolors-cjs": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", diff --git a/e2e/package.json b/e2e/package.json index 60dcb770e..f5fe345eb 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -27,28 +27,28 @@ "repository": "CodesWhat/drydock", "license": "AGPL-3.0-only", "dependencies": { - "@cucumber/cucumber": "12.7.0", - "socket.io-parser": "4.2.6" + "@cucumber/cucumber": "12.9.0", + "socket.io-parser": "4.2.7" }, "devDependencies": { - "@dotenvx/dotenvx": "1.57.1", + "@dotenvx/dotenvx": "2.17.4", "@playwright/test": "1.61.1", - "artillery": "2.0.32", + "artillery": "2.0.33", "lodash": "4.18.1" }, "overrides": { "brace-expansion": "5.0.8", - "fast-xml-parser": "5.7.3", - "joi": "18.2.1", - "minimatch": "10.2.4", - "picomatch": "4.0.4", - "postcss": "8.5.10", - "uuid": "14.0.0", - "ws": "8.21.0", - "yaml": "2.8.3", + "fast-xml-parser": "5.10.1", + "joi": "18.2.3", + "minimatch": "10.2.5", + "picomatch": "4.0.5", + "postcss": "8.5.23", + "uuid": "14.0.1", + "ws": "8.21.1", + "yaml": "2.9.0", "form-data": "4.0.6", "protobufjs": "7.6.5", - "@opentelemetry/core": "2.8.0", - "undici": "7.28.0" + "@opentelemetry/core": "2.10.0", + "undici": "7.29.0" } } diff --git a/e2e/tests/security/picomatch-lockfile.test.js b/e2e/tests/security/picomatch-lockfile.test.js index b9b712a90..fac16af60 100644 --- a/e2e/tests/security/picomatch-lockfile.test.js +++ b/e2e/tests/security/picomatch-lockfile.test.js @@ -23,7 +23,7 @@ test('package manifest explicitly pins picomatch to the patched version', () => const packageJsonPath = join(process.cwd(), 'package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); - assert.equal(packageJson.overrides?.picomatch, '4.0.4'); + assert.equal(packageJson.overrides?.picomatch, '4.0.5'); }); test('package lockfile does not resolve vulnerable picomatch versions', () => { diff --git a/e2e/tests/security/yaml-lockfile.test.js b/e2e/tests/security/yaml-lockfile.test.js index 9a95a546f..86e108f4e 100644 --- a/e2e/tests/security/yaml-lockfile.test.js +++ b/e2e/tests/security/yaml-lockfile.test.js @@ -22,7 +22,7 @@ function compareSemver(a, b) { test('package manifest explicitly pins yaml to the patched version', () => { const packageJson = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')); - assert.equal(packageJson.overrides?.yaml, '2.8.3'); + assert.equal(packageJson.overrides?.yaml, '2.9.0'); }); test('package lockfile does not resolve vulnerable yaml versions', () => { diff --git a/package-lock.json b/package-lock.json index 2ead8be3f..331f70ef7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "dd-593-wt", + "name": "drydock", "version": "1.6.0", "lockfileVersion": 3, "requires": true, @@ -7,10 +7,10 @@ "": { "version": "1.6.0", "devDependencies": { - "@biomejs/biome": "2.5.2", - "@types/node": "25.9.4", - "lefthook": "2.1.9", - "vitest": "4.1.9", + "@biomejs/biome": "2.5.5", + "@types/node": "25.9.5", + "lefthook": "2.1.10", + "vitest": "4.1.10", "yaml": "2.9.0" }, "engines": { @@ -18,9 +18,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.2.tgz", - "integrity": "sha512-VQ3RCqr7JmDIX+w6stWYl+g/3bYofN3q2wDBHUKKc/c7i5QWrFKFBZYCYPWTE6agsUPMIZZe6/CMmVUfUAhkKA==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.5.tgz", + "integrity": "sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -34,20 +34,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.5.2", - "@biomejs/cli-darwin-x64": "2.5.2", - "@biomejs/cli-linux-arm64": "2.5.2", - "@biomejs/cli-linux-arm64-musl": "2.5.2", - "@biomejs/cli-linux-x64": "2.5.2", - "@biomejs/cli-linux-x64-musl": "2.5.2", - "@biomejs/cli-win32-arm64": "2.5.2", - "@biomejs/cli-win32-x64": "2.5.2" + "@biomejs/cli-darwin-arm64": "2.5.5", + "@biomejs/cli-darwin-x64": "2.5.5", + "@biomejs/cli-linux-arm64": "2.5.5", + "@biomejs/cli-linux-arm64-musl": "2.5.5", + "@biomejs/cli-linux-x64": "2.5.5", + "@biomejs/cli-linux-x64-musl": "2.5.5", + "@biomejs/cli-win32-arm64": "2.5.5", + "@biomejs/cli-win32-x64": "2.5.5" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.2.tgz", - "integrity": "sha512-e7P3P7EkwFc/KiX2AHw4YDLIBOMfG9CPCAwy52k5Bp0dfhkozx9hf6wCmIr2QeXy2XeccJ3V/Sg+hDmzYEqxSg==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.5.tgz", + "integrity": "sha512-kUrAhXVWUrwmAUnV2iXSK7umxKFysTwvqK+Ty6ptUcLY/7T3SnCAjUowE4uvwaEej6nXZ7hu/dTtbokKdsPeag==", "cpu": [ "arm64" ], @@ -62,9 +62,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.2.tgz", - "integrity": "sha512-ymzMvjC1Jg0b9K0D26ZdARqFQXs7MocfLC5FOCGfkC0Ss+ACUJkX5364ZM5nT4NLZanHRZNVrZEy+Ibwcvux/g==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.5.tgz", + "integrity": "sha512-DamiYc5bUYZ2uxlfc+RLEPtz1Abb6PO5eTbOkufLpSGwd/7AMQAdxhFYiXmwwkJL8IsT8S7GvdgwDHqaMFAvKw==", "cpu": [ "x64" ], @@ -79,9 +79,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.2.tgz", - "integrity": "sha512-t7sseOmqND57uUWTwlawU6BYj+J06T/9EkydzBhkrgw/FK3QVhjU2wsJR0frljrKZ0/I8A/rYw7284QgqjQfIQ==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.5.tgz", + "integrity": "sha512-lRKF/pH/1RiYiBKExi3TCZVAtvzEm77aifrvcNiDFrR9WxeAnDUjDnseb6y2XV85mjitLs6SILGm2XG77cHtSQ==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.2.tgz", - "integrity": "sha512-w+ANG0ZvTu9IeEg9QnstoOnk6L0fpwJifW6aHR18+cb5Z39bkANItYjAfMrnvce5tmMK+IQ6nPX7/kQFdam5iw==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.5.tgz", + "integrity": "sha512-U4WMl/sy/E/Q73vf15VspakLRRs2LDFcCeBxJnQfXzssb88zpV6PJPaQ3ezhQ7H6Ht2/8bvuZeHgJWzmoxllZg==", "cpu": [ "arm64" ], @@ -119,9 +119,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.2.tgz", - "integrity": "sha512-M/lOZrewzTCRDINbjhQ1gYYru37KlD3kJBQwwKCG0ckz5E9IZwIoJ3X0wBwRXA+yBDIwWUuPBHS67HzJY4dTfA==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.5.tgz", + "integrity": "sha512-H/O39nJEw/2Zm/fm7hrmxxoF8kK/aU1uCoPp70ruXVbomaAdLpJJnCmL11Q2JotT8QVHH06So04Oq53lCSwSwQ==", "cpu": [ "x64" ], @@ -139,9 +139,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.2.tgz", - "integrity": "sha512-VArNLAzND063tF+XY0yPyM+DyahpzOMzOAvb7qs259nhjJWRjvjZdssuA+Rfl+l07+NOesKZ0Xu2yFrXyBMtzw==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.5.tgz", + "integrity": "sha512-m7wC7tjX5Lrmo69dc4md8FeKpPU1NTCY1v7xUoQQ2vadWwNnBS0KZOG8471otFPHrTHihQJAjQPgMObpLvDe6A==", "cpu": [ "x64" ], @@ -159,9 +159,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.2.tgz", - "integrity": "sha512-kbjFFKyZlzYnAuw7sRy5qDoFG6zrP40UK08oPQsWK0ct3NMnGSt+Bs1iviEEyEIP57N5MrykGXdO/wRiaR4lww==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.5.tgz", + "integrity": "sha512-7BryINPuYypLUAH3o/o5ZdgomJ4zn3EDR0ChZJst7n32S6ZhKbgHXuYydLu+YAnx59ehGFR0z/MG6qnzQi3Yyw==", "cpu": [ "arm64" ], @@ -176,9 +176,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.2.tgz", - "integrity": "sha512-4InchVpdVmdkkkgjQqKpgvyu+VPnoF/7RPSw5YATgEVpt2j72wcCAeV5TwaE9ZGJUZWZn7v2CwSAj6CrMJEx8A==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.5.tgz", + "integrity": "sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g==", "cpu": [ "x64" ], @@ -253,9 +253,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.138.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", - "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -263,9 +263,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", - "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -280,9 +280,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", - "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -297,9 +297,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", - "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -314,9 +314,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", - "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -331,9 +331,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", - "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -348,9 +348,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", - "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -368,9 +368,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", - "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -388,9 +388,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", - "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -408,9 +408,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", - "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -428,9 +428,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", - "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -448,9 +448,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", - "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -468,9 +468,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", - "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -485,9 +485,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", - "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -504,9 +504,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", - "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -521,9 +521,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", - "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -588,9 +588,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", - "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", "dev": true, "license": "MIT", "dependencies": { @@ -598,16 +598,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -616,13 +616,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.9", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -643,9 +643,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -656,13 +656,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -670,14 +670,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -686,9 +686,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -696,13 +696,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -808,9 +808,9 @@ } }, "node_modules/lefthook": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.9.tgz", - "integrity": "sha512-bwDaIOViTktE8kJLf9jP0p+H2/RDTlFFlc43Am2YgUsX22hI6Sq4RbzsrecwzY5y+MHTipOH7WsmWSEniePHWQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.10.tgz", + "integrity": "sha512-K7mM4WoqMwqfXYK11EHy+lSH1uW8XHni3Yn/bSqyerPkUPygGdf3xn18JoV5HyA06xuQL3ofGAOjG01QX9oJ4w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -818,22 +818,22 @@ "lefthook": "bin/index.js" }, "optionalDependencies": { - "lefthook-darwin-arm64": "2.1.9", - "lefthook-darwin-x64": "2.1.9", - "lefthook-freebsd-arm64": "2.1.9", - "lefthook-freebsd-x64": "2.1.9", - "lefthook-linux-arm64": "2.1.9", - "lefthook-linux-x64": "2.1.9", - "lefthook-openbsd-arm64": "2.1.9", - "lefthook-openbsd-x64": "2.1.9", - "lefthook-windows-arm64": "2.1.9", - "lefthook-windows-x64": "2.1.9" + "lefthook-darwin-arm64": "2.1.10", + "lefthook-darwin-x64": "2.1.10", + "lefthook-freebsd-arm64": "2.1.10", + "lefthook-freebsd-x64": "2.1.10", + "lefthook-linux-arm64": "2.1.10", + "lefthook-linux-x64": "2.1.10", + "lefthook-openbsd-arm64": "2.1.10", + "lefthook-openbsd-x64": "2.1.10", + "lefthook-windows-arm64": "2.1.10", + "lefthook-windows-x64": "2.1.10" } }, "node_modules/lefthook-darwin-arm64": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.1.9.tgz", - "integrity": "sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.1.10.tgz", + "integrity": "sha512-nw+X8wRNDoUUV6WSteyKBbcLySq+fsmZt5WV/s50ZJpysmsDKJOUMln6SllNfP+60dzUahAO7REco/2633BsLg==", "cpu": [ "arm64" ], @@ -845,9 +845,9 @@ ] }, "node_modules/lefthook-darwin-x64": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.1.9.tgz", - "integrity": "sha512-dwo5Tke2XcQCM56DGHgFKBfRbJIL6xs2wZ0zG1TUVZgl4t4mQUt6LiZ4V/ZQfYHTZF9qywvXoIlR5N35qOaiVQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.1.10.tgz", + "integrity": "sha512-KQ/bHmvpkFdHMn4pZnUdTf+GuSC+aBBgBTxZT4GW+6cSf+qbErKZBhK7cH6BmILsvx43+VzEArvHYY7YOfRFOQ==", "cpu": [ "x64" ], @@ -859,9 +859,9 @@ ] }, "node_modules/lefthook-freebsd-arm64": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.1.9.tgz", - "integrity": "sha512-+09PVap6nl6xsaHch5JLtq7WvIR++U1Q2MzA2ai0M4uB/VP3AqrvKqHw6+9hjyKnIH+HHL83uqi77EAY+LaxLA==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.1.10.tgz", + "integrity": "sha512-8su6DwydP7+pv7kG0zCtjphqsw4ouOnfexRUErapy5GTxYBoUOhYz3RSHTSWNRsK6W4jva7FPUh2Lp5/PSn30w==", "cpu": [ "arm64" ], @@ -873,9 +873,9 @@ ] }, "node_modules/lefthook-freebsd-x64": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.1.9.tgz", - "integrity": "sha512-8XresjKIYpkE9ARgCtBEZgJZxAU3T4MIqzj4zNy15XRT59I1Us+QdqXTNm+pkZ41Yd2X/nxs2Pkvbq3NWWlIGw==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.1.10.tgz", + "integrity": "sha512-GeAJEFxko3Lk+AsnS3NleAFrpyMLFUKOlgJvPKuU0xHwVEI/z+ZoCcmuO0BX+4CS0NLbZhC/YQAvBASqDvvVdQ==", "cpu": [ "x64" ], @@ -887,9 +887,9 @@ ] }, "node_modules/lefthook-linux-arm64": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.1.9.tgz", - "integrity": "sha512-1oNIQfwrPe6rgU2KcDM3aF6+hpZDCKx1TmawQKpXUY5gVsbZ7MqX0Sk/1lnnWxqPm+kQQ5f6J2dpFWd+4xH8jg==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.1.10.tgz", + "integrity": "sha512-1sHTCmpTWjVMs+yKPBLRNT1kuuIr1yjietlk7rCB6wFPVOS6Ph3o2zPFH2AvW1UymHlqwyHXzBr9EtDpQ7j1mQ==", "cpu": [ "arm64" ], @@ -901,9 +901,9 @@ ] }, "node_modules/lefthook-linux-x64": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.1.9.tgz", - "integrity": "sha512-fT+7Q+BJyGp+CslFQkNXmdFRgyVXsPHPi9NAsDX0a6QOyNnoORByAsvx6zeAKuF5rL3BBgNfho1/v2RuGxGy9w==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.1.10.tgz", + "integrity": "sha512-z/VlRB3bh6mBvW3r1rwnJ5vP8z+Krx5gJzkZ4veDXh+6FlRTx8wtd3g3fllOv/yZMxkgmL3fQoFXv05Esa7vBQ==", "cpu": [ "x64" ], @@ -915,9 +915,9 @@ ] }, "node_modules/lefthook-openbsd-arm64": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.1.9.tgz", - "integrity": "sha512-4bVuafBk3dddVNo0+3hMbjcJs4mqYAstxpPMmX2ufkudSTYFNIhWoqwuGVQV/SS/xdcOKJAldW4qayAzed2ysw==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.1.10.tgz", + "integrity": "sha512-430zL8sSIKw5P0YXGG6PB+eAhHa06n0PXuaERaAQE4Ss3odfqwnl5Mq9hQmkEnOS1EGiQEKkd0UHv/i4PtMNIQ==", "cpu": [ "arm64" ], @@ -929,9 +929,9 @@ ] }, "node_modules/lefthook-openbsd-x64": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.1.9.tgz", - "integrity": "sha512-PmPoMmLP/wQQWcQ9u2YH86bTZ3UCfBsxuEmVTEyPU2U8R1qSTp5r/Gs3G8cN5Mxo91XB9oBERtF1n+xD3W6aVA==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.1.10.tgz", + "integrity": "sha512-bgkO8PphGZVDhQgCJ524aYYPI5491pVmCiLPGjBIo1AvOSlIyw4N1Y+1C3QfqwEmechzw+Aq16SNc8pqv6UuXg==", "cpu": [ "x64" ], @@ -943,9 +943,9 @@ ] }, "node_modules/lefthook-windows-arm64": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.1.9.tgz", - "integrity": "sha512-KphfkBKmwBnmolyrdhIl3lrBaOyTcCgXBT2AB/9OHnEXhOLvv5uTCUkrD4YRAxXPtFKq6UvnapIeoL3GZq0bdA==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.1.10.tgz", + "integrity": "sha512-5Q6etF0Fla2DDA4ilDySrdNgiR5+W7cJZwnZ69Je3kvWCaWm4wnkuc8FEdjp3kiL2x3ZXipdI00f5vpO8aWmog==", "cpu": [ "arm64" ], @@ -957,9 +957,9 @@ ] }, "node_modules/lefthook-windows-x64": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.1.9.tgz", - "integrity": "sha512-2qlUtkJHZ3MyUxgV5XTEmcrIoNZA07iwaquoswAcqv/1MeBFXlD+O+koFRfrzWng2O5WYEbpJnd8tvaYnV8fTA==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.1.10.tgz", + "integrity": "sha512-c/XH8YZtylG4XaxzqFfXluvq2LXq2W/p54Bnzn3+Z7E5X2Fk3JlFJAibulMbIt2+w8T7UI/r97ok5GqE4kGaeA==", "cpu": [ "x64" ], @@ -1286,9 +1286,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -1299,9 +1299,9 @@ } }, "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -1328,13 +1328,13 @@ } }, "node_modules/rolldown": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", - "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.138.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -1344,21 +1344,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.4", - "@rolldown/binding-darwin-arm64": "1.1.4", - "@rolldown/binding-darwin-x64": "1.1.4", - "@rolldown/binding-freebsd-x64": "1.1.4", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", - "@rolldown/binding-linux-arm64-gnu": "1.1.4", - "@rolldown/binding-linux-arm64-musl": "1.1.4", - "@rolldown/binding-linux-ppc64-gnu": "1.1.4", - "@rolldown/binding-linux-s390x-gnu": "1.1.4", - "@rolldown/binding-linux-x64-gnu": "1.1.4", - "@rolldown/binding-linux-x64-musl": "1.1.4", - "@rolldown/binding-openharmony-arm64": "1.1.4", - "@rolldown/binding-wasm32-wasi": "1.1.4", - "@rolldown/binding-win32-arm64-msvc": "1.1.4", - "@rolldown/binding-win32-x64-msvc": "1.1.4" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/siginfo": { @@ -1452,16 +1452,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", - "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.16", - "rolldown": "~1.1.3", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "bin": { @@ -1530,19 +1530,19 @@ } }, "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -1570,12 +1570,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index e4d38a8c5..54d584ef6 100644 --- a/package.json +++ b/package.json @@ -9,14 +9,14 @@ "node": ">=24.0.0" }, "devDependencies": { - "@biomejs/biome": "2.5.2", - "@types/node": "25.9.4", - "lefthook": "2.1.9", - "vitest": "4.1.9", + "@biomejs/biome": "2.5.5", + "@types/node": "25.9.5", + "lefthook": "2.1.10", + "vitest": "4.1.10", "yaml": "2.9.0" }, "overrides": { - "vite": "8.1.3", - "postcss": "8.5.22" + "vite": "8.1.5", + "postcss": "8.5.23" } } diff --git a/scripts/changelog-links.test.mjs b/scripts/changelog-links.test.mjs index 7610d7d39..769dd2848 100644 --- a/scripts/changelog-links.test.mjs +++ b/scripts/changelog-links.test.mjs @@ -56,7 +56,8 @@ test('every linked changelog heading has exactly one link definition', () => { test('v1.6 RC and v1.5.2 GA have a complete chronological comparison-link chain', () => { const definitions = new Map(getLinkDefinitions(changelog).map(({ label, url }) => [label, url])); const expected = new Map([ - ['Unreleased', `${repositoryUrl}/compare/v1.6.0-rc.7...HEAD`], + ['Unreleased', `${repositoryUrl}/compare/v1.6.0-rc.8...HEAD`], + ['1.6.0-rc.8', `${repositoryUrl}/compare/v1.6.0-rc.7...v1.6.0-rc.8`], ['1.6.0-rc.7', `${repositoryUrl}/compare/v1.6.0-rc.6...v1.6.0-rc.7`], ['1.6.0-rc.6', `${repositoryUrl}/compare/v1.6.0-rc.5...v1.6.0-rc.6`], ['1.6.0-rc.5', `${repositoryUrl}/compare/v1.6.0-rc.4...v1.6.0-rc.5`], diff --git a/scripts/release-docs-identity.test.mjs b/scripts/release-docs-identity.test.mjs index 8cd9084cd..a634799d7 100644 --- a/scripts/release-docs-identity.test.mjs +++ b/scripts/release-docs-identity.test.mjs @@ -2,10 +2,10 @@ import assert from 'node:assert/strict'; import { readdirSync, readFileSync } from 'node:fs'; import test from 'node:test'; -const RC_VERSION = '1.6.0-rc.7'; -const PREV_RC_VERSION = '1.6.0-rc.6'; -const RC_DATE = '2026-07-26'; -const RC_DISPLAY_DATE = 'July 26, 2026'; +const RC_VERSION = '1.6.0-rc.8'; +const PREV_RC_VERSION = '1.6.0-rc.7'; +const RC_DATE = '2026-07-28'; +const RC_DISPLAY_DATE = 'July 28, 2026'; const DOC_ROOTS = ['content/docs/current', 'content/docs/v1.5']; const BROAD_401_CLAIM = /(?:all|every) API (?:call|request)s?(?: (?:is|are) rejected with| returns?) `401`/iu; diff --git a/scripts/release-identity.test.mjs b/scripts/release-identity.test.mjs index 57bda8c57..60b2d276a 100644 --- a/scripts/release-identity.test.mjs +++ b/scripts/release-identity.test.mjs @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; const BASE_VERSION = '1.6.0'; -const RC_VERSION = '1.6.0-rc.7'; +const RC_VERSION = '1.6.0-rc.8'; const DEMO_RELEASE_FIXTURES = [ { path: 'apps/demo/src/mocks/data/server.ts', diff --git a/ui/tests/components/containers/ContainerLinkActions.spec.ts b/ui/tests/components/containers/ContainerLinkActions.spec.ts index 869eb1546..8e26049c9 100644 --- a/ui/tests/components/containers/ContainerLinkActions.spec.ts +++ b/ui/tests/components/containers/ContainerLinkActions.spec.ts @@ -71,28 +71,28 @@ describe('ContainerLinkActions', () => { expect(wrapper.find('[data-test="update-release-notes-panel"]').exists()).toBe(true); }); - it.each([ - 'Enter', - ' ', - ])('stops %s keydown events from activating the clickable parent row', async (key) => { - const parentKeydown = vi.fn(); - const Host = defineComponent({ - components: { ContainerLinkActions }, - setup() { - return { parentKeydown }; - }, - template: ` + it.each(['Enter', ' '])( + 'stops %s keydown events from activating the clickable parent row', + async (key) => { + const parentKeydown = vi.fn(); + const Host = defineComponent({ + components: { ContainerLinkActions }, + setup() { + return { parentKeydown }; + }, + template: `
`, - }); - const wrapper = mountWithPlugins(Host); + }); + const wrapper = mountWithPlugins(Host); - await wrapper.get('[data-test="project-link"]').trigger('keydown', { key }); + await wrapper.get('[data-test="project-link"]').trigger('keydown', { key }); - expect(parentKeydown).not.toHaveBeenCalled(); - }); + expect(parentKeydown).not.toHaveBeenCalled(); + }, + ); it('allows non-activation keys to reach containing keyboard handlers', async () => { const parentKeydown = vi.fn(); diff --git a/ui/tests/components/containers/ContainersGroupedViews.button-states.spec.ts b/ui/tests/components/containers/ContainersGroupedViews.button-states.spec.ts index 04b48fddc..3abe0bfc4 100644 --- a/ui/tests/components/containers/ContainersGroupedViews.button-states.spec.ts +++ b/ui/tests/components/containers/ContainersGroupedViews.button-states.spec.ts @@ -646,26 +646,26 @@ describe('ContainersGroupedViews — update button states', () => { expect(row.find('[data-icon="lock"]').exists()).toBe(false); }); - it.each([ - 'icons', - 'buttons', - ] as const)('%s mode: notify mode renders no update or blocker control', async (actionStyle) => { - const container = makeContainer({ - id: `c-notify-${actionStyle}`, - name: 'alpha', - newTag: '2.0.0', - updateEligibility: makeEligibility([ - { reason: 'agent-mismatch', message: 'Agent mismatch.', actionable: true }, - ]), - }); - const { wrapper } = mountWithSingleContainer(container, actionStyle, 'notify'); - await nextTick(); - - const row = rowByName(wrapper, 'alpha'); - expect(row.find('[data-icon="cloud-download"]').exists()).toBe(false); - expect(row.find('[data-icon="lock"]').exists()).toBe(false); - expect(row.find('[data-icon="stop"]').exists()).toBe(true); - }); + it.each(['icons', 'buttons'] as const)( + '%s mode: notify mode renders no update or blocker control', + async (actionStyle) => { + const container = makeContainer({ + id: `c-notify-${actionStyle}`, + name: 'alpha', + newTag: '2.0.0', + updateEligibility: makeEligibility([ + { reason: 'agent-mismatch', message: 'Agent mismatch.', actionable: true }, + ]), + }); + const { wrapper } = mountWithSingleContainer(container, actionStyle, 'notify'); + await nextTick(); + + const row = rowByName(wrapper, 'alpha'); + expect(row.find('[data-icon="cloud-download"]').exists()).toBe(false); + expect(row.find('[data-icon="lock"]').exists()).toBe(false); + expect(row.find('[data-icon="stop"]').exists()).toBe(true); + }, + ); // ------------------------------------------------------------------------- // cards mode — same state machine, 44px icon targets in the custom #card footer diff --git a/ui/tests/components/containers/ContainersGroupedViews.spec.ts b/ui/tests/components/containers/ContainersGroupedViews.spec.ts index 4fd48cbba..4530a989b 100644 --- a/ui/tests/components/containers/ContainersGroupedViews.spec.ts +++ b/ui/tests/components/containers/ContainersGroupedViews.spec.ts @@ -3190,15 +3190,15 @@ describe('ContainersGroupedViews', () => { currentTag: 'compose-X-version-9.0.1', newTag: 'compose-X-version-9.0.1', }, - ])('table view — $label shows human-readable tag, never sha256 (non-pinned digest)', async ({ - currentTag, - newTag, - }) => { - const { wrapper } = mountGuardContainer({ currentTag, newTag }); - const text = rowByName(wrapper, 'alpha').text(); - expect(text).toContain(currentTag); - expect(text).not.toContain('sha256:'); - }); + ])( + 'table view — $label shows human-readable tag, never sha256 (non-pinned digest)', + async ({ currentTag, newTag }) => { + const { wrapper } = mountGuardContainer({ currentTag, newTag }); + const text = rowByName(wrapper, 'alpha').text(); + expect(text).toContain(currentTag); + expect(text).not.toContain('sha256:'); + }, + ); it('table view — hybrid both-halves change (1.2.3 → 1.2.4, digest also changes) shows currentTag, never sha256', async () => { const { wrapper } = mountGuardContainer({ diff --git a/ui/tests/composables/useUpdateStatus.spec.ts b/ui/tests/composables/useUpdateStatus.spec.ts index 2569a2cd7..41d4ab382 100644 --- a/ui/tests/composables/useUpdateStatus.spec.ts +++ b/ui/tests/composables/useUpdateStatus.spec.ts @@ -414,26 +414,29 @@ describe('deriveUpdateStatus', () => { ['no-update-trigger-configured', 'hard', 'danger', 'external'], ['self-update-unavailable', 'hard', 'danger', 'external'], ['maintenance-window-closed', 'soft', 'warning', 'external'], - ] as const)('maps %s to a localized presentation and safe action', (reason, severity, tone, actionKind) => { - const status = deriveUpdateStatus( - input({ - container: { - id: 'container-1', - name: 'nginx', - newTag: '1.2.3', - updateEligibility: eligibility([ - blocker({ reason, severity, details: { triggerId: 'docker.local' } }), - ]), - }, - }), - ); - const condition = status.conditions[0]; - - expect(condition.heading).not.toBe(reason); - expect(condition.icon).toBeTruthy(); - expect(condition.tone).toBe(tone); - expect(condition.action?.kind).toBe(actionKind); - }); + ] as const)( + 'maps %s to a localized presentation and safe action', + (reason, severity, tone, actionKind) => { + const status = deriveUpdateStatus( + input({ + container: { + id: 'container-1', + name: 'nginx', + newTag: '1.2.3', + updateEligibility: eligibility([ + blocker({ reason, severity, details: { triggerId: 'docker.local' } }), + ]), + }, + }), + ); + const condition = status.conditions[0]; + + expect(condition.heading).not.toBe(reason); + expect(condition.icon).toBeTruthy(); + expect(condition.tone).toBe(tone); + expect(condition.action?.kind).toBe(actionKind); + }, + ); it('composes the maturity condition body from a trusted-publishedAt clock (#display-honesty)', () => { const clockStartAt = '2026-07-18T00:00:00.000Z'; diff --git a/ui/tests/layouts/AppLayout.spec.ts b/ui/tests/layouts/AppLayout.spec.ts index c102e88cb..539e5d7c2 100644 --- a/ui/tests/layouts/AppLayout.spec.ts +++ b/ui/tests/layouts/AppLayout.spec.ts @@ -714,40 +714,39 @@ describe('AppLayout', () => { } }); - it.each([ - ['succeeded'], - ['rolled-back'], - ['expired'], - ])('recovers when status is %s', async (terminalStatus) => { - const { setIntervalSpy, clearIntervalSpy } = setupSelfUpdateTest(); - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ status: terminalStatus }), - } as unknown as Response); - - try { - const wrapper = mountLayout(); - mountedWrappers.push(wrapper); - await flushPromises(); - - const emit = getEmit(wrapper); - emit?.('self-update', { opId: 'abc-123' }); - await flushPromises(); - - const pollTimer = setIntervalSpy.mock.results[0]?.value; - - vi.advanceTimersByTime(5_000); - await flushPromises(); - - expect(mockSseDisconnect).toHaveBeenCalledTimes(1); - expect(clearIntervalSpy).toHaveBeenCalledWith(pollTimer); - } finally { - clearIntervalSpy.mockRestore(); - setIntervalSpy.mockRestore(); - vi.useRealTimers(); - } - }); + it.each([['succeeded'], ['rolled-back'], ['expired']])( + 'recovers when status is %s', + async (terminalStatus) => { + const { setIntervalSpy, clearIntervalSpy } = setupSelfUpdateTest(); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ status: terminalStatus }), + } as unknown as Response); + + try { + const wrapper = mountLayout(); + mountedWrappers.push(wrapper); + await flushPromises(); + + const emit = getEmit(wrapper); + emit?.('self-update', { opId: 'abc-123' }); + await flushPromises(); + + const pollTimer = setIntervalSpy.mock.results[0]?.value; + + vi.advanceTimersByTime(5_000); + await flushPromises(); + + expect(mockSseDisconnect).toHaveBeenCalledTimes(1); + expect(clearIntervalSpy).toHaveBeenCalledWith(pollTimer); + } finally { + clearIntervalSpy.mockRestore(); + setIntervalSpy.mockRestore(); + vi.useRealTimers(); + } + }, + ); it('recovers on 404 (operation unknown)', async () => { const { setIntervalSpy, clearIntervalSpy } = setupSelfUpdateTest(); diff --git a/ui/tests/preferences/migrate.spec.ts b/ui/tests/preferences/migrate.spec.ts index d9d6f8d52..b738c8b73 100644 --- a/ui/tests/preferences/migrate.spec.ts +++ b/ui/tests/preferences/migrate.spec.ts @@ -214,20 +214,18 @@ describe('preferences migration', () => { expect(result.appearance.radius).toBe(DEFAULTS.appearance.radius); }); - it.each([ - {}, - [], - 42, - null, - ])('should delete non-string allow-listed fields and restore defaults for %j', (invalid) => { - const result = migrate({ - schemaVersion: CURRENT_SCHEMA_VERSION, - appearance: { radius: invalid }, - theme: { family: invalid }, - }); - expect(result.appearance.radius).toBe(DEFAULTS.appearance.radius); - expect(result.theme.family).toBe(DEFAULTS.theme.family); - }); + it.each([{}, [], 42, null])( + 'should delete non-string allow-listed fields and restore defaults for %j', + (invalid) => { + const result = migrate({ + schemaVersion: CURRENT_SCHEMA_VERSION, + appearance: { radius: invalid }, + theme: { family: invalid }, + }); + expect(result.appearance.radius).toBe(DEFAULTS.appearance.radius); + expect(result.theme.family).toBe(DEFAULTS.theme.family); + }, + ); it('should replace invalid fontSize with default', () => { const result = migrate({ schemaVersion: 1, appearance: { fontSize: 5 } }); @@ -600,21 +598,22 @@ describe('preferences migration', () => { ); }); - it.each([ - 1, 2, - ] as const)('cascades released schema v%s preferences through the softwareVersion migration', (schemaVersion) => { - const result = migrate({ - schemaVersion, - containers: { - columns: ['icon', 'name', 'version', 'kind', 'status', 'server', 'registry'], - }, - }); + it.each([1, 2] as const)( + 'cascades released schema v%s preferences through the softwareVersion migration', + (schemaVersion) => { + const result = migrate({ + schemaVersion, + containers: { + columns: ['icon', 'name', 'version', 'kind', 'status', 'server', 'registry'], + }, + }); - expect(result.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); - expect(result.containers.columns.indexOf('softwareVersion')).toBe( - result.containers.columns.indexOf('version') + 1, - ); - }); + expect(result.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); + expect(result.containers.columns.indexOf('softwareVersion')).toBe( + result.containers.columns.indexOf('version') + 1, + ); + }, + ); it('should add softwareVersion column when migrating from schemaVersion 6', () => { const result = migrate({ diff --git a/ui/tests/preferences/sync.spec.ts b/ui/tests/preferences/sync.spec.ts index 16c10ee6f..39b0adc7a 100644 --- a/ui/tests/preferences/sync.spec.ts +++ b/ui/tests/preferences/sync.spec.ts @@ -297,56 +297,56 @@ describe('preference sync engine', () => { await vi.waitFor(() => expect(mocks.getPreferences).toHaveBeenCalled()); }); - it.each([ - 'resolve', - 'reject', - ] as const)('blocks SSE refetch during an in-flight debounced write, then resumes after %s', async (outcome) => { - const { sync, preferences } = await load(); - mocks.getPreferences.mockResolvedValue(response(null)); - await sync.hydrateFromServer('alice'); - mocks.getPreferences.mockClear(); - const write = deferred>(); - mocks.updatePreferences.mockReturnValue(write.promise); - preferences.sync.enabled = true; - mocks.watchCallbacks[0](); - await vi.advanceTimersByTimeAsync(3000); - - mocks.listeners.get('dd:sse-preferences-updated')!(new CustomEvent('x', { detail: {} })); - expect(mocks.getPreferences).not.toHaveBeenCalled(); - - if (outcome === 'resolve') write.resolve(response(preferences)); - else write.reject(new Error('write failed')); - await Promise.resolve(); - await Promise.resolve(); - mocks.listeners.get('dd:sse-preferences-updated')!(new CustomEvent('x', { detail: {} })); - await vi.waitFor(() => expect(mocks.getPreferences).toHaveBeenCalledTimes(1)); - }); - - it.each([ - 'resolve', - 'reject', - ] as const)('blocks SSE refetch during an explicit push, then resumes after %s', async (outcome) => { - const { sync, preferences } = await load(); - mocks.getPreferences.mockResolvedValue(response(null)); - await sync.hydrateFromServer('alice'); - mocks.getPreferences.mockClear(); - const write = deferred>(); - mocks.updatePreferences.mockReturnValue(write.promise); - const push = sync.pushInitialSync('alice'); - - mocks.listeners.get('dd:sse-preferences-updated')!(new CustomEvent('x', { detail: {} })); - expect(mocks.getPreferences).not.toHaveBeenCalled(); - - if (outcome === 'resolve') { - write.resolve(response(preferences)); - await push; - } else { - write.reject(new Error('push failed')); - await expect(push).rejects.toThrow('push failed'); - } - mocks.listeners.get('dd:sse-preferences-updated')!(new CustomEvent('x', { detail: {} })); - await vi.waitFor(() => expect(mocks.getPreferences).toHaveBeenCalledTimes(1)); - }); + it.each(['resolve', 'reject'] as const)( + 'blocks SSE refetch during an in-flight debounced write, then resumes after %s', + async (outcome) => { + const { sync, preferences } = await load(); + mocks.getPreferences.mockResolvedValue(response(null)); + await sync.hydrateFromServer('alice'); + mocks.getPreferences.mockClear(); + const write = deferred>(); + mocks.updatePreferences.mockReturnValue(write.promise); + preferences.sync.enabled = true; + mocks.watchCallbacks[0](); + await vi.advanceTimersByTimeAsync(3000); + + mocks.listeners.get('dd:sse-preferences-updated')!(new CustomEvent('x', { detail: {} })); + expect(mocks.getPreferences).not.toHaveBeenCalled(); + + if (outcome === 'resolve') write.resolve(response(preferences)); + else write.reject(new Error('write failed')); + await Promise.resolve(); + await Promise.resolve(); + mocks.listeners.get('dd:sse-preferences-updated')!(new CustomEvent('x', { detail: {} })); + await vi.waitFor(() => expect(mocks.getPreferences).toHaveBeenCalledTimes(1)); + }, + ); + + it.each(['resolve', 'reject'] as const)( + 'blocks SSE refetch during an explicit push, then resumes after %s', + async (outcome) => { + const { sync, preferences } = await load(); + mocks.getPreferences.mockResolvedValue(response(null)); + await sync.hydrateFromServer('alice'); + mocks.getPreferences.mockClear(); + const write = deferred>(); + mocks.updatePreferences.mockReturnValue(write.promise); + const push = sync.pushInitialSync('alice'); + + mocks.listeners.get('dd:sse-preferences-updated')!(new CustomEvent('x', { detail: {} })); + expect(mocks.getPreferences).not.toHaveBeenCalled(); + + if (outcome === 'resolve') { + write.resolve(response(preferences)); + await push; + } else { + write.reject(new Error('push failed')); + await expect(push).rejects.toThrow('push failed'); + } + mocks.listeners.get('dd:sse-preferences-updated')!(new CustomEvent('x', { detail: {} })); + await vi.waitFor(() => expect(mocks.getPreferences).toHaveBeenCalledTimes(1)); + }, + ); it('cancels the previous user debounce and hydrates a switched user', async () => { const { sync, preferences } = await load(); diff --git a/ui/tests/security/mockServiceWorker-origin-check.spec.ts b/ui/tests/security/mockServiceWorker-origin-check.spec.ts index f0af83a8a..3c0e55b7a 100644 --- a/ui/tests/security/mockServiceWorker-origin-check.spec.ts +++ b/ui/tests/security/mockServiceWorker-origin-check.spec.ts @@ -3,30 +3,30 @@ import { resolve } from 'node:path'; const liveWorkerPath = resolve(process.cwd(), '../apps/demo/public/mockServiceWorker.js'); const messageHandlerPattern = - /addEventListener\('message',\s*(?:async\s*function\s*\(event\)|async\s*\(event\)\s*=>)\s*\{[\s\S]*?\n\}\);/; -const fallbackMessageHandler = `addEventListener('message', async (event) => { - const clientId = Reflect.get(event.source || {}, 'id'); + /addEventListener\('message',\s*(?:async\s*function\s*\(event\)|async\s*\(event\)\s*=>)\s*\{[\s\S]*?\n\}\)/; +const fallbackMessageHandler = `addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') if (!clientId || !self.clients) { - return; + return } - const client = await self.clients.get(clientId); + const client = await self.clients.get(clientId) if (!client) { - return; + return } const allClients = await self.clients.matchAll({ type: 'window', - }); + }) switch (event.data) { case 'KEEPALIVE_REQUEST': { sendToClient(client, { type: 'KEEPALIVE_RESPONSE', - }); - break; + }) + break } case 'INTEGRITY_CHECK_REQUEST': { @@ -36,12 +36,12 @@ const fallbackMessageHandler = `addEventListener('message', async (event) => { packageVersion: PACKAGE_VERSION, checksum: INTEGRITY_CHECKSUM, }, - }); - break; + }) + break } case 'MOCK_ACTIVATE': { - activeClientIds.add(clientId); + activeClientIds.add(clientId) sendToClient(client, { type: 'MOCKING_ENABLED', @@ -51,26 +51,26 @@ const fallbackMessageHandler = `addEventListener('message', async (event) => { frameType: client.frameType, }, }, - }); - break; + }) + break } case 'CLIENT_CLOSED': { - activeClientIds.delete(clientId); + activeClientIds.delete(clientId) const remainingClients = allClients.filter((client) => { - return client.id !== clientId; - }); + return client.id !== clientId + }) // Unregister itself when there are no more clients if (remainingClients.length === 0) { - self.registration.unregister(); + self.registration.unregister() } - break; + break } } -});`; +})`; function readWorkerSource(): string { if (existsSync(liveWorkerPath)) { diff --git a/ui/tests/services/preview.spec.ts b/ui/tests/services/preview.spec.ts index 6789ac13f..4bab7a945 100644 --- a/ui/tests/services/preview.spec.ts +++ b/ui/tests/services/preview.spec.ts @@ -109,30 +109,33 @@ describe('preview service', () => { it.each([ [{ label: 42, href: '/registries' }, 'Bad Gateway'], [{ label: 'Unsafe link', href: 'https://attacker.example' }, ''], - ])('drops malformed preview actions and handles optional status text', async (action, statusText) => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 502, - statusText, - json: () => - Promise.resolve({ - code: ' ', - message: ' ', - details: [], - action, - }), - }); - - const failure = await previewContainer('bad-id').catch((error) => error); - - expect(failure).toMatchObject({ - code: 'preview-http-error', - message: `Unable to prepare this update preview (502${statusText ? ` ${statusText}` : ''})`, - status: 502, - details: undefined, - action: undefined, - }); - }); + ])( + 'drops malformed preview actions and handles optional status text', + async (action, statusText) => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 502, + statusText, + json: () => + Promise.resolve({ + code: ' ', + message: ' ', + details: [], + action, + }), + }); + + const failure = await previewContainer('bad-id').catch((error) => error); + + expect(failure).toMatchObject({ + code: 'preview-http-error', + message: `Unable to prepare this update preview (502${statusText ? ` ${statusText}` : ''})`, + status: 502, + details: undefined, + action: undefined, + }); + }, + ); it('normalizes compose preview fields while preserving generic preview fields', async () => { global.fetch = vi.fn().mockResolvedValue({ diff --git a/ui/tests/views/ConfigView.spec.ts b/ui/tests/views/ConfigView.spec.ts index 7abaf30a6..4fb944aa2 100644 --- a/ui/tests/views/ConfigView.spec.ts +++ b/ui/tests/views/ConfigView.spec.ts @@ -613,26 +613,26 @@ describe('ConfigView', () => { it.each([ { initial: false, expected: false, label: 'ON' }, { initial: true, expected: true, label: 'OFF' }, - ])('restores the prior state and shows an error after a failed $label push', async ({ - initial, - expected, - }) => { - let reject!: (error: Error) => void; - const wrapper = await mountAppearance('alice'); - preferences.sync.enabled = initial; - mockPushInitialSync.mockReturnValue( - new Promise((_resolve, rejectPromise) => { - reject = rejectPromise; - }), - ); - const toggle = wrapper.find('[data-test="sync-toggle"]'); - await toggle.trigger('click'); - await nextTick(); - expect(wrapper.find('[data-test="sync-toggle"]').attributes('disabled')).toBeDefined(); - reject(new Error('sync failed')); - await vi.waitFor(() => expect(wrapper.text()).toContain('sync failed')); - expect(preferences.sync.enabled).toBe(expected); - }); + ])( + 'restores the prior state and shows an error after a failed $label push', + async ({ initial, expected }) => { + let reject!: (error: Error) => void; + const wrapper = await mountAppearance('alice'); + preferences.sync.enabled = initial; + mockPushInitialSync.mockReturnValue( + new Promise((_resolve, rejectPromise) => { + reject = rejectPromise; + }), + ); + const toggle = wrapper.find('[data-test="sync-toggle"]'); + await toggle.trigger('click'); + await nextTick(); + expect(wrapper.find('[data-test="sync-toggle"]').attributes('disabled')).toBeDefined(); + reject(new Error('sync failed')); + await vi.waitFor(() => expect(wrapper.text()).toContain('sync failed')); + expect(preferences.sync.enabled).toBe(expected); + }, + ); it('hides the toggle when the profile load fails', async () => { mockRouteQuery.value = { tab: 'appearance' }; diff --git a/ui/tests/views/ContainersView.spec.ts b/ui/tests/views/ContainersView.spec.ts index e4e78c7f2..fd2def000 100644 --- a/ui/tests/views/ContainersView.spec.ts +++ b/ui/tests/views/ContainersView.spec.ts @@ -1693,27 +1693,26 @@ describe('ContainersView', () => { expect(vm.availableContentWidth).toBe(1440 - 240 - 48); }); - it.each([ - 'sm', - 'md', - 'lg', - ] as const)('uses the real DataViewLayout measurement once emitted, regardless of panelSize=%s', async (size) => { - mockDetailPanelOpen.value = true; - mockPanelSize.value = size; - const wrapper = await mountContainersView([makeContainer()]); - const vm = wrapper.vm as any; + it.each(['sm', 'md', 'lg'] as const)( + 'uses the real DataViewLayout measurement once emitted, regardless of panelSize=%s', + async (size) => { + mockDetailPanelOpen.value = true; + mockPanelSize.value = size; + const wrapper = await mountContainersView([makeContainer()]); + const vm = wrapper.vm as any; - const layout = wrapper.findComponent(childStubs.DataViewLayout as any); - layout.vm.$emit('content-width', 905); - await flushPromises(); + const layout = wrapper.findComponent(childStubs.DataViewLayout as any); + layout.vm.$emit('content-width', 905); + await flushPromises(); - // The old hand-rolled formula subtracted a fixed PANEL_WIDTH_PX[panelSize] (sm=420, - // md=560, lg=720) and was ~23px too generous versus the real box. Once a real - // measurement has been emitted, availableContentWidth must equal it exactly — the same - // number for every panelSize — because DataViewLayout's ResizeObserver already accounts - // for the actual panel width, the flexbox gap, and the panel's own margins. - expect(vm.availableContentWidth).toBe(905); - }); + // The old hand-rolled formula subtracted a fixed PANEL_WIDTH_PX[panelSize] (sm=420, + // md=560, lg=720) and was ~23px too generous versus the real box. Once a real + // measurement has been emitted, availableContentWidth must equal it exactly — the same + // number for every panelSize — because DataViewLayout's ResizeObserver already accounts + // for the actual panel width, the flexbox gap, and the panel's own margins. + expect(vm.availableContentWidth).toBe(905); + }, + ); it('prefers the latest measurement over the fallback once one has arrived', async () => { mockWindowWidth.value = 1440; diff --git a/ui/tests/views/containers/useContainerActions.spec.ts b/ui/tests/views/containers/useContainerActions.spec.ts index fd95303e0..e491a9a9e 100644 --- a/ui/tests/views/containers/useContainerActions.spec.ts +++ b/ui/tests/views/containers/useContainerActions.spec.ts @@ -508,29 +508,28 @@ describe('useContainerActions', () => { expect(mocks.toastSuccess).not.toHaveBeenCalledWith('Update started: web'); }); - it.each([ - null, - undefined, - { code: 'E_UNKNOWN' }, - ])('treats %p update failures as normal errors instead of stale-update refreshes', async (rejection) => { - const container = makeContainer({ id: 'container-1', name: 'web', newTag: '1.1.0' }); - const { composable, error, loadContainers } = await mountActionsHarness({ - containers: [container], - selectedContainer: container, - selectedContainerId: container.id, - containerIdMap: { web: 'container-1' }, - }); - mocks.updateContainer.mockRejectedValueOnce(rejection); - loadContainers.mockClear(); + it.each([null, undefined, { code: 'E_UNKNOWN' }])( + 'treats %p update failures as normal errors instead of stale-update refreshes', + async (rejection) => { + const container = makeContainer({ id: 'container-1', name: 'web', newTag: '1.1.0' }); + const { composable, error, loadContainers } = await mountActionsHarness({ + containers: [container], + selectedContainer: container, + selectedContainerId: container.id, + containerIdMap: { web: 'container-1' }, + }); + mocks.updateContainer.mockRejectedValueOnce(rejection); + loadContainers.mockClear(); - await composable.updateContainer('web'); + await composable.updateContainer('web'); - expect(mocks.updateContainer).toHaveBeenCalledWith('container-1'); - expect(loadContainers).toHaveBeenCalledTimes(1); - expect(error.value).toBe('Action failed for web'); - expect(mocks.toastError).toHaveBeenCalledWith('Update failed: web', 'Action failed for web'); - expect(mocks.toastSuccess).not.toHaveBeenCalledWith('Update started: web'); - }); + expect(mocks.updateContainer).toHaveBeenCalledWith('container-1'); + expect(loadContainers).toHaveBeenCalledTimes(1); + expect(error.value).toBe('Action failed for web'); + expect(mocks.toastError).toHaveBeenCalledWith('Update failed: web', 'Action failed for web'); + expect(mocks.toastSuccess).not.toHaveBeenCalledWith('Update started: web'); + }, + ); it('validates snooze-until input before policy updates', async () => { const container = makeContainer({ id: 'container-1', name: 'web' }); diff --git a/ui/tests/views/dashboard/DashboardGrid.spec.ts b/ui/tests/views/dashboard/DashboardGrid.spec.ts index ae7909b68..987091b96 100644 --- a/ui/tests/views/dashboard/DashboardGrid.spec.ts +++ b/ui/tests/views/dashboard/DashboardGrid.spec.ts @@ -244,9 +244,11 @@ describe('DashboardGrid', () => { resizers[0]!.element.dispatchEvent(pointerEvent('pointerdown', { clientX: 0, clientY: 0 })); window.dispatchEvent(pointerEvent('pointermove', { clientX: 500, clientY: 200 })); window.dispatchEvent(pointerEvent('pointercancel', {})); - expect( - (wrapper.emitted('update:layout')?.at(-1)?.[0] as Array<{ w: number; h: number }>)[0], - ).toMatchObject({ w: 4, h: 5 }); + const cancelledLayout = wrapper.emitted('update:layout')?.at(-1)?.[0] as Array<{ + w: number; + h: number; + }>; + expect(cancelledLayout[0]).toMatchObject({ w: 4, h: 5 }); resizers[1]!.element.dispatchEvent(pointerEvent('pointerdown', { clientX: 0, clientY: 0 })); window.dispatchEvent(pointerEvent('pointermove', { clientX: 100, clientY: 50 })); diff --git a/ui/tests/views/dashboard/useDashboardComputed.spec.ts b/ui/tests/views/dashboard/useDashboardComputed.spec.ts index de71ae4a5..3c7d0e961 100644 --- a/ui/tests/views/dashboard/useDashboardComputed.spec.ts +++ b/ui/tests/views/dashboard/useDashboardComputed.spec.ts @@ -344,27 +344,26 @@ describe('useDashboardComputed update summary', () => { color: 'var(--dd-danger)', colorMuted: 'var(--dd-danger-muted)', }, - ])('uses the expected updates stat colors when $updates of 4 containers have updates', ({ - updates, - color, - colorMuted, - }) => { - const containers = Array.from({ length: 4 }, (_, index) => - makeBaseContainer({ - id: `ratio-${index}`, - updateKind: index < updates ? 'minor' : null, - }), - ); - const state = createState({ containers }); - const updateStat = state.stats.value.find((card) => card.id === 'stat-updates'); - - expect(updateStat).toMatchObject({ - value: String(updates), - color, - colorMuted, - route: { path: '/containers', query: { filterKind: 'any' } }, - }); - }); + ])( + 'uses the expected updates stat colors when $updates of 4 containers have updates', + ({ updates, color, colorMuted }) => { + const containers = Array.from({ length: 4 }, (_, index) => + makeBaseContainer({ + id: `ratio-${index}`, + updateKind: index < updates ? 'minor' : null, + }), + ); + const state = createState({ containers }); + const updateStat = state.stats.value.find((card) => card.id === 'stat-updates'); + + expect(updateStat).toMatchObject({ + value: String(updates), + color, + colorMuted, + route: { path: '/containers', query: { filterKind: 'any' } }, + }); + }, + ); it('shows new and mature counts in the updates stat detail when new updates exist', () => { const now = Date.now();