unflake 0.6.1__tar.gz
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.
- unflake-0.6.1/LICENSE +21 -0
- unflake-0.6.1/PKG-INFO +143 -0
- unflake-0.6.1/README.md +125 -0
- unflake-0.6.1/pyproject.toml +29 -0
- unflake-0.6.1/setup.cfg +4 -0
- unflake-0.6.1/src/unflake/__init__.py +3 -0
- unflake-0.6.1/src/unflake/__main__.py +14 -0
- unflake-0.6.1/src/unflake/cli.py +214 -0
- unflake-0.6.1/src/unflake/ingest.py +95 -0
- unflake-0.6.1/src/unflake/patterns.py +243 -0
- unflake-0.6.1/src/unflake/quarantine.py +78 -0
- unflake-0.6.1/src/unflake/report.py +123 -0
- unflake-0.6.1/src/unflake/runner.py +151 -0
- unflake-0.6.1/src/unflake/scaffold.py +89 -0
- unflake-0.6.1/src/unflake/score.py +122 -0
- unflake-0.6.1/src/unflake.egg-info/PKG-INFO +143 -0
- unflake-0.6.1/src/unflake.egg-info/SOURCES.txt +22 -0
- unflake-0.6.1/src/unflake.egg-info/dependency_links.txt +1 -0
- unflake-0.6.1/src/unflake.egg-info/entry_points.txt +2 -0
- unflake-0.6.1/src/unflake.egg-info/top_level.txt +1 -0
- unflake-0.6.1/tests/test_e2e.py +71 -0
- unflake-0.6.1/tests/test_npm.py +70 -0
- unflake-0.6.1/tests/test_study.py +36 -0
- unflake-0.6.1/tests/test_unflake.py +370 -0
unflake-0.6.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Unflake Contributors
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
unflake-0.6.1/PKG-INFO
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unflake
|
|
3
|
+
Version: 0.6.1
|
|
4
|
+
Summary: Green CI without the rerun ritual — detect, score, and quarantine flaky tests
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/YOUR-USER/unflake
|
|
7
|
+
Project-URL: Issues, https://github.com/YOUR-USER/unflake/issues
|
|
8
|
+
Keywords: testing,flaky-tests,ci,qa,agent-skills
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Testing
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Unflake 🟢
|
|
20
|
+
|
|
21
|
+
[](https://github.com/YOUR-USER/unflake/actions/workflows/ci.yml)
|
|
22
|
+
[](https://scorecard.dev/viewer/?uri=github.com/YOUR-USER/unflake)
|
|
23
|
+
[](https://pypi.org/project/unflake/)
|
|
24
|
+
[](LICENSE)
|
|
25
|
+
|
|
26
|
+
**Green CI without the "just rerun it" ritual.**
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
CI is red. You rerun. It's green. You merge, trusting nothing. Unflake hunts the flake instead: static scan in milliseconds, FlakeScore across repeated runs, regression-vs-flake verdicts, and quarantine configs that keep the build green *without deleting a single test*.
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install unflake
|
|
36
|
+
unflake scan tests/ # 1. smells, instantly
|
|
37
|
+
unflake run --runs 3 --runner pytest -- pytest tests/ -q # 2. collect + score
|
|
38
|
+
unflake quarantine run1.xml run2.xml --framework pytest # 3. quarantine, don't delete
|
|
39
|
+
unflake init --framework pytest --write # 4. seeded RNG scaffold (prevention)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
JS-first repo? Same tool, no Python packaging — just Python itself (3.9+):
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx unflake-ci scan tests/
|
|
46
|
+
npx unflake-ci run --runs 3 --runner vitest -- npx vitest run tests/
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Proven on real suites: 3,676 tests × 5 runs across attrs/click/tqdm → FlakeScore 100,
|
|
50
|
+
0 false quarantines ([study](benchmarks/BENCH.md)).
|
|
51
|
+
|
|
52
|
+
No install handy? `bash demo.sh` runs the whole loop on fixtures in 60 seconds.
|
|
53
|
+
|
|
54
|
+
> ⭐ If flaky tests have ever paged you, star this — it helps other devs find it.
|
|
55
|
+
|
|
56
|
+
## Before / after
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
# before: the 3am pager
|
|
60
|
+
def test_checkout():
|
|
61
|
+
time.sleep(5) # pray the server is up
|
|
62
|
+
assert requests.get(API).ok # real network in a unit test
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
$ unflake scan tests/
|
|
67
|
+
WARNING [FLK001] tests/test_shop.py:4 — time.sleep() in tests makes timing-dependent flakes
|
|
68
|
+
fix: Replace with polling (wait_for / waitFor / eventually) with a timeout, or freeze time.
|
|
69
|
+
WARNING [FLK004] tests/test_shop.py:5 — Real network calls in tests fail without mocks ...
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
$ unflake run --runs 3 -- pytest tests/ -q
|
|
74
|
+
FlakeScore: 50.0/100 (2 flaky of 4 tests)
|
|
75
|
+
FLAKY test_cart::test_checkout (2P/1F over 3 runs)
|
|
76
|
+
FLAKY test_cart::test_discount (2P/1F over 3 runs)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
$ unflake quarantine run1.xml run2.xml --framework pytest
|
|
81
|
+
pytest -k "not test_checkout and not test_discount" # gating run stays green
|
|
82
|
+
pytest -k "test_checkout or test_discount" # nightly run still reports
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Quarantine preserves signal; deletion hides it. Every quarantined test still runs — it just can't fail the build. **Regressions are never quarantined**: green-then-red-forever is a real failure — fix it.
|
|
86
|
+
|
|
87
|
+
## Flaky or regression? Unflake knows the difference
|
|
88
|
+
|
|
89
|
+
Pass result files oldest → newest and Unflake separates three fates:
|
|
90
|
+
|
|
91
|
+
| Verdict | Meaning | Action |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `FLAKY` | mixed outcomes, no clean break | quarantine + fix root cause |
|
|
94
|
+
| `REGRESSION` | green until run N, red ever since | fix now — never quarantine |
|
|
95
|
+
| `NEW` / stable | seen once / always green / always red | more runs / ship it / real bug |
|
|
96
|
+
|
|
97
|
+
Two runs can only suggest a flake; calling a regression needs ≥3. One run proves nothing — `unflake run` exists so there's no excuse.
|
|
98
|
+
|
|
99
|
+
## Why not the others?
|
|
100
|
+
|
|
101
|
+
| | Unflake | Heavy flake platforms |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| Install | zero-dep, stdlib only, `pip install unflake` | OTel collectors, dashboards, SaaS |
|
|
104
|
+
| Input | JUnit XML from **any** framework (pytest ✅, Vitest ✅, Playwright ✅, Jest ✅ e2e — plus generic `--junit-flag`, Go, JUnit…) | per-framework reporters/plugins |
|
|
105
|
+
| First value | `scan` in milliseconds, no execution | needs history ingestion |
|
|
106
|
+
| Verdicts | flaky vs regression vs new, change-point included | usually just a score |
|
|
107
|
+
| Philosophy | quarantine, never delete | often auto-skip-and-forget |
|
|
108
|
+
| Agent-native | `SKILL.md` + harness adapters day one | docs page, if you're lucky |
|
|
109
|
+
|
|
110
|
+
## Works with your harness
|
|
111
|
+
|
|
112
|
+
Claude Code · Codex · Copilot · Cursor · Gemini · Pi · OpenCode · Windsurf · Cline · Qoder — plus a GitHub Action (`action.yml`, SARIF → code scanning) and a pre-commit hook. Details: [`docs/ADAPTERS.md`](docs/ADAPTERS.md). The skill lives at [`skills/unflake/SKILL.md`](skills/unflake/SKILL.md).
|
|
113
|
+
|
|
114
|
+
10 rules today, incl. the JS classics: `cy.wait(ms)` (FLK009) and `waitForTimeout` (FLK010).
|
|
115
|
+
Precision features: localhost-aware severities, string-literal awareness (Python),
|
|
116
|
+
`--exclude` globs, and SARIF rule metadata for code scanning.
|
|
117
|
+
|
|
118
|
+
## Benchmarks (honest, reproducible)
|
|
119
|
+
|
|
120
|
+
Fixtures + real pytest e2e + a real-repo scan (`psf/requests`: 120 findings in ~0.1s — with the noise documented, not hidden). Method, numbers, and known limitations: [`benchmarks/BENCH.md`](benchmarks/BENCH.md). If a number doesn't reproduce, that's a bug — file it.
|
|
121
|
+
|
|
122
|
+
## Contributing — built for drive-by PRs
|
|
123
|
+
|
|
124
|
+
- 🧪 New FLK rule = one regex + one test (`good first issue`)
|
|
125
|
+
- 🔌 New harness adapter or quarantine emitter = one file + one test
|
|
126
|
+
- 🌱 `unflake init` for your framework = one snippet
|
|
127
|
+
- 🌍 Translations welcome (`README.<lang>.md`)
|
|
128
|
+
- Full loop: [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
|
129
|
+
|
|
130
|
+
## Roadmap
|
|
131
|
+
|
|
132
|
+
- [x] v0.1 — scan / analyze / quarantine, SARIF, 4 frameworks, skill + adapters
|
|
133
|
+
- [x] v0.2 — `run` collector, regression-vs-flake verdicts, `init` scaffolder, FLK009/FLK010, pre-commit + GitHub Action, `pip install unflake`
|
|
134
|
+
- [x] v0.3 — `--runner` presets, per-run logs, real-pytest e2e in CI, launch kit (`demo.sh`, templates, `LAUNCH.md`), real-repo validation
|
|
135
|
+
- [x] v0.4 — precision (localhost-aware FLK004, literal-awareness), `--exclude`, SARIF rules, safe `-k` quoting, CI matrix 3.10–3.14
|
|
136
|
+
- [x] v0.5 — `unflake-ci` npx wrapper (verified pack→install→run), `--runner vitest` verified e2e
|
|
137
|
+
- [x] v0.6 — `--runner` playwright + jest verified e2e, real-repo study (3,676 tests, 0 false quarantines)
|
|
138
|
+
- [ ] Recall study v2 on suites with known flakes (specificity proven; recall needs wild flakes)
|
|
139
|
+
- [ ] JS/TS string-literal awareness + variable-host local-server detection (see BENCH.md)
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT — see [LICENSE](LICENSE).
|
unflake-0.6.1/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Unflake 🟢
|
|
2
|
+
|
|
3
|
+
[](https://github.com/YOUR-USER/unflake/actions/workflows/ci.yml)
|
|
4
|
+
[](https://scorecard.dev/viewer/?uri=github.com/YOUR-USER/unflake)
|
|
5
|
+
[](https://pypi.org/project/unflake/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
**Green CI without the "just rerun it" ritual.**
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
CI is red. You rerun. It's green. You merge, trusting nothing. Unflake hunts the flake instead: static scan in milliseconds, FlakeScore across repeated runs, regression-vs-flake verdicts, and quarantine configs that keep the build green *without deleting a single test*.
|
|
13
|
+
|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install unflake
|
|
18
|
+
unflake scan tests/ # 1. smells, instantly
|
|
19
|
+
unflake run --runs 3 --runner pytest -- pytest tests/ -q # 2. collect + score
|
|
20
|
+
unflake quarantine run1.xml run2.xml --framework pytest # 3. quarantine, don't delete
|
|
21
|
+
unflake init --framework pytest --write # 4. seeded RNG scaffold (prevention)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
JS-first repo? Same tool, no Python packaging — just Python itself (3.9+):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx unflake-ci scan tests/
|
|
28
|
+
npx unflake-ci run --runs 3 --runner vitest -- npx vitest run tests/
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Proven on real suites: 3,676 tests × 5 runs across attrs/click/tqdm → FlakeScore 100,
|
|
32
|
+
0 false quarantines ([study](benchmarks/BENCH.md)).
|
|
33
|
+
|
|
34
|
+
No install handy? `bash demo.sh` runs the whole loop on fixtures in 60 seconds.
|
|
35
|
+
|
|
36
|
+
> ⭐ If flaky tests have ever paged you, star this — it helps other devs find it.
|
|
37
|
+
|
|
38
|
+
## Before / after
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
# before: the 3am pager
|
|
42
|
+
def test_checkout():
|
|
43
|
+
time.sleep(5) # pray the server is up
|
|
44
|
+
assert requests.get(API).ok # real network in a unit test
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
$ unflake scan tests/
|
|
49
|
+
WARNING [FLK001] tests/test_shop.py:4 — time.sleep() in tests makes timing-dependent flakes
|
|
50
|
+
fix: Replace with polling (wait_for / waitFor / eventually) with a timeout, or freeze time.
|
|
51
|
+
WARNING [FLK004] tests/test_shop.py:5 — Real network calls in tests fail without mocks ...
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
$ unflake run --runs 3 -- pytest tests/ -q
|
|
56
|
+
FlakeScore: 50.0/100 (2 flaky of 4 tests)
|
|
57
|
+
FLAKY test_cart::test_checkout (2P/1F over 3 runs)
|
|
58
|
+
FLAKY test_cart::test_discount (2P/1F over 3 runs)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
$ unflake quarantine run1.xml run2.xml --framework pytest
|
|
63
|
+
pytest -k "not test_checkout and not test_discount" # gating run stays green
|
|
64
|
+
pytest -k "test_checkout or test_discount" # nightly run still reports
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Quarantine preserves signal; deletion hides it. Every quarantined test still runs — it just can't fail the build. **Regressions are never quarantined**: green-then-red-forever is a real failure — fix it.
|
|
68
|
+
|
|
69
|
+
## Flaky or regression? Unflake knows the difference
|
|
70
|
+
|
|
71
|
+
Pass result files oldest → newest and Unflake separates three fates:
|
|
72
|
+
|
|
73
|
+
| Verdict | Meaning | Action |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `FLAKY` | mixed outcomes, no clean break | quarantine + fix root cause |
|
|
76
|
+
| `REGRESSION` | green until run N, red ever since | fix now — never quarantine |
|
|
77
|
+
| `NEW` / stable | seen once / always green / always red | more runs / ship it / real bug |
|
|
78
|
+
|
|
79
|
+
Two runs can only suggest a flake; calling a regression needs ≥3. One run proves nothing — `unflake run` exists so there's no excuse.
|
|
80
|
+
|
|
81
|
+
## Why not the others?
|
|
82
|
+
|
|
83
|
+
| | Unflake | Heavy flake platforms |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| Install | zero-dep, stdlib only, `pip install unflake` | OTel collectors, dashboards, SaaS |
|
|
86
|
+
| Input | JUnit XML from **any** framework (pytest ✅, Vitest ✅, Playwright ✅, Jest ✅ e2e — plus generic `--junit-flag`, Go, JUnit…) | per-framework reporters/plugins |
|
|
87
|
+
| First value | `scan` in milliseconds, no execution | needs history ingestion |
|
|
88
|
+
| Verdicts | flaky vs regression vs new, change-point included | usually just a score |
|
|
89
|
+
| Philosophy | quarantine, never delete | often auto-skip-and-forget |
|
|
90
|
+
| Agent-native | `SKILL.md` + harness adapters day one | docs page, if you're lucky |
|
|
91
|
+
|
|
92
|
+
## Works with your harness
|
|
93
|
+
|
|
94
|
+
Claude Code · Codex · Copilot · Cursor · Gemini · Pi · OpenCode · Windsurf · Cline · Qoder — plus a GitHub Action (`action.yml`, SARIF → code scanning) and a pre-commit hook. Details: [`docs/ADAPTERS.md`](docs/ADAPTERS.md). The skill lives at [`skills/unflake/SKILL.md`](skills/unflake/SKILL.md).
|
|
95
|
+
|
|
96
|
+
10 rules today, incl. the JS classics: `cy.wait(ms)` (FLK009) and `waitForTimeout` (FLK010).
|
|
97
|
+
Precision features: localhost-aware severities, string-literal awareness (Python),
|
|
98
|
+
`--exclude` globs, and SARIF rule metadata for code scanning.
|
|
99
|
+
|
|
100
|
+
## Benchmarks (honest, reproducible)
|
|
101
|
+
|
|
102
|
+
Fixtures + real pytest e2e + a real-repo scan (`psf/requests`: 120 findings in ~0.1s — with the noise documented, not hidden). Method, numbers, and known limitations: [`benchmarks/BENCH.md`](benchmarks/BENCH.md). If a number doesn't reproduce, that's a bug — file it.
|
|
103
|
+
|
|
104
|
+
## Contributing — built for drive-by PRs
|
|
105
|
+
|
|
106
|
+
- 🧪 New FLK rule = one regex + one test (`good first issue`)
|
|
107
|
+
- 🔌 New harness adapter or quarantine emitter = one file + one test
|
|
108
|
+
- 🌱 `unflake init` for your framework = one snippet
|
|
109
|
+
- 🌍 Translations welcome (`README.<lang>.md`)
|
|
110
|
+
- Full loop: [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
|
111
|
+
|
|
112
|
+
## Roadmap
|
|
113
|
+
|
|
114
|
+
- [x] v0.1 — scan / analyze / quarantine, SARIF, 4 frameworks, skill + adapters
|
|
115
|
+
- [x] v0.2 — `run` collector, regression-vs-flake verdicts, `init` scaffolder, FLK009/FLK010, pre-commit + GitHub Action, `pip install unflake`
|
|
116
|
+
- [x] v0.3 — `--runner` presets, per-run logs, real-pytest e2e in CI, launch kit (`demo.sh`, templates, `LAUNCH.md`), real-repo validation
|
|
117
|
+
- [x] v0.4 — precision (localhost-aware FLK004, literal-awareness), `--exclude`, SARIF rules, safe `-k` quoting, CI matrix 3.10–3.14
|
|
118
|
+
- [x] v0.5 — `unflake-ci` npx wrapper (verified pack→install→run), `--runner vitest` verified e2e
|
|
119
|
+
- [x] v0.6 — `--runner` playwright + jest verified e2e, real-repo study (3,676 tests, 0 false quarantines)
|
|
120
|
+
- [ ] Recall study v2 on suites with known flakes (specificity proven; recall needs wild flakes)
|
|
121
|
+
- [ ] JS/TS string-literal awareness + variable-host local-server detection (see BENCH.md)
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "unflake"
|
|
7
|
+
version = "0.6.1"
|
|
8
|
+
description = "Green CI without the rerun ritual — detect, score, and quarantine flaky tests"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["testing", "flaky-tests", "ci", "qa", "agent-skills"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Software Development :: Testing",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
unflake = "unflake.cli:main"
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/YOUR-USER/unflake"
|
|
26
|
+
Issues = "https://github.com/YOUR-USER/unflake/issues"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
unflake-0.6.1/setup.cfg
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Entry point. Works as `python -m unflake` AND as `python path/to/__main__.py`
|
|
2
|
+
(the latter is how the npx wrapper invokes the vendored core)."""
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from .cli import main
|
|
6
|
+
except ImportError: # pragma: no cover - path-invoked fallback
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
11
|
+
from unflake.cli import main
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""`unflake` CLI. Stdlib only. Exit codes: 0 clean, 1 findings, 2 usage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .ingest import load_runs
|
|
11
|
+
from .patterns import SEVERITIES, Finding, scan_path
|
|
12
|
+
from .quarantine import FRAMEWORKS, emit
|
|
13
|
+
from .report import (
|
|
14
|
+
format_analyze_text,
|
|
15
|
+
format_sarif,
|
|
16
|
+
format_scan_json,
|
|
17
|
+
format_scan_text,
|
|
18
|
+
)
|
|
19
|
+
from .runner import collect
|
|
20
|
+
from .scaffold import FRAMEWORKS as SCAFFOLD_FRAMEWORKS
|
|
21
|
+
from .scaffold import init_framework
|
|
22
|
+
from .score import score_runs
|
|
23
|
+
|
|
24
|
+
ORDER = {"error": 0, "warning": 1, "info": 2}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _meets_threshold(f: Finding, fail_on: str) -> bool:
|
|
28
|
+
return ORDER[f.severity] <= ORDER[fail_on]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
32
|
+
findings: list[Finding] = []
|
|
33
|
+
for target in args.targets:
|
|
34
|
+
try:
|
|
35
|
+
findings.extend(scan_path(target, exclude=args.exclude))
|
|
36
|
+
except ValueError as exc:
|
|
37
|
+
print(f"unflake: error: {exc}", file=sys.stderr)
|
|
38
|
+
return 2
|
|
39
|
+
if args.format == "json":
|
|
40
|
+
print(format_scan_json(findings))
|
|
41
|
+
elif args.format == "sarif":
|
|
42
|
+
print(format_sarif(findings))
|
|
43
|
+
else:
|
|
44
|
+
print(format_scan_text(findings))
|
|
45
|
+
bad = [f for f in findings if _meets_threshold(f, args.fail_on)]
|
|
46
|
+
return 1 if bad else 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _print_suite(suite, fmt: str) -> None:
|
|
50
|
+
if fmt == "json":
|
|
51
|
+
print(json.dumps(suite.to_dict(), indent=2))
|
|
52
|
+
elif fmt == "sarif":
|
|
53
|
+
print(format_sarif([], suite))
|
|
54
|
+
else:
|
|
55
|
+
print(format_analyze_text(suite))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _analyze_gate(suite, fail_on: str) -> int:
|
|
59
|
+
if fail_on == "never":
|
|
60
|
+
return 0
|
|
61
|
+
if fail_on == "flaky":
|
|
62
|
+
return 1 if (suite.flaky_count or suite.regression_count) else 0
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _quarantine_note(suite, framework: str | None) -> None:
|
|
67
|
+
if not framework:
|
|
68
|
+
return
|
|
69
|
+
flaky_ids = [t.id for t in suite.tests if t.verdict == "flaky"]
|
|
70
|
+
if not flaky_ids:
|
|
71
|
+
return
|
|
72
|
+
print()
|
|
73
|
+
print(emit(framework, flaky_ids))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_analyze(args: argparse.Namespace) -> int:
|
|
77
|
+
try:
|
|
78
|
+
runs = load_runs(args.results)
|
|
79
|
+
except ValueError as exc:
|
|
80
|
+
print(f"unflake: error: {exc}", file=sys.stderr)
|
|
81
|
+
return 2
|
|
82
|
+
if len(runs) < 2:
|
|
83
|
+
print("unflake: warning: only 1 run given — flakiness needs >= 2 runs "
|
|
84
|
+
"of the same suite to compare.", file=sys.stderr)
|
|
85
|
+
suite = score_runs(runs)
|
|
86
|
+
_print_suite(suite, args.format)
|
|
87
|
+
return _analyze_gate(suite, args.fail_on)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_quarantine(args: argparse.Namespace) -> int:
|
|
91
|
+
try:
|
|
92
|
+
runs = load_runs(args.results)
|
|
93
|
+
except ValueError as exc:
|
|
94
|
+
print(f"unflake: error: {exc}", file=sys.stderr)
|
|
95
|
+
return 2
|
|
96
|
+
suite = score_runs(runs)
|
|
97
|
+
regressions = [t.id for t in suite.tests if t.verdict == "regression"]
|
|
98
|
+
if regressions:
|
|
99
|
+
print("# unflake quarantine: NOT quarantining regressions "
|
|
100
|
+
"(real failures — fix them):", file=sys.stderr)
|
|
101
|
+
for rid in regressions:
|
|
102
|
+
print(f"# - {rid}", file=sys.stderr)
|
|
103
|
+
flaky_ids = [t.id for t in suite.tests if t.verdict == "flaky"]
|
|
104
|
+
if not flaky_ids:
|
|
105
|
+
print("# unflake quarantine: no flaky tests — nothing to quarantine.")
|
|
106
|
+
return 0
|
|
107
|
+
try:
|
|
108
|
+
print(emit(args.framework, flaky_ids))
|
|
109
|
+
except ValueError as exc:
|
|
110
|
+
print(f"unflake: error: {exc}", file=sys.stderr)
|
|
111
|
+
return 2
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
116
|
+
if args.runs < 2:
|
|
117
|
+
print("unflake: error: --runs must be >= 2 (one run proves nothing)",
|
|
118
|
+
file=sys.stderr)
|
|
119
|
+
return 2
|
|
120
|
+
cmd = list(args.test_command or [])
|
|
121
|
+
if cmd[:1] == ["--"]:
|
|
122
|
+
cmd = cmd[1:] # argparse.REMAINDER keeps the separator
|
|
123
|
+
if not cmd:
|
|
124
|
+
print("unflake: error: no test command given after `--`", file=sys.stderr)
|
|
125
|
+
return 2
|
|
126
|
+
try:
|
|
127
|
+
files = collect(
|
|
128
|
+
cmd, runs=args.runs, out_dir=args.out_dir,
|
|
129
|
+
junit_flag=args.junit_flag, runner=args.runner, timeout=args.timeout,
|
|
130
|
+
progress=lambda m: print(f"unflake: {m}", file=sys.stderr),
|
|
131
|
+
)
|
|
132
|
+
except ValueError as exc:
|
|
133
|
+
print(f"unflake: error: {exc}", file=sys.stderr)
|
|
134
|
+
return 2
|
|
135
|
+
print(f"unflake: collected {len(files)} run(s) in {args.out_dir}", file=sys.stderr)
|
|
136
|
+
suite = score_runs(load_runs(files))
|
|
137
|
+
_print_suite(suite, args.format)
|
|
138
|
+
_quarantine_note(suite, args.quarantine)
|
|
139
|
+
return _analyze_gate(suite, args.fail_on)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
143
|
+
try:
|
|
144
|
+
print(init_framework(args.framework, root=args.root, write=args.write))
|
|
145
|
+
except ValueError as exc:
|
|
146
|
+
print(f"unflake: error: {exc}", file=sys.stderr)
|
|
147
|
+
return 2
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
152
|
+
p = argparse.ArgumentParser(
|
|
153
|
+
prog="unflake",
|
|
154
|
+
description="Green CI without the rerun ritual — detect, score, quarantine flaky tests.",
|
|
155
|
+
)
|
|
156
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
157
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
158
|
+
|
|
159
|
+
s = sub.add_parser("scan", help="Static scan for flake anti-patterns (no test execution).")
|
|
160
|
+
s.add_argument("targets", nargs="+", help="File(s) or directorie(s) to scan.")
|
|
161
|
+
s.add_argument("--exclude", action="append", default=[],
|
|
162
|
+
help="Extra path-part glob to skip (repeatable; "
|
|
163
|
+
"defaults already skip .git, node_modules, venvs, build dirs).")
|
|
164
|
+
s.add_argument("--format", choices=["text", "json", "sarif"], default="text")
|
|
165
|
+
s.add_argument("--fail-on", choices=list(SEVERITIES), default="warning",
|
|
166
|
+
help="Exit 1 if any finding at/above this severity (default: warning).")
|
|
167
|
+
s.set_defaults(func=cmd_scan)
|
|
168
|
+
|
|
169
|
+
a = sub.add_parser("analyze", help="Score flakiness across >=2 result files (JUnit XML or JSON).")
|
|
170
|
+
a.add_argument("results", nargs="+", help="Result files from repeated runs, oldest first.")
|
|
171
|
+
a.add_argument("--format", choices=["text", "json", "sarif"], default="text")
|
|
172
|
+
a.add_argument("--fail-on", choices=["flaky", "never"], default="never",
|
|
173
|
+
help="'flaky' exits 1 on flaky tests OR regressions.")
|
|
174
|
+
a.set_defaults(func=cmd_analyze)
|
|
175
|
+
|
|
176
|
+
q = sub.add_parser("quarantine", help="Emit quarantine config for flaky tests (never deletes).")
|
|
177
|
+
q.add_argument("results", nargs="+", help="Result files from repeated runs, oldest first.")
|
|
178
|
+
q.add_argument("--framework", choices=list(FRAMEWORKS), required=True)
|
|
179
|
+
q.set_defaults(func=cmd_quarantine)
|
|
180
|
+
|
|
181
|
+
r = sub.add_parser("run", help="Run your test command N times, then score (collects JUnit when possible).")
|
|
182
|
+
r.add_argument("--runs", type=int, default=3, help="Repeat count, >= 2 (default: 3).")
|
|
183
|
+
r.add_argument("--out-dir", default=".unflake/runs", help="Where per-run files land.")
|
|
184
|
+
r.add_argument("--junit-flag", default=None,
|
|
185
|
+
help="Flag your runner uses for JUnit output, e.g. '--junitxml=' "
|
|
186
|
+
"(auto-detected for pytest; omit for exit-code-only mode).")
|
|
187
|
+
r.add_argument("--runner", default=None,
|
|
188
|
+
help="Verified runner preset (pytest, vitest, playwright, jest). "
|
|
189
|
+
"Others: use --junit-flag.")
|
|
190
|
+
r.add_argument("--timeout", type=float, default=None, help="Per-run timeout in seconds.")
|
|
191
|
+
r.add_argument("--format", choices=["text", "json", "sarif"], default="text")
|
|
192
|
+
r.add_argument("--fail-on", choices=["flaky", "never"], default="flaky")
|
|
193
|
+
r.add_argument("--quarantine", choices=list(FRAMEWORKS), default=None,
|
|
194
|
+
help="Also emit a quarantine snippet for this framework.")
|
|
195
|
+
r.add_argument("test_command", nargs=argparse.REMAINDER,
|
|
196
|
+
help="Test command after `--`, e.g. `-- pytest tests/ -q`.")
|
|
197
|
+
r.set_defaults(func=cmd_run)
|
|
198
|
+
|
|
199
|
+
i = sub.add_parser("init", help="Scaffold seeded-RNG / frozen-time helpers for a framework.")
|
|
200
|
+
i.add_argument("--framework", choices=list(SCAFFOLD_FRAMEWORKS), required=True)
|
|
201
|
+
i.add_argument("--root", default=".", help="Project root to write into.")
|
|
202
|
+
i.add_argument("--write", action="store_true",
|
|
203
|
+
help="Write files (idempotent). Without it, print to stdout.")
|
|
204
|
+
i.set_defaults(func=cmd_init)
|
|
205
|
+
return p
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main(argv: list[str] | None = None) -> int:
|
|
209
|
+
args = build_parser().parse_args(argv)
|
|
210
|
+
return int(args.func(args))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Ingest test results from JUnit XML and generic JSON result files.
|
|
2
|
+
|
|
3
|
+
Universal input: anything that can emit JUnit XML works
|
|
4
|
+
(pytest --junitxml, jest-junit, vitest --reporter=junit, Playwright,
|
|
5
|
+
JUnit/Gradle, go-junit-report, ...). No framework SDK required.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import xml.etree.ElementTree as ET
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
PASS = "passed"
|
|
15
|
+
FAIL = "failed"
|
|
16
|
+
SKIP = "skipped"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _test_id(classname: str, name: str, filepath: str = "") -> str:
|
|
20
|
+
if classname:
|
|
21
|
+
return f"{classname}::{name}"
|
|
22
|
+
if filepath:
|
|
23
|
+
return f"{filepath}::{name}"
|
|
24
|
+
return name
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_junit_xml(path: str | Path) -> dict[str, str]:
|
|
28
|
+
"""Parse one JUnit XML file -> {test_id: status}."""
|
|
29
|
+
path = Path(path)
|
|
30
|
+
try:
|
|
31
|
+
tree = ET.parse(path)
|
|
32
|
+
except ET.ParseError as exc:
|
|
33
|
+
raise ValueError(f"{path}: not valid XML ({exc})") from exc
|
|
34
|
+
root = tree.getroot()
|
|
35
|
+
results: dict[str, str] = {}
|
|
36
|
+
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
|
|
37
|
+
if not suites and root.tag != "testsuite":
|
|
38
|
+
raise ValueError(f"{path}: no <testsuite> found, is this JUnit XML?")
|
|
39
|
+
for suite in suites:
|
|
40
|
+
for case in suite.iter("testcase"):
|
|
41
|
+
name = case.get("name", "unknown")
|
|
42
|
+
classname = case.get("classname", "") or ""
|
|
43
|
+
filepath = case.get("file", "") or ""
|
|
44
|
+
tid = _test_id(classname, name, filepath)
|
|
45
|
+
if case.find("failure") is not None or case.find("error") is not None:
|
|
46
|
+
status = FAIL
|
|
47
|
+
elif case.find("skipped") is not None:
|
|
48
|
+
status = SKIP
|
|
49
|
+
else:
|
|
50
|
+
status = PASS
|
|
51
|
+
results[tid] = status
|
|
52
|
+
return results
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_generic_json(path: str | Path) -> dict[str, str]:
|
|
56
|
+
"""Parse {"tests": [{"id": ..., "status": "passed|failed|skipped"}]}."""
|
|
57
|
+
path = Path(path)
|
|
58
|
+
try:
|
|
59
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
60
|
+
except json.JSONDecodeError as exc:
|
|
61
|
+
raise ValueError(f"{path}: not valid JSON ({exc})") from exc
|
|
62
|
+
items = data.get("tests", data if isinstance(data, list) else [])
|
|
63
|
+
results: dict[str, str] = {}
|
|
64
|
+
for item in items:
|
|
65
|
+
tid = str(item.get("id", item.get("name", "unknown")))
|
|
66
|
+
status = str(item.get("status", item.get("outcome", PASS))).lower()
|
|
67
|
+
if status in {"failure", "fail", "failed", "error"}:
|
|
68
|
+
status = FAIL
|
|
69
|
+
elif status in {"skip", "skipped", "xskip"}:
|
|
70
|
+
status = SKIP
|
|
71
|
+
else:
|
|
72
|
+
status = PASS
|
|
73
|
+
results[tid] = status
|
|
74
|
+
return results
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_file(path: str | Path) -> dict[str, str]:
|
|
78
|
+
"""Auto-detect format by extension/content -> {test_id: status}."""
|
|
79
|
+
path = Path(path)
|
|
80
|
+
if not path.exists():
|
|
81
|
+
raise ValueError(f"{path}: file not found")
|
|
82
|
+
if path.suffix.lower() == ".json":
|
|
83
|
+
return parse_generic_json(path)
|
|
84
|
+
head = path.read_bytes()[:2000].lstrip()
|
|
85
|
+
if head.startswith(b"{") or head.startswith(b"["):
|
|
86
|
+
return parse_generic_json(path)
|
|
87
|
+
return parse_junit_xml(path)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_runs(paths: list[str | Path]) -> list[dict[str, str]]:
|
|
91
|
+
"""Load N result files (e.g. N repeated runs of the same suite)."""
|
|
92
|
+
runs = []
|
|
93
|
+
for p in paths:
|
|
94
|
+
runs.append(parse_file(p))
|
|
95
|
+
return runs
|