Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9e5d978
feat: advisory AI triage with pluggable backends + HTML annotations
pftg Oct 5, 2026
525605f
fix: address review — lint, offline prefetch, render-time AI lookup, …
pftg Oct 5, 2026
296c833
test: fix memberless Struct stub and nil-key leak in AI annotation
pftg Oct 5, 2026
285a150
docs+polish: red-team fixes — real typesafe-sdk Jev recipe, README/CH…
pftg Oct 5, 2026
761b32d
test: HTML report renders clean when AI is not enabled
pftg Oct 5, 2026
a649ea9
feat: rename Ai->AI, AI verdict in failure messages, optional fail_on…
pftg Oct 5, 2026
d901ac4
ci: retrigger lint
pftg Oct 5, 2026
775092d
refactor: single store (drop ai_report.json), factory-failure degrada…
pftg Oct 5, 2026
0463ae2
test: end-to-end AI triage runs in a fresh process with a fake backend
pftg Oct 5, 2026
1490f91
fix: gate safety — per-comparison memoization, verdict allowlist
pftg Oct 5, 2026
c9f71f7
fix: fail-closed edge cases — non-finite similarity, input-scoped memo
pftg Oct 5, 2026
2ab0c95
merge master: ci-gate job
pftg Oct 5, 2026
dde4878
ci: expensive cells only where they pay — JRuby weekly/dispatch, no M…
pftg Oct 5, 2026
bc5b63d
ci: retrigger — runner pickup flaked, jobs cancelled with zero steps
pftg Oct 5, 2026
ce400dd
ci: retrigger after GitHub Actions incident recovery (githubstatus 19…
pftg Oct 5, 2026
79a8295
ci: retrigger — functional-test flake check (code identical to 17:37 …
pftg Oct 5, 2026
976550b
test: run AI integration tests only when the AI surface changes
pftg Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 51 additions & 18 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# Full history: AI integration tests skip themselves unless the AI
# surface changed vs origin/master (no merge-base in a shallow clone).
- uses: actions/checkout@v7
with:
fetch-depth: 0

- uses: ./.github/actions/setup-ruby-and-dependencies
with:
Expand All @@ -71,6 +75,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v7
# Full history: AI integration tests skip themselves unless the AI
# surface changed vs origin/master (no merge-base in a shallow clone).
with:
fetch-depth: 0

- uses: ./.github/actions/setup-ruby-and-dependencies
with:
Expand Down Expand Up @@ -107,10 +115,9 @@ jobs:
contains(github.event.pull_request.labels.*.name, 'full-ci')
needs: [ functional-test ]
runs-on: ubuntu-latest
# Must fit `max_attempts * timeout_minutes` below, plus ~1 min of setup,
# or the last attempt gets killed mid-run and the cell reports `cancelled`
# -- a dead gate. JRuby: 1 + 20 + 20 = 41. MRI: 1 + 4 + 4 = 9.
timeout-minutes: ${{ contains(matrix.ruby-version, 'jruby') && 41 || 9 }}
# ~1 min of setup plus one 4-min attempt; re-measure if the suite grows
# into it -- a killed attempt reports `cancelled`, a dead gate.
timeout-minutes: 6
continue-on-error: ${{ matrix.experimental }}
strategy:
matrix:
Expand All @@ -131,17 +138,47 @@ jobs:
- ruby-version: "4.0"
gemfile: edge_gems.rb
experimental: true
# One cell: JRuby's differences (no Kernel#fork, thread semantics,
# the FFI/vips path) are JVM-level, not Rails-level.
- ruby-version: jruby-10.1
gemfile: rails81_gems.rb
experimental: false

env:
BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}
# JRuby is the only place ruby-vips' FFI path runs on a non-MRI engine.
# Test Drivers covers both backends on CRuby.
SCREENSHOT_DRIVER: ${{ contains(matrix.ruby-version, 'jruby') && 'vips' || 'chunky_png' }}
SCREENSHOT_DRIVER: chunky_png

steps:
- uses: actions/checkout@v7

- uses: ./.github/actions/setup-ruby-and-dependencies
with:
ruby-version: ${{ matrix.ruby-version }}
ruby-cache-version: ${{ matrix.ruby-version }}-${{ matrix.gemfile }}-1
cache-apt-packages: true

# Measured, not chosen: slowest MRI attempt is ~146s. The suite has
# timed out before when it grew into this -- re-measure then.
- run: bin/rake test
timeout-minutes: 4

matrix-jruby:
name: Test JRuby
# The single most expensive cell (~12 min x up to 2 attempts): JRuby's
# differences (no Kernel#fork, thread semantics, FFI/vips) are JVM-level,
# so the weekly drift check covers them. Not on every master push.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
needs: [ functional-test ]
runs-on: ubuntu-latest
# 1 min setup + 20 + 20: the retry below exists only for the
# intermittent JRuby teardown hang (#244).
timeout-minutes: 41
strategy:
matrix:
ruby-version: [ jruby-10.1 ]
gemfile: [ rails81_gems.rb ]

env:
BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}
# The one place ruby-vips' FFI path runs on a non-MRI engine.
SCREENSHOT_DRIVER: vips

