mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
chore(ci): add visual regression PR workflow (#2372)
* Add visual regression PR workflow * Allow manual visual PR comments * Post visual comments for same-repo PRs * fix(ci): surface R2 lookup failures in visual report Generated-By: looper 0.8.1 (runner=fixer, agent=opencode) * Align visual workflow names
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
name: Visual screenshot setup
|
||||
description: Prepare the workspace for visual Playwright screenshot capture.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.8
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Resolve Playwright version
|
||||
id: playwright-version
|
||||
shell: bash
|
||||
run: |
|
||||
version=$(node -p "require('./e2e/package.json').devDependencies['@playwright/test'].replace(/[^0-9.]/g,'')")
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore Playwright browser cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
shell: bash
|
||||
run: pnpm -C e2e exec playwright install --with-deps chromium
|
||||
|
||||
- name: Prebuild workspace type declarations
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm --filter @open-design/daemon build
|
||||
pnpm --filter @open-design/desktop build
|
||||
pnpm --filter @open-design/web build:sidecar
|
||||
@@ -0,0 +1,66 @@
|
||||
name: visual-baseline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: visual-baseline-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
baseline:
|
||||
name: Capture visual baselines
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Prepare visual screenshot environment
|
||||
uses: ./.github/actions/visual-screenshot
|
||||
|
||||
- name: Capture baseline screenshots
|
||||
env:
|
||||
OD_VISUAL_OUTPUT_DIR: ui/reports/visual-screenshots
|
||||
run: |
|
||||
pnpm -C e2e exec tsx scripts/playwright.ts clean
|
||||
pnpm -C e2e exec playwright test -c playwright.visual.config.ts
|
||||
|
||||
- name: Upload baseline screenshots to R2
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_ORIGIN: ${{ vars.R2_PUBLIC_ORIGIN }}
|
||||
CLOUDFLARE_R2_RELEASES_AK: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
|
||||
CLOUDFLARE_R2_RELEASES_SK: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
|
||||
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
|
||||
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
|
||||
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
|
||||
run: |
|
||||
pnpm -C e2e exec tsx scripts/visual-report.ts upload-baseline \
|
||||
--sha "${{ github.sha }}" \
|
||||
--screenshots ui/reports/visual-screenshots \
|
||||
--manifest-out ui/reports/visual-report/baseline-manifest.json
|
||||
|
||||
- name: Upload baseline debug artifact
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: visual-baseline-${{ github.sha }}
|
||||
path: |
|
||||
e2e/ui/reports/visual-screenshots
|
||||
e2e/ui/reports/visual-results.json
|
||||
e2e/ui/reports/visual-report/baseline-manifest.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,204 @@
|
||||
name: visual-pr-capture
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- 'apps/web/**'
|
||||
- '.github/actions/visual-screenshot/**'
|
||||
- '.github/workflows/visual-*.yml'
|
||||
- 'e2e/package.json'
|
||||
- 'e2e/playwright.visual.config.ts'
|
||||
- 'e2e/lib/playwright/**'
|
||||
- 'e2e/scripts/playwright.ts'
|
||||
- 'e2e/scripts/visual-report.ts'
|
||||
- 'e2e/ui/visual-*.test.ts'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: visual-pr-capture-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
capture:
|
||||
name: Capture PR visual screenshots
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Prepare visual screenshot environment
|
||||
uses: ./.github/actions/visual-screenshot
|
||||
|
||||
- name: Capture PR screenshots
|
||||
id: capture
|
||||
continue-on-error: true
|
||||
env:
|
||||
OD_VISUAL_OUTPUT_DIR: ui/reports/visual-screenshots
|
||||
run: |
|
||||
pnpm -C e2e exec tsx scripts/playwright.ts clean
|
||||
pnpm -C e2e exec playwright test -c playwright.visual.config.ts
|
||||
|
||||
- name: Write capture manifest
|
||||
run: |
|
||||
mkdir -p e2e/ui/reports/visual-report
|
||||
cat > e2e/ui/reports/visual-report/manifest.json <<'JSON'
|
||||
{
|
||||
"pr_number": "${{ github.event.pull_request.number }}",
|
||||
"head_sha": "${{ github.event.pull_request.head.sha }}",
|
||||
"base_sha": "${{ github.event.pull_request.base.sha }}",
|
||||
"run_id": "${{ github.run_id }}",
|
||||
"capture_outcome": "${{ steps.capture.outcome }}"
|
||||
}
|
||||
JSON
|
||||
|
||||
- name: Upload PR visual artifact
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: visual-pr-capture-${{ github.event.pull_request.number }}-${{ github.run_id }}
|
||||
path: |
|
||||
e2e/ui/reports/visual-screenshots
|
||||
e2e/ui/reports/visual-results.json
|
||||
e2e/ui/reports/visual-report/manifest.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
comment_same_repo:
|
||||
name: Comment PR visual screenshots
|
||||
needs: [capture]
|
||||
if: ${{ always() && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.8
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download PR visual artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
run-id: ${{ github.run_id }}
|
||||
path: visual-artifact
|
||||
merge-multiple: true
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Validate capture manifest
|
||||
id: manifest
|
||||
shell: bash
|
||||
env:
|
||||
EXPECTED_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
EXPECTED_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
EXPECTED_RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
manifest="visual-artifact/visual-report/manifest.json"
|
||||
if [ ! -f "$manifest" ]; then
|
||||
manifest="visual-artifact/manifest.json"
|
||||
fi
|
||||
if [ ! -f "$manifest" ]; then
|
||||
echo "Capture manifest not found" >&2
|
||||
find visual-artifact -maxdepth 4 -type f >&2
|
||||
exit 1
|
||||
fi
|
||||
pr_number="$(jq -r '.pr_number' "$manifest")"
|
||||
head_sha="$(jq -r '.head_sha' "$manifest")"
|
||||
base_sha="$(jq -r '.base_sha' "$manifest")"
|
||||
run_id="$(jq -r '.run_id' "$manifest")"
|
||||
capture_outcome="$(jq -r '.capture_outcome // "success"' "$manifest")"
|
||||
if [ "$pr_number" != "$EXPECTED_PR_NUMBER" ] || [ "$head_sha" != "$EXPECTED_HEAD_SHA" ] || [ "$base_sha" != "$EXPECTED_BASE_SHA" ] || [ "$run_id" != "$EXPECTED_RUN_ID" ]; then
|
||||
echo "Capture manifest does not match the current pull_request event." >&2
|
||||
jq . "$manifest" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
|
||||
echo "capture_outcome=$capture_outcome" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build visual diff report
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_ORIGIN: ${{ vars.R2_PUBLIC_ORIGIN }}
|
||||
CLOUDFLARE_R2_RELEASES_AK: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
|
||||
CLOUDFLARE_R2_RELEASES_SK: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
|
||||
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
|
||||
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
|
||||
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
|
||||
run: |
|
||||
pnpm -C e2e exec tsx scripts/visual-report.ts compare-pr \
|
||||
--pr-number "${{ steps.manifest.outputs.pr_number }}" \
|
||||
--run-id "${{ steps.manifest.outputs.run_id }}" \
|
||||
--head-sha "${{ steps.manifest.outputs.head_sha }}" \
|
||||
--base-sha "${{ steps.manifest.outputs.base_sha }}" \
|
||||
--capture-outcome "${{ steps.manifest.outputs.capture_outcome }}" \
|
||||
--screenshots ../visual-artifact/visual-screenshots \
|
||||
--comment-out ui/reports/visual-report/comment.md \
|
||||
--manifest-out ui/reports/visual-report/report-manifest.json
|
||||
|
||||
- name: Upsert PR comment
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
body_path="e2e/ui/reports/visual-report/comment.md"
|
||||
comments_json="$RUNNER_TEMP/comments.json"
|
||||
gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" > "$comments_json"
|
||||
comment_id="$(jq -r '.[] | select(.body | contains("<!-- visual-regression-bot -->")) | .id' "$comments_json" | tail -n 1)"
|
||||
if [ -n "$comment_id" ]; then
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" --field "body=$(cat "$body_path")"
|
||||
else
|
||||
gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field "body=$(cat "$body_path")"
|
||||
fi
|
||||
|
||||
- name: Upload visual report artifact
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: visual-pr-report-${{ github.event.pull_request.number }}-${{ github.run_id }}
|
||||
path: e2e/ui/reports/visual-report
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,225 @@
|
||||
name: visual-pr-comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [visual-pr-capture]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
capture_run_id:
|
||||
description: GitHub Actions run id for the Visual PR capture workflow to comment on.
|
||||
required: true
|
||||
type: string
|
||||
pr_number:
|
||||
description: Pull request number to upsert the visual comment on.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: visual-pr-comment-${{ github.event.workflow_run.head_repository.full_name || github.repository }}-${{ github.event.workflow_run.head_branch || inputs.pr_number || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Publish PR visual diff comment
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.8
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download PR visual artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
run-id: ${{ github.event.workflow_run.id || inputs.capture_run_id }}
|
||||
path: visual-artifact
|
||||
merge-multiple: true
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Read capture manifest
|
||||
id: manifest
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
WORKFLOW_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
|
||||
WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha || '' }}
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id || inputs.capture_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
manifest="visual-artifact/visual-report/manifest.json"
|
||||
if [ ! -f "$manifest" ]; then
|
||||
manifest="visual-artifact/manifest.json"
|
||||
fi
|
||||
if [ ! -f "$manifest" ]; then
|
||||
echo "Capture manifest not found" >&2
|
||||
find visual-artifact -maxdepth 4 -type f >&2
|
||||
exit 1
|
||||
fi
|
||||
manifest_pr_number="$(jq -r '.pr_number' "$manifest")"
|
||||
base_sha="$(jq -r '.base_sha' "$manifest")"
|
||||
run_id="$(jq -r '.run_id' "$manifest")"
|
||||
capture_outcome="$(jq -r '.capture_outcome // "success"' "$manifest")"
|
||||
if ! [[ "$manifest_pr_number" =~ ^[0-9]+$ ]]; then
|
||||
echo "Invalid manifest pr_number: $manifest_pr_number" >&2
|
||||
exit 1
|
||||
fi
|
||||
pr_number="$WORKFLOW_PR_NUMBER"
|
||||
if [ -z "$pr_number" ]; then
|
||||
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/commits/$WORKFLOW_HEAD_SHA/pulls" --jq '.[0].number // empty')"
|
||||
fi
|
||||
if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then
|
||||
echo "Unable to derive PR number for workflow head $WORKFLOW_HEAD_SHA from GitHub event/API." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$manifest_pr_number" != "$pr_number" ]; then
|
||||
echo "Manifest pr_number ($manifest_pr_number) does not match GitHub-derived PR number ($pr_number)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$base_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Invalid manifest base_sha: $base_sha" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$run_id" != "$WORKFLOW_RUN_ID" ]; then
|
||||
echo "Artifact run_id ($run_id) does not match workflow_run id ($WORKFLOW_RUN_ID)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$capture_outcome" =~ ^(success|failure|cancelled|skipped)$ ]]; then
|
||||
echo "Invalid manifest capture_outcome: $capture_outcome" >&2
|
||||
exit 1
|
||||
fi
|
||||
manifest_head="$(jq -r '.head_sha' "$manifest")"
|
||||
if ! [[ "$manifest_head" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Invalid manifest head_sha: $manifest_head" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$WORKFLOW_HEAD_SHA" ] && [ "$manifest_head" != "$WORKFLOW_HEAD_SHA" ]; then
|
||||
echo "Artifact manifest head_sha ($manifest_head) does not match workflow_run head_sha ($WORKFLOW_HEAD_SHA)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "path=$manifest" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||
echo "head_sha=$manifest_head" >> "$GITHUB_OUTPUT"
|
||||
echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
|
||||
echo "capture_outcome=$capture_outcome" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Stop stale visual runs
|
||||
id: stale
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
|
||||
ARTIFACT_HEAD_SHA: ${{ steps.manifest.outputs.head_sha }}
|
||||
ARTIFACT_BASE_SHA: ${{ steps.manifest.outputs.base_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefOid,baseRefOid)"
|
||||
current_head="$(jq -r '.headRefOid' <<< "$pr_json")"
|
||||
current_base="$(jq -r '.baseRefOid' <<< "$pr_json")"
|
||||
if [ "$current_head" != "$ARTIFACT_HEAD_SHA" ]; then
|
||||
echo "Skipping stale visual artifact for $ARTIFACT_HEAD_SHA; current PR head is $current_head."
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$current_base" != "$ARTIFACT_BASE_SHA" ]; then
|
||||
echo "Skipping stale visual artifact for base $ARTIFACT_BASE_SHA; current PR base is $current_base."
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "stale=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build visual diff report
|
||||
if: ${{ steps.stale.outputs.stale != 'true' }}
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_ORIGIN: ${{ vars.R2_PUBLIC_ORIGIN }}
|
||||
CLOUDFLARE_R2_RELEASES_AK: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
|
||||
CLOUDFLARE_R2_RELEASES_SK: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
|
||||
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
|
||||
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
|
||||
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
|
||||
run: |
|
||||
pnpm -C e2e exec tsx scripts/visual-report.ts compare-pr \
|
||||
--pr-number "${{ steps.manifest.outputs.pr_number }}" \
|
||||
--run-id "${{ steps.manifest.outputs.run_id }}" \
|
||||
--head-sha "${{ steps.manifest.outputs.head_sha }}" \
|
||||
--base-sha "${{ steps.manifest.outputs.base_sha }}" \
|
||||
--capture-outcome "${{ steps.manifest.outputs.capture_outcome }}" \
|
||||
--screenshots ../visual-artifact/visual-screenshots \
|
||||
--comment-out ui/reports/visual-report/comment.md \
|
||||
--manifest-out ui/reports/visual-report/report-manifest.json
|
||||
|
||||
- name: Upsert PR comment
|
||||
if: ${{ steps.stale.outputs.stale != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
|
||||
ARTIFACT_HEAD_SHA: ${{ steps.manifest.outputs.head_sha }}
|
||||
ARTIFACT_BASE_SHA: ${{ steps.manifest.outputs.base_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefOid,baseRefOid)"
|
||||
current_head="$(jq -r '.headRefOid' <<< "$pr_json")"
|
||||
current_base="$(jq -r '.baseRefOid' <<< "$pr_json")"
|
||||
if [ "$current_head" != "$ARTIFACT_HEAD_SHA" ]; then
|
||||
echo "Skipping stale visual comment for $ARTIFACT_HEAD_SHA; current PR head is $current_head."
|
||||
exit 0
|
||||
fi
|
||||
if [ "$current_base" != "$ARTIFACT_BASE_SHA" ]; then
|
||||
echo "Skipping stale visual comment for base $ARTIFACT_BASE_SHA; current PR base is $current_base."
|
||||
exit 0
|
||||
fi
|
||||
body_path="e2e/ui/reports/visual-report/comment.md"
|
||||
comments_json="$RUNNER_TEMP/comments.json"
|
||||
gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" > "$comments_json"
|
||||
comment_id="$(jq -r '.[] | select(.body | contains("<!-- visual-regression-bot -->")) | .id' "$comments_json" | tail -n 1)"
|
||||
if [ -n "$comment_id" ]; then
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" --field "body=$(cat "$body_path")"
|
||||
else
|
||||
gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field "body=$(cat "$body_path")"
|
||||
fi
|
||||
|
||||
- name: Upload visual report artifact
|
||||
if: ${{ always() && steps.stale.outputs.stale != 'true' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: visual-pr-report-${{ steps.manifest.outputs.pr_number }}-${{ steps.manifest.outputs.run_id }}
|
||||
path: e2e/ui/reports/visual-report
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const STORAGE_KEY = 'open-design:config';
|
||||
const VISUAL_STYLE_ID = 'od-visual-stability-style';
|
||||
|
||||
const VISUAL_CONFIG = {
|
||||
mode: 'daemon',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-sonnet-4-5',
|
||||
agentId: 'mock',
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
onboardingCompleted: true,
|
||||
agentModels: {},
|
||||
privacyDecisionAt: 1,
|
||||
telemetry: { metrics: false, content: false, artifactManifest: false },
|
||||
} as const;
|
||||
|
||||
const MOCK_AGENT = {
|
||||
id: 'mock',
|
||||
name: 'Mock Agent',
|
||||
bin: 'mock-agent',
|
||||
available: true,
|
||||
version: 'test',
|
||||
models: [{ id: 'default', label: 'Default' }],
|
||||
} as const;
|
||||
|
||||
export async function configureVisualPage(page: Page): Promise<void> {
|
||||
await page.addInitScript(([key, config]) => {
|
||||
window.localStorage.setItem(key, JSON.stringify(config));
|
||||
}, [STORAGE_KEY, VISUAL_CONFIG] as const);
|
||||
|
||||
await page.route('**/api/app-config', async (route) => {
|
||||
await fulfillGet(route, { config: VISUAL_CONFIG });
|
||||
});
|
||||
|
||||
await page.route('**/api/agents', async (route) => {
|
||||
await fulfillGet(route, { agents: [MOCK_AGENT] });
|
||||
});
|
||||
|
||||
await page.addInitScript(([styleId]) => {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
*, *::before, *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
caret-color: transparent !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}, [VISUAL_STYLE_ID] as const);
|
||||
}
|
||||
|
||||
export async function waitForVisualReady(page: Page): Promise<void> {
|
||||
await page.getByText('Loading Open Design…').waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {});
|
||||
await expect(page.getByTestId('home-hero')).toBeVisible();
|
||||
await expect(page.getByTestId('home-hero-input')).toBeVisible();
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
});
|
||||
}
|
||||
|
||||
export async function gotoVisualHome(page: Page): Promise<void> {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await waitForVisualReady(page);
|
||||
}
|
||||
|
||||
export async function captureVisual(page: Page, name: string): Promise<string> {
|
||||
const outputDir = path.resolve(process.env.OD_VISUAL_OUTPUT_DIR || 'ui/reports/visual-screenshots');
|
||||
const safeName = sanitizeVisualName(name);
|
||||
const outputPath = path.join(outputDir, `${safeName}.png`);
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
await page.screenshot({ path: outputPath, animations: 'disabled', caret: 'hide' });
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
function sanitizeVisualName(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/[^a-z0-9-_]+/g, '-').replace(/^-+|-+$/g, '') || 'visual';
|
||||
}
|
||||
|
||||
async function fulfillGet(route: Route, json: unknown): Promise<void> {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ json });
|
||||
}
|
||||
@@ -10,8 +10,12 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "3.1050.0",
|
||||
"@playwright/test": "1.60.0",
|
||||
"@types/node": "20.19.39",
|
||||
"@types/pngjs": "6.0.5",
|
||||
"pixelmatch": "7.2.0",
|
||||
"pngjs": "7.0.0",
|
||||
"tsx": "4.22.2",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.6"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const daemonPort = Number(process.env.OD_PORT) || 17_456;
|
||||
const webPort = Number(process.env.OD_WEB_PORT) || 17_573;
|
||||
const baseURL = `http://127.0.0.1:${webPort}`;
|
||||
const namespace = process.env.OD_E2E_NAMESPACE || `playwright-visual-${process.pid}`;
|
||||
const dataDir = process.env.OD_E2E_DATA_DIR || `e2e/ui/.od-data/${namespace}`;
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './ui',
|
||||
testMatch: 'visual-*.test.ts',
|
||||
outputDir: './ui/reports/visual-test-results',
|
||||
timeout: Number(process.env.OD_PLAYWRIGHT_TIMEOUT) || 30_000,
|
||||
retries: 0,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: process.env.CI
|
||||
? [['github'], ['list'], ['json', { outputFile: './ui/reports/visual-results.json' }]]
|
||||
: [['list'], ['json', { outputFile: './ui/reports/visual-results.json' }]],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
baseURL,
|
||||
trace: 'off',
|
||||
screenshot: 'off',
|
||||
viewport: { width: 1440, height: 900 },
|
||||
deviceScaleFactor: 1,
|
||||
},
|
||||
webServer: {
|
||||
command:
|
||||
`OD_DATA_DIR=${shellQuote(dataDir)} ` +
|
||||
`pnpm --dir .. tools-dev run web --namespace ${shellQuote(namespace)} --daemon-port ${daemonPort} --web-port ${webPort}`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
@@ -29,9 +29,13 @@ async function cleanArtifacts(): Promise<void> {
|
||||
path.join(uiDir, '.od-data'),
|
||||
path.join(uiDir, 'test-results'),
|
||||
path.join(uiDir, 'reports', 'test-results'),
|
||||
path.join(uiDir, 'reports', 'visual-test-results'),
|
||||
path.join(uiDir, 'reports', 'html'),
|
||||
path.join(uiDir, 'reports', 'playwright-html-report'),
|
||||
path.join(uiDir, 'reports', 'results.json'),
|
||||
path.join(uiDir, 'reports', 'visual-results.json'),
|
||||
path.join(uiDir, 'reports', 'visual-screenshots'),
|
||||
path.join(uiDir, 'reports', 'visual-report'),
|
||||
path.join(uiDir, 'reports', 'junit.xml'),
|
||||
path.join(uiDir, '.DS_Store'),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client, S3ServiceException } from '@aws-sdk/client-s3';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import pixelmatch from 'pixelmatch';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const marker = '<!-- visual-regression-bot -->';
|
||||
const visualPrefix = 'visual-regression';
|
||||
const inlineCaseLimit = 20;
|
||||
const pixelThreshold = 0.1;
|
||||
const maxCaseCount = 40;
|
||||
const maxPngBytes = 10 * 1024 * 1024;
|
||||
const caseNamePattern = /^visual-[a-z0-9][a-z0-9-_]{0,80}$/u;
|
||||
|
||||
type CommandName = 'upload-baseline' | 'compare-pr';
|
||||
|
||||
type R2Config = {
|
||||
bucket: string;
|
||||
publicOrigin: string;
|
||||
client: S3Client;
|
||||
};
|
||||
|
||||
type VisualCase = {
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type BaselineLookup = {
|
||||
sha: string;
|
||||
key: string;
|
||||
behindBy: number;
|
||||
};
|
||||
|
||||
type ComparedCase = {
|
||||
name: string;
|
||||
status: 'changed' | 'unchanged' | 'missing-baseline' | 'failed';
|
||||
diffPixels?: number;
|
||||
baselineSha?: string;
|
||||
baselineBehindBy?: number;
|
||||
mainUrl?: string;
|
||||
prUrl?: string;
|
||||
diffUrl?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const e2eDir = path.resolve(scriptDir, '..');
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const command = args._[0] as CommandName | undefined;
|
||||
|
||||
if (command === 'upload-baseline') {
|
||||
await uploadBaseline(args);
|
||||
} else if (command === 'compare-pr') {
|
||||
await comparePr(args);
|
||||
} else {
|
||||
printUsage();
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function uploadBaseline(options: ParsedArgs): Promise<void> {
|
||||
const sha = requiredOption(options, 'sha');
|
||||
const screenshotsDir = path.resolve(optionString(options, 'screenshots') ?? 'ui/reports/visual-screenshots');
|
||||
const manifestOut = optionString(options, 'manifest-out');
|
||||
const r2 = r2ConfigFromEnv();
|
||||
const cases = await listPngCases(screenshotsDir);
|
||||
|
||||
if (cases.length === 0) {
|
||||
throw new Error(`No PNG screenshots found in ${screenshotsDir}`);
|
||||
}
|
||||
|
||||
const uploaded = [];
|
||||
for (const visualCase of cases) {
|
||||
const key = baselineKey(sha, visualCase.name);
|
||||
await putFile(r2, key, visualCase.path);
|
||||
uploaded.push({ name: visualCase.name, key, url: publicUrl(r2, key) });
|
||||
}
|
||||
|
||||
if (manifestOut != null) {
|
||||
await writeJson(path.resolve(manifestOut), { sha, uploaded });
|
||||
}
|
||||
|
||||
console.log(`Uploaded ${uploaded.length} visual baseline screenshots for ${sha}.`);
|
||||
}
|
||||
|
||||
async function comparePr(options: ParsedArgs): Promise<void> {
|
||||
const prNumber = requiredOption(options, 'pr-number');
|
||||
const runId = requiredOption(options, 'run-id');
|
||||
const headSha = requiredOption(options, 'head-sha');
|
||||
const baseSha = requiredOption(options, 'base-sha');
|
||||
const screenshotsDir = path.resolve(optionString(options, 'screenshots') ?? 'ui/reports/visual-screenshots');
|
||||
const outputDir = path.resolve(optionString(options, 'output-dir') ?? 'ui/reports/visual-report');
|
||||
const commentOut = path.resolve(optionString(options, 'comment-out') ?? path.join(outputDir, 'comment.md'));
|
||||
const manifestOut = path.resolve(optionString(options, 'manifest-out') ?? path.join(outputDir, 'manifest.json'));
|
||||
const maxAncestors = Number(optionString(options, 'max-ancestors') ?? 20);
|
||||
const captureOutcome = optionString(options, 'capture-outcome') ?? 'success';
|
||||
const cases = await listPngCases(screenshotsDir);
|
||||
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
if (cases.length === 0) {
|
||||
const compared: ComparedCase[] = [
|
||||
{
|
||||
name: 'visual-capture',
|
||||
status: 'failed',
|
||||
error: `No PNG screenshots found in ${screenshotsDir}. Check the Playwright capture logs for details.`,
|
||||
},
|
||||
];
|
||||
await writeFile(commentOut, renderComment({ compared, headSha, baseSha }));
|
||||
await writeJson(manifestOut, { prNumber, runId, headSha, baseSha, compared });
|
||||
console.log(`Wrote visual capture failure report to ${commentOut}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const r2 = r2ConfigFromEnv();
|
||||
const candidateShas = await baselineCandidateShas(baseSha, maxAncestors);
|
||||
const compared: ComparedCase[] = captureOutcome === 'success'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: 'visual-capture',
|
||||
status: 'failed',
|
||||
error: `Playwright capture completed with outcome '${captureOutcome}'. Partial screenshots may be shown below.`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const visualCase of cases) {
|
||||
compared.push(await compareCase({ r2, prNumber, runId, headSha, visualCase, candidateShas, outputDir }));
|
||||
}
|
||||
|
||||
const comment = renderComment({ compared, headSha, baseSha });
|
||||
await writeFile(commentOut, comment);
|
||||
await writeJson(manifestOut, { prNumber, runId, headSha, baseSha, compared });
|
||||
|
||||
console.log(`Wrote visual report for ${compared.length} cases to ${commentOut}.`);
|
||||
}
|
||||
|
||||
async function compareCase(input: {
|
||||
r2: R2Config;
|
||||
prNumber: string;
|
||||
runId: string;
|
||||
headSha: string;
|
||||
visualCase: VisualCase;
|
||||
candidateShas: string[];
|
||||
outputDir: string;
|
||||
}): Promise<ComparedCase> {
|
||||
const { r2, prNumber, runId, headSha, visualCase, candidateShas, outputDir } = input;
|
||||
const prKey = prImageKey(prNumber, runId, 'pr', visualCase.name);
|
||||
await putFile(r2, prKey, visualCase.path);
|
||||
|
||||
const baseline = await findBaseline(r2, visualCase.name, candidateShas);
|
||||
if (baseline == null) {
|
||||
return {
|
||||
name: visualCase.name,
|
||||
status: 'missing-baseline',
|
||||
prUrl: publicUrl(r2, prKey),
|
||||
};
|
||||
}
|
||||
|
||||
const mainPath = path.join(outputDir, 'main', `${visualCase.name}.png`);
|
||||
const diffPath = path.join(outputDir, 'diff', `${visualCase.name}.png`);
|
||||
await downloadObject(r2, baseline.key, mainPath);
|
||||
|
||||
try {
|
||||
const diffPixels = await writeDiffPng(mainPath, visualCase.path, diffPath);
|
||||
const mainKey = prImageKey(prNumber, runId, 'main', visualCase.name);
|
||||
const diffKey = prImageKey(prNumber, runId, 'diff', visualCase.name);
|
||||
await putFile(r2, mainKey, mainPath);
|
||||
await putFile(r2, diffKey, diffPath);
|
||||
|
||||
return {
|
||||
name: visualCase.name,
|
||||
status: diffPixels > 0 ? 'changed' : 'unchanged',
|
||||
diffPixels,
|
||||
baselineSha: baseline.sha,
|
||||
baselineBehindBy: baseline.behindBy,
|
||||
mainUrl: publicUrl(r2, mainKey),
|
||||
prUrl: publicUrl(r2, prKey),
|
||||
diffUrl: publicUrl(r2, diffKey),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: visualCase.name,
|
||||
status: 'failed',
|
||||
baselineSha: baseline.sha,
|
||||
baselineBehindBy: baseline.behindBy,
|
||||
mainUrl: publicUrl(r2, baseline.key),
|
||||
prUrl: publicUrl(r2, prKey),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDiffPng(mainPath: string, prPath: string, diffPath: string): Promise<number> {
|
||||
const main = PNG.sync.read(await readFile(mainPath));
|
||||
const pr = PNG.sync.read(await readFile(prPath));
|
||||
const width = Math.max(main.width, pr.width);
|
||||
const height = Math.max(main.height, pr.height);
|
||||
const normalizedMain = normalizePng(main, width, height);
|
||||
const normalizedPr = normalizePng(pr, width, height);
|
||||
const diff = new PNG({ width, height });
|
||||
const diffPixels = pixelmatch(normalizedMain.data, normalizedPr.data, diff.data, width, height, {
|
||||
threshold: pixelThreshold,
|
||||
alpha: 0.2,
|
||||
diffColor: [255, 0, 0],
|
||||
});
|
||||
|
||||
await mkdir(path.dirname(diffPath), { recursive: true });
|
||||
await writeFile(diffPath, PNG.sync.write(diff));
|
||||
return diffPixels;
|
||||
}
|
||||
|
||||
function normalizePng(source: PNG, width: number, height: number): PNG {
|
||||
if (source.width === width && source.height === height) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const target = new PNG({ width, height, fill: true });
|
||||
for (let y = 0; y < source.height; y += 1) {
|
||||
for (let x = 0; x < source.width; x += 1) {
|
||||
const sourceIndex = (source.width * y + x) << 2;
|
||||
const targetIndex = (width * y + x) << 2;
|
||||
target.data[targetIndex] = source.data[sourceIndex] ?? 255;
|
||||
target.data[targetIndex + 1] = source.data[sourceIndex + 1] ?? 255;
|
||||
target.data[targetIndex + 2] = source.data[sourceIndex + 2] ?? 255;
|
||||
target.data[targetIndex + 3] = source.data[sourceIndex + 3] ?? 255;
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
async function listPngCases(screenshotsDir: string): Promise<VisualCase[]> {
|
||||
const entries = await readdir(screenshotsDir, { withFileTypes: true }).catch((error: unknown) => {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const cases = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.png'))
|
||||
.map((entry) => ({ name: entry.name.replace(/\.png$/u, ''), path: path.join(screenshotsDir, entry.name) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (cases.length > maxCaseCount) {
|
||||
throw new Error(`Visual artifact contains ${cases.length} PNG files; maximum allowed is ${maxCaseCount}`);
|
||||
}
|
||||
|
||||
for (const visualCase of cases) {
|
||||
if (!caseNamePattern.test(visualCase.name)) {
|
||||
throw new Error(`Invalid visual case filename: ${visualCase.name}. Expected ${caseNamePattern.source}`);
|
||||
}
|
||||
const stats = await stat(visualCase.path);
|
||||
if (stats.size > maxPngBytes) {
|
||||
throw new Error(`Visual case ${visualCase.name} is ${stats.size} bytes; maximum allowed is ${maxPngBytes}`);
|
||||
}
|
||||
}
|
||||
|
||||
return cases;
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && 'code' in error;
|
||||
}
|
||||
|
||||
async function baselineCandidateShas(baseSha: string, maxAncestors: number): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['rev-list', `--max-count=${maxAncestors + 1}`, baseSha], {
|
||||
cwd: path.resolve(e2eDir, '..'),
|
||||
});
|
||||
const shas = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
return shas.length > 0 ? shas : [baseSha];
|
||||
} catch {
|
||||
return [baseSha];
|
||||
}
|
||||
}
|
||||
|
||||
async function findBaseline(r2: R2Config, caseName: string, candidateShas: string[]): Promise<BaselineLookup | null> {
|
||||
for (const [index, sha] of candidateShas.entries()) {
|
||||
const key = baselineKey(sha, caseName);
|
||||
if (await objectExists(r2, key)) {
|
||||
return { sha, key, behindBy: index };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderComment(input: { compared: ComparedCase[]; headSha: string; baseSha: string }): string {
|
||||
const { compared, headSha, baseSha } = input;
|
||||
const changed = compared.filter((visualCase) => visualCase.status === 'changed');
|
||||
const unchanged = compared.filter((visualCase) => visualCase.status === 'unchanged');
|
||||
const missing = compared.filter((visualCase) => visualCase.status === 'missing-baseline');
|
||||
const failed = compared.filter((visualCase) => visualCase.status === 'failed');
|
||||
const hasFallbackBaseline = compared.some((visualCase) => (visualCase.baselineBehindBy ?? 0) > 0);
|
||||
const lines = [marker, '## Visual regression review', ''];
|
||||
|
||||
lines.push(`Head: \`${shortSha(headSha)}\` · Base: \`${shortSha(baseSha)}\``);
|
||||
if (missing.length === compared.length) {
|
||||
lines.push('', '> Baseline unavailable; PR screenshots captured, no diff computed.');
|
||||
} else if (missing.length > 0) {
|
||||
lines.push('', `> ${missing.length} case(s) have no baseline; PR screenshots are shown without a diff.`);
|
||||
}
|
||||
if (hasFallbackBaseline) {
|
||||
lines.push('', '> Some cases used the nearest available ancestor baseline instead of the exact base SHA.');
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
lines.push('', `> ⚠️ ${failed.length} case(s) failed during diff generation; partial captures are shown below.`);
|
||||
}
|
||||
|
||||
lines.push('', summaryLine({ changed, unchanged, missing, failed }), '');
|
||||
|
||||
if (changed.length > 0) {
|
||||
lines.push('### Changed cases', '', ...renderCaseTable(changed.slice(0, inlineCaseLimit)), '');
|
||||
if (changed.length > inlineCaseLimit) {
|
||||
lines.push(`_${changed.length - inlineCaseLimit} additional changed case(s) omitted from this comment._`, '');
|
||||
}
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
lines.push('### Capture or diff failures', '');
|
||||
for (const visualCase of failed) {
|
||||
lines.push(`- **${escapeMarkdown(visualCase.name)}**: ${escapeMarkdown(visualCase.error ?? 'Unknown error')}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
lines.push('<details><summary>PR screenshots without baselines</summary>', '', ...renderCaseTable(missing.slice(0, inlineCaseLimit), false), '</details>', '');
|
||||
}
|
||||
|
||||
if (unchanged.length > 0) {
|
||||
lines.push('<details><summary>Unchanged cases</summary>', '', ...renderCaseTable(unchanged.slice(0, inlineCaseLimit)), '</details>', '');
|
||||
}
|
||||
|
||||
lines.push('_Visual diff is advisory only and does not block merging._', '');
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function summaryLine(groups: { changed: ComparedCase[]; unchanged: ComparedCase[]; missing: ComparedCase[]; failed: ComparedCase[] }): string {
|
||||
return [
|
||||
`**${groups.changed.length} changed**`,
|
||||
`${groups.unchanged.length} unchanged`,
|
||||
`${groups.missing.length} missing baseline`,
|
||||
`${groups.failed.length} failed`,
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
function renderCaseTable(cases: ComparedCase[], includeDiff = true): string[] {
|
||||
const lines = includeDiff
|
||||
? ['| Case | Main | PR | Diff |', '| --- | --- | --- | --- |']
|
||||
: ['| Case | PR |', '| --- | --- |'];
|
||||
|
||||
for (const visualCase of cases) {
|
||||
const baselineNote = visualCase.baselineBehindBy != null && visualCase.baselineBehindBy > 0
|
||||
? `<br><sub>baseline ${visualCase.baselineBehindBy} commit(s) behind</sub>`
|
||||
: '';
|
||||
if (includeDiff) {
|
||||
lines.push(
|
||||
`| ${escapeMarkdown(visualCase.name)}${baselineNote} | ${imageCell(visualCase.mainUrl, 'main')} | ${imageCell(visualCase.prUrl, 'pr')} | ${imageCell(visualCase.diffUrl, 'diff')} |`,
|
||||
);
|
||||
} else {
|
||||
lines.push(`| ${escapeMarkdown(visualCase.name)} | ${imageCell(visualCase.prUrl, 'pr')} |`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function imageCell(url: string | undefined, alt: string): string {
|
||||
return url == null ? 'n/a' : `<img src="${escapeHtml(url)}" alt="${escapeHtml(alt)}" width="280">`;
|
||||
}
|
||||
|
||||
async function objectExists(r2: R2Config, key: string): Promise<boolean> {
|
||||
try {
|
||||
await r2.client.send(new HeadObjectCommand({ Bucket: r2.bucket, Key: key }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissingObjectError(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingObjectError(error: unknown): error is S3ServiceException {
|
||||
return error instanceof S3ServiceException
|
||||
&& (error.name === 'NotFound' || error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404);
|
||||
}
|
||||
|
||||
async function putFile(r2: R2Config, key: string, filePath: string): Promise<void> {
|
||||
await r2.client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: r2.bucket,
|
||||
Key: key,
|
||||
Body: createReadStream(filePath),
|
||||
ContentType: 'image/png',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function downloadObject(r2: R2Config, key: string, outputPath: string): Promise<void> {
|
||||
const response = await r2.client.send(new GetObjectCommand({ Bucket: r2.bucket, Key: key }));
|
||||
if (response.Body == null) {
|
||||
throw new Error(`R2 object ${key} returned no body`);
|
||||
}
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
const bytes = await response.Body.transformToByteArray();
|
||||
await writeFile(outputPath, bytes);
|
||||
}
|
||||
|
||||
function r2ConfigFromEnv(): R2Config {
|
||||
const bucket = env('R2_BUCKET') ?? env('CLOUDFLARE_R2_RELEASES_BUCKET');
|
||||
const publicOrigin = env('R2_PUBLIC_ORIGIN') ?? env('CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN');
|
||||
const accessKeyId = env('R2_ACCESS_KEY_ID') ?? env('CLOUDFLARE_R2_RELEASES_AK');
|
||||
const secretAccessKey = env('R2_SECRET_ACCESS_KEY') ?? env('CLOUDFLARE_R2_RELEASES_SK');
|
||||
const endpoint = env('R2_ENDPOINT') ?? env('CLOUDFLARE_R2_RELEASES_URL') ?? endpointFromAccountId();
|
||||
|
||||
if (bucket == null || publicOrigin == null || accessKeyId == null || secretAccessKey == null || endpoint == null) {
|
||||
throw new Error(
|
||||
'Missing R2 configuration. Set R2_BUCKET, R2_PUBLIC_ORIGIN, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_ENDPOINT or R2_ACCOUNT_ID.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
bucket,
|
||||
publicOrigin: publicOrigin.replace(/\/+$/u, ''),
|
||||
client: new S3Client({
|
||||
region: 'auto',
|
||||
endpoint,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function endpointFromAccountId(): string | undefined {
|
||||
const accountId = env('R2_ACCOUNT_ID');
|
||||
return accountId == null ? undefined : `https://${accountId}.r2.cloudflarestorage.com`;
|
||||
}
|
||||
|
||||
function baselineKey(sha: string, caseName: string): string {
|
||||
return `${visualPrefix}/${sha}/${caseName}.png`;
|
||||
}
|
||||
|
||||
function prImageKey(prNumber: string, runId: string, kind: 'main' | 'pr' | 'diff', caseName: string): string {
|
||||
return `${visualPrefix}/pr-${prNumber}/${runId}/${kind}/${caseName}.png`;
|
||||
}
|
||||
|
||||
function publicUrl(r2: R2Config, key: string): string {
|
||||
return `${r2.publicOrigin}/${key.split('/').map(encodeURIComponent).join('/')}`;
|
||||
}
|
||||
|
||||
async function writeJson(filePath: string, value: unknown): Promise<void> {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function requiredOption(options: ParsedArgs, name: string): string {
|
||||
const value = optionString(options, name);
|
||||
if (typeof value !== 'string' || value === '') {
|
||||
throw new Error(`Missing required --${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionString(options: ParsedArgs, name: string): string | undefined {
|
||||
const value = options[name];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
type ParsedArgs = { _: string[] } & { [key: string]: string | string[] | undefined };
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const parsed: ParsedArgs = { _: [] };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg == null) {
|
||||
continue;
|
||||
}
|
||||
if (!arg.startsWith('--')) {
|
||||
parsed._.push(arg);
|
||||
continue;
|
||||
}
|
||||
const name = arg.slice(2);
|
||||
const value = argv[index + 1];
|
||||
if (value == null || value.startsWith('--')) {
|
||||
parsed[name] = 'true';
|
||||
} else {
|
||||
parsed[name] = value;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function env(name: string): string | undefined {
|
||||
const value = process.env[name];
|
||||
return value == null || value === '' ? undefined : value;
|
||||
}
|
||||
|
||||
function shortSha(sha: string): string {
|
||||
return sha.slice(0, 7);
|
||||
}
|
||||
|
||||
function escapeMarkdown(value: string): string {
|
||||
return escapeHtml(value).replace(/[|\\`*_{}[\]()#+.!-]/gu, '\\$&');
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
.replace(/[\r\n]+/gu, ' ');
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`Usage: tsx scripts/visual-report.ts <command> [options]
|
||||
|
||||
Commands:
|
||||
upload-baseline --sha <sha> --screenshots <dir> [--manifest-out <path>]
|
||||
compare-pr --pr-number <num> --run-id <id> --head-sha <sha> --base-sha <sha> --screenshots <dir> [--comment-out <path>]
|
||||
`);
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"include": [
|
||||
"playwright.config.ts",
|
||||
"playwright.visual.config.ts",
|
||||
"vitest.config.ts",
|
||||
"lib/**/*.ts",
|
||||
"resources/**/*.ts",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { captureVisual, configureVisualPage, gotoVisualHome } from '@/playwright/visual';
|
||||
|
||||
test('captures the visual home harness', async ({ page }) => {
|
||||
await configureVisualPage(page);
|
||||
await gotoVisualHome(page);
|
||||
|
||||
await expect(page.getByTestId('home-hero')).toBeVisible();
|
||||
await expect(page.getByTestId('home-hero-input')).toBeVisible();
|
||||
|
||||
await captureVisual(page, 'visual-home');
|
||||
});
|
||||
Generated
+517
@@ -313,12 +313,24 @@ importers:
|
||||
|
||||
e2e:
|
||||
devDependencies:
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: 3.1050.0
|
||||
version: 3.1050.0
|
||||
'@playwright/test':
|
||||
specifier: 1.60.0
|
||||
version: 1.60.0
|
||||
'@types/node':
|
||||
specifier: 20.19.39
|
||||
version: 20.19.39
|
||||
'@types/pngjs':
|
||||
specifier: 6.0.5
|
||||
version: 6.0.5
|
||||
pixelmatch:
|
||||
specifier: 7.2.0
|
||||
version: 7.2.0
|
||||
pngjs:
|
||||
specifier: 7.0.0
|
||||
version: 7.0.0
|
||||
tsx:
|
||||
specifier: 4.22.2
|
||||
version: 4.22.2
|
||||
@@ -664,6 +676,125 @@ packages:
|
||||
'@astrojs/yaml2ts@0.2.3':
|
||||
resolution: {integrity: sha512-PJzRmgQzUxI2uwpdX2lXSHtP4G8ocp24/t+bZyf5Fy0SZLSF9f9KXZoMlFM/XCGue+B0nH/2IZ7FpBYQATBsCg==}
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@aws-crypto/crc32c@5.2.0':
|
||||
resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==}
|
||||
|
||||
'@aws-crypto/sha1-browser@5.2.0':
|
||||
resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==}
|
||||
|
||||
'@aws-crypto/sha256-browser@5.2.0':
|
||||
resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==}
|
||||
|
||||
'@aws-crypto/sha256-js@5.2.0':
|
||||
resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@aws-crypto/supports-web-crypto@5.2.0':
|
||||
resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==}
|
||||
|
||||
'@aws-crypto/util@5.2.0':
|
||||
resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
|
||||
|
||||
'@aws-sdk/client-s3@3.1050.0':
|
||||
resolution: {integrity: sha512-9kgtv+bXZQrOIJT2INPPBCezrJu1FlgGrzEat/ut4A4V53IT00LynsBZgp12eFKbjJuNCeTo7iPSKjPsX8ub+A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/core@3.974.12':
|
||||
resolution: {integrity: sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/crc64-nvme@3.972.8':
|
||||
resolution: {integrity: sha512-fVfUCL/Xh2zINYMPZvj+iBn6XWouQf0DAnjaWCI9MkmqXzL2Iy5FoQB8O7syFe6gN6AH1ecDDU58T51Ou0kFkA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.38':
|
||||
resolution: {integrity: sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.40':
|
||||
resolution: {integrity: sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.972.42':
|
||||
resolution: {integrity: sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.42':
|
||||
resolution: {integrity: sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.43':
|
||||
resolution: {integrity: sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.38':
|
||||
resolution: {integrity: sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.972.42':
|
||||
resolution: {integrity: sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.42':
|
||||
resolution: {integrity: sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-bucket-endpoint@3.972.14':
|
||||
resolution: {integrity: sha512-Aaj0d+xbo1jJquBWJP0/9V/XZRYukO3LWIRp3dOLHmoFrYKb4YZ0aLefgVHfGcNOVBS2ZTq7L/n5JcrE7DaC+Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-expect-continue@3.972.12':
|
||||
resolution: {integrity: sha512-dA5pKTom/Ls9mgeyeaRBNQrRIVOLVjv4AmKOB0/e4yaiXEUy0gSz2d3liP8JHtYoCAEWySU1jWnyzwLOREN+4g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-flexible-checksums@3.974.20':
|
||||
resolution: {integrity: sha512-NdnMVQCR1YjIcqFAiNLdBiOwr2DyQDB2IiXQrBhzolKOv32ae4d4Ll7IzLMi04eMHiq/o/Y/GjFuVjF9HuG0QA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-location-constraint@3.972.10':
|
||||
resolution: {integrity: sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.41':
|
||||
resolution: {integrity: sha512-M4T2I2WPuH5WQpU8Tsp+u2bcO29zGRkU14ATzuqb9I4xh8tzsLqtp4hzaJM5aO2dhMZnHDzyQwSFVgc3XbnoGg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-ssec@3.972.10':
|
||||
resolution: {integrity: sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.10':
|
||||
resolution: {integrity: sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.27':
|
||||
resolution: {integrity: sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1049.0':
|
||||
resolution: {integrity: sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/types@3.973.8':
|
||||
resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/util-locate-window@3.965.5':
|
||||
resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.24':
|
||||
resolution: {integrity: sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws/lambda-invoke-store@0.2.4':
|
||||
resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1687,6 +1818,42 @@ packages:
|
||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@smithy/core@3.24.3':
|
||||
resolution: {integrity: sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/credential-provider-imds@4.3.3':
|
||||
resolution: {integrity: sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/fetch-http-handler@5.4.3':
|
||||
resolution: {integrity: sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/is-array-buffer@2.2.0':
|
||||
resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/node-http-handler@4.7.3':
|
||||
resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/signature-v4@5.4.3':
|
||||
resolution: {integrity: sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/types@4.14.2':
|
||||
resolution: {integrity: sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-buffer-from@2.2.0':
|
||||
resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/util-utf8@2.3.0':
|
||||
resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@@ -1889,6 +2056,9 @@ packages:
|
||||
'@types/plist@3.0.5':
|
||||
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
|
||||
|
||||
'@types/pngjs@6.0.5':
|
||||
resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}
|
||||
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
@@ -2189,6 +2359,9 @@ packages:
|
||||
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
|
||||
bowser@2.14.1:
|
||||
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -2750,6 +2923,10 @@ packages:
|
||||
fast-xml-builder@1.2.0:
|
||||
resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
|
||||
|
||||
fast-xml-parser@5.7.3:
|
||||
resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==}
|
||||
hasBin: true
|
||||
|
||||
fast-xml-parser@5.8.0:
|
||||
resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==}
|
||||
hasBin: true
|
||||
@@ -3767,6 +3944,10 @@ packages:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pixelmatch@7.2.0:
|
||||
resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==}
|
||||
hasBin: true
|
||||
|
||||
pkce-challenge@5.0.1:
|
||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
@@ -3789,6 +3970,10 @@ packages:
|
||||
resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==}
|
||||
engines: {node: '>=10.4.0'}
|
||||
|
||||
pngjs@7.0.0:
|
||||
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
|
||||
engines: {node: '>=14.19.0'}
|
||||
|
||||
postcss@8.5.14:
|
||||
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -5012,6 +5197,271 @@ snapshots:
|
||||
dependencies:
|
||||
yaml: 2.9.0
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 5.2.0
|
||||
'@aws-sdk/types': 3.973.8
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-crypto/crc32c@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 5.2.0
|
||||
'@aws-sdk/types': 3.973.8
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-crypto/sha1-browser@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/supports-web-crypto': 5.2.0
|
||||
'@aws-crypto/util': 5.2.0
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@aws-sdk/util-locate-window': 3.965.5
|
||||
'@smithy/util-utf8': 2.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-crypto/sha256-browser@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-js': 5.2.0
|
||||
'@aws-crypto/supports-web-crypto': 5.2.0
|
||||
'@aws-crypto/util': 5.2.0
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@aws-sdk/util-locate-window': 3.965.5
|
||||
'@smithy/util-utf8': 2.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-crypto/sha256-js@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 5.2.0
|
||||
'@aws-sdk/types': 3.973.8
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-crypto/supports-web-crypto@5.2.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-crypto/util@5.2.0':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/util-utf8': 2.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-s3@3.1050.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha1-browser': 5.2.0
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
'@aws-crypto/sha256-js': 5.2.0
|
||||
'@aws-sdk/core': 3.974.12
|
||||
'@aws-sdk/credential-provider-node': 3.972.43
|
||||
'@aws-sdk/middleware-bucket-endpoint': 3.972.14
|
||||
'@aws-sdk/middleware-expect-continue': 3.972.12
|
||||
'@aws-sdk/middleware-flexible-checksums': 3.974.20
|
||||
'@aws-sdk/middleware-location-constraint': 3.972.10
|
||||
'@aws-sdk/middleware-sdk-s3': 3.972.41
|
||||
'@aws-sdk/middleware-ssec': 3.972.10
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.27
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/fetch-http-handler': 5.4.3
|
||||
'@smithy/node-http-handler': 4.7.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/core@3.974.12':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@aws-sdk/xml-builder': 3.972.24
|
||||
'@aws/lambda-invoke-store': 0.2.4
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/signature-v4': 5.4.3
|
||||
'@smithy/types': 4.14.2
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/crc64-nvme@3.972.8':
|
||||
dependencies:
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.38':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.12
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.40':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.12
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/fetch-http-handler': 5.4.3
|
||||
'@smithy/node-http-handler': 4.7.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.972.42':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.12
|
||||
'@aws-sdk/credential-provider-env': 3.972.38
|
||||
'@aws-sdk/credential-provider-http': 3.972.40
|
||||
'@aws-sdk/credential-provider-login': 3.972.42
|
||||
'@aws-sdk/credential-provider-process': 3.972.38
|
||||
'@aws-sdk/credential-provider-sso': 3.972.42
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.42
|
||||
'@aws-sdk/nested-clients': 3.997.10
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/credential-provider-imds': 4.3.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.42':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.12
|
||||
'@aws-sdk/nested-clients': 3.997.10
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.43':
|
||||
dependencies:
|
||||
'@aws-sdk/credential-provider-env': 3.972.38
|
||||
'@aws-sdk/credential-provider-http': 3.972.40
|
||||
'@aws-sdk/credential-provider-ini': 3.972.42
|
||||
'@aws-sdk/credential-provider-process': 3.972.38
|
||||
'@aws-sdk/credential-provider-sso': 3.972.42
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.42
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/credential-provider-imds': 4.3.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.38':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.12
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.972.42':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.12
|
||||
'@aws-sdk/nested-clients': 3.997.10
|
||||
'@aws-sdk/token-providers': 3.1049.0
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.42':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.12
|
||||
'@aws-sdk/nested-clients': 3.997.10
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-bucket-endpoint@3.972.14':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.12
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-expect-continue@3.972.12':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-flexible-checksums@3.974.20':
|
||||
dependencies:
|
||||
'@aws-crypto/crc32': 5.2.0
|
||||
'@aws-crypto/crc32c': 5.2.0
|
||||
'@aws-crypto/util': 5.2.0
|
||||
'@aws-sdk/core': 3.974.12
|
||||
'@aws-sdk/crc64-nvme': 3.972.8
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-location-constraint@3.972.10':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.41':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.12
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.27
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/signature-v4': 5.4.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-ssec@3.972.10':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.10':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
'@aws-crypto/sha256-js': 5.2.0
|
||||
'@aws-sdk/core': 3.974.12
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.27
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/fetch-http-handler': 5.4.3
|
||||
'@smithy/node-http-handler': 4.7.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.27':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/signature-v4': 5.4.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1049.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.12
|
||||
'@aws-sdk/nested-clients': 3.997.10
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/types@3.973.8':
|
||||
dependencies:
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/util-locate-window@3.965.5':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.24':
|
||||
dependencies:
|
||||
'@nodable/entities': 2.1.0
|
||||
'@smithy/types': 4.14.2
|
||||
fast-xml-parser: 5.7.3
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws/lambda-invoke-store@0.2.4': {}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
@@ -5791,6 +6241,54 @@ snapshots:
|
||||
|
||||
'@sindresorhus/is@4.6.0': {}
|
||||
|
||||
'@smithy/core@3.24.3':
|
||||
dependencies:
|
||||
'@aws-crypto/crc32': 5.2.0
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/credential-provider-imds@4.3.3':
|
||||
dependencies:
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/fetch-http-handler@5.4.3':
|
||||
dependencies:
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/is-array-buffer@2.2.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@4.7.3':
|
||||
dependencies:
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/signature-v4@5.4.3':
|
||||
dependencies:
|
||||
'@smithy/core': 3.24.3
|
||||
'@smithy/types': 4.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/types@4.14.2':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-buffer-from@2.2.0':
|
||||
dependencies:
|
||||
'@smithy/is-array-buffer': 2.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-utf8@2.3.0':
|
||||
dependencies:
|
||||
'@smithy/util-buffer-from': 2.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
@@ -6002,6 +6500,10 @@ snapshots:
|
||||
xmlbuilder: 15.1.1
|
||||
optional: true
|
||||
|
||||
'@types/pngjs@6.0.5':
|
||||
dependencies:
|
||||
'@types/node': 24.12.2
|
||||
|
||||
'@types/prop-types@15.7.15': {}
|
||||
|
||||
'@types/qs@6.15.0': {}
|
||||
@@ -6469,6 +6971,8 @@ snapshots:
|
||||
boolean@3.2.0:
|
||||
optional: true
|
||||
|
||||
bowser@2.14.1: {}
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
@@ -7150,6 +7654,13 @@ snapshots:
|
||||
path-expression-matcher: 1.5.0
|
||||
xml-naming: 0.1.0
|
||||
|
||||
fast-xml-parser@5.7.3:
|
||||
dependencies:
|
||||
'@nodable/entities': 2.1.0
|
||||
fast-xml-builder: 1.2.0
|
||||
path-expression-matcher: 1.5.0
|
||||
strnum: 2.3.0
|
||||
|
||||
fast-xml-parser@5.8.0:
|
||||
dependencies:
|
||||
'@nodable/entities': 2.1.0
|
||||
@@ -8345,6 +8856,10 @@ snapshots:
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pixelmatch@7.2.0:
|
||||
dependencies:
|
||||
pngjs: 7.0.0
|
||||
|
||||
pkce-challenge@5.0.1: {}
|
||||
|
||||
playwright-core@1.60.0: {}
|
||||
@@ -8368,6 +8883,8 @@ snapshots:
|
||||
xmlbuilder: 15.1.1
|
||||
optional: true
|
||||
|
||||
pngjs@7.0.0: {}
|
||||
|
||||
postcss@8.5.14:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
|
||||
@@ -20,6 +20,7 @@ const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||
const allowedE2eScripts = new Set([
|
||||
"e2e/scripts/playwright.ts",
|
||||
"e2e/scripts/release-smoke.ts",
|
||||
"e2e/scripts/visual-report.ts",
|
||||
]);
|
||||
|
||||
type GuardCheck = {
|
||||
@@ -493,6 +494,7 @@ async function checkE2eLayout(): Promise<boolean> {
|
||||
repositoryPath === "e2e/tsconfig.json" ||
|
||||
repositoryPath === "e2e/vitest.config.ts" ||
|
||||
repositoryPath === "e2e/playwright.config.ts" ||
|
||||
repositoryPath === "e2e/playwright.visual.config.ts" ||
|
||||
repositoryPath === "e2e/AGENTS.md"
|
||||
) {
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user