diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml new file mode 100644 index 000000000..8fc99b39b --- /dev/null +++ b/.github/workflows/regression.yml @@ -0,0 +1,109 @@ +name: Regression Tests + +on: + # Trigger via PR comment: "/regression" + issue_comment: + types: [created] + # Also run on push to master for baseline updates + push: + branches: [master] + # Allow manual re-runs (useful for baseline setup) + workflow_dispatch: + +# Explicit minimal permissions (fix: workflow missing permissions) +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + regression: + runs-on: ubuntu-latest + timeout-minutes: 15 + # For issue_comment: only run on PR comments matching "/regression" + # For push: always run on master + # For workflow_dispatch: always run + # NOTE: issue_comment trigger only works when this workflow file exists on the default branch (master). + # You must merge this file to master first before /regression comments will trigger runs. + if: >- + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + contains(github.event.comment.body, '/regression')) + steps: + # Permission check for comment-triggered runs + - name: Check commenter permissions + if: github.event_name == 'issue_comment' + uses: actions/github-script@v4 + id: check-access + with: + script: | + const { owner, repo } = context.repo; + const { login } = context.payload.comment.user; + const { data } = await github.repos.getCollaboratorPermissionLevel({ owner, repo, username: login }); + if (data.permission !== 'write' && data.permission !== 'admin') { + core.setFailed(`User ${login} does not have write access`); + } + result-encoding: string + + # Resolve immutable PR head SHA for comment-triggered runs + # (fix: TOCTOU — use head_sha not head_ref to prevent code changes between permission check and execution) + - name: Get PR head SHA + if: github.event_name == 'issue_comment' + uses: actions/github-script@v4 + id: pr-sha + with: + script: | + const { owner, repo } = context.repo; + const { data: pr } = await github.pulls.get({ + owner, + repo, + pull_number: context.issue.number + }); + core.setOutput('sha', pr.head.sha); + result-encoding: string + + - uses: actions/checkout@v5 + with: + # Use immutable PR commit SHA for comment triggers (prevents TOCTOU), + # default ref for push/dispatch + ref: ${{ steps.pr-sha.outputs.sha || github.sha }} + + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: yarn + + - uses: actions/cache@v3 + with: + path: | + packages/core/.local-chromium + key: chromium-${{ runner.os }}-${{ hashFiles('packages/core/package.json') }} + + - run: yarn install --frozen-lockfile + - run: yarn build + + - name: Install browser dependencies + run: sudo apt-get install -y libgbm-dev + + - name: Run regression tests + run: yarn test:regression + env: + PERCY_TOKEN: ${{ secrets.PERCY_REGRESSION_TOKEN }} + + # Post result back to PR for comment-triggered runs + - name: Comment result on PR + if: github.event_name == 'issue_comment' && always() + uses: actions/github-script@v4 + with: + script: | + const status = '${{ job.status }}'; + const emoji = status === 'success' ? '✅' : '❌'; + const body = `${emoji} **Regression tests ${status}**\n\n[View run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`; + github.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); diff --git a/.gitignore b/.gitignore index b68953490..1aca20b24 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ oclif.manifest.json packages/logger/test/client.js packages/sdk-utils/test/client.js packs +docs/ \ No newline at end of file diff --git a/package.json b/package.json index bb53958d6..b82e83328 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "test:coverage": "lerna run --stream --concurrency=1 --no-bail test:coverage", "test:types": "lerna run --parallel test:types", "global:link": "lerna exec -- yarn link", - "global:unlink": "lerna exec -- yarn unlink" + "global:unlink": "lerna exec -- yarn unlink", + "test:regression": "node test/regression/regression.test.js" }, "devDependencies": { "@babel/cli": "^7.11.6", diff --git a/test/regression/.percy.yml b/test/regression/.percy.yml new file mode 100644 index 000000000..c4d6bcf16 --- /dev/null +++ b/test/regression/.percy.yml @@ -0,0 +1,17 @@ +version: 2 +snapshot: + widths: [1280] + minHeight: 1024 + percyCSS: | + *, *::before, *::after { + animation-duration: 0s !important; + transition-duration: 0s !important; + } + * { caret-color: transparent !important; cursor: none !important; } +discovery: + allowedHostnames: + - localhost:9101 + networkIdleTimeout: 150 + concurrency: 5 + launchOptions: + timeout: 120000 diff --git a/test/regression/README.md b/test/regression/README.md new file mode 100644 index 000000000..8905b9a1c --- /dev/null +++ b/test/regression/README.md @@ -0,0 +1,47 @@ +# Percy CLI E2E Regression Tests + +Visual regression tests that upload real snapshots to Percy, covering all asset discovery features. + +## Setup + +1. Create a Percy project at [percy.io](https://percy.io) named `percy-cli-regression` +2. Link it to the GitHub repo for VCS integration +3. Add `PERCY_REGRESSION_TOKEN` as a GitHub repository secret +4. Set the base branch to `master` in Percy project settings + +## Running Locally + +```bash +# Requires PERCY_TOKEN — skips gracefully without it +PERCY_TOKEN=your_token_here yarn test:regression +``` + +## How It Works + +1. Starts two local servers: main (port 9100) and CORS (port 9101) +2. Runs `percy snapshot` against all pages defined in `snapshots.yml` +3. Percy uploads snapshots and creates a build +4. Visual diffs are reviewed in the Percy dashboard +5. Percy's GitHub VCS integration gates PRs via checks + +## Adding a New Test + +1. Create a new HTML page in `pages/` +2. Add an entry to `snapshots.yml` +3. (Optional) Add assets to `assets/` +4. (Optional) Add special server routes to `server.js` + +No changes to `regression.test.js` needed. + +## Configuration + +- `snapshots.yml` — Snapshot definitions (URLs, names, per-snapshot options) +- `.percy.yml` — Percy project config (discovery settings, anti-flakiness CSS) + +Each snapshot entry supports all Percy options: `widths`, `enableJavaScript`, `discovery.allowedHostnames`, `waitForSelector`, `execute`, `percyCSS`, etc. + +## CI + +Runs automatically on PRs and pushes to master via `.github/workflows/regression.yml` (Linux only). + +**Important:** Never hardcode `PERCY_TOKEN` in any committed file. Always use environment variables. diff --git a/test/regression/assets/css/base.css b/test/regression/assets/css/base.css new file mode 100644 index 000000000..d19686e03 --- /dev/null +++ b/test/regression/assets/css/base.css @@ -0,0 +1,47 @@ +/* Base styles for regression test pages */ +body { + font-family: sans-serif; + margin: 0; + padding: 20px; + background: #fff; + color: #333; +} + +h1 { + font-size: 24px; + margin-bottom: 16px; +} + +h2 { + font-size: 18px; + margin-bottom: 12px; + color: #555; +} + +.section { + margin-bottom: 24px; + padding: 16px; + border: 1px solid #ddd; + border-radius: 4px; +} + +.section-title { + font-weight: bold; + margin-bottom: 8px; +} + +img { + max-width: 100%; + height: auto; +} + +/* Pseudo-class rules (tested in comprehensive.html) */ +.hover-target:hover { + background-color: #e0e0ff; + border-color: #3333ff; +} + +.focus-target:focus { + outline: 3px solid #3333ff; + box-shadow: 0 0 5px rgba(51, 51, 255, 0.5); +} diff --git a/test/regression/assets/css/responsive.css b/test/regression/assets/css/responsive.css new file mode 100644 index 000000000..d1675aace --- /dev/null +++ b/test/regression/assets/css/responsive.css @@ -0,0 +1,48 @@ +/* Responsive styles — visually distinct at each breakpoint */ +.responsive-container { + padding: 20px; + text-align: center; +} + +.responsive-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(3, 1fr); +} + +.responsive-card { + padding: 20px; + background: #f0f4ff; + border: 2px solid #3366cc; + border-radius: 8px; +} + +.breakpoint-indicator { + font-size: 24px; + font-weight: bold; + padding: 12px; + text-align: center; + border-radius: 4px; +} + +/* Desktop: 3 columns, blue indicator */ +.breakpoint-indicator { background: #ddeeff; color: #003366; } +.breakpoint-indicator::after { content: "Desktop (1280px)"; } + +/* Tablet: 2 columns, green indicator */ +@media (max-width: 768px) { + .responsive-grid { + grid-template-columns: repeat(2, 1fr); + } + .breakpoint-indicator { background: #ddf5dd; color: #003300; } + .breakpoint-indicator::after { content: "Tablet (768px)"; } +} + +/* Mobile: 1 column, orange indicator */ +@media (max-width: 375px) { + .responsive-grid { + grid-template-columns: 1fr; + } + .breakpoint-indicator { background: #ffedcc; color: #663300; } + .breakpoint-indicator::after { content: "Mobile (375px)"; } +} diff --git a/test/regression/assets/fonts/test-font.woff b/test/regression/assets/fonts/test-font.woff new file mode 100644 index 000000000..b04838531 Binary files /dev/null and b/test/regression/assets/fonts/test-font.woff differ diff --git a/test/regression/assets/fonts/test-font.woff2 b/test/regression/assets/fonts/test-font.woff2 new file mode 100644 index 000000000..1e6c05a67 Binary files /dev/null and b/test/regression/assets/fonts/test-font.woff2 differ diff --git a/test/regression/assets/images/icon.svg b/test/regression/assets/images/icon.svg new file mode 100644 index 000000000..2deeb5fac --- /dev/null +++ b/test/regression/assets/images/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/regression/assets/images/logo.png b/test/regression/assets/images/logo.png new file mode 100644 index 000000000..0f2de3749 Binary files /dev/null and b/test/regression/assets/images/logo.png differ diff --git a/test/regression/assets/images/photo.jpg b/test/regression/assets/images/photo.jpg new file mode 100644 index 000000000..76f9dbfd2 Binary files /dev/null and b/test/regression/assets/images/photo.jpg differ diff --git a/test/regression/assets/images/picture-lg.jpg b/test/regression/assets/images/picture-lg.jpg new file mode 100644 index 000000000..76f9dbfd2 Binary files /dev/null and b/test/regression/assets/images/picture-lg.jpg differ diff --git a/test/regression/assets/images/picture-sm.jpg b/test/regression/assets/images/picture-sm.jpg new file mode 100644 index 000000000..76f9dbfd2 Binary files /dev/null and b/test/regression/assets/images/picture-sm.jpg differ diff --git a/test/regression/assets/images/poster.jpg b/test/regression/assets/images/poster.jpg new file mode 100644 index 000000000..76f9dbfd2 Binary files /dev/null and b/test/regression/assets/images/poster.jpg differ diff --git a/test/regression/assets/js/custom-element.js b/test/regression/assets/js/custom-element.js new file mode 100644 index 000000000..2522c48c9 --- /dev/null +++ b/test/regression/assets/js/custom-element.js @@ -0,0 +1,77 @@ +/* eslint-env browser */ +// Custom element for regression testing +// Tests: shadow DOM, scoped styles, attributeChangedCallback, slotted content + +class PercyTestCard extends HTMLElement { + static get observedAttributes() { + return ['title', 'theme']; + } + + constructor() { + super(); + const shadow = this.attachShadow({ mode: 'open' }); + shadow.innerHTML = ` + +
+
+ +
+ `; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name === 'title') { + const titleEl = this.shadowRoot.querySelector('.card-title'); + if (titleEl) titleEl.textContent = newValue; + } + } + + connectedCallback() { + const title = this.getAttribute('title'); + if (title) { + this.shadowRoot.querySelector('.card-title').textContent = title; + } + } +} + +class PercyNestedShadow extends HTMLElement { + constructor() { + super(); + const shadow = this.attachShadow({ mode: 'open' }); + shadow.innerHTML = ` + +

