# Gaffer Documentation (Full) > Complete documentation for Gaffer, a test analytics platform. > Blog posts are listed as links; fetch any URL with an `.md` suffix for its markdown source. # Documentation --- ## Analytics & Metrics Source: https://gaffer.sh/docs/analytics/ import JsonLd from '../../../components/JsonLd.astro' Gaffer derives four signals from the reports you upload: a health score, a flaky-test list, a pass-rate trend, and failure clusters. Each is a deterministic function of your run history. This page gives the formula, the threshold, and a worked example for every one of them. One number to calibrate against first: **a suite with a 100% pass rate, zero flaky tests, and a flat trend scores 95, not 100.** Gaffer's own dashboard suite sits exactly there. Against this repository's local run history, which spans 2026-05-09 to 2026-08-08 and holds 2,537 tests in its newest run, `gaffer query health` returns a score of 95 with label `excellent` and trend `stable`, and `gaffer query flaky` returns an empty list. The missing 5 points are the trend bonus, which only a measurably improving suite collects. ## Threshold reference Every constant the analytics pipeline uses, in one place. | Threshold | Value | Configurable | Where it applies | |---|---|---|---| | Health label boundaries | 90 / 75 / 50 / 25 | No | Score to label mapping | | Trend sensitivity | ±2 percentage points | No | Improving vs stable vs declining | | Flaky flip rate | 10% | Yes (1-100) | Flagging a test as flaky | | Flaky failure-rate band | 10% to 90% | Lower bound only | Dashboard flaky detection | | Minimum sample size | 5 executions | No | Both flaky algorithms | | Distinct failure patterns | 2 | No | CLI variance-based flaky detection | | Cluster similarity | 0.7 | No | Grouping failures by error message | | Analysis window | 30 days | Per plan, via API | Trends, flaky detection, duration stats | | Recompute interval | 4 hours | Manual override | Snapshot and alert evaluation | | Alert cooldown | 24 hours per organization, per type | No | Health and flaky notifications | ## What is the Gaffer health score and how is it calculated? The health score is a 0-100 number combining three inputs with fixed weights: pass rate at 60%, stability at 30%, and trend direction at 10%. ``` health = passRate * 0.6 + (100 - flakyPercentage) * 0.3 + trendBonus * 0.1 ``` The result is clamped to 0-100 and rounded to an integer. | Term | Weight | Range | How it is derived | |---|---|---|---| | `passRate` | 60% | 0-100 | Passed tests over passed plus failed, across the window | | `100 - flakyPercentage` | 30% | 0-100 | `flakyPercentage` is flaky tests over unique test names | | `trendBonus` | 10% | 0, 50, or 100 | 100 improving, 50 stable, 0 declining | Pass rate carries the most weight because it is the most direct answer to "are my tests passing". Flakiness carries 30% because a suite full of coin-flip tests is untrustworthy even at a high pass rate. Trend carries the remaining 10% as a nudge, not a verdict. The 60/30/10 weighting is identical in the two implementations: the dashboard (`server/utils/health-score.ts`), which the public API's `/health` endpoint imports directly, and the CLI's local intelligence store (`gaffer-core`, `intel/health.rs`). The inputs are not identical, so the two can disagree. The CLI derives its trend by comparing the current score against the previously stored score rather than splitting a 30-day window in half, and it divides flaky tests by the total test count where the dashboard divides by the number of unique test names. ### Worked examples Every row below is an assertion in the health-score test suite, so these are the exact values Gaffer produces. | Pass rate | Flaky % | Trend | Arithmetic | Score | Label | |---|---|---|---|---|---| | 100% | 0% | stable | 60 + 30 + 5 | **95** | Excellent | | 100% | 0% | improving | 60 + 30 + 10 | **100** | Excellent | | 90% | 50% | stable | 54 + 15 + 5 | **74** | Needs Attention | | 50% | 0% | declining | 30 + 30 + 0 | **60** | Needs Attention | | 0% | 0% | stable | 0 + 30 + 5 | **35** | At Risk | The last row is worth staring at. A suite where **every single test fails** still scores 35, because stability and trend account for 40% of the weight and neither one collapses when the pass rate does. Read the health score next to the pass rate, never instead of it. The score is a trend instrument for a suite you already know is roughly working, not a smoke detector for a suite that just broke. ### What do the health score ranges mean? The boundaries are 90, 75, 50, and 25. The dashboard and the CLI use the same cutoffs with different words. | Score | Dashboard label | CLI label | What it should prompt | |---|---|---|---| | 90-100 | Excellent | `excellent` | Nothing. Watch for label transitions. | | 75-89 | Healthy | `good` | Check the flaky list. At a normal pass rate, this range usually means flakiness is eating the stability term. | | 50-74 | Needs Attention | `fair` | Open failure clusters. A handful of root causes is typically producing most of the failures. | | 25-49 | At Risk | `poor` | The pass rate is likely below 50%. Triage failures before adding tests. | | 0-24 | Critical | `critical` | Something structural broke: a bad merge, a missing service, or a config change. | ### How does Gaffer decide whether the trend is improving? Gaffer buckets the last 30 days into daily pass rates, drops every day whose pass rate is not above zero, splits the remaining series in half, and compares the two averages. A gap larger than 2 percentage points sets the direction. | Difference (recent half minus older half) | Direction | Bonus | |---|---|---| | Greater than +2 points | Improving | 100 | | Between -2 and +2 points | Stable | 50 | | Less than -2 points | Declining | 0 | A worked case. Over a 30-day window you have runs on 10 days, with daily pass rates of 88, 90, 91, 89, 92 for the first five and 95, 96, 94, 97, 96 for the last five. First-half average is 90, second-half average is 95.6, difference is +5.6, so the trend is improving and the score collects the full 10 points. Days with no runs never enter the calculation, so a quiet weekend does not register as a decline. Neither does a day on which nothing passed: a 0% bucket is dropped by the same filter, so a total outage is currently invisible to the trend. ## How does Gaffer detect flaky tests? A test is flaky when it produces different outcomes for the same code. Gaffer runs two detection algorithms against two different data stores, and they answer slightly different questions. | | Dashboard and public API | CLI (`gaffer query flaky`) | |---|---|---| | Data store | Cloudflare Analytics Engine, SQL aggregate | Local SQLite run history | | Primary signal | Failure rate inside a band | Flip rate between consecutive runs | | Flag condition | 10% < failure rate < 90% | flip rate ≥ 10% **or** ≥ 2 distinct failure patterns | | Minimum sample | 5 executions | 5 executions | | Window | 30 days (plan-clamped via API) | Whatever is in the local store | | Third signal | Duration variability | Distinct error-message count | Both produce a composite score from 0 to 1 and sort the list by it, so the noisiest test is always at the top. ### What is a flip rate? A flip is a change of outcome between two consecutive runs of the same test. The flip rate is flips divided by transitions. ``` flip_rate = flips / (executions - 1) ``` Given the sequence `pass, fail, pass, pass, fail` over five runs, there are four transitions and three of them flip (pass to fail, fail to pass, pass to fail). Flip rate is 3/4, or 75%. Skipped results are dropped from the sequence before counting, so a conditionally-skipped test does not manufacture flips. ### Why does the dashboard use a failure-rate band instead of a flip rate? Analytics Engine aggregates rows with SQL and does not preserve the per-test ordering that flip counting requires. So the dashboard asks a related question that a `GROUP BY` can answer: over the window, what fraction of this test's executions failed? Tests landing strictly between 10% and 90% are neither reliably passing nor reliably failing, which is the same population flip rate is trying to isolate. The two thresholds carve out the cases that are not flakiness: - **At or below 10% failure**: an occasional real failure, or noise below your tolerance. - **At or above 90% failure**: a broken test. It needs fixing, but it is not flaky, and putting it on the flaky list buries the tests that are. ### How does Gaffer catch a test that fails every run? The CLI adds a second, independent trigger: **two or more distinct failure patterns**. Error messages are normalized (UUIDs, timestamps, hex addresses, line numbers, and long numeric IDs are replaced with placeholders) and then counted as a set. A test that fails on all six runs has a flip rate of zero and would be invisible to flip-rate analysis, but if those six failures carry two different normalized messages, the CLI flags it anyway. Varying errors point at environmental dependence, not at one deterministic bug. ### How is the composite flakiness score computed? Both implementations weight three terms, and both cap the result at 1.0. **Dashboard:** ``` score = 0.4 * flipProximity + 0.4 * failureRate + 0.2 * durationVariability ``` `flipProximity` is `1 - 2 * |failureRate - 0.5|`, which peaks at 1.0 for a perfect 50/50 coin flip. `durationVariability` is `(max - min) / avg`, capped at 1.0. Worked example. A test fails 8 of 20 runs, with durations ranging from 80 ms to 400 ms and averaging 150 ms. - `flipProximity` = 1 - 2 × |0.4 - 0.5| = 0.8 - `failureRate` = 0.4 - `durationVariability` = (400 - 80) / 150 = 2.13, capped to 1.0 - **score** = 0.4 × 0.8 + 0.4 × 0.4 + 0.2 × 1.0 = **0.68** **CLI:** ``` score = 0.4 * flipRate + 0.4 * failureRate + 0.2 * patternVariance ``` `patternVariance` is `(distinctPatterns - 1) / 4`, capped at 1.0, so it saturates at five distinct error messages and contributes nothing when there is one or zero. A test that fails on all 5 runs with 2 distinct errors scores 0.4 × 0 + 0.4 × 1.0 + 0.2 × 0.25 = **0.45**. ### What should you do about a flaky test? There is a long-running argument about whether a race condition, a shared-fixture leak, and a genuinely intermittent production bug all deserve the same word. For deciding what to do next, the distinction does not help: the cost to the team is identical. Gaffer flags the test and leaves the call to you. | Composite score | Reading | Reasonable action | |---|---|---| | 0.7 and above | Near coin-flip, often slow and variable too | Quarantine now, investigate after | | 0.4 to 0.7 | Real intermittency, moderate failure rate | Fix within the sprint | | 0.1 to 0.4 | Marginal, or only just over the threshold | Watch. Re-check next week. | ### Can I change the flaky test threshold? **Yes.** Settings then Analytics exposes Flip rate threshold as an integer from 1 to 100, defaulting to 10. It is organization-wide and takes effect at the next computation. Lower it to surface marginal tests, raise it if a legitimately noisy suite is drowning you in flags. The 5-execution minimum sample size is not configurable. ## How is pass rate calculated? Pass rate for the project overview is passed tests over executed tests. Skipped, pending, and todo results are excluded from both the numerator and the denominator, because a test that never ran is not evidence either way. The daily series that feeds the trend chart uses a wider denominator, described below. ``` pass_rate = passed / (passed + failed) ``` That formula runs over each run's passed and failed counts. The daily series does not use it: it divides passed executions by every execution recorded that day, with no status filter, so skipped executions do sit in the denominator of each daily bucket. A period with a lot of skips reads lower in the trend than the overview pass rate for the same period. Aggregation happens per calendar day, not per run, so a day with 40 runs does not outweigh a day with 2 when the trend is computed. The daily series feeds three consumers: the trend chart, the trend direction that supplies the health score's 10% term, and the daily snapshot row that backs the historical view. ## How does failure clustering work? Clustering groups failed tests whose error messages are at least **70% similar** after normalization, so a single root cause shows up as one entry instead of thirty. The pipeline runs in three steps. 1. **Normalize.** Volatile substrings are replaced so that two instances of the same error compare as equal. | Pattern | Replaced with | |---|---| | UUIDs | `` | | ISO 8601 timestamps | `` | | File paths with line numbers | `file.ts:` | | `id=123`, `id: 456` | `id=` | | Standalone numbers of 4+ digits | `` | | Hex addresses (`0x...`) | `
` | 2. **Compare.** Normalized messages are truncated to 500 characters and compared with Levenshtein distance, converted to a similarity score of `1 - distance / maxLength`. Greedy assignment: the first unassigned failure seeds a cluster and every unassigned failure at or above 0.7 similarity joins it. 3. **Group the remainder.** Failures with no error message at all are grouped by file path instead, so they stay visible rather than collapsing into one meaningless bucket. Clusters are returned sorted by size. The largest cluster is the highest-leverage fix on the page: 30 tests failing on one `Connection refused` is one problem, not thirty. ### What are the failure categories? When Workers AI is available, each cluster is also classified into one of three root-cause categories, with a confidence level and a one-sentence rationale. Results are cached for 24 hours. | Category | Means | Typical evidence | |---|---|---| | `product_bug` | Real application defect | Assertion failures on expected behavior, wrong return values, unexpected exceptions from app code | | `automation_bug` | Test infrastructure issue | Stale selectors, element not found, setup and teardown failures, mock misconfiguration | | `environment_issue` | Infrastructure problem | Connection refused, DNS failures, out of memory, certificate errors, timeouts across many tests | A cluster the model cannot place with at least medium confidence is returned uncategorized rather than guessed at. ## When do analytics update? Analytics recompute **every four hours**, on a cron at 00:00, 04:00, 08:00, 12:00, 16:00, and 20:00 UTC. Each cycle analyzes the trailing 30 days and does four things: recomputes the overview, writes a daily snapshot row, refreshes the cache the dashboard reads, and evaluates health alerts. Reports uploaded between cycles are stored immediately and appear in the analytics on the next run. To skip the wait, press **Compute now** in Settings then Analytics. That is the right move after backfilling historical reports, where waiting four hours to find out whether the import produced sensible numbers is a bad trade. ## How do health alerts work? Gaffer sends an alert when a metric crosses a boundary, not when it moves. Two triggers exist, and each carries an independent 24-hour cooldown. Both the score and the cooldown are organization-wide, since analytics are computed across all projects in the organization. | Alert | Fires when | Payload | |---|---|---| | Health degradation | The score crosses into a **worse** label band | Previous score and label, current score and label | | New flaky tests | Tests are detected as flaky for the first time | The list of newly flaky test names | Boundary crossing is the important detail. A drop from 94 to 91 sends nothing, because both are Excellent. A drop from 91 to 88 sends an alert, because the label changed from Excellent to Healthy. Improvements never alert. The cooldown is per alert type, so a suite deteriorating through several bands in one afternoon produces one notification rather than four. Alerts route to your configured destinations: [Slack](/docs/integrations/slack/) and [webhooks](/docs/integrations/webhooks/), set up under Settings then Notifications. ## Reading the same metrics from the CLI and API Everything on this page is available without opening the dashboard. ```sh # Health score, label, trend, and previous score gaffer query health # Flaky tests, ranked by composite score gaffer query flaky # Duration percentiles and the slowest tests gaffer query slowest --limit 10 ``` The same numbers come back from the [public API](/docs/api-reference/): ```sh curl -H "X-API-Key: gfr_your_project_token" \ "https://app.gaffer.sh/api/v1/user/projects/PROJECT_ID/health?days=30" ``` The `days` parameter is clamped by plan tier (Free 30, Pro 90, Team 180). When clamping happens, the response carries a `meta` object reporting `appliedDays` and `requestedDays`, so read the applied value rather than assuming your request was honored. ## Frequently asked questions ### Why does a perfect test suite score 95 instead of 100? The trend term contributes 10 points, and a stable trend earns half of them. A suite with a 100% pass rate, zero flaky tests, and a flat trend scores 100 * 0.6 + 100 * 0.3 + 50 * 0.1 = 95. Only a measurably improving pass rate, meaning the recent half of the window averages more than 2 points above the older half, collects the full 10 and reaches 100. ### How many runs does Gaffer need before it flags a test as flaky? Five executions of the same test name. Below that, Gaffer records the results but will not classify the test, because a single failure in two runs is indistinguishable from a real bug. The minimum sample size is 5 in both the dashboard and the CLI. ## Next steps Analytics need consistent data flowing in before the trend terms mean anything. If reports are not yet uploading on every run: - [Set up the GitHub Action](/docs/guides/github-actions/) to upload from CI automatically - [Integrate the Upload API](/docs/upload-api/) into a custom pipeline - [Query the metrics programmatically](/docs/api-reference/) or through the [MCP server](/docs/mcp/) --- ## README Badges Source: https://gaffer.sh/docs/badges/ import { Aside } from '@astrojs/starlight/components' Showcase your test health with dynamic badges powered by [shields.io](https://shields.io). Badges update automatically with each test run. ## Available Badges Gaffer provides three badge types: | Badge | Example | Description | |-------|---------|-------------| | **Tests** | ![Tests badge](https://img.shields.io/badge/tests-95%25%20passing-brightgreen) | Shows your test pass rate percentage | | **Coverage** | ![Coverage badge](https://img.shields.io/badge/coverage-87%25-green) | Shows your code coverage percentage | | **Flaky** | ![Flaky badge](https://img.shields.io/badge/flaky-none-brightgreen) | Shows the count of flaky tests detected | ## How It Works Badges use the [shields.io endpoint badge](https://shields.io/badges/endpoint-badge) format. Gaffer provides public JSON endpoints that shields.io fetches to render your badges dynamically. ### Badge Endpoints | Badge | Endpoint | |-------|----------| | Tests | `/api/badges/:projectId/health.json` | | Coverage | `/api/badges/:projectId/coverage.json` | | Flaky | `/api/badges/:projectId/flaky.json` | ## Getting Your Badge Code The easiest way to get badge embed code is from your project settings: 1. Go to your project in the Gaffer dashboard 2. Navigate to **Settings** → **Badges** 3. Choose your preferred badge style 4. Copy the Markdown or HTML code 5. Paste into your README ## Manual Setup You can also construct badge URLs manually. Replace `YOUR_PROJECT_ID` with your Gaffer project ID: ### Markdown ```markdown [![Tests](https://img.shields.io/endpoint?url=https://app.gaffer.sh/api/badges/YOUR_PROJECT_ID/health.json)](https://app.gaffer.sh/projects/YOUR_PROJECT_ID) [![Coverage](https://img.shields.io/endpoint?url=https://app.gaffer.sh/api/badges/YOUR_PROJECT_ID/coverage.json)](https://app.gaffer.sh/projects/YOUR_PROJECT_ID) [![Flaky](https://img.shields.io/endpoint?url=https://app.gaffer.sh/api/badges/YOUR_PROJECT_ID/flaky.json)](https://app.gaffer.sh/projects/YOUR_PROJECT_ID) ``` ### HTML ```html Tests ``` ## Badge Styles Shields.io supports multiple badge styles. Add the `style` parameter to customize: | Style | Example | Parameter | |-------|---------|-----------| | Flat (default) | ![Flat style](https://img.shields.io/badge/tests-95%25-brightgreen?style=flat) | `style=flat` | | Flat Square | ![Flat square style](https://img.shields.io/badge/tests-95%25-brightgreen?style=flat-square) | `style=flat-square` | | Plastic | ![Plastic style](https://img.shields.io/badge/tests-95%25-brightgreen?style=plastic) | `style=plastic` | | For the Badge | ![For the badge style](https://img.shields.io/badge/tests-95%25-brightgreen?style=for-the-badge) | `style=for-the-badge` | ## Badge Colors Badge colors automatically reflect your project's health using a 5-level scale: ### Tests Badge | Pass Rate | Color | |-----------|-------| | ≥90% | ![brightgreen](https://img.shields.io/badge/tests-95%25-brightgreen) | | ≥75% | ![green](https://img.shields.io/badge/tests-80%25-green) | | ≥50% | ![yellow](https://img.shields.io/badge/tests-60%25-yellow) | | ≥25% | ![orange](https://img.shields.io/badge/tests-30%25-orange) | | {'<'}25% | ![red](https://img.shields.io/badge/tests-10%25-red) | ### Coverage Badge | Coverage | Color | |----------|-------| | ≥80% | ![brightgreen](https://img.shields.io/badge/coverage-85%25-brightgreen) | | ≥60% | ![green](https://img.shields.io/badge/coverage-70%25-green) | | ≥40% | ![yellow](https://img.shields.io/badge/coverage-50%25-yellow) | | ≥20% | ![orange](https://img.shields.io/badge/coverage-25%25-orange) | | {'<'}20% | ![red](https://img.shields.io/badge/coverage-10%25-red) | ### Flaky Badge | Flaky Tests | Color | |-------------|-------| | 0 | ![brightgreen](https://img.shields.io/badge/flaky-none-brightgreen) | | 1-3 | ![yellow](https://img.shields.io/badge/flaky-2%20tests-yellow) | | 4-10 | ![orange](https://img.shields.io/badge/flaky-7%20tests-orange) | | >10 | ![red](https://img.shields.io/badge/flaky-15%20tests-red) | When no data is available (e.g., no test runs yet), badges display "N/A" with a neutral gray color. ## Caching Badge data is cached for 5 minutes to ensure fast loading times and reduce API load. After uploading new test results, badges will update within 5 minutes. ## Example Here's how badges might look in a README: **my-awesome-project** ![Tests](https://img.shields.io/badge/tests-98%25%20passing-brightgreen) ![Coverage](https://img.shields.io/badge/coverage-84%25-green) ![Flaky](https://img.shields.io/badge/flaky-none-brightgreen) A well-tested project with great coverage and no flaky tests! ## Next Steps - [Getting Started](/docs/getting-started/) - Set up your first project - [Analytics & Metrics](/docs/analytics/) - Understand your test health data - [Slack Integration](/docs/integrations/slack/) - Get test results in Slack --- ## CLI Reference Source: https://gaffer.sh/docs/cli/ import { Aside } from '@astrojs/starlight/components' ## Installation Install via Homebrew (macOS, Linux): ```bash brew install gaffer-sh/tap/gaffer ``` Or via the install script (macOS, Linux): ```bash curl -fsSL https://app.gaffer.sh/install.sh | sh ``` The install script places the `gaffer` binary in `~/.local/bin`. Both methods support Linux (x86_64, aarch64) and macOS (Apple Silicon, Intel). For a guided walkthrough, see the [Getting Started guide](/docs/getting-started/). ## `gaffer test` Run a test command and analyze results. ```bash gaffer test -- npm test gaffer test -- pytest -x gaffer test -- go test ./... gaffer test -- cargo test ``` ### Flags | Flag | Env var | Description | |------|---------|-------------| | `--token ` | `GAFFER_TOKEN` | API token for cloud sync | | `--report ` / `-r ` | — | Report file path(s) to parse (repeatable) | | `--root ` | — | Project root directory (default: `.`) | | `--format ` | — | Output format (default: `human`) | | `--show-errors` | — | Show full error messages, stack traces, and context files for failed tests | | `--compare ` | — | Compare against the latest run on a branch (e.g. `--compare=main`) | | `--fail-on ` | — | Override exit code based on failure classification. `new` exits 0 when only pre-existing or flaky failures exist | | `--affected` | — | Derive the wrapped command from `affected-tests`. Use with `--files`. The trailing `-- ` is ignored when set | | `--files ` | — | Changed source files. Only meaningful with `--affected` (repeatable) | | `--no-graph` | — | With `--affected`, disable the import-graph strategy and fall back to naming + proximity heuristics | | `--no-cache` | — | With `--affected`, force an in-memory graph build instead of using `.gaffer/graph.db` | | `--on-empty ` | — | With `--affected`, behavior when no tests are affected. `auto` (default) exits 0 only when all signals were available; `skip` always exits 0; `fail` always exits non-zero | | `--api-url ` | `GAFFER_API_URL` | Override API endpoint | ### Behavior 1. Runs your command as a child process, passing through stdout/stderr 2. Discovers report files via glob patterns (config or defaults) 3. Parses test results and coverage reports 4. Computes health score, flaky tests, failure clusters, duration analysis 5. Classifies each failure as `new`, `pre_existing`, `flaky`, or `unknown` (auto-compares against the default branch) 6. Prints enriched summary to stderr 7. Syncs results to cloud (if token configured) 8. Exits with the child process's exit code (or overrides via `--fail-on`) ### Example output ``` gaffer 40 passed 2 failed 3 skipped 12.4s Health: 87 (good) ^ Slow: p95 245.3ms Flaky: 2 tests src/auth.test.ts > login — 40% flip rate (4/10 runs) src/api.test.ts > timeout handler — 20% flip rate (2/10 runs) Clusters: 1 pattern (3 tests) "Connection refused" — 3 tests New failures: 1 src/billing.test.ts > charge card Pre-existing: 1 src/db.test.ts > connection timeout Coverage: 78.5% lines (1234/1572) Synced: 1 run uploaded ``` ### Branch comparison Compare the current run against a baseline branch: ```bash gaffer test --compare=main -- npm test ``` ``` vs main: 2 new failures, 1 fixed, 3 pre-existing pass rate -5.0% duration +1.2s NEW src/auth.test.ts > login > OAuth redirect NEW src/billing.test.ts > charge card FIX src/api.test.ts > timeout handler ``` ### Failure classification Every failure is automatically classified by comparing against your default branch: | Classification | Meaning | |---------------|---------| | `new` | Failed now, passed on the baseline branch. Likely caused by your changes. | | `pre_existing` | Already failing on the baseline branch. Not your fault. | | `flaky` | Known flaky test (high flip rate in historical data). | | `unknown` | No baseline data available (first run or no runs on the default branch). | Classification runs automatically on every `gaffer test` invocation. The default branch is detected via git (falls back to `main`, then `master`). ### Smart exit codes Use `--fail-on=new` to exit 0 when only pre-existing or flaky failures exist: ```bash gaffer test --fail-on=new -- npm test ``` This is useful in CI to avoid blocking PRs on failures that existed before your changes. If a failure is classified as `unknown` (no baseline), it's treated as `new` for safety. Signal exits (e.g. SIGTERM killing the test process) always propagate regardless of `--fail-on`. ### JSON output Use `--format=json` to get machine-readable output on stdout: ```bash gaffer test --format=json -- npm test | jq .health.score ``` The JSON output includes a `classification` object with each failure's type: ```bash gaffer test --format=json -- npm test | jq '.classification.classified_failures[] | {name, classification}' ``` ### Run only affected tests `--affected` collapses the `affected-tests` + `gaffer test` agentic loop into one invocation. Pass the changed source files; Gaffer maps them to test files, scopes the runner, and parses results as usual. ```bash gaffer test --affected --files src/auth.ts src/api.ts ``` Use `--on-empty=auto` (default) to exit 0 only when all detection signals were available. When some signals were unavailable (degraded mode), `auto` exits non-zero so CI doesn't silently green-light a partial run. Use `--on-empty=skip` to always exit 0 or `--on-empty=fail` to always exit non-zero. The CLI currently reports `coverage_history` and `failure_history` as unavailable on every run (those signals require a future Gaffer-history connection), so `auto` will exit non-zero on any empty result today. If you want silent-skip on empty, pass `--on-empty=skip` explicitly. ## `gaffer affected-tests` Map changed source files to relevant test specs. Returns test files and a suggested run command. ```bash gaffer affected-tests --files src/auth.ts src/api.ts ``` ### Flags | Flag | Description | |------|-------------| | `--files ` | Source files that changed (required, repeatable) | | `--root ` | Project root directory (default: `.`) | | `--format ` | Output format (default: `json`) | | `--pretty` | Human-readable output to stderr. Equivalent to `--format human` | | `--no-graph` | Disable the import-graph strategy. Falls back to naming + proximity heuristics only. Faster on huge codebases at the cost of missing indirect dependencies | | `--no-cache` | Force an in-memory graph build instead of using `.gaffer/graph.db`. Useful for ephemeral CI runs and read-only filesystems | | `--print-cmd` | Print only the bare `run_command` string to stdout. Exit 1 when no command is available so `gaffer test -- $(gaffer affected-tests --files X --print-cmd)` fails fast on the empty case | ### Detection strategies | Strategy | Example | |----------|---------| | Naming convention | `src/auth.ts` finds `src/auth.test.ts`, `src/auth.spec.ts`, `src/__tests__/auth.test.ts` | | Directory proximity | `src/utils.ts` finds test files in sibling `__tests__/` or `tests/` directories | | Import graph | Reverse-reachability over the static import graph. First call walks the project; subsequent calls incrementally update files whose mtime has changed, persisted to `.gaffer/graph.db` | The import graph runs by default. Pass `--no-graph` to opt out. Results are deduplicated across strategies; the JSON payload reports which signals were attempted and which were unavailable so callers can detect degraded runs. ### Example output ```json { "affected": [ { "test_file": "src/auth.test.ts", "source_file": "src/auth.ts", "confidence": 0.97, "strategy": "naming_convention", "signals": [ { "strategy": "naming_convention", "confidence": 0.9 }, { "strategy": "import_graph", "confidence": 0.7 } ] }, { "test_file": "src/__tests__/api.test.ts", "source_file": "src/api.ts", "confidence": 0.3, "strategy": "directory_proximity", "signals": [ { "strategy": "directory_proximity", "confidence": 0.3 } ] } ], "run_command": "pnpm vitest src/auth.test.ts src/__tests__/api.test.ts", "framework": "vitest", "signals": { "attempted": ["naming_convention", "directory_proximity", "import_graph"], "unavailable": ["coverage_history", "failure_history"] } } ``` Per-test fields: `confidence` is the noisy-OR combination across signals, and `strategy` is the highest-confidence individual signal (kept flat for legacy consumers). The `signals` array carries every signal that selected the test with its per-signal confidence. The run command auto-detects your framework and package manager (pnpm, yarn, bun, or npm from lock files). `signals.unavailable` lists detection sources that weren't reachable on this run; when `affected` is empty and this list is non-empty, the result is degraded rather than confirmed-empty. ### Use with AI agents The integrated `gaffer test --affected` flag is the simplest path. To pipe through `affected-tests` directly, use `--print-cmd`: ```bash gaffer test -- $(gaffer affected-tests --files $(git diff --name-only main) --print-cmd) ``` `--print-cmd` exits 1 when no command is available, so the `gaffer test` invocation never runs with an empty wrapped command. ## `gaffer doctor` Diagnose common setup issues. Checks config, database, token validity, report discovery, framework detection, and CLI version. ```bash gaffer doctor ``` ``` gaffer doctor OK Config .gaffer/config.toml OK Database .gaffer/data.db (48KB), has data OK Token gaf_...x4f2 (valid, API reachable) OK Reports 12 files match current patterns OK Frameworks vitest (vitest.config.ts), playwright (playwright.config.ts) OK Version gaffer 0.1.0 ``` Useful as a first diagnostic step when tests fail to sync or report files aren't detected. Each check outputs OK, WARN, or FAIL with actionable detail. ## `gaffer init` Interactive project setup. ```bash gaffer init ``` Steps: 1. Detects test frameworks (Vitest, Playwright, Jest, pytest, Go, RSpec, .NET, Cargo, PHPUnit, Mocha) 2. Shows reporter setup instructions for each detected framework 3. Optionally authenticates via browser (creates API token) 4. Writes `.gaffer/config.toml` 5. Adds `.gaffer/` to `.gitignore` ## `gaffer query` Query local test intelligence without running tests. Output is JSON by default — use `--pretty` for human-readable. AI agents can access the same data via the [MCP server](/docs/mcp/). ### `gaffer query health` Health score, trend, and label. ```bash gaffer query health gaffer query health --pretty gaffer query health | jq .score ``` ### `gaffer query flaky` Flaky tests ranked by composite score. ```bash gaffer query flaky gaffer query flaky | jq '.[].test_name' ``` ### `gaffer query slowest` Top N slowest tests by duration. ```bash gaffer query slowest gaffer query slowest --limit 5 ``` ### `gaffer query runs` Recent test runs with pass/fail counts. ```bash gaffer query runs gaffer query runs --limit 5 ``` ### `gaffer query history ""` Pass/fail history for a specific test (name matched with LIKE). ```bash gaffer query history "login" gaffer query history "auth > login" --limit 10 ``` ### `gaffer query failures ""` Search failures across runs by test name or error message. ```bash gaffer query failures "timeout" gaffer query failures "connection refused" --limit 10 ``` ## `gaffer sync` Force-sync pending uploads. Use when a previous `gaffer test` run was interrupted before syncing, or to retry failed uploads. To upload reports without the CLI, see the [Upload API](/docs/upload-api/). ```bash gaffer sync gaffer sync --token gaf_xxx ``` ## Configuration Config file: `.gaffer/config.toml` (or `gaffer.toml` at project root) ```toml [project] token = "gaf_..." api_url = "https://app.gaffer.sh" [test] report_patterns = [ "**/.gaffer/reports/**/*.xml", "**/.gaffer/reports/**/*.json", "**/junit*.xml", "**/test-results/**/*.xml", "**/test-reports/**/*.xml", "**/target/nextest/**/*.xml", "**/ctrf/**/*.json", "**/ctrf-report.json", "**/coverage/lcov.info", "**/lcov.info", ] ``` **Resolution order:** CLI flags > environment variables > config file > defaults. **Config discovery:** Walks up from the working directory looking for `.gaffer/config.toml` or `gaffer.toml`. The directory containing the config becomes the project root. ## Environment variables | Variable | Purpose | |----------|---------| | `GAFFER_TOKEN` | API token for cloud sync (overridden by `--token`) | | `GAFFER_API_URL` | API endpoint URL (overridden by `--api-url`) | On GitHub Actions, `GAFFER_TOKEN` is optional: if it's unset and the job has `permissions: id-token: write`, `gaffer test` and `gaffer upload` exchange the runner's ambient `ACTIONS_ID_TOKEN_REQUEST_URL` / `ACTIONS_ID_TOKEN_REQUEST_TOKEN` identity token for a project-scoped upload token automatically. See the [GitHub Actions guide](/docs/guides/github-actions/#without-a-stored-token-oidc). ## Default report patterns When no `--report` flag or `report_patterns` config is set, Gaffer auto-discovers: - `**/.gaffer/reports/**/*.xml` — Gaffer's own report directory - `**/.gaffer/reports/**/*.json` — Gaffer's own report directory - `**/junit*.xml` — JUnit XML reports - `**/test-results/**/*.xml` — Common test result directories - `**/test-reports/**/*.xml` — Common test report directories - `**/target/nextest/**/*.xml` — Cargo nextest JUnit output - `**/ctrf/**/*.json` — CTRF JSON reports - `**/ctrf-report.json` — Default CTRF output - `**/coverage/lcov.info` — Default coverage output - `**/lcov.info` — Root-level coverage --- ## Coverage Reports (Beta) Source: https://gaffer.sh/docs/coverage/ import { Aside } from '@astrojs/starlight/components' Gaffer can track your code coverage metrics alongside your test results. Upload coverage reports to see coverage trends, identify gaps, and monitor improvements over time. ## Supported Formats | Format | Status | File Extension | |--------|--------|---------------| | **LCOV** | Supported | `lcov.info`, `.lcov` | | **Cobertura XML** | Supported | `coverage.xml`, `cobertura.xml` | | **JaCoCo XML** | Supported | `jacoco.xml` | | **Clover XML** | Supported | `clover.xml` | ## Upload API Use the standard [Upload API](/docs/upload-api/) to send coverage files. The only difference is the file type being uploaded. | Field | Required | Description | |-------|----------|-------------| | `files` | Yes | Your coverage file (e.g., `coverage/lcov.info`, `coverage.xml`) | | `tags.commitSha` | Recommended | Git commit SHA for tracking coverage per commit | | `tags.branch` | Recommended | Git branch name for filtering coverage by branch | | `tags.type` | Optional | Set to `"coverage"` for clarity | ### Example Request ```bash title="curl" curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: YOUR_PROJECT_TOKEN" \ -F "files=@coverage/lcov.info" \ -F 'tags={"commitSha":"abc123","branch":"main","type":"coverage"}' ``` ## Generating LCOV Reports Most test frameworks support LCOV output. Here's how to configure common tools: ### Vitest ```typescript title="vitest.config.ts" import { defineConfig } from 'vitest/config' export default defineConfig({ test: { coverage: { provider: 'v8', reporter: ['lcov', 'text'], reportsDirectory: './coverage' } } }) ``` Run tests with: `pnpm vitest --coverage` ### Jest ```javascript title="jest.config.js" module.exports = { collectCoverage: true, coverageReporters: ['lcov', 'text'], coverageDirectory: './coverage' } ``` Run tests with: `pnpm jest --coverage` ### pytest Install pytest-cov: `pip install pytest-cov` ```bash title="pyproject.toml" [tool.pytest.ini_options] addopts = "--cov=src --cov-report=lcov:coverage/lcov.info" ``` Run tests with: `pytest` ## CI Integration ### GitHub Actions ```yaml title=".github/workflows/test.yml" name: Tests with Coverage on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run tests with coverage run: pnpm test --coverage - name: Upload coverage to Gaffer run: | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: ${{ secrets.GAFFER_TOKEN }}" \ -F "files=@coverage/lcov.info" \ -F 'tags={"commitSha":"${{ github.sha }}","branch":"${{ github.ref_name }}","type":"coverage"}' ``` ### GitLab CI ```yaml title=".gitlab-ci.yml" test: stage: test script: - pnpm test --coverage - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_TOKEN" \ -F "files=@coverage/lcov.info" \ -F "tags={\"commitSha\":\"$CI_COMMIT_SHA\",\"branch\":\"$CI_COMMIT_REF_NAME\",\"type\":\"coverage\"}" artifacts: reports: coverage_report: coverage_format: cobertura path: coverage/cobertura-coverage.xml ``` ## Viewing Coverage Once you've uploaded coverage reports, you can view them in the Gaffer dashboard: - **Project Analytics** - See coverage trends over time in the analytics tab - **Test Run Details** - View coverage associated with specific test runs - **File Breakdown** - Drill down into per-file coverage metrics ## GitHub Commit Status Gating Gaffer can report coverage as a GitHub commit status on pull requests, optionally failing the status if coverage doesn't meet your criteria. This requires the Gaffer GitHub App installed and a repository linked to your project (configured under Project Settings > GitHub). Configure this at **Project Settings > GitHub > Coverage Status Settings**. Three modes are available: | Mode | Behavior | |------|----------| | **Always Pass** (default) | Posts coverage as an informational status. The check always passes regardless of the coverage value. | | **Threshold** | Fails the status if coverage is below a configured percentage (e.g., 80%). Useful for enforcing a minimum coverage floor. | | **Delta** | Fails the status if coverage drops by more than a configured percentage compared to the default branch (e.g., fails if coverage decreases by more than 5%). Useful for preventing regressions without requiring a fixed minimum. | You can choose which metric to gate on: **line coverage** or **branch coverage**. Delta mode compares the PR's coverage against the most recent coverage data from the repository's default branch. If no default branch coverage exists yet, it falls back to the most recent coverage report from any branch. If no previous coverage data exists at all, the delta check passes. ## Metrics Tracked | Metric | Description | |--------|-------------| | **Line Coverage** | Percentage of executable lines covered by tests | | **Branch Coverage** | Percentage of code branches (if/else) covered | | **Function Coverage** | Percentage of functions called during tests | ## Best Practices - **Upload with every test run** - Consistent uploads enable accurate trend tracking - **Include commit SHA** - Links coverage to specific code changes - **Set a baseline** - Track coverage over time to catch regressions - **Focus on meaningful coverage** - High coverage doesn't always mean quality tests ## Troubleshooting ### No coverage data showing - Verify the coverage file exists and contains data - Check that the file uses a supported format (LCOV, Cobertura, JaCoCo, or Clover) - Ensure the upload request succeeded (check for 201 response) ### Coverage seems incorrect - Some tools report different metrics - Gaffer uses the LCOV-reported values - Verify source maps are correctly configured if using transpiled code ## Next Steps - [Upload API Reference](/docs/upload-api/) - Full API documentation - [Analytics & Metrics](/docs/analytics/) - Understanding your test metrics - [Live Demo](https://app.gaffer.sh/demo/) - See coverage tracking in action --- ## Getting Started Source: https://gaffer.sh/docs/getting-started/ import { Aside, LinkCard, CardGrid } from '@astrojs/starlight/components' ## Install the CLI Homebrew (macOS, Linux): ```bash brew install gaffer-sh/tap/gaffer ``` Or via the install script (macOS, Linux): ```bash curl -fsSL https://app.gaffer.sh/install.sh | sh ``` The script installs the `gaffer` binary to `~/.local/bin` and detects your OS and architecture automatically. ## Initialize your project Run `gaffer init` in your project root: ```bash gaffer init ``` The setup wizard will: 1. **Detect your test frameworks**: Vitest, Playwright, Jest, pytest, Go, RSpec 2. **Show reporter setup instructions**, so your framework outputs a format Gaffer can parse (JUnit XML, CTRF, or native) 3. **Authenticate via browser** (optional): creates an API token for syncing results to the dashboard 4. **Write `.gaffer/config.toml`**: stores your token and report patterns 5. **Add `.gaffer/` to `.gitignore`** ## Run your tests Wrap your existing test command with `gaffer test`: ```bash gaffer test -- npm test ``` Gaffer runs your command, parses the results, and prints an enriched summary: ``` gaffer 40 passed 2 failed 3 skipped 12.4s Health: 87 (good) ^ Slow: p95 245.3ms Flaky: 2 tests src/auth.test.ts > login — 40% flip rate (4/10 runs) src/api.test.ts > timeout handler — 20% flip rate (2/10 runs) Coverage: 78.5% lines (1234/1572) Synced: 1 run uploaded ``` Works with any test command: `pytest`, `go test ./...`, `cargo test`, `pnpm test`, etc. ## Sync to the dashboard If you authenticated during `gaffer init`, results sync automatically after each run. If you skipped that step: 1. [Sign up](https://app.gaffer.sh/register?utm_source=gaffer&utm_medium=website&utm_campaign=docs&utm_content=inline) and create a project 2. Copy the API token from your project settings 3. Add it to your config: ```bash gaffer init # re-run to authenticate via browser ``` Or set the token directly: ```toml # .gaffer/config.toml [project] token = "gaf_..." ``` Once synced, your team can view test history, trends, and analytics in the [dashboard](https://app.gaffer.sh/demo). ## Add to CI Install the CLI and set `GAFFER_TOKEN` as a secret. Example for GitHub Actions (see the full [GitHub Actions guide](/docs/guides/github-actions/) for more options): ```yaml - name: Install Gaffer CLI run: curl -fsSL https://app.gaffer.sh/install.sh | sh - name: Run tests run: gaffer test -- npm test env: GAFFER_TOKEN: ${{ secrets.GAFFER_TOKEN }} ``` ## Next steps --- ## Azure DevOps Source: https://gaffer.sh/docs/guides/azure-devops/ import { Aside } from '@astrojs/starlight/components'; Azure DevOps Pipelines is Microsoft's CI/CD solution for cloud and on-premises deployments. With Gaffer, you can automatically upload and share test reports from your Azure Pipelines. ## Prerequisites - A [Gaffer account](https://app.gaffer.sh/register?utm_source=gaffer&utm_medium=website&utm_campaign=docs&utm_content=inline) with a project - Your [project token](/docs/upload-api/) - An Azure DevOps project with a pipeline configured ## Setup ### 1. Add your project token as a pipeline variable 1. Go to your Azure DevOps project 2. Navigate to **Pipelines** → select your pipeline → **Edit** 3. Click **Variables** → **New variable** 4. Name: `GAFFER_PROJECT_TOKEN` 5. Value: Your Gaffer project token 6. Check **Keep this value secret** 7. Click **OK** and **Save** ### 2. Add the upload step to your pipeline ```yaml trigger: - main pool: vmImage: 'ubuntu-latest' steps: - task: NodeTool@0 inputs: versionSpec: '20.x' - script: npm ci displayName: 'Install dependencies' - script: npm test displayName: 'Run tests' - script: | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $(GAFFER_PROJECT_TOKEN)" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"$(Build.SourceVersion)","branch":"$(Build.SourceBranchName)"}' displayName: 'Upload to Gaffer' condition: always() ``` ## Environment Variables Azure DevOps provides these predefined variables: | Variable | Description | Example | |----------|-------------|---------| | `$(Build.SourceVersion)` | Full commit SHA | `abc123def456...` | | `$(Build.SourceBranchName)` | Branch name (short) | `main`, `feature/login` | | `$(Build.SourceBranch)` | Full branch ref | `refs/heads/main` | | `$(System.PullRequest.SourceBranch)` | PR source branch | `refs/heads/feature/login` | | `$(Build.BuildNumber)` | Build number | `20231215.1` | | `$(Build.Repository.Name)` | Repository name | `my-app` | ## Examples ### Playwright ```yaml trigger: - main pool: vmImage: 'ubuntu-latest' steps: - task: NodeTool@0 inputs: versionSpec: '20.x' - script: npm ci displayName: 'Install dependencies' - script: npx playwright install --with-deps displayName: 'Install Playwright browsers' - script: npx playwright test displayName: 'Run Playwright tests' - script: | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $(GAFFER_PROJECT_TOKEN)" \ -F "files=@playwright-report/index.html" \ -F 'tags={"commitSha":"$(Build.SourceVersion)","branch":"$(Build.SourceBranchName)","test_framework":"playwright","test_suite":"e2e"}' displayName: 'Upload to Gaffer' condition: always() - publish: playwright-report artifact: playwright-report condition: always() ``` ### Jest with JUnit Reporter ```yaml trigger: - main pool: vmImage: 'ubuntu-latest' steps: - task: NodeTool@0 inputs: versionSpec: '20.x' - script: npm ci displayName: 'Install dependencies' - script: npm test -- --reporters=default --reporters=jest-junit displayName: 'Run Jest tests' env: JEST_JUNIT_OUTPUT_DIR: ./test-results - script: | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $(GAFFER_PROJECT_TOKEN)" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"$(Build.SourceVersion)","branch":"$(Build.SourceBranchName)","test_framework":"jest"}' displayName: 'Upload to Gaffer' condition: always() - task: PublishTestResults@2 inputs: testResultsFormat: 'JUnit' testResultsFiles: '**/junit.xml' condition: always() ``` ### pytest ```yaml trigger: - main pool: vmImage: 'ubuntu-latest' steps: - task: UsePythonVersion@0 inputs: versionSpec: '3.11' - script: pip install pytest pytest-html displayName: 'Install dependencies' - script: pytest --html=report.html --self-contained-html displayName: 'Run pytest' - script: | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $(GAFFER_PROJECT_TOKEN)" \ -F "files=@report.html" \ -F 'tags={"commitSha":"$(Build.SourceVersion)","branch":"$(Build.SourceBranchName)","test_framework":"pytest"}' displayName: 'Upload to Gaffer' condition: always() - publish: report.html artifact: pytest-report condition: always() ``` ## Using CTRF Format For a standardized format across all your test frameworks, consider using [CTRF](/docs/guides/ctrf/): ```yaml # Install the CTRF reporter for your framework: # npm install --save-dev jest-ctrf-json-reporter # npm install --save-dev playwright-ctrf-json-reporter # npm install --save-dev vitest-ctrf-json-reporter - script: npm install --save-dev jest-ctrf-json-reporter displayName: 'Install CTRF reporter' - script: npm test -- --reporter=jest-ctrf-json-reporter displayName: 'Run tests with CTRF' - script: | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $(GAFFER_PROJECT_TOKEN)" \ -F "files=@ctrf-report.json" \ -F 'tags={"commitSha":"$(Build.SourceVersion)","branch":"$(Build.SourceBranchName)"}' displayName: 'Upload CTRF to Gaffer' condition: always() ``` ## Pull Request Pipelines For PR pipelines, use the source branch: ```yaml trigger: none pr: - main steps: - script: npm ci && npm test displayName: 'Run tests' - script: | # Extract clean branch name from PR source BRANCH_NAME=$(echo "$(System.PullRequest.SourceBranch)" | sed 's|refs/heads/||') curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $(GAFFER_PROJECT_TOKEN)" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"$(Build.SourceVersion)","branch":"'"$BRANCH_NAME"'"}' displayName: 'Upload to Gaffer' condition: always() ``` ## Troubleshooting ### Report not uploading - Verify `GAFFER_PROJECT_TOKEN` is set as a secret variable - Ensure `condition: always()` is set on the upload step - Check the file path is correct ### 401 Unauthorized - Check your project token starts with `gfr_` - Verify the variable name matches exactly (case-sensitive) ### Variable syntax Azure DevOps uses `$(VariableName)` syntax, not `$VARIABLE_NAME`. Make sure to use the correct format. ### Branch name includes refs/heads/ Use `$(Build.SourceBranchName)` instead of `$(Build.SourceBranch)` for clean branch names. ## Next Steps - [CTRF Guide](/docs/guides/ctrf/) - Use the universal test format - [Upload API Reference](/docs/upload-api/) - Full API documentation - [Slack Integration](/docs/integrations/slack/) - Get test results in Slack **Other CI Providers:** [GitHub Actions](/docs/guides/github-actions/) · [GitLab CI](/docs/guides/gitlab-ci/) · [CircleCI](/docs/guides/circleci/) · [Jenkins](/docs/guides/jenkins/) · [Bitbucket](/docs/guides/bitbucket-pipelines/) --- ## Bitbucket Pipelines Source: https://gaffer.sh/docs/guides/bitbucket-pipelines/ import { Aside } from '@astrojs/starlight/components'; Bitbucket Pipelines is Atlassian's integrated CI/CD solution for Bitbucket Cloud. With Gaffer, you can automatically upload and share test reports from your pipelines. ## Prerequisites - A [Gaffer account](https://app.gaffer.sh/register?utm_source=gaffer&utm_medium=website&utm_campaign=docs&utm_content=inline) with a project - Your [project token](/docs/upload-api/) - A Bitbucket repository with a `bitbucket-pipelines.yml` file ## Setup ### 1. Add your project token as a repository variable 1. Go to your Bitbucket repository 2. Navigate to **Repository settings** → **Pipelines** → **Repository variables** 3. Add a new variable: - Name: `GAFFER_PROJECT_TOKEN` - Value: Your Gaffer project token - Check **Secured** to hide it in logs ### 2. Add the upload step to your pipeline ```yaml image: node:20 pipelines: default: - step: name: Test caches: - node script: - npm ci - npm test after-script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$BITBUCKET_COMMIT"'","branch":"'"$BITBUCKET_BRANCH"'"}' ``` ## Environment Variables Bitbucket Pipelines provides these variables: | Variable | Description | Example | |----------|-------------|---------| | `$BITBUCKET_COMMIT` | Full commit SHA | `abc123def456...` | | `$BITBUCKET_BRANCH` | Branch name | `main`, `feature/login` | | `$BITBUCKET_PR_ID` | Pull request ID (if applicable) | `42` | | `$BITBUCKET_PR_DESTINATION_BRANCH` | PR target branch | `main` | | `$BITBUCKET_REPO_SLUG` | Repository slug | `my-app` | | `$BITBUCKET_BUILD_NUMBER` | Build number | `123` | ## Examples ### Playwright ```yaml image: mcr.microsoft.com/playwright:v1.40.0-jammy pipelines: default: - step: name: Playwright Tests script: - npm ci - npx playwright test after-script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@playwright-report/index.html" \ -F 'tags={"commitSha":"'"$BITBUCKET_COMMIT"'","branch":"'"$BITBUCKET_BRANCH"'","test_framework":"playwright","test_suite":"e2e"}' artifacts: - playwright-report/** ``` ### Jest with JUnit Reporter ```yaml image: node:20 pipelines: default: - step: name: Jest Tests caches: - node script: - npm ci - npm test -- --reporters=default --reporters=jest-junit after-script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@junit.xml" \ -F 'tags={"commitSha":"'"$BITBUCKET_COMMIT"'","branch":"'"$BITBUCKET_BRANCH"'","test_framework":"jest"}' ``` ### pytest ```yaml image: python:3.11 pipelines: default: - step: name: pytest script: - pip install pytest pytest-html - pytest --html=report.html --self-contained-html after-script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@report.html" \ -F 'tags={"commitSha":"'"$BITBUCKET_COMMIT"'","branch":"'"$BITBUCKET_BRANCH"'","test_framework":"pytest"}' artifacts: - report.html ``` ## Using CTRF Format For a standardized format across all your test frameworks, consider using [CTRF](/docs/guides/ctrf/): ```yaml - step: name: Test with CTRF script: - npm ci # Install the CTRF reporter for your framework: # npm install --save-dev jest-ctrf-json-reporter # npm install --save-dev playwright-ctrf-json-reporter # npm install --save-dev vitest-ctrf-json-reporter - npm install --save-dev jest-ctrf-json-reporter - npm test -- --reporter=jest-ctrf-json-reporter after-script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@ctrf-report.json" \ -F 'tags={"commitSha":"'"$BITBUCKET_COMMIT"'","branch":"'"$BITBUCKET_BRANCH"'"}' ``` ## Pull Request Pipelines For pull request pipelines, include the PR information: ```yaml pipelines: pull-requests: '**': - step: name: PR Tests script: - npm ci - npm test after-script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$BITBUCKET_COMMIT"'","branch":"'"$BITBUCKET_BRANCH"'","pr":"'"$BITBUCKET_PR_ID"'"}' ``` ## Troubleshooting ### Report not uploading - Verify `GAFFER_PROJECT_TOKEN` is set in repository variables - Check the variable is marked as **Secured** - Ensure `after-script` is used (runs even when tests fail) ### 401 Unauthorized - Check your project token starts with `gfr_` - Verify the token is correctly copied (no extra spaces) ### Variable not available Some variables like `$BITBUCKET_PR_ID` are only available in pull request pipelines. Check the [Bitbucket variables documentation](https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets/) for availability. ## Next Steps - [CTRF Guide](/docs/guides/ctrf/) - Use the universal test format - [Upload API Reference](/docs/upload-api/) - Full API documentation - [Slack Integration](/docs/integrations/slack/) - Get test results in Slack **Other CI Providers:** [GitHub Actions](/docs/guides/github-actions/) · [GitLab CI](/docs/guides/gitlab-ci/) · [CircleCI](/docs/guides/circleci/) · [Jenkins](/docs/guides/jenkins/) · [Azure DevOps](/docs/guides/azure-devops/) --- ## CircleCI Source: https://gaffer.sh/docs/guides/circleci/ import { Aside } from '@astrojs/starlight/components' import JsonLd from '../../../../components/JsonLd.astro'
CircleCI's own test insights only cover a single project's build history inside CircleCI. Gaffer adds cross-build history, flaky detection, and a report link that works for anyone, CircleCI account or not. ## Prerequisites - A [Gaffer account](https://app.gaffer.sh/register?utm_source=gaffer&utm_medium=website&utm_campaign=docs&utm_content=inline) with a project - Your [project token](/docs/upload-api/) - A CircleCI project with a `config.yml` file ## How do I add my Gaffer project token to CircleCI? **Add it as a project environment variable, or a context if you have more than one CircleCI project.** For a single project: 1. Go to your CircleCI project settings 2. Navigate to **Environment Variables** 3. Click **Add Environment Variable** 4. Name: `GAFFER_PROJECT_TOKEN` 5. Value: your Gaffer project token ## How do I share one project token across multiple CircleCI projects? **Use a CircleCI context instead of a project-level environment variable.** Contexts are defined once at the organization level and attached to jobs in any project, so you set `GAFFER_PROJECT_TOKEN` once instead of re-entering it per project. 1. Go to **Organization Settings** → **Contexts** 2. Create a context, e.g. `gaffer` 3. Add `GAFFER_PROJECT_TOKEN` as an environment variable on the context 4. Reference it in each project's `config.yml` with `context: gaffer` ## How do I upload a report after a CircleCI job runs? **Add a `run` step with `when: always`.** Without it, the step is skipped whenever an earlier step in the job fails, which is exactly when you want the report. ```yaml version: 2.1 jobs: test: docker: - image: cimg/node:20.0 steps: - checkout - run: name: Install dependencies command: npm ci - run: name: Run tests command: npm test - run: name: Upload to Gaffer when: always command: | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$CIRCLE_SHA1"'","branch":"'"$CIRCLE_BRANCH"'"}' - store_test_results: path: test-results workflows: test: jobs: - test ``` Keeping `store_test_results` alongside the Gaffer upload is intentional: it feeds CircleCI's own Insights timing charts for this one project, while Gaffer keeps the report and history independent of CircleCI. ## Which CircleCI variables carry commit and branch info? | Variable | Description | Example | |----------|-------------|---------| | `$CIRCLE_SHA1` | Full commit SHA | `abc123def456...` | | `$CIRCLE_BRANCH` | Branch name (empty on tag-triggered builds) | `main`, `feature/login` | | `$CIRCLE_TAG` | Git tag name (only set on tag-triggered builds) | `v1.2.0` | | `$CIRCLE_PR_NUMBER` | Pull request number (only available on forked PRs) | `42` | | `$CIRCLE_NODE_INDEX` | Index of the current container under `parallelism` | `0`, `1`, `2` | | `$CIRCLE_BUILD_NUM` | Build number | `123` | ## Framework examples ### Playwright ```yaml version: 2.1 jobs: playwright: docker: - image: mcr.microsoft.com/playwright:v1.40.0-jammy steps: - checkout - run: name: Install dependencies command: npm ci - run: name: Run Playwright tests command: npx playwright test - run: name: Upload to Gaffer when: always command: | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@playwright-report/index.html" \ -F 'tags={"commitSha":"'"$CIRCLE_SHA1"'","branch":"'"$CIRCLE_BRANCH"'","test_framework":"playwright","test_suite":"e2e"}' - store_artifacts: path: playwright-report ``` ### Jest with JUnit reporter ```yaml version: 2.1 jobs: test: docker: - image: cimg/node:20.0 steps: - checkout - run: name: Install dependencies command: npm ci - run: name: Run Jest tests command: npm test -- --reporters=default --reporters=jest-junit environment: JEST_JUNIT_OUTPUT_DIR: ./test-results - run: name: Upload to Gaffer when: always command: | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$CIRCLE_SHA1"'","branch":"'"$CIRCLE_BRANCH"'","test_framework":"jest"}' - store_test_results: path: test-results ``` ### pytest ```yaml version: 2.1 jobs: pytest: docker: - image: cimg/python:3.11 steps: - checkout - run: name: Install dependencies command: pip install pytest pytest-html - run: name: Run pytest command: pytest --html=report.html --self-contained-html - run: name: Upload to Gaffer when: always command: | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@report.html" \ -F 'tags={"commitSha":"'"$CIRCLE_SHA1"'","branch":"'"$CIRCLE_BRANCH"'","test_framework":"pytest"}' - store_artifacts: path: report.html ``` ### Using CTRF format For a standardized format across all your test frameworks, use [CTRF](/docs/guides/ctrf/): ```yaml - run: name: Install CTRF reporter # Choose the reporter for your framework: # npm install --save-dev jest-ctrf-json-reporter # npm install --save-dev playwright-ctrf-json-reporter # npm install --save-dev vitest-ctrf-json-reporter command: npm install --save-dev jest-ctrf-json-reporter - run: name: Run tests with CTRF command: npm test -- --reporter=jest-ctrf-json-reporter - run: name: Upload CTRF to Gaffer when: always command: | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@ctrf-report.json" \ -F 'tags={"commitSha":"'"$CIRCLE_SHA1"'","branch":"'"$CIRCLE_BRANCH"'"}' ``` ## Does CircleCI's own test insights replace Gaffer? **No, they cover different scope.** `store_test_results` feeds CircleCI Insights, which summarizes timing and flake rate for a single project's own build history, visible only inside that CircleCI org. Gaffer keeps a report you can link to someone without CircleCI access, and correlates runs against the commit and branch tags you send rather than CircleCI's internal job graph. Keep `store_test_results` for CircleCI's native charts and add Gaffer for anything that needs to leave CircleCI, like a report link in a customer ticket or a Slack alert. ## Is there an official Gaffer CircleCI orb? **No.** Gaffer publishes a GitHub Action and a CLI, but not a CircleCI orb. On CircleCI, the two supported paths are calling the upload API directly with `curl`, as every example on this page does, or running the `gaffer` CLI as a normal `run` step. If you want to avoid repeating the same `run` block across jobs, define it once as a reusable command in CircleCI config 2.1 rather than reaching for a third-party orb: ```yaml version: 2.1 commands: upload-to-gaffer: parameters: report_path: type: string test_framework: type: string default: "" steps: - run: name: Upload to Gaffer when: always command: | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@<< parameters.report_path >>" \ -F 'tags={"commitSha":"'"$CIRCLE_SHA1"'","branch":"'"${CIRCLE_BRANCH:-$CIRCLE_TAG}"'","test_framework":"<< parameters.test_framework >>"}' jobs: test: docker: - image: cimg/node:20.0 steps: - checkout - run: npm ci - run: npm test - upload-to-gaffer: report_path: test-results/junit.xml test_framework: jest ``` ## Why is $CIRCLE_BRANCH empty on some builds? **CircleCI only populates `$CIRCLE_BRANCH` on branch-triggered builds.** A build triggered by pushing a git tag sets `$CIRCLE_TAG` instead and leaves `$CIRCLE_BRANCH` unset. If your release pipeline runs on tags, an upload that only reads `$CIRCLE_BRANCH` sends an empty branch tag to Gaffer. Fall back explicitly: ```bash BRANCH="${CIRCLE_BRANCH:-$CIRCLE_TAG}" curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$CIRCLE_SHA1"'","branch":"'"$BRANCH"'"}' ``` ## How do I upload from CircleCI's parallel test splitting without runs colliding? **Tag each container's upload with `$CIRCLE_NODE_INDEX`.** Gaffer records each `curl` call as an independent test run; `parallelism` fans the same job out across N containers, and without a per-container tag those N uploads land as N indistinguishable runs against the same commit. ```yaml jobs: test: parallelism: 4 steps: - run: name: Run tests command: | circleci tests glob "**/*.spec.js" | circleci tests split | xargs npm test -- - run: name: Upload to Gaffer when: always command: | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$CIRCLE_SHA1"'","branch":"'"$CIRCLE_BRANCH"'","container":"'"$CIRCLE_NODE_INDEX"'"}' ``` ## What goes wrong and how do I fix it? | Symptom | Cause | Fix | |---------|-------|-----| | Upload step never runs | Missing `when: always` on the upload `run` step | Add `when: always`, CircleCI skips steps by default once an earlier step fails | | Branch shows as empty in Gaffer | Build triggered by a tag, not a branch push, and `$CIRCLE_BRANCH` is unset in that case | Fall back with `${CIRCLE_BRANCH:-$CIRCLE_TAG}` | | 401 Unauthorized on forked-PR builds | CircleCI withholds project environment variables from fork-triggered builds by design | Skip the upload on fork builds (see below). Do not turn on "Pass secrets to builds from forked pull requests" to work around this | | Same commit shows N nearly-identical runs | `parallelism` fans a job across containers, and each container uploads independently | Add a `container` or similar tag using `$CIRCLE_NODE_INDEX` | | 401 Unauthorized, variable looks correct | Token missing the `gfr_` prefix, or the variable is defined on the wrong project/context | Re-copy the token, confirm the context is attached to the job if you're using one | ## Next Steps - [CTRF Guide](/docs/guides/ctrf/) - Use the universal test format - [Upload API Reference](/docs/upload-api/) - Full API documentation - [Slack Integration](/docs/integrations/slack/) - Get test results in Slack **Other CI Providers:** [GitHub Actions](/docs/guides/github-actions/) · [GitLab CI](/docs/guides/gitlab-ci/) · [Jenkins](/docs/guides/jenkins/) · [Bitbucket](/docs/guides/bitbucket-pipelines/) · [Azure DevOps](/docs/guides/azure-devops/)
--- ## Fix jest-ctrf-json-reporter ENOENT: CTRF Guide Source: https://gaffer.sh/docs/guides/ctrf/ import { Aside } from '@astrojs/starlight/components'; CTRF (Common Test Report Format) is a universal JSON schema for test results. ## Why does jest-ctrf-json-reporter throw ENOENT? Because a directory path was put inside `outputFile`. The reporter writes to `path.join(outputDir, outputFile)` and only creates `outputDir` (with `mkdirSync(..., { recursive: true })`), so any directory segments embedded in `outputFile` are never created and the write fails with `ENOENT: no such file or directory`. The fix is to move the directory into `outputDir` and keep `outputFile` a bare filename. ```javascript // jest.config.js ['jest-ctrf-json-reporter', { outputFile: 'ctrf/coverage/report.json' }] // ENOENT ['jest-ctrf-json-reporter', { outputDir: 'ctrf/coverage', outputFile: 'report.json' }] // works ``` Both paths are resolved relative to the process working directory, which is your repo root under `npm test` but not necessarily inside a container or a monorepo package. There is no `CTRF_OUTPUT_PATH` environment variable in any CTRF reporter: `outputDir` in the config is the only supported way to move the file. `playwright-ctrf-json-reporter` and `cypress-ctrf-json-reporter` behave the same way. [Full troubleshooting below](/docs/guides/ctrf/#enoent-no-such-file-or-directory-writing-the-ctrf-report). ## Why Use CTRF? CTRF gives you one report shape across every testing tool, framework, and language you run. - **Universal**: Works with any test framework that has a CTRF reporter - **Consistent**: Same format across Jest, Playwright, pytest, RSpec, and more - **Rich data**: Includes timing, retries, flaky test detection, and metadata - **Open standard**: Community-driven, MIT-licensed specification at [ctrf.io](https://ctrf.io) ## Supported Frameworks CTRF has reporters for most popular test frameworks: | Framework | Package | Language | |-----------|---------|----------| | Playwright | [`playwright-ctrf-json-reporter`](https://www.npmjs.com/package/playwright-ctrf-json-reporter) | JavaScript/TypeScript | | Jest | [`jest-ctrf-json-reporter`](https://www.npmjs.com/package/jest-ctrf-json-reporter) | JavaScript/TypeScript | | Vitest | [`vitest-ctrf-json-reporter`](https://www.npmjs.com/package/vitest-ctrf-json-reporter) | JavaScript/TypeScript | | Cypress | [`cypress-ctrf-json-reporter`](https://www.npmjs.com/package/cypress-ctrf-json-reporter) | JavaScript/TypeScript | | Mocha | [`mocha-ctrf-json-reporter`](https://www.npmjs.com/package/mocha-ctrf-json-reporter) | JavaScript/TypeScript | | pytest | [`pytest-ctrf`](https://pypi.org/project/pytest-ctrf/) | Python | | Go | [`ctrf-go-json-reporter`](https://github.com/ctrf-io/go-ctrf-json-reporter) | Go | | JUnit XML | [`junit-to-ctrf`](https://github.com/ctrf-io/junit-to-ctrf) | Any (CLI converter, not a reporter) | See the full list at [ctrf.io](https://ctrf.io). ## Installation ### Playwright ```bash npm install playwright-ctrf-json-reporter --save-dev ``` ```typescript // playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ reporter: [ ['playwright-ctrf-json-reporter', { outputDir: '.', outputFile: 'ctrf-report.json' }], ['list'], // Also show in console ], }); ``` The reporter writes to `/`. `outputDir` defaults to `ctrf`, so setting it to `.` puts the report at `./ctrf-report.json`, which is the path the upload examples below use. ### Jest ```bash npm install jest-ctrf-json-reporter --save-dev ``` ```javascript // jest.config.js module.exports = { reporters: [ 'default', ['jest-ctrf-json-reporter', { outputDir: '.', outputFile: 'ctrf-report.json' }], ], }; ``` ### Vitest ```bash npm install vitest-ctrf-json-reporter --save-dev ``` ```typescript // vitest.config.ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { reporters: [ 'default', ['vitest-ctrf-json-reporter', { outputDir: '.', outputFile: 'ctrf-report.json' }], ], }, }); ``` ### pytest ```bash pip install pytest-ctrf ``` ```bash pytest --ctrf ctrf-report.json ``` ## Troubleshooting ### `ENOENT: no such file or directory` writing the CTRF report ``` ENOENT: no such file or directory, open 'ctrf/coverage/report.json' ``` The cause is putting a directory path inside `outputFile`. The JS reporters take two separate options and write to `path.join(outputDir, outputFile)`. They create `outputDir` with `mkdirSync(outputDir, { recursive: true })`, but nothing creates the directory segments you embed in `outputFile`, so the write lands in a directory that never got made. ```javascript // jest.config.js // ❌ Wrong: resolves to ctrf/coverage/report.json (default outputDir is `ctrf`), // and the coverage/ segment is never created ['jest-ctrf-json-reporter', { outputFile: 'coverage/report.json' }] // ✅ Correct: outputDir is created recursively, outputFile is just the filename ['jest-ctrf-json-reporter', { outputDir: 'ctrf/coverage', outputFile: 'report.json' }] ``` `playwright-ctrf-json-reporter` and `cypress-ctrf-json-reporter` behave identically, including the `ctrf` default for `outputDir`. `vitest-ctrf-json-reporter` uses the same option shape with different defaults (`vitest-ctrf/report.json`). If a reporter's README doesn't document `outputDir`, add a `mkdir -p` step before the test run and keep `outputFile` a plain filename. The same rule explains the other half of this error: with no options at all, the report is written to `ctrf/ctrf-report.json`, not `./ctrf-report.json`. Point your upload step at the real path or set `outputDir: '.'`. ### `tests` array missing or empty If the report is generated but Gaffer reports zero tests parsed, the reporter probably wasn't invoked. - **Default reporter overridden.** The `reporters` array in `jest.config.js` (or the equivalent in your framework) replaces the default. Make sure both `'default'` and the CTRF reporter are listed, otherwise tests run but no CTRF file is written. - **Tests crashed before reporting.** If the suite throws during setup (a `beforeAll` that fails to connect to a service, for example), some reporters bail before writing the file. Check the CI logs for the underlying error before assuming the reporter is broken. ## Uploading CTRF Reports Once you have a CTRF JSON file, upload it to Gaffer: ```bash curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: YOUR_PROJECT_TOKEN" \ -F "files=@ctrf-report.json" \ -F 'tags={"commitSha":"abc123","branch":"main"}' ``` ### GitHub Actions ```yaml - name: Run tests run: npm test - name: Upload CTRF report to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./ctrf-report.json commit_sha: ${{ github.sha }} branch: ${{ github.ref_name }} ``` ### GitLab CI ```yaml test: script: - npm ci - npm test after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@ctrf-report.json" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'"}' ``` ## CTRF Report Structure A CTRF report contains standardized test result data: ```json { "results": { "tool": { "name": "playwright" }, "summary": { "tests": 42, "passed": 40, "failed": 1, "pending": 0, "skipped": 1, "other": 0, "start": 1703520000000, "stop": 1703520060000 }, "tests": [ { "name": "should login successfully", "status": "passed", "duration": 1234, "retries": 0, "flaky": false }, { "name": "should display dashboard", "status": "failed", "duration": 5678, "message": "Element not found: #dashboard" } ] } } ``` ## Benefits with Gaffer When you upload CTRF reports to Gaffer, you get: - **Structured analytics**: Pass rates, duration trends, flaky test detection - **Cross-framework comparison**: Compare results from different test suites - **Failure patterns**: See which tests fail most frequently - **Historical tracking**: See how tests perform over time ## Next Steps **CI Provider Guides:** - [GitHub Actions](/docs/guides/github-actions/) - Use the official Gaffer Action - [GitLab CI](/docs/guides/gitlab-ci/) - GitLab pipeline integration - [CircleCI](/docs/guides/circleci/) - CircleCI workflow integration - [Jenkins](/docs/guides/jenkins/) - Jenkins pipeline integration - [Bitbucket Pipelines](/docs/guides/bitbucket-pipelines/) - Bitbucket integration - [Azure DevOps](/docs/guides/azure-devops/) - Azure Pipelines integration **Reference:** - [Upload API](/docs/upload-api/) - Full API documentation - [cURL Guide](/docs/guides/curl/) - Manual uploads and debugging ## Get Started Gaffer's free tier includes 500 MB of storage with 7-day retention. Upload your first CTRF report in under 5 minutes. [Start Uploading CTRF Reports - Free](https://app.gaffer.sh/register?utm_source=gaffer&utm_medium=website&utm_campaign=docs&utm_content=inline) --- ## cURL Guide Source: https://gaffer.sh/docs/guides/curl/ import { Aside } from '@astrojs/starlight/components' import JsonLd from '../../../../components/JsonLd.astro'
This guide is the plain-HTTP path: no CLI to install, no GitHub Action, no orb. Use it when your CI system doesn't have a dedicated Gaffer guide, when you're debugging what the CLI or Action does under the hood, or when you just want to see a report on your project without adding a dependency. ## Prerequisites - A [Gaffer account](https://app.gaffer.sh/register?utm_source=gaffer&utm_medium=website&utm_campaign=docs&utm_content=inline) with a project - Your [project token](/docs/upload-api/), a string starting with `gfr_` followed by 64 hex characters - cURL installed (available by default on macOS and most Linux distributions) ## What's the simplest way to upload a report? **POST the file to `/api/upload` as multipart form data with your project token in the `X-API-Key` header.** ```bash title="curl" curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: YOUR_PROJECT_TOKEN" \ -F "files=@path/to/test-report.html" ``` Replace `YOUR_PROJECT_TOKEN` with your actual project token, and update the file path to point to your test report. Don't set `Content-Type` yourself: curl generates the `multipart/form-data` boundary header for you when you use `-F`, and overriding it breaks the request. ## How do I attach commit and branch metadata? **Pass a `tags` field alongside `files`, as a JSON string.** `commitSha` and `branch` are the two tags Gaffer uses to correlate a run with your code. ```bash title="curl" curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: YOUR_PROJECT_TOKEN" \ -F "files=@playwright-report/index.html" \ -F 'tags={"commitSha":"abc123def456","branch":"main","test_framework":"playwright","test_suite":"e2e"}' ``` ## How do I upload more than one file at once? **Repeat the `-F "files=..."` flag once per file.** Gaffer accepts multiple files in the same request and parses each one it recognizes. ```bash title="curl" curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: YOUR_PROJECT_TOKEN" \ -F "files=@playwright-report/index.html" \ -F "files=@playwright-report/data/report.json" \ -F "files=@playwright-report/screenshots/failed-test.png" \ -F 'tags={"commitSha":"abc123","branch":"feature/login"}' ``` ## How do I pull git metadata in automatically? **Use shell command substitution inside the `tags` field.** This works in any CI system with a git checkout, which is why it's the fallback used later in this guide for CI systems without a dedicated integration. ```bash title="curl" curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: YOUR_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$(git rev-parse HEAD)"'","branch":"'"$(git branch --show-current)"'"}' ``` ## What's the full request shape? The request is `multipart/form-data` over HTTPS. This is the complete contract, not just the fields the examples above use: | Part | Required | Details | |------|----------|---------| | `X-API-Key` header | Yes | Your project token (`gfr_` + 64 hex chars). Missing header returns 401 with "API key required. Provide X-API-Key header." | | `files` field | Yes | One or more files, repeat the field per file. At least one file with the exact field name `files` is required. | | `tags` field | No | A JSON object of string keys to string values. Max 20 tags, keys max 64 characters, values max 256 characters, and the whole JSON blob capped at 64 KB. | Filenames are sanitized server-side; a filename with path-traversal or control characters is rejected with a 400 before anything touches storage. ## What does a successful response look like? **A 201 with a `testRun` object and a `files` array**, both derived from what you sent: ```json title="Response" { "testRun": { "id": "abc123xyz", "uniqueId": "1701234567890", "projectId": "proj_abc123", "commitSha": "abc123def456", "branch": "main", "tags": { "commitSha": "abc123def456", "branch": "main" }, "createdAt": "2024-01-15T10:30:00.000Z" }, "files": [ { "filename": "index.html", "size": 245678, "path": "org_123/proj_456/1701234567890/index.html", "contentType": "text/html" } ] } ``` If you're scripting against this, pull fields out with `jq` instead of parsing JSON by hand: ```bash title="curl" RESPONSE=$(curl -s -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: YOUR_PROJECT_TOKEN" \ -F "files=@report.html") TEST_RUN_ID=$(echo "$RESPONSE" | jq -r '.testRun.id') ``` Your project token also authenticates read requests, so you can poll a run's processing status the same way without opening the dashboard: ```bash title="curl" curl -s "https://app.gaffer.sh/api/v1/user/projects/$PROJECT_ID/upload-sessions/$SESSION_ID" \ -H "X-API-Key: YOUR_PROJECT_TOKEN" | jq -r '.session.processingStatus' ``` `processingStatus` moves through `pending` → `processing` → `completed` (or `error`). ## When do I need multipart upload instead of a single POST? **Above 75 MB total request size.** `POST /api/upload` enforces a 75 MB ceiling and returns a 413 (`file_too_large_for_v1`) past it, well under Cloudflare's own edge limit, so the error is a clean, actionable one instead of a raw connection failure. For files up to 5 GB (R2's per-object ceiling), use the multipart upload (MPU) endpoints directly. The MPU flow is three calls: create the upload, PUT each part, then complete it. The server tells you the part size, don't invent your own: ```bash title="mpu-upload.sh" #!/bin/bash set -euo pipefail FILE="huge-playwright-trace.zip" FILE_SIZE=$(stat -f%z "$FILE" 2>/dev/null || stat -c%s "$FILE") # 1. Start the multipart upload CREATE=$(curl -s -X POST https://app.gaffer.sh/api/upload/mpu/create \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"fileName\":\"$FILE\",\"fileSize\":$FILE_SIZE,\"tags\":{\"commitSha\":\"$(git rev-parse HEAD)\",\"branch\":\"$(git branch --show-current)\"}}") UPLOAD_ID=$(echo "$CREATE" | jq -r '.uploadId') SESSION_ID=$(echo "$CREATE" | jq -r '.uploadSessionId') PART_SIZE=$(echo "$CREATE" | jq -r '.partSize') # 2. Split the file and PUT each part, collecting the etags it returns split -b "$PART_SIZE" -d -a 4 "$FILE" /tmp/gaffer-part- PARTS="[]" i=1 for part in /tmp/gaffer-part-*; do ETAG=$(curl -s -X PUT "https://app.gaffer.sh/api/upload/mpu/part?uploadSessionId=$SESSION_ID&uploadId=$UPLOAD_ID&partNumber=$i" \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ --data-binary "@$part" | jq -r '.etag') PARTS=$(echo "$PARTS" | jq --argjson pn "$i" --arg et "$ETAG" '. + [{"partNumber": $pn, "etag": $et}]') i=$((i + 1)) done rm -f /tmp/gaffer-part-* # 3. Complete the upload once every part has landed curl -s -X POST https://app.gaffer.sh/api/upload/mpu/complete \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"uploadSessionId\":\"$SESSION_ID\",\"uploadId\":\"$UPLOAD_ID\",\"parts\":$PARTS}" ``` The server picks the part size from your declared `fileSize`: 10 MB parts under 500 MB, 25 MB parts up to 2 GB, and 50 MB parts up to the 5 GB ceiling. If a part upload fails partway through, call `DELETE /api/upload/mpu/abort?uploadSessionId=...&uploadId=...` to clean up the orphaned R2 upload rather than leaving it dangling. ## What HTTP errors can the upload endpoint return? Every error response is JSON with a `reason` field, useful if you're branching on it in a script rather than eyeballing logs. | Status | `reason` | Cause | Fix | |--------|----------|-------|-----| | 400 | `no_files` | No `files` field, or a field named something else | Use `-F "files=@path"` with the exact field name | | 400 | `invalid_tags` | `tags` isn't valid JSON, or exceeds 20 keys / 64-char keys / 256-char values | Fix the JSON, trim the tag count or length | | 400 | `invalid_content_type` | Body wasn't sent as `multipart/form-data` | Let curl set `Content-Type`, don't pass your own | | 400 | `invalid_filename` | Filename contains unsafe or malformed characters | Rename the file before uploading | | 401 | (none, plain 401) | Missing, empty, or malformed `X-API-Key` | Confirm the header is set and the token starts with `gfr_` | | 402 | `quota_exceeded` | Free tier only: the upload would push the organization over its storage quota. Pro and Team bill metered overage instead, so they never hit this | Upgrade the plan or delete old reports | | 413 | `file_too_large_for_v1` | Request over 75 MB | Use the multipart flow above, the CLI, or the GitHub Action | | 503 | `r2_error`, `db_error` | Transient storage or database failure | Retry, see the next section first | The multipart endpoints add their own reasons: `invalid_body` and `invalid_query` for malformed create/complete requests, `invalid_part_number` for out-of-order or out-of-range parts, `size_mismatch` if the assembled object's size drifts more than 1% from what you declared in `fileSize`, and `mpu_error` (503) when a part upload or the final assembly fails against storage. ## Is it safe to retry a failed upload? **Not blindly.** `POST /api/upload` has no client-supplied idempotency key: every call generates a fresh run ID server-side. If curl times out waiting for a response but the upload actually completed, retrying creates a second test run tagged with the same commit and branch. Retry safely by distinguishing the failure mode: - **curl never got a response** (connection refused, DNS failure, timeout before headers): safe to retry, nothing was created server-side in most of these cases. - **curl got a 5xx**: the request was received but failed server-side; retrying is generally fine since these paths clean up on failure, but if you're not sure, check first. - **curl got any 2xx, or you're unsure whether it landed**: query the upload-sessions API for an existing session with the same `commitSha` before uploading again. ```bash title="curl" curl -s "https://app.gaffer.sh/api/v1/user/projects/$PROJECT_ID/upload-sessions?commitSha=$(git rev-parse HEAD)" \ -H "X-API-Key: YOUR_PROJECT_TOKEN" | jq '.sessions | length' ``` ## How do I upload from a CI system without a Gaffer guide? **Read git metadata directly instead of relying on CI-specific environment variables**, since those are the one thing that won't be documented for a CI system Gaffer hasn't written a guide for. This pattern works in Drone, Buildkite, TeamCity, Woodpecker, or a bare Docker container, anywhere a git checkout and curl are available: ```bash title="upload.sh" #!/bin/bash set -euo pipefail REPORT_PATH="${1:?Usage: upload.sh }" COMMIT_SHA=$(git rev-parse HEAD) BRANCH=$(git rev-parse --abbrev-ref HEAD) STATUS=$(curl -s -o /tmp/gaffer-response.json -w "%{http_code}" \ -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@${REPORT_PATH}" \ -F "tags={\"commitSha\":\"${COMMIT_SHA}\",\"branch\":\"${BRANCH}\"}") if [ "$STATUS" -ge 300 ]; then echo "Gaffer upload failed with HTTP $STATUS:" >&2 cat /tmp/gaffer-response.json >&2 exit 1 fi echo "Uploaded to Gaffer: $(jq -r '.testRun.id' /tmp/gaffer-response.json)" ``` Run it as a post-test step, whatever your CI calls that concept: `after_script`, `post`, a second pipeline step gated on the test step's exit code. The important part is running it even when tests fail, since a failing run is usually the one you most want a shareable report for. ## FAQ ### Does uploading to Gaffer require a specific curl version? No. Every example on this page uses -F for multipart fields, which curl has supported since version 7.1 (1998). Any curl your CI image ships with will work. The only thing to check is that -F sends files as multipart/form-data, not as a raw request body. ### Can I upload with wget or another HTTP client instead of curl? Yes. The endpoint is plain HTTP: a POST with an X-API-Key header and a multipart/form-data body. Any client that can build a multipart request works, including `wget --post-file`, Python's requests library, or a raw socket. curl is used here because every CI image already has it. ### Why do I get "No files provided" even though I passed -F? The field name has to be exactly files, and the value has to start with @ so curl reads the file from disk instead of sending the path as a literal string. Using file instead of files, or leaving off the @, both trigger this error. ## Next Steps - [Upload API Reference](/docs/upload-api/) - Field-by-field reference for the single-POST endpoint - [GitHub Action](/docs/guides/github-actions/) - Automate uploads without hand-writing the multipart flow
--- ## GitHub Actions Source: https://gaffer.sh/docs/guides/github-actions/ import { Aside } from '@astrojs/starlight/components'; The official [Gaffer Uploader](https://github.com/gaffer-sh/gaffer-uploader) GitHub Action makes it easy to automatically upload test reports from your CI workflows. ## Prerequisites - A [Gaffer account](https://app.gaffer.sh/register?utm_source=gaffer&utm_medium=website&utm_campaign=docs&utm_content=inline) with a project - Your [project token](/docs/upload-api/) - A GitHub repository with a test workflow ## Setup ### 1. Add your project token as a secret 1. Go to your GitHub repository 2. Navigate to **Settings** → **Secrets and variables** → **Actions** 3. Click **New repository secret** 4. Name: `GAFFER_PROJECT_TOKEN` 5. Value: Your Gaffer project token ### 2. Add the upload step to your workflow Add the Gaffer uploader step after your test step. Use `if: always()` to ensure reports are uploaded even when tests fail. ### Without a stored token (OIDC) Skip the `GAFFER_PROJECT_TOKEN` secret entirely by granting the job `id-token: write`. The `gaffer` CLI inside the uploader exchanges the runner's own GitHub Actions OIDC identity token for a project-scoped upload token, so there's nothing to create or rotate: ```yaml name: Tests on: push jobs: test: runs-on: ubuntu-latest permissions: id-token: write # required: lets gaffer request a runner identity token contents: read steps: - uses: actions/checkout@v4 - name: Run tests run: npm test - name: Upload to Gaffer uses: gaffer-sh/gaffer-uploader@v2 if: always() with: report_path: ./test-results commit_sha: ${{ github.sha }} branch: ${{ github.ref_name }} # no gaffer_upload_token ``` On the first upload from a repository, Gaffer automatically provisions an unclaimed organization and project for that repository, no signup step required. The job log prints a claim URL; a repo admin visits it once to claim the org into their own account. If `gaffer_upload_token` (or the deprecated `gaffer_api_key`) is set, it still works and takes precedence: OIDC is only the fallback when no token is configured. ## Inputs | Input | Required | Description | |-------|----------|-------------| | `gaffer_upload_token` | No | Your Gaffer project token (the deprecated `gaffer_api_key` alias still works but emits a deprecation warning). Omit it under `permissions: id-token: write` to authenticate via OIDC instead; see [Without a stored token](#without-a-stored-token-oidc) | | `report_path` | Yes | Path to the report file or directory to upload | | `api_endpoint` | No | Custom API endpoint URL (for staging/preview environments) | | `commit_sha` | No | Git commit SHA to associate with the test run | | `branch` | No | Git branch name to associate with the test run | | `test_framework` | No | Test framework used (e.g., `playwright`, `jest`, `pytest`) | | `test_suite` | No | Name of the test suite (e.g., `unit`, `e2e`) | ## Examples ### Basic Usage ```yaml name: Tests on: push jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Upload to Gaffer uses: gaffer-sh/gaffer-uploader@v2 if: always() with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./test-results commit_sha: ${{ github.sha }} branch: ${{ github.ref_name }} ``` ### Playwright Example A complete example for Playwright tests: ```yaml name: Playwright Tests on: push jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v3 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'pnpm' - name: Install dependencies run: pnpm install - name: Install Playwright browsers run: pnpm exec playwright install --with-deps - name: Run Playwright tests run: pnpm exec playwright test --reporter=html - name: Upload Playwright Report to Gaffer uses: gaffer-sh/gaffer-uploader@v2 if: always() with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./playwright-report commit_sha: ${{ github.sha }} branch: ${{ github.ref_name }} test_framework: playwright test_suite: e2e ``` `--reporter=html` is what creates the `playwright-report/` directory. Playwright's default reporter writes to the terminal only (`list` locally, `dot` in CI), so without it the upload step has nothing to find. If your `playwright.config.ts` already sets `reporter: 'html'`, you can drop the flag. ### Pull Request Workflow For pull request workflows, use `github.head_ref` to get the correct branch name: ```yaml name: PR Tests on: pull_request: branches: [main] env: branch_name: ${{ github.head_ref || github.ref_name }} jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run tests run: npm test - name: Upload to Gaffer uses: gaffer-sh/gaffer-uploader@v2 if: always() with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./test-results commit_sha: ${{ github.event.pull_request.head.sha }} branch: ${{ env.branch_name }} test_framework: jest test_suite: unit ``` ## Using CTRF Format For a standardized format across all your test frameworks, consider using [CTRF](/docs/guides/ctrf/). Select the reporter in your test config, not on the command line: ```javascript // jest.config.js module.exports = { reporters: [ 'default', ['jest-ctrf-json-reporter', { outputDir: '.', outputFile: 'ctrf-report.json' }], ], }; ``` ```yaml - name: Install CTRF reporter run: npm install --save-dev jest-ctrf-json-reporter # or playwright-ctrf-json-reporter, vitest-ctrf-json-reporter - name: Run tests with CTRF reporter run: npm test - name: Upload CTRF report to Gaffer uses: gaffer-sh/gaffer-uploader@v2 if: always() with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./ctrf-report.json commit_sha: ${{ github.sha }} branch: ${{ github.ref_name }} ``` ## Environment Variables GitHub Actions provides these variables for your workflow: | Variable | Description | Example | |----------|-------------|---------| | `${{ github.sha }}` | Full commit SHA | `abc123def456...` | | `${{ github.ref_name }}` | Branch or tag name | `main`, `feature/login` | | `${{ github.head_ref }}` | PR source branch (PR workflows only) | `feature/login` | | `${{ github.event.pull_request.head.sha }}` | PR head commit SHA | `def456abc789...` | ## Troubleshooting ### Report not uploading - Verify the `report_path` points to the correct file or directory - Confirm your test command actually writes a report there. Several runners print results to the terminal by default and produce no file at all until you select a file-based reporter (Playwright needs `--reporter=html` or `reporter: 'html'` in its config) - Check that your project token secret is named exactly `GAFFER_PROJECT_TOKEN` - Ensure `if: always()` is set so the step runs even when tests fail ### Wrong branch showing For pull request workflows, use `github.head_ref || github.ref_name` pattern to get the correct source branch name. ### 401 Unauthorized - Check your project token starts with `gfr_` - Verify the token is correctly copied (no extra spaces) - Make sure the secret is accessible to the workflow ## Next Steps - [CTRF Guide](/docs/guides/ctrf/) - Use the universal test format - [Upload API Reference](/docs/upload-api/) - Full API documentation - [cURL Guide](/docs/guides/curl/) - Manual uploads for debugging - [Slack Integration](/docs/integrations/slack/) - Get test results in Slack **Other CI Providers:** [GitLab CI](/docs/guides/gitlab-ci/) · [CircleCI](/docs/guides/circleci/) · [Jenkins](/docs/guides/jenkins/) · [Bitbucket](/docs/guides/bitbucket-pipelines/) · [Azure DevOps](/docs/guides/azure-devops/) --- ## GitLab CI Source: https://gaffer.sh/docs/guides/gitlab-ci/ import { Aside } from '@astrojs/starlight/components' import JsonLd from '../../../../components/JsonLd.astro'
GitLab CI/CD runs tests on every push, but its own reporting resets at the merge request boundary. Gaffer adds run history, flaky detection, and a link you can hand to anyone, whether or not they have GitLab access. ## Prerequisites - A [Gaffer account](https://app.gaffer.sh/register?utm_source=gaffer&utm_medium=website&utm_campaign=docs&utm_content=inline) with a project - Your [project token](/docs/upload-api/) - A GitLab repository with a `.gitlab-ci.yml` file ## How do I add my Gaffer project token to a GitLab pipeline? **Store it as a masked CI/CD variable**, not inline in the YAML. 1. Go to your GitLab project 2. Navigate to **Settings** → **CI/CD** → **Variables** 3. Click **Add variable** 4. Key: `GAFFER_PROJECT_TOKEN` 5. Value: your Gaffer project token 6. Check **Mask variable** to hide it in job logs ## How do I upload a report after the test job runs? **Add the curl call to `after_script`.** Unlike `script`, `after_script` runs even when the job's `script` section fails, which is when you most need the report. ```yaml test: stage: test script: - npm ci - npm test after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'"}' artifacts: when: always reports: junit: test-results/junit.xml ``` Keeping `artifacts:reports:junit` alongside the Gaffer upload is intentional: GitLab's widget gives reviewers an inline pass/fail count on the merge request itself, and Gaffer gives you the history that widget doesn't keep once the artifact retention window passes. ## Which GitLab CI variables carry commit and branch info? | Variable | Description | Example | |----------|-------------|---------| | `$CI_COMMIT_SHA` | Full commit SHA | `abc123def456...` | | `$CI_COMMIT_REF_NAME` | Branch or tag name | `main`, `feature/login` | | `$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME` | Source branch in MR pipelines | `feature/login` | | `$CI_NODE_INDEX` | 1-based index of the current job in a `parallel:` job. Not set by `parallel:matrix:` | `1`, `2`, `3` | | `$CI_PROJECT_NAME` | Project name | `my-app` | ## Framework examples ### Playwright ```yaml playwright: stage: test image: mcr.microsoft.com/playwright:v1.40.0-jammy script: - npm ci - npx playwright install --with-deps - npx playwright test after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@playwright-report/index.html" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'","test_framework":"playwright","test_suite":"e2e"}' artifacts: when: always paths: - playwright-report/ ``` ### Jest with JUnit reporter ```yaml jest: stage: test script: - npm ci - npm test -- --reporters=default --reporters=jest-junit after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@junit.xml" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'","test_framework":"jest"}' artifacts: when: always reports: junit: junit.xml ``` ### pytest ```yaml pytest: stage: test image: python:3.11 script: - pip install pytest pytest-html - pytest --html=report.html --self-contained-html after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@report.html" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'","test_framework":"pytest"}' artifacts: when: always paths: - report.html ``` ### Using CTRF format For a standardized format across all your test frameworks, use [CTRF](/docs/guides/ctrf/): ```yaml test: script: - npm ci # Install the CTRF reporter for your framework: # npm install --save-dev jest-ctrf-json-reporter # npm install --save-dev playwright-ctrf-json-reporter # npm install --save-dev vitest-ctrf-json-reporter - npm test -- --reporter=jest-ctrf-json-reporter after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@ctrf-report.json" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'"}' ``` ## Does Gaffer replace GitLab's JUnit test report widget? **No, run both.** GitLab's `artifacts:reports:junit` widget shows pass/fail counts inline on a single merge request and disappears once that job's artifact retention expires. Gaffer keeps every run's full history, flags tests that flip pass/fail across branches, and gives you a link that works for people without GitLab access, like a customer asking why a release slipped. Use the widget for the at-a-glance MR check and Gaffer for anything that needs to survive past that one pipeline. ## How do I upload from merge request pipelines specifically? **Use `rules:` to detect an MR pipeline, and tag the branch with `$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME`.** That variable is set only in merge request pipelines and names the source branch explicitly. `$CI_COMMIT_REF_NAME` resolves to the same branch there, so keep it as the fallback for ordinary branch pipelines. The synthetic ref `refs/merge-requests/42/head` lives in `$CI_MERGE_REQUEST_REF_PATH`, which is not what you want in a branch tag: ```yaml test: rules: - if: $CI_MERGE_REQUEST_IID script: - npm test after_script: - | BRANCH="${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME:-$CI_COMMIT_REF_NAME}" curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$BRANCH"'"}' ``` ## How do I structure a test-then-upload pipeline with `needs:`? **Split the upload into its own job that `needs:` the test job's artifacts**, so it runs as soon as the test job finishes instead of waiting for the whole stage to complete: ```yaml stages: - test - report test: stage: test script: - npm ci - npm test || true # let the pipeline continue to the report job artifacts: when: always paths: - test-results/ upload-to-gaffer: stage: report needs: - job: test artifacts: true script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'"}' ``` `needs:` creates a direct dependency edge rather than a stage-order one, so `upload-to-gaffer` starts the moment `test` finishes instead of waiting on everything else in the `test` stage. This only works because `artifacts: true` passes the report file across the job boundary. A GitLab job's workspace doesn't persist between jobs on its own: if you split test and upload into separate jobs and skip `artifacts:`, the upload job won't find the report file at all, since it's running on a clean checkout with none of the previous job's output. ## Can I upload to Gaffer from a job that only runs on failure? **Yes, and `after_script` already does it.** `after_script` runs even when the preceding `script` section fails, which is the GitLab equivalent of running a step regardless of exit code. If you would rather have the upload as its own job than as a script hook, gate that job with `rules: - when: on_failure`, which GitLab evaluates only once a job in an earlier stage has failed. ## How do I upload from a GitLab parallel matrix job? **Tag each upload with one of your matrix variables**, such as the `$SHARD` value below, so you can tell the runs apart later. `$CI_NODE_INDEX` is not set in `parallel:matrix:` jobs, only in `parallel:` jobs that take an integer. Gaffer records each `curl` call as a separate test run; without a distinguishing tag, four parallel shards produce four runs on the same commit and branch with no way to tell which shard is which. ```yaml test: parallel: matrix: - SHARD: [1, 2, 3, 4] script: - npm test -- --shard=$SHARD/4 after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'","shard":"'"$SHARD"'"}' ``` ## What goes wrong and how do I fix it? | Symptom | Cause | Fix | |---------|-------|-----| | 401 API key required, and the variable looks correctly set | The variable is marked **Protected** but the pipeline is running on an unprotected branch or a fork MR | Unprotect the variable, or restrict the upload job to protected refs with `rules: - if: $CI_COMMIT_REF_PROTECTED` | | Upload never runs | The curl call is in `script` instead of `after_script`, and the test step failed first | Move the upload to `after_script`, which runs regardless of the preceding step's exit code | | curl can't find the report file in a later job | Job workspaces don't persist between jobs without `artifacts:` | Add `artifacts: paths:` to the test job and `needs: - job: test artifacts: true` to the upload job | | Branch shows as a merge-request ref like `refs/merge-requests/42/head` | Tagging with `$CI_MERGE_REQUEST_REF_PATH`, which holds the synthetic MR ref rather than a branch name | Use `$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME` instead | | 401 Unauthorized, variable is set correctly | Token missing the `gfr_` prefix, or extra whitespace from copy-paste | Re-copy the token from project settings, check for trailing newlines in the variable value | ## Next Steps - [CTRF Guide](/docs/guides/ctrf/) - Use the universal test format - [Upload API Reference](/docs/upload-api/) - Full API documentation - [Slack Integration](/docs/integrations/slack/) - Get test results in Slack **Other CI Providers:** [GitHub Actions](/docs/guides/github-actions/) · [CircleCI](/docs/guides/circleci/) · [Jenkins](/docs/guides/jenkins/) · [Bitbucket](/docs/guides/bitbucket-pipelines/) · [Azure DevOps](/docs/guides/azure-devops/)
--- ## CI Provider Guides Source: https://gaffer.sh/docs/guides/index/ import { LinkCard, CardGrid } from '@astrojs/starlight/components' import { Aside } from '@astrojs/starlight/components' Choose your CI provider below to get started with automatic test report uploads. ## Universal Format: CTRF CTRF (Common Test Report Format) provides a standardized JSON format that works with any CI provider and test framework. Use it for consistent analytics across your entire test suite. [Learn about CTRF →](/docs/guides/ctrf/) ## CI Providers ## Other Methods - [cURL Guide](/docs/guides/curl/) — Upload manually or from any CI system using curl - [Upload API Reference](/docs/upload-api/) — Build custom integrations with the full API documentation --- ## Jenkins Source: https://gaffer.sh/docs/guides/jenkins/ import { Aside } from '@astrojs/starlight/components'; Jenkins is a widely-used open-source automation server. With Gaffer, you can automatically upload and share test reports from your Jenkins pipelines. ## Prerequisites - A [Gaffer account](https://app.gaffer.sh/register?utm_source=gaffer&utm_medium=website&utm_campaign=docs&utm_content=inline) with a project - Your [project token](/docs/upload-api/) - A Jenkins instance with a pipeline configured ## Setup ### 1. Add your project token as a credential 1. Go to **Manage Jenkins** → **Credentials** 2. Select the appropriate domain (or global) 3. Click **Add Credentials** 4. Kind: **Secret text** 5. Secret: Your Gaffer project token 6. ID: `gaffer-project-token` 7. Description: `Gaffer Project Token` ### 2. Add the upload step to your Jenkinsfile ```groovy pipeline { agent any environment { GAFFER_PROJECT_TOKEN = credentials('gaffer-project-token') } stages { stage('Test') { steps { sh 'npm ci' sh 'npm test' } post { always { sh ''' curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$GIT_COMMIT"'","branch":"'"$GIT_BRANCH"'"}' ''' junit 'test-results/junit.xml' } } } } } ``` ## Environment Variables Jenkins provides these variables for your pipeline: | Variable | Description | Example | |----------|-------------|---------| | `$GIT_COMMIT` | Full commit SHA | `abc123def456...` | | `$GIT_BRANCH` | Branch name (may include `origin/`) | `origin/main`, `origin/feature/login` | | `$BRANCH_NAME` | Branch name (multibranch pipelines) | `main`, `feature/login` | | `$BUILD_NUMBER` | Build number | `123` | | `$JOB_NAME` | Job name | `my-app/main` | ## Examples ### Playwright ```groovy pipeline { agent { docker { image 'mcr.microsoft.com/playwright:v1.40.0-jammy' } } environment { GAFFER_PROJECT_TOKEN = credentials('gaffer-project-token') } stages { stage('Install') { steps { sh 'npm ci' } } stage('Test') { steps { sh 'npx playwright test' } post { always { sh ''' curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@playwright-report/index.html" \ -F 'tags={"commitSha":"'"$GIT_COMMIT"'","branch":"'"$BRANCH_NAME"'","test_framework":"playwright","test_suite":"e2e"}' ''' archiveArtifacts artifacts: 'playwright-report/**' } } } } } ``` ### Jest with JUnit Reporter ```groovy pipeline { agent any environment { GAFFER_PROJECT_TOKEN = credentials('gaffer-project-token') } stages { stage('Test') { steps { sh 'npm ci' sh 'npm test -- --reporters=default --reporters=jest-junit' } post { always { sh ''' curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@junit.xml" \ -F 'tags={"commitSha":"'"$GIT_COMMIT"'","branch":"'"$BRANCH_NAME"'","test_framework":"jest"}' ''' junit 'junit.xml' } } } } } ``` ### Declarative with Scripted Post ```groovy pipeline { agent any stages { stage('Test') { steps { sh 'npm ci && npm test' } } } post { always { withCredentials([string(credentialsId: 'gaffer-project-token', variable: 'GAFFER_PROJECT_TOKEN')]) { sh ''' # Clean branch name (remove origin/ prefix) CLEAN_BRANCH="${GIT_BRANCH#origin/}" curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$GIT_COMMIT"'","branch":"'"$CLEAN_BRANCH"'"}' ''' } } } } ``` ## Using CTRF Format For a standardized format across all your test frameworks, consider using [CTRF](/docs/guides/ctrf/): ```groovy stage('Test') { steps { // Install the CTRF reporter for your framework: // npm install --save-dev jest-ctrf-json-reporter // npm install --save-dev playwright-ctrf-json-reporter // npm install --save-dev vitest-ctrf-json-reporter sh 'npm install --save-dev jest-ctrf-json-reporter' sh 'npm test -- --reporter=jest-ctrf-json-reporter' } post { always { sh ''' curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@ctrf-report.json" \ -F 'tags={"commitSha":"'"$GIT_COMMIT"'","branch":"'"$BRANCH_NAME"'"}' ''' } } } ``` ## Troubleshooting ### Report not uploading - Verify the credential ID matches (`gaffer-project-token`) - Ensure the upload is in a `post { always { } }` block - Check the file path is correct relative to the workspace ### 401 Unauthorized - Check your project token starts with `gfr_` - Verify the credential is accessible to the pipeline ### Branch name includes "origin/" Use `${GIT_BRANCH#origin/}` to strip the prefix: ```groovy sh ''' CLEAN_BRANCH="${GIT_BRANCH#origin/}" curl ... -F 'tags={"branch":"'"$CLEAN_BRANCH"'"}' ''' ``` ### curl not found Ensure curl is installed on your Jenkins agent or use a Docker image that includes it. ## Next Steps - [CTRF Guide](/docs/guides/ctrf/) - Use the universal test format - [Upload API Reference](/docs/upload-api/) - Full API documentation - [Slack Integration](/docs/integrations/slack/) - Get test results in Slack **Other CI Providers:** [GitHub Actions](/docs/guides/github-actions/) · [GitLab CI](/docs/guides/gitlab-ci/) · [CircleCI](/docs/guides/circleci/) · [Bitbucket](/docs/guides/bitbucket-pipelines/) · [Azure DevOps](/docs/guides/azure-devops/) --- ## Docs Source: https://gaffer.sh/docs/ import { LinkCard } from '@astrojs/starlight/components' Redirecting to [Getting Started](/docs/getting-started/)... --- ## OpenTelemetry Source: https://gaffer.sh/docs/integrations/opentelemetry/ import { Aside } from '@astrojs/starlight/components' Gaffer can export test run and coverage metrics as OpenTelemetry (OTLP) data to your existing observability stack. Track pass rates, failure counts, and coverage trends alongside your service metrics in Datadog, Grafana Cloud, or any OTLP-compatible backend. ## Features - **Provider presets** — built-in support for Datadog and Grafana Cloud, plus a generic OTLP option for any compatible endpoint - **Branch filtering** — limit metric export to specific branches using glob patterns - **Test run metrics** — pass rate, total/passed/failed/skipped counts per run - **Coverage metrics** — line and branch coverage percentages per upload - **Test connectivity** — verify your configuration from the dashboard before waiting for real data - **Encrypted API keys** — credentials are encrypted at rest with AES-256-GCM ## Setup by Provider ### Datadog 1. In Datadog, go to **Organization Settings > API Keys** and create (or copy) an API key 2. In Gaffer, navigate to **Settings > OpenTelemetry** and click **"Add destination"** 3. Select **Datadog** as the provider 4. Paste your API key and select your Datadog site (e.g., `datadoghq.com`, `datadoghq.eu`, `us3.datadoghq.com`, `us5.datadoghq.com`, `ap1.datadoghq.com`) 5. Choose which event types to export (test runs, coverage, or both) 6. Click **"Create"** 7. Click **"Test"** to verify connectivity 8. Verify in Datadog: go to **Metrics > Explorer** and search for `gaffer.tests.pass_rate` ### Grafana Cloud 1. In Grafana Cloud, go to your stack's **OpenTelemetry** section to get your OTLP gateway credentials (instance ID, region, API token) 2. In Gaffer, navigate to **Settings > OpenTelemetry** and click **"Add destination"** 3. Select **Grafana** as the provider 4. Enter your Grafana Cloud region, instance ID, and API token 5. Choose event types and click **"Create"** 6. Click **"Test"** to verify connectivity 7. Verify in Grafana: go to **Explore** and query for `gaffer_tests_pass_rate` ### Generic OTLP Any backend that accepts OTLP over HTTP can be used as a destination. 1. In Gaffer, navigate to **Settings > OpenTelemetry** and click **"Add destination"** 2. Select **Generic OTLP** as the provider 3. Enter your OTLP HTTP endpoint URL (must be HTTPS) 4. Enter an API key or bearer token for authentication 5. Choose event types and click **"Create"** 6. Click **"Test"** to verify connectivity ## Configuration Options ### Event Types Choose which events export metrics: - **Test runs** — exports pass rate, total/passed/failed/skipped counts when a test run completes - **Coverage** — exports line and branch coverage percentages when a coverage report is uploaded At least one event type must be enabled. ### Branch Filters Restrict which branches trigger metric export using glob patterns: - `main` — exact match - `release/*` — matches `release/v1.0`, `release/2024-01`, etc. - `feature-*` — matches `feature-auth`, `feature-dashboard`, etc. Leave branch filters empty to export metrics for all branches. ### Enabled Toggle Each destination can be enabled or disabled independently. Disabled destinations remain configured but do not export metrics. ## Available Metrics ### Test Run Metrics | Metric | Type | Unit | Description | |--------|------|------|-------------| | `gaffer.tests.pass_rate` | Gauge | % | Pass rate of the test run | | `gaffer.tests.total` | Gauge | test | Total test count | | `gaffer.tests.passed` | Gauge | test | Passed test count | | `gaffer.tests.failed` | Gauge | test | Failed test count | | `gaffer.tests.skipped` | Gauge | test | Skipped test count | ### Coverage Metrics | Metric | Type | Unit | Description | |--------|------|------|-------------| | `gaffer.coverage.line_percent` | Gauge | % | Line coverage percentage | | `gaffer.coverage.branch_percent` | Gauge | % | Branch coverage percentage | ### Dimensions (Attributes) Metrics include these attributes for filtering and grouping: | Attribute | Included On | Description | |-----------|-------------|-------------| | `gaffer.project.id` | All metrics | Project identifier | | `gaffer.project.name` | All metrics | Project name | | `gaffer.branch` | All metrics | Git branch name (when available) | | `gaffer.commit_sha` | All metrics | Git commit SHA (when available) | | `gaffer.report_format` | Test run metrics only | Report format (e.g., `ctrf`) | ## Dashboard Templates Get started faster with a pre-built Datadog dashboard. See [Datadog Test Metrics Dashboard](/solutions/datadog-test-metrics/) for a downloadable JSON template and import instructions. ## Testing Each destination has a **"Test"** button in the dashboard. Clicking it sends a test metric payload to your configured endpoint and reports the HTTP status code. Use this to verify connectivity and authentication before waiting for real test data. ## Troubleshooting ### Test button returns an error? - **Check your API key** — make sure the key is correct and has the necessary permissions - **Verify the site/region** — Datadog and Grafana Cloud require the correct region to be selected - **Check endpoint URL** — for generic OTLP, ensure the URL is correct and reachable - **Network issues** — if your backend is behind a firewall, ensure it accepts connections from Gaffer's servers ### Metrics not appearing in your backend? - **Check the destination is enabled** — disabled destinations do not export metrics - **Verify branch filters** — if configured, only matching branches trigger export - **Check event types** — make sure the relevant event type (test runs or coverage) is enabled - **Check "Last Sent"** — the destinations list shows the last delivery time and any errors - **Allow propagation time** — some backends take a few minutes to index new metrics ## Security - **Encrypted API keys** — credentials are encrypted at rest using AES-256-GCM - **HTTPS recommended** — Datadog and Grafana Cloud presets use HTTPS endpoints; for generic OTLP, use HTTPS to protect data in transit --- ## Slack Integration Source: https://gaffer.sh/docs/integrations/slack/ Gaffer's Slack integration sends notifications to your team when test runs complete. Stay informed about test failures without leaving Slack, and quickly navigate to detailed reports when issues arise. ## Features - **Real-time notifications** - Get notified immediately when test runs finish - **Smart message updates** - Multiple test runs for the same commit update a single message instead of spamming your channel - **Branch filtering** - Only receive notifications for branches you care about (e.g., `main`, `release/*`) - **Configurable triggers** - Choose to be notified on all runs, only failures, or after consecutive failures - **Quick links** - Jump directly to the full test report from the notification ## Connecting Slack 1. Navigate to **Settings > Slack** in your Gaffer dashboard 2. Click **"Connect Slack"** to start the OAuth flow 3. Select the Slack workspace you want to connect 4. Authorize Gaffer to post messages 5. You'll be redirected back to Gaffer with Slack connected After connecting, you'll need to configure which channels receive notifications and customize your notification preferences. ## Configuration Options ### Channels Select one or more channels where Gaffer should post notifications. The Gaffer bot must be a member of any private channels you want to use. **Tip:** Consider creating a dedicated `#test-results` channel to keep notifications organized without cluttering general channels. ### Branch Filters By default, Gaffer sends notifications for all branches. You can restrict notifications to specific branches using patterns: - `main` - Exact match for the main branch - `release/*` - All release branches (e.g., `release/v1.0`, `release/2024-01`) - `feature/*` - All feature branches Leave the branch filters empty to receive notifications for all branches. ### Notification Triggers Control when notifications are sent: - **All test runs** - Notify on every completed test run (good for high-visibility branches like `main`) - **Failures only** - Only notify when tests fail (reduces noise while keeping you informed of problems) - **Consecutive failures** - Only notify after multiple consecutive failures (useful for catching real issues vs. one-off flakes) For the consecutive failures option, you can set the threshold (e.g., notify after 3 consecutive failures). ## Notification Format Slack notifications include: - Project name and branch - Test summary (passed, failed, skipped counts) - Commit SHA (when available) - List of failed test names (up to 5, with "and X more" if there are additional failures) - Direct link to the full report in Gaffer When multiple test runs complete for the same commit (e.g., Vitest unit tests and Playwright E2E tests), Gaffer updates the existing message instead of posting a new one. This keeps your channel tidy and gives you a consolidated view of all test results for that commit. ## Disconnecting Slack To disconnect Slack: 1. Go to **Settings > Slack** 2. Click **"Disconnect"** 3. Confirm the disconnection This removes the Slack integration and deletes all notification history. You can reconnect at any time by going through the OAuth flow again. ## Troubleshooting ### Not receiving notifications? - **Check channel membership** - For private channels, ensure the Gaffer bot has been added to the channel - **Verify branch filters** - If you have branch filters configured, make sure your branch matches one of the patterns - **Check notification trigger** - If set to "failures only", you won't receive notifications for passing test runs - **Confirm notifications are enabled** - The toggle in Settings > Slack must be enabled ### Token expired or revoked? If your Slack token is revoked (e.g., if someone removes the Gaffer app from your workspace), notifications will stop. You'll need to reconnect Slack by going through the OAuth flow again. ## Security Gaffer requests minimal Slack permissions: - `chat:write` - Post messages to channels - `channels:read` - List public channels for selection - `groups:read` - List private channels for selection OAuth tokens are encrypted at rest using AES-256-GCM. Gaffer uses Slack's token rotation feature, automatically refreshing tokens before they expire. --- ## Webhooks Source: https://gaffer.sh/docs/integrations/webhooks/ Webhooks send HTTP POST requests to your endpoint when events occur in Gaffer. Use them to integrate test results with your monitoring tools, trigger deployments, update dashboards, or build custom notification flows. ## Features - **Test run and coverage events** — receive notifications for either or both event types - **Branch filtering** — only trigger for specific branches using glob patterns - **Configurable triggers** — fire on all events, failures only, or after consecutive failures - **HMAC-SHA256 signing** — verify that payloads are from Gaffer - **Test delivery** — send a test payload from the dashboard to verify your endpoint ## Creating a Webhook 1. Navigate to **Settings > Webhooks** in your Gaffer dashboard 2. Click **"Add Webhook"** 3. Enter a name and HTTPS endpoint URL 4. Configure notification settings (event types, trigger mode, branch filters) 5. Click **"Create"** Gaffer generates an HMAC signing secret automatically when you create a webhook. The signing secret is displayed once at creation — store it securely, as it cannot be retrieved later. You can regenerate the secret from the webhook settings if needed. ## Configuration ### Event Types Choose which events trigger the webhook: - **Test runs** — fires when a test run completes - **Coverage reports** — fires when a coverage report is uploaded At least one event type must be enabled. ### Notification Triggers Control when test run webhooks fire: - **All test runs** — fire on every completed test run - **Failures only** — only fire when tests fail - **Consecutive failures** — only fire after N consecutive failures (configurable, 1-10). Useful for filtering out one-off flakes Coverage events always fire when enabled — trigger settings only apply to test runs. ### Branch Filters Restrict which branches trigger the webhook using glob patterns: - `main` — exact match - `release/*` — matches `release/v1.0`, `release/2024-01`, etc. - `feature-*` — matches `feature-auth`, `feature-dashboard`, etc. Leave branch filters empty to receive events for all branches. ### Project Scope Webhooks can be scoped to a specific project or apply to all projects in your organization. Organization-wide webhooks receive events from every project. ## Payload Format ### Test Run Event ```json title="test_run event" { "event": "test_run", "timestamp": "2026-01-25T14:30:00.000Z", "delivery_id": "550e8400-e29b-41d4-a716-446655440000", "data": { "test_run_id": "tr_abc123", "upload_id": "upl_xyz789", "project_id": "prj_def456", "project_name": "my-project", "organization_id": "org_ghi789", "branch": "main", "commit_sha": "a1b2c3d4e5f6", "report_format": "ctrf", "passed_count": 142, "failed_count": 3, "skipped_count": 2, "total_count": 147, "status": "failed", "dashboard_url": "https://app.gaffer.sh/projects/prj_def456/runs/tr_abc123", "hosted_report_url": "https://app.gaffer.sh/reports/upl_xyz789/index.html", "url": "https://app.gaffer.sh/projects/prj_def456/runs/tr_abc123" } } ``` ### Coverage Event ```json title="coverage event" { "event": "coverage", "timestamp": "2026-01-25T14:30:00.000Z", "delivery_id": "660e8400-e29b-41d4-a716-446655440000", "data": { "coverage_report_id": "cov_jkl012", "upload_id": "upl_xyz789", "project_id": "prj_def456", "project_name": "my-project", "organization_id": "org_ghi789", "branch": "main", "commit_sha": "a1b2c3d4e5f6", "format": "lcov", "lines": { "covered": 1200, "total": 1500, "percent": 80.0 }, "branches": { "covered": 300, "total": 500, "percent": 60.0 }, "functions": { "covered": 95, "total": 120, "percent": 79.2 }, "dashboard_url": "https://app.gaffer.sh/projects/prj_def456/coverage", "hosted_report_url": null, "url": "https://app.gaffer.sh/projects/prj_def456/coverage" } } ```

