mirror of
https://github.com/immich-app/immich.git
synced 2026-05-23 16:12:30 -04:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b374d7eb87 | |||
| d20def9f66 | |||
| 13e8a0121f | |||
| fe4c0a95d5 | |||
| c5abd18a64 | |||
| cf1a9ed3f5 | |||
| feacf9b134 | |||
| 67eb33b3a7 | |||
| 23e3d43578 | |||
| 028c8a2276 | |||
| b7f4cc8171 | |||
| 5c11d15008 |
@@ -0,0 +1,406 @@
|
|||||||
|
name: Visual Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [labeled, synchronize]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
visual-diff:
|
||||||
|
name: Visual Diff Screenshots
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
(github.event.action == 'labeled' && github.event.label.name == 'visual-review') ||
|
||||||
|
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'visual-review'))
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./e2e
|
||||||
|
steps:
|
||||||
|
- id: token
|
||||||
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout PR branch
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Determine changed web files
|
||||||
|
id: changed-files
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const files = [];
|
||||||
|
const perPage = 100;
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const { data } = await github.rest.pulls.listFiles({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: context.issue.number,
|
||||||
|
per_page: perPage,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
files.push(...data);
|
||||||
|
if (data.length < perPage) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const webPrefixes = ['web/', 'i18n/', 'open-api/typescript-sdk/'];
|
||||||
|
const webFiles = files
|
||||||
|
.map(f => f.filename)
|
||||||
|
.filter(f => webPrefixes.some(p => f.startsWith(p)));
|
||||||
|
|
||||||
|
console.log(`Total PR files: ${files.length}`);
|
||||||
|
console.log(`Web-related files: ${webFiles.length}`);
|
||||||
|
for (const f of webFiles) {
|
||||||
|
console.log(` ${f}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
core.setOutput('files', webFiles.join('\n'));
|
||||||
|
core.setOutput('has_changes', webFiles.length > 0 ? 'true' : 'false');
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
if: steps.changed-files.outputs.has_changes == 'true'
|
||||||
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
if: steps.changed-files.outputs.has_changes == 'true'
|
||||||
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
|
with:
|
||||||
|
node-version-file: './e2e/.nvmrc'
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
|
- name: Install e2e dependencies
|
||||||
|
if: steps.changed-files.outputs.has_changes == 'true'
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install Playwright
|
||||||
|
if: steps.changed-files.outputs.has_changes == 'true'
|
||||||
|
run: pnpm exec playwright install chromium --only-shell
|
||||||
|
|
||||||
|
- name: Analyze affected routes
|
||||||
|
if: steps.changed-files.outputs.has_changes == 'true'
|
||||||
|
id: routes
|
||||||
|
env:
|
||||||
|
CHANGED_FILES: ${{ steps.changed-files.outputs.files }}
|
||||||
|
run: |
|
||||||
|
echo "Changed files:"
|
||||||
|
echo "$CHANGED_FILES"
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
ROUTES=$(echo "$CHANGED_FILES" | xargs pnpm exec tsx src/screenshots/analyze-deps.ts 2>&1 | tee /dev/stderr | grep "^ /" | sed 's/^ //' || true)
|
||||||
|
|
||||||
|
echo "routes<<EOF" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "$ROUTES" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
if [ -z "$ROUTES" ]; then
|
||||||
|
echo "has_routes=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "has_routes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Build the scenario filter JSON array
|
||||||
|
SCENARIO_NAMES=$(pnpm exec tsx -e "
|
||||||
|
import { getScenariosForRoutes } from './src/screenshots/page-map.ts';
|
||||||
|
const routes = process.argv.slice(1);
|
||||||
|
const scenarios = getScenariosForRoutes(routes);
|
||||||
|
console.log(JSON.stringify(scenarios.map(s => s.name)));
|
||||||
|
" $ROUTES)
|
||||||
|
echo "scenarios=$SCENARIO_NAMES" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Scenarios: $SCENARIO_NAMES"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Post initial comment
|
||||||
|
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes == 'true'
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
env:
|
||||||
|
AFFECTED_ROUTES: ${{ steps.routes.outputs.routes }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const routes = process.env.AFFECTED_ROUTES || '';
|
||||||
|
const body = `## Visual Review\n\nGenerating screenshots for affected pages...\n\nAffected routes:\n\`\`\`\n${routes}\n\`\`\``;
|
||||||
|
const comments = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
});
|
||||||
|
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
# === Screenshot PR version ===
|
||||||
|
- name: Build SDK (PR)
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
run: pnpm install --frozen-lockfile && pnpm build
|
||||||
|
working-directory: ./open-api/typescript-sdk
|
||||||
|
|
||||||
|
- name: Build web (PR)
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
run: pnpm install --frozen-lockfile && pnpm build
|
||||||
|
working-directory: ./web
|
||||||
|
|
||||||
|
- name: Take screenshots (PR)
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
env:
|
||||||
|
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
|
||||||
|
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/pr
|
||||||
|
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
|
||||||
|
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
|
||||||
|
run: |
|
||||||
|
# Start the preview server in background
|
||||||
|
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
|
||||||
|
SERVER_PID=$!
|
||||||
|
|
||||||
|
# Wait for server to be ready
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
|
||||||
|
echo "Server ready after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Run screenshot tests
|
||||||
|
pnpm exec playwright test --config playwright.screenshot.config.ts || true
|
||||||
|
|
||||||
|
# Stop the preview server and all children (pnpm spawns vite as child)
|
||||||
|
kill $SERVER_PID 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
# Ensure port is fully released — kill any lingering vite process
|
||||||
|
fuser -k 4173/tcp 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# === Screenshot base version ===
|
||||||
|
# Disable pnpm's verifyDepsBeforeRun for all base steps since the base
|
||||||
|
# checkout changes package.json files, making them mismatch the lockfile.
|
||||||
|
- name: Checkout base web directory
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
# Restore web directory from base branch
|
||||||
|
git checkout "$BASE_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
|
||||||
|
# Clear SvelteKit build cache to avoid stale artifacts from the PR build
|
||||||
|
rm -rf web/.svelte-kit web/build
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: Build SDK (base)
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
id: base-sdk
|
||||||
|
env:
|
||||||
|
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
|
||||||
|
run: pnpm build
|
||||||
|
working-directory: ./open-api/typescript-sdk
|
||||||
|
|
||||||
|
- name: Build web (base)
|
||||||
|
if: steps.routes.outputs.has_routes == 'true' && steps.base-sdk.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
id: base-web
|
||||||
|
env:
|
||||||
|
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
|
||||||
|
run: pnpm build
|
||||||
|
working-directory: ./web
|
||||||
|
|
||||||
|
- name: Take screenshots (base)
|
||||||
|
if: steps.routes.outputs.has_routes == 'true' && steps.base-web.outcome == 'success'
|
||||||
|
env:
|
||||||
|
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
|
||||||
|
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/base
|
||||||
|
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
|
||||||
|
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
|
||||||
|
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
|
||||||
|
run: |
|
||||||
|
# Kill any process still on port 4173 from the PR step
|
||||||
|
fuser -k 4173/tcp 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Start the preview server in background
|
||||||
|
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
|
||||||
|
SERVER_PID=$!
|
||||||
|
|
||||||
|
# Wait for server to be ready
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
|
||||||
|
echo "Server ready after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Run screenshot tests
|
||||||
|
pnpm exec playwright test --config playwright.screenshot.config.ts || true
|
||||||
|
|
||||||
|
# Stop the preview server
|
||||||
|
kill $SERVER_PID 2>/dev/null || true
|
||||||
|
fuser -k 4173/tcp 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Restore PR source
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
env:
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
run: |
|
||||||
|
git checkout "$HEAD_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
# === Compare and report ===
|
||||||
|
- name: Compare screenshots
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
env:
|
||||||
|
WORKSPACE_DIR: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
# Ensure directories exist even if base screenshots were skipped
|
||||||
|
mkdir -p "$WORKSPACE_DIR/screenshots/base" "$WORKSPACE_DIR/screenshots/pr" "$WORKSPACE_DIR/screenshots/diff"
|
||||||
|
pnpm exec tsx src/screenshots/compare.ts \
|
||||||
|
"$WORKSPACE_DIR/screenshots/base" \
|
||||||
|
"$WORKSPACE_DIR/screenshots/pr" \
|
||||||
|
"$WORKSPACE_DIR/screenshots/diff"
|
||||||
|
|
||||||
|
- name: Upload screenshot artifacts
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: visual-review-screenshots
|
||||||
|
path: screenshots/
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Upload HTML report
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
id: html-report
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
path: screenshots/diff/visual-review.html
|
||||||
|
archive: false
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Post comparison results
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
env:
|
||||||
|
REPORT_URL: ${{ steps.html-report.outputs.artifact-url }}
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const reportPath = path.join(process.env.GITHUB_WORKSPACE, 'screenshots', 'diff', 'report.md');
|
||||||
|
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = fs.readFileSync(reportPath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
body = '## Visual Review\n\nScreenshot comparison failed. Check the workflow artifacts for details.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append links to the HTML report artifact and workflow run
|
||||||
|
const reportUrl = process.env.REPORT_URL;
|
||||||
|
const runUrl = process.env.RUN_URL;
|
||||||
|
body += '\n---\n';
|
||||||
|
if (reportUrl) {
|
||||||
|
body += `[View full visual comparison](${reportUrl}) | `;
|
||||||
|
}
|
||||||
|
body += `[Download all screenshots](${runUrl}#artifacts)\n`;
|
||||||
|
|
||||||
|
// Find and update existing comment or create new one
|
||||||
|
const comments = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
});
|
||||||
|
|
||||||
|
const botComment = comments.data.find(c =>
|
||||||
|
c.body && c.body.includes('## Visual Review')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (botComment) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: botComment.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: No web changes
|
||||||
|
if: steps.changed-files.outputs.has_changes != 'true'
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const body = '## Visual Review\n\nNo web-related file changes detected in this PR. Visual review not needed.';
|
||||||
|
const comments = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
});
|
||||||
|
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner, repo: context.repo.repo,
|
||||||
|
comment_id: existing.id, body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner, repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number, body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: No affected routes
|
||||||
|
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes != 'true'
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const body = '## Visual Review\n\nChanged files don\'t affect any pages with screenshot scenarios configured.\nTo add coverage, define new scenarios in `e2e/src/screenshots/page-map.ts`.';
|
||||||
|
const comments = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
});
|
||||||
|
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner, repo: context.repo.repo,
|
||||||
|
comment_id: existing.id, body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner, repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number, body,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ open-api/typescript-sdk/build
|
|||||||
mobile/android/fastlane/report.xml
|
mobile/android/fastlane/report.xml
|
||||||
mobile/ios/fastlane/report.xml
|
mobile/ios/fastlane/report.xml
|
||||||
|
|
||||||
|
screenshots-output
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
.devcontainer/library
|
.devcontainer/library
|
||||||
|
|||||||
+5
-1
@@ -18,7 +18,10 @@
|
|||||||
"format:fix": "prettier --write .",
|
"format:fix": "prettier --write .",
|
||||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||||
"lint:fix": "pnpm run lint --fix",
|
"lint:fix": "pnpm run lint --fix",
|
||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit",
|
||||||
|
"screenshots": "pnpm exec playwright test --config playwright.screenshot.config.ts",
|
||||||
|
"screenshots:compare": "pnpm exec tsx src/screenshots/compare.ts",
|
||||||
|
"screenshots:analyze": "pnpm exec tsx src/screenshots/analyze-deps.ts"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -51,6 +54,7 @@
|
|||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"socket.io-client": "^4.7.4",
|
"socket.io-client": "^4.7.4",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"typescript-eslint": "^8.28.0",
|
"typescript-eslint": "^8.28.0",
|
||||||
"utimes": "^5.2.1",
|
"utimes": "^5.2.1",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const baseUrl = process.env.SCREENSHOT_BASE_URL ?? 'http://127.0.0.1:4173';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './src/screenshots',
|
||||||
|
testMatch: /run-scenarios\.ts/,
|
||||||
|
fullyParallel: false,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: 0,
|
||||||
|
reporter: 'list',
|
||||||
|
use: {
|
||||||
|
baseURL: baseUrl,
|
||||||
|
screenshot: 'off',
|
||||||
|
trace: 'off',
|
||||||
|
},
|
||||||
|
workers: 1,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'screenshots',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Reverse dependency analyzer for the Immich web app.
|
||||||
|
*
|
||||||
|
* Given a list of changed files, traces upward through the import graph
|
||||||
|
* to find which +page.svelte routes are affected, then maps those to URL paths.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
|
import { dirname, join, relative, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const WEB_SRC = resolve(import.meta.dirname, '../../../web/src');
|
||||||
|
const LIB_ALIAS = resolve(WEB_SRC, 'lib');
|
||||||
|
|
||||||
|
/** Collect all .svelte, .ts, .js files under web/src/ */
|
||||||
|
function collectFiles(dir: string): string[] {
|
||||||
|
const results: string[] = [];
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
const full = join(dir, entry);
|
||||||
|
const stat = statSync(full);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
if (entry === 'node_modules' || entry === '.svelte-kit') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.push(...collectFiles(full));
|
||||||
|
} else if (/\.(svelte|ts|js)$/.test(entry)) {
|
||||||
|
results.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract import specifiers from a file's source text. */
|
||||||
|
function extractImports(source: string): string[] {
|
||||||
|
const specifiers: string[] = [];
|
||||||
|
|
||||||
|
// Match: import ... from '...' / import '...' / export ... from '...'
|
||||||
|
const importRegex = /(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
||||||
|
let match;
|
||||||
|
while ((match = importRegex.exec(source)) !== null) {
|
||||||
|
specifiers.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match dynamic imports: import('...')
|
||||||
|
const dynamicRegex = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||||
|
while ((match = dynamicRegex.exec(source)) !== null) {
|
||||||
|
specifiers.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return specifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve an import specifier to an absolute file path (or null if external). */
|
||||||
|
function resolveImport(specifier: string, fromFile: string, allFiles: Set<string>): string | null {
|
||||||
|
// Handle $lib alias
|
||||||
|
let resolved: string;
|
||||||
|
if (specifier.startsWith('$lib/') || specifier === '$lib') {
|
||||||
|
resolved = specifier.replace('$lib', LIB_ALIAS);
|
||||||
|
} else if (specifier.startsWith('./') || specifier.startsWith('../')) {
|
||||||
|
resolved = resolve(dirname(fromFile), specifier);
|
||||||
|
} else {
|
||||||
|
// External package import — not relevant
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try exact match, then common extensions
|
||||||
|
const extensions = ['', '.ts', '.js', '.svelte', '/index.ts', '/index.js', '/index.svelte'];
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const candidate = resolved + ext;
|
||||||
|
if (allFiles.has(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the forward dependency graph: file → set of files it imports. */
|
||||||
|
function buildDependencyGraph(files: string[]): Map<string, Set<string>> {
|
||||||
|
const fileSet = new Set(files);
|
||||||
|
const graph = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const deps = new Set<string>();
|
||||||
|
graph.set(file, deps);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const source = readFileSync(file, 'utf8');
|
||||||
|
for (const specifier of extractImports(source)) {
|
||||||
|
const resolved = resolveImport(specifier, file, fileSet);
|
||||||
|
if (resolved) {
|
||||||
|
deps.add(resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip files that can't be read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invert the dependency graph: file → set of files that import it. */
|
||||||
|
function buildReverseDependencyGraph(forwardGraph: Map<string, Set<string>>): Map<string, Set<string>> {
|
||||||
|
const reverse = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
for (const [file, deps] of forwardGraph) {
|
||||||
|
for (const dep of deps) {
|
||||||
|
let importers = reverse.get(dep);
|
||||||
|
if (!importers) {
|
||||||
|
importers = new Set();
|
||||||
|
reverse.set(dep, importers);
|
||||||
|
}
|
||||||
|
importers.add(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BFS from changed files upward through reverse deps to find +page.svelte files. */
|
||||||
|
function findAffectedPages(changedFiles: string[], reverseGraph: Map<string, Set<string>>): Set<string> {
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const pages = new Set<string>();
|
||||||
|
const queue = [...changedFiles];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const file = queue.shift()!;
|
||||||
|
if (visited.has(file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visited.add(file);
|
||||||
|
|
||||||
|
if (file.endsWith('+page.svelte') || file.endsWith('+layout.svelte')) {
|
||||||
|
pages.add(file);
|
||||||
|
// If it's a layout, keep tracing upward because the layout itself
|
||||||
|
// isn't a page — but the pages under it are affected.
|
||||||
|
// If it's a +page.svelte, we still want to continue in case
|
||||||
|
// this page is imported by others.
|
||||||
|
}
|
||||||
|
|
||||||
|
const importers = reverseGraph.get(file);
|
||||||
|
if (importers) {
|
||||||
|
for (const importer of importers) {
|
||||||
|
if (!visited.has(importer)) {
|
||||||
|
queue.push(importer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For +layout.svelte hits, also find all +page.svelte under the same directory tree
|
||||||
|
const layoutDirs: string[] = [];
|
||||||
|
for (const page of pages) {
|
||||||
|
if (page.endsWith('+layout.svelte')) {
|
||||||
|
layoutDirs.push(dirname(page));
|
||||||
|
pages.delete(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layoutDirs.length > 0) {
|
||||||
|
for (const file of reverseGraph.keys()) {
|
||||||
|
if (file.endsWith('+page.svelte')) {
|
||||||
|
for (const layoutDir of layoutDirs) {
|
||||||
|
if (file.startsWith(layoutDir)) {
|
||||||
|
pages.add(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also check the forward graph keys for page files under layout dirs
|
||||||
|
for (const layoutDir of layoutDirs) {
|
||||||
|
const allFiles = collectFiles(layoutDir);
|
||||||
|
for (const f of allFiles) {
|
||||||
|
if (f.endsWith('+page.svelte')) {
|
||||||
|
pages.add(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a +page.svelte file path to its URL route. */
|
||||||
|
export function pageFileToRoute(pageFile: string): string {
|
||||||
|
const routesDir = resolve(WEB_SRC, 'routes');
|
||||||
|
let rel = relative(routesDir, dirname(pageFile));
|
||||||
|
|
||||||
|
// Remove SvelteKit group markers: (user), (list), etc.
|
||||||
|
rel = rel.replaceAll(/\([^)]+\)\/?/g, '');
|
||||||
|
|
||||||
|
// Remove parameter segments: [albumId=id], [[photos=photos]], [[assetId=id]]
|
||||||
|
rel = rel.replaceAll(/\[\[?[^\]]+\]\]?\/?/g, '');
|
||||||
|
|
||||||
|
// Clean up trailing slashes and normalize
|
||||||
|
rel = rel.replaceAll(/\/+/g, '/').replace(/\/$/, '');
|
||||||
|
|
||||||
|
return '/' + rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisResult {
|
||||||
|
affectedPages: string[];
|
||||||
|
affectedRoutes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Main entry: analyze which routes are affected by the given changed files. */
|
||||||
|
export function analyzeAffectedRoutes(changedFiles: string[]): AnalysisResult {
|
||||||
|
// Resolve changed files to absolute paths relative to web/src
|
||||||
|
const webRoot = resolve(WEB_SRC, '..');
|
||||||
|
const resolvedChanged = changedFiles
|
||||||
|
.filter((f) => f.startsWith('web/'))
|
||||||
|
.map((f) => resolve(webRoot, '..', f))
|
||||||
|
.filter((f) => statSync(f, { throwIfNoEntry: false })?.isFile());
|
||||||
|
|
||||||
|
if (resolvedChanged.length === 0) {
|
||||||
|
return { affectedPages: [], affectedRoutes: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFiles = collectFiles(WEB_SRC);
|
||||||
|
const forwardGraph = buildDependencyGraph(allFiles);
|
||||||
|
const reverseGraph = buildReverseDependencyGraph(forwardGraph);
|
||||||
|
|
||||||
|
const pages = findAffectedPages(resolvedChanged, reverseGraph);
|
||||||
|
|
||||||
|
const affectedPages = [...pages].toSorted();
|
||||||
|
const affectedRoutes = [...new Set(affectedPages.map((f) => pageFileToRoute(f)))].toSorted();
|
||||||
|
|
||||||
|
return { affectedPages, affectedRoutes };
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI usage: node --import tsx analyze-deps.ts file1 file2 ...
|
||||||
|
if (process.argv[1]?.endsWith('analyze-deps.ts') || process.argv[1]?.endsWith('analyze-deps.js')) {
|
||||||
|
const files = process.argv.slice(2);
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log('Usage: analyze-deps.ts <changed-file1> <changed-file2> ...');
|
||||||
|
console.log('Files should be relative to the repo root (e.g. web/src/lib/components/Button.svelte)');
|
||||||
|
throw new Error('No files provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = analyzeAffectedRoutes(files);
|
||||||
|
console.log('Affected pages:');
|
||||||
|
for (const page of result.affectedPages) {
|
||||||
|
console.log(` ${page}`);
|
||||||
|
}
|
||||||
|
console.log('\nAffected routes:');
|
||||||
|
for (const route of result.affectedRoutes) {
|
||||||
|
console.log(` ${route}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* Pixel-level comparison of base vs PR screenshots.
|
||||||
|
*
|
||||||
|
* Uses pixelmatch to generate diff images and calculate change percentages.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx e2e/src/screenshots/compare.ts <base-dir> <pr-dir> <output-dir>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { basename, join, resolve } from 'node:path';
|
||||||
|
import { PNG } from 'pngjs';
|
||||||
|
|
||||||
|
// pixelmatch is a lightweight dependency — use a simple inline implementation
|
||||||
|
// based on the approach from the pixelmatch library to avoid adding a new dependency.
|
||||||
|
// The e2e package already has pngjs.
|
||||||
|
|
||||||
|
function pixelMatch(img1Data: Uint8Array, img2Data: Uint8Array, diffData: Uint8Array): number {
|
||||||
|
let diffCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < img1Data.length; i += 4) {
|
||||||
|
const r1 = img1Data[i];
|
||||||
|
const g1 = img1Data[i + 1];
|
||||||
|
const b1 = img1Data[i + 2];
|
||||||
|
|
||||||
|
const r2 = img2Data[i];
|
||||||
|
const g2 = img2Data[i + 1];
|
||||||
|
const b2 = img2Data[i + 2];
|
||||||
|
|
||||||
|
const dr = Math.abs(r1 - r2);
|
||||||
|
const dg = Math.abs(g1 - g2);
|
||||||
|
const db = Math.abs(b1 - b2);
|
||||||
|
|
||||||
|
// Threshold: if any channel differs by more than 25, mark as different
|
||||||
|
const isDiff = dr > 25 || dg > 25 || db > 25;
|
||||||
|
|
||||||
|
if (isDiff) {
|
||||||
|
// Red highlight for diff pixels
|
||||||
|
diffData[i] = 255;
|
||||||
|
diffData[i + 1] = 0;
|
||||||
|
diffData[i + 2] = 0;
|
||||||
|
diffData[i + 3] = 255;
|
||||||
|
diffCount++;
|
||||||
|
} else {
|
||||||
|
// Dimmed original for unchanged pixels
|
||||||
|
const gray = Math.round(0.299 * r1 + 0.587 * g1 + 0.114 * b1);
|
||||||
|
diffData[i] = gray;
|
||||||
|
diffData[i + 1] = gray;
|
||||||
|
diffData[i + 2] = gray;
|
||||||
|
diffData[i + 3] = 128;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComparisonResult {
|
||||||
|
name: string;
|
||||||
|
baseExists: boolean;
|
||||||
|
prExists: boolean;
|
||||||
|
diffPixels: number;
|
||||||
|
totalPixels: number;
|
||||||
|
changePercent: number;
|
||||||
|
diffImagePath: string | null;
|
||||||
|
baseImagePath: string | null;
|
||||||
|
prImagePath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareScreenshots(baseDir: string, prDir: string, outputDir: string): ComparisonResult[] {
|
||||||
|
mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
// Collect all screenshot names from both directories
|
||||||
|
const baseFiles = existsSync(baseDir)
|
||||||
|
? new Set(readdirSync(baseDir).filter((f) => f.endsWith('.png')))
|
||||||
|
: new Set<string>();
|
||||||
|
const prFiles = existsSync(prDir) ? new Set(readdirSync(prDir).filter((f) => f.endsWith('.png'))) : new Set<string>();
|
||||||
|
|
||||||
|
const allNames = new Set([...baseFiles, ...prFiles]);
|
||||||
|
const results: ComparisonResult[] = [];
|
||||||
|
|
||||||
|
for (const fileName of [...allNames].toSorted()) {
|
||||||
|
const name = basename(fileName, '.png');
|
||||||
|
const basePath = join(baseDir, fileName);
|
||||||
|
const prPath = join(prDir, fileName);
|
||||||
|
const baseExists = baseFiles.has(fileName);
|
||||||
|
const prExists = prFiles.has(fileName);
|
||||||
|
|
||||||
|
if (!baseExists || !prExists) {
|
||||||
|
// New or removed page
|
||||||
|
results.push({
|
||||||
|
name,
|
||||||
|
baseExists,
|
||||||
|
prExists,
|
||||||
|
diffPixels: -1,
|
||||||
|
totalPixels: -1,
|
||||||
|
changePercent: 100,
|
||||||
|
diffImagePath: null,
|
||||||
|
baseImagePath: baseExists ? basePath : null,
|
||||||
|
prImagePath: prExists ? prPath : null,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load both PNGs
|
||||||
|
const basePng = PNG.sync.read(readFileSync(basePath));
|
||||||
|
const prPng = PNG.sync.read(readFileSync(prPath));
|
||||||
|
|
||||||
|
// Handle size mismatches by comparing the overlapping region
|
||||||
|
const width = Math.max(basePng.width, prPng.width);
|
||||||
|
const height = Math.max(basePng.height, prPng.height);
|
||||||
|
|
||||||
|
// Resize images to the same dimensions (pad with transparent)
|
||||||
|
const normalizedBase = normalizeImage(basePng, width, height);
|
||||||
|
const normalizedPr = normalizeImage(prPng, width, height);
|
||||||
|
|
||||||
|
const diffPng = new PNG({ width, height });
|
||||||
|
const totalPixels = width * height;
|
||||||
|
const diffPixels = pixelMatch(normalizedBase, normalizedPr, diffPng.data as unknown as Uint8Array);
|
||||||
|
|
||||||
|
const diffImagePath = join(outputDir, `${name}-diff.png`);
|
||||||
|
writeFileSync(diffImagePath, PNG.sync.write(diffPng));
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
name,
|
||||||
|
baseExists,
|
||||||
|
prExists,
|
||||||
|
diffPixels,
|
||||||
|
totalPixels,
|
||||||
|
changePercent: totalPixels > 0 ? (diffPixels / totalPixels) * 100 : 0,
|
||||||
|
diffImagePath,
|
||||||
|
baseImagePath: basePath,
|
||||||
|
prImagePath: prPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImage(png: PNG, targetWidth: number, targetHeight: number): Uint8Array {
|
||||||
|
if (png.width === targetWidth && png.height === targetHeight) {
|
||||||
|
return png.data as unknown as Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Uint8Array(targetWidth * targetHeight * 4);
|
||||||
|
for (let y = 0; y < targetHeight; y++) {
|
||||||
|
for (let x = 0; x < targetWidth; x++) {
|
||||||
|
const targetIdx = (y * targetWidth + x) * 4;
|
||||||
|
if (x < png.width && y < png.height) {
|
||||||
|
const sourceIdx = (y * png.width + x) * 4;
|
||||||
|
data[targetIdx] = png.data[sourceIdx];
|
||||||
|
data[targetIdx + 1] = png.data[sourceIdx + 1];
|
||||||
|
data[targetIdx + 2] = png.data[sourceIdx + 2];
|
||||||
|
data[targetIdx + 3] = png.data[sourceIdx + 3];
|
||||||
|
} else {
|
||||||
|
// Transparent padding
|
||||||
|
data[targetIdx + 3] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a text-only markdown summary for the PR comment. */
|
||||||
|
export function generateMarkdownReport(results: ComparisonResult[]): string {
|
||||||
|
const changed = results.filter((r) => r.changePercent > 0.1);
|
||||||
|
const unchanged = results.filter((r) => r.changePercent <= 0.1);
|
||||||
|
|
||||||
|
if (changed.length === 0) {
|
||||||
|
return '## Visual Review\n\nNo visual changes detected in the affected pages.';
|
||||||
|
}
|
||||||
|
|
||||||
|
let md = '## Visual Review\n\n';
|
||||||
|
md += `Found **${changed.length}** page(s) with visual changes`;
|
||||||
|
if (unchanged.length > 0) {
|
||||||
|
md += ` (${unchanged.length} unchanged)`;
|
||||||
|
}
|
||||||
|
md += '.\n\n';
|
||||||
|
|
||||||
|
md += '| Page | Status | Change |\n';
|
||||||
|
md += '|------|--------|--------|\n';
|
||||||
|
|
||||||
|
for (const result of changed) {
|
||||||
|
if (result.baseExists && result.prExists) {
|
||||||
|
md += `| ${result.name} | Changed | ${result.changePercent.toFixed(1)}% |\n`;
|
||||||
|
} else if (result.prExists) {
|
||||||
|
md += `| ${result.name} | New | - |\n`;
|
||||||
|
} else {
|
||||||
|
md += `| ${result.name} | Removed | - |\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
md += '\n';
|
||||||
|
|
||||||
|
if (unchanged.length > 0) {
|
||||||
|
md += '<details>\n<summary>Unchanged pages</summary>\n\n';
|
||||||
|
for (const result of unchanged) {
|
||||||
|
md += `- ${result.name}\n`;
|
||||||
|
}
|
||||||
|
md += '\n</details>\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imgTag(filePath: string | null, alt: string): string {
|
||||||
|
if (!filePath || !existsSync(filePath)) {
|
||||||
|
return `<div class="no-image">${alt} not available</div>`;
|
||||||
|
}
|
||||||
|
const data = readFileSync(filePath);
|
||||||
|
return `<img src="data:image/png;base64,${data.toString('base64')}" alt="${alt}" loading="lazy" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate an HTML report with embedded base64 images for the artifact. */
|
||||||
|
export function generateHtmlReport(results: ComparisonResult[]): string {
|
||||||
|
const changed = results.filter((r) => r.changePercent > 0.1);
|
||||||
|
const unchanged = results.filter((r) => r.changePercent <= 0.1);
|
||||||
|
|
||||||
|
let html = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Visual Review</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||||
|
background: #0d1117; color: #e6edf3; padding: 32px; line-height: 1.5; }
|
||||||
|
.container { max-width: 1800px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 24px; border-bottom: 1px solid #30363d; padding-bottom: 12px; margin-bottom: 24px; }
|
||||||
|
.summary { color: #8b949e; margin-bottom: 32px; font-size: 16px; }
|
||||||
|
.scenario { margin-bottom: 40px; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
|
||||||
|
.scenario-header { background: #161b22; padding: 12px 16px; display: flex; align-items: center; gap: 12px; }
|
||||||
|
.scenario-header h2 { font-size: 16px; font-weight: 600; }
|
||||||
|
.badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||||
|
.badge-changed { background: #da363380; color: #f85149; }
|
||||||
|
.badge-new { background: #1f6feb80; color: #58a6ff; }
|
||||||
|
.badge-removed { background: #6e767e80; color: #8b949e; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1px; background: #30363d; }
|
||||||
|
.grid-cell { background: #0d1117; }
|
||||||
|
.grid-label { text-align: center; padding: 8px; font-size: 13px; color: #8b949e; font-weight: 600;
|
||||||
|
background: #161b22; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.grid-cell img { width: 100%; display: block; }
|
||||||
|
.no-image { padding: 40px; text-align: center; color: #484f58; font-style: italic; }
|
||||||
|
.unchanged-section { margin-top: 32px; color: #8b949e; }
|
||||||
|
.unchanged-section summary { cursor: pointer; font-size: 14px; }
|
||||||
|
.unchanged-section ul { margin-top: 8px; padding-left: 24px; }
|
||||||
|
.unchanged-section li { font-size: 14px; margin: 4px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Visual Review</h1>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (changed.length === 0) {
|
||||||
|
html += '<p class="summary">No visual changes detected in the affected pages.</p>';
|
||||||
|
} else {
|
||||||
|
html += `<p class="summary">Found <strong>${changed.length}</strong> page(s) with visual changes`;
|
||||||
|
if (unchanged.length > 0) {
|
||||||
|
html += ` (${unchanged.length} unchanged)`;
|
||||||
|
}
|
||||||
|
html += '.</p>\n';
|
||||||
|
|
||||||
|
for (const result of changed) {
|
||||||
|
html += '<div class="scenario">\n<div class="scenario-header">\n';
|
||||||
|
html += `<h2>${result.name}</h2>\n`;
|
||||||
|
|
||||||
|
if (!result.baseExists) {
|
||||||
|
html += '<span class="badge badge-new">New</span>\n';
|
||||||
|
html += '</div>\n';
|
||||||
|
html += `<div style="padding: 16px;">${imgTag(result.prImagePath, 'PR')}</div>\n`;
|
||||||
|
html += '</div>\n';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.prExists) {
|
||||||
|
html += '<span class="badge badge-removed">Removed</span>\n';
|
||||||
|
html += '</div>\n</div>\n';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<span class="badge badge-changed">${result.changePercent.toFixed(1)}% changed</span>\n`;
|
||||||
|
html += '</div>\n';
|
||||||
|
html += '<div class="grid">\n';
|
||||||
|
html += `<div class="grid-cell"><div class="grid-label">Base</div>${imgTag(result.baseImagePath, 'Base')}</div>\n`;
|
||||||
|
html += `<div class="grid-cell"><div class="grid-label">PR</div>${imgTag(result.prImagePath, 'PR')}</div>\n`;
|
||||||
|
html += `<div class="grid-cell"><div class="grid-label">Diff</div>${imgTag(result.diffImagePath, 'Diff')}</div>\n`;
|
||||||
|
html += '</div>\n</div>\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unchanged.length > 0) {
|
||||||
|
html += '<div class="unchanged-section">\n<details>\n<summary>Unchanged pages</summary>\n<ul>\n';
|
||||||
|
for (const result of unchanged) {
|
||||||
|
html += `<li>${result.name}</li>\n`;
|
||||||
|
}
|
||||||
|
html += '</ul>\n</details>\n</div>\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>\n</body>\n</html>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI usage
|
||||||
|
if (process.argv[1]?.endsWith('compare.ts') || process.argv[1]?.endsWith('compare.js')) {
|
||||||
|
const [baseDir, prDir, outputDir] = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (!baseDir || !prDir || !outputDir) {
|
||||||
|
throw new Error('Usage: compare.ts <base-dir> <pr-dir> <output-dir>');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedOutputDir = resolve(outputDir);
|
||||||
|
const results = compareScreenshots(resolve(baseDir), resolve(prDir), resolvedOutputDir);
|
||||||
|
|
||||||
|
console.log('\nComparison Results:');
|
||||||
|
console.log('==================');
|
||||||
|
for (const r of results) {
|
||||||
|
const status = r.changePercent > 0.1 ? 'CHANGED' : 'unchanged';
|
||||||
|
console.log(` ${r.name}: ${status} (${r.changePercent.toFixed(1)}%)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = generateMarkdownReport(results);
|
||||||
|
const reportPath = join(resolvedOutputDir, 'report.md');
|
||||||
|
writeFileSync(reportPath, report);
|
||||||
|
console.log(`\nMarkdown report written to: ${reportPath}`);
|
||||||
|
|
||||||
|
const htmlReport = generateHtmlReport(results);
|
||||||
|
const htmlPath = join(resolvedOutputDir, 'visual-review.html');
|
||||||
|
writeFileSync(htmlPath, htmlReport);
|
||||||
|
console.log(`HTML report written to: ${htmlPath}`);
|
||||||
|
|
||||||
|
const jsonPath = join(resolvedOutputDir, 'results.json');
|
||||||
|
writeFileSync(jsonPath, JSON.stringify(results, null, 2));
|
||||||
|
console.log(`Results JSON written to: ${jsonPath}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Maps URL routes to screenshot scenario keys.
|
||||||
|
*
|
||||||
|
* Routes discovered by the dependency analyzer are matched against this map
|
||||||
|
* to determine which screenshot scenarios to run. Routes not in this map
|
||||||
|
* are skipped (they don't have a scenario defined yet).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ScenarioDefinition {
|
||||||
|
/** The URL path to navigate to */
|
||||||
|
url: string;
|
||||||
|
/** Human-readable name for the screenshot file */
|
||||||
|
name: string;
|
||||||
|
/** Which mock networks this scenario needs */
|
||||||
|
mocks: ('base' | 'timeline' | 'memory')[];
|
||||||
|
/** Optional: selector to wait for before screenshotting */
|
||||||
|
waitForSelector?: string;
|
||||||
|
/** Optional: time to wait after page load (ms) for animations to settle */
|
||||||
|
settleTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map from route paths (as output by analyze-deps) to scenario definitions.
|
||||||
|
* A single route might map to multiple scenarios (e.g., different states).
|
||||||
|
*/
|
||||||
|
export const PAGE_SCENARIOS: Record<string, ScenarioDefinition[]> = {
|
||||||
|
'/photos': [
|
||||||
|
{
|
||||||
|
url: '/photos',
|
||||||
|
name: 'photos-timeline',
|
||||||
|
mocks: ['base', 'timeline'],
|
||||||
|
waitForSelector: '[data-thumbnail-focus-container]',
|
||||||
|
settleTime: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/albums': [
|
||||||
|
{
|
||||||
|
url: '/albums',
|
||||||
|
name: 'albums-list',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/explore': [
|
||||||
|
{
|
||||||
|
url: '/explore',
|
||||||
|
name: 'explore',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/favorites': [
|
||||||
|
{
|
||||||
|
url: '/favorites',
|
||||||
|
name: 'favorites',
|
||||||
|
mocks: ['base', 'timeline'],
|
||||||
|
waitForSelector: '#asset-grid',
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/archive': [
|
||||||
|
{
|
||||||
|
url: '/archive',
|
||||||
|
name: 'archive',
|
||||||
|
mocks: ['base', 'timeline'],
|
||||||
|
waitForSelector: '#asset-grid',
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/trash': [
|
||||||
|
{
|
||||||
|
url: '/trash',
|
||||||
|
name: 'trash',
|
||||||
|
mocks: ['base', 'timeline'],
|
||||||
|
waitForSelector: '#asset-grid',
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/people': [
|
||||||
|
{
|
||||||
|
url: '/people',
|
||||||
|
name: 'people',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/sharing': [
|
||||||
|
{
|
||||||
|
url: '/sharing',
|
||||||
|
name: 'sharing',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/search': [
|
||||||
|
{
|
||||||
|
url: '/search',
|
||||||
|
name: 'search',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/memory': [
|
||||||
|
{
|
||||||
|
url: '/memory',
|
||||||
|
name: 'memory',
|
||||||
|
mocks: ['base', 'memory'],
|
||||||
|
settleTime: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/user-settings': [
|
||||||
|
{
|
||||||
|
url: '/user-settings',
|
||||||
|
name: 'user-settings',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/map': [
|
||||||
|
{
|
||||||
|
url: '/map',
|
||||||
|
name: 'map',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/admin': [
|
||||||
|
{
|
||||||
|
url: '/admin',
|
||||||
|
name: 'admin-dashboard',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/admin/system-settings': [
|
||||||
|
{
|
||||||
|
url: '/admin/system-settings',
|
||||||
|
name: 'admin-system-settings',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/admin/users': [
|
||||||
|
{
|
||||||
|
url: '/admin/users',
|
||||||
|
name: 'admin-users',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/auth/login': [
|
||||||
|
{
|
||||||
|
url: '/auth/login',
|
||||||
|
name: 'login',
|
||||||
|
mocks: [],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/': [
|
||||||
|
{
|
||||||
|
url: '/',
|
||||||
|
name: 'landing',
|
||||||
|
mocks: [],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Given a list of routes from the analyzer, return the matching scenarios. */
|
||||||
|
export function getScenariosForRoutes(routes: string[]): ScenarioDefinition[] {
|
||||||
|
const scenarios: ScenarioDefinition[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
const defs = PAGE_SCENARIOS[route];
|
||||||
|
if (defs) {
|
||||||
|
for (const def of defs) {
|
||||||
|
if (!seen.has(def.name)) {
|
||||||
|
seen.add(def.name);
|
||||||
|
scenarios.push(def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scenarios;
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Playwright script to capture screenshots for visual diff scenarios.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx playwright test --config e2e/playwright.screenshot.config.ts
|
||||||
|
*
|
||||||
|
* Environment variables:
|
||||||
|
* SCREENSHOT_SCENARIOS - JSON array of scenario names to run (from page-map.ts)
|
||||||
|
* If not set, runs all scenarios.
|
||||||
|
* SCREENSHOT_OUTPUT_DIR - Directory to save screenshots to. Defaults to e2e/screenshots-output.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { MemoryResponseDto } from '@immich/sdk';
|
||||||
|
import { test } from '@playwright/test';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { generateMemoriesFromTimeline } from 'src/ui/generators/memory';
|
||||||
|
import {
|
||||||
|
createDefaultTimelineConfig,
|
||||||
|
generateTimelineData,
|
||||||
|
type TimelineAssetConfig,
|
||||||
|
type TimelineData,
|
||||||
|
} from 'src/ui/generators/timeline';
|
||||||
|
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||||
|
import { setupMemoryMockApiRoutes } from 'src/ui/mock-network/memory-network';
|
||||||
|
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||||
|
import { PAGE_SCENARIOS, type ScenarioDefinition } from './page-map';
|
||||||
|
|
||||||
|
const OUTPUT_DIR = process.env.SCREENSHOT_OUTPUT_DIR || resolve(import.meta.dirname, '../../../screenshots-output');
|
||||||
|
const SCENARIO_FILTER: string[] | null = process.env.SCREENSHOT_SCENARIOS
|
||||||
|
? JSON.parse(process.env.SCREENSHOT_SCENARIOS)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Collect scenarios to run
|
||||||
|
const allScenarios: ScenarioDefinition[] = [];
|
||||||
|
for (const defs of Object.values(PAGE_SCENARIOS)) {
|
||||||
|
for (const def of defs) {
|
||||||
|
if (!SCENARIO_FILTER || SCENARIO_FILTER.includes(def.name)) {
|
||||||
|
allScenarios.push(def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a fixed seed so screenshots are deterministic across runs
|
||||||
|
faker.seed(42);
|
||||||
|
|
||||||
|
let adminUserId: string;
|
||||||
|
let timelineData: TimelineData;
|
||||||
|
let timelineAssets: TimelineAssetConfig[];
|
||||||
|
let memories: MemoryResponseDto[];
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
adminUserId = faker.string.uuid();
|
||||||
|
timelineData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||||
|
|
||||||
|
timelineAssets = [];
|
||||||
|
for (const timeBucket of timelineData.buckets.values()) {
|
||||||
|
timelineAssets.push(...timeBucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
memories = generateMemoriesFromTimeline(
|
||||||
|
timelineAssets,
|
||||||
|
adminUserId,
|
||||||
|
[
|
||||||
|
{ year: 2024, assetCount: 3 },
|
||||||
|
{ year: 2023, assetCount: 2 },
|
||||||
|
],
|
||||||
|
42,
|
||||||
|
);
|
||||||
|
|
||||||
|
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const scenario of allScenarios) {
|
||||||
|
test(`Screenshot: ${scenario.name}`, async ({ context, page }) => {
|
||||||
|
// Set up mocks based on scenario requirements
|
||||||
|
if (scenario.mocks.includes('base')) {
|
||||||
|
await setupBaseMockApiRoutes(context, adminUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scenario.mocks.includes('timeline')) {
|
||||||
|
const testContext = new TimelineTestContext();
|
||||||
|
testContext.adminId = adminUserId;
|
||||||
|
await setupTimelineMockApiRoutes(
|
||||||
|
context,
|
||||||
|
timelineData,
|
||||||
|
{
|
||||||
|
albumAdditions: [],
|
||||||
|
assetDeletions: [],
|
||||||
|
assetArchivals: [],
|
||||||
|
assetFavorites: [],
|
||||||
|
},
|
||||||
|
testContext,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scenario.mocks.includes('memory')) {
|
||||||
|
await setupMemoryMockApiRoutes(context, memories, {
|
||||||
|
memoryDeletions: [],
|
||||||
|
assetRemovals: new Map(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the page. Use networkidle so SvelteKit hydrates and API
|
||||||
|
// calls complete, but fall back to domcontentloaded if it times out
|
||||||
|
// (e.g. a persistent connection the catch-all mock didn't cover).
|
||||||
|
try {
|
||||||
|
await page.goto(scenario.url, { waitUntil: 'networkidle', timeout: 15_000 });
|
||||||
|
} catch {
|
||||||
|
console.warn(`networkidle timed out for ${scenario.name}, falling back to current state`);
|
||||||
|
// Page has already navigated, just continue with what we have
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for specific selector if specified
|
||||||
|
if (scenario.waitForSelector) {
|
||||||
|
try {
|
||||||
|
await page.waitForSelector(scenario.waitForSelector, { timeout: 15_000 });
|
||||||
|
} catch {
|
||||||
|
console.warn(`Selector ${scenario.waitForSelector} not found for ${scenario.name}, continuing...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for loading spinners to disappear
|
||||||
|
await page
|
||||||
|
.waitForFunction(() => document.querySelectorAll('[data-testid="loading-spinner"]').length === 0, {
|
||||||
|
timeout: 10_000,
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Wait for animations/transitions to settle
|
||||||
|
await page.waitForTimeout(scenario.settleTime ?? 500);
|
||||||
|
|
||||||
|
// Take the screenshot
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(OUTPUT_DIR, `${scenario.name}.png`),
|
||||||
|
fullPage: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,6 +10,27 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
|||||||
path: '/',
|
path: '/',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Block socket.io connections — these are persistent WebSocket connections
|
||||||
|
// that prevent networkidle from resolving since there's no real server.
|
||||||
|
await context.route('**/api/socket.io**', async (route) => {
|
||||||
|
return route.abort('connectionrefused');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catch-all for any /api/ endpoint not explicitly mocked below.
|
||||||
|
// Registered FIRST so specific routes (registered after) take priority
|
||||||
|
// (Playwright checks routes in reverse registration order).
|
||||||
|
// Without this, unmocked API calls hit the static preview server which
|
||||||
|
// either hangs or returns HTML, preventing networkidle and causing timeouts.
|
||||||
|
await context.route('**/api/**', async (route) => {
|
||||||
|
const method = route.request().method();
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: method === 'GET' ? [] : {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await context.route('**/api/users/me', async (route) => {
|
await context.route('**/api/users/me', async (route) => {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
Generated
+3
@@ -276,6 +276,9 @@ importers:
|
|||||||
supertest:
|
supertest:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.2.2
|
version: 7.2.2
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.21.0
|
||||||
|
version: 4.21.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.3.3
|
specifier: ^5.3.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|||||||
@@ -50,7 +50,10 @@
|
|||||||
|
|
||||||
<svelte:window bind:innerWidth />
|
<svelte:window bind:innerWidth />
|
||||||
|
|
||||||
<nav id="dashboard-navbar" class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm">
|
<nav
|
||||||
|
id="dashboard-navbar"
|
||||||
|
class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm bg-red-50 dark:bg-red-950"
|
||||||
|
>
|
||||||
<SkipLink text={$t('skip_to_content')} />
|
<SkipLink text={$t('skip_to_content')} />
|
||||||
<div
|
<div
|
||||||
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
|
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
|
||||||
@@ -80,6 +83,7 @@
|
|||||||
<a data-sveltekit-preload-data="hover" href={Route.photos()}>
|
<a data-sveltekit-preload-data="hover" href={Route.photos()}>
|
||||||
<Logo variant={mediaQueryManager.isFullSidebar ? 'inline' : 'icon'} class="max-md:h-12" />
|
<Logo variant={mediaQueryManager.isFullSidebar ? 'inline' : 'icon'} class="max-md:h-12" />
|
||||||
</a>
|
</a>
|
||||||
|
<span class="text-xs font-bold text-red-500 ms-2">[VISUAL TEST]</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between gap-4 lg:gap-8 pe-6">
|
<div class="flex justify-between gap-4 lg:gap-8 pe-6">
|
||||||
<div class="hidden w-full max-w-5xl flex-1 tall:ps-0 sm:block">
|
<div class="hidden w-full max-w-5xl flex-1 tall:ps-0 sm:block">
|
||||||
|
|||||||
Reference in New Issue
Block a user