Nested shadow root content

+ + Content inside nested shadow DOM + + `; + } +} + +customElements.define('percy-test-card', PercyTestCard); +customElements.define('percy-nested-shadow', PercyNestedShadow); diff --git a/test/regression/pages/canvas.html b/test/regression/pages/canvas.html new file mode 100644 index 000000000..e8d7634b8 --- /dev/null +++ b/test/regression/pages/canvas.html @@ -0,0 +1,53 @@ + + + + + + Canvas Regression Test + + + +

Canvas

+ + +
+
2D Canvas
+ +
+ + +
+
WebGL Canvas
+ +
+ + + + diff --git a/test/regression/pages/comprehensive.html b/test/regression/pages/comprehensive.html new file mode 100644 index 000000000..66f495e7f --- /dev/null +++ b/test/regression/pages/comprehensive.html @@ -0,0 +1,96 @@ + + + + + + Comprehensive Regression Test + + + + +

Comprehensive Smoke Test

+

This page combines all major asset discovery features into a single snapshot.

+ + +
+
Basic HTML + CSS
+

Static text content with bold and italic formatting.

+
+ + +
+
Image
+ Test logo +
+ + +
+
Web Font
+

This text uses a custom web font loaded via @font-face.

+
+ + +
+
Shadow DOM Component
+ +

This content is slotted into a shadow DOM component.

+
+
+ + +
+
Canvas
+ +
+ + +
+
Video Poster
+ +
+ + +
+
Base64 Inline Image
+ Inline base64 +
+ + +
+
Pseudo-class CSS
+ + +
+ + +
+
SVG Image
+ SVG icon +
+ + + + + diff --git a/test/regression/pages/cors-iframes.html b/test/regression/pages/cors-iframes.html new file mode 100644 index 000000000..2a7ca2acc --- /dev/null +++ b/test/regression/pages/cors-iframes.html @@ -0,0 +1,22 @@ + + + + + + CORS Iframes Regression Test + + + +

CORS Iframes

+ + +
+
Cross-origin Iframe (port 9101)
+ +

+ This iframe is loaded from a different origin (port 9101). + Percy's CORS iframe processing should handle serialization. +

+
+ + diff --git a/test/regression/pages/cors-resources.html b/test/regression/pages/cors-resources.html new file mode 100644 index 000000000..9862aa6ea --- /dev/null +++ b/test/regression/pages/cors-resources.html @@ -0,0 +1,42 @@ + + + + + + CORS Resources Regression Test + + + + + + +

CORS Resources

+ + +
+
Cross-origin CSS
+

This page loads a stylesheet from http://localhost:9101/css/base.css.

+

Percy must discover and capture this cross-origin resource via allowedHostnames.

+
+ + +
+
Cross-origin Image
+ Cross-origin image +
+ + +
+
Cross-origin Font
+ +

Text using a font loaded from the CORS server.

+
+ + diff --git a/test/regression/pages/cssom.html b/test/regression/pages/cssom.html new file mode 100644 index 000000000..b20af1a97 --- /dev/null +++ b/test/regression/pages/cssom.html @@ -0,0 +1,76 @@ + + + + + + CSSOM Regression Test + + + + +

CSSOM

+ + +
+
insertRule (dynamic CSSOM)
+
+ This element is styled by a rule injected via document.styleSheets[].insertRule() +
+
+ + +
+
Adopted Stylesheets
+
+ This element is styled via document.adoptedStyleSheets (Constructable Stylesheets API). +
+
+ + +
+
CSS @import
+ +

This section has a style tag using @import to load an external stylesheet.

+
+ + +
+
JS-modified Style
+
+ This element's background is set by JavaScript after page load. +
+
+ + + + diff --git a/test/regression/pages/fonts.html b/test/regression/pages/fonts.html new file mode 100644 index 000000000..e5028922c --- /dev/null +++ b/test/regression/pages/fonts.html @@ -0,0 +1,68 @@ + + + + + + Fonts Regression Test + + + + +

Fonts

+ +
+
Local Font (woff2 from main server)
+

+ The quick brown fox jumps over the lazy dog. 0123456789 +

+
+ +
+
Cross-origin Font (from port 9101)
+

+ The quick brown fox jumps over the lazy dog. 0123456789 +

+
+ +
+
Wrong MIME Type Font (server returns text/html)
+

+ This font is served with content-type: text/html. Percy should detect it via magic bytes. +

+
+ +
+
Font loaded via CSS @import
+ +

Fallback system serif font for comparison.

+
+ + diff --git a/test/regression/pages/iframe-content.html b/test/regression/pages/iframe-content.html new file mode 100644 index 000000000..4da58fbee --- /dev/null +++ b/test/regression/pages/iframe-content.html @@ -0,0 +1,17 @@ + + + + + Iframe Content + + + +

Same-origin Iframe

+

This content is loaded from a separate HTML file via same-origin iframe.

+ Icon in iframe + + diff --git a/test/regression/pages/iframes.html b/test/regression/pages/iframes.html new file mode 100644 index 000000000..998f9b595 --- /dev/null +++ b/test/regression/pages/iframes.html @@ -0,0 +1,51 @@ + + + + + + Iframes Regression Test + + + +

Iframes

+ + +
+
Same-origin Iframe
+ +
+ + +
+
Nested Iframe
+ +
+ + +
+
srcdoc Iframe
+ +
+ + +
+
JavaScript-created Iframe
+
+
+ + + + diff --git a/test/regression/pages/images.html b/test/regression/pages/images.html new file mode 100644 index 000000000..d60877808 --- /dev/null +++ b/test/regression/pages/images.html @@ -0,0 +1,62 @@ + + + + + + Images Regression Test + + + +

Images

+ + +
+
Basic PNG Image
+ Logo PNG +
+ + +
+
srcset Image
+ Responsive srcset image +
+ + +
+
Picture Element
+ + + + Picture element fallback + +
+ + +
+
Lazy-loaded Image
+ Lazy loaded photo +
+ + +
+
Base64 Inline Image
+ Base64 inline +
+ + +
+
SVG Image
+ SVG icon +
+ + +
+
JPEG Image
+ JPEG photo +
+ + diff --git a/test/regression/pages/js-enabled.html b/test/regression/pages/js-enabled.html new file mode 100644 index 000000000..0a4de0e1a --- /dev/null +++ b/test/regression/pages/js-enabled.html @@ -0,0 +1,55 @@ + + + + + + JavaScript Enabled Regression Test + + + +

JavaScript Enabled

+ + + + +
+

Loading...

+
+ + + + diff --git a/test/regression/pages/nested-iframe.html b/test/regression/pages/nested-iframe.html new file mode 100644 index 000000000..e9590a1d4 --- /dev/null +++ b/test/regression/pages/nested-iframe.html @@ -0,0 +1,16 @@ + + + + + Nested Iframe (Outer) + + + +

Outer Iframe

+

This iframe contains another iframe nested inside it.

+ + + diff --git a/test/regression/pages/redirects.html b/test/regression/pages/redirects.html new file mode 100644 index 000000000..b13bd9a14 --- /dev/null +++ b/test/regression/pages/redirects.html @@ -0,0 +1,27 @@ + + + + + + Redirects Regression Test + + + + +

Redirected Resources

+ + +
+
CSS via 302 Redirect
+

The stylesheet for this page is loaded from /redirect/style.css which returns a 302 redirect to /assets/css/base.css.

+

Percy should follow the redirect and capture the final resource.

+
+ + +
+
Image via 301 Redirect
+ Redirected image +

This image is loaded from /redirect/image.png which returns a 301 redirect to /assets/images/logo.png.

+
+ + diff --git a/test/regression/pages/responsive.html b/test/regression/pages/responsive.html new file mode 100644 index 000000000..171bf01db --- /dev/null +++ b/test/regression/pages/responsive.html @@ -0,0 +1,45 @@ + + + + + + Responsive Regression Test + + + + +

Responsive Layout

+

This page is snapshotted at 375px, 768px, and 1280px widths.

+ + +
+ + +
+
+

Card 1

+

First grid item. Layout changes at breakpoints.

+
+
+

Card 2

+

Second grid item. Check column count per width.

+
+
+

Card 3

+

Third grid item. 3 cols on desktop, 2 on tablet, 1 on mobile.

+
+
+

Card 4

+

Fourth grid item. Visual diff catches layout regressions.

+
+
+

Card 5

+

Fifth grid item. Each width is a separate discovery cycle.

+
+
+

Card 6

+

Sixth grid item. Resources are cached across widths.

+
+
+ + diff --git a/test/regression/pages/shadow-dom.html b/test/regression/pages/shadow-dom.html new file mode 100644 index 000000000..3fbbeda26 --- /dev/null +++ b/test/regression/pages/shadow-dom.html @@ -0,0 +1,83 @@ + + + + + + Shadow DOM Regression Test + + + +

Shadow DOM

+ + +
+
Basic Shadow Root
+
+
+ + +
+
Custom Element with Slots
+ +

This paragraph is slotted into the shadow DOM.

+

Multiple slotted children should be serialized.

+
+
+ + +
+
attributeChangedCallback
+ +

This card uses a dark theme set via attribute.

+
+
+ + +
+
Nested Shadow Roots
+ +
+ + +
+
Shadow DOM with External Resources
+
+
+ + + + + diff --git a/test/regression/regression.test.js b/test/regression/regression.test.js new file mode 100644 index 000000000..02f5dcf84 --- /dev/null +++ b/test/regression/regression.test.js @@ -0,0 +1,80 @@ +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { startServers, stopServers } from './server.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, '..', '..'); +const percyBin = join(repoRoot, 'packages', 'cli', 'bin', 'run.cjs'); + +// Graceful skip when PERCY_TOKEN is not set +if (!process.env.PERCY_TOKEN) { + console.log('Skipping regression tests (PERCY_TOKEN not set)'); + process.exit(0); +} + +async function run() { + console.log('Starting test servers...'); + await startServers(); + console.log('Main server listening on 127.0.0.1:9100'); + console.log('CORS server listening on 127.0.0.1:9101'); + + let stdout = ''; + let stderr = ''; + let exitCode; + + try { + exitCode = await new Promise((resolve, reject) => { + const child = spawn('node', [ + percyBin, + 'snapshot', + join(__dirname, 'snapshots.yml'), + '--base-url', 'http://localhost:9100', + '--config', join(__dirname, '.percy.yml') + ], { + cwd: repoRoot, + env: { ...process.env }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + child.stdout.on('data', (data) => { + const text = data.toString(); + stdout += text; + process.stdout.write(text); + }); + + child.stderr.on('data', (data) => { + const text = data.toString(); + stderr += text; + process.stderr.write(text); + }); + + child.on('error', reject); + child.on('close', (code) => resolve(code)); + }); + } finally { + console.log('\nStopping test servers...'); + await stopServers(); + } + + // Assertions + const output = stdout + stderr; + + if (exitCode !== 0) { + console.error(`\nREGRESSION TEST FAILED: percy snapshot exited with code ${exitCode}`); + process.exit(1); + } + + if (!output.includes('Finalized build')) { + console.error('\nREGRESSION TEST FAILED: output does not contain "Finalized build"'); + process.exit(1); + } + + console.log('\nREGRESSION TESTS PASSED'); + process.exit(0); +} + +run().catch((err) => { + console.error('Regression test runner error:', err); + stopServers().finally(() => process.exit(1)); +}); diff --git a/test/regression/server.js b/test/regression/server.js new file mode 100644 index 000000000..2c89912ac --- /dev/null +++ b/test/regression/server.js @@ -0,0 +1,177 @@ +import http from 'http'; +import { readFileSync, existsSync, statSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join, extname, resolve } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pagesDir = join(__dirname, 'pages'); +const assetsDir = join(__dirname, 'assets'); + +// MIME types for static file serving +const MIME_TYPES = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.mp4': 'video/mp4', + '.webm': 'video/webm' +}; + +// Pre-load static files used in special routes (no per-request readFileSync) +const wrongMimeFont = existsSync(join(assetsDir, 'fonts/test-font.woff2')) + ? readFileSync(join(assetsDir, 'fonts/test-font.woff2')) + : Buffer.alloc(0); + +// Special routes for the main server (add new entries to extend) +const mainRoutes = { + '/redirect/style.css': (req, res) => { + res.writeHead(302, { Location: '/assets/css/base.css' }); + res.end(); + }, + '/redirect/image.png': (req, res) => { + res.writeHead(301, { Location: '/assets/images/logo.png' }); + res.end(); + }, + '/fonts/wrong-mime.woff2': (req, res) => { + // Serves valid woff2 bytes with intentionally wrong MIME type + // Tests Percy's font MIME detection (magic byte sniffing) + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(wrongMimeFont); + } +}; + +function serveStaticFile(baseDir, urlPath, res, corsHeaders = {}) { + // Sanitize: strip query strings, decode URI, remove null bytes + const sanitized = decodeURIComponent(urlPath.split('?')[0]).replace(/\0/g, ''); + + // Resolve to absolute path and verify it's within baseDir (prevents path traversal) + const filePath = resolve(baseDir, sanitized); + const resolvedBase = resolve(baseDir); + + if (!filePath.startsWith(resolvedBase + '/') && filePath !== resolvedBase) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + + if (!existsSync(filePath) || !statSync(filePath).isFile()) { + res.writeHead(404); + res.end('Not Found'); + return; + } + + const ext = extname(filePath); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + const content = readFileSync(filePath); + + res.writeHead(200, { 'content-type': contentType, ...corsHeaders }); + res.end(content); +} + +function createMainServer() { + return http.createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const pathname = url.pathname; + + // Check special routes first + if (mainRoutes[pathname]) { + mainRoutes[pathname](req, res); + return; + } + + // Serve assets from /assets/ path + if (pathname.startsWith('/assets/')) { + const assetPath = pathname.replace('/assets/', ''); + serveStaticFile(assetsDir, assetPath, res); + return; + } + + // Serve pages from root path (e.g., /comprehensive.html → pages/comprehensive.html) + const pagePath = pathname === '/' ? 'comprehensive.html' : pathname.slice(1); + serveStaticFile(pagesDir, pagePath, res); + }); +} + +function createCorsServer() { + const corsAssetsDir = join(__dirname, 'assets'); + + return http.createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const pathname = url.pathname; + const corsHeaders = { 'Access-Control-Allow-Origin': '*' }; + + // Handle CORS preflight + if (req.method === 'OPTIONS') { + res.writeHead(204, { + ...corsHeaders, + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': '*' + }); + res.end(); + return; + } + + // Serve assets with CORS headers + if (pathname.startsWith('/css/') || pathname.startsWith('/images/') || + pathname.startsWith('/fonts/') || pathname.startsWith('/js/')) { + serveStaticFile(corsAssetsDir, pathname.slice(1), res, corsHeaders); + return; + } + + // Serve CORS iframe page + if (pathname === '/iframe-page.html') { + const content = ` +CORS Iframe +

