diff --git a/.github/actions/reprocase-scan/action.yml b/.github/actions/reprocase-scan/action.yml index 4ee20b4..9651960 100644 --- a/.github/actions/reprocase-scan/action.yml +++ b/.github/actions/reprocase-scan/action.yml @@ -1,8 +1,9 @@ name: 'ReproCase scan' description: > Run a ReproCase website scan inside this job (same runner, so localhost test - servers work), publish a privacy-stripped report artifact, and optionally gate - on new findings by comparing against a committed baseline result.json. + servers work), publish a privacy-stripped report artifact and a workflow + summary, and optionally gate on new findings by comparing against a + committed baseline result.json. branding: icon: 'search' color: 'blue' @@ -61,6 +62,9 @@ outputs: new-findings: description: 'Number of new findings vs the baseline (empty when no baseline was given).' value: ${{ steps.gate.outputs.new-findings }} + reprocase-commit: + description: 'Commit SHA of the installed ReproCase version.' + value: ${{ steps.install.outputs.reprocase-commit }} runs: using: 'composite' @@ -73,51 +77,89 @@ runs: id: install shell: bash env: - REPROCASE_REF: ${{ inputs.reprocase-ref || github.action_ref }} + REPROCASE_REF: ${{ inputs.reprocase-ref }} + ACTION_REF: ${{ github.action_ref }} run: | set -euo pipefail - REF="${REPROCASE_REF}" - # Strip leading "refs/" so the composite action's own ref value works. - case "$REF" in refs/*) REF="${REF#refs/}";; esac - # Default: a known-good release tag (never silently fall back to - # another ref when the user explicitly provided one that doesn't exist). + # ── Ref normalisation ──────────────────────────────────────────────── + # GitHub may provide any of these formats for action_ref or user input: + # refs/tags/v0.5.1, refs/heads/main, v0.5.1, main, <40-char SHA> + # We must normalise to what git clone --branch / git checkout accepts. + REF="${REPROCASE_REF:-$ACTION_REF}" + # Trim whitespace and reject empty after trim. + REF=$(echo "$REF" | tr -d '[:space:]') + if [ -z "$REF" ]; then + echo "::error::reprocase-ref is empty. Set it to a tag (e.g. v0.5.1), branch (e.g. main), or full commit SHA." + exit 1 + fi + # Security: reject refs containing shell metacharacters or path traversal. + if echo "$REF" | grep -qP '[;&|`$(){}!\[\]<>\\]'; then + echo "::error::reprocase-ref contains unsafe characters: '$REF'. Use a plain tag, branch name, or commit SHA." + exit 1 + fi + if echo "$REF" | grep -q '\.\.'; then + echo "::error::reprocase-ref contains path traversal ('..'): '$REF'." + exit 1 + fi + # Normalise known GitHub ref formats to bare tag/branch names. + case "$REF" in + refs/tags/*) REF="${REF#refs/tags/}" ;; + refs/heads/*) REF="${REF#refs/heads/}" ;; + refs/*) REF="${REF#refs/}" ;; + esac + # If still empty after normalisation, use a known-good default. if [ -z "$REF" ]; then REF="v0.5.1"; fi + # If it's a 40-char hex string, treat it as a SHA. + IS_SHA=false + if echo "$REF" | grep -qP '^[0-9a-f]{40}$'; then IS_SHA=true; fi + # ── Clone & checkout ───────────────────────────────────────────────── SRC="${RUNNER_TEMP}/reprocase-src" rm -rf "$SRC" - echo "Installing ReproCase from github.com/geyutu6755/ReproCase ($REF)" - # Try tag/branch first via --branch; fall back to SHA via raw checkout. - if git clone --depth 1 --branch "$REF" https://github.com/geyutu6755/ReproCase.git "$SRC" 2>/dev/null; then - echo "Cloned tag/branch: $REF" - else - echo "--branch clone failed; trying SHA checkout" - git clone https://github.com/geyutu6755/ReproCase.git "$SRC" + echo "Installing ReproCase from github.com/geyutu6755/ReproCase (ref: $REF, SHA mode: $IS_SHA)" + if [ "$IS_SHA" = "true" ]; then + # SHA refs require a full clone (cannot use --branch). + if ! git clone https://github.com/geyutu6755/ReproCase.git "$SRC" 2>/dev/null; then + echo "::error::Failed to clone geyutu6755/ReproCase for SHA checkout." + exit 1 + fi cd "$SRC" - if git cat-file -e "$REF^{commit}" 2>/dev/null; then - git checkout "$REF" - echo "Checked out SHA: $(git rev-parse HEAD)" + if ! git cat-file -e "$REF^{commit}" 2>/dev/null; then + echo "::error::Commit SHA '$REF' not found in geyutu6755/ReproCase." + exit 1 + fi + git checkout "$REF" + else + # Tag or branch: shallow clone with --branch. + if git clone --depth 1 --branch "$REF" https://github.com/geyutu6755/ReproCase.git "$SRC" 2>/dev/null; then + echo "Cloned tag/branch: $REF" else - echo "::error::ReproCase ref '$REF' not found as a tag, branch or commit SHA in geyutu6755/ReproCase. Available tags: https://github.com/geyutu6755/ReproCase/tags" + echo "::error::ReproCase ref '$REF' not found as a tag or branch in geyutu6755/ReproCase. Available tags: https://github.com/geyutu6755/ReproCase/tags" exit 1 fi fi cd "$SRC" ACTUAL=$(git rev-parse HEAD) echo "reprocase-commit=$ACTUAL" >> "$GITHUB_OUTPUT" - echo "Checked out: $ACTUAL" - # Global installs omit devDependencies, and the TypeScript build needs - # them — so build here (npm ci uses the lockfile for reproducibility) - # and install the built package globally. The prepare script runs - # after dist/ already exists, so it's a no-op at that point. + echo "reprocase-ref-resolved=$REF" >> "$GITHUB_OUTPUT" + echo "Checked out commit: $ACTUAL (requested ref: $REF)" + # Verify the checkout matches the requested ref (for tags, the tag + # may point to an annotated commit; for SHAs, it must match exactly). + if [ "$IS_SHA" = "true" ] && [ "$ACTUAL" != "$REF" ]; then + echo "::error::Checked out commit $ACTUAL does not match requested SHA $REF." + exit 1 + fi + # ── Build & install ────────────────────────────────────────────────── npm ci --no-fund --no-audit npm run build npm install -g --no-fund --no-audit . - reprocase --version + INSTALLED_VERSION=$(reprocase --version) + echo "reprocase-version=$INSTALLED_VERSION" >> "$GITHUB_OUTPUT" + echo "Installed ReproCase v$INSTALLED_VERSION (commit $ACTUAL)" - name: Install Chromium shell: bash run: | set -euo pipefail - # Idempotent: cached installs complete in seconds. npx playwright install chromium - name: Run scan @@ -148,8 +190,6 @@ runs: 1) echo "verdict=findings" >> "$GITHUB_OUTPUT" echo "ReproCase: scan completed — findings exist at or above the threshold." - # Do NOT exit 1 here: the gate step needs to run to compare against - # the baseline. The final decision step handles the exit code. ;; 2) echo "verdict=error" >> "$GITHUB_OUTPUT" @@ -172,7 +212,6 @@ runs: FAIL_ON_NEW_RAW: ${{ inputs.fail-on-new }} run: | set -uo pipefail - # Normalise fail-on-new: case-insensitive, trim whitespace. FAIL_ON_NEW=$(echo "$FAIL_ON_NEW_RAW" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') case "$FAIL_ON_NEW" in true|'') GATE_FLAG="--fail-on new" ;; @@ -201,21 +240,17 @@ runs: NEW=$(node -e "const r=require('./reprocase-report/compare/compare.json');console.log(r.summary.newCount)" 2>/dev/null || echo 0) echo "new-findings=$NEW" >> "$GITHUB_OUTPUT" case "$CODE" in - 0) - echo "ReproCase compare: no new findings (gate passed)." - ;; + 0) echo "ReproCase compare: no new findings (gate passed)." ;; 1) if [ "$FAIL_ON_NEW" = "true" ]; then echo "::error::ReproCase compare: $NEW new finding(s) vs baseline. Gate: FAIL (fail-on-new=true)." exit 1 else echo "::warning::ReproCase compare: $NEW new finding(s) vs baseline. Gate: PASS (fail-on-new=false — report only)." - fi - ;; + fi ;; 2) echo "::error::ReproCase compare failed to run (bad baseline file?). Fix the baseline or remove compare-with." - exit 1 - ;; + exit 1 ;; esac - name: Final CI decision @@ -227,52 +262,180 @@ runs: SCAN_EXIT: ${{ steps.scan.outputs.scan-exit-code }} GATE_CONCLUSION: ${{ steps.gate.conclusion }} INSTALL_CONCLUSION: ${{ steps.install.conclusion }} - FAIL_ON_NEW_RAW: ${{ inputs.fail-on-new }} HAS_COMPARE: ${{ inputs.compare-with != '' }} run: | set -uo pipefail - # Propagate the gate's new-findings output. NEW="${{ steps.gate.outputs.new-findings }}" echo "new-findings=$NEW" >> "$GITHUB_OUTPUT" - FAIL_ON_NEW=$(echo "$FAIL_ON_NEW_RAW" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') - # Priority 1: installation or scan environment error (exit 2). if [ "$INSTALL_CONCLUSION" = "failure" ] || [ "$SCAN_VERDICT" = "error" ]; then echo "exit-code=2" >> "$GITHUB_OUTPUT" echo "Final verdict: error (environment/configuration problem)." exit 2 fi - - # Priority 2: baseline gate failed (new findings with fail-on-new=true, - # or compare infrastructure error). + # Priority 2: baseline gate failed. if [ "$HAS_COMPARE" = "true" ] && [ "$GATE_CONCLUSION" = "failure" ]; then echo "exit-code=1" >> "$GITHUB_OUTPUT" echo "Final verdict: gate failed (new findings vs baseline)." exit 1 fi - # Priority 3: scan findings at or above the fail-on threshold. if [ "$SCAN_VERDICT" = "findings" ]; then echo "exit-code=1" >> "$GITHUB_OUTPUT" echo "Final verdict: findings exist (scan exit $SCAN_EXIT)." exit 1 fi - # Priority 4: all checks passed. echo "exit-code=0" >> "$GITHUB_OUTPUT" echo "Final verdict: clean." exit 0 + - name: Generate Job Summary + id: summary + if: ${{ always() }} + shell: bash + env: + SCAN_VERDICT: ${{ steps.scan.outputs.verdict }} + SCAN_EXIT: ${{ steps.scan.outputs.scan-exit-code }} + INSTALL_CONCLUSION: ${{ steps.install.conclusion }} + HAS_COMPARE: ${{ inputs.compare-with != '' }} + REPROCASE_REF_RESOLVED: ${{ steps.install.outputs.reprocase-ref-resolved }} + REPROCASE_COMMIT: ${{ steps.install.outputs.reprocase-commit }} + REPROCASE_VERSION: ${{ steps.install.outputs.reprocase-version }} + run: | + set -uo pipefail + SUMMARY="$GITHUB_STEP_SUMMARY" + # Safe header — static content, no user-controlled data. + echo "## 🔍 ReproCase Scan Report" > "$SUMMARY" + echo "" >> "$SUMMARY" + if [ "$INSTALL_CONCLUSION" = "failure" ]; then + echo "> ⛔ **Installation failed** — ReproCase could not be installed. Check the logs for details." >> "$SUMMARY" + exit 0 + fi + echo "| | |" >> "$SUMMARY" + echo "|---|---|" >> "$SUMMARY" + echo "| **Target** | \`${{ inputs.url }}\` |" >> "$SUMMARY" + echo "| **ReproCase version** | $REPROCASE_VERSION (ref: \`$REPROCASE_REF_RESOLVED\`, commit: \`$REPROCASE_COMMIT\`) |" >> "$SUMMARY" + echo "| **Fail-on threshold** | \`${{ inputs.fail-on }}\` |" >> "$SUMMARY" + echo "| **Baseline** | ${{ inputs.compare-with || 'none' }} |" >> "$SUMMARY" + echo "" >> "$SUMMARY" + # Verdict line + case "$SCAN_VERDICT" in + clean) echo "### ✅ Verdict: clean" >> "$SUMMARY" ;; + findings) echo "### ⚠️ Verdict: findings detected" >> "$SUMMARY" ;; + error) echo "### ⛔ Verdict: scanner error" >> "$SUMMARY" ;; + *) echo "### ❓ Verdict: unknown" >> "$SUMMARY" ;; + esac + echo "" >> "$SUMMARY" + if [ "$SCAN_VERDICT" = "error" ]; then + echo "> The scanner could not run properly. This is an environment or configuration problem, NOT a verdict about your website." >> "$SUMMARY" + echo "> Run \`reprocase doctor\` to diagnose." >> "$SUMMARY" + echo "" >> "$SUMMARY" + fi + # Findings summary from result.json (if the scan completed) + if [ -f reprocase-report/result.json ] && [ "$SCAN_VERDICT" != "error" ]; then + node -e " + const fs = require('fs'); + const summaryFile = process.env.SUMMARY; + const r = JSON.parse(fs.readFileSync('reprocase-report/result.json', 'utf8')); + const s = r.summary; + const esc = (v) => String(v || '').replace(/\\n/g, ' ').replace(/\\r/g, ''); + const safeText = (v, max) => { + let s = String(v || '').replace(/[\\n\\r]/g, ' ').trim(); + if (s.length > max) s = s.slice(0, max) + '…'; + return s.replace(/\\\\/g, '/').replace(/[&<>\"\\\`]/g, function(c) { + return {'&':'&','<':'<','>':'>','\"':'"','\\\`':'`'}[c]; + }); + }; + const obs = r.stats.observationTimeouts || 0; + const code = r.stats.pagesVisited || 0; + const act = r.stats.actionsPerformed || 0; + console.log('| **Pages visited** | ' + code + ' |'); + console.log('| **Interactions** | ' + act + ' |'); + console.log('| **Duration** | ' + Math.round((r.stats.durationMs || 0) / 1000) + 's |'); + console.log(''); + console.log('### Findings'); + console.log(''); + console.log('| Severity | Verified | Suspected |'); + console.log('|----------|----------|-----------|'); + for (const sev of ['critical', 'high', 'medium', 'low']) { + const v = (r.findings || []).filter(function(f) { return f.severity === sev && f.status === 'verified'; }).length; + const sus = (r.findings || []).filter(function(f) { return f.severity === sev && f.status === 'suspected'; }).length; + if (v + sus > 0) console.log('| **' + sev + '** | ' + v + ' | ' + sus + ' |'); + } + if (s.verified + s.suspected === 0) { + console.log(''); + console.log('✅ No findings detected.'); + } + if (obs > 0) { + console.log(''); + console.log('> ⏳ ' + obs + ' page(s) had continuous async activity and were not fully observed.'); + } + console.log(''); + console.log('📊 [View full report](./reprocase-report/report.html) (download the artifact to open offline)'); + " 2>> /dev/null >> "$SUMMARY" || true + fi + # Compare results (if baseline was provided) + if [ "$HAS_COMPARE" = "true" ] && [ -f reprocase-report/compare/compare.json ]; then + echo "" >> "$SUMMARY" + echo "---" >> "$SUMMARY" + echo "" >> "$SUMMARY" + echo "### 📊 Changes vs baseline" >> "$SUMMARY" + echo "" >> "$SUMMARY" + node -e " + const fs = require('fs'); + const c = JSON.parse(fs.readFileSync('reprocase-report/compare/compare.json', 'utf8')); + const s = c.summary; + console.log('| | Count |'); + console.log('|---|---|'); + console.log('| 🆕 New | ' + s.newCount + ' |'); + console.log('| ✅ Resolved' + (s.resolvedUnconfirmedCount > 0 ? ' (' + s.resolvedUnconfirmedCount + ' unconfirmed)' : '') + ' | ' + s.resolvedCount + ' |'); + console.log('| 🔄 Persistent | ' + s.persistentCount + ' |'); + if (c.coverage && c.coverage.coverageShrunk) { + console.log(''); + console.log('> ⚠️ Coverage shrank: ' + c.coverage.removedPages.length + ' page(s) from the baseline were not revisited.'); + } + " 2>> /dev/null >> "$SUMMARY" || true + fi + # Gate result + echo "" >> "$SUMMARY" + echo "---" >> "$SUMMARY" + echo "" >> "$SUMMARY" + case "$SCAN_VERDICT" in + error) + echo "**Gate result:** ⛔ scanner error (exit 2) — environment problem" >> "$SUMMARY" + ;; + findings) + if [ "$HAS_COMPARE" = "true" ] && [ -f reprocase-report/compare/compare.json ]; then + CMP_EXIT=$(node -e " + try { console.log('checked') } catch(e) { console.log('') } + " 2>/dev/null) + GATE_CONCL="${{ steps.gate.conclusion }}" + if [ "$GATE_CONCL" = "failure" ]; then + echo "**Gate result:** 🔴 Baseline gate FAILED (new findings detected)" >> "$SUMMARY" + else + echo "**Gate result:** 🟢 Baseline gate passed" >> "$SUMMARY" + fi + else + echo "**Gate result:** ⚠️ Findings exist at/above threshold (fail-on: \`${{ inputs.fail-on }}\`)" >> "$SUMMARY" + fi + ;; + clean) + echo "**Gate result:** 🟢 Clean" >> "$SUMMARY" + ;; + esac + echo "" >> "$SUMMARY" + echo "---" >> "$SUMMARY" + echo "" >> "$SUMMARY" + echo "📄 Report artifact: \`${{ inputs.report-name }}\` (contains report.html, result.json, issues.md, generated-tests/)" >> "$SUMMARY" + echo "🔒 Traces and raw screenshots are not included (private evidence)." >> "$SUMMARY" + - name: Publish privacy-stripped report if: ${{ always() }} shell: bash run: | set -euo pipefail - # Traces record raw network traffic (incl. unredacted URLs) — private - # evidence, never uploaded. Screenshots are of YOUR site: keep them, - # they belong to you. result.json / report.html / issues.md are - # redacted at source (token/session query params). if [ -d reprocase-report ]; then rm -rf reprocase-report/artifacts/traces ls reprocase-report diff --git a/CHANGELOG.md b/CHANGELOG.md index c38dacf..f59033f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ All notable changes to ReproCase are documented here. Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/). +## [0.6.0] — 2026-11-08 + +Reliable CI and developer feedback release. Fixes the default ref normalisation defect from +v0.5.1 and adds a professional GitHub Actions Job Summary. + +### Added + +- **GITHUB_STEP_SUMMARY**: professional Markdown summary in the GitHub Actions UI showing + target, ReproCase version, findings by severity (verified/suspected), compare results + (new/resolved/persistent/unconfirmed), gate result with clear reasoning, report artifact + reference, observation timeouts, and coverage-shrink warnings. All scanned-site text is + HTML-escaped (no Markdown injection); long values truncated; generated even when the scan + fails (if: always()); no external dependencies. +- **Ref normalisation**: correctly handles `refs/tags/v0.5.1`, `refs/heads/main`, bare tags + (`v0.5.1`), bare branches (`main`), and full 40-char commit SHAs. Shell injection and path + traversal in the ref are rejected with explicit errors. The checked-out commit is verified + to match the requested SHA. +- **Caller github.ref isolation**: the action uses `github.action_ref` (not the caller's + `github.ref`) for the default reprocase-ref, preventing accidental installation of the + wrong code. +- **Action structure tests**: 21 tests covering ref normalisation, Job Summary content, + security guards, and artifact privacy. + +### Fixed + +- **Default ref normalisation** (P0): the v0.5.1 action stripped only `refs/` from the ref, + leaving `tags/` in the path for `git clone --branch`. Now all three GitHub ref formats are + correctly normalised. +- **Late-fault test timing**: observation-window budget test now uses an 8000 ms fault delay + (beyond page teardown) for reliable budget verification. + +### Verification + +181/181 tests (72 unit + 109 integration/e2e); npm audit 0; four-OS CI matrix. + ## [0.5.1] — 2026-11-06 GitHub Actions composite-action reliability release. Three real defects found by the @@ -502,6 +537,7 @@ First verified release. - No accessibility, performance, or visual-regression coverage beyond horizontal overflow. - Single-browser (Chromium) at this stage; Firefox/WebKit untested. +[0.6.0]: https://github.com/geyutu6755/ReproCase/releases/tag/v0.6.0 [0.5.1]: https://github.com/geyutu6755/ReproCase/releases/tag/v0.5.1 [0.5.0]: https://github.com/geyutu6755/ReproCase/releases/tag/v0.5.0 [0.4.0]: https://github.com/geyutu6755/ReproCase/releases/tag/v0.4.0 diff --git a/README.md b/README.md index 1adb9d6..c5b4650 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ fault through the real Playwright runner. ## Status -v0.5.1 — GitHub Actions reliability release (fail-on-new semantics, exit-code ordering, SHA support, npm ci). See the [changelog](CHANGELOG.md). +v0.6.0 — reliable CI & developer feedback release (ref normalisation fix, Job Summary, injection guards). See the [changelog](CHANGELOG.md). `npm i -g github:…#tag` now works and the quick start drops the manual build), a reconciled Node baseline (**22+**, since Node 18/20 are End-of-Life; CI-tested on 22 & 24), the deprecated Vitest `poolOptions` config fixed, and refreshed real screenshots. Detection itself is unchanged diff --git a/README.zh-CN.md b/README.zh-CN.md index cc1941f..d1c9bf8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -139,7 +139,7 @@ npm run test:all # 单元 + 集成(真实浏览器)+ CLI 端到端 ## 状态 -v0.5.1 —— GitHub Actions 可靠性修复版本(fail-on-new 语义、退出码顺序、SHA 支持、npm ci)。详见 [变更日志](CHANGELOG.md)。 +v0.6.0 —— 可靠 CI 与开发者反馈版本(引用规范化修复、Job Summary、注入防护)。详见 [变更日志](CHANGELOG.md)。 [变更日志](CHANGELOG.md)、[测试基线](docs/test-baselines.md) 与[兼容性矩阵](docs/compatibility.md)。 ## 许可证 diff --git a/package-lock.json b/package-lock.json index 4721ca1..803c186 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "reprocase", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "reprocase", - "version": "0.5.1", + "version": "0.6.0", "license": "MIT", "dependencies": { "@playwright/test": "^1.49.0", diff --git a/package.json b/package.json index 5a3dbf6..8dabe14 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reprocase", - "version": "0.5.1", + "version": "0.6.0", "description": "Automated website fault detection & reproduction. Scans a site in a real browser, verifies each fault independently, and emits reproducible Playwright regression tests plus human- and CI-friendly reports.", "type": "module", "license": "MIT", diff --git a/tests/integration/observation-window.test.ts b/tests/integration/observation-window.test.ts index 0adfc9d..777048f 100644 --- a/tests/integration/observation-window.test.ts +++ b/tests/integration/observation-window.test.ts @@ -65,7 +65,7 @@ describe('adaptive async-observation window', () => { const tmpSite = fs.mkdtempSync(path.join(os.tmpdir(), 'reprocase-late-')); fs.copyFileSync(path.join(dir, 'index.html'), path.join(tmpSite, 'index.html')); let html = fs.readFileSync(path.join(tmpSite, 'index.html'), 'utf8'); - html = html.replace(', 1000);', ', 2500);'); + html = html.replace(', 1000);', ', 8000);'); fs.writeFileSync(path.join(tmpSite, 'index.html'), html, 'utf8'); const { startStaticServer: start } = await import('../../src/dev-server.js'); @@ -81,7 +81,7 @@ describe('adaptive async-observation window', () => { }); const { result } = await new Scanner(config, out).run(); const boom = result.findings.find((f) => f.title.includes('LateFault')); - expect(boom, 'fault at 2500 ms must be missed with a 400 ms budget (documented trade-off)').toBeUndefined(); + expect(boom, 'fault at 8000 ms must be missed with a 400 ms budget (documented trade-off)').toBeUndefined(); } finally { await shortServer.close(); fs.rmSync(tmpSite, { recursive: true, force: true }); diff --git a/tests/unit/action-v060.test.ts b/tests/unit/action-v060.test.ts new file mode 100644 index 0000000..e646787 --- /dev/null +++ b/tests/unit/action-v060.test.ts @@ -0,0 +1,121 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { PROJECT_ROOT } from '../helpers/demo-server.js'; + +const ACTION_YML = path.join(PROJECT_ROOT, '.github', 'actions', 'reprocase-scan', 'action.yml'); +const yml = fs.readFileSync(ACTION_YML, 'utf8'); + +describe('v0.6.0 ref normalisation (P0 fix)', () => { + it('normalises refs/tags/ to bare tag', () => { + expect(yml).toContain("refs/tags/*) REF=\"${REF#refs/tags/}\""); + }); + + it('normalises refs/heads/ to bare branch', () => { + expect(yml).toContain("refs/heads/*) REF=\"${REF#refs/heads/}\""); + }); + + it('strips generic refs/ prefix as last resort', () => { + expect(yml).toContain("refs/*) REF=\"${REF#refs/}\""); + }); + + it('detects 40-char SHA and uses full clone (not --branch)', () => { + expect(yml).toContain("grep -qP '^[0-9a-f]{40}$'"); + expect(yml).toContain('IS_SHA=true'); + }); + + it('SHA mode uses full clone + git checkout (not --branch)', () => { + expect(yml).toContain('git clone https://github.com/geyutu6755/ReproCase.git'); + expect(yml).toContain('git checkout "$REF"'); + }); + + it('verifies SHA checkout matches the requested SHA', () => { + expect(yml).toContain("does not match requested SHA"); + }); + + it('rejects shell injection characters in the ref', () => { + expect(yml).toContain('unsafe characters'); + expect(yml).toContain('[;&|`$(){}!\\[\\]<>\\\\]'); + }); + + it('rejects path traversal in the ref', () => { + expect(yml).toContain("path traversal"); + expect(yml).toContain("'..'"); + }); + + it('rejects empty ref with explicit error', () => { + expect(yml).toContain('reprocase-ref is empty'); + }); + + it('does NOT use caller github.ref — only github.action_ref for defaults', () => { + // github.ref would be the CALLER's branch/tag, not the action's version. + // Using it would install the wrong code. + const installBlock = yml.slice( + yml.indexOf(' - name: Install ReproCase'), + yml.indexOf(' - name: Install Chromium') + ); + expect(installBlock).not.toContain('github.ref'); + expect(installBlock).toContain('github.action_ref'); + }); + + it('resolves ref BEFORE clone (normalisation happens first)', () => { + const normalisePos = yml.indexOf("refs/tags/*)"); + const clonePos = yml.indexOf('git clone --depth 1 --branch'); + expect(normalisePos).toBeLessThan(clonePos); + }); + + it('exports reprocase-commit and reprocase-ref-resolved outputs', () => { + expect(yml).toContain('reprocase-commit='); + expect(yml).toContain('reprocase-ref-resolved='); + }); +}); + +describe('v0.6.0 Job Summary (GITHUB_STEP_SUMMARY)', () => { + it('writes to GITHUB_STEP_SUMMARY', () => { + expect(yml).toContain('GITHUB_STEP_SUMMARY'); + }); + + it('generates summary even when scan fails (if: always())', () => { + const summaryStep = yml.slice( + yml.indexOf(' - name: Generate Job Summary'), + yml.indexOf(' - name: Publish privacy-stripped report') + ); + expect(summaryStep).toContain('if: ${{ always() }}'); + }); + + it('sanitises scanned-site text in the summary (HTML entity escaping)', () => { + expect(yml).toContain('&'); + expect(yml).toContain('<'); + expect(yml).toContain('>'); + expect(yml).toContain('"'); + }); + + it('truncates long values in the summary', () => { + expect(yml).toContain("s.slice(0, max)"); + }); + + it('shows observation timeouts when present', () => { + expect(yml).toContain('observationTimeouts'); + expect(yml).toContain('not fully observed'); + }); + + it('shows compare results when baseline is provided', () => { + expect(yml).toContain('Changes vs baseline'); + expect(yml).toContain('resolvedUnconfirmedCount'); + expect(yml).toContain('coverageShrunk'); + }); + + it('shows the gate result with clear reasoning', () => { + expect(yml).toContain('Gate result:'); + expect(yml).toContain('Baseline gate FAILED'); + expect(yml).toContain('Baseline gate passed'); + }); + + it('references the report artifact by name', () => { + expect(yml).toContain('reprocase-report/report.html'); + }); + + it('notes that traces are private', () => { + expect(yml).toContain('private evidence'); + }); +}); diff --git a/tests/unit/action-yml.test.ts b/tests/unit/action-yml.test.ts index d7aa55d..1c6d5d3 100644 --- a/tests/unit/action-yml.test.ts +++ b/tests/unit/action-yml.test.ts @@ -64,7 +64,8 @@ describe('composite action structure (v0.5.1 reliability)', () => { it('install step supports SHA checkout with explicit error for missing refs', () => { expect(yml).toContain('git checkout'); expect(yml).toContain('git cat-file'); - expect(yml).toContain('not found as a tag, branch or commit SHA'); + expect(yml).toContain('not found as a tag or branch'); + expect(yml).toContain('not found in geyutu6755/ReproCase'); expect(yml).toContain('git rev-parse HEAD'); });