Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,55 @@ 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.7.0] — 2026-11-08

Real-world reliability & adoption release. Validated against an independent matrix of
real applications (HTML5 Boilerplate 9.0.1 multi-page site, create-vite React SPA,
create-vite Vue SPA) with a hand-maintained truth list, and fixed the accuracy defects
that matrix exposed. Performance verified regression-free against v0.6.0 on the same
machine (9 / 42 / 102-page sites).

### Fixed

- **Duplicate resource failures merged** (accuracy): a real browser can observe the same
missing resource with different failure shapes (HTTP 404 on one request, `net::ERR_ABORTED`
on a duplicate). Resource-load-failure fingerprints are now keyed by resource URL, so one
root cause produces one finding instead of two.
- **Handled API responses no longer reported** (accuracy / false positives): first-party
`fetch`/XHR responses with 4xx/5xx statuses are application-level business states (REST
lookups legitimately return 404 and pages handle them). They are no longer reported as
resource-load-failure. Network-level failures on those requests are still reported via the
request-failure channel, and broken images/scripts/stylesheets keep their existing coverage.
- **Safe buttons inside forms are now explored** (coverage): previously ALL form-internal
controls were skipped, hiding real interaction faults on form-heavy pages. `<button
type="button">` and links inside forms are now candidates; submit buttons, default-type
buttons and form inputs remain untouched, and the HARD-dangerous keyword policy
(delete / pay / submit / ...) still applies everywhere — verified by a negative test.
- **`<noscript>` fallback in the HTML report**: JS-disabled browsers now get a pointer to
`result.json` and `issues.md` instead of an empty shell.

### Verified

- **Detection accuracy matrix** (truth list maintained alongside the release): 12 planted
faults across three real applications — 12/12 detected after the fixes, 0 false positives
on 7 control scenarios (mailto, external links, lazy loading, handled business errors,
dangerous buttons, hash anchors, clean pages), reproduction 3/3 on every finding.
- **Dangerous-operation boundary re-audited on v0.7.0**: destructive account/invoice
controls inside and outside forms received 0 requests (server-side request log audit).
- **Performance (same machine, same browser, 2 runs each)**: small 9 pages 33→34.5 s,
medium 42 pages 128→130 s, large 102 pages 293→288 s (all within run-to-run noise);
memory peak ~130 MB; 0 observation timeouts; report sizes equal.
- **Stability**: 0 lingering browser processes after normal exit and after Ctrl+C
(graceful close, exit code 130); clear error when the output path is not a directory;
force-killed scans may briefly orphan the browser process (OS-level, self-exits).
- **Report at scale**: 200-finding stress render in ~166 ms, search/filter interaction
responsive, no mobile-viewport overflow, keyboard reachable.

### Verification

185/185 tests (24 files: unit + real-browser integration + CLI e2e); npm audit 0;
four-OS CI matrix (ubuntu Node 22/24, macOS, Windows).

## [0.6.0] — 2026-11-08

Reliable CI and developer feedback release. Fixes the default ref normalisation defect from
Expand Down
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ Full inputs, exit-code semantics and the baseline flow:
- **No buttons were explored** — check the *Exploration log* section in the report: buttons
matching risky keywords (pay, delete, run, ...) are skipped by the read-only-first policy.
Precisely authorize test controls via `exploration.allowSelectors` in the config.
- **My REST API returns 404 but the page handles it** — ReproCase treats fetch/XHR responses
as application-level business states and does not report them as resource failures (network-level
request failures on those calls are still reported). Broken images/scripts/stylesheets keep full coverage.
- **A fault I fixed still shows as "unconfirmed resolved"** — the comparison scan did not visit
that page. Rerun with the same scope as the baseline, then compare again.
- **SPA routes show up twice** — a deep link re-renders the app; the same JS fault is reported
Expand Down Expand Up @@ -152,15 +155,12 @@ fault through the real Playwright runner.

## Status

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
from v0.3.x — cross-page fault clusters, adaptive async observation, shadow DOM + same-origin
iframe detection, `scan --compare-with`, and the report/attachment security hardening of
v0.3.2–v0.3.3. See the [changelog](CHANGELOG.md),
[test baselines](docs/test-baselines.md) and the
[compatibility matrix](docs/compatibility.md).
v0.7.0 — real-world reliability release: detection accuracy validated against an independent
matrix of real applications (HTML5 Boilerplate MPA, create-vite React & Vue SPAs) with a
hand-maintained truth list; same-root-cause resource failures are no longer double-counted,
handled API (fetch/XHR) responses are no longer reported as faults, and safe non-submitting
buttons inside forms are now explored (dangerous-keyword protections unchanged and re-audited).
Performance verified regression-free vs v0.6.0 on 9/42/102-page sites. See the [changelog](CHANGELOG.md).
Known limitations are listed in the changelog and in the "Not checked" section of every report.

## License
Expand Down
9 changes: 6 additions & 3 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,13 @@ npm run test:all # 单元 + 集成(真实浏览器)+ CLI 端到端
测试套件会扫描内置演示网站,断言四种植入故障全部被检出、干净对照页零误报,
并且生成的测试能通过真实 Playwright 运行器复现每个故障。

