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 = ` + +
+Nested shadow root content
+This page combines all major asset discovery features into a single snapshot.
+ + +Static text content with bold and italic formatting.
+
+ This text uses a custom web font loaded via @font-face.
+This content is slotted into a shadow DOM component.
++ This iframe is loaded from a different origin (port 9101). + Percy's CORS iframe processing should handle serialization. +
+This page loads a stylesheet from http://localhost:9101/css/base.css.
Percy must discover and capture this cross-origin resource via allowedHostnames.
+
+ Text using a font loaded from the CORS server.
+This section has a style tag using @import to load an external stylesheet.
++ The quick brown fox jumps over the lazy dog. 0123456789 +
++ The quick brown fox jumps over the lazy dog. 0123456789 +
++ This font is served with content-type: text/html. Percy should detect it via magic bytes. +
+Fallback system serif font for comparison.
+This content is loaded from a separate HTML file via same-origin iframe.
+
+
+
+
+
+ Loading...
+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 @@ + + + + + +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.
+
+ This image is loaded from /redirect/image.png which returns a 301 redirect to /assets/images/logo.png.
This page is snapshotted at 375px, 768px, and 1280px widths.
+ + + + + +First grid item. Layout changes at breakpoints.
+Second grid item. Check column count per width.
+Third grid item. 3 cols on desktop, 2 on tablet, 1 on mobile.
+Fourth grid item. Visual diff catches layout regressions.
+Fifth grid item. Each width is a separate discovery cycle.
+Sixth grid item. Resources are cached across widths.
+This paragraph is slotted into the shadow DOM.
+Multiple slotted children should be serialized.
+This card uses a dark theme set via attribute.
+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]