twinshot 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/PLAN.md +107 -0
- data/README.md +179 -0
- data/Rakefile +12 -0
- data/exe/twinshot +6 -0
- data/lib/twinshot/adapters/binary.rb +21 -0
- data/lib/twinshot/adapters/html.rb +27 -0
- data/lib/twinshot/adapters/png.rb +128 -0
- data/lib/twinshot/adapters/txt.rb +27 -0
- data/lib/twinshot/adapters/xml.rb +28 -0
- data/lib/twinshot/adapters.rb +105 -0
- data/lib/twinshot/cli.rb +503 -0
- data/lib/twinshot/compare.rb +249 -0
- data/lib/twinshot/compare_worker.rb +47 -0
- data/lib/twinshot/config.rb +212 -0
- data/lib/twinshot/logo.svg +8 -0
- data/lib/twinshot/opener/darwin.rb +18 -0
- data/lib/twinshot/opener/linux.rb +19 -0
- data/lib/twinshot/opener/windows.rb +20 -0
- data/lib/twinshot/opener.rb +59 -0
- data/lib/twinshot/progress.rb +104 -0
- data/lib/twinshot/report.rb +600 -0
- data/lib/twinshot/soft_check.rb +19 -0
- data/lib/twinshot/tree.rb +31 -0
- data/lib/twinshot/version.rb +5 -0
- data/lib/twinshot.rb +18 -0
- data/logo.svg +100 -0
- data/sig/twinshot.rbs +4 -0
- metadata +130 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 61129562a7595873fe7d577f08d2d98b5a7d608a4c021c1b76eb3b66533deca8
|
|
4
|
+
data.tar.gz: 1c78a3f84ad0edcffe46144084a959d7db5eadd0886baa9d348f72d8d89fb2d7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f71390ff6a0309a3d7f2008cb44cb96a902494275bbfc0993f0f3ff2cc492cf3f75fddffb6eecaf7b11fdc6ea23a892a892ed0324a364d2bd2f412bc96a21d07
|
|
7
|
+
data.tar.gz: 3984e3f511e04341b7ddaaf3089c9281c415988fa73590a248ca2d12a38ef080ffd934439b0a3d63a835eadb8965851833c379aeba88c77c5d9aaa4bc9e8c4ce
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oleg Orlov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/PLAN.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Twinshot — план реализации
|
|
2
|
+
|
|
3
|
+
Гем для 1:1-парити двух деревьев артефактов: оракул (verified) vs текущее
|
|
4
|
+
состояние. Структура папок свободная, но должна совпасть. Любой файл без
|
|
5
|
+
адаптера — фейл. Отчёт — самодостаточный HTML с 1:1-галереей (как текущий
|
|
6
|
+
`artifacts/compare/report.html` в capybara-tests) + поиск, фильтры, режимы
|
|
7
|
+
сравнения картинок. В хорошем случае команды генерации не нужны — compare
|
|
8
|
+
берёт две готовые папки.
|
|
9
|
+
|
|
10
|
+
## Конфиг (`twinshot.yml`, минимальный)
|
|
11
|
+
|
|
12
|
+
```yaml
|
|
13
|
+
oracle:
|
|
14
|
+
path: artifacts/rails
|
|
15
|
+
generate: bin/record # опционально
|
|
16
|
+
current:
|
|
17
|
+
path: artifacts/crails
|
|
18
|
+
generate: bin/record --crails # опционально
|
|
19
|
+
png:
|
|
20
|
+
max_diff_ratio: 0.002 # опционально, дефолт 0.002
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Отчёт всегда пишется в `<dirname(config)>/twinshot-report/`
|
|
24
|
+
(`report_for_human.html` + `report_for_agent.md` + `summary.json` +
|
|
25
|
+
`diffs/*`). Не настраивается.
|
|
26
|
+
|
|
27
|
+
## Структура кода
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
lib/twinshot.rb — точка входа, requires
|
|
31
|
+
lib/twinshot/cli.rb — OptionParser: help / generate / compare
|
|
32
|
+
lib/twinshot/config.rb — загрузка + валидация twinshot.yml
|
|
33
|
+
lib/twinshot/tree.rb — скан дерева, сверка структуры
|
|
34
|
+
lib/twinshot/adapters.rb — реестр адаптеров по расширению
|
|
35
|
+
lib/twinshot/adapters/binary.rb — sha256, побайтово
|
|
36
|
+
lib/twinshot/adapters/txt.rb — построчный diff (diff-lcs)
|
|
37
|
+
lib/twinshot/adapters/html.rb — по структуре: nokogiri-канонизация → построчный diff
|
|
38
|
+
lib/twinshot/adapters/png.rb — байты fast-path → попиксельно (chunky_png) + diff-картинка
|
|
39
|
+
lib/twinshot/compare.rb — оркестратор: структура → файлы в пуле форк-воркеров
|
|
40
|
+
lib/twinshot/report.rb — HTML-отчёт (инлайн CSS/JS, без зависимостей)
|
|
41
|
+
exe/twinshot
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Правила сравнения
|
|
45
|
+
|
|
46
|
+
1. **Структура**: дерево файлов `current` обязано совпадать с `oracle` 1:1.
|
|
47
|
+
Лишний файл / недостающий / пустая папка в стороне — фейл-запись в отчёт.
|
|
48
|
+
2. **Файлы**: адаптер по расширению. Нет адаптера → фейл («no adapter for .xyz»).
|
|
49
|
+
3. PNG: байты равны → pass; иначе попиксельно, pass если diff-доля ≤
|
|
50
|
+
`max_diff_ratio`; при несовпадении размеров — фейл с указанием размеров.
|
|
51
|
+
Для провала генерится `diffs/<rel>.diff.png` (изменённые пиксели красным).
|
|
52
|
+
4. HTML: канонизация DOM (nokogiri: убрать комментарии, схлопнуть пробелы в
|
|
53
|
+
текст-нодах, отсортировать атрибуты, сериализовать построчно) → построчный
|
|
54
|
+
diff. Pass = 0 расхождений.
|
|
55
|
+
5. TXT: построчный diff (diff-lcs). Pass = 0 расхождений.
|
|
56
|
+
6. Бинарные (зарегистрированные как binary): sha256.
|
|
57
|
+
|
|
58
|
+
## Параллелизм
|
|
59
|
+
|
|
60
|
+
Структура — синхронно (дёшево). Файлы — пул форк-воркеров (`--jobs N`,
|
|
61
|
+
дефолт = число ядер, кап 8): round-robin срезы, результаты пишутся воркерами
|
|
62
|
+
в tmpdir как JSON, родитель мёржит и сортирует. `--jobs 1` — последовательно.
|
|
63
|
+
Форки вместо Ractors: совместимо с 3.2+ и без GVL-проблем chunky_png.
|
|
64
|
+
|
|
65
|
+
## CLI
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
twinshot help [COMMAND] — справка
|
|
69
|
+
twinshot generate oracle|current — запускает generate-команду из конфига (chdir = dirname конфига)
|
|
70
|
+
twinshot compare — сравнивает две папки, пишет отчёт
|
|
71
|
+
-c, --config PATH (default: twinshot.yml)
|
|
72
|
+
-j, --jobs N
|
|
73
|
+
--json — machine-итог в stdout
|
|
74
|
+
-V, --version
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Exit-коды: 0 — всё сошлось; 1 — есть расхождения; 2 — ошибка конфига/usage.
|
|
78
|
+
|
|
79
|
+
## Отчёт
|
|
80
|
+
|
|
81
|
+
- Группировка по папкам (заголовок группы + счётчик), внутри провалы первыми.
|
|
82
|
+
- Sticky-шапка: итоги, поиск по подстроке имени, фильтры все/провалы/прошедшие,
|
|
83
|
+
режимы картинок: рядом / слайдер / мигание.
|
|
84
|
+
- Sticky-имя файла над каждой парой (как в текущем отчёте capybara-tests).
|
|
85
|
+
- Клавиши n/p — прыжок к следующему/предыдущему видимому шоту.
|
|
86
|
+
- Текстовые диффы инлайном (`+/-` строки, кап 400 строк).
|
|
87
|
+
- Всё инлайн (CSS/JS), внешних ассетов нет; картинки — относительные пути.
|
|
88
|
+
|
|
89
|
+
## Тесты (minitest)
|
|
90
|
+
|
|
91
|
+
- config: загрузка, дефолты, ошибки валидации
|
|
92
|
+
- tree: совпадение/лишний/недостающий файл
|
|
93
|
+
- adapters: binary/txt/png (мини-PNG генерим chunky_png)/html-канонизация/unknown ext
|
|
94
|
+
- compare: e2e на tmp-деревьях, включая fork-пул (jobs 1 и 2)
|
|
95
|
+
- cli: help, unknown command, exit-коды
|
|
96
|
+
- report: генерится, содержит маркеры групп/шотов
|
|
97
|
+
|
|
98
|
+
## Интеграция
|
|
99
|
+
|
|
100
|
+
`capybara-tests/twinshot.yml` → `oracle: artifacts/rails`, `current: artifacts/crails`,
|
|
101
|
+
запуск `twinshot compare`, проверка exit-кода, отчёта и summary.json на живых данных
|
|
102
|
+
(127 файлов rails, 129 crails — есть реальный лишний файл для проверки структуры).
|
|
103
|
+
|
|
104
|
+
## Готовность
|
|
105
|
+
|
|
106
|
+
rake test зелёный, rake build собирается, на capybara-артефактах compare
|
|
107
|
+
возвращает ненулевой exit (лишний файл), отчёт открывается и работает.
|
data/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="logo.svg" alt="Twinshot logo" width="256">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Twinshot — Make "clean room" implementation of every program in the earth!
|
|
6
|
+
|
|
7
|
+
Save and compare screenshots of the functionality of other applications you
|
|
8
|
+
want to copy end to end: a proven **oracle** tree against the **current**
|
|
9
|
+
state, 1:1 parity between two folders of snapshots (screenshots, HTML, text).
|
|
10
|
+
|
|
11
|
+
## Use cases
|
|
12
|
+
|
|
13
|
+
- **Pixel-perfect design from mockups** — export every screen from Figma or a
|
|
14
|
+
sketch into `oracle/`, screenshot your implementation into `current/`, and
|
|
15
|
+
twinshot verifies it pixel-by-pixel: a tolerance (`png.max_diff_ratio`) for
|
|
16
|
+
antialiasing, a red diff image and a heat-zone overlay showing exactly where
|
|
17
|
+
you drifted. "Matches the design" stops being an opinion.
|
|
18
|
+
- **Clean-room reimplementation** — rebuilding an app on a new stack
|
|
19
|
+
(the gem's origin: a Rails app rewritten in crails): snapshot both versions
|
|
20
|
+
and drive to 1:1 parity, screen by screen.
|
|
21
|
+
- **Upgrade/refactor safety net** — before a framework or Ruby major upgrade,
|
|
22
|
+
snapshot pages before and after; zero diff proves the UI didn't move.
|
|
23
|
+
- **AI agent feedback loop** — an agent implements, `twinshot compare` writes
|
|
24
|
+
`report_for_agent.md` designed to be fed back to the model; exit codes
|
|
25
|
+
fit CI and `git bisect run`.
|
|
26
|
+
- **Golden-master testing** — not just pixels: rendered HTML compared by
|
|
27
|
+
canonicalized DOM structure, JSON/CSV/logs by line diff — catch structural
|
|
28
|
+
regressions with no flaky selectors.
|
|
29
|
+
- **Cross-browser parity** — the same pages screenshotted in different
|
|
30
|
+
browsers/OSes, compared as trees.
|
|
31
|
+
|
|
32
|
+
## How it works
|
|
33
|
+
|
|
34
|
+
1. **Structure**: the `current` file tree must match `oracle` 1:1. An extra
|
|
35
|
+
or missing file is a failure.
|
|
36
|
+
2. **Files**: every file is compared by its extension's adapter. No adapter —
|
|
37
|
+
failure (no fallbacks).
|
|
38
|
+
3. **Report**: a self-contained HTML gallery (`report_for_human.html`) +
|
|
39
|
+
a markdown report for LLM agents (`report_for_agent.md`) + `summary.json`.
|
|
40
|
+
|
|
41
|
+
Adapters:
|
|
42
|
+
|
|
43
|
+
| Type | How it compares |
|
|
44
|
+
|-------|-----------------------------------------------------------------------|
|
|
45
|
+
| `png` | bytes fast-path → pixel-by-pixel; threshold `png.max_diff_ratio`; diff image (red) + purple heat-zone overlay |
|
|
46
|
+
| `html`| by DOM structure: canonicalization (nokogiri: no comments, whitespace collapsed, attributes sorted) → line diff |
|
|
47
|
+
| `xml` | by structure like html, via the XML parser (case-sensitive names): canonicalization → line diff (`xml`, `svg`) |
|
|
48
|
+
| `txt` | line diff (`txt`, `md`, `log`, `json`, `csv`) |
|
|
49
|
+
| `bin` | byte-wise SHA-256 (`bin`, `dat`) |
|
|
50
|
+
|
|
51
|
+
## Config — `twinshot.yml`
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
oracle:
|
|
55
|
+
path: artifacts/rails
|
|
56
|
+
generate: bin/record # optional
|
|
57
|
+
|
|
58
|
+
current:
|
|
59
|
+
path: artifacts/crails
|
|
60
|
+
generate: bin/record --crails # optional
|
|
61
|
+
|
|
62
|
+
png:
|
|
63
|
+
max_diff_ratio: 0.002 # optional, default 0.002 (applies to all groups)
|
|
64
|
+
|
|
65
|
+
soft_check: # optional: exact tree-relative paths or globs
|
|
66
|
+
- dashboard/header.png # diffs tolerated (clocks, dates…); missing/
|
|
67
|
+
- code/capybara-tests/* # extra files on one side — all warnings,
|
|
68
|
+
# exit code stays 0
|
|
69
|
+
|
|
70
|
+
# optional: named groups — additional tree pairs with the same fields
|
|
71
|
+
# (path + generate per side). Any names work.
|
|
72
|
+
groups:
|
|
73
|
+
dogs:
|
|
74
|
+
oracle:
|
|
75
|
+
path: artifacts/dogs-rails
|
|
76
|
+
generate: bin/record --dogs
|
|
77
|
+
current:
|
|
78
|
+
path: artifacts/dogs-crails
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The top-level `oracle`/`current` pair is the unnamed **default group**;
|
|
82
|
+
`compare` runs every group in order (or just one — `compare dogs`), and
|
|
83
|
+
`groups` in the yaml adds named ones (the name `default` is reserved).
|
|
84
|
+
There is nothing special about an "API-style" group: if you need to compare
|
|
85
|
+
not just the output but the input too, simply put the input artifacts into
|
|
86
|
+
the group's trees — they are compared by the same adapters.
|
|
87
|
+
|
|
88
|
+
Every group writes its report into its own folder:
|
|
89
|
+
`<config dir>/twinshot-report/<group>/` — the default group reports into
|
|
90
|
+
`twinshot-report/default/`. Each folder holds the same layout as before:
|
|
91
|
+
`report_for_human.html`, `report_for_agent.md`, `summary.json`, `diffs/`.
|
|
92
|
+
Not configurable.
|
|
93
|
+
|
|
94
|
+
Entries in `soft_check` (the same list for every group) are exact
|
|
95
|
+
tree-relative paths or glob patterns. They are snapshots whose content is
|
|
96
|
+
allowed to differ — typically screenshots with a clock or other dynamic
|
|
97
|
+
content — and files that may legitimately exist on only one side of the
|
|
98
|
+
trees. Globs match nested files and dotfiles (`*` crosses `/`):
|
|
99
|
+
`code/capybara-tests/*` covers the entire subtree. Everything a listed path
|
|
100
|
+
produces is a **warning** (`!` in yellow in the run output): a content diff
|
|
101
|
+
when it exists on both sides, or a missing/extra structure result when it
|
|
102
|
+
exists on only one. Warnings are fully visible in both reports and counted
|
|
103
|
+
separately, but they never change the exit code.
|
|
104
|
+
|
|
105
|
+
## Commands
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
twinshot compare [GROUP|all] [-c PATH] [-j N] [--json] [--[no-]open] # compare, write reports
|
|
109
|
+
twinshot generate [GROUP|all|SIDE][:oracle|current] # run generate commands
|
|
110
|
+
twinshot review [GROUP|all] [compare flags] # regenerate, then compare
|
|
111
|
+
twinshot help [COMMAND] # help
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- `compare` in the happy case needs no generation — it takes the folders as
|
|
115
|
+
they are. `GROUP` (a group name, `all` by default) selects what runs:
|
|
116
|
+
`compare dogs` compares only that group and writes only its report;
|
|
117
|
+
`compare all` — every configured group.
|
|
118
|
+
- `-j N` — worker pool (forks, default: core count, capped at 8): the
|
|
119
|
+
structure is checked synchronously, files in parallel.
|
|
120
|
+
- After `compare`, the first compared group's report opens in a browser: on
|
|
121
|
+
a tty — automatically, `--open` — force it (even with `--json`),
|
|
122
|
+
`--no-open` — disable. Cross-platform "open" (`Twinshot::Opener`): darwin
|
|
123
|
+
— `open`, windows — `start` via `cmd /c`, linux/BSD — `xdg-open`; a failed
|
|
124
|
+
launch does not affect the exit code.
|
|
125
|
+
- `generate` takes a `GROUP:SIDE` selector: `generate dogs:oracle` — one
|
|
126
|
+
tree of a named group; a bare side (`generate oracle`, same as
|
|
127
|
+
`default:oracle`) — the default group's tree (no namespace means
|
|
128
|
+
default); `generate dogs` — both trees of one group; `generate all` /
|
|
129
|
+
`all:oracle` / no selector — every configured tree of every group (one
|
|
130
|
+
side or both), announced as a `Generate:` list first. A failing command
|
|
131
|
+
doesn't stop the rest, but the exit code reports it. The words `all`,
|
|
132
|
+
`oracle` and `current` are reserved and cannot be group names.
|
|
133
|
+
- `review` is generate + compare in one run: both sides of the selected
|
|
134
|
+
groups (`review dogs`, `all` by default) regenerate first — a tree
|
|
135
|
+
without a generate command is taken as it is — then the compare runs and
|
|
136
|
+
writes the same reports, with the same options as `compare`. A failed
|
|
137
|
+
generate command fails the run (exit 1) without comparing.
|
|
138
|
+
- Exit codes: `0` — everything matched, `1` — differences found (fits
|
|
139
|
+
`git bisect run` and CI), `2` — config/usage error. `soft_check` warnings
|
|
140
|
+
never affect the exit code.
|
|
141
|
+
|
|
142
|
+
## Report
|
|
143
|
+
|
|
144
|
+
Two files per group folder:
|
|
145
|
+
|
|
146
|
+
- `report_for_human.html` — the visual report (opened in a browser after
|
|
147
|
+
`compare`):
|
|
148
|
+
- flat gallery ordered by folder (one card per file, no folder headings);
|
|
149
|
+
- substring search, filters all/failed/warned/passed;
|
|
150
|
+
- image modes: **side-by-side** (50/50), **slider** (overlay with a handle),
|
|
151
|
+
**blink** (oracle/current alternating);
|
|
152
|
+
- **zones** — a purple overlay over images: the picture is split into 32×32 px
|
|
153
|
+
squares, a square's opacity grows with the share of differing pixels (`heat`
|
|
154
|
+
metadata in `summary.json`, a toolbar toggle);
|
|
155
|
+
- inline text diffs (`+/-` lines);
|
|
156
|
+
- `n`/`p` keys — navigate shots, `Esc` — reset search.
|
|
157
|
+
- `report_for_agent.md` — the same results as markdown to feed to a model:
|
|
158
|
+
every failed file gets a section with its metadata (absolute oracle/current
|
|
159
|
+
paths, kind, detail, ratio, diff-image path) plus the divergence snippet as a
|
|
160
|
+
unified-style `diff` fence — the same line diffs the human report renders
|
|
161
|
+
inline (for `html` that is the canonicalized DOM line diff, so the snippet
|
|
162
|
+
shows what structurally differs, not raw source noise). Passed files are a
|
|
163
|
+
plain list at the bottom.
|
|
164
|
+
|
|
165
|
+
## Install and development
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
bundle exec rake test && bundle exec rake build # pkg/twinshot-X.Y.Z.gem
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Ruby >= 3.2 (developed on 4.0). Dependencies: `ruby-vips` (native `libvips`),
|
|
172
|
+
`colorize`, `diff-lcs`, `nokogiri`.
|
|
173
|
+
|
|
174
|
+
A run prints live progress: a banner, a colored per-file verdict (✓ pass /
|
|
175
|
+
`!` warn / ✗ fail) in worker arrival order, and a final stats block by type.
|
|
176
|
+
When anything failed, a copyable `FAILS:` list of every failed file follows
|
|
177
|
+
the stats block; on an otherwise green run with warnings, a `WARNS:` list of
|
|
178
|
+
the `soft_check` files instead. Colors on a tty only; `--json` disables
|
|
179
|
+
progress.
|
data/Rakefile
ADDED
data/exe/twinshot
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Twinshot
|
|
6
|
+
# Adapter implementations (the contract lives in adapters.rb).
|
|
7
|
+
module Adapters
|
|
8
|
+
# Byte-for-byte comparison via SHA-256.
|
|
9
|
+
module Binary
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def call(_rel, oracle_file, current_file, _ctx)
|
|
13
|
+
same = Digest::SHA256.file(oracle_file).hexdigest == Digest::SHA256.file(current_file).hexdigest
|
|
14
|
+
{ "kind" => "binary", "status" => same ? "pass" : "fail",
|
|
15
|
+
"detail" => same ? "bytes equal" : "bytes differ" }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
register(Binary, "bin", "dat")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Twinshot
|
|
6
|
+
# Adapter implementations (the contract lives in adapters.rb).
|
|
7
|
+
module Adapters
|
|
8
|
+
# HTML compares by DOM structure, not raw text: canonical serialization
|
|
9
|
+
# (comments dropped, whitespace-only text nodes dropped, runs of whitespace
|
|
10
|
+
# in text squashed to one space, attributes sorted by name) followed by a
|
|
11
|
+
# line diff over that canonical form. Pass when structures are identical.
|
|
12
|
+
module Html
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def call(_rel, oracle_file, current_file, _ctx)
|
|
16
|
+
Adapters.structural_result("html", canonical_lines(oracle_file), canonical_lines(current_file))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def canonical_lines(path)
|
|
20
|
+
doc = Nokogiri::HTML4(File.read(path, encoding: "UTF-8"))
|
|
21
|
+
Adapters.canonical_dom_lines(doc, downcase_names: true)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
register(Html, "html", "htm")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "vips"
|
|
6
|
+
|
|
7
|
+
# twinshot parallelizes across files with its own worker pool, so keep each
|
|
8
|
+
# libvips call single-threaded — otherwise every forked worker would spin up
|
|
9
|
+
# its own nproc-sized libvips threadpool and oversubscribe the machine.
|
|
10
|
+
Vips.vips_concurrency_set(1)
|
|
11
|
+
|
|
12
|
+
module Twinshot
|
|
13
|
+
# Adapter implementations (the contract lives in adapters.rb).
|
|
14
|
+
module Adapters
|
|
15
|
+
# PNG: bytes fast-path, then pixel-by-pixel via libvips (decode, diff mask
|
|
16
|
+
# and diff image are all native C — a pure-ruby pixel walk was the old
|
|
17
|
+
# bottleneck). Pass when the share of differing pixels is within
|
|
18
|
+
# ctx.max_diff_ratio. On fail writes a diff image (differing pixels painted
|
|
19
|
+
# red over the current snapshot) into the report dir. Whenever any pixel
|
|
20
|
+
# differs, also emits "heat" — a grid metadata for the report's purple
|
|
21
|
+
# overlay (see #heat_grid).
|
|
22
|
+
module Png
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
HEAT_CELL = 32
|
|
26
|
+
RED = [255, 0, 0, 255].freeze
|
|
27
|
+
|
|
28
|
+
def call(rel, oracle_file, current_file, ctx)
|
|
29
|
+
if Digest::SHA256.file(oracle_file).hexdigest == Digest::SHA256.file(current_file).hexdigest
|
|
30
|
+
return { "kind" => "png", "image" => true, "status" => "pass", "detail" => "bytes equal" }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
a = decode(oracle_file)
|
|
34
|
+
b = decode(current_file)
|
|
35
|
+
return a if a.is_a?(Hash)
|
|
36
|
+
return b if b.is_a?(Hash)
|
|
37
|
+
if a.width != b.width || a.height != b.height
|
|
38
|
+
return { "kind" => "png", "image" => true, "status" => "fail",
|
|
39
|
+
"detail" => "size mismatch #{a.width}x#{a.height} vs #{b.width}x#{b.height}" }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
mask = diff_mask(a, b).copy_memory
|
|
43
|
+
total = a.width * a.height
|
|
44
|
+
diff_count = (mask.avg * total / 255.0).round
|
|
45
|
+
ratio = diff_count.fdiv(total)
|
|
46
|
+
result = { "kind" => "png", "image" => true,
|
|
47
|
+
"status" => ratio <= ctx.max_diff_ratio ? "pass" : "fail",
|
|
48
|
+
"detail" => format("%.3f%% px differ (%d/%d)", ratio * 100, diff_count, total),
|
|
49
|
+
"ratio" => ratio }
|
|
50
|
+
if diff_count.positive?
|
|
51
|
+
result["heat"] = heat_grid(a, mask)
|
|
52
|
+
result["diff_image"] = write_diff_image(rel, b, mask, ctx) if result["status"] == "fail"
|
|
53
|
+
end
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def decode(path)
|
|
58
|
+
Vips::Image.new_from_file(path, access: :random)
|
|
59
|
+
rescue Vips::Error => e
|
|
60
|
+
{ "kind" => "png", "image" => true, "status" => "fail", "detail" => "decode error: #{e.message}" }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Single-band 0/255 mask: 255 where any band of the two images differs.
|
|
64
|
+
# Both sides are normalized to rgba first so an rgb/rgba (or grey/rgba)
|
|
65
|
+
# pair still compares pixel-wise.
|
|
66
|
+
def diff_mask(oracle, current)
|
|
67
|
+
(rgba(oracle) != rgba(current)).bandor
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def rgba(img)
|
|
71
|
+
case img.bands
|
|
72
|
+
when 1 then g = img.extract_band(0)
|
|
73
|
+
g.bandjoin([g, g]).bandjoin_const([255])
|
|
74
|
+
when 2 then g = img.extract_band(0)
|
|
75
|
+
g.bandjoin([g, g, img.extract_band(1)])
|
|
76
|
+
when 3 then img.bandjoin_const([255])
|
|
77
|
+
else img
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Splits the image into HEAT_CELL squares and returns, for every cell
|
|
82
|
+
# with at least one differing pixel, its share of differing pixels:
|
|
83
|
+
#
|
|
84
|
+
# { "cell" => 32, "w" => 1280, "h" => 800,
|
|
85
|
+
# "cells" => [[col, row, intensity], ...] } # intensity in 0..1
|
|
86
|
+
#
|
|
87
|
+
# Per-cell differing-pixel counts come from a libvips shrink (average
|
|
88
|
+
# over each cell) so the counting stays native. Edge cells on the
|
|
89
|
+
# right/bottom cover the remainder, so their area (the intensity
|
|
90
|
+
# denominator) is smaller than HEAT_CELL^2.
|
|
91
|
+
def heat_grid(image, mask)
|
|
92
|
+
width = image.width
|
|
93
|
+
height = image.height
|
|
94
|
+
cols = (width + HEAT_CELL - 1) / HEAT_CELL
|
|
95
|
+
rows = (height + HEAT_CELL - 1) / HEAT_CELL
|
|
96
|
+
# pad to a HEAT_CELL multiple so shrink keeps whole cells (and works
|
|
97
|
+
# at all when the image is smaller than one cell); the zero padding
|
|
98
|
+
# leaves each cell's differing-pixel count untouched.
|
|
99
|
+
padded = mask.embed(0, 0, cols * HEAT_CELL, rows * HEAT_CELL)
|
|
100
|
+
avgs = padded.cast(:float).shrink(HEAT_CELL, HEAT_CELL).write_to_memory.unpack("e*")
|
|
101
|
+
cells = avgs.each_with_index.filter_map do |avg, idx|
|
|
102
|
+
col = idx % cols
|
|
103
|
+
row = idx / cols
|
|
104
|
+
cell_w = [width - col * HEAT_CELL, HEAT_CELL].min
|
|
105
|
+
cell_h = [height - row * HEAT_CELL, HEAT_CELL].min
|
|
106
|
+
# avg is over the padded HEAT_CELL^2 cell, so the count denominator
|
|
107
|
+
# is the padded area, not the cell's actual (edge-trimmed) area.
|
|
108
|
+
count = (avg * HEAT_CELL * HEAT_CELL / 255.0).round
|
|
109
|
+
next if count.zero?
|
|
110
|
+
|
|
111
|
+
[col, row, (count.fdiv(cell_w * cell_h) * 100).round / 100.0]
|
|
112
|
+
end
|
|
113
|
+
{ "cell" => HEAT_CELL, "w" => width, "h" => height, "cells" => cells }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def write_diff_image(rel, base, mask, ctx)
|
|
117
|
+
diff = mask.ifthenelse(RED, rgba(base))
|
|
118
|
+
rel_path = File.join("diffs", "#{rel}.diff.png")
|
|
119
|
+
abs = File.join(ctx.report_dir, rel_path)
|
|
120
|
+
FileUtils.mkdir_p(File.dirname(abs))
|
|
121
|
+
diff.pngsave(abs)
|
|
122
|
+
rel_path
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
register(Png, "png")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Twinshot
|
|
4
|
+
# Adapter implementations (the contract lives in adapters.rb).
|
|
5
|
+
module Adapters
|
|
6
|
+
# Plain text: line-by-line diff. Pass when every line matches.
|
|
7
|
+
module Txt
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(_rel, oracle_file, current_file, _ctx)
|
|
11
|
+
a_lines = read_lines(oracle_file)
|
|
12
|
+
b_lines = read_lines(current_file)
|
|
13
|
+
return { "kind" => "txt", "status" => "pass", "detail" => "lines identical" } if a_lines == b_lines
|
|
14
|
+
|
|
15
|
+
changed, entries = Adapters.line_diff(a_lines, b_lines)
|
|
16
|
+
{ "kind" => "txt", "status" => "fail", "detail" => "#{changed} line(s) differ",
|
|
17
|
+
"diff_lines" => changed, "diff" => entries }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def read_lines(path)
|
|
21
|
+
File.read(path, encoding: "UTF-8").gsub(/\r\n?/, "\n").lines.map(&:chomp)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
register(Txt, "txt", "md", "log", "json", "csv")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Twinshot
|
|
6
|
+
# Adapter implementations (the contract lives in adapters.rb).
|
|
7
|
+
module Adapters
|
|
8
|
+
# XML compares by structure like html, but through the XML parser:
|
|
9
|
+
# element names stay case-sensitive and no html/body structure is
|
|
10
|
+
# implied. Canonical serialization → line diff; pass when structures are
|
|
11
|
+
# identical. svg is XML, so it lands here too (compared as markup, not
|
|
12
|
+
# rendered pixels).
|
|
13
|
+
module Xml
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def call(_rel, oracle_file, current_file, _ctx)
|
|
17
|
+
Adapters.structural_result("xml", canonical_lines(oracle_file), canonical_lines(current_file))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def canonical_lines(path)
|
|
21
|
+
doc = Nokogiri::XML(File.read(path, encoding: "UTF-8"))
|
|
22
|
+
Adapters.canonical_dom_lines(doc, downcase_names: false)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
register(Xml, "xml", "svg")
|
|
27
|
+
end
|
|
28
|
+
end
|