Field Reference

| Field | Type | Notes | |-------|------|-------| | `upload_id` | string | Parent upload ID (`upl_*`). Stable across schema changes. Useful for correlating the webhook to dashboard views and to the `hosted_report_url`. | | `dashboard_url` | string | Deep link to the Gaffer dashboard for this run or coverage report. Opening this link in a browser authenticates via session or prompts sign-in. | | `hosted_report_url` | string or null | Link to the hosted HTML report (e.g., Playwright `index.html`) when the upload includes one. `null` when the upload is a raw report file (JUnit XML, lcov, etc.) with no browsable HTML. Opening requires sign-in or a valid share — the link is designed to drop recipients straight into Gaffer. Coverage events may return `null` for every upload today. | | `url` | string | **Deprecated.** Alias of `dashboard_url`, retained for backwards compatibility. Switch to `dashboard_url`. Will be removed on **Sat, 01 Aug 2026**. Outbound requests include `Deprecation` and `Link: rel="successor-version"` headers pointing here. | ### Test Event When you click "Test" in the dashboard, Gaffer sends a test payload: ```json title="test event" { "event": "test", "timestamp": "2026-01-25T14:30:00.000Z", "delivery_id": "770e8400-e29b-41d4-a716-446655440000", "test": true, "data": { "message": "This is a test webhook from Gaffer", "webhook_id": "whk_abc123", "webhook_name": "My Webhook", "organization_id": "org_ghi789" } } ``` ## HTTP Headers Every webhook request includes these headers: | Header | Description | |--------|-------------| | `Content-Type` | `application/json` | | `X-Gaffer-Event` | Event type: `test_run`, `coverage`, or `test` | | `X-Gaffer-Signature` | HMAC-SHA256 signature of the request body | | `X-Gaffer-Delivery` | Unique delivery ID (UUID) for idempotency | | `User-Agent` | `Gaffer-Webhooks/1.0` | | `Deprecation` | Present on `test_run` and `coverage` events. HTTP-date of the sunset for the deprecated `url` field. See [Field Reference](#v2-payload). | | `Link` | Present on `test_run` and `coverage` events. Points at this documentation section with `rel="successor-version"`. | ## Verifying Signatures Every webhook is signed with your webhook's secret using HMAC-SHA256. Always verify the signature before processing the payload to confirm it came from Gaffer. ### Node.js ```javascript title="verify-signature.js" const crypto = require('crypto'); function verifySignature(payload, signature, secret) { const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(payload) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected) ); } // In your request handler: const payload = JSON.stringify(req.body); const signature = req.headers['x-gaffer-signature']; if (!verifySignature(payload, signature, process.env.GAFFER_WEBHOOK_SECRET)) { return res.status(401).send('Invalid signature'); } ``` ### Python ```python title="verify_signature.py" import hmac import hashlib def verify_signature(payload: bytes, signature: str, secret: str) -> bool: expected = 'sha256=' + hmac.new( secret.encode(), payload, hashlib.sha256 ).hexdigest() return hmac.compare_digest(signature, expected) # In your request handler: payload = request.get_data() signature = request.headers.get('X-Gaffer-Signature') if not verify_signature(payload, signature, os.environ['GAFFER_WEBHOOK_SECRET']): abort(401) ``` ## Retry Behavior Gaffer retries webhook deliveries when your endpoint returns a 5xx status code or a network error occurs. Deliveries that receive a 4xx response are not retried — these indicate a permanent issue with the request (e.g., your endpoint rejected it). Each delivery has a unique `X-Gaffer-Delivery` ID. Use this for idempotency — if you receive the same delivery ID twice, you can safely skip the duplicate. ## Testing Webhooks 1. Create your webhook in **Settings > Webhooks** 2. Click the **"Test"** button next to your webhook 3. Gaffer sends a test payload to your endpoint 4. Check the result — the dashboard shows whether the delivery succeeded or failed, including the HTTP status code For local development, use a tunnel service like [ngrok](https://ngrok.com) to expose your local server to the internet. ## Plan Limits | Plan | Webhooks | |------|----------| | Free | 1 | | Pro | 5 | | Team | Unlimited | ## Troubleshooting ### Not receiving webhooks? - **Check the webhook is enabled** — disabled webhooks don't fire - **Verify branch filters** — if configured, only matching branches trigger the webhook - **Check notification trigger** — "failures only" won't fire for passing test runs - **Check event types** — make sure the relevant event type (test runs or coverage) is enabled - **Check "Last Sent" and error status** — the webhooks list shows the last delivery time and any errors ### Receiving a 401 from signature verification? - **Use the raw request body** — verify against the exact bytes received, not a re-serialized version - **Check the secret** — make sure you're using the correct secret for this webhook - **Regenerate if needed** — edit the webhook and check "Regenerate secret" to get a new one ### Endpoint URL rejected? - **HTTPS required** — webhook URLs must use HTTPS - **No private IPs** — localhost, 127.0.0.1, and private IP ranges (10.x, 172.16-31.x, 192.168.x) are blocked for security ## Security - **HTTPS only** — webhook URLs must use HTTPS to protect payloads in transit - **SSRF protection** — private IP ranges and localhost are blocked - **Encrypted secrets** — signing secrets are encrypted at rest using AES-256-GCM - **HMAC-SHA256 signing** — every payload is signed so you can verify authenticity --- ## Introduction Source: https://gaffer.sh/docs/introduction/ Gaffer wraps your test command and extracts flaky tests, failure clusters, health scores, and coverage trends from raw results. Same data in three places: - **[CLI](/docs/cli/)** — `gaffer test` enriches your terminal output after every run - **Dashboard** — your team sees history, trends, and share links at [app.gaffer.sh](https://app.gaffer.sh) - **[MCP server](/docs/mcp/)** — AI coding agents (Claude Code, Cursor, Windsurf) query your test data and act on it ## What you get - **[Flaky test detection](/solutions/flaky-test-detection/)** — flip rates, composite scores, and history for every test that alternates between pass and fail - **Failure clusters** — groups failing tests by shared error patterns so you fix root causes, not symptoms - **Health scores** — a single 0–100 number combining pass rate, flakiness, and trend direction - **[Coverage tracking](/docs/coverage/)** — line, branch, and function coverage with GitHub commit status gating - **Duration analysis** — P50/P75/P90/P95/P99 percentiles and slowest test identification - **Works with everything** — Playwright, Jest, Vitest, pytest, Go, RSpec, JUnit XML, CTRF, and any CI provider ## How it works ```bash # Install curl -fsSL https://app.gaffer.sh/install.sh | sh # Wrap your test command gaffer test -- npm test ``` Gaffer runs your tests, discovers report files, parses results, computes analytics, prints an enriched summary, and syncs to the cloud — all in one command. Your test command's exit code passes through unchanged. ## Next steps Head to the [Getting Started](/docs/getting-started/) guide to install the CLI and run your first test. --- ## MCP Server Source: https://gaffer.sh/docs/mcp/ The Gaffer MCP server (`@gaffer-sh/mcp`) connects AI coding assistants to your test history. Ask about test health, investigate flaky tests, and get context about failures without leaving your editor. ## What is MCP? The [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) is an open standard that lets AI assistants access external tools and data sources. By installing the Gaffer MCP server, your AI assistant gains direct access to your test analytics. ## Features - **Test health insights** - Ask about pass rates, trends, and overall test suite health - **Flaky test detection** - Identify tests with inconsistent behavior - **Test history lookup** - Check the history of specific tests to understand stability - **Cross-project access** - Query all your projects from a single API key - **Report file access** - Get links to HTML reports, coverage files, and other artifacts - **Slowest test analysis** - Find tests that are slowing down your CI pipeline ## Prerequisites 1. A [Gaffer account](https://app.gaffer.sh) with test results uploaded 2. An API Key from **Account Settings > API Keys** ## Setup ### Claude Code Add to your Claude Code settings (`~/.claude.json` or project `.claude/settings.json`): ```json { "mcpServers": { "gaffer": { "command": "npx", "args": ["-y", "@gaffer-sh/mcp"], "env": { "GAFFER_API_KEY": "gaf_your_api_key_here" } } } } ``` ### Cursor Add to `.cursor/mcp.json` in your project: ```json { "mcpServers": { "gaffer": { "command": "npx", "args": ["-y", "@gaffer-sh/mcp"], "env": { "GAFFER_API_KEY": "gaf_your_api_key_here" } } } } ``` ## What tools does the Gaffer MCP server expose? Three. The server runs in **code mode**: rather than one MCP tool per API call, it exposes three tools plus a `codemode` namespace of 17 analytics functions that you call from JavaScript. That keeps 17 tool definitions out of the context window, and it lets one execution chain several calls instead of paying a round-trip each. | MCP tool | What it does | |----------|--------------| | `execute_code` | Runs JavaScript against `codemode.()`. Max 20 API calls, 30s timeout. | | `search_tools` | Finds available functions by keyword. An empty query lists all of them. | | `list_projects` | Lists projects. Registered only when your token is a user API Key (`gaf_`). | You rarely write this code yourself. You ask a question, and the assistant writes the call: ```javascript const health = await codemode.get_project_health({ projectId: "proj_abc" }); if (health.flakyTestCount > 0) { const flaky = await codemode.get_flaky_tests({ projectId: "proj_abc" }); return { health, flaky }; } return { health }; ``` ## Which functions can the assistant call? All 17 are reachable through `execute_code`. Ask `search_tools` with an empty query to list them at runtime. | Function | Category | Returns | |----------|----------|---------| | `get_project_health` | health | Health score (0-100), pass rate, run count, flaky count, trend | | `get_test_history` | testing | Pass/fail history for one test, with branch, commit, and errors | | `get_flaky_tests` | testing | Flip rates, transition counts, and last-seen timestamps | | `list_test_runs` | testing | Recent runs, filterable by commit, branch, or status | | `get_test_run_details` | testing | Individual results for one run, with stack traces | | `get_failure_clusters` | testing | Failed tests grouped by error similarity | | `get_slowest_tests` | testing | Slowest tests by average and P95 duration | | `compare_test_metrics` | testing | Before/after metrics across two commits or runs | | `search_failures` | testing | Past failures matching an error or test-name pattern | | `get_coverage_summary` | coverage | Line, branch, and function coverage, plus trend | | `get_coverage_for_file` | coverage | Coverage for an exact or partial file path | | `get_untested_files` | coverage | Files below a coverage threshold | | `find_uncovered_failure_areas` | coverage | Files with low coverage and test failures | | `get_report` | reports | Report file URLs for a test run | | `get_report_browser_url` | reports | Signed browser URL, valid 30 minutes | | `get_upload_status` | uploads | Whether CI results are uploaded and processed | | `upload_test_results` | uploads | Uploads structured test results to a project (the one write function) | With a project token (`gfr_`), omit `projectId` on every function. It resolves automatically. ## Does gaffer.sh expose WebMCP tools? Yes. Every page on gaffer.sh registers four read-only [WebMCP](https://webmachinelearning.github.io/webmcp/) tools on `document.modelContext`, for browser agents rather than MCP clients. They cover the site, not your test data: `gaffer.search_docs`, `gaffer.get_pricing`, `gaffer.install_mcp`, and `gaffer.signup_url`. As of August 2026 the API is available only in Chrome's origin trial, and the MCP server above remains the way to give an assistant your test history. Background: [WebMCP on gaffer.sh](/blog/webmcp-agent-callable-marketing-site/). ## Example Prompts Once the MCP server is connected, try asking your AI assistant: - "What projects do I have in Gaffer?" - "What's the health of my test suite?" - "Which tests are flaky in my project?" - "Is the login test flaky? Check its history" - "What tests failed in the last commit?" - "Show me test runs on the main branch" - "Which tests are slowing down my CI pipeline?" - "Get the Playwright report for the latest test run" ## Environment Variables | Variable | Required | Description | |----------|----------|-------------| | `GAFFER_API_KEY` | Yes | Your Gaffer API Key (starts with `gaf_`) | | `GAFFER_API_URL` | No | API base URL (default: `https://app.gaffer.sh`) | ## Authentication The MCP server uses **User API Keys** (`gaf_` prefix) which provide read-only access to all projects across your organizations. Get your API Key from **Account Settings > API Keys** in the Gaffer dashboard. **Note:** Project Tokens (`gfr_` prefix) are designed for uploading test results from CI and only provide access to a single project. For the MCP server, use a User API Key instead. --- ## Upload API Source: https://gaffer.sh/docs/upload-api/ Gaffer's Upload API allows you to upload test reports from any CI/CD system or local environment. The API accepts multipart form data containing your test report files and optional metadata. ## Endpoint `POST https://app.gaffer.sh/api/upload` ## Authentication All requests must include your project token in the `X-API-Key` header. You can find your project token in your project settings. | Header | Description | |--------|-------------| | `X-API-Key` | Your project token (required) | | `Content-Type` | `multipart/form-data` (set automatically by most HTTP clients) | When using the [Gaffer CLI](/docs/cli/), the canonical environment variable is `GAFFER_PROJECT_TOKEN`. `GAFFER_UPLOAD_TOKEN` (the previous name) and `GAFFER_TOKEN` remain supported as fallbacks for backward compatibility. ## Request Body The request body must be `multipart/form-data` with the following fields: | Field | Type | Required | Description | |-------|------|----------|-------------| | `files` | File(s) | Yes | One or more test report files to upload. Include multiple files by repeating the field. | | `tags` | JSON string | No | Metadata tags as a JSON object (see below). | ## Tags Tags are optional metadata that help you organize and filter your test runs. Pass them as a JSON string in the `tags` field. | Tag | Recommended | Description | |-----|-------------|-------------| | `commitSha` | Strongly recommended | The git commit SHA associated with this test run. | | `branch` | Recommended | The git branch name (e.g., `main`, `feature/auth`). | | `test_framework` | Optional | The test framework used (e.g., `playwright`, `jest`, `pytest`). | | `test_suite` | Optional | A label for the test suite (e.g., `unit`, `integration`, `e2e`). | You can also include any custom tags you like. All tag values must be strings. ## File Size Limits The single-POST endpoint documented here is capped at **75 MB total per request**. Past that it returns `413` with reason `file_too_large_for_v1`. The cap sits deliberately below Cloudflare's ~100 MiB edge limit so you get a clean, actionable error instead of a dropped connection. For anything larger (Playwright traces, large videos, full HTML reports), use the [`gaffer` CLI](/docs/cli/) or the [`gaffer-uploader@v2` GitHub Action](/docs/guides/github-actions/). Both route uploads through Gaffer's multipart endpoints transparently, supporting individual files up to **5 GB**. Your plan's storage cap is the only practical ceiling. To drive the multipart endpoints yourself, see the [cURL guide](/docs/guides/curl/). ## Supported Report Formats Gaffer supports a variety of test report formats: - **Playwright HTML** - Full HTML report with embedded data - **Vitest HTML** - Vitest HTML reporter output - **Jest JSON** - Native Jest JSON output (`--json`) - **Jest HTML** - jest-html-reporter output - **pytest HTML** - pytest-html plugin output - **JUnit XML** - Standard JUnit XML format - **Vitest JSON** - Vitest JSON reporter output ## Example Request ### Single File Upload ```bash title="curl" curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: YOUR_PROJECT_TOKEN" \ -F "files=@playwright-report/index.html" \ -F 'tags={"commitSha":"abc123def456","branch":"main","test_framework":"playwright"}' ``` ### Multiple Files Upload ```bash title="curl" curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: YOUR_PROJECT_TOKEN" \ -F "files=@playwright-report/index.html" \ -F "files=@playwright-report/data/test-results.json" \ -F 'tags={"commitSha":"abc123","branch":"feature/auth"}' ``` ## Response A successful upload returns HTTP `201 Created` with the following JSON response: ```json title="Response" { "testRun": { "id": "abc123xyz", "uniqueId": "1701234567890", "projectId": "proj_abc123", "commitSha": "abc123def456", "branch": "main", "tags": { "commitSha": "abc123def456", "branch": "main", "test_framework": "playwright" }, "createdAt": "2024-01-15T10:30:00.000Z" }, "files": [ { "filename": "index.html", "size": 245678, "path": "org_123/proj_456/1701234567890/index.html", "contentType": "text/html" } ] } ``` | Field | Description | |-------|-------------| | `testRun.id` | Unique identifier for the test run. | | `testRun.uniqueId` | Timestamp-based identifier. | | `testRun.projectId` | The project this test run belongs to. | | `testRun.commitSha` | Git commit SHA (if provided in tags). | | `testRun.branch` | Git branch name (if provided in tags). | | `testRun.tags` | All tags provided in the request. | | `testRun.createdAt` | When the test run was created. | | `files` | Array of uploaded file details. | | `warnings` | Optional array of warning messages (e.g., if parsing couldn't be queued). | ## Error Responses | Status | Meaning | Description | |--------|---------|-------------| | `400` | Bad Request | No files provided, invalid tags format, or file too large. | | `401` | Unauthorized | Missing or invalid project token. | | `402` | Payment Required | Storage limit exceeded. Upgrade your plan for more storage. | | `503` | Service Unavailable | Temporary upload failure. Retry the request. | ## Next Steps - [cURL Guide](/docs/guides/curl/) - Quick examples for uploading with cURL - [GitHub Action](/docs/guides/github-actions/) - Automate uploads from GitHub Actions # Solutions --- ## AI Test Memory: Give Agents Test History via MCP Source: https://gaffer.sh/solutions/ai-agent-test-memory/

