diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1bd0ca..1c3e81b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,22 +123,57 @@ jobs: path: shots/ if-no-files-found: ignore retention-days: 14 - - name: Visual diff vs main (informational) - if: github.event_name == 'pull_request' && !cancelled() - continue-on-error: true - timeout-minutes: 12 + + visual: + needs: prep + if: github.event_name == 'pull_request' && needs.prep.outputs.site == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + continue-on-error: true + env: { SHOT_WIDTHS: '360,1280' } + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: { node-version: 24, cache: npm } + - name: Cache Playwright browsers + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-chromium-${{ hashFiles('package-lock.json') }} + - name: Install tools + run: | + npm ci + npx playwright install --with-deps chromium + python3 -m venv ~/v && ~/v/bin/pip install -q pillow + - name: Resolve main SHA + id: m + run: echo "sha=$(git ls-remote origin refs/heads/main | cut -f1)" >> "$GITHUB_OUTPUT" + - name: Cache main screenshots + id: bc + uses: actions/cache@v6 + with: + path: shots_base + key: shots-base-${{ steps.m.outputs.sha }}-360-1280-${{ hashFiles('tests/shots_all.js') }} + - name: Screenshot main + if: steps.bc.outputs.cache-hit != 'true' run: | git clone -q --depth 1 --branch main "https://github.com/${{ github.repository }}.git" _base - if [ -d site/downloads ]; then cp -r site/downloads _base/site/downloads; fi + (cd _base && python3 build/build.py --publish) python3 -m http.server 8766 --directory _base/site >/dev/null 2>&1 & curl -fsS --retry 10 --retry-connrefused --retry-delay 1 -o /dev/null http://localhost:8766/ node tests/shots_all.js http://localhost:8766/ shots_base + - name: Screenshot PR + run: | + python3 build/build.py --publish + python3 -m http.server 8765 --directory site >/dev/null 2>&1 & + curl -fsS --retry 10 --retry-connrefused --retry-delay 1 -o /dev/null http://localhost:8765/ node tests/shots_all.js http://localhost:8765/ shots_pr - python3 -m venv ~/v && ~/v/bin/pip install -q pillow + - name: Diff + run: | ~/v/bin/python tests/visual_diff.py shots_base shots_pr shots_diff 0.02 || true { echo '### Visual diff vs main'; echo '```'; tail -40 shots_diff/report.txt 2>/dev/null || echo 'no report'; echo '```'; } >> "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@v7 - if: github.event_name == 'pull_request' && !cancelled() + if: always() with: name: visual-diff path: | diff --git a/tests/shots_all.js b/tests/shots_all.js index e1a27e2..f608a6b 100644 --- a/tests/shots_all.js +++ b/tests/shots_all.js @@ -3,6 +3,9 @@ const { chromium } = require("playwright"); const fs = require("fs"), path = require("path"); const base = process.argv[2], out = process.argv[3]; +const WIDTHS = (process.env.SHOT_WIDTHS || "360,768,1280").split(",").map(Number); +const THEMES = (process.env.SHOT_THEMES || "dark,light").split(","); +const CONC = +process.env.SHOT_CONC || 4; const SITE = path.join(__dirname, "..", "site"); const walk = d => fs.readdirSync(d, { withFileTypes: true }).flatMap(e => e.isDirectory() ? (e.name === "downloads" ? [] : walk(path.join(d, e.name))) : [path.join(d, e.name)]); @@ -10,16 +13,19 @@ const pages = walk(SITE).filter(f => f.endsWith(".html") && !f.endsWith("404.htm (async () => { fs.mkdirSync(out, { recursive: true }); const b = await chromium.launch(); -for (const theme of ["dark", "light"]) for (const w of [360, 768, 1280]) { +for (const theme of THEMES) for (const w of WIDTHS) { const c = await b.newContext({ viewport: { width: w, height: 900 }, reducedMotion: "reduce" }); await c.addInitScript(t => { try { localStorage.setItem("ha-theme", t); } catch (e) {} }, theme); -for (const pg of pages) { +let i = 0; +const worker = async () => { +while (i < pages.length) { +const pg = pages[i++]; const p = await c.newPage(); try { await p.goto(base + pg, { waitUntil: "networkidle" }); await p.addStyleTag({ content: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}" }); await p.evaluate(() => document.fonts.ready); -await p.waitForTimeout(400); +await p.waitForTimeout(250); const name = pg.replace(/[\/.]/g, "_") + "-" + w + "-" + theme; await p.screenshot({ path: out + "/" + name + ".png", fullPage: true }); if (w === 360 && pg === "index.html") { @@ -29,6 +35,8 @@ if (m) { try { await m.click({ timeout: 500 }); await p.waitForTimeout(300); awa } catch (e) { console.log("skip " + pg + ": " + e.message); } await p.close(); } +}; +await Promise.all(Array.from({ length: CONC }, worker)); await c.close(); } await b.close();