From 55338d018869b3cb9a6d049609452468cdae6c9b Mon Sep 17 00:00:00 2001 From: prklm10 Date: Sun, 15 Mar 2026 10:06:18 +0530 Subject: [PATCH 1/3] Add regression tests for various web features - Create HTML pages for testing canvas rendering, CORS iframes, CORS resources, CSSOM, fonts, images, iframes, and shadow DOM. - Implement a regression test runner using Node.js to start servers and capture snapshots with Percy. - Add support for handling redirects and serving static assets with appropriate MIME types. - Include tests for JavaScript-enabled rendering and responsive layouts. - Define snapshot configurations in a YAML file for easy management of test cases. --- .github/workflows/regression.yml | 88 ++++++++++ .gitignore | 1 + package.json | 3 +- test/regression/.percy.yml | 15 ++ test/regression/README.md | 47 +++++ test/regression/assets/css/base.css | 47 +++++ test/regression/assets/css/responsive.css | 48 +++++ test/regression/assets/fonts/test-font.woff | Bin 0 -> 44 bytes test/regression/assets/fonts/test-font.woff2 | Bin 0 -> 50 bytes test/regression/assets/images/icon.svg | 1 + test/regression/assets/images/logo.png | Bin 0 -> 70 bytes test/regression/assets/images/photo.jpg | Bin 0 -> 272 bytes test/regression/assets/images/picture-lg.jpg | Bin 0 -> 272 bytes test/regression/assets/images/picture-sm.jpg | Bin 0 -> 272 bytes test/regression/assets/images/poster.jpg | Bin 0 -> 272 bytes test/regression/assets/js/custom-element.js | 76 ++++++++ test/regression/pages/canvas.html | 53 ++++++ test/regression/pages/comprehensive.html | 96 ++++++++++ test/regression/pages/cors-iframes.html | 22 +++ test/regression/pages/cors-resources.html | 41 +++++ test/regression/pages/cssom.html | 76 ++++++++ test/regression/pages/fonts.html | 68 ++++++++ test/regression/pages/iframe-content.html | 17 ++ test/regression/pages/iframes.html | 51 ++++++ test/regression/pages/images.html | 62 +++++++ test/regression/pages/js-enabled.html | 55 ++++++ test/regression/pages/nested-iframe.html | 16 ++ test/regression/pages/redirects.html | 27 +++ test/regression/pages/responsive.html | 45 +++++ test/regression/pages/shadow-dom.html | 83 +++++++++ test/regression/regression.test.js | 80 +++++++++ test/regression/server.js | 174 +++++++++++++++++++ test/regression/snapshots.yml | 54 ++++++ 33 files changed, 1345 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/regression.yml create mode 100644 test/regression/.percy.yml create mode 100644 test/regression/README.md create mode 100644 test/regression/assets/css/base.css create mode 100644 test/regression/assets/css/responsive.css create mode 100644 test/regression/assets/fonts/test-font.woff create mode 100644 test/regression/assets/fonts/test-font.woff2 create mode 100644 test/regression/assets/images/icon.svg create mode 100644 test/regression/assets/images/logo.png create mode 100644 test/regression/assets/images/photo.jpg create mode 100644 test/regression/assets/images/picture-lg.jpg create mode 100644 test/regression/assets/images/picture-sm.jpg create mode 100644 test/regression/assets/images/poster.jpg create mode 100644 test/regression/assets/js/custom-element.js create mode 100644 test/regression/pages/canvas.html create mode 100644 test/regression/pages/comprehensive.html create mode 100644 test/regression/pages/cors-iframes.html create mode 100644 test/regression/pages/cors-resources.html create mode 100644 test/regression/pages/cssom.html create mode 100644 test/regression/pages/fonts.html create mode 100644 test/regression/pages/iframe-content.html create mode 100644 test/regression/pages/iframes.html create mode 100644 test/regression/pages/images.html create mode 100644 test/regression/pages/js-enabled.html create mode 100644 test/regression/pages/nested-iframe.html create mode 100644 test/regression/pages/redirects.html create mode 100644 test/regression/pages/responsive.html create mode 100644 test/regression/pages/shadow-dom.html create mode 100644 test/regression/regression.test.js create mode 100644 test/regression/server.js create mode 100644 test/regression/snapshots.yml diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml new file mode 100644 index 000000000..99ba8d96a --- /dev/null +++ b/.github/workflows/regression.yml @@ -0,0 +1,88 @@ +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: + +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 + if: >- + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + 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 + + # Get PR branch for comment-triggered runs + - uses: xt0rted/pull-request-comment-branch@v3 + if: github.event_name == 'issue_comment' + id: comment-branch + + - uses: actions/checkout@v5 + with: + # Use PR branch for comment triggers, default for push/dispatch + ref: ${{ steps.comment-branch.outputs.head_ref || github.ref }} + + - 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..0a2ab40f6 --- /dev/null +++ b/test/regression/.percy.yml @@ -0,0 +1,15 @@ +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 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 0000000000000000000000000000000000000000..b04838531e23947e47290fb6a4a4155b1e799f9a GIT binary patch literal 44 WcmXT-cXMN4WB>sjG#V_3OaK5v>;bL- literal 0 HcmV?d00001 diff --git a/test/regression/assets/fonts/test-font.woff2 b/test/regression/assets/fonts/test-font.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..1e6c05a672d0d8deab8267da029107753d7e6e50 GIT binary patch literal 50 acmXT-cQayOWB>sJC=FtP07MkbL?!@A1_7b~ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0f2de3749df299a6b84bf6ff1a0b393a1c1fd22b GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBTuKYyVd1A7xwz3mB) Q_dp2-Pgg&ebxsLQ0NDZ%&ZMzixHAiDo;heAEjN>P5+j?KnoCvQ4b|eOq0DL2FS_Z7KcBg5-U3{ e7qa5zg)sPn50^!%e^b~86r+5ZX>4(Zy}tp^J1E!y literal 0 HcmV?d00001 diff --git a/test/regression/assets/images/picture-lg.jpg b/test/regression/assets/images/picture-lg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..76f9dbfd20ad3d3f804bc893c1b295ba561505a9 GIT binary patch literal 272 zcmb7;%?`mp9K`2uwY#g^=+Z;UQeR0)M5rc6h=WA%tRBL_L-@FOlhr2T;AZAFzsY3m z&At$v_QySl2#4ITPwXIEm%EPOoJ%PxD)g1-sYb0{4O`8)-D(=s8BCH+Z`?O#xEPJ6 zvn&ZMzixHAiDo;heAEjN>P5+j?KnoCvQ4b|eOq0DL2FS_Z7KcBg5-U3{ e7qa5zg)sPn50^!%e^b~86r+5ZX>4(Zy}tp^J1E!y literal 0 HcmV?d00001 diff --git a/test/regression/assets/images/picture-sm.jpg b/test/regression/assets/images/picture-sm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..76f9dbfd20ad3d3f804bc893c1b295ba561505a9 GIT binary patch literal 272 zcmb7;%?`mp9K`2uwY#g^=+Z;UQeR0)M5rc6h=WA%tRBL_L-@FOlhr2T;AZAFzsY3m z&At$v_QySl2#4ITPwXIEm%EPOoJ%PxD)g1-sYb0{4O`8)-D(=s8BCH+Z`?O#xEPJ6 zvn&ZMzixHAiDo;heAEjN>P5+j?KnoCvQ4b|eOq0DL2FS_Z7KcBg5-U3{ e7qa5zg)sPn50^!%e^b~86r+5ZX>4(Zy}tp^J1E!y literal 0 HcmV?d00001 diff --git a/test/regression/assets/images/poster.jpg b/test/regression/assets/images/poster.jpg new file mode 100644 index 0000000000000000000000000000000000000000..76f9dbfd20ad3d3f804bc893c1b295ba561505a9 GIT binary patch literal 272 zcmb7;%?`mp9K`2uwY#g^=+Z;UQeR0)M5rc6h=WA%tRBL_L-@FOlhr2T;AZAFzsY3m z&At$v_QySl2#4ITPwXIEm%EPOoJ%PxD)g1-sYb0{4O`8)-D(=s8BCH+Z`?O#xEPJ6 zvn&ZMzixHAiDo;heAEjN>P5+j?KnoCvQ4b|eOq0DL2FS_Z7KcBg5-U3{ e7qa5zg)sPn50^!%e^b~86r+5ZX>4(Zy}tp^J1E!y literal 0 HcmV?d00001 diff --git a/test/regression/assets/js/custom-element.js b/test/regression/assets/js/custom-element.js new file mode 100644 index 000000000..5b55ed7f4 --- /dev/null +++ b/test/regression/assets/js/custom-element.js @@ -0,0 +1,76 @@ +// 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..a856fbd4b --- /dev/null +++ b/test/regression/pages/cors-resources.html @@ -0,0 +1,41 @@ + + + + + + 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..b1185af96 --- /dev/null +++ b/test/regression/server.js @@ -0,0 +1,174 @@ +import http from 'http'; +import { readFileSync, existsSync, statSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join, extname } 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 = {}) { + const filePath = join(baseDir, urlPath); + + // Prevent directory traversal + if (!filePath.startsWith(baseDir)) { + 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'); + const corsPagesDir = join(__dirname, 'pages'); + + 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] From 2bc144bc6022455726bb32ca1d0a2d4ffeff9953 Mon Sep 17 00:00:00 2001 From: prklm10 Date: Sun, 15 Mar 2026 12:12:12 +0530 Subject: [PATCH 2/3] feat: Enhance regression workflow permissions and improve CORS resource handling --- .github/workflows/regression.yml | 29 +++++++++++++++++++---- test/regression/pages/cors-resources.html | 3 ++- test/regression/server.js | 12 ++++++---- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml index 99ba8d96a..2556c0d5b 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -10,6 +10,12 @@ on: # 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 @@ -39,15 +45,28 @@ jobs: } result-encoding: string - # Get PR branch for comment-triggered runs - - uses: xt0rted/pull-request-comment-branch@v3 + # 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' - id: comment-branch + 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 PR branch for comment triggers, default for push/dispatch - ref: ${{ steps.comment-branch.outputs.head_ref || github.ref }} + # 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: diff --git a/test/regression/pages/cors-resources.html b/test/regression/pages/cors-resources.html index a856fbd4b..9862aa6ea 100644 --- a/test/regression/pages/cors-resources.html +++ b/test/regression/pages/cors-resources.html @@ -6,7 +6,8 @@ CORS Resources Regression Test - + +