Cross-origin iframe content served from port 9101

+`; + res.writeHead(200, { 'content-type': 'text/html', ...corsHeaders }); + res.end(content); + return; + } + + // Routes WITHOUT CORS headers (for testing blocked scenarios) + if (pathname === '/no-cors/image.png') { + serveStaticFile(corsAssetsDir, 'images/logo.png', res); + return; + } + + res.writeHead(404, corsHeaders); + res.end('Not Found'); + }); +} + +let mainServer; +let corsServer; + +export function startServers() { + return new Promise((resolve, reject) => { + mainServer = createMainServer(); + corsServer = createCorsServer(); + + let ready = 0; + const onReady = () => { + ready++; + if (ready === 2) resolve(); + }; + + mainServer.listen(9100, '127.0.0.1', onReady); + corsServer.listen(9101, '127.0.0.1', onReady); + + mainServer.on('error', reject); + corsServer.on('error', reject); + }); +} + +export function stopServers() { + return Promise.all([ + mainServer ? new Promise(r => mainServer.close(r)) : Promise.resolve(), + corsServer ? new Promise(r => corsServer.close(r)) : Promise.resolve() + ]); +} diff --git a/test/regression/snapshots.yml b/test/regression/snapshots.yml new file mode 100644 index 000000000..690b34481 --- /dev/null +++ b/test/regression/snapshots.yml @@ -0,0 +1,54 @@ +# Percy CLI E2E Regression Test Snapshots +# To add a new test: create an HTML page in pages/ and add an entry here. +# Each entry can override any Percy snapshot/discovery option. + +# Comprehensive smoke test — includes video poster, base64 inline image, pseudo-class CSS +- name: Comprehensive + url: /comprehensive.html + widths: [1280] + +# Individual feature pages +- name: Shadow DOM + url: /shadow-dom.html + widths: [1280] + +- name: Iframes + url: /iframes.html + widths: [1280] + +- name: CORS Iframes + url: /cors-iframes.html + widths: [1280] + +- name: CSSOM + url: /cssom.html + widths: [1280] + +- name: Fonts + url: /fonts.html + widths: [1280] + +- name: Canvas + url: /canvas.html + widths: [1280] + +- name: Images + url: /images.html + widths: [1280] + +- name: CORS Resources + url: /cors-resources.html + widths: [1280] + +- name: Redirected Resources + url: /redirects.html + widths: [1280] + +- name: JavaScript Enabled + url: /js-enabled.html + widths: [1280] + enableJavaScript: true + +- name: Responsive + url: /responsive.html + widths: [375, 768, 1280]