## 状态
## Status

v0.6.0 —— 可靠 CI 与开发者反馈版本(引用规范化修复、Job Summary、注入防护)。详见 [变更日志](CHANGELOG.md)。
[变更日志](CHANGELOG.md)、[测试基线](docs/test-baselines.md) 与[兼容性矩阵](docs/compatibility.md)。
v0.7.0 — 真实项目可靠性版本:在独立真实应用矩阵(HTML5 Boilerplate 多页站、create-vite React/Vue SPA)
上以人工真值清单验证检测准确性;同根因资源失败不再重复计数、已被页面处理的 API(fetch/XHR)响应不再误报、
表单内安全的非提交按钮纳入探索(危险关键词保护不变并已复审)。性能经同机对照验证与 v0.6.0 无回归
(9/42/102 页站点)。详见 [changelog](CHANGELOG.md)。
已知限制见 changelog 与每份报告的 "Not checked" 部分。

## 许可证

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "reprocase",
"version": "0.6.0",
"version": "0.7.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",
Expand Down
6 changes: 5 additions & 1 deletion src/findings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ export function fingerprintOf(obs: ObservationInput, redactQuery: boolean): stri
case 'page-load-failure':
return ['page-load-failure', normalizeUrl(obs.targetUrl ?? obs.url), String(e.statusCode ?? norm(e.failureText))].join('|');
case 'resource-load-failure':
return ['resource-load-failure', normalizeUrl(e.resourceUrl ?? ''), String(e.statusCode ?? norm(e.failureText))].join('|');
// Key by resource URL only: a real browser can observe the same missing
// resource with different failure shapes (HTTP 404 on one request,
// net::ERR_ABORTED on a duplicate). Same root cause -> one finding
// (v0.7.0 accuracy fix); the title keeps the first-observed detail.
return ['resource-load-failure', normalizeUrl(e.resourceUrl ?? '')].join('|');
case 'broken-internal-link':
return ['broken-internal-link', normalizeUrl(obs.targetUrl ?? '')].join('|');
case 'horizontal-overflow':
Expand Down
1 change: 1 addition & 0 deletions src/report/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,7 @@ export function renderHtml(result: ScanResult, compare?: CompareResult): string
<footer id="footer"></footer>
</main>
<div id="lightbox" role="dialog" aria-modal="true" aria-label="Screenshot preview (Escape closes)" tabindex="-1"><img alt="Screenshot preview"></div>
<noscript><p style="padding:1rem;border:1px solid #b91c1c;color:#b91c1c">This report is interactive and requires JavaScript. The full scan data is available in <code>result.json</code> (machine-readable) and <code>issues.md</code> (plain Markdown) next to this file.</p></noscript>
<script>window.__REPROCASE_DATA__ = ${jsonForScript(compare ? { ...result, compare } : result)};</script>
<script>${CLIENT_JS}</script>
</body>
Expand Down
7 changes: 7 additions & 0 deletions src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,13 @@ export class Scanner {
}
}
if (res.status() < 400) return;
// XHR/fetch responses are application-level business states: REST lookups
// legitimately return 404/410/etc. and pages handle them (friendly errors,
// empty states). Reporting them as resource failures produced false
// positives on real sites (v0.7.0 accuracy fix). Network-level failures on
// those requests are still captured by the requestfailed listener above.
const rt = res.request().resourceType();
if (rt === 'xhr' || rt === 'fetch') return;
const u = res.url();
if (FAVICON_RE.test(new URL(u, page.url()).pathname)) return;
if (!this.isFirstParty(u)) return;
Expand Down
14 changes: 12 additions & 2 deletions src/util/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,18 @@ export function collectCandidatesInPage(opts: CandidateOptions): CandidateCollec
const style = getComputedStyle(node);
if (style.visibility === 'hidden' || style.display === 'none') continue;
if (node.hasAttribute('disabled') || node.getAttribute('aria-disabled') === 'true') continue;
// Never touch form controls: submitting or mutating forms is out of scope.
if (node.closest('form')) continue;
// Never touch controls that submit or mutate a form (submit buttons,
// inputs, selects, textareas): form submission is out of scope by design.
// <button type="button"> and links do neither, so they stay explorable —
// the three-tier keyword policy above/below still guards their labels
// (v0.7.0: previously ALL form-internal candidates were skipped, which
// hid real interaction faults on form-heavy pages).
if (node.closest('form')) {
const tag = node.tagName;
const type = (node.getAttribute('type') || '').toLowerCase();
const safe = (tag === 'BUTTON' && type === 'button') || tag === 'A';
if (!safe) continue;
}