Your AI agent just "fixed" a test that's been flipping between pass and fail for two weeks. It spent tokens debugging a 40% flip-rate flaky test that no human would touch. The agent doesn't know that because it has no memory of what happened before this session.

## The Problem AI coding agents operate without test history. Each session starts from zero. This leads to three failure modes that waste time and CI minutes: **Wasted cycles on flaky tests.** An agent sees a test failure and tries to fix it. But the test has a 40% flip rate: it was going to pass on the next run anyway. The agent doesn't know because it can't see historical pass/fail patterns. **Duplicate work on shared root causes.** CI reports 14 failures. The agent attempts 14 separate fixes. In reality, 11 of those failures share one root cause: a database connection timeout. Fixing one line would have resolved them all. **No before/after comparison.** After making changes, the agent can't verify whether a specific test actually improved. Did the fix work, or did a flaky test just happen to pass this time? Without comparing metrics across commits, the agent is guessing. This isn't theoretical. Google's 2025 DORA report found that [AI coding tools correlate with a 7.2% decrease in delivery stability](https://dora.dev/research/2025/ai-assistance/). The agents are fast at writing code, but they make poor decisions when they can't see test history. ## What Teams Try (and Why It Falls Short) **Piping CI logs into context.** Raw CI output is unstructured text designed for humans. An agent can't compute a flip rate from log lines. It can't cluster failures by error similarity. And it definitely can't compare results across commits when it's parsing different log formats from Playwright, Jest, and Pytest. **General-purpose memory tools.** Tools like mem0 or Letta give agents fuzzy vector recall, useful for remembering conversations but not for computing that `test_checkout_flow` has flipped 8 times in 20 runs. You can't calculate a flakiness score from semantic similarity search. **Checking CI directly.** GitHub Actions artifacts expire. Navigating workflow runs programmatically is brittle. And even if the agent finds the right artifact, it gets back HTML reports or raw XML, not structured data it can reason about. ## Structured Test Memory "Test memory" means giving agents deterministic, queryable access to test history — not fuzzy recall, but exact data: pass rates, flip counts, failure clusters, duration changes. Gaffer's [MCP server](https://www.npmjs.com/package/@gaffer-sh/mcp) exposes this as structured tool calls that any MCP-compatible agent (Claude, Cursor, Windsurf, Copilot) can use automatically. ### Identify Flaky Tests Before Wasting Time ```json // get_flaky_tests response { "flakyTests": [ { "name": "should complete checkout flow", "flipRate": 0.4, "flipCount": 8, "totalRuns": 20, "flakinessScore": 0.72 } ], "summary": { "totalFlaky": 3, "threshold": 0.1, "period": 30 } } ``` The agent sees a `flakinessScore` of 0.72 and a 40% flip rate. It skips the flaky test and focuses on real regressions. ### Group Failures by Root Cause ```json // get_failure_clusters response { "clusters": [ { "representativeError": "Connection refused: localhost:5432", "count": 11, "tests": [ { "name": "should create user", "fullName": "Auth > should create user" }, { "name": "should update profile", "fullName": "Profile > should update profile" } ] }, { "representativeError": "Expected 200, received 401", "count": 3, "tests": [ { "name": "should access dashboard", "fullName": "Dashboard > should access dashboard" } ] } ], "totalFailures": 14 } ``` 14 failures, 2 root causes. The agent fixes the database connection issue and the auth bug, not 14 individual tests. ### Track Pass/Fail Across Commits ```json // get_test_history response { "history": [ { "status": "failed", "commitSha": "a1b2c3", "durationMs": 4200 }, { "status": "passed", "commitSha": "d4e5f6", "durationMs": 3800 }, { "status": "passed", "commitSha": "g7h8i9", "durationMs": 3900 } ], "summary": { "totalRuns": 3, "passedRuns": 2, "failedRuns": 1, "passRate": 66.67 } } ``` Failed once, passed twice before: likely a regression in the latest commit, not a flaky test. ### Compare Before and After ```json // compare_test_metrics response { "before": { "status": "failed", "durationMs": 12400, "commit": "a1b2c3" }, "after": { "status": "passed", "durationMs": 3200, "commit": "d4e5f6" }, "change": { "statusChanged": true, "percentChange": -74.2 } } ``` The fix worked: status changed from failed to passed, duration dropped 74%. No guessing. ## Comparison | Capability | CI Logs | Vector Memory | Gaffer MCP | |------------|---------|---------------|------------| | Flip rate / flakiness score | No | No | Yes | | Failure clustering by root cause | No | No | Yes | | Cross-commit comparison | No | Approximate | Exact | | Structured, queryable data | No | Fuzzy | Yes | | Works across test frameworks | Varies | N/A | Yes | | Agent can use without parsing | No | Yes | Yes | ## Setup Three steps. No code changes to your test suite. ### 1. Upload test results from CI Add the [Gaffer uploader](/docs/guides/github-actions/) to your CI pipeline. Supports GitHub Actions, GitLab CI, CircleCI, and others. If your CI system isn't covered, the [CLI](/docs/cli/) uploads from any shell. ```yaml - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} ``` ### 2. Add the MCP server ```bash claude mcp add gaffer -e GAFFER_API_KEY=gaf_your_key -- npx -y @gaffer-sh/mcp@latest ``` Works with Claude Code, Cursor, Windsurf, and any MCP-compatible tool. The [MCP server docs](/docs/mcp/) list every tool the agent can call and the configuration for each client. The site itself also exposes [WebMCP tools](/blog/webmcp-agent-callable-marketing-site/) for browser agents, separate from the MCP server. ### 3. Agent queries automatically Once connected, the agent calls the right tool based on context. No configuration for when to use which tool: it sees a test failure and checks history, checks flakiness, clusters failures, and compares results across commits. ## Get Started Gaffer's free tier includes test history and flaky test detection. The MCP server is [open source](https://github.com/gaffer-sh/mcp).
--- ## Allure Report Alternative: Hosted Test Reports, No Setup Source: https://gaffer.sh/solutions/allure-alternative/ import { Image } from "astro:assets";

