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:
Marc Chan
2026-05-20 15:05:59 +08:00
committed by GitHub
parent 38b59fa4d9
commit f294ab4915
13 changed files with 1754 additions and 0 deletions
@@ -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
+66
View File
@@ -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
+204
View File
@@ -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
+225
View File
@@ -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
+94
View File
@@ -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 });
}
+4
View File
@@ -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"
+40
View File
@@ -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,
},
});
+4
View File
@@ -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'),
];
+530
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
.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>]
`);
}
+1
View File
@@ -23,6 +23,7 @@
},
"include": [
"playwright.config.ts",
"playwright.visual.config.ts",
"vitest.config.ts",
"lib/**/*.ts",
"resources/**/*.ts",
+12
View File
@@ -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');
});
+517
View File
@@ -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
+2
View File
@@ -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;