const isHashLink = node.tagName === 'A';
const text = (node.textContent || '').trim();
Expand Down
Empty file.
19 changes: 19 additions & 0 deletions tests/fixtures/api-noise/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>API noise fixture</title></head>
<body>
<main>
<h1>API noise fixture</h1>
<!-- Control: broken image must still be found -->
<img src="img/missing.png" alt="broken" width="16" height="16">
<p id="plan-status"></p>
<script>
// Handled business 404: fetch a missing API resource, handle the error.
// ReproCase must NOT report this as a resource failure (Fix A).
fetch('/api/plan/42').then(function (r) { return r.json(); }).catch(function () {
document.getElementById('plan-status').textContent = 'Plan not found (handled)';
});
</script>
</main>
</body>
</html>
50 changes: 50 additions & 0 deletions tests/integration/api-noise.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { fileURLToPath } from "node:url";
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Scanner } from '../../src/scanner.js';
import { resolveConfig } from '../../src/config.js';
import { startStaticServer, type StaticServer } from '../../src/dev-server.js';

const FIXTURE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'fixtures', 'api-noise');

let server: StaticServer;

beforeAll(async () => {
server = await startStaticServer(FIXTURE_DIR);
});

afterAll(async () => {
await server.close();
});

describe('API business statuses are not resource faults (v0.7.0 accuracy fix)', () => {
it(
'does not report a handled first-party fetch 404, while still reporting a broken image',
async () => {
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'reprocase-api-'));
const { config } = resolveConfig({
entry: server.url,
maxPages: 2,
runTimeoutMs: 120_000,
verification: { attempts: 2 }
});
const { result } = await new Scanner(config, out).run();

// The genuinely broken image is still detected through the same channel.
const brokenImg = result.findings.find(
(f) => f.type === 'resource-load-failure' && f.evidence.resourceUrl?.includes('/img/missing.png')
);
expect(brokenImg, 'broken <img> must still be reported').toBeTruthy();

// The handled fetch 404 is an application-level business status, not a
// page fault: REST lookups legitimately 404 and pages handle it.
const apiNoise = result.findings.filter(
(f) => f.type === 'resource-load-failure' && (f.evidence.resourceUrl || '').includes('/api/')
);
expect(apiNoise, 'handled fetch 404 must not be reported as a resource failure').toHaveLength(0);
},
120_000
);
});
23 changes: 23 additions & 0 deletions tests/integration/exploration-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,29 @@ describe('exploration authorization policy (real browser)', () => {
expect(reasonOf('#deny-me')).toBe('denied');
}, 60_000);

it('explores safe type=button controls inside forms but never submit or hard-dangerous ones (v0.7.0)', async () => {
const page = await browser.newPage({ viewport: { width: 1000, height: 800 } });
await page.setContent(`<!doctype html><html><body>
<form>
<button id="form-safe" type="button">Load preview</button>
<button id="form-del" type="button">Delete account</button>
<button id="form-submit" type="submit">Send</button>
<button id="form-default">Continue</button>
</form>
</body></html>`);
const out = await page.evaluate(collectCandidatesInPage, { max: 20, allowSelectors: [], denySelectors: [] });
await page.close();
const chosen = out.candidates.map((c) => c.selector);
// Safe non-submitting button inside a form is explorable.
expect(chosen, 'safe type=button inside a form must be a candidate').toContain('#form-safe');
// Hard-dangerous keyword keeps its protection regardless of location.
expect(chosen).not.toContain('#form-del');
expect(out.skipped.find((s) => s.selector === '#form-del')?.reason).toBe('forbidden');
// Submitting controls (explicit submit and default-type button) stay untouched.
expect(chosen).not.toContain('#form-submit');
expect(chosen).not.toContain('#form-default');
}, 60_000);

it('skips a soft keyword by default and offers no candidate bypass', async () => {
const out = await collect([], []);
const chosen = out.candidates.map((c) => c.selector);
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/findings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ describe('FindingCollector dedupe', () => {
expect(a.finding.repro.observedCount).toBe(2);
});

it('merges resource failures with the same URL but different failure modes (404 vs abort)', () => {
// A real browser can observe the same missing resource twice with different
// failure shapes (HTTP 404 on one request, net::ERR_ABORTED on a duplicate
// request). Same root cause -> one finding, not two.
const c = new FindingCollector(true);
const a = c.observe(obs({ type: 'resource-load-failure', url: 'http://127.0.0.1:4610/p1.html', evidence: { resourceUrl: 'http://127.0.0.1:4610/app.js', statusCode: 404 } }));
const b = c.observe(obs({ type: 'resource-load-failure', url: 'http://127.0.0.1:4610/p2.html', evidence: { resourceUrl: 'http://127.0.0.1:4610/app.js', failureText: 'net::ERR_ABORTED' } }));
expect(b.created, 'same resource URL must dedupe regardless of failure mode').toBe(false);
expect(a.finding.repro.observedCount).toBe(2);
expect(c.list()).toHaveLength(1);
});

it('assigns stable ids derived from the fingerprint', () => {
const c1 = new FindingCollector(true);
const c2 = new FindingCollector(true);
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ describe('HTML report', () => {
expect(html).toContain('<!doctype html>');
});

it('provides a noscript fallback pointing at result.json/issues.md', () => {
const html = renderHtml(makeResult());
expect(html).toContain('<noscript>');
expect(html).toContain('result.json');
expect(html).toContain('issues.md');
});

it('escapes HTML-sensitive characters inside the embedded data', () => {
const evil = makeFinding({
title: 'Evil <img src=x onerror=alert(1)> finding',
Expand Down
Loading