You picked Allure for its HTML reports. Then you tried to share those reports with your team, and hit the real problem: self-hosting a server, configuring a database, managing CI integration, handling user access, and keeping everything updated. For many teams, the infrastructure tax exceeds the reporting benefit.

## Why Teams Look for an Allure Report Alternative Allure Report generates beautiful HTML reports locally. Allure TestOps (the paid product) adds history and collaboration features. But both come with trade-offs: ### Self-Hosting Complexity Running Allure TestOps means: - Setting up and maintaining a server - Configuring PostgreSQL or another database - Managing backups and storage - Handling authentication and user management - Keeping everything updated and secure For small teams, this is a lot of overhead just to share test results. ### CI Pipeline Integration Allure requires adding the Allure commandline tool to your CI environment, generating reports, then uploading them somewhere your team can access. This works, but it's another moving part to maintain. ### Report Hosting Allure generates static HTML reports, but hosting them requires additional setup. Common approaches: - GitHub Pages (works but limited for private repos) - S3 + CloudFront (more infrastructure to manage) - Self-hosted web server (even more maintenance) ## Gaffer: Hosted Test Reporting Gaffer takes a different approach: hosted test reporting with zero infrastructure. ### No Self-Hosting Gaffer is a SaaS platform. No servers to manage, no databases to configure, no updates to apply. Sign up and start uploading test results. ### Simple CI Integration One step in your CI pipeline: ```yaml - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./test-results ``` Or with curl if you prefer: ```bash curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/report.html" ``` One YAML step is all it takes — no additional tools in CI, no database, no server to maintain. Teams migrating from Allure typically have their first report uploaded within ten minutes of creating a Gaffer project. ### Instant Team Sharing Every upload gets a shareable URL. Send it in Slack, paste it in a PR, or bookmark it. No downloads, no zip files, no "let me spin up the report server." ### Built-in Flaky Test Detection Gaffer automatically tracks test stability over time and identifies flaky tests by analyzing flip rates. No additional setup required: just upload your results. Full detail in [flaky test detection](/solutions/flaky-test-detection/). ## Feature Comparison | Feature | Allure Report | Allure TestOps | Gaffer | |---------|---------------|----------------|--------| | Test reports | Local HTML | Hosted | Hosted | | Historical trends | Manual setup | Yes | Yes | | Flaky test detection | No | Yes | Yes | | Team sharing | Manual hosting | Yes | Yes | | Self-hosting required | N/A | Yes | No | | Database required | No | Yes | No | | Pricing | Free | Per user | Flat-rate tiers | | Setup time | Minutes | Hours/Days | Minutes | ## Framework Support Both Allure and Gaffer support major test frameworks: | Framework | Allure | Gaffer | |-----------|--------|--------| | Playwright | Yes | Yes | | Jest | Yes | Yes | | Vitest | Via adapter | Yes | | pytest | Yes | Yes | | JUnit | Yes | Yes ([JUnit XML guide](/blog/junit-xml-format-guide/)) | | Cypress | Yes | Yes (via CTRF) | Gaffer also supports CTRF (Common Test Report Format), which provides adapters for 20+ test frameworks. ## When to Use Allure Allure might be a better fit if you: - Already have infrastructure and ops capacity to self-host - Need Allure's specific annotation features (@Step, @Attachment) - Have strict data residency requirements that prevent SaaS usage - Want to avoid any SaaS vendor dependency ## When to Use Gaffer Gaffer is probably a better fit if you: - Want test reporting without infrastructure overhead - Have a small team without dedicated DevOps - Need quick setup (minutes, not hours) - Want built-in Slack notifications and GitHub integration - Prefer flat-rate pricing with unlimited users over per-seat ## Migration from Allure Already using Allure? Gaffer works alongside your existing setup: 1. Keep generating Allure reports if your team is used to them 2. Add the Gaffer upload step to CI 3. Use CTRF or JUnit XML format for Gaffer analytics 4. Gradually shift to Gaffer for sharing and history No big-bang migration required. ## Other Comparisons Evaluating multiple tools? See how Gaffer compares to [ReportPortal](/solutions/reportportal-alternative/), [Currents.dev](/solutions/currents-dev-alternative/), and [TestDino](/solutions/testdino-vs-gaffer/). ## Try It Gaffer's free tier includes 500 MB storage and 7-day retention. Enough to evaluate whether it fits your workflow.
--- ## How to Analyze Test Failures Across CI Runs (Without Drowning in Logs) Source: https://gaffer.sh/solutions/analyze-test-failures/

A CI run goes red with 14 failed tests. You open the job log and start scrolling: stack traces, framework noise, retry output, the occasional unrelated warning. Twenty minutes later you've confirmed what you suspected on line one, that most of those failures share a single cause. Analyzing test failures shouldn't mean reading thousands of lines of console output to find the three that matter.