steps:
- uses: actions/checkout@v7
Expand All @@ -152,15 +189,10 @@ jobs:
ruby-cache-version: ${{ matrix.ruby-version }}-${{ matrix.gemfile }}-1
cache-apt-packages: true

- name: Run tests (with 1 retry)
- name: Run tests (with 1 retry for the #244 teardown hang)
uses: nick-fields/retry@v4
with:
# Measured, not chosen: slowest attempt is ~880s JRuby, ~146s MRI.
# Both have timed out before when the suite grew into them -- when
# that happens, re-measure and raise this AND timeout-minutes above.
timeout_minutes: ${{ contains(matrix.ruby-version, 'jruby') && 20 || 4 }}
# Exists only for the intermittent JRuby teardown hang (#244).
# Once that is fixed, drop to 1 and halve the JRuby bill.
timeout_minutes: 20
max_attempts: 2
command: bin/rake test

Expand Down Expand Up @@ -213,8 +245,9 @@ jobs:
# gemfile, so a rule listing them stops requiring anything the moment the
# matrix changes shape. `skipped` passes -- the matrix is off on PRs.
if: always()
needs: [ test-minimal-setup, functional-test, matrix, matrix-screenshot-driver ]
needs: [ test-minimal-setup, functional-test, matrix, matrix-jruby, matrix-screenshot-driver ]
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ green while comparing nothing**, and all four are fixed here — that is the rea
The sections below the divider are the prerelease notes (alpha1 → beta3) and are
kept as history. This entry is the one to read if you are coming from **1.15.1**.

### Added

- **AI triage (optional, offline-first, advisory by default).** `SnapDiff::Reporters::AISimple`
classifies every failed comparison as `flaky` / `intentional` / `real_bug`,
logs one line per diff, quotes the verdict in the failure message, and
annotates the HTML report with a verdict badge and summary — all via one
shared in-memory store (`SnapDiff::AI`), no files written. With
`fail_on: %w[real_bug]` the verdict gates pass/fail:
flaky/intentional diffs stay green, `unknown` always fails. Default backend
is offline CLIP via the `informers` gem; custom backends (local VLM,
decision APIs) register by name via `SnapDiff::AI.register`. Zero new hard
dependencies. See `docs/ai.md`.

### Upgrading from 1.15.1: change the version, run your suite

```ruby
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,28 @@ After tests run, open `doc/screenshots/snap_diff_report.html`:

See [Web UI & Custom Reporters](docs/reporters.md) for full feature details and [CI Integration](docs/ci-integration.md) for GitHub Actions setup.

## AI-Assisted Triage (optional)

AI tells you *which* failures to look at first. One line enables offline
CLIP triage — every failed comparison is classified as `flaky` /
`intentional` / `real_bug`, logged per diff, quoted in the failure message,
and badged in the HTML report (all via one shared in-memory store — no
files). Advisory by
default; pass `fail_on: %w[real_bug]` to keep the suite green on flaky and
intentional diffs and fail only on what AI calls a real bug:

```ruby
# Gemfile
gem "informers" # offline CLIP via ONNX, no API key

# test/test_helper.rb
require "snap_diff/reporters/ai_simple"
SnapDiff::Reporting.register(SnapDiff::Reporters::AISimple.new)
```

Plug in your own backend (a local VLM, a decision API) with
`SnapDiff::AI.register(:name) { ... }` — see [AI triage](docs/ai.md).

## Compare Any Two Images

Works without a browser — PDFs, generated images, CI artifacts:
Expand Down Expand Up @@ -328,6 +350,7 @@ instead of failing quietly — useful when `snap_diff_report.html` is missing en
- [Image Processing Drivers](docs/drivers.md) — VIPS, ChunkyPNG, perceptual threshold
- [Screenshot Organization](docs/organization.md) — groups, sections, cropping, multi-browser
- [Web UI & Custom Reporters](docs/reporters.md) — interactive report, custom reporters
- [AI-Assisted Triage](docs/ai.md) — optional flaky/intentional/real_bug classification, pluggable backends

## Development

Expand Down
Loading
Loading