CORS Resources

diff --git a/test/regression/server.js b/test/regression/server.js index b1185af96..54a6c615f 100644 --- a/test/regression/server.js +++ b/test/regression/server.js @@ -1,7 +1,7 @@ import http from 'http'; import { readFileSync, existsSync, statSync } from 'fs'; import { fileURLToPath } from 'url'; -import { dirname, join, extname } from 'path'; +import { dirname, join, extname, resolve } from 'path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const pagesDir = join(__dirname, 'pages'); @@ -48,10 +48,14 @@ const mainRoutes = { }; function serveStaticFile(baseDir, urlPath, res, corsHeaders = {}) { - const filePath = join(baseDir, urlPath); + // Sanitize: strip query strings, decode URI, remove null bytes + const sanitized = decodeURIComponent(urlPath.split('?')[0]).replace(/\0/g, ''); - // Prevent directory traversal - if (!filePath.startsWith(baseDir)) { + // 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; From e240ffbe08e5c26c8a7c3c4ca614dc31de0709d8 Mon Sep 17 00:00:00 2001 From: prklm10 Date: Sun, 15 Mar 2026 12:37:51 +0530 Subject: [PATCH 3/3] feat: Update regression workflow and improve custom element definitions --- .github/workflows/regression.yml | 4 +++- test/regression/.percy.yml | 2 ++ test/regression/assets/js/custom-element.js | 1 + test/regression/server.js | 1 - 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml index 2556c0d5b..8fc99b39b 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -23,12 +23,14 @@ jobs: # 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 && - github.event.comment.body == '/regression') + contains(github.event.comment.body, '/regression')) steps: # Permission check for comment-triggered runs - name: Check commenter permissions diff --git a/test/regression/.percy.yml b/test/regression/.percy.yml index 0a2ab40f6..c4d6bcf16 100644 --- a/test/regression/.percy.yml +++ b/test/regression/.percy.yml @@ -13,3 +13,5 @@ discovery: - localhost:9101 networkIdleTimeout: 150 concurrency: 5 + launchOptions: + timeout: 120000 diff --git a/test/regression/assets/js/custom-element.js b/test/regression/assets/js/custom-element.js index 5b55ed7f4..2522c48c9 100644 --- a/test/regression/assets/js/custom-element.js +++ b/test/regression/assets/js/custom-element.js @@ -1,3 +1,4 @@ +/* eslint-env browser */ // Custom element for regression testing // Tests: shadow DOM, scoped styles, attributeChangedCallback, slotted content diff --git a/test/regression/server.js b/test/regression/server.js index 54a6c615f..2c89912ac 100644 --- a/test/regression/server.js +++ b/test/regression/server.js @@ -101,7 +101,6 @@ function createMainServer() { function createCorsServer() { const corsAssetsDir = join(__dirname, 'assets'); - const corsPagesDir = join(__dirname, 'pages'); return http.createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`);