## What Analyzing Test Failures Actually Means for a CI/CD Team Analyzing test failures is the work of turning a list of red results into a short list of decisions: what to fix now, what to ignore, what to file. The raw CI log gives you the symptoms. The job is to find the cause, decide who owns it, and confirm the fix landed. ### Test failure vs. error: the distinction that changes your response A **test failure** is an assertion that didn't hold: the code ran, produced a result, and the result was wrong. `expected 200, received 500`. A **test error** is the test itself not completing: a thrown exception, a timeout, a missing fixture, a connection refused before any assertion ran. The distinction matters because the response differs. A failure usually points at application code or a stale expectation. An error often points at the environment, a dependency, or test setup. Treating every red result the same way is how teams waste time mocking around an assertion when the real problem is that a service never came up. ## Why Test Failures Are Hard to Triage at Scale One failed test is easy. A suite that produces dozens of failures across multiple CI runs per day is where triage breaks down, and where most teams fall into one of three traps. **The log-scanning trap.** You read the CI job output top to bottom. It works for a handful of failures and collapses past that. Logs are ordered by execution time, not by cause, so five failures from one dead database land scattered between unrelated passes and warnings. You reconstruct the pattern by hand, every single run. **The rerun-until-green trap.** Hit re-run, hope the failures were flaky, merge if they clear. Sometimes they were flaky. Sometimes you just rolled the dice on a real bug and got lucky. Either way you learned nothing about which tests are actually unreliable, so you do it again next week. **The spreadsheet trap.** Someone starts tracking failures manually: test name, date, "looking into it." It's stale within a week because nothing updates it automatically, and it answers none of the questions that matter, like whether this failure has happened before or how often. ## The Four Types of Test Failures and the Right Response to Each Every red result falls into one of four buckets, and each one calls for a different action. **Consistent failures** fail on every run with the same error. These are real regressions. The right response: fix the code or update the expectation, then confirm the test passes. **Flaky failures** pass and fail without code changes. The right response is not to re-run until green. It's to [identify the flaky test](/solutions/flaky-test-detection/) by its history and either stabilize it or quarantine it so it stops polluting triage. **New failures** are tests that started failing on a specific run. The right response: tie the failure to the change that introduced it and route it to whoever made that change. **Environment failures** are errors, not assertion failures: connection refused, missing variable, wrong runtime version. The right response is to fix the runner or the setup, not the test. These often cascade, which is exactly where clustering earns its keep. ## How Failure Clustering Changes Triage Most reporting tools show failures as a flat list ordered by test name or execution order. That ordering hides the thing you most need to see: which failures share a cause. [Failure clustering](/solutions/failure-clustering/) groups failures by their error signature instead of by test name. Gaffer extracts the error message from each failing test, strips the variable parts (timestamps, UUIDs, port numbers), and groups tests with matching signatures into a single cluster named after the shared error. Fourteen red tests become "Connection refused (9 tests)" and "expected 200, received 500 (5 tests)". You're now looking at two problems, not fourteen. The clustering runs on parsed error output, so it works the same whether the report came from Playwright, Jest, Vitest, pytest, or JUnit XML. No configuration, no per-framework rules. ## Step-by-Step: Triaging a Failure Cluster in Gaffer ### Step 1: Upload the CI report Add one step to your pipeline after the test run. GitHub Actions: ```yaml - name: Run tests run: npm test - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./test-results/results.xml ``` The `if: always()` matters: without it the upload is skipped exactly when tests fail, which is when you need the report. ### Step 2: Read the clusters Open the run. Clusters appear below the failure list, sorted by how many tests each one affects. Start at the top, because the largest cluster is usually one fix. ### Step 3: Separate flaky from consistent For each cluster, check whether those tests have failed before without code changes. A cluster of known-flaky tests gets a different owner and a different priority than a cluster of consistent regressions. This is the step the rerun-until-green habit skips. ### Step 4: Assign or file an issue Consistent cluster: file one issue for the shared cause, not one per test. New failures: route to the author of the introducing change. Flaky cluster: send it to whoever owns test stability. ### Step 5: Confirm it clears After the fix merges, the next run either shows the cluster gone or it doesn't. No manual spreadsheet update, no guessing whether the re-run was luck. ## What Gaffer Shows That Raw CI Logs Don't A CI log is a snapshot of one run. It can't answer the questions that actually drive triage decisions, because those questions span runs. - **Cross-run persistence:** has this exact failure happened before, or is it new on this commit? A log can't tell you. Gaffer keeps the failure history, so you can tell a fresh regression from a failure that has come back before. - **Frequency:** is this test failing 2% of the time or 40%? That number decides whether you stabilize it or quarantine it, and it only exists if something is tracking runs over time. - **Co-occurrence:** which failures keep showing up together? Tests that fail as a group point at a shared dependency, and the cluster makes that obvious instead of leaving you to notice it across scrolling sessions. To get failures in front of the right person without anyone watching CI, connect [test failure notifications](/solutions/test-failure-notifications/): Slack or webhook alerts with the pass/fail summary and a direct link to the hosted failure detail, filterable by branch so feature branches don't spam the channel. ## Get Started Gaffer's free tier includes 500 MB of storage with 7-day retention, enough to wire up the upload step and watch a few real CI runs cluster their own failures.

Stop reconstructing failure patterns by hand

Gaffer clusters failures by root cause across runs and keeps the history a CI log throws away. Free tier, no credit card.

Start Free
--- ## CI Test Artifacts Keep Expiring? Here's the Fix Source: https://gaffer.sh/solutions/ci-test-artifacts-expiring/ import { Image } from "astro:assets";

You're debugging a production issue. You remember a test failed with the same error three months ago. You go to find that test report... and it's gone. The CI artifacts expired. Sound familiar?

## Why CI Providers Delete Your Test Artifacts Every major CI/CD platform automatically deletes artifacts after a retention period: | CI Provider | Default Retention | Max Retention | |-------------|-------------------|---------------| | GitHub Actions | 90 days | 400 days (paid) | | GitLab CI | 30 days | Configurable | | CircleCI | 30 days | 30 days | | Azure DevOps | 30 days | Configurable | | Bitbucket Pipelines | 14 days | 14 days | **Why do they do this?** Storage costs money. CI providers optimize for build speed and compute, not long-term artifact storage. Your test reports are considered disposable. ## The Real Cost of Expiring Artifacts Losing [test artifacts](/blog/what-are-test-artifacts/) isn't just annoying—it has real consequences: ### 1. Debugging Becomes Harder When a bug resurfaces, historical test data is invaluable. Which tests were failing before we "fixed" this? What did the error message say? Without the original artifacts, you're starting from scratch. ### 2. Flaky Test Analysis is Impossible To understand flaky tests, you need data across many runs. If artifacts expire after 30 days, you lose the historical context needed to identify patterns. ### 3. Compliance Requirements Regulated industries (finance, healthcare, automotive) often require test evidence retention for years. CI artifact expiration violates these requirements. ### 4. Onboarding Suffers New team members can't see how the test suite evolved. They can't reference past failures to understand why certain tests exist. ### 5. Post-Mortems Lack Evidence "What went wrong?" is hard to answer when the test reports from the incident are gone. ## Workarounds (and Why They Fall Short) ### Increase CI Retention Some CI providers let you extend retention. GitHub Actions goes up to 400 days with paid plans. But: - Still not permanent - Expensive at scale (GitHub charges for storage) - Doesn't solve the access problem (finding artifacts is still tedious) ### Upload to S3/GCS You can add a CI step to upload artifacts to your own cloud storage: ```yaml - name: Upload to S3 run: aws s3 cp ./test-results s3://my-bucket/reports/${{ github.sha }} --recursive ``` Problems: - Manual setup and maintenance - No built-in access control - No UI for browsing reports - Still need to manage storage costs and cleanup ### Save Locally Some teams download important artifacts before they expire. This doesn't scale and relies on someone remembering to do it. ## The Real Solution: Dedicated Test Report Hosting Instead of fighting CI artifact limits, host your test reports on a platform designed for it: 1. **Automatic upload** from CI pipeline 2. **Configurable retention** per project (up to 90 days on paid plans) 3. **Instant access** via shareable URLs 4. **Team-based permissions** tied to your organization 5. **Search and analytics** across all historical runs ## How Gaffer Solves CI Artifact Expiration Gaffer is purpose-built for test report hosting. Here's how it works: ### Step 1: Add One Line to CI **GitHub Actions:** ```yaml - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./test-results ``` **Any CI with curl:** ```bash curl -X POST https://api.gaffer.sh/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/report.html" \ -F "branch=$CI_BRANCH" \ -F "commit=$CI_COMMIT_SHA" ``` ### Step 2: Choose Your Retention Every upload gets a shareable URL. You control how long reports are kept: | Plan | Default Retention | Per-Project Settings | |------|-------------------|---------------------| | Free | 7 days | Custom retention periods | | Pro | 30 days | Custom retention periods | | Team | 90 days | Custom retention periods | **Longer retention than CI providers** - Keep test data for up to 90 days with Team plans, plenty of time for debugging and analysis. **Want to keep costs low?** Enable automatic cleanup and set a shorter retention period. Test reports from feature branches might only need 7 days, while production test data could be kept longer. ### Step 3: Access Anytime - Browse reports in the Gaffer dashboard - Search by branch, commit, date range - Share links with teammates - Get Slack notifications with direct links ## Per-Project Retention Control Different projects have different needs. Gaffer lets you configure retention per-project: - **Production tests**: Keep for 90 days (Team plan) - **Feature branch tests**: Auto-delete after 7 days - **Important projects**: Use longer retention periods All plans can set custom retention periods to match your needs. ## Beyond Storage: Analytics Over Time When you keep test data longer, you unlock insights that short CI retention makes difficult: - **Pass rate trends** - Is the suite getting more stable? - **Flaky test detection** - Which tests fail intermittently? - **Regression detection** - When did this test start failing? - **Duration trends** - Are tests getting slower? ## Migration Path Already have test reports scattered across CI providers? Gaffer works alongside your existing setup: 1. Add Gaffer upload to CI (takes 5 minutes) 2. New reports go to Gaffer automatically 3. Old reports in CI continue to expire (nothing changes) 4. Over time, Gaffer becomes your source of truth No migration, no data import, just better going forward. ## Get Started Stop losing test artifacts. Gaffer gives you control over retention with per-project settings and longer retention periods than most CI providers offer.
--- ## Track Code Coverage Trends Over Time Source: https://gaffer.sh/solutions/code-coverage/ import ScreenshotLink from "../components/ScreenshotLink.astro"; import coverageSummary from "../assets/screenshots/coverage-summary.png";

Your CI generates a coverage report every run. But by next month, that report is gone: CI artifacts expire, and with them, any record of what your coverage looked like. You can't track whether coverage is improving or regressing because there's no historical data.

## The Problem: Coverage Without History Most teams treat code coverage as a point-in-time check. CI runs, coverage is 74%, the build passes. But that number alone doesn't answer the questions that matter: - **Is coverage going up or down?** A single number doesn't tell you direction - **When did coverage drop?** Without history, you can't pinpoint the commit that removed tests - **Are new files getting tested?** You can't tell if recent code has coverage without comparing over time - **Is the team hitting coverage goals?** Progress requires a baseline and a trend line ### Why This Happens **CI artifacts expire.** GitHub Actions keeps artifacts 90 days max. Most teams set shorter retention. Once they're gone, the coverage data is gone. **No built-in trend tracking.** Coverage tools (Istanbul, coverage.py, gcov) generate reports for the current run. They don't store or compare across runs. **Context disappears.** A coverage report says `utils/parser.ts` has 45% coverage. It doesn't tell you it had 60% last week before someone deleted tests, or that it's been at 45% for six months while the file tripled in size. ## Better: Persistent Coverage Tracking The fix is to store coverage data from every run and visualize it over time. Every data point builds a picture of where your test suite is heading. ### What This Looks Like - **Trend charts** showing line, branch, and function coverage over days, weeks, or months - **Regression alerts** when coverage drops after a commit - **Per-file breakdown** showing which files have the lowest coverage - **Commit linking** connecting coverage changes to specific code changes ## Setting Up Coverage Tracking with Gaffer ### Step 1: Generate LCOV Output Most test frameworks can output LCOV format. For a full walkthrough of provider choice, threshold enforcement, and CI setup, see the [Vitest coverage reports guide](/blog/vitest-coverage-reports/). ```bash # JavaScript/TypeScript (Vitest) vitest run --coverage # JavaScript/TypeScript (Jest) jest --coverage --coverageReporters=lcov # Python coverage run -m pytest && coverage lcov # Go go test -coverprofile=coverage.out ./... ``` ### Step 2: Upload to Gaffer Add coverage upload to your CI pipeline alongside test results: ```yaml # GitHub Actions - name: Upload coverage to Gaffer if: always() run: | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: ${{ secrets.GAFFER_TOKEN }}" \ -F "files=@coverage/lcov.info" \ -F 'tags={"commitSha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}' ``` ### Step 3: View Trends The analytics dashboard shows coverage over time. Spot regressions immediately. Track progress toward goals. ## What You Get ### Trend Visualization See line, branch, and function coverage plotted over time. Zoom in on specific date ranges. Compare branches. ### Regression Detection and Coverage Gating When coverage drops after a commit, you see exactly when it happened and can correlate with the code change that caused it. You can also enforce coverage gates directly on pull requests: Gaffer posts a GitHub commit status that fails if coverage drops below a threshold or regresses by more than a configured delta compared to your default branch. No more relying on manual review to catch coverage regressions. ### Per-File Breakdown Drill down to see which files have the lowest coverage. Focus testing efforts where they'll have the most impact instead of chasing an aggregate number. ### Commit Correlation Coverage data is linked to specific commits and branches. See how each PR affects overall coverage before merging. Combined with commit status gating, you get automated enforcement: PRs that reduce coverage beyond your tolerance are flagged before review. ## Supported Formats Currently in beta with support for LCOV, Cobertura XML, JaCoCo XML, and Clover XML — covering JavaScript, TypeScript, Python, Go, Java, and more. Read the full documentation → ## Get Started Gaffer's free tier includes coverage tracking. Upload your LCOV data and start building a coverage history.
--- ## Currents.dev Alternative: Flat Pricing and Unlimited Users Source: https://gaffer.sh/solutions/currents-dev-alternative/

[Currents.dev](https://currents.dev/) is a mature Cypress-era test analytics product, now oriented around enterprise workflows (SSO/SCIM, Slack Connect, 1-year retention). Its Team plan is $49/month for 10 seats and 10,000 test results per month, with $5 per additional 1,000 results on top. Teams start shopping for a Currents.dev alternative when they cross 10 users, hit the test-result cap, or simply don't want a per-usage meter on their CI output. Gaffer prices on storage, not seats or test volume: $49/month flat, unlimited users, 50 GB, 90-day retention.

## Why Teams Look for Currents.dev Alternatives ### Seat Count Ceiling on the Team Plan Currents' Team plan includes 10 seats. Past that, the path is Enterprise with custom pricing. For a 15-person engineering team that wants everyone to see test results, the Team plan doesn't fit and the next step is a sales call. ### Test-Result Metering The 10,000 test results per month included on Team sounds generous until you do the arithmetic. A team running 300 CI runs per day at 50 tests per run produces roughly 450,000 test results per month. At $5 per additional 1,000 results, that's around $2,200/month in metered overage on top of the $49 base. Usage scales with your test suite size and CI frequency, both of which tend to grow. ### No Free Tier Currents does not publish a free tier. Evaluation means a trial window, not a persistent free workspace you can leave running on a side project. ## Gaffer: Same Outcomes, Different Economics ### Flat Pricing, Unlimited Users Gaffer has three tiers. Free is $0 with 500 MB and 7-day retention. Pro is $15/month with 10 GB and 30-day retention. Team is $49/month with 50 GB and 90-day retention. Every tier includes unlimited users. Overage on Pro and Team is $0.50/GB/month. There is no test-result cap; storage is the only billed resource. ### Same CI Upload Pattern One step in your CI pipeline: ```yaml - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./test-results ``` Or with curl: ```bash curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/report.xml" ``` JUnit XML and CTRF are both first-class. Playwright, Jest, Vitest, pytest, and Cypress all work. ### MCP Code-Mode for Agentic CI Both products expose an MCP server. The shapes differ. Currents' MCP provides roughly 25 CRUD tools for its Dashboard, GitHub PR, CLI, and Slack surfaces. Gaffer's MCP uses code-mode: three primitives (`search_tools`, `execute_code`, and a small set of named functions like `get_project_health`, `get_flaky_tests`, `get_slowest_tests`, `get_failure_clusters`, `get_test_history`, `get_coverage_summary`, `find_uncovered_failure_areas`, `compare_test_metrics`, `search_failures`, `list_test_runs`). The agent composes JavaScript that calls those functions, rather than issuing one MCP call per CRUD endpoint. A typical agent session is one `execute_code` call that does the work of 5 to 10 CRUD requests. See [GitHub Agentic Workflows with Gaffer MCP](/blog/github-agentic-workflows-test-analytics/) for the reasoning behind this shape. ### Built-in Analytics Without Extra Configuration Flaky detection, slowest-test surfacing, and failure clustering run on every upload. As a calibration point, Gaffer's own 30-day metrics (pulled via the MCP server) currently sit at a 95 health score, 99.96% pass rate over 200 runs, 4 flaky tests, and a p95 duration of 62 seconds on the slowest test. Those are the same numbers the dashboard and MCP tools would return for any project with comparable activity. ## Pricing Comparison | Tier | Currents.dev | Gaffer | |------|--------------|--------| | Free | Not offered | $0, 500 MB, 7-day retention, unlimited users | | Entry paid | Not offered | $15/month, 10 GB, 30-day retention, unlimited users | | Team | $49/month, 10 seats, 10K test results/month, +$5 per additional 1K results, up to 1-year retention | $49/month, unlimited users, 50 GB, 90-day retention | | Overage | $5 per 1,000 test results over cap | $0.50/GB/month (Pro and Team) | | Enterprise | Custom (SSO/SCIM, Slack Connect, data redaction) | Not offered | ## Feature Comparison | Feature | Currents.dev | Gaffer | |---------|--------------|--------| | Test report hosting | Yes | Yes | | Historical trends | Yes | Yes | | Flaky test detection | Yes | Yes | | Slowest-test and failure clustering | Yes | Yes | | MCP server | Yes (CRUD tools) | Yes (code-mode) | | GitHub commit status | Yes | Yes | | Slack notifications | Yes (incl. Slack Connect on Enterprise) | Yes | | Webhook notifications | Yes | Yes | | SSO / SCIM | Enterprise | Not available | | Jira / MS Teams / GitLab / BitBucket integrations | Yes | Limited | | Self-hosting | Enterprise | Not available | | Retention (paid) | Up to 1 year | Up to 90 days | | User pricing | Per seat (10 included on Team) | Unlimited users, all tiers | | Free tier | No | Yes | ## When to Use Currents.dev Currents is the better fit if you need: - SSO or SCIM today, not on a roadmap - Slack Connect, Jira, MS Teams, GitLab, or BitBucket as first-class integrations - A team larger than 10 users where per-seat pricing has already been negotiated - Retention longer than 90 days (up to 1 year on Team) - Self-hosting via an Enterprise contract ## When to Use Gaffer Gaffer is the better fit if: - Your team is approaching or past 10 users and per-seat pricing is the binding constraint - You want a free tier for evaluation or for side projects - Storage-based pricing matches your workload better than test-result metering - You want the MCP code-mode primitive for agentic CI workflows. See [Test Intelligence for AI Tools](/blog/test-intelligence-for-ai-tools/) - You prefer flat billing without a per-usage meter on CI output ## Migration from Currents.dev No big-bang cutover needed. 1. Keep uploading to Currents. Nothing breaks. 2. Add a Gaffer upload step to CI using JUnit XML or CTRF output. 3. Point the team at Gaffer for sharing and historical analytics. Retire Currents when you're ready. ## Other Comparisons Evaluating multiple tools? See how Gaffer compares to [Allure](/solutions/allure-alternative/), [ReportPortal](/solutions/reportportal-alternative/), and [TestDino](/solutions/testdino-vs-gaffer/). ## Try It Gaffer's free tier includes 500 MB storage and 7-day retention. Enough to run your CI against it for a week and decide.
--- ## How to Share Cypress Test Results with Your Team Source: https://gaffer.sh/solutions/cypress-test-reporting/

Cypress generates test results, but sharing them with your team means either paying for Cypress Cloud or pasting terminal output into Slack. If you want hosted results with shareable links, analytics, and notifications without Cypress Cloud pricing, there's a simpler path.

## The Cypress Reporting Problem Cypress runs tests and prints results to the console. The built-in dashboard experience requires Cypress Cloud (formerly Cypress Dashboard), which is a paid service for teams that need parallelization and analytics. ### What Teams Do Instead **1. Cypress Cloud** - Good feature set: parallelization, analytics, flaky test management - Pricing scales with recorded test runs, which can get expensive for active teams - Tightly coupled to Cypress: doesn't help if you also run Jest, Playwright, or pytest **2. CI artifacts** - Cypress screenshots and videos upload as artifacts - Recipients need CI access to download zips - Artifacts expire (90 days max on GitHub Actions) - No analytics, no historical trends **3. Mochawesome reports** - Generates HTML reports locally, but hosting them is your problem - No built-in sharing, analytics, or notification support - Another reporter to configure and maintain **4. Terminal output in Slack** - Loses all context for screenshots and videos - Truncated for large suites - No way to filter or drill into failures ## Better: CTRF + Hosted Reports Cypress doesn't produce a report format that Gaffer parses natively (like Playwright HTML or JUnit XML). Instead, the integration uses [CTRF (Common Test Report Format)](https://ctrf.io/) — a standardized JSON format with reporters for 20+ test frameworks including Cypress. The setup: 1. Add the `cypress-ctrf-json-reporter` to your project 2. Cypress generates a CTRF JSON file after tests run 3. Upload the CTRF file to Gaffer in CI 4. Get a shareable URL, Slack notification, and analytics ### What This Looks Like After CI runs: - Your team gets a Slack notification with pass/fail summary - Anyone can click through to the full hosted report - Test analytics accumulate over time: pass rates, flaky tests, slow tests - No Cypress Cloud subscription required ## Setting Up Cypress Reporting with Gaffer ### Step 1: Install the CTRF Reporter ```bash npm install cypress-ctrf-json-reporter --save-dev ``` ### Step 2: Configure Cypress Add the reporter to your `cypress.config.ts` via the `setupNodeEvents` hook: ```typescript import { defineConfig } from 'cypress'; import { GenerateCtrfReport } from 'cypress-ctrf-json-reporter'; export default defineConfig({ e2e: { setupNodeEvents(on) { new GenerateCtrfReport({ on }); }, }, }); ``` This generates a `ctrf/ctrf-report.json` file after each test run. You can customize the output: ```typescript new GenerateCtrfReport({ on, outputFile: 'ctrf-report.json', outputDir: '.', }); ``` ### Step 3: Add the Upload Step to CI **GitHub Actions:** ```yaml - name: Run Cypress tests run: npx cypress run - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./ctrf/ctrf-report.json ``` **GitLab CI:** ```yaml test: script: - npx cypress run after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@ctrf/ctrf-report.json" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'"}' ``` ### Step 4: Share the Link After upload, share the report URL in Slack, GitHub PRs, or Jira. Recipients see structured test results in their browser, no CI access needed. Need to share with someone outside your organization: a contractor, client, or partner team? Generate a [public share link](/solutions/shareable-test-report-links/) with an expiration you control (1 hour to 30 days, or never). Recipients view the results in their browser without needing a Gaffer account. ## Slack Notifications Once you're uploading results, connect Slack in project settings for automatic notifications: - Pass/fail summary with test counts - Direct link to the full report - Filter by branch: only notify on `main`, skip feature branches - Choose triggers: all runs, failures only, or consecutive failures ## Comparing Cypress Reporting Options | Feature | Cypress Cloud | Mochawesome | CI Artifacts | Gaffer (via CTRF) | |---------|--------------|-------------|--------------|-------------------| | Hosted reports | Yes | Self-host | Download zips | Yes | | Shareable links | Yes | Manual | With CI access | Yes | | Analytics/trends | Yes | No | No | Yes | | Flaky test detection | Yes | No | No | Yes | | Slack notifications | Yes | No | No | Yes | | Works with other frameworks | No | No | N/A | Yes | | Parallelization | Yes | No | No | No | | Video recording | Yes | No | Via artifacts | No | | Pricing | Paid (usage-based) | Free | Free | Free tier available | ### When Cypress Cloud Makes Sense Cypress Cloud is worth it if you need test parallelization (splitting tests across machines), video recording with cloud storage, or you're all-in on Cypress with no other frameworks. ### When Gaffer Makes Sense Gaffer is a better fit if you want a unified view across multiple frameworks (Cypress + Jest + Playwright), want test analytics without per-seat pricing, or need Slack/webhook notifications with more flexible triggers. ## Beyond Cypress: Multi-Framework Support Since Gaffer works with CTRF, you can upload results from any framework that has a CTRF reporter. One dashboard for: - **Cypress** (via `cypress-ctrf-json-reporter`) - **Playwright** (native HTML reports) - **Jest** (via `jest-ctrf-json-reporter`) - **Vitest** (native HTML reports) - **pytest** (via JUnit XML or CTRF) All results appear in the same project dashboard with unified analytics. ## Get Started Gaffer's free tier includes 500 MB of storage with 7-day retention. Enough to evaluate the CTRF workflow with your Cypress suite.
--- ## How to Get Test Metrics into Datadog Source: https://gaffer.sh/solutions/datadog-test-metrics/

Your services have dashboards, alerts, and SLOs in Datadog. Your test suite doesn't. Here's how to get test metrics into Datadog without writing custom instrumentation.

## The Problem You run hundreds of tests in CI every day, but the results live in CI logs. To answer "is our test suite healthy?" you check individual pipeline runs. There's no trend line, no alert when pass rates drop, and no way to correlate test health with deployment metrics. ### What Teams Usually Try **1. Custom metric scripts in CI** - Parse test output, POST to Datadog's API in an `after_script` block - Breaks when test runner output format changes - Every team reinvents this differently **2. Datadog CI Visibility** - Full-featured but requires Datadog's test runner instrumentation - Per-test pricing can add up at scale - Tightly coupled to Datadog: hard to switch later **3. Manual monitoring** - Someone checks CI periodically - Problems are discovered after they've compounded ## A Simpler Approach Gaffer already stores your test results and coverage reports. With OpenTelemetry export, it pushes metrics to Datadog automatically: pass rates, failure counts, test totals, and coverage percentages arrive as standard OTLP metrics. No test runner plugins. No CI script changes beyond what you already have for Gaffer uploads. ## Setup If you're already uploading test results to Gaffer, adding Datadog export takes a few steps: 1. Go to **Settings > OpenTelemetry** in the Gaffer dashboard 2. Click **"Add destination"** and select **Datadog** 3. Paste your Datadog API key and select your site region 4. Click **"Test"** to verify connectivity For full setup instructions including Grafana Cloud and generic OTLP endpoints, see the [OpenTelemetry integration guide](/docs/integrations/opentelemetry/). ## Dashboard Template We've published a Datadog dashboard template you can import directly. It includes: - **Overview gauges**: current pass rate, total tests, failed count, skipped count - **Pass rate over time**: line chart of `gaffer.tests.pass_rate` over 30 days, grouped by branch - **Test counts by result**: stacked bar chart of passed/failed/skipped per day - **Coverage trends**: line and branch coverage percentages over time ### Import the Dashboard 1. Download the [dashboard template](/templates/datadog-dashboard.json) 2. In Datadog, go to **Dashboards > New Dashboard** 3. Click the gear icon and select **Import Dashboard JSON** 4. Paste or upload the JSON file 5. Use the `$project` template variable to filter by Gaffer project ## Available Metrics | Metric | Type | Description | |--------|------|-------------| | `gaffer.tests.pass_rate` | Gauge (%) | Pass rate of the test run | | `gaffer.tests.total` | Gauge | Total test count | | `gaffer.tests.passed` | Gauge | Passed test count | | `gaffer.tests.failed` | Gauge | Failed test count | | `gaffer.tests.skipped` | Gauge | Skipped test count | | `gaffer.coverage.line_percent` | Gauge (%) | Line coverage percentage | | `gaffer.coverage.branch_percent` | Gauge (%) | Branch coverage percentage | All metrics include `gaffer.project.name`, `gaffer.branch`, and `gaffer.commit_sha` as attributes for filtering. ## What You Get - **Alerts**: set a Datadog monitor on `gaffer.tests.pass_rate` dropping below a threshold - **Correlation**: overlay test health with deploy events, error rates, and service metrics - **Trend visibility**: spot gradual test suite degradation before it becomes a problem - **Team dashboards**: share test health alongside service dashboards that your team already checks daily
--- ## How to Share .NET Test Results with Your Team Source: https://gaffer.sh/solutions/dotnet-test-reporting/

`dotnet test` produces results, but everyone who wants to see them has to go somewhere different: the Azure DevOps pipeline tab, a CI artifact zip, or the Visual Studio Test Explorer on whoever ran them locally. None of those work for a quick "did the suite pass?" link in a PR or Slack thread. There's a simpler path that reuses output your build already generates.

## The .NET Test Reporting Problem `dotnet test` runs MSTest, NUnit, and xUnit suites and prints results to the console. Turning that console output into something a teammate can open in a browser is left to you, and the common options each trap the results somewhere. ### What Teams Do Instead **1. Azure DevOps "Publish Test Results" task** - Solid in-pipeline view: pass/fail counts, history, failed-test drill-down - Locked to the Azure DevOps pipeline UI. There's no link you can hand to someone outside the org - Tied to the build it ran in. No unified view if some suites run elsewhere or in a different pipeline **2. Visual Studio Test Explorer** - Good for the developer at the keyboard - Local only. There's nothing to share. Results live and die in one IDE session **3. CI artifacts (.trx zips)** - The `.trx` file uploads as a build artifact - Recipients need CI access to download and a TRX viewer to read it - Artifacts expire (90 days max on GitHub Actions) and carry no historical trends **4. Pasting console output into Slack** - Loses the structure: no per-test status, no stack traces you can expand - Truncated for large suites - No way to filter failures or compare against the last run ## Better: TRX + Hosted Reports You don't need a new reporter. `dotnet test --logger:trx` writes a `.trx` file, and it does so whether the project uses MSTest, NUnit, or xUnit. The `dotnet test` CLI emits TRX for all three. Gaffer parses TRX natively, so the integration is: tell `dotnet test` to write TRX, then upload the file in CI. The setup: 1. Run `dotnet test --logger:trx` 2. Upload the `.trx` file to Gaffer in CI 3. Get a shareable URL, analytics, and flaky test detection If you prefer JUnit XML (for example to standardize across non-.NET suites), the `JUnitXml.TestLogger` NuGet package adds a `--logger:"junit"` option, and Gaffer parses JUnit XML natively too. ### What This Looks Like After CI runs: - Anyone with the link sees the full hosted report in their browser - Failures keep their structure: per-test status, duration, and stack traces - Analytics accumulate over time: pass rates, [flaky tests](/solutions/flaky-test-detection/), slow tests - No Azure DevOps access required to view results ## Setting Up .NET Reporting with Gaffer ### Step 1: Write a TRX File Add the TRX logger to your test run. This works for MSTest, NUnit, and xUnit projects without changing test code: ```bash dotnet test --logger:trx --results-directory ./TestResults ``` This writes a `.trx` file into `./TestResults`. The filename includes a timestamp, so reference the directory in the upload step rather than a fixed name. ### Step 2: Add the Upload Step to CI **GitHub Actions:** ```yaml - name: Run tests run: dotnet test --logger:trx --results-directory ./TestResults - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./TestResults/*.trx ``` **Azure Pipelines (curl):** ```yaml - script: dotnet test --logger:trx --results-directory $(Build.SourcesDirectory)/TestResults displayName: Run tests - script: | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $(GAFFER_PROJECT_TOKEN)" \ -F "files=@$(ls TestResults/*.trx | head -n1)" \ -F 'tags={"commitSha":"$(Build.SourceVersion)","branch":"$(Build.SourceBranchName)"}' displayName: Upload to Gaffer condition: always() ``` The `if: always()` and `condition: always()` guards matter: you want the report uploaded even when tests fail, since that's when the report is most useful. ### Step 3: Share the Link After upload, drop the report URL into a PR, Slack, or your Azure DevOps work item. Recipients see structured results in their browser with no CI access needed. Need to share with someone outside your organization, like a contractor or client? Generate a [public share link](/solutions/shareable-test-report-links/) with an expiration you control (1 hour to 30 days, or never). They view the results without a Gaffer account. ## Comparing .NET Reporting Options | Feature | Azure DevOps Test Results | CI Artifacts | Gaffer | |---------|---------------------------|--------------|--------| | Hosted reports | In pipeline UI | Download zips | Yes | | Shareable links outside the org | No | With CI access | Yes | | Analytics/trends | In pipeline | No | Yes | | Flaky test detection | Limited | No | Yes | | Works across frameworks | Per pipeline | N/A | Yes | | Pricing | Included with Azure DevOps | Free | Free tier available | ### When Azure DevOps Test Results Makes Sense If your whole team lives in Azure DevOps and every suite runs in the same pipeline, the built-in "Publish Test Results" task covers the in-pipeline case well and costs nothing extra. The friction shows up when you need to share a link outside the org or unify results from suites that run in different pipelines or providers. ### When Gaffer Makes Sense Gaffer fits when you want a link anyone can open, one dashboard across multiple frameworks and pipelines, or test analytics and flaky detection without per-seat pricing. ## Beyond .NET: Multi-Framework Support TRX and JUnit XML are not .NET-specific. The same upload step handles results from any framework Gaffer parses, so a project with a .NET backend, a JavaScript frontend, and a Python service can [report all three to one dashboard](/solutions/multiple-test-frameworks/): - **.NET** (TRX via `dotnet test --logger:trx`) - **JavaScript / TypeScript** (Jest, Vitest, Playwright) - **Python** (pytest via JUnit XML) All results land in the same project with [unified hosted reports](/solutions/host-test-reports/) and shared analytics. ## Get Started Gaffer's free tier includes 500 MB of storage with 7-day retention. Enough to wire `dotnet test --logger:trx` into CI and see your suite hosted with a shareable link.
--- ## Failure Clustering: Debug Root Causes, Not Individual Tests Source: https://gaffer.sh/solutions/failure-clustering/

Five tests fail in CI. You investigate the first one: "Connection refused." The second shows the same error. The third, fourth, and fifth match too, all the same root cause. You just spent 30 minutes discovering what should have been obvious in 3 seconds: one service is down, and every test that depends on it failed.

## What Is Failure Clustering? Failure clustering groups test failures that share the same root cause into a single cluster. Instead of showing you 5 individual failures, it shows you 1 pattern affecting 5 tests. The idea is simple: failures don't happen in isolation. When a database connection drops, every test that queries the database fails. When an API endpoint changes, every test that calls that endpoint fails. Treating each failure independently wastes time. ## Why Individual Test Failures Mislead Most test reporting tools show a flat list of failures: | Test | Status | Error | |------|--------|-------| | `auth.test.ts > login` | FAIL | Connection refused | | `auth.test.ts > signup` | FAIL | Connection refused | | `api.test.ts > get users` | FAIL | Connection refused | | `api.test.ts > create user` | FAIL | ECONNREFUSED 127.0.0.1:5432 | | `db.test.ts > migrations` | FAIL | Connection refused | A developer sees 5 failures and might think 5 things broke. In reality, one thing broke (the database connection), and it cascaded to 5 tests. The fourth failure even has a slightly different error message, making it harder to spot the pattern manually. ### The cost of debugging individually - **Time:** 5-10 minutes investigating each failure before realizing they're the same - **False fixes:** Developers sometimes "fix" individual tests (adding retries, mocking) when the real issue is upstream - **Noise:** A 5-failure CI report feels worse than a 1-pattern report, even though the same amount of work is needed - **Context switching:** Jumping between 5 test files when the fix is in one shared dependency ## How Gaffer Clusters Failures Gaffer analyzes error messages across all failing tests in a run and groups them by shared patterns: ``` Clusters: 1 pattern (5 tests) "Connection refused" — 5 tests auth.test.ts > login auth.test.ts > signup api.test.ts > get users api.test.ts > create user db.test.ts > migrations ``` The algorithm: 1. **Extract error signatures** from each failing test: the error message, stripped of variable parts (timestamps, UUIDs, port numbers) 2. **Group by similarity**, so tests with matching signatures form a cluster 3. **Name the cluster** using the most common error message in the group 4. **Sort by impact**, so clusters with more affected tests appear first This works across frameworks. Whether your errors come from Playwright, Jest, pytest, or JUnit XML, the clustering applies to the parsed error output. ## When Clustering Matters Most ### Infrastructure failures Database down, Redis unavailable, network partition: these cascade across your entire test suite. Without clustering, a single infrastructure blip looks like 50 unrelated failures. ### API contract changes Someone changes an API response format. Every test that parses that response fails with a similar error. Clustering shows you the one change that needs reverting or adapting. ### Environment issues Missing environment variables, wrong Node version, incompatible dependency: these affect multiple tests in the same way. Clustering reveals the environment problem instead of burying it in individual test errors. ### Flaky infrastructure in CI CI runners sometimes have transient issues: DNS resolution failures, disk space, rate limits. Clustering helps you distinguish "the runner had a bad day" from "our code has bugs." ## Failure Clustering vs. Flaky Test Detection These are complementary features that answer different questions: | | Failure Clustering | Flaky Test Detection | |---|---|---| | **Question** | "Why did these tests fail together?" | "Does this test sometimes fail without code changes?" | | **Scope** | Within a single test run | Across multiple runs over time | | **Action** | Fix the shared root cause | Investigate test non-determinism | | **Example** | 5 tests fail with "Connection refused" | `login` test fails 40% of runs | Used together, you get a complete picture: clustering tells you what went wrong *right now*, and flaky detection tells you what's been unreliable *over time*. Both signals matter most on a large [automated regression testing](/blog/automated-regression-testing/) suite, where a single broken dependency can produce dozens of failures from one cause. ## Getting Started Failure clustering works automatically with any test report uploaded to Gaffer. No configuration required. 1. **Upload a test report** via [CLI](/docs/getting-started/), [API](/docs/guides/github-actions/), or direct upload 2. **View clusters** on the test run detail page, below the failure list 3. **Query via CLI**: `gaffer query failures` shows clusters in your terminal 4. **Query via MCP**: AI agents access clusters through `get_test_run_details()`

Stop debugging the same root cause five times

Gaffer clusters failures automatically. Free tier, no credit card.

Start Free
--- ## Faster CI/CD Feedback Loop: Get Test Results in Slack Instantly Source: https://gaffer.sh/solutions/faster-ci-feedback-loop/ import { Image } from "astro:assets"; import slack from "../assets/screenshots/slack-alert.png";

A fast CI/CD feedback loop is essential for developer productivity. When tests fail, you need to know immediately - and you need to access the results without friction. Gaffer delivers test results directly to Slack, cutting your feedback loop from minutes to seconds.

## The CI/CD Feedback Loop Problem Every developer knows this frustrating workflow: 1. Push code and wait for CI to run 2. Notice the build failed (eventually) 3. Navigate to GitHub/GitLab to find the workflow 4. Scroll through logs trying to find the failure 5. Download artifact zip files 6. Extract and open locally to see the actual report This process takes 5-10 minutes *every time* a test fails. Multiply that by the number of failures per day, and you're losing hours to CI archaeology. The worst part? By the time you've found the failure, you've lost your mental context. You've context-switched to Slack, email, or another task. Getting back into flow takes even more time. ## How Gaffer Improves Your CI/CD Feedback Loop Gaffer shortens your feedback loop to a single Slack notification with a direct link to the full test report.
### Instant Slack Notifications When your CI pipeline finishes, Gaffer sends a notification to your configured Slack channel: - **Pass/fail summary** at a glance - **Direct link** to the full HTML report - **Branch and commit context** so you know exactly what triggered it - **Failure details** with test names and error snippets
### Smart Notification Filtering You won't get spammed. Gaffer lets you configure exactly when to notify: - **All runs** - Get notified on every test run - **Failures only** - Only hear about problems - **Consecutive failures** - Alert after multiple failures in a row, filtering out one-off noise - **Branch filtering** - Only notify on `main`, `release/*`, or specific branches ### One-Click Access to Full Reports Click the link in Slack and you're looking at the full test report - no downloads, no zip files. The report is hosted and shareable with your entire team. ## Real Impact on Developer Velocity | Before Gaffer | After Gaffer | |---------------|--------------| | 5-10 min to find failure details | 10 seconds from notification to report | | Context switch to CI provider UI | Stay in Slack, click one link | | Scattered across CI runs | Centralized, searchable history | | Manual log parsing | Structured HTML reports with filtering | ## Setting Up Your CI/CD Feedback Loop Getting started takes less than 5 minutes: ### 1. Add the Gaffer Upload Step ```yaml - name: Upload to Gaffer uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./test-results ``` ### 2. Connect Slack In your Gaffer project settings, click "Connect to Slack" and authorize the integration. ### 3. Configure Notifications Choose which channel to notify, which branches to monitor, and when to send alerts. That's it. Your next CI run will notify your team automatically. ## Beyond Notifications: Full Test Analytics A faster feedback loop is just the start. Gaffer also provides: - **Pass rate trends** - Track test health over time - **[Flaky test detection](/solutions/flaky-test-detection/)** - Identify tests that fail intermittently - **Duration tracking** - See which tests are getting slower - **Historical comparison** - Compare test runs across commits and branches Want more control over when and how you get notified? See [Test Failure Notifications](/solutions/test-failure-notifications/) for details on triggers, branch filtering, and webhook integrations. ## Get Started Gaffer's free tier includes Slack integration with 500 MB of storage and 7-day retention. Connect your first project in under 5 minutes.
--- ## Flaky Test Detection: Find Which Tests Are Unreliable Source: https://gaffer.sh/solutions/flaky-test-detection/ import flakyTestsTable from "../assets/screenshots/flaky-tests-table.png"; import PullCta from "../components/PullCta.astro"; import ScreenshotLink from "../components/ScreenshotLink.astro";

A test fails on `main`, you re-run the pipeline, and it passes. You know flaky tests are eating your team's time, but you can't name the specific tests doing the damage. CI artifacts expire before patterns emerge, retries hide which tests are actually unstable, and nobody has time to keep a spreadsheet. The missing piece is persistent pass/fail history per test.

### What is a flaky test? A **flaky test** is an automated test that produces inconsistent results without any code change: it passes on one run and fails on the next under the same conditions. Flaky tests are distinct from legitimate test failures. A real failure means the code is broken. A flaky test means the test itself is unreliable, typically due to timing dependencies, shared state, or external service calls. A suite with a high proportion of flaky tests trains engineers to ignore red builds, which is how real regressions slip through. ## The Flaky Test Detection Problem Most teams experience flakiness without being able to fix it because the data needed to identify the specific bad tests is gone by the time anyone goes looking. - **Re-runs mask the signal.** A test that passes on attempt 2 looks identical to a test that passed on attempt 1. - **CI artifacts expire.** GitHub Actions defaults to 90 days, GitLab to 30. By the time a pattern is obvious, the underlying runs are gone. - **No history per test.** Build pages show "this run." Nobody shows "this test, last 50 runs." - **Retry configs lie.** `retries: 2` keeps the build green and tells you nothing about which tests needed the retry. - **Quarantine has no exit criteria.** Tests get `.skip`'d, then forgotten. Coverage rots. ### Common (Bad) Solutions **1. Manual re-run tracking** - Slack channel for "this failed but passed on rerun" - Falls apart at >2 engineers; nobody catches the trends **2. Retry everything** - `retries: 3` in CI keeps builds green - Hides root cause, doubles or triples build time, masks regressions **3. Spreadsheets** - Someone updates a sheet with flaky tests after each red build - Dies in week three when that engineer goes on vacation **4. Reading CI artifacts manually** - `actions/upload-artifact` keeps reports for 90 days - Finding the right artifact across hundreds of runs is its own job ## Better Solution: Flip-Rate Analysis Across Runs The metric that actually identifies flaky tests is **flip rate**: how often a test transitions between pass and fail across consecutive runs. A test that stays passing is stable. A test that flips constantly is the one wasting your time. ``` Flip Rate = (number of pass/fail transitions) / (total runs - 1) ``` A test with results `[pass, fail, pass, fail, pass]` has 4 flips across 5 runs, or 80%. | Flip Rate | Interpretation | |-----------|----------------| | 0–10% | Stable test | | 10–30% | Moderately flaky, worth investigating | | 30–50% | Severely flaky, fix immediately or quarantine | | 50%+ | Random; remove from the build until rewritten | Flip rate only works with persistent history. A single run cannot tell you anything. A handful of runs cannot either: Gaffer requires at least 5 runs before evaluating a test, to keep a single bad run from poisoning the score. ## Detecting Flaky Tests with Gaffer Gaffer stores every test result and computes flip rate per test, per branch, automatically. - **Flip rate**: transitions per run, last 30 days - **Flip count**: total pass/fail transitions observed - **Total runs**: sample size, so you know whether to trust the score - **Last seen flaky**: when the most recent flip happened - **Branch filter**: separate `main` flakiness from feature-branch noise No new test framework to adopt, no annotation to add. If your CI already produces JUnit XML, CTRF, Playwright HTML, Jest JSON, or pytest output, Gaffer ingests it and starts the count. Threshold defaults to 10% flip rate before a test is flagged. Tune it higher if your suite is noisy and you want to focus on the worst offenders first. ## Setting Up Flaky Detection in CI One step in your CI pipeline. Test results upload, Gaffer parses them, flaky detection happens server-side. ```yaml # .github/workflows/test.yml - name: Run tests run: npx playwright test --reporter=junit env: PLAYWRIGHT_JUNIT_OUTPUT_NAME: test-results/junit.xml - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./test-results/junit.xml ``` Gaffer supports [multiple test frameworks](/solutions/multiple-test-frameworks/) (Jest, Vitest, pytest, Mocha, and more) with the same setup: emit a JUnit XML or CTRF JSON, point the uploader at it, done. Full setup per CI provider lives in the [CI guides](/docs/guides/). ## Fixing Flaky Tests Once You've Found Them Detection is the hard part. Once you have a list of specific tests with high flip rates, the fixes are mostly mechanical. For deeper background, see [how to manage flaky E2E tests](/blog/how-to-manage-flaky-e2e-tests/) and [how much flaky tests are actually costing your team](/blog/how-much-are-flaky-tests-costing-you/). ### Quarantine first, fix second Don't let a flaky test block the build while you investigate. Move it to a separate suite that runs but doesn't gate merges. Set a calendar reminder; quarantine without an exit date is just deletion. ```javascript // Jest example: skip with a tracking issue test.skip('checkout flow: flaky, see #4127', () => { // ... }); ``` ### Replace arbitrary waits with explicit conditions ```typescript // Bad: fixed timeout, hopes the page is ready await page.waitForTimeout(2000); // Good: wait for the specific thing you're testing await page.waitForSelector('[data-testid="loaded"]'); ``` ### Mock external dependencies Network calls are the single largest source of flakiness in E2E suites. Mock the API at the test boundary; let an integration test exercise the real call. ```typescript await page.route('**/api/data', route => { route.fulfill({ json: mockData }); }); ``` ### Reset shared state per test ```javascript beforeEach(() => { jest.clearAllMocks(); db.reset(); }); ``` ### Run in random order to surface order dependence ```bash jest --randomize ``` ## Comparing Flaky Test Detection Approaches
MethodHistory per testIdentifies which testsBranch-awareSetup
Re-run, hope, repeatNone✕✕None
Manual spreadsheetManual~ (whoever's tracking)ManualHours/week
CI artifact archaeology30–90 days~ (if you click through)✕None
Retry configsNone✕✕One line
GafferUp to 90 days✓ flip rate per test✓One CI step
## What Comes With Detection Once test history is hosted, the analytics that depend on it come along: pass-rate trends, duration regressions per test, [failure clustering](/solutions/failure-clustering/) to group related failures automatically, and a [health score](/docs/analytics/) that combines them. When you need to dig into why specific tests are failing, [failure analysis](/solutions/analyze-test-failures/) gives you the full breakdown. Gaffer can also [post a Slack message](/solutions/test-failure-notifications/) the moment a new flaky test crosses your threshold. ## Who Uses Flaky Test Detection Small teams and OSS projects who can't justify dedicated test-infrastructure engineers but still want to know which 10 tests in a 2,000-test suite are responsible for half the red builds. Free tier covers 500 MB storage with 7-day retention. Paid plans extend to 90 days, which is usually long enough for the worst flakiness patterns to surface.
--- ## GitHub PR Test Status: See Test Results Without Leaving GitHub Source: https://gaffer.sh/solutions/github-pr-test-status/ import { Image } from "astro:assets"; import githubPrCommitStatus from "../assets/screenshots/github-commit-statuses.png";

Your PR has 3 green checkmarks and 1 red X. You click the red X, land on a workflow run page, expand the test job, scroll through 200 lines of output, and finally find the failing test. Why is seeing test results in GitHub so hard?

## The GitHub Actions Test Results Problem Every developer knows this workflow: 1. Push code to PR 2. Wait for CI to finish 3. See a red X on the PR 4. Click to see details 5. Land on the workflow run page 6. Click into the job 7. Expand the test step 8. Scroll through logs to find failures **Total time to see what failed: 2-5 minutes** And if you want the full test report? Download artifacts, extract the zip, open the HTML file. Good luck doing that on your phone when you're away from your desk. ## What You Actually Want
- See pass/fail on the PR (GitHub does this) - See *which tests* failed without digging through logs - Click directly to a readable report - Share the report with QA who doesn't have GitHub access
GitHub pull request showing Gaffer commit status with test results and Details link
GitHub's built-in features get you partway there, but not all the way: | Feature | What It Does | Limitation | |---------|--------------|------------| | Status checks | Shows pass/fail | No details about which tests | | Annotations | Shows failures inline | Limited to ~50, no full context | | Job summaries | Markdown in Actions tab | Manual setup, buried in workflow | | Artifacts | Stores reports | Requires download, expires | ## The Solution: Commit Statuses with Report Links The fix is simple: post a commit status that links directly to a hosted test report. ### How It Works ``` Tests run in GitHub Actions ↓ Results uploaded to Gaffer ↓ Gaffer posts commit status to GitHub ↓ PR shows: ✓ Gaffer / Vitest: 47 passed [Details →] ``` ### What Developers See On your pull request, under the checks section: ``` ✓ Gaffer / Playwright: 23 passed Details ✗ Gaffer / Vitest: 45 passed, 2 failed Details ``` Click "Details" and you're taken directly to the full test report in Gaffer. No logs, no artifacts, no extraction. ### What QA and PMs See A simple link they can open in any browser: ``` https://app.gaffer.sh/r/abc123 ``` No GitHub access required. No downloading. Click and view. ## Setting Up the Gaffer GitHub App ### Step 1: Install the GitHub App 1. In Gaffer, go to **Settings → GitHub** 2. Click **Install GitHub App** 3. Choose your GitHub organization or account 4. Select which repositories Gaffer can access ### Step 2: Link a Repository to Your Project 1. Go to your project in Gaffer 2. Navigate to **Settings → GitHub** 3. Select your repository from the dropdown 4. Enable **Commit statuses** ### Step 3: Upload Results with Commit SHA If you're already uploading to Gaffer, just make sure you're passing the commit SHA: ```yaml - name: Run tests run: npm test - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./test-results commit_sha: ${{ github.sha }} branch: ${{ github.ref_name }} ``` For pull requests, use the PR head SHA: ```yaml - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./test-results commit_sha: ${{ github.event.pull_request.head.sha || github.sha }} branch: ${{ github.head_ref || github.ref_name }} ``` That's it. The GitHub App handles posting the status automatically. ## What the Status Looks Like Gaffer posts commit statuses in this format: ``` Gaffer / {framework} ``` For example: - `Gaffer / Playwright` - Your Playwright E2E tests - `Gaffer / Vitest` - Your Vitest unit tests - `Gaffer / Jest` - Your Jest tests If you have multiple test suites, each gets its own status. At a glance, you can see which suite failed. ## Comparison: Finding Test Results | Approach | Time to Results | Click to Report | Works for QA | |----------|-----------------|-----------------|--------------| | Check workflow logs | 2-5 minutes | No | No | | GitHub Annotations | 30 seconds | No | No | | Download artifacts | 1-2 minutes | After extraction | No | | Gaffer commit status | 5 seconds | Yes, one click | Yes | ## Beyond Status Checks Once you have test results flowing through Gaffer, you also get: ### Historical Context See if this test has failed before. Was it flaky last week? The history is right there. ### Flaky Test Detection Tests that fail intermittently are flagged automatically. Know whether a failure is a real bug or just flakiness before you start debugging. ### Slack Notifications Get notified in Slack when tests fail, with a direct link to the report. No need to watch GitHub. ### Cross-Repo Dashboard If you have multiple repositories, see all your projects' test health in one place. ## Getting Started Gaffer's free tier includes 500 MB storage with 7-day retention - enough to try the GitHub integration on a real project. Commit statuses are included on all plans.
--- ## How to Host Test Reports Beyond CI Artifact Expiration Source: https://gaffer.sh/solutions/host-test-reports/

Every CI provider treats test reports as disposable. Debug a regression, answer a compliance question, or work out when a test started flaking, and by the time you go looking the data is gone. GitHub Actions deletes artifacts after 90 days. GitLab defaults to 30. Bitbucket gives you 14.

## CI Artifact Retention Limits Default retention limits across major CI platforms: | CI Provider | Default Retention | Max Retention | Access Method | |-------------|-------------------|---------------|---------------| | GitHub Actions | 90 days | 400 days (paid) | Download zip, extract locally | | GitLab CI | 30 days | Configurable | Download zip or browse in UI | | CircleCI | 30 days | 30 days (hard limit) | Download from artifacts tab | | Bitbucket Pipelines | 14 days | 14 days (hard limit) | Download from pipeline page | | Azure DevOps | 30 days | Configurable | Download from build summary | | Jenkins | Until disk fills up | Manual cleanup | Browse build artifacts (if server is up) | Even when artifacts exist, accessing them is friction-heavy. Most CI providers require downloading a zip file, extracting it locally, and opening it in a browser. There's no shareable URL. There's no way for someone without CI access (a QA lead, a product manager, a contractor) to view the results directly. The underlying issue is straightforward: CI providers optimize for compute, not for report hosting. [Test artifacts](/blog/what-are-test-artifacts/) are a side effect of the build, not a first-class feature. ## Why Teams Need Persistent Test Report Hosting Persistent report hosting solves four problems that come up repeatedly in engineering teams: **Debugging regressions.** A bug resurfaces that was supposedly fixed two months ago. The original test failure had a stack trace and screenshot that would tell you exactly what happened. With CI artifacts expired, you're starting from scratch. **Flaky test analysis.** Identifying [flaky tests](/solutions/flaky-test-detection/) requires data across dozens or hundreds of runs. If your CI only retains 30 days of artifacts, you're working with a narrow window, sometimes too narrow to see the pattern. **Cross-team visibility.** QA, product, and management often need to see test results. Giving everyone CI access just to view reports is either impractical (permissions) or impossible (external stakeholders). **Audit trails.** Regulated industries need evidence that tests passed before deployment. "The CI artifact expired" doesn't satisfy an auditor. ## DIY Approaches and Their Drawbacks Most teams eventually try to solve this themselves. Each approach works up to a point, then falls apart. ### S3 or GCS Bucket The most common approach: add a CI step to upload reports to cloud storage. ```yaml - name: Upload test reports to S3 run: | aws s3 cp ./test-results s3://team-test-reports/${{ github.sha }}/ \ --recursive ``` This gives you retention control, but creates new problems: - **No browsable UI.** You need to know the exact commit SHA to find a report. There's no way to browse by branch, date, or test status. - **Access control is manual.** S3 bucket policies are notoriously difficult to get right. Too permissive and it's a security risk. Too restrictive and people can't access what they need. - **No cleanup automation.** Lifecycle rules work for simple cases, but break down when you need different retention per project or per branch. - **HTML reports don't just work.** Playwright reports reference relative assets (CSS, JS, screenshots). Serving them from S3 requires CloudFront or static hosting configuration to get Content-Type headers right. - **[Storage costs compound](/solutions/test-report-storage-costs/).** Without active management, costs grow linearly every month. A team generating 50 GB/month of test artifacts is paying real money within a quarter. ### GitHub Pages Some teams push reports to a dedicated GitHub Pages repo. ```yaml - name: Deploy report to GitHub Pages run: | git clone https://token@github.com/org/test-reports.git cp -r ./playwright-report test-reports/$(date +%Y-%m-%d)-${{ github.sha }} cd test-reports && git add . && git commit -m "Add report" && git push ``` This creates browsable HTML, but: - The repo grows unboundedly. Git wasn't designed for this - No authentication (GitHub Pages is public by default for free plans) - No search, no filtering, no analytics - Cleanup requires rewriting Git history ### Custom Server A few teams build a lightweight Express or Flask app to receive and serve reports. This gives you full control, but now you're maintaining infrastructure for a problem that isn't your core product. You need to handle uploads, storage, authentication, cleanup, monitoring, and uptime, all for a tool that your team uses but nobody's job is to maintain. ### The Common Thread Every DIY approach solves the storage problem while creating operational problems. You trade "artifacts expire" for "who maintains this," "how do I find the report from Tuesday," and "why is the S3 bill so high." ## What a Purpose-Built Solution Looks Like A proper test report hosting platform handles the full lifecycle: upload, store, browse, share, analyze, and clean up. It should be framework-agnostic (not just Playwright or just JUnit), CI-agnostic (not just GitHub Actions), and require minimal setup. Specifically, it should: 1. Accept any report format: HTML reports, JUnit XML, [CTRF JSON](/docs/guides/ctrf/), raw files 2. Provide shareable URLs for every test run 3. Parse structured data (JUnit XML, CTRF) for analytics: pass rates, flaky tests, duration trends 4. Handle retention automatically with per-project controls 5. Work with any CI provider through a standard upload mechanism ## How Gaffer Handles Test Report Hosting Gaffer is built specifically for this. Upload test reports from any CI provider, any framework, and access them through shareable URLs with configurable retention. ### Upload Any Report Format Gaffer accepts HTML reports (Playwright, pytest-html, Mochawesome), structured data (JUnit XML, CTRF JSON), and raw files (screenshots, logs, traces). Upload them together or separately. **[HTML reports](/solutions/html-test-reports/)** are served directly in the browser, so your team sees the full interactive report without downloading anything. For everything Playwright's HTML report contains and how to get the most out of it, the [Playwright reports guide](/blog/playwright-reports-guide/) is a useful reference. **Structured data** (JUnit XML, CTRF JSON) is parsed for analytics: pass/fail counts, test durations, failure messages. This feeds into trend tracking, flaky test detection, and pass rate history. ### Framework-Agnostic Setup Gaffer works with whatever your tests already produce. Here are two common patterns: **JUnit XML (works with nearly every test framework):** ```yaml # GitHub Actions - name: Run tests run: npx vitest --reporter=junit --outputFile=results.xml - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./results.xml ``` **CTRF JSON (standardized format across frameworks):** ```yaml - name: Run tests run: npm i -D playwright-ctrf-json-reporter && npx playwright test --reporter=playwright-ctrf-json-reporter - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./ctrf/ctrf-report.json ``` **HTML reports with structured data (both human-readable and machine-parseable):** ```yaml - name: Run Playwright tests run: npx playwright test --reporter=html - name: Upload HTML report to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./playwright-report - name: Upload CTRF results to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./ctrf/ctrf-report.json ``` ### Non-GitHub CI Providers For [GitLab CI](/docs/guides/gitlab-ci/), [CircleCI](/docs/guides/circleci/), [Jenkins](/docs/guides/jenkins/), [Bitbucket Pipelines](/docs/guides/bitbucket-pipelines/), or [Azure DevOps](/docs/guides/azure-devops/), use the CLI upload: ```bash curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@./results.xml" \ -F 'tags={"branch":"'"$CI_BRANCH"'","commitSha":"'"$CI_COMMIT_SHA"'"}' ``` Three lines. Works anywhere you can run `curl`. ### Share with Anyone Every upload gets a URL in the Gaffer dashboard. Team members with access can browse reports by project, branch, or date. For people outside your organization (contractors, stakeholders, clients), generate a [share link](/solutions/shareable-test-report-links/) with a configurable expiration. Recipients view the report directly in their browser without needing a Gaffer account. ### Retention You Control | Plan | Retention | Storage | |------|-----------|---------| | Free | 7 days | 500 MB | | Pro ($15/mo) | 30 days | 10 GB | | Team ($49/mo) | 90 days | 50 GB | All plans include unlimited users, with no per-seat pricing. Configure retention per project: keep production test reports for 90 days while feature branch reports auto-delete after 7. ### Analytics from Structured Data When you upload JUnit XML or CTRF JSON alongside your HTML reports, Gaffer parses the structured data and gives you: - **Pass rate trends**: track stability across builds - **[Flaky test detection](/solutions/flaky-test-detection/)**: identify tests that flip between pass and fail - **Duration tracking**: catch tests that are getting slower - **Failure patterns**: see which tests fail most frequently These work across [multiple test frameworks](/solutions/multiple-test-frameworks/). A project with Playwright E2E tests, Cypress integration tests, and Vitest unit tests shows unified analytics in one dashboard. For teams running Cypress, the [Cypress reports guide](/blog/cypress-reports-guide/) covers how to get structured output from Cypress into Gaffer. ## Comparing Your Options | | CI Artifacts | S3 + CloudFront | GitHub Pages | Gaffer | |--|--------------|-----------------|--------------|--------| | Retention | 14-90 days | Unlimited (manual cleanup) | Unlimited (repo grows) | 7-90 days (auto cleanup) | | Shareable URLs | No (download zip) | Yes (with setup) | Yes (public only) | Yes | | HTML report viewing | Download required | Yes (with config) | Yes | Yes | | Structured data parsing | No | No | No | Yes | | Analytics | No | No | No | Yes | | Access control | CI permissions only | S3 policies | Public or private repo | Team-based + share links | | Setup time | Built-in | Hours | Hours | Minutes | | Maintenance | None | Ongoing | Ongoing | None | ## Get Started Gaffer's free tier includes 500 MB of storage with 7-day retention, enough to evaluate the workflow.
--- ## How to Host HTML Test Reports for Your Team Source: https://gaffer.sh/solutions/html-test-reports/ import ScreenshotLink from "../components/ScreenshotLink.astro"; import htmlReportExample from "../assets/screenshots/test-run-detail.png";

HTML test reports exist because developers want more than console output. Playwright gives you screenshots, traces, and video. pytest-html gives you an interactive summary. But these reports are local files, and there's no built-in way to host them where your team can access them.

## The Hosting Problem HTML reports are designed for browsers, but they're generated on CI runners or local machines. Your team can't view them without extra steps: ### Common (Bad) Solutions **Email the zip file** Zip up the report folder, email it, hope the recipient extracts it and opens `index.html`. Works, but feels like 2005. **Upload to S3 manually** Set up a bucket, configure permissions, upload after each run, manage cleanup. That's infrastructure work just to share a test report. **Rely on CI artifacts** GitHub Actions and GitLab CI store artifacts, but: - Viewing requires downloading and extracting a zip - Finding the right artifact in dozens of workflow runs is tedious - QA might not have CI access - [Artifacts expire](/solutions/ci-test-artifacts-expiring/) (30-90 days depending on the CI system) **Share your screen** Works for live debugging. Can't reference it later. Not async-friendly. ## Better: Hosted HTML Reports with Shareable Links Upload your HTML reports after every CI run. Every report gets a permanent URL that opens directly in the browser — no downloads, no extraction, no CI access required. ### What This Looks Like 1. CI runs your tests and generates the HTML report (unchanged) 2. One upload step sends the report to Gaffer 3. You get a URL like `https://app.gaffer.sh/reports/abc123` 4. Share it in Slack, GitHub PRs, Jira, anywhere 5. Anyone with access sees the full interactive report in their browser Need to share with someone outside your organization? Generate a [public share link](/solutions/shareable-test-report-links/) with an expiration you control: recipients view the HTML report directly in their browser without needing an account. ## Setting Up HTML Report Hosting with Gaffer ### Step 1: Generate Your Report (Unchanged) Use whatever reporter your framework provides: ```bash # Playwright npx playwright test --reporter=html # pytest pytest --html=report.html --self-contained-html # Cypress (with mochawesome) npx cypress run --reporter mochawesome ``` ### Step 2: Upload in CI **GitHub Actions:** ```yaml - name: Run tests run: npx playwright test --reporter=html - name: Upload report to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./playwright-report ``` ### Step 3: Share the Link Share the URL in Slack, add it to a GitHub PR comment, attach it to a Jira ticket. Recipients see the full interactive report in their browser. ## HTML Reports + Structured Data HTML reports are great for humans, but you also want structured data for analytics: pass/fail trends, duration tracking, flaky test detection. Most frameworks can output both: ```typescript // playwright.config.ts export default defineConfig({ reporter: [ ['html', { open: 'never' }], // For humans ['json', { outputFile: 'results.json' }] // For analytics ], }); ``` ```bash # pytest pytest --html=report.html --junitxml=results.xml ``` Upload both formats. Gaffer serves the HTML for viewing and parses the structured data for trend analytics. ## Slack and Webhook Notifications Get notified when tests finish instead of checking CI manually: - Pass/fail summary posted to your Slack channel - Direct link to the hosted HTML report - Filter by branch: only notify on `main`, skip feature branches - Webhooks for custom integrations ## Comparing Your Options | Solution | HTML Viewing | Shareable Link | Automated | Analytics | |----------|--------------|----------------|-----------|-----------| | Local `open report.html` | Native | No | No | No | | Email zip file | After extraction | Awkward | Manual | No | | CI artifacts | Download required | With CI access | Built-in | No | | S3 + CloudFront | Native | Yes | DIY | No | | Gaffer | Native | Yes | Yes | Yes | ## Get Started This page covers HTML output specifically. The same workflow [hosts test reports](/solutions/host-test-reports/) in any format Gaffer parses. Gaffer's free tier includes 500 MB of storage with 7-day retention. Paid plans offer up to 90-day retention.
--- ## The True Cost of Ignoring Test Failures Source: https://gaffer.sh/solutions/ignoring-test-failures/

It starts small. A test fails, but it's probably flaky. Someone re-runs the pipeline. It passes. Everyone moves on. Over weeks and months, this pattern repeats until the team has unconsciously learned to ignore test failures. The test suite becomes decoration - green checkmarks that nobody trusts.

## How Teams Learn to Ignore Failures Test failure fatigue doesn't happen overnight. It's a gradual erosion: ### Stage 1: Friction Creates Delay Test results are buried in CI logs. Accessing them requires: - Navigating to the CI provider - Finding the right workflow run - Downloading artifact zip files - Extracting and opening locally When checking a failure takes 5 minutes, developers wait. "I'll look at it after this meeting." The failure sits. ### Stage 2: Flaky Tests Breed Skepticism Some tests fail randomly. Developers learn which ones. When they see a failure, the first thought is "is this the flaky one?" not "did I break something?" Re-running the pipeline becomes the default response. ### Stage 3: Normalization The team develops shared knowledge: "Oh, that test always fails on Mondays" or "just re-run it, it'll pass." Failures become background noise. New team members pick up these habits. The institutional knowledge is "tests fail sometimes, don't worry about it." ### Stage 4: Real Bugs Slip Through A genuine failure appears. It looks like all the other noise. Someone re-runs the pipeline. It fails again, but they're in a hurry. "Probably infrastructure, I'll check later." The bug ships to production. ## The Compounding Cost Ignoring test failures creates a vicious cycle: ### Trust Erosion When tests cry wolf too often, the team stops listening. The suite's primary value - confidence that the code works - disappears. You're paying the cost of running tests without getting the benefit. ### Technical Debt Accumulation Tests that "always fail" get skipped or deleted rather than fixed. Test coverage shrinks. The gaps compound as new code builds on untested foundations. ### Slower Debugging Without reliable test history, debugging production issues becomes harder: - "Did we have a test for this?" - "When did this behavior change?" - "Was this ever working?" These questions have no answers when test data is ignored or expired. ### Release Anxiety Teams add manual verification steps because they don't trust automated tests. QA cycles extend. Releases slow down. The promise of continuous delivery breaks. ## Breaking the Cycle The solution isn't more discipline or stricter processes. It's reducing friction and surfacing signal. ### 1. Make Results Instantly Accessible If checking a failure takes 5 minutes, it won't happen consistently. If it takes 10 seconds, it becomes habit. **What helps:** - Shareable links directly to the failure (no downloads) - Notifications in Slack where the team already works - Mobile-friendly reports for quick checks **What doesn't help:** - Telling developers to "check CI more often" - Adding Slack bots that just say "build failed" without details - Documentation on "how to download artifacts" ### 2. Separate Signal from Noise Flaky tests poison the well. Quarantine them ruthlessly: - Track flip rates to identify flaky tests automatically - Move flaky tests to a separate suite that doesn't block merges - Fix or delete - don't let them linger When failures are rare and meaningful, developers pay attention. ### 3. Make Trends Visible A single failure is easy to dismiss. A trend is harder to ignore. - Pass rate dropping from 95% to 80% over two weeks - Three new flaky tests introduced this sprint - Test duration increasing 20% month over month Dashboards and reports make patterns visible to the whole team, not just whoever happened to see the last failure. ### 4. Keep Historical Data "When did this start failing?" is only answerable with history. CI artifacts expire. If your test data disappears after 30-90 days, you lose the ability to: - Correlate failures with code changes - Track improvement over time - Debug recurring issues Persistent storage turns test runs into organizational knowledge. ### 5. Involve the Whole Team Test health isn't just a developer concern. When PMs, QA, and stakeholders can access results: - Failures get visibility beyond the person who triggered them - "Test health" becomes a team metric, not a CI detail - Accountability distributes naturally If only developers can access test results, only developers will care about them. ## How Gaffer Helps Gaffer is built around reducing friction and surfacing signal: **Instant Access** - Shareable URLs for every test run - No downloads needed - Share links in Slack, Jira, PRs **Slack Notifications** - Test results delivered where your team works - Direct links to full reports - Filter by branch to reduce noise **Flaky Test Detection** - Automatic flip rate tracking - Dashboard showing most problematic tests - Catch new flakiness before it spreads **Analytics and Trends** - Pass rate over time - Health scores per project - Historical data with configurable retention **Team Access** - Organization-based permissions - No CI login required - Works for PMs, QA, anyone who needs visibility ## The Alternative: A Healthy Test Culture Teams that maintain trust in their test suites share common traits: - Failures are rare enough to be meaningful - When failures happen, investigation is immediate - Flaky tests are quarantined quickly - Test health metrics are visible and discussed - Everyone - not just developers - can access results This doesn't require heroic effort. It requires removing friction and making signal visible. ## Get Started Stop ignoring test failures. Gaffer makes results accessible, surfaces flaky tests, and tracks trends so your team can trust the suite again.
--- ## How to Share Jest Test Results with Your Team Source: https://gaffer.sh/solutions/jest-test-reporting/ import { Image } from "astro:assets";

Jest runs your tests and prints results to the console. CI captures the output, but sharing it with your team means "go check the workflow run" or screenshotting terminal output into Slack. There's no built-in way to share Jest results as a link.

## The Sharing Problem Jest outputs to stdout by default. You can add reporters for JSON or HTML, but those files are stuck in CI artifacts: ### Common (Bad) Solutions **1. "Check the CI logs"** - Requires repo access and navigating through workflow runs - QA or PMs might not have CI access - Logs disappear when retention expires **2. CI artifacts** - Download a zip, extract, find the right file - GitHub Actions keeps artifacts 90 days max - Finding the right artifact in dozens of workflow runs is tedious **3. Screenshot the terminal** - Loses all structure and detail - Can't reference later - Looks terrible in Slack **4. Paste JSON output in a thread** - Nobody reads raw JSON - Truncated for large test suites - No filtering or navigation ## Better: Hosted Jest Reports with Shareable Links Upload Jest results after every CI run. Every test run gets a permanent URL that anyone on your team can open. ### What This Looks Like 1. CI runs your Jest tests 2. Results upload automatically 3. You get a URL like `https://app.gaffer.sh/reports/abc123` 4. Share in Slack, GitHub PRs, Jira, or anywhere else 5. Anyone with access sees the full report No downloads. No CI access required. No expiring artifacts. ## Setting Up Jest Report Sharing with Gaffer ### Step 1: Configure Jest Output CTRF gives the richest analytics data: ```bash npm install jest-ctrf-json-reporter --save-dev ``` ```javascript // jest.config.js module.exports = { reporters: [ 'default', ['jest-ctrf-json-reporter', { outputDir: '.', outputFile: 'ctrf-report.json' }] ] }; ``` Gaffer also accepts Jest's built-in JSON output and jest-html-reporter if you prefer those. If the reporter fails with `ENOENT`, see [why jest-ctrf-json-reporter throws ENOENT](/docs/guides/ctrf/#why-does-jest-ctrf-json-reporter-throw-enoent). Vitest's JSON reporter uses the same shape; see the [Jest/Vitest JSON schema](/blog/vitest-json-reporter-output-format/) for the field-by-field breakdown. ### Step 2: Add the Upload Step to CI **GitHub Actions:** ```yaml - name: Run tests run: npm test - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./ctrf-report.json commit_sha: ${{ github.sha }} branch: ${{ github.ref_name }} ``` **GitLab CI:** ```yaml test: script: - npm ci - npm test after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@ctrf-report.json" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'"}' ``` ### Step 3: Share the Link After upload, share the URL in Slack, GitHub PR comments, or Jira tickets. Recipients see the full report in their browser. For people outside your organization, you can generate a [public share link](/solutions/shareable-test-report-links/) with configurable expiration. They view the results without needing a Gaffer account. ## Slack and Webhook Notifications Stop checking CI manually. Get notified when Jest tests finish: **Slack integration:** ``` [FAILED] my-project - main 3 tests failed, 142 passed View report: https://app.gaffer.sh/reports/abc123 ``` - Direct link to the full report - Filter by branch: only notify on `main`, skip feature branches **Webhooks:** - Send results to any endpoint - Integrate with your existing alerting or workflow tools - Trigger custom actions on failure No more "did the tests pass?" messages in Slack. See [Test Failure Notifications](/solutions/test-failure-notifications/) for the full setup guide. ## GitHub Integration Commit statuses show test results directly on PRs. No clicking through to CI logs to find out if tests passed. ## Monorepo Support Running Jest across multiple packages? Upload results from each: ```yaml - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: | ./packages/api/ctrf-report.json ./packages/web/ctrf-report.json ./packages/shared/ctrf-report.json ``` All results appear in the same project dashboard. ## Get Started Gaffer's free tier includes 500 MB of storage with 7-day retention. Paid plans offer extended retention up to 90 days.
--- ## Test Report Sprawl: Comparing Results Across Multiple Test Frameworks Source: https://gaffer.sh/solutions/multiple-test-frameworks/ import ScreenshotLink from "../components/ScreenshotLink.astro"; import PullCta from "../components/PullCta.astro"; import JsonLd from "../components/JsonLd.astro"; import activityFeed from "../assets/screenshots/org-activity-feed.png";

A Python service, a TypeScript frontend, and a Rust worker in one repo means four report formats in one CI run. Downloading them is annoying. The real problem shows up when you try to answer "is the suite healthier than last week?" and discover the suites disagree about what they are counting.

Gaffer's own CI uploads three report formats from three test suites into one project on a code pull request: a Vitest HTML report bundled with LCOV coverage, JUnit XML from the 331 `gaffer-parsers` test functions run under `cargo-nextest`, and a Playwright HTML report from the browser suite. Two of those three, the Vitest HTML report and the Rust JUnit XML, carry no retry count and no flaky flag, so any flakiness figure for those suites has to be computed from run history rather than read out of the file.

## Why does one repo end up with four report formats? Nobody picks four formats. Each framework ships its own reporter, and the reporter is the path of least resistance at the moment you add that framework. JUnit XML became the common denominator by accident. It started as Ant's output format for Java, has no formal specification, and every framework that emits it invented its own conventions on top. CTRF is the deliberate attempt at a shared JSON schema, but adoption is per-framework, so it arrives only where someone installed the reporter. Playwright and .NET each ship a native format that carries more than JUnit XML can express, which is a good reason to keep them and a bad reason for comparability. So the sprawl is not a discipline problem. It is the sum of five reasonable local decisions. ## What does each test report format actually carry? The formats are not interchangeable subsets of each other. Each drops something the others keep. This is the field-level picture Gaffer's parsers work from: | Format | Skip representation | Retries | Flaky | File and line | Timing detail | |---|---|---|---|---|---| | JUnit XML | `` child element | not carried | not carried | only if the writer sets `file` / `line` attributes | per-test `time`, seconds | | Jest / Vitest JSON | `pending`, `todo`, `skipped`, `disabled` | not carried | not carried | file path plus line | per-test duration, suite start and end | | Playwright JSON | `skipped` status | `retry` index per attempt | native `flaky` status and run-level count | spec file plus line | per-test start time and worker index | | CTRF JSON | `skipped`, `pending`, `other` | `retries` count | per-test boolean plus summary count | optional `filePath` and `line` | per-test duration, run start and stop | | TRX (.NET) | `NotExecuted`, `NotRunnable`, `Inconclusive` | not carried | not carried | not carried | per-test duration as a .NET timespan | Two consequences fall straight out of that table. Only Playwright JSON and CTRF can report flakiness in the file itself. Only Playwright JSON and TRX distinguish a timeout from an ordinary failure, through Playwright's `timedOut` result status and TRX's `Timeout` outcome. ## What breaks when you compare suites that disagree? Four specific things, and all four are invisible until you put the numbers side by side. ### Pass rate has a different denominator per format Take one run of 100 tests where three failed on the first attempt and passed on retry. - **Playwright JSON** puts those three in a separate flaky bucket. They are neither passed nor failed, and the run total is expected plus unexpected plus flaky plus skipped. Divide passed by total and you get 97%. - **Jest or Vitest JSON** has no flaky bucket. The same three tests are simply passed, and you get 100%. Same run, same underlying reality, three-point spread. Stack a few frameworks and the aggregate number is meaningless. ### Why does the skip count differ between suites? A skip in JUnit XML is a `` child element. In Jest and Vitest JSON it is one of `pending`, `todo`, `skipped`, or `disabled`. In CTRF it is `skipped`, `pending`, or `other`. In TRX it is `NotExecuted`, `NotRunnable`, or `Inconclusive`, which lumps "we chose not to run this" together with "this could not produce a verdict". Any tool that pattern-matches on the literal string gets a different skip count from each suite. ### Retries are recorded in one format and inferred in the rest Playwright JSON records a `retry` index on every attempt and CTRF records a `retries` count. JUnit XML, Jest/Vitest JSON, and TRX record nothing. Some JUnit generators bolt on a `` element, but it is not standardized, so parsing it is per-generator work. See the [JUnit XML format guide](/blog/junit-xml-format-guide/) for what the schema does and does not define. ### Parameterized and matrix runs collapse unless the name carries the parameter Cross-run analysis groups executions by test name. The CLI records Playwright's full test path with the project name appended, so the same spec under chromium, firefox, and webkit stays three distinct histories. JUnit XML has no equivalent: parameterized cases are distinguishable only to the extent the generator wrote the parameter into the test name. A generator that does not collapses an entire matrix into one history, and the flakiness signal for that test becomes noise. ## What do teams do about it, and why does each approach fall short? **1. Standardize every suite on JUnit XML** - Genuinely gives you one parser and one shape - Levels down to the weakest format. Playwright's retry data, trace links, and browser metadata are gone the moment you convert - TRX outcome granularity collapses too: `Inconclusive` and `NotExecuted` both become a bare `` **2. One CI job per framework, read the artifacts separately** - No conversion, no data loss - No shared denominator, so there is no suite-wide number to trend - Artifacts expire on different clocks, so historical comparison degrades unevenly **3. Write a merge script** - Works, right up until it is load-bearing - You now own a parser per format, forever, and a framework upgrade can change its JSON shape without warning - Merging summaries is the easy half. Reconciling status vocabularies is the half that gets skipped **4. Buy a framework-native tool per framework** - Deeper framework-specific coverage than any generic parser gives you - Two or three bills, two or three dashboards, and still no cross-suite number - Tools tied to one runner cannot answer "which layer is least reliable this month" ## How does Gaffer normalize across frameworks? Gaffer parses each format natively, then maps every test onto one of five statuses: `passed`, `failed`, `skipped`, `timedOut`, or `flaky`. Everything is stored against the CTRF field set, so a Rust suite and a .NET suite land in the same columns and the comparison is arithmetic rather than interpretation. Three parts of that are worth spelling out, because they are what makes the cross-framework number trustworthy. **Skip vocabularies are folded, not string-matched.** Jest's `pending` and `todo` both become `skipped`. CTRF's `skipped`, `pending`, and `other` are summed into the same bucket. TRX's `NotExecuted`, `NotRunnable`, and `Inconclusive` follow. One skip count per run, whatever emitted it. **Flakiness is computed from history, not read from the file.** Since three of the five formats cannot express flakiness at all, trusting the file would mean a JavaScript suite gets flaky detection and a Python suite does not. Instead, Gaffer groups executions by test name across runs, ignores anything that is not a pass or a fail, and requires at least five executions before judging a test. It flags a test when the pass/fail sequence flips on 10% or more of consecutive run pairs, or when the test fails with two or more distinct error messages after normalization. The second rule catches a test that fails consistently for shifting reasons, which pure flip-rate analysis misses. Details in [flaky test detection](/solutions/flaky-test-detection/). **Health is one score per project, not per runner.** The score weights pass rate at 60%, stability at 30%, and trend direction at 10%, over the normalized statuses. Because the inputs are normalized, adding a fourth suite in a fourth language changes the score by the amount that suite actually contributes. ## How do you get every suite into one dashboard? Upload every report to the same project and tag each one. Reports join on commit SHA, so suites that run in different jobs, on different runners, in different languages still land under the same commit. This is the pattern to follow: ```yaml # Job 1: Vitest unit tests, emitted as JUnit XML plus HTML and LCOV - name: Upload unit results if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: apps/dashboard/test-reports commit_sha: ${{ github.sha }} branch: ${{ github.ref_name }} test_framework: vitest test_suite: unit # Job 1 (cont.): Rust tests, JUnit XML from cargo-nextest - name: Upload Rust results if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: target/nextest/ci/junit.xml commit_sha: ${{ github.sha }} branch: ${{ github.ref_name }} test_framework: cargo-nextest test_suite: rust-unit ``` ```yaml # Job 2: Playwright, native HTML report - name: Upload E2E results if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: apps/dashboard/playwright-report commit_sha: ${{ github.sha }} branch: ${{ github.ref_name }} test_framework: playwright test_suite: e2e ``` Format detection is automatic, so there is no format argument to keep in sync. `test_framework` and `test_suite` are labels for filtering, not parsing hints. If a framework has no natively parsed output, install a [CTRF](https://ctrf.io) reporter and upload the JSON. CTRF reporters exist for Mocha, Jasmine, Go test, Cypress, WebdriverIO, .NET, Nightwatch, and others, and CTRF carries retries and per-test metadata that a JUnit XML conversion would drop. ## Framework-specific setup Each suite has its own reporter configuration and its own sharing quirks. The per-framework guides cover setup end to end: - [Playwright](/solutions/share-playwright-test-reports/): native HTML report with traces, screenshots, and video - [Jest](/solutions/jest-test-reporting/): built-in JSON, `jest-html-reporter`, or the CTRF reporter - [Vitest](/solutions/vitest-test-reporting/): JUnit, HTML, or CTRF reporters, plus [Vitest coverage reports](/blog/vitest-coverage-reports/) and the [Vitest JSON reporter's field layout](/blog/vitest-json-reporter-output-format/) - [pytest](/solutions/pytest-test-reporting/): `pytest-html`, JUnit XML, or `pytest-ctrf` - [Cypress](/solutions/cypress-test-reporting/): CTRF via `cypress-ctrf-json-reporter`, and the [Cypress reports guide](/blog/cypress-reports-guide/) for Mochawesome and JUnit export - [.NET](/solutions/dotnet-test-reporting/): TRX from `dotnet test --logger:trx`, covering MSTest, NUnit, and xUnit - [Code coverage](/solutions/code-coverage/): LCOV, Cobertura, JaCoCo, and Clover alongside the test results ## What you get | Before | After | |---|---| | A pass rate per framework, none comparable | One pass rate per commit, computed on normalized statuses | | Flaky detection only where the format supports it | Flaky detection on every suite, computed from run history | | Retention that expires on a different clock per artifact | One retention window for every suite | | A link per framework to share | One project URL, filterable by suite | | "Which layer is least reliable?" is unanswerable | A health score that moves when any suite moves | Coverage rides along in the same view, so line coverage from LCOV and JaCoCo trends next to pass rate instead of in a separate tool. ## Frequently asked questions ### Can one dashboard show Playwright, Jest, pytest, and .NET results together? Yes. Gaffer parses JUnit XML, Jest/Vitest JSON, native Playwright JSON, CTRF JSON, and TRX, and normalizes all of them to the same field set before storing them. Reports join on commit SHA, so every suite that ran against a commit appears under that commit regardless of which language or CI job produced it. ### How is pass rate calculated when frameworks count skips and retries differently? Gaffer maps every parsed test to one of five statuses: passed, failed, skipped, timedOut, or flaky. Formats that use several names for a skip are folded into one bucket, so Jest's pending and todo and CTRF's skipped, pending, and other all become skipped. Pass rate is computed on that normalized set rather than on each format's own totals. ### Do I have to convert everything to JUnit XML first? No, and converting is usually a downgrade. JUnit XML has no retry count, no flaky flag, and no standard place for screenshots or browser metadata, so a Playwright suite converted to JUnit XML loses data Gaffer would otherwise keep. Upload each suite in its richest supported format and let the parser normalize. ### Does Gaffer detect flaky tests in frameworks that have no flaky concept? Yes. Flakiness is computed from history, not read from the report file. Gaffer groups executions by test name across runs, ignores anything that is not a pass or a fail, and needs at least five executions before it will judge a test. A test is flagged when it flips between pass and fail on at least 10% of consecutive run pairs, or when it fails with two or more distinct error messages after normalization. ### What happens to a test that fails once and passes on retry? It depends on the format, which is why the normalized view matters. Playwright JSON reports it in a separate flaky bucket, so it counts as neither passed nor failed in the run summary. Jest and Vitest JSON have no flaky bucket and report it as passed. Gaffer keeps each format's own counts and separately recomputes flakiness across runs, so the two suites end up comparable. ## Get started Gaffer's free tier includes 500 MB of storage with 7-day retention, which is enough to wire every suite in a polyglot repo into one project and see whether the numbers agree.
--- ## How to Share pytest Results with Your Team Source: https://gaffer.sh/solutions/pytest-test-reporting/ import htmlReportExample from "../assets/screenshots/html-report-example.png"; import { Image } from "astro:assets";

pytest runs your tests. pytest-html gives you a nice report. Then someone asks "what failed in CI?" and you're walking them through downloading artifacts from GitHub Actions. There's no built-in way to share pytest results with your team.

## The Sharing Problem pytest outputs results to the terminal or generates files (HTML, JUnit XML). Either way, those results are stuck wherever the tests ran: your machine or a CI runner. Getting them to your team means: ### Common (Bad) Solutions **1. CI artifacts** - GitHub Actions keeps artifacts 90 days max, GitLab even shorter by default - "Go to the workflow run, click artifacts, download, unzip, open in browser" - QA might not have CI access at all **2. Paste terminal output in Slack** - Loses formatting, hard to parse - Can't reference later - Doesn't scale past a handful of tests **3. Email the HTML report** - Recipient downloads, opens locally - Version confusion across multiple runs - Feels like 2005 **4. Upload to S3 manually** - Works, but it's a manual step that gets skipped - Someone has to manage the bucket and permissions ## Better: Hosted pytest Reports with Shareable Links Upload pytest results after every CI run. Every test run gets a permanent URL your team can open directly.
Hosted pytest test report in the Gaffer dashboard
### What This Looks Like 1. CI runs pytest 2. Results upload automatically 3. You get a URL like `https://app.gaffer.sh/reports/abc123` 4. Share in Slack, GitHub PRs, Jira, anywhere 5. Anyone with access sees the full report No downloads. No manual steps. No expiring artifacts. ## Setting Up pytest Report Sharing with Gaffer ### Step 1: Generate Reports Pick your output format, or use multiple: ```bash # HTML for humans pip install pytest-html pytest --html=report.html --self-contained-html # CTRF for richest analytics pip install pytest-ctrf pytest --ctrf=ctrf-report.json # Or both pytest --html=report.html --self-contained-html --ctrf=ctrf-report.json ``` ### Step 2: Add the Upload Step to CI **GitHub Actions:** ```yaml - name: Run tests run: pytest --html=report.html --self-contained-html --ctrf=ctrf-report.json - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: | ./report.html ./ctrf-report.json commit_sha: ${{ github.sha }} branch: ${{ github.ref_name }} ``` **GitLab CI:** ```yaml test: image: python:3.11 script: - pip install -r requirements.txt - pip install pytest pytest-html pytest-ctrf - pytest --html=report.html --self-contained-html --ctrf=ctrf-report.json after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@report.html" \ -F "files=@ctrf-report.json" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'"}' ``` ### Step 3: Share the Link After upload, share the URL in Slack, add it to a GitHub PR comment, or attach it to a Jira ticket. Recipients see the full report in their browser — no downloads. For people outside your organization, you can generate a [public share link](/solutions/shareable-test-report-links/) with configurable expiration. They view the results without needing a Gaffer account. ## Slack and Webhook Notifications Stop checking CI manually. Get notified when pytest finishes: **Slack integration:** ``` [FAILED] api-tests - main 2 tests failed, 89 passed View: https://app.gaffer.sh/reports/xyz789 ``` - Direct link to the full report - Filter by branch: only notify on `main`, skip feature branches **Webhooks:** - Send results to any endpoint - Integrate with your existing alerting tools - Trigger custom actions on failure No more "did the tests pass?" back-and-forth. ## GitHub Integration Commit statuses show test results directly on PRs. Your team sees pass/fail without clicking through to CI logs. ## One Dashboard for Every Suite Running JavaScript or Go suites alongside Python? Gaffer combines [multiple test frameworks](/solutions/multiple-test-frameworks/) in one dashboard, so every suite reports to the same place regardless of language. ## What About Flaky Tests? Python tests can be flaky for many reasons: database state, network calls, timing issues, fixture leaks. Gaffer tracks test results across runs and identifies tests that flip between pass and fail over time. You get a list of unreliable tests to fix or quarantine, instead of investigating the same "random failure" repeatedly. ## Get Started Gaffer's free tier includes 500 MB of storage with 7-day retention. Paid plans offer extended retention up to 90 days.
--- ## ReportPortal Alternative: Test Analytics Without Enterprise Complexity Source: https://gaffer.sh/solutions/reportportal-alternative/ import { Image } from "astro:assets";

You need test analytics, so you evaluate ReportPortal. Then you see the requirements: Elasticsearch, PostgreSQL, RabbitMQ, MinIO — five services just to view test results. For most teams, the infrastructure cost of running ReportPortal dwarfs the value of the analytics it provides.

## The ReportPortal Trade-offs ReportPortal offers a lot of features: real-time reporting, ML-based failure analysis, custom dashboards, and extensive integrations. But those features come with costs: ### Infrastructure Requirements A typical ReportPortal deployment needs: - PostgreSQL database - Elasticsearch (or OpenSearch) - RabbitMQ message queue - MinIO or S3 for binary storage - The ReportPortal services themselves That's 5+ services to deploy, monitor, and maintain. The official docs recommend 16GB+ RAM for a production setup. ### Operational Overhead Running ReportPortal means: - Managing Elasticsearch indices and storage - Monitoring RabbitMQ queues - Database backups and maintenance - Keeping all services updated - Handling scaling as test volume grows This requires dedicated DevOps attention, which smaller teams often can't spare. ### Learning Curve ReportPortal's feature set is extensive. Dashboards, widgets, filters, defect types, analysis rules - there's a lot to learn. Teams often end up using 20% of the features after spending significant time on setup. ## Gaffer: Test Reporting Without the Overhead Gaffer focuses on the core problem: getting test results out of CI and into a place where your team can actually use them. ### Zero Infrastructure Gaffer is a hosted service. No databases, no message queues, no Elasticsearch. Sign up, get an API key, upload results. ### Simple Setup Add one step to CI: ```yaml - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./playwright-report ``` That's it. Reports are now hosted and shareable. ### Focus on What Matters Gaffer provides: - **Test report hosting** - Shareable URLs for every test run - **Flaky test detection** - Automatic identification of unreliable tests - **Pass rate trends** - See test health over time - **Slow test tracking** - Find tests dragging down your CI - **Slack notifications** - Get alerts when tests fail - **GitHub integration** - Commit statuses and PR comments No ML-powered failure classification, no complex dashboard builders. Just the essentials, done well. ## Feature Comparison | Feature | ReportPortal | Gaffer | |---------|--------------|--------| | Test report hosting | Yes | Yes | | Historical trends | Yes | Yes | | Flaky test detection | Yes | Yes | | ML failure analysis | Yes | No | | Custom dashboards | Yes | No | | Self-hosting required | Yes | No | | Infrastructure needs | High | None | | Setup time | Hours/Days | Minutes | | Pricing | Self-host or SaaS | SaaS | ## Framework Support Both platforms support major frameworks, though integration methods differ: | Framework | ReportPortal | Gaffer | |-----------|--------------|--------| | Playwright | Agent | Native HTML + CTRF | | Jest | Agent | JSON + CTRF | | Vitest | Agent | HTML + CTRF | | pytest | Agent | pytest-html + CTRF | | JUnit | Agent | JUnit XML | ReportPortal uses "agents" that report results in real-time. Gaffer parses standard report formats after test runs complete - no custom agents needed. ## When to Use ReportPortal ReportPortal might be a better fit if you: - Have dedicated DevOps capacity for infrastructure - Need ML-powered failure analysis - Want highly customizable dashboards - Run thousands of tests and need real-time reporting - Have enterprise compliance requirements for self-hosting ## When to Use Gaffer Gaffer is probably a better fit if you: - Want test reporting without infrastructure overhead - Have a small-to-medium team - Need quick setup that just works - Value simplicity over feature count - Want to avoid the ops burden of running 5+ services ## Migration Path If you're currently using ReportPortal and finding the maintenance burden too high: 1. Add Gaffer upload to your CI pipeline (alongside ReportPortal initially) 2. Use your existing report formats - no agent changes needed 3. Evaluate for a few weeks 4. Remove ReportPortal agents if Gaffer meets your needs 5. Decommission ReportPortal infrastructure The upload-based approach means you're not locked in - you can switch back or run both if needed. ## Other Comparisons Evaluating multiple tools? See how Gaffer compares to [Allure](/solutions/allure-alternative/) and [TestDino](/solutions/testdino-vs-gaffer/). ## Try It Gaffer's free tier gives you 500 MB storage with 7-day retention. Enough to see if the simpler approach works for your team.
--- ## How to Share Playwright Test Reports with Your Team Source: https://gaffer.sh/solutions/share-playwright-test-reports/ import playwrightReportExample from "../assets/screenshots/playwright-report-example.png"; import BrowserChrome from "../components/BrowserChrome.astro"; import PullCta from "../components/PullCta.astro";

Playwright generates HTML test reports, but sharing them with your team is surprisingly difficult. `npx playwright show-report` works on your laptop; sending the results to QA, your PM, or a client on a Slack thread is a different problem. Here's how to share Playwright test reports properly.

## The Playwright Report Sharing Problem The local server from `npx playwright show-report` is only accessible on your machine. How do you share the report when: - **QA needs to see what failed** before approving a release - **A PM wants to check test coverage** for a feature - **You're debugging with a colleague** and need to show them the exact failure - **You need to reference an old test run** from weeks ago ### Common (Bad) Solutions **1. Email the zip file** - Clunky, fills up inboxes, version confusion - Recipient has to download, extract, open in browser **2. Upload to S3/GCS manually** - Works, but requires manual steps every time - Who manages the bucket? What about access control? **3. Rely on CI artifacts** - [GitHub Actions artifacts expire](/solutions/ci-test-artifacts-expiring/) after 90 days (configurable) - GitLab artifacts expire too - Finding the right artifact in a sea of workflow runs is tedious **4. Share your screen** - Not async-friendly - Can't reference later ## `npx playwright show-report` Only Works on Your Machine `npx playwright show-report` starts a local web server on `http://localhost:9323` (or the next free port if 9323 is taken) and serves the `playwright-report/` directory Playwright wrote during the run. It also takes a path, so a custom output folder or a zipped report works the same way: ```bash npx playwright show-report # serves ./playwright-report npx playwright show-report reports/html # serves a custom dir such as reports/html or html-report npx playwright show-report playwright-report.zip # serves a zipped report ``` That server is bound to your laptop. A teammate cannot open your `localhost:9323`, the process stops when you close the terminal, and the copy sitting in CI disappears once the artifact retention window closes. Sending the folder instead means sending a zip and asking someone to unpack it and run the same command. Uploading that same directory from CI gives you a URL anyone can open, with no local server involved: ```yaml - name: Upload report to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./playwright-report ``` ## Better Solution: Hosted Playwright Reports The real solution is to host your Playwright reports on a dedicated platform. Every test run gets a unique URL that your team can access through the dashboard. ### What Good Looks Like 1. CI runs your Playwright tests 2. Reports automatically upload to a hosting service 3. You get a shareable URL like `https://app.gaffer.sh/reports/abc123` 4. Share the link in Slack, Jira, GitHub PR, wherever 5. Anyone with access can view the full interactive report No downloads. No manual steps. Configurable retention periods. ### Sharing Beyond Your Team Need to share a report with someone who doesn't have a Gaffer account — a contractor, a stakeholder, or another team? [Share links](/solutions/shareable-test-report-links/) let you generate a public URL for any test run. Set an expiration (1 hour to 30 days, or never), and revoke the link when you're done. Recipients see the full interactive Playwright report in their browser without needing to log in. ## Setting Up Playwright Report Sharing with Gaffer Gaffer hosts your Playwright reports with a single step in your CI pipeline: run your tests, then upload `./playwright-report` with the Gaffer uploader action (GitHub Actions) or a `curl` POST (GitLab, CircleCI, anywhere else). Full setup for each CI provider is in the [CI guides](/docs/guides/). Use the same upload step shown above. After upload, Gaffer returns a URL to the hosted report. Paste it in Slack, PR comments, or Jira tickets, wherever your team already talks about releases. ## Slack Notifications Gaffer [posts test results to Slack](/solutions/test-failure-notifications/) so nobody has to open CI to find out: - Pass/fail summary at a glance - Direct link to the full report - Filter by branch (only notify on `main`, not every feature branch) No more "did the tests pass?" messages in Slack. ## Comparing Playwright Report Sharing Options
MethodRetentionShareable linkAutomatedTeam access
Local show-reportNone✕✕✕
Email zip filesManual✕✕✓
CI artifacts30–90 days✓✓✓ (if CI access)
S3 manual uploadConfigurable✓✕Depends
GafferUp to 90 days✓✓✓ (public share links)
## Beyond Sharing: Playwright Analytics Once you're hosting reports, you unlock analytics: - **Pass rate trends** - Is your test suite getting more stable or less? - **Flaky test detection** - Which tests fail intermittently? - **Duration tracking** - Are tests getting slower? - **Failure patterns** - See which tests fail most often For a deeper look at Playwright's report formats, trace files, and what each artifact tells you, see the [Playwright reports guide](/blog/playwright-reports-guide/). Free tier includes 500 MB of storage with 7-day retention; paid plans extend retention up to 90 days. Running Cypress alongside Playwright? The same setup works for Cypress. See [Cypress test reporting](/solutions/cypress-test-reporting/) for the configuration, or the [Cypress reports guide](/blog/cypress-reports-guide/) for a walkthrough of Cypress's report formats.
--- ## Share test reports with anyone. No login required. Source: https://gaffer.sh/solutions/shareable-test-report-links/ import { Image } from "astro:assets";

Your test results live behind a login. That works for your team, but not for the contractor debugging a regression, the PM checking release status, or the partner team blaming your API. Share links give you a read-only public URL for any test run. Recipients open the full interactive HTML report in their browser without a Gaffer account.

## The Problem: Test Results Are Locked Behind Logins Test reports sit in your CI dashboard or your Gaffer organization. When someone outside your team needs to see them, you're stuck with bad options: - **Screenshots in Slack**: Loses detail, can't drill into failures, no interactivity - **Screen sharing**: Requires both people online at the same time - **Exporting to PDF**: Static, loses the interactive HTML report - **Adding them to your org**: Overkill for a contractor who needs one report You shouldn't need to add someone to your organization just to show them a test result. ## Share Links: Public URLs with Controls From any test run in Gaffer, open the share modal and get a public URL. The link gives read-only access to the test run summary and the HTML report, no login required. ### How It Works 1. Open a test run and click **Share** 2. Gaffer generates a unique URL for that test run 3. Send the link to anyone, they see the results in their browser 4. Set an expiration (1 hour, 24 hours, 7 days, 30 days, or never) 5. Revoke the link at any time Each test run gets one share link. Creating a link is idempotent — opening the share modal again returns the same URL. ### What Recipients See The share page shows the same data your team sees on the test run detail page: - **Test counts**: Passed, failed, and skipped - **Branch and commit info** - **Interactive HTML report**: The full Playwright, Jest, or other framework report, viewable in the browser Recipients who don't have a Gaffer account see a sign-up prompt. Members of your organization see a link back to the dashboard. Authenticated users from other organizations see an option to request access. ## Use Cases ### Sharing with Contractors A freelance QA engineer is testing your app. They don't need full dashboard access, they need to see what failed in the latest run. Send them a share link with a 7-day expiration. ### Stakeholder Updates Your PM asks "did the regression suite pass before release?" Instead of a screenshot or a verbal "yes," send them the link. They see the actual results. ### Cross-Team Debugging Another team's API change broke your integration tests. Share the link in their Slack channel. They can see exactly which tests failed and what the errors were. ### Client Reporting If you're an agency running tests for a client, share links let you send test results without provisioning accounts. Set the link to expire after 30 days so it doesn't linger. ## Expiration and Revocation Share links aren't permanent by default. You control how long they're active: | Setting | Duration | |---------|----------| | 1 hour | Quick review, then auto-expires | | 24 hours | End-of-day sharing | | 7 days | Sprint-length access | | 30 days | Monthly reporting | | Never | Link stays active until manually revoked | You can update the expiration after creating the link, or revoke it entirely. Revoked links return a "not found" page. ## How It Fits with the Rest of Gaffer Share links complement the existing workflow: - **CI uploads test results** to Gaffer automatically - **Your team** views results in the dashboard and gets [Slack notifications](/solutions/test-failure-notifications/) - **External collaborators** get share links when they need access to specific runs - **[HTML reports](/solutions/html-test-reports/)** are viewable through share links, no downloads required Share links are read-only. Recipients can view results but can't modify anything in your organization. ## Get Started Share links are available on all Gaffer plans. Open any test run, click Share, and send the link.
--- ## Test Artifact Management: Stop Downloading CI Zip Files Source: https://gaffer.sh/solutions/test-artifact-management/ import htmlReportExample from "../assets/screenshots/html-report-example.png"; import { Image } from "astro:assets";

Your Playwright report is in GitHub Actions. The screenshot is in a Slack thread from last Tuesday. The coverage data is in an S3 bucket that two people have access to. When a test fails, the first ten minutes aren't spent debugging. They're spent finding the evidence.

For definitions and file types, read [What Are Test Artifacts in Software Testing?](/blog/what-are-test-artifacts/). This page covers where the files go once CI has produced them.

Playwright HTML Test Report Example
## Which Test Artifacts Are Worth Storing Not everything a run produces is worth storage. These five are: - **Test Reports** - HTML, JSON, or XML summaries of test results (Playwright, Jest, Vitest, pytest, JUnit) - **Screenshots & Videos** - Visual evidence of test failures, especially for E2E tests - **Log Files** - Console output, stack traces, and debugging information - **Coverage Reports** - Code coverage data showing which lines were tested - **Performance Metrics** - Timing data, memory usage, and resource consumption All five are cheap to generate and easy to lose. Storage is the whole problem. ## The CI Artifact Expiration Problem Most CI/CD platforms (GitHub Actions, GitLab CI, CircleCI) automatically delete test artifacts after a retention period - typically 30-90 days. This creates real problems: - **Debugging old failures** - When a bug resurfaces, the original test artifacts are gone - **Compliance audits** - Regulated industries need to retain test evidence for years, not months - **Onboarding engineers** - New team members can't see historical context for test behavior - **[Flaky test analysis](/solutions/flaky-test-detection/)** - Understanding intermittent failures requires data across many runs Even worse, accessing artifacts *while they exist* is painful. You have to navigate to the CI provider, find the right workflow run, download a zip file, and extract it locally. ## How Gaffer Solves Test Artifact Management Gaffer provides searchable test artifact storage with configurable retention and instant team access: ### Automatic Upload from CI Add one step to your CI pipeline and Gaffer captures your test artifacts automatically: ```yaml - name: Upload to Gaffer uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./test-results ``` ### Instant Team Sharing Every test run gets a URL in the Gaffer dashboard. Share it in Slack, add it to a bug ticket, or bookmark it for later. No downloads, no zip files. For external collaborators, you can generate [public share links](/solutions/shareable-test-report-links/) with configurable expiration. They view the results in their browser without needing a Gaffer account. ### Slack Notifications Get notified in Slack when tests fail, with a direct link to the full report. Filter notifications by branch so you only hear about failures that matter. ### Historical Analytics Track test health over time with pass rate trends, [flaky test detection](/solutions/flaky-test-detection/), and [historical analytics](/solutions/test-results-dashboard/). ## Supported Test Frameworks Gaffer works with the test artifacts you already generate: | Framework | Report Types | |-----------|--------------| | [Playwright](/solutions/share-playwright-test-reports/) | HTML reports, traces, screenshots | | Jest | JSON output, jest-html-reporter | | Vitest | HTML reports, JSON output | | pytest | pytest-html, JUnit XML | | Any framework | JUnit XML, [CTRF JSON](/docs/guides/ctrf/) | ## Get Started Gaffer's free tier includes 500 MB of artifact storage with 7-day retention. Paid plans offer extended retention (up to 90 days) and up to 50 GB of storage.
--- ## Test Failure Notifications: Get Alerted in Slack, Not CI Logs Source: https://gaffer.sh/solutions/test-failure-notifications/

Your CI pipeline runs tests on every push. When tests fail, how does your team find out? If the answer is "someone checks the GitHub Actions tab," you have a notification problem. Failures sit unnoticed for minutes or hours while developers context-switch to other work.

## The Problem: Nobody Checks CI Proactively Here's what typically happens when tests fail in CI: 1. Developer pushes code and moves on to the next task 2. CI runs tests in the background 3. Tests fail, but nobody's watching 4. Eventually someone notices the red X on the PR (or doesn't) 5. By the time they investigate, they've lost the mental context The feedback loop is broken. CI runs tests, but the results sit in a dashboard nobody monitors in real-time. ### What Teams Do Instead **1. Check CI manually** - Requires remembering to check after every push - Developers context-switch away while waiting for CI - Easy to miss failures on shared branches like `main` **2. GitHub email notifications** - Buried in inbox noise alongside PR reviews, mentions, and issue updates - No summary of *which* tests failed or why - No link to the full report **3. Custom scripts with `curl` and Slack webhooks** - Brittle, hard to maintain, lives in CI config - Usually just sends "build failed" with no detail - No filtering by branch or failure severity - Every team reinvents this from scratch **4. CI provider's built-in notifications** - GitHub Actions has no native Slack integration for test results - GitLab notifications are noisy and lack test-level detail - No structured test result data, just "job passed/failed" ## Better: Structured Test Notifications What you actually want is a notification that tells you: - **Which tests failed**: not just "the build broke" - **A link to the full report**: one click to see errors, traces, screenshots - **Branch and commit context**: what triggered the failure - **Filtering**: only notify on branches you care about ## How Gaffer Handles Test Notifications Gaffer sends test results to Slack, webhooks, and GitHub commit status after every upload. No custom scripts, no maintenance. ### Slack Integration Connect your Slack workspace in project settings. After each test run upload, Gaffer posts a summary: - Pass/fail counts and overall status - Direct link to the hosted test report - Branch, commit SHA, and trigger context - Failed test names for quick triage ### Notification Triggers Choose when to get notified: - **All runs**: every test upload sends a notification - **Failures only**: only hear about problems - **Consecutive failures**: alert when a test fails multiple runs in a row, filtering out one-off noise ### Branch Filtering Don't get spammed by feature branch noise. Configure notifications per branch pattern: - Only notify on `main` and `release/*` - Skip `dependabot/*` and draft PRs - Different channels for different branches ### Webhooks For custom integrations, Gaffer sends HMAC-SHA256 signed HTTP POST payloads to any endpoint. Payloads include test run results, project context, and report links. See the [webhook docs](/docs/integrations/webhooks/) for the full payload schema. Use webhooks to: - Post to Microsoft Teams, Discord, or any chat tool - Trigger PagerDuty alerts for critical test suites - Update a status page or internal dashboard - Feed into your existing alerting pipeline ### GitHub Commit Status Gaffer sets commit status checks on your PRs automatically. Developers see test results without leaving GitHub: green check or red X directly on the commit. ## Setting Up Test Failure Notifications ### Step 1: Upload Test Results from CI ```yaml # GitHub Actions example - name: Run tests run: npm test - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./test-results ``` ### Step 2: Connect Slack In your Gaffer project settings, click "Connect to Slack" and authorize the integration. Select a channel and choose your notification trigger. ### Step 3: Configure Triggers Pick the trigger that matches your workflow: | Trigger | Best For | |---------|----------| | All runs | Small teams that want full visibility | | Failures only | Teams that only want to hear about problems | | Consecutive failures | Filtering out flaky one-off failures | That's it. Your next CI run will notify your team automatically. ## Comparing Test Notification Approaches | Approach | Setup | Maintenance | Test Detail | Filtering | |----------|-------|-------------|-------------|-----------| | Check CI manually | None | None | Full (if you find it) | None | | GitHub email notifications | Built-in | None | Minimal | Limited | | Custom Slack webhook script | High | High | Whatever you build | Whatever you build | | CI provider notifications | Built-in | Low | Job-level only | Limited | | Gaffer | Low | None | Full report link + summary | Branch, trigger type | ## Beyond Notifications Notifications are the entry point. Once you're uploading test results, you also get: - **[Hosted test reports](/solutions/html-test-reports/)**: shareable URLs for every run - **[Flaky test detection](/solutions/flaky-test-detection/)**: automatic identification of unreliable tests - **[Test analytics dashboard](/solutions/test-results-dashboard/)**: pass rate trends, duration tracking, failure patterns ## Get Started Gaffer's free tier includes Slack integration, webhook support, and GitHub commit status with 500 MB of storage and 7-day retention.
--- ## Test Report Storage Costs Getting Out of Control? Here's the Fix Source: https://gaffer.sh/solutions/test-report-storage-costs/ import { Image } from "astro:assets";

You set up an S3 bucket to store test reports. Smart move. Now you have historical data. Six months later, the storage bill is 10x what you expected. Sound familiar?

## The Hidden Cost of "Just Store It" When teams outgrow CI artifact storage, the natural solution is cloud storage: ```yaml - name: Upload to S3 run: aws s3 cp ./test-results s3://test-reports/${{ github.sha }} --recursive ``` Simple. Effective. And a ticking time bomb for your cloud bill. ### Why Storage Costs Compound Unlike compute (which stops billing when idle), storage bills accumulate: | Month | New Reports | Total Stored | Monthly Cost* | |-------|-------------|--------------|---------------| | 1 | 50 GB | 50 GB | $1.15 | | 3 | 50 GB | 150 GB | $3.45 | | 6 | 50 GB | 300 GB | $6.90 | | 12 | 50 GB | 600 GB | $13.80 | | 24 | 50 GB | 1.2 TB | $27.60 | *S3 Standard pricing at $0.023/GB That's assuming steady growth. Teams that scale up CI runs or add more test suites see exponential increases. ### The Playwright Problem E2E testing frameworks like Playwright make storage costs explode. A typical Playwright setup generates: - **Screenshots on failure**: 200KB–2MB each - **Video recordings**: 5–50MB per test - **Trace files**: 10–100MB per test run A team running 100 E2E tests across 3 browsers, 4 times per day: | Artifact Type | Size per Run | Daily | Monthly | |---------------|--------------|-------|---------| | HTML Report | 5 MB | 20 MB | 600 MB | | Screenshots | 50 MB | 200 MB | 6 GB | | Videos | 500 MB | 2 GB | 60 GB | | Traces | 200 MB | 800 MB | 24 GB | | **Total** | **755 MB** | **~3 GB** | **~90 GB** | That's 90 GB per month from *one* project. Most teams have multiple. And nobody wants to disable recordings: they're essential for debugging flaky tests. ## DIY Cleanup: Harder Than It Looks "We'll just write a cleanup script." Famous last words. ### Lifecycle Rules Look Simple ```xml 90 test-reports/ ``` ### But Reality Is Messy **Problem 1: One-size-fits-all doesn't work** Your main branch test reports are critical for debugging production issues. Feature branch reports are disposable after merge. S3 lifecycle rules can't distinguish between them without complex prefix schemes. **Problem 2: Accidental deletion** A misconfigured rule deleted 6 months of production test history. Now you're explaining to leadership why you can't investigate that customer-reported bug from Q2. **Problem 3: Cost visibility** Which project is eating all the storage? S3 doesn't tell you without additional tooling (CloudWatch, Cost Explorer tags, third-party analytics). **Problem 4: Access control** Who can view reports? S3 bucket policies are notoriously tricky. Teams end up either too permissive (security risk) or too restrictive (people can't access what they need). ## What Teams Actually Need After talking to dozens of engineering teams, the requirements are clear: 1. **Automatic cleanup** - Old reports deleted without manual intervention 2. **Per-project control** - Different retention for different projects 3. **Predictable costs** - Know what you'll pay before the bill arrives 4. **Easy access** - Browse and share reports without S3 permissions 5. **No maintenance** - No scripts to write, debug, or update ## How Gaffer Solves Storage Costs Gaffer is purpose-built for test report hosting. Storage management is built in, not bolted on. ### Automatic Cleanup by Default Every plan includes automatic cleanup based on your retention period: | Plan | Default Retention | Storage Included | |------|-------------------|------------------| | Free | 7 days | 500 MB | | Pro | 30 days | 10 GB | | Team | 90 days | 50 GB | Old reports are automatically deleted. No lifecycle rules to configure. No scripts to maintain. ### Per-Project Retention Not all projects are equal. Configure retention per-project: - **Feature branch tests**: 7 days (auto-cleanup after merge) - **Main branch tests**: 90 days (debug production issues) - **Compliance projects**: Unlimited (paid plans can disable cleanup) ```yaml # Your CI stays simple - name: Upload to Gaffer uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./test-results ``` Retention is configured in the Gaffer dashboard, not your CI pipeline. ### Predictable Pricing Go over your storage limit? Simple overage billing at $0.50/GB. No surprise bills, no complex pricing tiers. **Example scenario:** - Team plan: 50 GB included - Actual usage: 65 GB - Overage: 15 GB × $0.50 = $7.50 extra Compare that to debugging S3 cost spikes across multiple buckets and projects. ### Cost Visibility Built In See storage usage per project in your dashboard: - Which projects use the most storage? - How is usage trending over time? - Where should you enable more aggressive cleanup? No CloudWatch dashboards or Cost Explorer tags required. ## Migration Is Painless Already have test reports in S3? You don't need to migrate anything: 1. Add Gaffer upload to CI (5 minutes) 2. New reports go to Gaffer automatically 3. Old S3 reports stay where they are (or delete them to save costs) 4. Over time, Gaffer becomes your source of truth Your S3 bucket can run down naturally while Gaffer handles new reports. ## When to Keep S3 Gaffer isn't the right fit for everyone: - **Raw build artifacts** (binaries, packages) → Keep in S3/artifact storage - **Compliance archives** requiring specific certifications → Use compliant storage - **Existing tooling** that depends on S3 paths → Evaluate migration cost For test reports specifically (HTML reports, JUnit XML, JSON results), Gaffer is purpose-built and cost-optimized. ## The Bottom Line S3 is great for general-purpose storage. It's not optimized for test reports. | Concern | S3 DIY | Gaffer | |---------|--------|--------| | Automatic cleanup | Manual lifecycle rules | Built-in | | Per-project retention | Complex prefix schemes | Dashboard toggle | | Cost predictability | Varies with usage | Fixed + simple overage | | Access control | Bucket policies | Team-based permissions | | Setup time | Hours to days | 5 minutes | | Maintenance | Ongoing | Zero | ## Get Started Stop watching storage costs climb. Gaffer's automatic cleanup keeps your test report storage predictable, with per-project controls when you need them.
--- ## Test Reporting for QA Teams: Visibility Without Developer Tooling Source: https://gaffer.sh/solutions/test-reporting-qa-teams/ import { Image } from "astro:assets";

QA needs to see test results. But test results live in CI: behind GitHub logins, buried in workflow runs, locked in expiring artifacts. QA shouldn't need to learn developer tooling just to find out what failed.

## The QA Access Problem Automated test results typically live in CI: - **GitHub Actions** - Requires repo access, navigation through workflow runs - **Jenkins** - Another dashboard to learn, often requires VPN - **GitLab CI** - Mixed with pipelines, deployments, and other dev stuff QA engineers end up asking developers "can you send me the test results?" or learning CI tools they shouldn't need to know. And when CI artifacts expire (30-90 days), historical data disappears. Good luck comparing this week's test health to last month. ## What QA Teams Need ### Direct Report Access A URL that shows test results. No CI login, no navigating pipelines, no downloading zip files. Share it in Slack: ``` Here's the regression suite from this morning: https://app.gaffer.sh/reports/abc123 ``` Anyone with the link sees the results immediately. ### Historical View QA needs to answer questions like: - "Is this test suite getting more stable?" - "When did these tests start failing?" - "How often does this test actually pass?" This requires data across time, not just the latest run. ### Flaky Test Tracking Flaky tests are a QA nightmare. "It passed locally" vs "It failed in CI" wastes hours of back-and-forth. Knowing which tests are legitimately unreliable vs which failures are real bugs is essential. ### Non-Technical Interface QA shouldn't need to learn git, CI pipelines, or command-line tools to check test results. A web interface that shows pass/fail counts, trends, and failures is enough. ## How Gaffer Helps QA Teams ### Shareable Report URLs Every test run gets a URL in the Gaffer dashboard. QA accesses results directly — no CI login required. Share links in: - Slack channels - Bug tickets - Test management tools - Emails to stakeholders For people outside your organization (external QA consultants, clients, or partner teams), you can generate [public share links](/solutions/shareable-test-report-links/) with configurable expiration. Recipients view the full report in their browser without needing a Gaffer account. ### Dashboard View See all projects and recent test runs in one place: - Pass/fail counts - Trend direction (improving or degrading) - Recent failures No navigating through CI menus. Just a list of projects and their test health. ### Historical Trends Track test suite health over time: - **Pass rate trends** - Is quality improving? - **Failure patterns** - Which tests fail most often? - **Duration trends** - Is the suite getting slower? Gaffer retains data for up to 90 days (depending on plan), longer than most CI artifact retention. ### Flaky Test Reports Automatically identify tests with inconsistent results: - **Flip rate** - How often does this test change between pass and fail? - **Last seen** - When did the flaky behavior last occur? - **Run count** - How many executions are in the analysis? QA can take this list to developers: "These 5 tests are flaky. Can we fix or quarantine them?" ### Slack Notifications Get notified when tests fail: ``` [FAILED] regression-suite - main 3 tests failed, 142 passed View report: https://app.gaffer.sh/reports/xyz789 ``` QA sees failures as they happen, with direct links to investigate. ## QA + Dev Collaboration Test results become a shared artifact: 1. **CI runs tests** - Results upload to Gaffer automatically 2. **QA monitors** - Sees failures in Slack or dashboard 3. **QA investigates** - Opens report, reviews failures 4. **QA files bugs** - Includes report link for context 5. **Dev fixes** - Uses the same report link for debugging 6. **QA verifies** - Checks next test run No "can you send me the output?" conversations. Everyone looks at the same report. ## For QA Leads and Managers ### Test Health Reporting Need to report on test suite health to stakeholders? Gaffer provides: - Overall pass rates - Trend direction - Flaky test counts - Test count over time Export data or share dashboard views in status meetings. ### Coverage Across Projects If your team tests multiple projects, see them all in one dashboard. Compare health across projects, identify which need attention. ## Getting Started QA doesn't need to set up Gaffer - developers add one CI step and results start flowing. But QA should be involved in: 1. **Getting access** - Request a Gaffer account from whoever set it up 2. **Connecting Slack** - Ensure notifications go to the right channels 3. **Learning the dashboard** - It's simple, but a quick walkthrough helps Once set up, QA has direct access to test results without touching CI tools.
--- ## Test Reporting for Small Teams: Simple Setup, No DevOps Required Source: https://gaffer.sh/solutions/test-reporting-small-teams/ import { Image } from "astro:assets";

Small teams ship fast. You don't have dedicated DevOps. You don't have time to set up and maintain test infrastructure. You just need to see what failed in CI without digging through logs.

## The Small Team Testing Reality You're probably dealing with some combination of: - **Limited CI artifacts** - GitHub Actions gives you 90 days, then reports disappear - **No central dashboard** - Test results scattered across branches and workflow runs - **Slack is your command center** - That's where work happens - **Nobody has time for infrastructure** - You're writing features, not managing servers Enterprise test reporting tools want you to set up databases, deploy services, and manage infrastructure. That's not realistic when you have 3-10 engineers and a hundred things to ship. ## What Small Teams Actually Need ### Shareable Test Results When a test fails, you need to share it with the person who can fix it: - Not "go to GitHub, find the workflow, click through three menus, download artifacts" - Just a URL that shows the failure Need to share results with someone outside your org — a contractor or a stakeholder? Generate a [public share link](/solutions/shareable-test-report-links/) with an expiration you control. They see the results in their browser, no account needed. ### Slack Integration You're already in Slack. Test failures should show up there: ``` [FAILED] api-tests - main 2 tests failed, 47 passed View report: https://app.gaffer.sh/reports/abc123 ``` Click the link, see the failure, fix it. No context switching to CI dashboards. ### Flaky Test Detection Small teams can't afford to waste time re-running "that flaky test." You need to know which tests are unreliable so you can fix or quarantine them. ### Zero Infrastructure If it requires running a server, maintaining a database, or configuring Kubernetes, it's not for small teams. You need something that works with a single CI step. ## How Gaffer Works for Small Teams ### 5-Minute Setup 1. Sign up (free tier available) 2. Get an API key 3. Add one step to CI **GitHub Actions:** ```yaml - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./test-results ``` That's it. Test reports are now hosted and shareable. ### Connect Slack Enable Slack notifications and choose which branches trigger alerts. Most teams only want notifications for `main` - you don't need noise from every feature branch. ### Connect GitHub Enable commit statuses to see test results directly on PRs. No more "CI passed" that actually means "tests passed" - you see the test count right on the commit. ### Track Flaky Tests Gaffer automatically identifies tests that flip between pass and fail. The analytics page shows: - Which tests are most unreliable - How often they flip - Whether they're getting better or worse ## Pricing That Makes Sense Small teams don't have enterprise budgets. Gaffer pricing is flat-rate, not per seat: - **Free** - 500 MB storage, 7-day retention - **Pro** - $15/mo, 10 GB storage, 30-day retention - **Team** - $49/mo, 50 GB storage, 90-day retention All plans include unlimited users and unlimited projects. One price whether you have 3 engineers or 15. ## Real Small Team Workflow Here's how it typically works: 1. **Developer pushes code** - Tests run in CI 2. **Tests fail** - Slack gets a notification with a report link 3. **Developer clicks link** - Sees exactly what failed, with screenshots and stack traces 4. **Developer fixes it** - Or asks for help by sharing the same link 5. **Tests pass** - Commit status turns green No digging through CI logs. No downloading artifacts. No "can you check the test output?" conversations. ## What You Don't Need Gaffer skips features that small teams don't use: - No complex dashboard builder - No ML-powered failure analysis - No role-based access control tiers - No enterprise SSO requirements Just test reports, sharing, and analytics. The basics, done well. ## Try It Start with the free tier. One project, 7-day retention. Enough to see if it fits your workflow before paying anything.
--- ## Test Results Dashboard: One Place for Every CI Run Source: https://gaffer.sh/solutions/test-results-dashboard/ import ScreenshotLink from "../components/ScreenshotLink.astro"; import dashboard from "../assets/screenshots/project-overview.png"; import durationTrends from "../assets/screenshots/duration-trends-chart.png";

Your tests finished 10 minutes ago. You click into GitHub Actions, find the workflow run, expand the test step, scroll through 500 lines of output looking for the failure. By the time you find it, you've forgotten why you were looking. Sound familiar?

## Where CI Test Results Hide Every CI platform buries test results differently, but they all make you work for it: - **GitHub Actions** - Workflow run → Job → Step → Expand logs → Scroll - **GitLab CI** - Pipeline → Job → Scroll through output - **Jenkins** - Build → Console Output → Search for "FAILED" - **CircleCI** - Workflow → Job → Steps → Find the test step Even when you find the results, you're looking at raw terminal output. Pass counts mixed with stack traces mixed with timing info. Good luck sharing that with your PM. ## The Real Pain Points ### No Single View Test results are scattered across CI runs, branches, and PRs. Want to know the overall health of your test suite? You'd need to manually check multiple workflow runs and piece it together. ### Context Switching Every failed test means: leave your IDE → open CI → find the right run → expand the right step → scroll to the failure. Multiply by 5 failures and 10 minutes are gone. ### No Historical Context Was this test flaky last week? Has the pass rate been declining? CI logs don't tell you. Each run exists in isolation with no connection to the past. ### Team Visibility When QA asks "did the tests pass?" you have to go check. When the PM wants to see test coverage for a feature, you export a CSV. When a colleague debugs the same flaky test you fixed last month, there's no shared knowledge. ## What Teams Actually Need A test results dashboard should answer these questions instantly: | Question | Dashboard Answer | |----------|------------------| | Did the tests pass? | Green checkmark or red X, visible in 2 seconds | | Which tests failed? | Click to see the specific failures | | Is this test flaky? | Historical pass rate shows the pattern | | Is the suite healthy? | Trend line shows improvement or decline | | Can I share this with QA? | Send a link, no CI access required | ## CI Logs vs. Test Dashboard | Aspect | CI Logs | Test Dashboard | |--------|---------|----------------| | Time to find failure | 2-5 minutes | 10 seconds | | Historical data | None | Full run history | | Shareable | Requires CI access | Simple link | | Cross-framework view | Separate per tool | Unified | | Flaky test detection | Manual observation | Automatic | | Team access | Developer-only | Anyone with link | ## How Gaffer Works as Your Test Dashboard Gaffer collects test results from your CI pipeline and presents them in a clean, searchable dashboard. ### Automatic Upload from CI Add one step to your workflow. Results appear in the dashboard immediately. **GitHub Actions:** ```yaml - name: Run tests run: npm test - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./test-results ``` **GitLab CI:** ```yaml test: script: - npm test after_script: - curl -X POST https://api.gaffer.sh/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@test-results/results.json" ``` ### Dashboard Features **Project Overview** - Pass rate at a glance - Recent test runs with status - Trend over time **Test Run Details** - Every run listed with status, duration, commit SHA - Filter by branch, date range, pass/fail - Click into any run to see individual test results **Failure Investigation** - See exactly which tests failed - Error messages and stack traces - Link directly to the failing test ### Works With Any Framework Gaffer accepts results from: - **JavaScript/TypeScript**: Jest, Vitest, Playwright, Cypress - **Python**: pytest (with JUnit XML output) - **Java**: JUnit, TestNG - **Any framework** that outputs JUnit XML or JSON ## Beyond Visibility: Analytics Once you have historical test data, you unlock insights that CI logs can't provide: ### Flaky Test Detection Gaffer tracks pass/fail rates across runs. Tests that fail intermittently (10-90% failure rate) are flagged as flaky. No more "I think that test is flaky" - you have data. ### Duration Trends Are your tests getting slower? The analytics dashboard shows duration trends so you can catch performance regressions early. ### Filter and Search Quickly find what you need: - Filter by branch, commit, or date range - See which tests fail most frequently - Track test duration trends over time ## Getting Started Gaffer's free tier includes 500 MB storage with 7-day retention - enough to try it on a real project. Paid plans offer up to 90-day retention and advanced analytics.
--- ## Gaffer vs TestDino: Two Bets on Test Reporting Source: https://gaffer.sh/solutions/testdino-vs-gaffer/ import JsonLd from '../components/JsonLd.astro';

Gaffer and TestDino both host and analyze CI test reports, and they bet on different things. [TestDino](https://testdino.com/) goes deep on Playwright: inline trace, DOM, and network debugging, a built-in test-case management layer, and an agent-writable MCP server. Gaffer stays framework-agnostic and prices on storage instead of executions or seats. The clearest split is the bill. TestDino's tiers gate on a monthly execution quota and a hard user cap (1 user on Free, 3 on Pro, 20 on Team), while Gaffer bills on stored bytes with unlimited users on every tier. A 25-person team running roughly 100,000 Playwright results a month clears TestDino's Team tier on two axes at once, the 75,000-result quota and the 20-seat cap, while the same workload sits inside Gaffer's $49/month Team plan, which never caps users.

## What Gaffer and TestDino Actually Are TestDino is a Playwright-native test reporting and analytics platform. It ingests Playwright output and adds inline debugging (trace, DOM snapshots, network logs), root-cause failure categorization, test-case management, and an MCP server that coding agents can both read from and write to. The full feature reference is in [TestDino's docs](https://docs.testdino.com/). Gaffer hosts and analyzes test reports from many frameworks. It ingests JUnit XML, CTRF, Jest and Vitest JSON, native Playwright JSON, and TRX/.NET, plus four coverage formats, then runs [flaky detection](/solutions/flaky-test-detection/), pass-rate trends, and [failure clustering](/solutions/failure-clustering/) on every upload. It is a reporting and analytics surface, not a test-management tool. ## Where TestDino Is Stronger If your stack is Playwright and you want depth over breadth, TestDino has real advantages Gaffer does not match: - **Playwright-native inline debugging:** trace, DOM snapshots, and network logs attached to each failure, viewable without leaving the report. - **Write-capable MCP:** its public MCP server (`testdino-hq/testdino-mcp`) exposes 33 tools, including mutations like `create_manual_test_case` and `create_release`, so a coding agent can manage test artifacts directly. - **Built-in test-case management:** suites, cycles, and imports from TestRail or CSV. - **Root-cause failure grouping:** failures are grouped by error message and a root-cause category (timing, environment, network, assertion), on top of message matching. - **Failed-only reruns and flakiness merge-gates:** rerun only the failures, and block merges on a flaky signal. - **Published compliance posture:** TestDino publicly claims SOC 2 Type II and ISO 27001 certification and GDPR compliance on its security page. Gaffer claims no certifications, which is a real gap for teams with a security-review checklist. ## Where Gaffer Is Stronger Gaffer's advantages are pricing shape and framework breadth, plus a smaller surface to adopt. - **Flat, storage-based pricing with unlimited users:** no per-tier execution quota and no seat cap. Everyone on the team can have an account on any tier, including Free. - **Multi-framework reporting:** one dashboard for JUnit XML, CTRF, Jest, Vitest, native Playwright JSON, and TRX/.NET, which transitively covers pytest, Go, Cypress, and anything that emits JUnit XML or CTRF. See [multiple test frameworks](/solutions/multiple-test-frameworks/). - **[Coverage tracking](/solutions/code-coverage/) alongside test results:** four coverage formats parsed and trended in the same view as pass rate. - **No test-management layer to adopt:** Gaffer hosts reports and runs analytics. There is nothing to configure beyond a CI upload step. ## Pricing: Execution Quotas vs Flat Storage The two products meter different things. TestDino charges a flat monthly subscription but gates each tier on a monthly execution quota and a hard user cap. Gaffer charges a flat monthly subscription that meters only storage, and never caps users. TestDino counts each test-case run as one result: a single test run across three browsers counts as three results, and each retry counts again. Cross-browser matrices and retries consume the quota quickly. Gaffer counts nothing per-execution. It bills on stored bytes, with $0.50/GB/month overage on Pro and Team, and puts no cap on users on any tier including Free. Which is cheaper depends on the shape of your workload. A small Playwright-only team may fit comfortably inside TestDino's Free or Pro tier. A CI-heavy team, or one past 20 people, usually pays less on Gaffer, because neither execution volume nor headcount moves the bill. The above-quota overage price on TestDino is not published, so a team near its quota should confirm it before scaling up. ## Pricing Comparison | Tier | TestDino | Gaffer | |------|----------|--------| | Free | $0, 5,000 results/mo, 1 user, 1 project | $0, 500 MB, 7-day retention, unlimited users | | Entry paid | $39/mo billed annually ($49/mo monthly), 25,000 results/mo, 3 users, 3 projects | $15/month, 10 GB, 30-day retention, unlimited users | | Team | $79/mo (annual-effective), 75,000 results/mo, 20 users, 5 projects | $49/month, 50 GB, 90-day retention, unlimited users | | Overage | Above-quota price not published | $0.50/GB/month (Pro and Team) | | Enterprise | Custom | Not offered | | Billing basis | Execution quota + user cap per tier | Storage, unlimited users | A "result" on TestDino is one test-case run, so cross-browser runs and retries multiply against the quota. Gaffer's bill moves only with stored bytes. ## Feature Comparison | Feature | TestDino | Gaffer | |---------|----------|--------| | Test report hosting | Yes | Yes | | Framework scope | Playwright-native | Multi-framework (JUnit XML, CTRF, Jest, Vitest, Playwright JSON, TRX/.NET; transitively pytest, Go, Cypress) | | Inline trace / DOM / network debugging | Yes (Playwright-native) | No | | Historical trends | Yes | Yes | | Flaky test detection | Yes | Yes | | Failure grouping | Error message + root-cause category | Error-message similarity + file-path fallback | | Test-case management (suites, cycles, imports) | Yes | No (by design) | | Failed-only reruns / flaky merge-gates | Yes | No | | MCP server | Yes, write-capable (33 tools) | Yes, read-only (16 GET functions) | | Coverage tracking | Not advertised | Yes (4 formats) | | Published compliance posture | SOC 2 Type II and ISO 27001 claimed; GDPR compliance stated | None | | User pricing | Capped per tier (1 / 3 / 20) | Unlimited users, all tiers | Both MCP servers are real. TestDino's is write-capable so an agent can create test cases and releases. Gaffer's is read-only code-mode analytics (16 GET functions, zero mutations), a deliberate choice that matches Gaffer not being a test-management tool: query your results, do not manage test cases from the editor. ## When to Use TestDino TestDino is the better fit if: - Your suite is Playwright and you want inline trace, DOM, and network debugging on every failure. - You want a coding agent that can write test artifacts (manual test cases, releases) through MCP, beyond reading metrics. - You need built-in test-case management: suites, cycles, and imports from TestRail or CSV. - Failed-only reruns and flakiness merge-gates are part of your CI workflow. - Your security review needs a vendor that publishes SOC 2 Type II and ISO 27001 claims. - Your team is small enough to sit inside the 1 / 3 / 20 user caps and the per-tier result quota. ## When to Use Gaffer Teams shopping for a TestDino alternative usually hit one of two walls first: the seat cap or the execution quota. Gaffer is the better fit if: - You run more than one test framework and want them in a single dashboard. - Your team is at or past 20 people, or growing, and a seat cap is the binding constraint. - Your CI produces high result volume (cross-browser matrices, frequent retries) and you would rather not track it against a quota. - You want coverage tracking trended next to pass rate for the same upload. - You want a plain hosted-report and analytics surface with no test-management layer to adopt. - You want read-only MCP analytics for agentic CI without giving an agent write access to your test data. ## Using Both You can run both without a migration. Standard report formats are portable, so a common split is to upload Playwright output to TestDino for deep, inline debugging and send JUnit XML or CTRF to Gaffer for multi-framework hosting and analytics. Neither side requires a big-bang cutover, and JUnit XML and CTRF are first-class inputs on Gaffer, so adding a second upload step in CI is the whole cost. ## FAQ ### Is Gaffer or TestDino cheaper? It depends on the shape of your workload. TestDino gates each tier on a monthly execution quota and a hard user cap (1 user on Free, 3 on Pro, 20 on Team), while Gaffer bills on storage with unlimited users. A CI-heavy team, or one larger than 20 people, usually pays less on Gaffer, because neither execution volume nor headcount changes the bill. A small Playwright-only team may fit inside TestDino's free or Pro tier. ### Does Gaffer support Playwright? Yes. Gaffer ingests native Playwright JSON, and also Jest, Vitest, pytest, Go, Cypress, and .NET through JUnit XML, CTRF, and native JSON formats, plus coverage formats. TestDino is Playwright-native and does not claim other frameworks. ### Does Gaffer have test-case management? No, by design. Gaffer hosts and analyzes test results; it is not a test-management tool and has no suites, cycles, or manual test plans. TestDino includes test-case management with suites, cycles, and imports from TestRail or CSV. ### Is TestDino only for Playwright? TestDino is Playwright-native and does not claim support for other frameworks. Its inline trace, DOM, and network debugging are built on Playwright's own artifacts. If you run multiple frameworks and want them in one dashboard, Gaffer's multi-framework ingestion is the better fit. ### Can I use both Gaffer and TestDino? Yes. Standard report formats are portable, so a team can upload Playwright output to TestDino for deep debugging and send JUnit XML or CTRF to Gaffer for multi-framework hosting and analytics. There is no big-bang migration required in either direction. ## Other Comparisons Evaluating more tools? See how Gaffer compares to [Currents.dev](/solutions/currents-dev-alternative/), [Allure](/solutions/allure-alternative/), and [ReportPortal](/solutions/reportportal-alternative/). ## Try It Gaffer's free tier includes 500 MB storage, 7-day retention, and unlimited users. Enough to point your CI at it for a week and decide.
--- ## How to Share Vitest Test Results with Your Team Source: https://gaffer.sh/solutions/vitest-test-reporting/ import { Image } from "astro:assets";

Vitest's HTML reporter is excellent: interactive, fast, and genuinely useful for debugging. But it generates local files. When your teammate asks "what failed?", you can't just send them a link. You end up screenshotting terminal output or walking them through downloading CI artifacts.

## The Sharing Problem After `vitest run`, your HTML report lands in a local directory. To share it with your team, you need to get that file to them somehow. Here's what most teams do: ### Common (Bad) Solutions **1. CI artifacts** - GitHub Actions keeps artifacts for 90 days max, other CI systems are shorter - Finding the right artifact means clicking through workflow runs - Recipient downloads a zip, extracts it, opens `index.html` locally - QA might not even have CI access **2. Email or Slack the file** - "Here's the test report" with a zip attachment - Recipient has to download, extract, open in browser - Version confusion when multiple runs are in flight **3. Upload to S3 manually** - Works, but someone has to set up the bucket, permissions, and cleanup - Manual step every time, so it gets skipped when things are busy **4. Paste terminal output** - Loses all the interactive HTML goodness - Hard to parse in a Slack thread - Can't reference it later ## Better: Hosted Vitest Reports with Shareable Links The fix is straightforward: upload your Vitest reports to a hosting service after every CI run. Every test run gets a permanent URL that anyone on your team can open. ### What This Looks Like 1. CI runs your Vitest tests 2. Reports upload automatically 3. You get a URL like `https://app.gaffer.sh/reports/abc123` 4. Share it in Slack, GitHub PRs, Jira, or anywhere else 5. Anyone with access sees the full interactive report No downloads. No manual steps. No expiring artifacts. ## Setting Up Vitest Report Sharing with Gaffer ### Step 1: Configure Vitest Reporters Make sure you're generating output Gaffer can parse. CTRF gives the richest analytics: ```bash npm install vitest-ctrf-json-reporter --save-dev ``` ```typescript // vitest.config.ts import { defineConfig } from 'vitest/config' export default defineConfig({ test: { reporters: [ 'default', ['vitest-ctrf-json-reporter', { outputDir: '.', outputFile: 'ctrf-report.json' }], ], } }) ``` If the reporter fails with `ENOENT`, see [why jest-ctrf-json-reporter throws ENOENT](/docs/guides/ctrf/#why-does-jest-ctrf-json-reporter-throw-enoent). Gaffer also accepts Vitest's built-in HTML and JSON reporters if you prefer those; the [Vitest JSON reporter output format](/blog/vitest-json-reporter-output-format/) is documented field by field. If you also want to [set up a Vitest coverage report and track it across runs](/blog/vitest-coverage-reports/), that's a separate upload step covered in the coverage guide. ### Step 2: Add the Upload Step to CI **GitHub Actions:** ```yaml - name: Run tests run: npm test - name: Upload to Gaffer if: always() uses: gaffer-sh/gaffer-uploader@v2 with: gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }} report_path: ./ctrf-report.json commit_sha: ${{ github.sha }} branch: ${{ github.ref_name }} ``` **GitLab CI:** ```yaml test: script: - npm ci - npm test after_script: - | curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@ctrf-report.json" \ -F 'tags={"commitSha":"'"$CI_COMMIT_SHA"'","branch":"'"$CI_COMMIT_REF_NAME"'"}' ``` ### Step 3: Share the Link After upload, Gaffer returns a URL. Share it anywhere: Slack, GitHub PR comments, Jira tickets. Recipients see the full report in their browser. For people outside your organization, you can generate a [public share link](/solutions/shareable-test-report-links/) with configurable expiration. They view the full interactive report without needing a Gaffer account. ## Slack and Webhook Notifications Instead of checking CI manually, get notified when tests finish: **Slack integration:** - Pass/fail summary posted to your channel - Direct link to the full report - Filter by branch: only notify on `main`, skip feature branches **Webhooks:** - Send test results to any endpoint - Integrate with your existing alerting or workflow tools - Trigger custom actions on failure No more "did the tests pass?" messages. Your team sees failures as they happen, with a link to investigate. ## GitHub Integration Commit statuses show test results directly on PRs. No clicking through to CI logs. You see pass/fail right on the commit. ## One Dashboard for Every Suite Most JavaScript teams run Vitest for unit tests and Playwright or Cypress for end-to-end. Gaffer aggregates [multiple test frameworks](/solutions/multiple-test-frameworks/) into one dashboard, so unit and e2e results land in the same place instead of separate report folders. ## What About Flaky Tests? Vitest's `--retry` flag handles flaky tests within a single run. Gaffer tracks flakiness *across* runs, identifying tests that sometimes pass and sometimes fail over time. Different problem, complementary solution. ## Get Started Gaffer's free tier includes 500 MB of storage with 7-day retention. Paid plans offer extended retention up to 90 days.
# Blog - [Your AI Agent's Missing Layer: Test Intelligence](https://gaffer.sh/blog/agentic-ci-test-intelligence/) - [Automated Regression Testing: How to Do It and Track It](https://gaffer.sh/blog/automated-regression-testing/) - [Best Test Automation Reporting Tools in 2026 (Compared)](https://gaffer.sh/blog/best-test-automation-reporting-tools/) - [Cypress Reports: Setup Guide for Mochawesome, JUnit, and Allure](https://gaffer.sh/blog/cypress-reports-guide/) - [Why `gaffer affected-tests` Returned Empty for Every E2E-Touching Edit I Made](https://gaffer.sh/blog/dogfooding-affected-tests-e2e-gap/) - [We Built Coverage Analytics. Then We Used Them On Ourselves.](https://gaffer.sh/blog/dogfooding-coverage-mcp-tools/) - [Dogfooding Gaffer's MCP Server to Fix Slow Playwright Tests](https://gaffer.sh/blog/dogfooding-mcp-playwright-optimization/) - [GitHub Agentic Workflows: Automated Test Reviews with MCP](https://gaffer.sh/blog/github-agentic-workflows-test-analytics/) - [Give Your AI Coding Tools Access to Your Test Results](https://gaffer.sh/blog/give-ai-tools-test-results/) - [Health Score Alerts: Know Before Your Test Suite Degrades](https://gaffer.sh/blog/health-score-alerts/) - [How Much Are Flaky Tests Costing You?](https://gaffer.sh/blog/how-much-are-flaky-tests-costing-you/) - [How to Manage Flaky E2E Tests at Scale](https://gaffer.sh/blog/how-to-manage-flaky-e2e-tests/) - [JUnit XML Format Explained: Schema, Examples, CI Integration](https://gaffer.sh/blog/junit-xml-format-guide/) - [OpenTelemetry for Test Metrics: Export to Any Stack](https://gaffer.sh/blog/opentelemetry-test-metrics/) - [Playwright MCP with Claude Code: Setup & CI Guide](https://gaffer.sh/blog/playwright-mcp-claude-code-setup/) - [Playwright MCP + Claude Code: A Complete Test Loop](https://gaffer.sh/blog/playwright-mcp-claude-code-test-loop/) - [Playwright MCP + Cursor: Complete Setup and Testing Workflow](https://gaffer.sh/blog/playwright-mcp-cursor-setup/) - [Playwright Pricing in 2026: Free vs Paid Compared](https://gaffer.sh/blog/playwright-pricing/) - [Playwright Reports: HTML, JSON, JUnit & Sharing in CI](https://gaffer.sh/blog/playwright-reports-guide/) - [Playwright Test Agents: Planner, Generator & Healer](https://gaffer.sh/blog/playwright-test-agents-guide/) - [Test Failures: Why the Failure Count Isn't the Bug Count](https://gaffer.sh/blog/test-failures-types-causes-fixes/) - [Test Intelligence: The Missing Context for AI Coding Tools](https://gaffer.sh/blog/test-intelligence-for-ai-tools/) - [Test Reporting: What to Include and How to Automate It](https://gaffer.sh/blog/test-reporting-guide/) - [Vitest Coverage Reports: CI Setup and Team Visibility](https://gaffer.sh/blog/vitest-coverage-reports/) - [Vitest JSON Reporter Output Format: Schema and Examples](https://gaffer.sh/blog/vitest-json-reporter-output-format/) - [We Put WebMCP Tools on gaffer.sh. No Agent Calls Them Yet.](https://gaffer.sh/blog/webmcp-agent-callable-marketing-site/) - [What Are Test Artifacts in Software Testing? 5 Types](https://gaffer.sh/blog/what-are-test-artifacts/)