tewip-ai 0.70.12 → 0.70.14
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.
- package/QUICKSTART.md +178 -178
- package/dist/engine/autofix/code-propose.js +8 -8
- package/dist/engine/autofix/selector-source.js +2 -1
- package/dist/engine/build/build.js +22 -22
- package/dist/engine/flaky/flaky-command.js +39 -3
- package/dist/engine/import/blob.js +1 -1
- package/dist/engine/import/junit.js +7 -2
- package/dist/engine/import/shortfall.js +62 -0
- package/dist/engine/import/xml.js +4 -0
- package/dist/engine/llm/prompt.js +22 -22
- package/dist/engine/quality/grounding.js +5 -1
- package/dist/engine/review/agreements.js +3 -1
- package/dist/engine/review/expectations.js +2 -1
- package/dist/engine/secure/deps.js +110 -64
- package/dist/engine/secure/secure-command.js +7 -2
- package/dist/engine/secure/zap.js +9 -3
- package/dist/engine/vouch/vouch-command.js +24 -0
- package/dist/scripts/explain.js +13 -2
- package/package.json +1 -1
package/QUICKSTART.md
CHANGED
|
@@ -1,179 +1,179 @@
|
|
|
1
|
-
# Quickstart: putting Tewip on one repository
|
|
2
|
-
|
|
3
|
-
For an engineer adding the agent to a project that already runs Playwright tests in GitHub Actions. It takes about ten minutes, and the first useful output arrives with the next failing run.
|
|
4
|
-
|
|
5
|
-
Start with **one** repository whose tests fail sometimes. A repository whose suite is always green has nothing to triage.
|
|
6
|
-
|
|
7
|
-
## What the agent does once it is installed
|
|
8
|
-
|
|
9
|
-
Every CI run, for every failed test, it decides the root cause — application bug, changed selector, race, environment, or test data — with a confidence, the evidence it used, and one concrete next action. It then delivers that to the people who need it: a comment on the pull request, the board in the job summary, one grouped issue for environment failures, and optionally a separate pull request with locator fixes that it re-ran and verified.
|
|
10
|
-
|
|
11
|
-
It never merges anything, never edits application code, and never opens a fix pull request for a change it has not proved by re-running the test.
|
|
12
|
-
|
|
13
|
-
## Step 1: make Playwright leave the evidence behind
|
|
14
|
-
|
|
15
|
-
The agent reads Playwright's own blob report and traces, so nothing from this project goes into your test config:
|
|
16
|
-
|
|
17
|
-
```ts
|
|
18
|
-
// playwright.config.ts
|
|
19
|
-
reporter: process.env.CI ? [['list'], ['blob']] : 'list',
|
|
20
|
-
use: { trace: 'retain-on-failure' },
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
If your config already uses a reporter list in CI, add `['blob']` to it.
|
|
24
|
-
|
|
25
|
-
## Step 2: add one step to the workflow that runs the tests
|
|
26
|
-
|
|
27
|
-
```yaml
|
|
28
|
-
permissions:
|
|
29
|
-
pull-requests: write # the diagnosis comment
|
|
1
|
+
# Quickstart: putting Tewip on one repository
|
|
2
|
+
|
|
3
|
+
For an engineer adding the agent to a project that already runs Playwright tests in GitHub Actions. It takes about ten minutes, and the first useful output arrives with the next failing run.
|
|
4
|
+
|
|
5
|
+
Start with **one** repository whose tests fail sometimes. A repository whose suite is always green has nothing to triage.
|
|
6
|
+
|
|
7
|
+
## What the agent does once it is installed
|
|
8
|
+
|
|
9
|
+
Every CI run, for every failed test, it decides the root cause — application bug, changed selector, race, environment, or test data — with a confidence, the evidence it used, and one concrete next action. It then delivers that to the people who need it: a comment on the pull request, the board in the job summary, one grouped issue for environment failures, and optionally a separate pull request with locator fixes that it re-ran and verified.
|
|
10
|
+
|
|
11
|
+
It never merges anything, never edits application code, and never opens a fix pull request for a change it has not proved by re-running the test.
|
|
12
|
+
|
|
13
|
+
## Step 1: make Playwright leave the evidence behind
|
|
14
|
+
|
|
15
|
+
The agent reads Playwright's own blob report and traces, so nothing from this project goes into your test config:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// playwright.config.ts
|
|
19
|
+
reporter: process.env.CI ? [['list'], ['blob']] : 'list',
|
|
20
|
+
use: { trace: 'retain-on-failure' },
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
If your config already uses a reporter list in CI, add `['blob']` to it.
|
|
24
|
+
|
|
25
|
+
## Step 2: add one step to the workflow that runs the tests
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
permissions:
|
|
29
|
+
pull-requests: write # the diagnosis comment
|
|
30
30
|
issues: write
|
|
31
|
-
actions: read # so it can read a failed job's log when no report was written # the grouped environment issue
|
|
32
|
-
contents: write # only if you turn autofix on
|
|
33
|
-
|
|
34
|
-
steps:
|
|
35
|
-
# ... your existing checkout, install, and `npx playwright test` steps ...
|
|
36
|
-
|
|
37
|
-
- name: Tewip
|
|
38
|
-
if: always() # it must run even though the test step failed
|
|
39
|
-
uses: hmamut39/tewip@main
|
|
40
|
-
with:
|
|
41
|
-
openai-api-key: ${{ secrets.OPENAI_API_KEY }} # optional, see step 3
|
|
42
|
-
autofix: 'false' # start with this off
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
`if: always()` matters: without it the step is skipped exactly when there is something to triage.
|
|
46
|
-
|
|
47
|
-
Keep `autofix: 'false'` for the first week. Read what the agent says, decide whether you trust it, and turn fixing on afterwards.
|
|
48
|
-
|
|
49
|
-
## Step 3: decide whether to give it an API key
|
|
50
|
-
|
|
51
|
-
| Without a key | With `OPENAI_API_KEY` |
|
|
52
|
-
|---|---|
|
|
53
|
-
| Only the deterministic rules run | The rules run first, and only what they cannot settle goes to the model |
|
|
54
|
-
| Every answer is explainable from the error, the trace and the test's history | Adds a judgement on the rest, with its reasoning and evidence shown |
|
|
55
|
-
| Free | A few cents per run in practice, because the rules answer most failures |
|
|
56
|
-
|
|
57
|
-
On real projects the rules alone decide most failures and are right on 96–98% of what they decide, so starting without a key is a reasonable first week. To add one later: repository **Settings → Secrets and variables → Actions → New repository secret**, named `OPENAI_API_KEY`.
|
|
58
|
-
|
|
59
|
-
Every run prints what it cost, priced from OpenAI's published prices. One guard worth setting once the key is in place:
|
|
60
|
-
|
|
61
|
-
```yaml
|
|
62
|
-
env:
|
|
63
|
-
TRIAGE_LLM_MAX_CALLS: '80' # hard cap on model calls per run
|
|
64
|
-
# TRIAGE_LLM_PRICE_IN / _OUT: only if your price differs (per million tokens)
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
## Not using Playwright? Every stack works
|
|
68
|
-
|
|
69
|
-
Point the agent at your test runner's **JUnit XML** report (or .NET's **TRX**) instead of a Playwright blob report. Each runner marked *verified* was run for real in CI (`fixtures/polyglot`) and its report is checked by the test suite on every change.
|
|
70
|
-
|
|
71
|
-
| Stack | Runner | How to write the report | Status |
|
|
72
|
-
|---|---|---|---|
|
|
73
|
-
| **Node.js** | node:test (built in) | `node --test --test-reporter=junit --test-reporter-destination=report.xml` | verified |
|
|
74
|
-
| Node.js, React, Vue, Angular, Next.js, NestJS, Express | Jest | `jest --reporters=default --reporters=jest-junit` | verified |
|
|
75
|
-
| Vite, Vue, Svelte, Nuxt, React | Vitest | `vitest run --reporter=junit --outputFile=report.xml` | verified |
|
|
76
|
-
| Node.js, Express | Mocha | `mocha --reporter mocha-junit-reporter` | verified |
|
|
77
|
-
| **Python**, Django, Flask, FastAPI | pytest (also runs `unittest`) | `pytest --junitxml=report.xml` (retries: `pytest-rerunfailures`) | verified |
|
|
78
|
-
| **Java**, Spring, Kotlin | JUnit 4/5, TestNG via Maven Surefire | written by default to `target/surefire-reports/` (retries: `rerunFailingTestsCount`) | verified |
|
|
79
|
-
| Java, Kotlin, Android | Gradle | written by default to `build/test-results/test/` | same format as Surefire |
|
|
80
|
-
| **Go** | gotestsum | `gotestsum --junitfile report.xml` | verified |
|
|
81
|
-
| **.NET** (C#, F#), ASP.NET | xUnit, NUnit, MSTest | `dotnet test --logger trx` (native) or `--logger junit` | both verified |
|
|
82
|
-
| **Ruby**, Rails | RSpec | `rspec --format RspecJunitFormatter --out report.xml` | verified |
|
|
83
|
-
| **PHP**, Laravel, Symfony | PHPUnit | `phpunit --log-junit report.xml` | verified |
|
|
84
|
-
| **Rust** | cargo-nextest | `[profile.ci.junit]` in `.config/nextest.toml` | verified |
|
|
85
|
-
| End-to-end | **Selenium** (Java, Python, JavaScript, C#) | through the runner above it: Surefire, pytest, Jest/Mocha, `dotnet test` | JUnit format; error wording read |
|
|
86
|
-
| End-to-end | Cypress, WebdriverIO | `mocha-junit-reporter` / `@wdio/junit-reporter` | JUnit format; error wording read |
|
|
87
|
-
| End-to-end | **Playwright** | blob reporter (step 1) — adds traces, selector drift and verified fixes | verified on 5 real projects |
|
|
88
|
-
|
|
89
|
-
```yaml
|
|
90
|
-
- name: Tewip
|
|
91
|
-
if: always()
|
|
92
|
-
uses: hmamut39/tewip@main
|
|
93
|
-
with:
|
|
94
|
-
junit-report: report.xml # a file, or a directory of them (.xml and .trx)
|
|
95
|
-
suite: unit # only needed when one workflow triages several suites
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
What it reads from any of these: the failure text, retries, and each test's history. From that it tells apart a wrong value (a real bug once the test has a passing history), an unreachable service (environment), a data collision, and a test that failed then passed on a retry (timing), in the wording each language uses: `expected: <4> but was: <3>` in Java, `got 3, want 4` in Go, `AssertionError: 3 != 4` in Python, `Expected: 4 / Actual: 3` in .NET, and so on. It also reads each browser tool's own words for the same three problems: an element that was never found (Selenium's `NoSuchElementException`, WebdriverIO's `still not displayed`, Cypress's `Expected to find element`), a click another element received (Selenium's `Other element would receive the click`, Cypress's `is being covered by another element`), and an element the page replaced mid-test (Selenium's `StaleElementReferenceException`, Cypress's `the page updated while this command was executing`). No labelled Selenium or Cypress runs have measured those rules yet, so they report at a confidence the merge gate does not trust on its own. What it cannot do without Playwright's traces is look at the page, so selector drift and automatic fixes stay Playwright-only.
|
|
99
|
-
|
|
100
|
-
A wrong value on a test's **first** recorded failure stays undecided rather than guessed: one failure alone does not show whether the test was reliable before. It becomes a real-bug diagnosis once the test's history shows it passing and then failing the same way.
|
|
101
|
-
|
|
102
|
-
When one workflow triages several suites (say `unit` and `e2e`), give each a `suite` name: each then keeps its own history and its own comment, and corrections name the suite, as in `/triage TIMING unit#2 flaky on CI`.
|
|
103
|
-
|
|
104
|
-
## On your own machine, without CI
|
|
105
|
-
|
|
106
|
-
Install the command once from the release file, `npm install -g tewip-<version>.tgz` (Tewip is not on the npm registry yet; Node 22.18 or newer), run your tests with the report switched on (see the table above), then:
|
|
107
|
-
|
|
108
|
-
```bash
|
|
109
|
-
tewip explain report.xml # JUnit XML or .trx
|
|
110
|
-
tewip explain reports/ # a folder of them
|
|
111
|
-
tewip explain blob-report/ # Playwright
|
|
112
|
-
tewip ci owner/repo --pr 12 # what Tewip's GitHub Action said about a pull request
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
It prints the same triage board CI writes: each failure's cause, confidence, evidence and next action. Each run is kept in a `.tewip/` folder in your project (add it to `.gitignore`), so from the second run on, failures are judged against their history exactly as in CI. `--name` keeps separate histories for different projects.
|
|
116
|
-
|
|
117
|
-
## What you will see on the next failing run
|
|
118
|
-
|
|
119
|
-
- **On the pull request:** one comment listing each failing test, grouped by whether your change likely caused it, each with a cause, a confidence, the evidence and the next action. It updates in place rather than adding a comment per run.
|
|
120
|
-
- **In the job summary:** the same failures as a triage board.
|
|
121
|
-
- **As an issue:** environment failures (5xx responses, connection resets, outages) grouped into one issue instead of one per test.
|
|
122
|
-
- **As an artifact** named `tewip-report`: every role's view, including the engineering-quality view with the flaky tests ranked by the CI minutes they cost.
|
|
123
|
-
|
|
124
|
-
## Correcting it
|
|
125
|
-
|
|
126
|
-
When it gets one wrong, reply to its comment:
|
|
127
|
-
|
|
128
|
-
```
|
|
129
|
-
/triage TIMING #2 this one is flaky on our CI runners, not a bug
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
From the next run on, that failure of that test gets your answer, shown as "corrected by a person" with your name and reason. A correction settles the cause; it never makes the agent change code.
|
|
133
|
-
|
|
134
|
-
## Asking it questions from your editor
|
|
135
|
-
|
|
136
|
-
- **VS Code, Cursor, Windsurf, VSCodium:** install the Tewip extension (*Extensions → … → Install from VSIX*, `tewip-<version>.vsix`). It connects Tewip to the editor's AI assistant by itself and adds *Tewip: Explain a test report…* and *Tewip: Why did CI fail on this branch?*
|
|
137
|
-
- **JetBrains (2025.2+), Claude, and other MCP clients:** add one server whose command is `tewip mcp`.
|
|
138
|
-
|
|
139
|
-
Then ask, in plain language: "why did CI fail on my PR?", "which tests are flaky in checkout?", "is main safe to release?". Every tool is read-only, and it reads the report the Action already uploaded, so you do not need the other repository cloned.
|
|
140
|
-
|
|
141
|
-
## Stop flaky tests from blocking merges (optional)
|
|
142
|
-
|
|
143
|
-
Once you trust its answers, let the agent decide whether a failed run should block:
|
|
144
|
-
|
|
145
|
-
```yaml
|
|
146
|
-
- name: Tests
|
|
147
|
-
continue-on-error: true # the gate below decides
|
|
148
|
-
run: npx playwright test
|
|
149
|
-
- name: Tewip
|
|
150
|
-
if: always()
|
|
151
|
-
uses: hmamut39/tewip@main
|
|
152
|
-
with:
|
|
153
|
-
gate: real-failures
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
The build then fails only for real bugs, changed selectors, test-data problems and anything undecided. Failures a rule or a person classified as flaky or environment no longer block. On 2,296 real failures this unblocked 84% of the flaky and environment ones without letting a real bug through.
|
|
157
|
-
|
|
158
|
-
## Turning fixing on, once you trust it
|
|
159
|
-
|
|
160
|
-
```yaml
|
|
161
|
-
autofix: 'true'
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
It then rewrites a drifted locator, re-runs that single test, and opens a **separate** pull request only with the changes that passed. Review it like any other pull request: an intended UI change and a regression can look identical from the outside, which is exactly why a human merges it.
|
|
165
|
-
|
|
166
|
-
On real WordPress CI data it would have fixed 14 drifted locators with none wrong, and every locator it suggested matched what the developers themselves wrote.
|
|
167
|
-
|
|
168
|
-
## If something does not appear
|
|
169
|
-
|
|
170
|
-
| Symptom | Cause |
|
|
171
|
-
|---|---|
|
|
172
|
-
| The step ran but says "no blob report" | The blob reporter is not on in CI, or the tests step did not run |
|
|
173
|
-
| No comment on the pull request | The run was not triggered by a pull request, or `pull-requests: write` is missing |
|
|
174
|
-
| "Resource not accessible by integration" | The workflow's permissions block is missing an entry |
|
|
175
|
-
| Diagnoses look thin on the first day | The agent judges each failure against that test's history, which it builds from your runs; it gets better as runs accumulate |
|
|
176
|
-
|
|
177
|
-
## What it costs, and what it keeps
|
|
178
|
-
|
|
179
|
-
The agent stores the run history in the Actions cache, never in your repository. Collected failure data and traces stay in CI. The only thing it writes to your repository is a pull request branch, and only when you turn fixing on.
|
|
31
|
+
actions: read # so it can read a failed job's log when no report was written # the grouped environment issue
|
|
32
|
+
contents: write # only if you turn autofix on
|
|
33
|
+
|
|
34
|
+
steps:
|
|
35
|
+
# ... your existing checkout, install, and `npx playwright test` steps ...
|
|
36
|
+
|
|
37
|
+
- name: Tewip
|
|
38
|
+
if: always() # it must run even though the test step failed
|
|
39
|
+
uses: hmamut39/tewip@main
|
|
40
|
+
with:
|
|
41
|
+
openai-api-key: ${{ secrets.OPENAI_API_KEY }} # optional, see step 3
|
|
42
|
+
autofix: 'false' # start with this off
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`if: always()` matters: without it the step is skipped exactly when there is something to triage.
|
|
46
|
+
|
|
47
|
+
Keep `autofix: 'false'` for the first week. Read what the agent says, decide whether you trust it, and turn fixing on afterwards.
|
|
48
|
+
|
|
49
|
+
## Step 3: decide whether to give it an API key
|
|
50
|
+
|
|
51
|
+
| Without a key | With `OPENAI_API_KEY` |
|
|
52
|
+
|---|---|
|
|
53
|
+
| Only the deterministic rules run | The rules run first, and only what they cannot settle goes to the model |
|
|
54
|
+
| Every answer is explainable from the error, the trace and the test's history | Adds a judgement on the rest, with its reasoning and evidence shown |
|
|
55
|
+
| Free | A few cents per run in practice, because the rules answer most failures |
|
|
56
|
+
|
|
57
|
+
On real projects the rules alone decide most failures and are right on 96–98% of what they decide, so starting without a key is a reasonable first week. To add one later: repository **Settings → Secrets and variables → Actions → New repository secret**, named `OPENAI_API_KEY`.
|
|
58
|
+
|
|
59
|
+
Every run prints what it cost, priced from OpenAI's published prices. One guard worth setting once the key is in place:
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
env:
|
|
63
|
+
TRIAGE_LLM_MAX_CALLS: '80' # hard cap on model calls per run
|
|
64
|
+
# TRIAGE_LLM_PRICE_IN / _OUT: only if your price differs (per million tokens)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Not using Playwright? Every stack works
|
|
68
|
+
|
|
69
|
+
Point the agent at your test runner's **JUnit XML** report (or .NET's **TRX**) instead of a Playwright blob report. Each runner marked *verified* was run for real in CI (`fixtures/polyglot`) and its report is checked by the test suite on every change.
|
|
70
|
+
|
|
71
|
+
| Stack | Runner | How to write the report | Status |
|
|
72
|
+
|---|---|---|---|
|
|
73
|
+
| **Node.js** | node:test (built in) | `node --test --test-reporter=junit --test-reporter-destination=report.xml` | verified |
|
|
74
|
+
| Node.js, React, Vue, Angular, Next.js, NestJS, Express | Jest | `jest --reporters=default --reporters=jest-junit` | verified |
|
|
75
|
+
| Vite, Vue, Svelte, Nuxt, React | Vitest | `vitest run --reporter=junit --outputFile=report.xml` | verified |
|
|
76
|
+
| Node.js, Express | Mocha | `mocha --reporter mocha-junit-reporter` | verified |
|
|
77
|
+
| **Python**, Django, Flask, FastAPI | pytest (also runs `unittest`) | `pytest --junitxml=report.xml` (retries: `pytest-rerunfailures`) | verified |
|
|
78
|
+
| **Java**, Spring, Kotlin | JUnit 4/5, TestNG via Maven Surefire | written by default to `target/surefire-reports/` (retries: `rerunFailingTestsCount`) | verified |
|
|
79
|
+
| Java, Kotlin, Android | Gradle | written by default to `build/test-results/test/` | same format as Surefire |
|
|
80
|
+
| **Go** | gotestsum | `gotestsum --junitfile report.xml` | verified |
|
|
81
|
+
| **.NET** (C#, F#), ASP.NET | xUnit, NUnit, MSTest | `dotnet test --logger trx` (native) or `--logger junit` | both verified |
|
|
82
|
+
| **Ruby**, Rails | RSpec | `rspec --format RspecJunitFormatter --out report.xml` | verified |
|
|
83
|
+
| **PHP**, Laravel, Symfony | PHPUnit | `phpunit --log-junit report.xml` | verified |
|
|
84
|
+
| **Rust** | cargo-nextest | `[profile.ci.junit]` in `.config/nextest.toml` | verified |
|
|
85
|
+
| End-to-end | **Selenium** (Java, Python, JavaScript, C#) | through the runner above it: Surefire, pytest, Jest/Mocha, `dotnet test` | JUnit format; error wording read |
|
|
86
|
+
| End-to-end | Cypress, WebdriverIO | `mocha-junit-reporter` / `@wdio/junit-reporter` | JUnit format; error wording read |
|
|
87
|
+
| End-to-end | **Playwright** | blob reporter (step 1) — adds traces, selector drift and verified fixes | verified on 5 real projects |
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
- name: Tewip
|
|
91
|
+
if: always()
|
|
92
|
+
uses: hmamut39/tewip@main
|
|
93
|
+
with:
|
|
94
|
+
junit-report: report.xml # a file, or a directory of them (.xml and .trx)
|
|
95
|
+
suite: unit # only needed when one workflow triages several suites
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
What it reads from any of these: the failure text, retries, and each test's history. From that it tells apart a wrong value (a real bug once the test has a passing history), an unreachable service (environment), a data collision, and a test that failed then passed on a retry (timing), in the wording each language uses: `expected: <4> but was: <3>` in Java, `got 3, want 4` in Go, `AssertionError: 3 != 4` in Python, `Expected: 4 / Actual: 3` in .NET, and so on. It also reads each browser tool's own words for the same three problems: an element that was never found (Selenium's `NoSuchElementException`, WebdriverIO's `still not displayed`, Cypress's `Expected to find element`), a click another element received (Selenium's `Other element would receive the click`, Cypress's `is being covered by another element`), and an element the page replaced mid-test (Selenium's `StaleElementReferenceException`, Cypress's `the page updated while this command was executing`). No labelled Selenium or Cypress runs have measured those rules yet, so they report at a confidence the merge gate does not trust on its own. What it cannot do without Playwright's traces is look at the page, so selector drift and automatic fixes stay Playwright-only.
|
|
99
|
+
|
|
100
|
+
A wrong value on a test's **first** recorded failure stays undecided rather than guessed: one failure alone does not show whether the test was reliable before. It becomes a real-bug diagnosis once the test's history shows it passing and then failing the same way.
|
|
101
|
+
|
|
102
|
+
When one workflow triages several suites (say `unit` and `e2e`), give each a `suite` name: each then keeps its own history and its own comment, and corrections name the suite, as in `/triage TIMING unit#2 flaky on CI`.
|
|
103
|
+
|
|
104
|
+
## On your own machine, without CI
|
|
105
|
+
|
|
106
|
+
Install the command once from the release file, `npm install -g tewip-<version>.tgz` (Tewip is not on the npm registry yet; Node 22.18 or newer), run your tests with the report switched on (see the table above), then:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
tewip explain report.xml # JUnit XML or .trx
|
|
110
|
+
tewip explain reports/ # a folder of them
|
|
111
|
+
tewip explain blob-report/ # Playwright
|
|
112
|
+
tewip ci owner/repo --pr 12 # what Tewip's GitHub Action said about a pull request
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
It prints the same triage board CI writes: each failure's cause, confidence, evidence and next action. Each run is kept in a `.tewip/` folder in your project (add it to `.gitignore`), so from the second run on, failures are judged against their history exactly as in CI. `--name` keeps separate histories for different projects.
|
|
116
|
+
|
|
117
|
+
## What you will see on the next failing run
|
|
118
|
+
|
|
119
|
+
- **On the pull request:** one comment listing each failing test, grouped by whether your change likely caused it, each with a cause, a confidence, the evidence and the next action. It updates in place rather than adding a comment per run.
|
|
120
|
+
- **In the job summary:** the same failures as a triage board.
|
|
121
|
+
- **As an issue:** environment failures (5xx responses, connection resets, outages) grouped into one issue instead of one per test.
|
|
122
|
+
- **As an artifact** named `tewip-report`: every role's view, including the engineering-quality view with the flaky tests ranked by the CI minutes they cost.
|
|
123
|
+
|
|
124
|
+
## Correcting it
|
|
125
|
+
|
|
126
|
+
When it gets one wrong, reply to its comment:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
/triage TIMING #2 this one is flaky on our CI runners, not a bug
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
From the next run on, that failure of that test gets your answer, shown as "corrected by a person" with your name and reason. A correction settles the cause; it never makes the agent change code.
|
|
133
|
+
|
|
134
|
+
## Asking it questions from your editor
|
|
135
|
+
|
|
136
|
+
- **VS Code, Cursor, Windsurf, VSCodium:** install the Tewip extension (*Extensions → … → Install from VSIX*, `tewip-<version>.vsix`). It connects Tewip to the editor's AI assistant by itself and adds *Tewip: Explain a test report…* and *Tewip: Why did CI fail on this branch?*
|
|
137
|
+
- **JetBrains (2025.2+), Claude, and other MCP clients:** add one server whose command is `tewip mcp`.
|
|
138
|
+
|
|
139
|
+
Then ask, in plain language: "why did CI fail on my PR?", "which tests are flaky in checkout?", "is main safe to release?". Every tool is read-only, and it reads the report the Action already uploaded, so you do not need the other repository cloned.
|
|
140
|
+
|
|
141
|
+
## Stop flaky tests from blocking merges (optional)
|
|
142
|
+
|
|
143
|
+
Once you trust its answers, let the agent decide whether a failed run should block:
|
|
144
|
+
|
|
145
|
+
```yaml
|
|
146
|
+
- name: Tests
|
|
147
|
+
continue-on-error: true # the gate below decides
|
|
148
|
+
run: npx playwright test
|
|
149
|
+
- name: Tewip
|
|
150
|
+
if: always()
|
|
151
|
+
uses: hmamut39/tewip@main
|
|
152
|
+
with:
|
|
153
|
+
gate: real-failures
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The build then fails only for real bugs, changed selectors, test-data problems and anything undecided. Failures a rule or a person classified as flaky or environment no longer block. On 2,296 real failures this unblocked 84% of the flaky and environment ones without letting a real bug through.
|
|
157
|
+
|
|
158
|
+
## Turning fixing on, once you trust it
|
|
159
|
+
|
|
160
|
+
```yaml
|
|
161
|
+
autofix: 'true'
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
It then rewrites a drifted locator, re-runs that single test, and opens a **separate** pull request only with the changes that passed. Review it like any other pull request: an intended UI change and a regression can look identical from the outside, which is exactly why a human merges it.
|
|
165
|
+
|
|
166
|
+
On real WordPress CI data it would have fixed 14 drifted locators with none wrong, and every locator it suggested matched what the developers themselves wrote.
|
|
167
|
+
|
|
168
|
+
## If something does not appear
|
|
169
|
+
|
|
170
|
+
| Symptom | Cause |
|
|
171
|
+
|---|---|
|
|
172
|
+
| The step ran but says "no blob report" | The blob reporter is not on in CI, or the tests step did not run |
|
|
173
|
+
| No comment on the pull request | The run was not triggered by a pull request, or `pull-requests: write` is missing |
|
|
174
|
+
| "Resource not accessible by integration" | The workflow's permissions block is missing an entry |
|
|
175
|
+
| Diagnoses look thin on the first day | The agent judges each failure against that test's history, which it builds from your runs; it gets better as runs accumulate |
|
|
176
|
+
|
|
177
|
+
## What it costs, and what it keeps
|
|
178
|
+
|
|
179
|
+
The agent stores the run history in the Actions cache, never in your repository. Collected failure data and traces stay in CI. The only thing it writes to your repository is a pull request branch, and only when you turn fixing on.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export const CODE_FIX_PROMPT_VERSION = 'code-fix-1';
|
|
2
|
-
const SYSTEM = `You are Tewip's fixer. A test is failing against this code. Find the smallest change to the APPLICATION code that makes the test pass because the bug is gone, not because the check was avoided.
|
|
3
|
-
|
|
4
|
-
Rules:
|
|
5
|
-
- Change application code only. Never suggest changing the test, the expected values in it, configuration, or anything that silences the failure.
|
|
6
|
-
- The change must be minimal and local: the fewest lines that fix the cause. Do not refactor, rename, reformat, add logging, or "improve" anything nearby.
|
|
7
|
-
- before must be copied character for character from the file you were given, including indentation, and must appear in it exactly once. Include enough surrounding lines to be unique. Copy only what is in the file: nothing is added to it, so do not add line numbers or markers.
|
|
8
|
-
- after must keep the file valid in its language and keep the behaviour every other test relies on.
|
|
9
|
-
- If the code is right and the test is wrong, or if you cannot see the cause in the file you were given, set fault to "test" and return before and after as empty strings. Saying "I cannot see it" is a correct answer; a guess is not.
|
|
2
|
+
const SYSTEM = `You are Tewip's fixer. A test is failing against this code. Find the smallest change to the APPLICATION code that makes the test pass because the bug is gone, not because the check was avoided.
|
|
3
|
+
|
|
4
|
+
Rules:
|
|
5
|
+
- Change application code only. Never suggest changing the test, the expected values in it, configuration, or anything that silences the failure.
|
|
6
|
+
- The change must be minimal and local: the fewest lines that fix the cause. Do not refactor, rename, reformat, add logging, or "improve" anything nearby.
|
|
7
|
+
- before must be copied character for character from the file you were given, including indentation, and must appear in it exactly once. Include enough surrounding lines to be unique. Copy only what is in the file: nothing is added to it, so do not add line numbers or markers.
|
|
8
|
+
- after must keep the file valid in its language and keep the behaviour every other test relies on.
|
|
9
|
+
- If the code is right and the test is wrong, or if you cannot see the cause in the file you were given, set fault to "test" and return before and after as empty strings. Saying "I cannot see it" is a correct answer; a guess is not.
|
|
10
10
|
- why is for a reviewer: what was wrong and why this fixes it.`;
|
|
11
11
|
const SCHEMA = {
|
|
12
12
|
type: 'object',
|
|
@@ -62,7 +62,8 @@ function commonPrefix(a, b) {
|
|
|
62
62
|
}
|
|
63
63
|
const git = (root, args) => {
|
|
64
64
|
try {
|
|
65
|
-
|
|
65
|
+
// Captured, not inherited: see the note in grounding.ts.
|
|
66
|
+
return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
66
67
|
}
|
|
67
68
|
catch {
|
|
68
69
|
return '';
|
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
export const BUILD_PROMPT_VERSION = 'build-1';
|
|
2
|
-
const RULES = `Rules that do not bend:
|
|
3
|
-
- Use only what the project already has: its framework, its style, its helpers, its way of importing. Copy the shape of the example test and the example source given.
|
|
4
|
-
- Never weaken or delete an existing test, and never change what one asserts.
|
|
5
|
-
- Keep the change small and local. Add a file when the project's own layout calls for one; do not reorganise anything.
|
|
6
|
-
- Write no comments that restate the code, no logging, no defensive try/catch around things that cannot fail.
|
|
2
|
+
const RULES = `Rules that do not bend:
|
|
3
|
+
- Use only what the project already has: its framework, its style, its helpers, its way of importing. Copy the shape of the example test and the example source given.
|
|
4
|
+
- Never weaken or delete an existing test, and never change what one asserts.
|
|
5
|
+
- Keep the change small and local. Add a file when the project's own layout calls for one; do not reorganise anything.
|
|
6
|
+
- Write no comments that restate the code, no logging, no defensive try/catch around things that cannot fail.
|
|
7
7
|
- before must be copied character for character from the file given and appear in it exactly once. For a new file, before is empty and after is the whole file.`;
|
|
8
|
-
const TEST_SYSTEM = `You are Tewip, writing the test for a feature that does not exist yet.
|
|
9
|
-
|
|
10
|
-
Write ONE test that checks the described behaviour, in the project's own framework and style. It must fail when run now, and fail for the right reason: the behaviour is missing, not because of a typo, a missing import or a file that does not exist. Use only functions, files and exports that already exist, or the one new export the feature obviously needs - name it as the project would.
|
|
11
|
-
|
|
12
|
-
checks is one sentence naming the behaviour the test asserts ("a full URL keeps its query when the trailing slash is removed"). It is not advice about file names or where to put things.
|
|
13
|
-
failsBecause is the behaviour that is missing today, in one sentence.
|
|
14
|
-
|
|
8
|
+
const TEST_SYSTEM = `You are Tewip, writing the test for a feature that does not exist yet.
|
|
9
|
+
|
|
10
|
+
Write ONE test that checks the described behaviour, in the project's own framework and style. It must fail when run now, and fail for the right reason: the behaviour is missing, not because of a typo, a missing import or a file that does not exist. Use only functions, files and exports that already exist, or the one new export the feature obviously needs - name it as the project would.
|
|
11
|
+
|
|
12
|
+
checks is one sentence naming the behaviour the test asserts ("a full URL keeps its query when the trailing slash is removed"). It is not advice about file names or where to put things.
|
|
13
|
+
failsBecause is the behaviour that is missing today, in one sentence.
|
|
14
|
+
|
|
15
15
|
${RULES}`;
|
|
16
|
-
const CODE_SYSTEM = `You are Tewip, writing the code that makes a failing test pass.
|
|
17
|
-
|
|
18
|
-
You are given the feature, the test, the real error it produced, and the project's relevant source. Write the smallest change that makes that test pass because the behaviour now exists - never by weakening the test, never by special-casing the test's own values.
|
|
19
|
-
|
|
20
|
-
If the feature cannot be built from what you were given - the code you need is not here, or the request is ambiguous in a way that changes the answer - set outcome to "cannot" and return no edits. Saying "I cannot" is a correct answer; a guess that happens to pass one test is not.
|
|
21
|
-
|
|
16
|
+
const CODE_SYSTEM = `You are Tewip, writing the code that makes a failing test pass.
|
|
17
|
+
|
|
18
|
+
You are given the feature, the test, the real error it produced, and the project's relevant source. Write the smallest change that makes that test pass because the behaviour now exists - never by weakening the test, never by special-casing the test's own values.
|
|
19
|
+
|
|
20
|
+
If the feature cannot be built from what you were given - the code you need is not here, or the request is ambiguous in a way that changes the answer - set outcome to "cannot" and return no edits. Saying "I cannot" is a correct answer; a guess that happens to pass one test is not.
|
|
21
|
+
|
|
22
22
|
${RULES}`;
|
|
23
|
-
const REPAIR_SYSTEM = `You are Tewip, fixing code you just wrote. The test still fails, or other tests broke. You are given what you changed and the real output.
|
|
24
|
-
|
|
25
|
-
Change your own code so the failing test passes and everything that was passing still passes. Never change the test. If you cannot, set outcome to "cannot".
|
|
26
|
-
|
|
23
|
+
const REPAIR_SYSTEM = `You are Tewip, fixing code you just wrote. The test still fails, or other tests broke. You are given what you changed and the real output.
|
|
24
|
+
|
|
25
|
+
Change your own code so the failing test passes and everything that was passing still passes. Never change the test. If you cannot, set outcome to "cannot".
|
|
26
|
+
|
|
27
27
|
${RULES}`;
|
|
28
28
|
const TEST_SCHEMA = {
|
|
29
29
|
type: 'object',
|
|
@@ -8,6 +8,9 @@ import { flakyTests, readyToRelease, weeklyCost } from './flaky.js';
|
|
|
8
8
|
import { applyQuarantine, frameworkOf, quarantineFor } from './quarantine.js';
|
|
9
9
|
import { loadVerdicts, saidByAPerson, verdictFor } from '../learn/verdicts.js';
|
|
10
10
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
/** How many tests each group lists, and how many quarantine lines are written out in full. */
|
|
12
|
+
const SHOW = 15;
|
|
13
|
+
const SHOW_LINES = 5;
|
|
11
14
|
const minutes = (ms) => `${(ms / 60_000).toFixed(1)} CI min`;
|
|
12
15
|
function projects(root, only) {
|
|
13
16
|
const dir = join(root, '.tewip', 'history');
|
|
@@ -50,7 +53,7 @@ export async function runFlaky(cmd) {
|
|
|
50
53
|
if (chosen.length === 0)
|
|
51
54
|
return;
|
|
52
55
|
lines.push(`### ${title} (${chosen.length})`, '', explain, '');
|
|
53
|
-
for (const test of chosen.slice(0,
|
|
56
|
+
for (const test of chosen.slice(0, SHOW)) {
|
|
54
57
|
lines.push(`- **${test.title}**`);
|
|
55
58
|
lines.push(` - ${test.failedRuns} of ${test.totalRuns} run(s), ${test.failedAttempts} failed attempt(s), ${minutes(test.wastedMs)} lost${test.recoveredOnRetry > 0 ? `, passed on a retry ${test.recoveredOnRetry} time(s)` : ''}`);
|
|
56
59
|
lines.push(` - Tewip read them as: ${test.categories.map((c) => `${c.category} ×${c.count}`).join(', ')}`);
|
|
@@ -58,6 +61,10 @@ export async function runFlaky(cmd) {
|
|
|
58
61
|
if (test.file)
|
|
59
62
|
lines.push(` - Where: ${test.file}`);
|
|
60
63
|
}
|
|
64
|
+
// A heading that says 48 above a list of 15 is the defect this project keeps finding in
|
|
65
|
+
// itself: a true number beside a shorter answer, with nothing saying they differ.
|
|
66
|
+
if (chosen.length > SHOW)
|
|
67
|
+
lines.push(`_Showing the ${SHOW} costliest. The other ${chosen.length - SHOW} are not listed here._`);
|
|
61
68
|
lines.push('');
|
|
62
69
|
};
|
|
63
70
|
group('quarantine', 'Quarantine these', 'Unreliable rather than wrong. Stop them blocking everyone, then fix them on purpose.');
|
|
@@ -82,6 +89,7 @@ export async function runFlaky(cmd) {
|
|
|
82
89
|
lines.push(...applyAll(cmd.root, toQuarantine));
|
|
83
90
|
else if (toQuarantine.length > 0)
|
|
84
91
|
lines.push(...suggest(cmd.root, toQuarantine));
|
|
92
|
+
lines.push(...notChecked(all, cmd.days));
|
|
85
93
|
const report = lines.join('\n');
|
|
86
94
|
console.log(report);
|
|
87
95
|
if (cmd.out) {
|
|
@@ -97,7 +105,7 @@ function readQuarantined(root) {
|
|
|
97
105
|
}
|
|
98
106
|
function suggest(root, tests) {
|
|
99
107
|
const lines = ['### The line to add', ''];
|
|
100
|
-
for (const test of tests.slice(0,
|
|
108
|
+
for (const test of tests.slice(0, SHOW_LINES)) {
|
|
101
109
|
const path = testFile(root, test);
|
|
102
110
|
const framework = path ? frameworkOf(path, readFileSync(path, 'utf8')) : null;
|
|
103
111
|
if (!path || !framework) {
|
|
@@ -106,7 +114,10 @@ function suggest(root, tests) {
|
|
|
106
114
|
}
|
|
107
115
|
lines.push(`- ${test.title} (${test.file}):`, '```', quarantineFor(test, framework).line, '```');
|
|
108
116
|
}
|
|
109
|
-
|
|
117
|
+
const shown = Math.min(tests.length, SHOW_LINES);
|
|
118
|
+
lines.push('', shown < tests.length
|
|
119
|
+
? `Showing ${shown} of ${tests.length}. \`tewip flaky --apply\` quarantines **all ${tests.length}**, not only the ${shown} printed here - each with its own evidence written above it. No test outside those ${tests.length} is touched.`
|
|
120
|
+
: 'Run `tewip flaky --apply` to put these lines in, each with the evidence above it. Only the named tests are touched.', '');
|
|
110
121
|
return lines;
|
|
111
122
|
}
|
|
112
123
|
/** Writes the quarantine into the test files, and remembers which tests were quarantined. */
|
|
@@ -148,3 +159,28 @@ function applyAll(root, tests) {
|
|
|
148
159
|
lines.push('', 'Nothing else in those files was changed. Review the diff before committing: a quarantined test is a decision, not a fix.', '');
|
|
149
160
|
return lines;
|
|
150
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* What this report did not check.
|
|
164
|
+
*
|
|
165
|
+
* The quarantine rules in `decide` carry a rail: a test whose failures Tewip read as the
|
|
166
|
+
* application being wrong is never quarantined, because hiding it would hide the bug. That rail
|
|
167
|
+
* reads the diagnosed category of each failure - so on history where nothing has been diagnosed it
|
|
168
|
+
* has nothing to read and cannot fire, and the verdict rests on retry behaviour alone. On real
|
|
169
|
+
* history that is the normal case rather than the exception: of the 15 quarantine candidates this
|
|
170
|
+
* printed for WordPress/gutenberg, all 15 were UNKNOWN - every one. The recommendation is still the
|
|
171
|
+
* best reading of the evidence there is. What was missing was the sentence telling the person which
|
|
172
|
+
* of the two they were being shown.
|
|
173
|
+
*/
|
|
174
|
+
export function notChecked(tests, days) {
|
|
175
|
+
const quarantine = tests.filter((t) => t.verdict === 'quarantine');
|
|
176
|
+
const undiagnosed = quarantine.filter((t) => t.categories.every((c) => c.category === 'UNKNOWN'));
|
|
177
|
+
const lines = ['## What this did not check', ''];
|
|
178
|
+
lines.push(`- Any test that did not fail, and any run nobody put through \`tewip explain\` or \`tewip report\`. These numbers are read from kept history of the last ${days} day(s), and a suite nobody recorded looks exactly like a suite with no flaky tests.`);
|
|
179
|
+
if (undiagnosed.length > 0) {
|
|
180
|
+
lines.push(`- **${undiagnosed.length} of the ${quarantine.length} quarantine recommendation(s) rest on retry behaviour alone.** None of those failures had been diagnosed, so the rule that refuses to quarantine a test whose failures looked like the application being wrong had nothing to read and did not fire. A test that fails and then passes on a retry can also be a test correctly catching a race in the product - quarantine that one and the race is what you hide. Running \`tewip explain\` over those runs is what tells the two apart.`);
|
|
181
|
+
}
|
|
182
|
+
lines.push('- Whether a test is the only thing covering what it covers. Tewip does not know what stops being tested when you quarantine it.');
|
|
183
|
+
lines.push('- Whether a test is the same test. These are matched by name, so a renamed test looks like a new one and its history stays behind with the old name.');
|
|
184
|
+
lines.push('');
|
|
185
|
+
return lines;
|
|
186
|
+
}
|
|
@@ -161,5 +161,5 @@ function importShards(shards, meta, filesDir) {
|
|
|
161
161
|
status: runStatus,
|
|
162
162
|
attempts,
|
|
163
163
|
};
|
|
164
|
-
return { records, summary, skippedShards };
|
|
164
|
+
return { records, summary, skippedShards, incomplete: skippedShards.map((shard) => `shard ${shard} could not be read, so its tests are missing from this run`) };
|
|
165
165
|
}
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
// selector drift is not diagnosed from them. Every format below was checked against a report its
|
|
11
11
|
// real runner wrote (fixtures/polyglot, run by .github/workflows/polyglot-fixtures.yml).
|
|
12
12
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
13
|
-
import { join } from 'node:path';
|
|
13
|
+
import { basename, join } from 'node:path';
|
|
14
14
|
import { SCHEMA_VERSION } from '../../reporters/types.js';
|
|
15
15
|
import { descendants, parseXml } from './xml.js';
|
|
16
|
+
import { shortfall } from './shortfall.js';
|
|
16
17
|
import { jmeterCases, jmeterCsvCases, k6Cases, looksLikeCsvJtl, looksLikeK6, robotCases, robotTime } from './other-formats.js';
|
|
17
18
|
import { nunitCases, trxCases } from './dotnet.js';
|
|
18
19
|
import { PASSED, failure } from './case.js';
|
|
@@ -161,6 +162,7 @@ export function importJunit(path, meta) {
|
|
|
161
162
|
let startedAt = '';
|
|
162
163
|
let anyFailed = false;
|
|
163
164
|
const unread = [];
|
|
165
|
+
const incomplete = [];
|
|
164
166
|
let parsedFiles = 0;
|
|
165
167
|
for (const file of junitFiles(path)) {
|
|
166
168
|
const raw = readFileSync(file, 'utf8');
|
|
@@ -199,6 +201,9 @@ export function importJunit(path, meta) {
|
|
|
199
201
|
}
|
|
200
202
|
else if (root.name === 'testsuites' || root.name === 'testsuite' || root.name === 'testrun') {
|
|
201
203
|
cases = junitCases(root, meta.workspace);
|
|
204
|
+
const missing = shortfall(root);
|
|
205
|
+
if (missing)
|
|
206
|
+
incomplete.push(`${basename(file)}: ${missing}`);
|
|
202
207
|
const stamp = root.attrs.timestamp ?? root.children.find((c) => c.name === 'testsuite')?.attrs.timestamp;
|
|
203
208
|
if (stamp && (!startedAt || stamp < startedAt))
|
|
204
209
|
startedAt = stamp;
|
|
@@ -271,5 +276,5 @@ export function importJunit(path, meta) {
|
|
|
271
276
|
}
|
|
272
277
|
console.error(`[tewip] ignored ${unread.length} file(s) that are not a test report: ${list}`);
|
|
273
278
|
}
|
|
274
|
-
return { records, summary, skippedShards: [] };
|
|
279
|
+
return { records, summary, skippedShards: [], incomplete };
|
|
275
280
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Does a test report hold the run it says it holds?
|
|
2
|
+
//
|
|
3
|
+
// Kept apart from the importers because it asks nothing about any one format's shape: it compares a
|
|
4
|
+
// report's own declared totals with what is actually inside it, which is the one check that catches
|
|
5
|
+
// a file that parsed cleanly and is missing most of its contents.
|
|
6
|
+
import { descendants } from './xml.js';
|
|
7
|
+
/** A failing outcome, under either of the two names JUnit uses for one. */
|
|
8
|
+
const FAILURE = new Set(['failure', 'error']);
|
|
9
|
+
/**
|
|
10
|
+
* What the report says about itself, against what it actually contains.
|
|
11
|
+
*
|
|
12
|
+
* Every JUnit suite declares its own totals - `tests`, `failures`, `errors` - in the attributes of
|
|
13
|
+
* the element that holds the results. Nothing here ever read them, and a truncated upload is the
|
|
14
|
+
* commonest corruption there is: the runner dies, or the artifact is cut off mid-write, and what
|
|
15
|
+
* arrives is well-formed enough to parse and missing most of the run.
|
|
16
|
+
*
|
|
17
|
+
* Found by handing `tewip explain` a report whose header said `tests="500" failures="37"` and whose
|
|
18
|
+
* body held one passing testcase. The answer was **"All 1 test(s) passed. Nothing to explain."** The
|
|
19
|
+
* evidence that it was reading a fragment was in the file, two lines above the fragment.
|
|
20
|
+
*
|
|
21
|
+
* Only a shortfall is reported. A surplus is ordinary: rerun plugins write an entry per attempt
|
|
22
|
+
* while `tests` counts the test once, so finding more testcases than declared means a re-run, not a
|
|
23
|
+
* corrupt file.
|
|
24
|
+
*/
|
|
25
|
+
export function shortfall(root) {
|
|
26
|
+
// The one signal that does not depend on the report declaring anything. A streaming writer like
|
|
27
|
+
// node:test puts each suite's totals in its closing tag, so a report cut off in the middle loses
|
|
28
|
+
// the tests and the count of them together and has nothing left to disagree with itself about.
|
|
29
|
+
// What it cannot hide is the tag it never closed.
|
|
30
|
+
if (root.truncated) {
|
|
31
|
+
return 'the file ends in the middle of itself - an element is left open, so it was cut off while being written or uploaded. Whatever ran after that point is not in here, and Tewip cannot say how much that was.';
|
|
32
|
+
}
|
|
33
|
+
const num = (e, key) => {
|
|
34
|
+
const n = Number(e.attrs[key]);
|
|
35
|
+
return Number.isFinite(n) ? n : 0;
|
|
36
|
+
};
|
|
37
|
+
// Only the innermost suites are counted. PHPUnit wraps every suite in another suite carrying the
|
|
38
|
+
// same totals, and real PHPUnit output was read as half missing when both levels were added up -
|
|
39
|
+
// a false alarm on a perfectly good report, which is worse than the silence this replaces. A
|
|
40
|
+
// parent's totals already include its children's, so the leaves are the whole run exactly once.
|
|
41
|
+
const all = [...(root.name === 'testsuite' ? [root] : []), ...descendants(root, 'testsuite')];
|
|
42
|
+
const suites = all.filter((e) => descendants(e, 'testsuite').length === 0);
|
|
43
|
+
const totals = (key, fallback) => root.attrs[key] !== undefined
|
|
44
|
+
? num(root, key)
|
|
45
|
+
: suites.reduce((sum, e) => sum + fallback.reduce((s, k) => s + num(e, k), 0), 0);
|
|
46
|
+
const declaredTests = totals('tests', ['tests']);
|
|
47
|
+
const declaredBad = root.attrs.failures !== undefined || root.attrs.errors !== undefined
|
|
48
|
+
? num(root, 'failures') + num(root, 'errors')
|
|
49
|
+
: suites.reduce((sum, e) => sum + num(e, 'failures') + num(e, 'errors'), 0);
|
|
50
|
+
const cases = descendants(root, 'testcase');
|
|
51
|
+
const foundBad = cases.filter((c) => c.children.some((ch) => FAILURE.has(ch.name))).length;
|
|
52
|
+
const missingTests = declaredTests - cases.length;
|
|
53
|
+
const missingBad = declaredBad - foundBad;
|
|
54
|
+
if (missingTests <= 0 && missingBad <= 0)
|
|
55
|
+
return null;
|
|
56
|
+
const said = [];
|
|
57
|
+
if (missingTests > 0)
|
|
58
|
+
said.push(`${missingTests} of the ${declaredTests} test(s) it says it holds are not in it`);
|
|
59
|
+
if (missingBad > 0)
|
|
60
|
+
said.push(`${missingBad} of the ${declaredBad} failure(s) it reports are not in it`);
|
|
61
|
+
return `${said.join(', and ')}. This report is a fragment of its run - most likely cut off while being written or uploaded. Nothing below covers the missing part.`;
|
|
62
|
+
}
|
|
@@ -65,6 +65,10 @@ export function parseXml(source) {
|
|
|
65
65
|
const first = root.children[0];
|
|
66
66
|
if (!first)
|
|
67
67
|
throw new Error('not an XML document: no root element');
|
|
68
|
+
// Anything still on the stack is an element nobody closed. Well-formed XML leaves only the
|
|
69
|
+
// document itself behind.
|
|
70
|
+
if (stack.length > 1)
|
|
71
|
+
first.truncated = true;
|
|
68
72
|
return first;
|
|
69
73
|
}
|
|
70
74
|
function end(source, token, from) {
|
|
@@ -3,28 +3,28 @@
|
|
|
3
3
|
import { ROOT_CAUSES } from '../../reporters/types.js';
|
|
4
4
|
/** Bump whenever the prompt, schema or context format changes; it is part of the cache key. */
|
|
5
5
|
export const PROMPT_VERSION = 'llm-6';
|
|
6
|
-
export const SYSTEM_PROMPT = `You are the root-cause classifier of a Playwright test-failure triage agent.
|
|
7
|
-
You receive a structured summary of ONE failed test: the error, the test's run history, what the deterministic rules already checked, the test steps, the page at failure time (accessibility snapshot + notable DOM elements), failed network requests and console errors.
|
|
8
|
-
|
|
9
|
-
Classify the root cause into exactly one of these categories:
|
|
10
|
-
- REAL_BUG: the application under test behaves incorrectly. Examples: a wrong value is displayed; a feature or element the test needs is absent from the page and nothing equivalent exists; the app never reaches the expected state even though nothing external failed; uncaught page errors from application code.
|
|
11
|
-
- LOCATOR_DRIFT: the element the test wants is on the page, but the selector no longer matches it (renamed id, class, test id or text). The page summary must show an element with the same purpose.
|
|
12
|
-
- TIMING: the expected element or state does arrive, but not in time, or a race makes the result vary between attempts or runs. Signs: another attempt of the same code passed, intermittent history with varying errors, hard-coded waits, the element resolved but was not yet visible, stable or enabled.
|
|
13
|
-
- ENV: something outside the app code and the test broke: failed or 5xx/502/503 responses from a backend or third-party service, connection or DNS errors, expired auth or credentials, or many tests in the same run failing the same way.
|
|
14
|
-
- TEST_DATA: the test's preconditions or fixture data are wrong: missing or stale seeded data, a user or account in the wrong state, an expected value that depends on data the test did not set up, a 404 for a record the test assumed exists.
|
|
15
|
-
|
|
16
|
-
How to decide:
|
|
17
|
-
- Use only facts present in the summary. Do not invent page content, requests or history.
|
|
18
|
-
- Prefer the category the evidence directly shows over one that is merely possible.
|
|
19
|
-
- An element that is missing with no similar element on the page is not LOCATOR_DRIFT.
|
|
20
|
-
- Playwright matches accessible names by substring, so a locator can match several elements at once ("strict mode violation"). That is LOCATOR_DRIFT only when one of the matched elements is the one the test means, which usually carries the locator's name as a whole word ("Edit" -> "Edit link"). When every match only contains the text inside a longer word or phrase ("Edit" -> "Editor canvas", "Gift" -> "Gifting options"), the element the test means is absent: judge it as REAL_BUG, TIMING or TEST_DATA on the other evidence, not LOCATOR_DRIFT.
|
|
21
|
-
- A failed request or server error is only ENV evidence if the test did not provoke it. When "test.exercisesErrorHandling" is set, the test deliberately triggers failures (rejections, server errors, retries), so a failed request is the scenario under test: judge whether the app handled it as the test expects. If it did not, that is REAL_BUG, not ENV. A real environment outage also tends to hit unrelated tests and to pass on retry, rather than failing every attempt on one commit while other commits pass.
|
|
22
|
-
- Check "thisRun" first. If anotherAttemptPassed is true, the same code both failed and passed, so the cause is not deterministic: a real bug or a drifted locator would fail every attempt. Choose among TIMING, ENV and TEST_DATA.
|
|
23
|
-
- Then read "history.pattern", which compares this commit's attempts with other commits. If every attempt on this commit failed while other commits passed, something in this commit changed behaviour: choose among REAL_BUG, LOCATOR_DRIFT and TEST_DATA using the page summary, not TIMING. An overall failure rate that mixes different commits is not evidence of flakiness; only failing and passing on the same code is.
|
|
24
|
-
- The "rules" section lists what the deterministic rules already examined and why they declined; treat those as checked facts.
|
|
25
|
-
|
|
26
|
-
Confidence (0 to 1): 0.9 or higher only when the summary directly shows the cause; 0.6-0.8 when it is the most likely reading but another category remains plausible; below 0.5 when you are mostly guessing. Low confidence routes the failure to a human, so do not inflate it.
|
|
27
|
-
|
|
6
|
+
export const SYSTEM_PROMPT = `You are the root-cause classifier of a Playwright test-failure triage agent.
|
|
7
|
+
You receive a structured summary of ONE failed test: the error, the test's run history, what the deterministic rules already checked, the test steps, the page at failure time (accessibility snapshot + notable DOM elements), failed network requests and console errors.
|
|
8
|
+
|
|
9
|
+
Classify the root cause into exactly one of these categories:
|
|
10
|
+
- REAL_BUG: the application under test behaves incorrectly. Examples: a wrong value is displayed; a feature or element the test needs is absent from the page and nothing equivalent exists; the app never reaches the expected state even though nothing external failed; uncaught page errors from application code.
|
|
11
|
+
- LOCATOR_DRIFT: the element the test wants is on the page, but the selector no longer matches it (renamed id, class, test id or text). The page summary must show an element with the same purpose.
|
|
12
|
+
- TIMING: the expected element or state does arrive, but not in time, or a race makes the result vary between attempts or runs. Signs: another attempt of the same code passed, intermittent history with varying errors, hard-coded waits, the element resolved but was not yet visible, stable or enabled.
|
|
13
|
+
- ENV: something outside the app code and the test broke: failed or 5xx/502/503 responses from a backend or third-party service, connection or DNS errors, expired auth or credentials, or many tests in the same run failing the same way.
|
|
14
|
+
- TEST_DATA: the test's preconditions or fixture data are wrong: missing or stale seeded data, a user or account in the wrong state, an expected value that depends on data the test did not set up, a 404 for a record the test assumed exists.
|
|
15
|
+
|
|
16
|
+
How to decide:
|
|
17
|
+
- Use only facts present in the summary. Do not invent page content, requests or history.
|
|
18
|
+
- Prefer the category the evidence directly shows over one that is merely possible.
|
|
19
|
+
- An element that is missing with no similar element on the page is not LOCATOR_DRIFT.
|
|
20
|
+
- Playwright matches accessible names by substring, so a locator can match several elements at once ("strict mode violation"). That is LOCATOR_DRIFT only when one of the matched elements is the one the test means, which usually carries the locator's name as a whole word ("Edit" -> "Edit link"). When every match only contains the text inside a longer word or phrase ("Edit" -> "Editor canvas", "Gift" -> "Gifting options"), the element the test means is absent: judge it as REAL_BUG, TIMING or TEST_DATA on the other evidence, not LOCATOR_DRIFT.
|
|
21
|
+
- A failed request or server error is only ENV evidence if the test did not provoke it. When "test.exercisesErrorHandling" is set, the test deliberately triggers failures (rejections, server errors, retries), so a failed request is the scenario under test: judge whether the app handled it as the test expects. If it did not, that is REAL_BUG, not ENV. A real environment outage also tends to hit unrelated tests and to pass on retry, rather than failing every attempt on one commit while other commits pass.
|
|
22
|
+
- Check "thisRun" first. If anotherAttemptPassed is true, the same code both failed and passed, so the cause is not deterministic: a real bug or a drifted locator would fail every attempt. Choose among TIMING, ENV and TEST_DATA.
|
|
23
|
+
- Then read "history.pattern", which compares this commit's attempts with other commits. If every attempt on this commit failed while other commits passed, something in this commit changed behaviour: choose among REAL_BUG, LOCATOR_DRIFT and TEST_DATA using the page summary, not TIMING. An overall failure rate that mixes different commits is not evidence of flakiness; only failing and passing on the same code is.
|
|
24
|
+
- The "rules" section lists what the deterministic rules already examined and why they declined; treat those as checked facts.
|
|
25
|
+
|
|
26
|
+
Confidence (0 to 1): 0.9 or higher only when the summary directly shows the cause; 0.6-0.8 when it is the most likely reading but another category remains plausible; below 0.5 when you are mostly guessing. Low confidence routes the failure to a human, so do not inflate it.
|
|
27
|
+
|
|
28
28
|
Return JSON only: the category, the confidence, a short reasoning (at most 3 sentences), and 1-4 evidence items quoting or pointing at specific facts from the summary.`;
|
|
29
29
|
export const OUTPUT_SCHEMA_NAME = 'triage_diagnosis';
|
|
30
30
|
export const OUTPUT_SCHEMA = {
|
|
@@ -115,7 +115,11 @@ export function relatedTestSources(root, feature, maxFiles = 4, maxChars = 8_000
|
|
|
115
115
|
/** What a git range changed (e.g. "main...HEAD"); null outside a git checkout. */
|
|
116
116
|
export function changeSummary(root, range, maxPatch = 12_000) {
|
|
117
117
|
try {
|
|
118
|
-
|
|
118
|
+
// `stdio` so git's own stderr is captured rather than inherited. `execFileSync` sends a child's
|
|
119
|
+
// stderr straight to the parent unless told not to, so a range that does not exist printed three
|
|
120
|
+
// lines of `fatal: ambiguous argument 'main...HEAD'` and a git usage hint **before** Tewip's own
|
|
121
|
+
// explanation of the same thing. The failure was always handled; it just looked like a crash.
|
|
122
|
+
const git = (args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
119
123
|
const files = git(['diff', '--name-only', range]).split('\n').filter(Boolean);
|
|
120
124
|
const patch = git(['diff', '--unified=2', range]);
|
|
121
125
|
return { range, files, patch: patch.length > maxPatch ? `${patch.slice(0, maxPatch)}\n... (diff cut at ${maxPatch} characters)` : patch };
|
|
@@ -92,7 +92,9 @@ function classify(file, before, after) {
|
|
|
92
92
|
}
|
|
93
93
|
function sourceDiff(root, range) {
|
|
94
94
|
try {
|
|
95
|
-
|
|
95
|
+
// Captured, not inherited: see the note in grounding.ts - an unknown range printed git's own
|
|
96
|
+
// error before Tewip's.
|
|
97
|
+
return execFileSync('git', ['diff', '--unified=0', range, '--'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
96
98
|
}
|
|
97
99
|
catch {
|
|
98
100
|
return '';
|
|
@@ -24,7 +24,8 @@ const title = (line) => /['"`]([^'"`]{4,120})['"`]/.exec(line)?.[1] ?? /\b(?:def
|
|
|
24
24
|
/** The diff of test files only, which is all this looks at. */
|
|
25
25
|
function testDiff(root, range) {
|
|
26
26
|
try {
|
|
27
|
-
|
|
27
|
+
// Captured, not inherited: see the note in grounding.ts.
|
|
28
|
+
return execFileSync('git', ['diff', '--unified=0', range, '--'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
28
29
|
}
|
|
29
30
|
catch {
|
|
30
31
|
return '';
|
|
@@ -6,6 +6,48 @@ import { existsSync } from 'node:fs';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
8
|
const NEWLINE = String.fromCharCode(10);
|
|
9
|
+
/**
|
|
10
|
+
* A scanner's JSON, or `undefined` when it did not produce any.
|
|
11
|
+
*
|
|
12
|
+
* Empty output counts as no report rather than as an empty one. A scanner that printed nothing
|
|
13
|
+
* cannot be told apart from a scanner that crashed before it printed, and the two possible
|
|
14
|
+
* mistakes are not equally bad: calling a clean scan unreadable sends somebody to look, while
|
|
15
|
+
* calling an unreadable scan clean is the false all-clear this whole report exists to avoid.
|
|
16
|
+
*/
|
|
17
|
+
function readJson(out) {
|
|
18
|
+
if (!out.trim())
|
|
19
|
+
return undefined;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(out);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Did this scanner produce a report, or only exit?
|
|
29
|
+
*
|
|
30
|
+
* `key` is the field its report always carries, so JSON that parses but is not a scan result - a
|
|
31
|
+
* usage message, a progress object, an error body - is caught too. The exit code is deliberately
|
|
32
|
+
* not consulted: pip-audit and npm audit both exit non-zero precisely when they have found
|
|
33
|
+
* something, so treating that as failure would discard the findings that matter most.
|
|
34
|
+
*/
|
|
35
|
+
export function scanOutcome(tool, out, err, shape, read) {
|
|
36
|
+
const report = readJson(out);
|
|
37
|
+
if (report === undefined)
|
|
38
|
+
return { findings: [], problem: `${tool} did not return a report${firstLine(out, err)}` };
|
|
39
|
+
// Older pip-audit prints the dependency list unwrapped, so a bare array is a real report there
|
|
40
|
+
// and nowhere else. Accepting one everywhere would let a stray array pass for an empty scan.
|
|
41
|
+
const known = report?.[shape.key] !== undefined || (shape.orArray === true && Array.isArray(report));
|
|
42
|
+
if (!known)
|
|
43
|
+
return { findings: [], problem: `${tool} returned JSON that is not a scan report` };
|
|
44
|
+
return { findings: read(report), problem: null };
|
|
45
|
+
}
|
|
46
|
+
/** The first line a failing tool said, for the report - whichever stream it used. */
|
|
47
|
+
function firstLine(out, err) {
|
|
48
|
+
const said = [err, out].map((x) => x.trim()).find(Boolean);
|
|
49
|
+
return said ? `: ${said.split(NEWLINE)[0].slice(0, 120)}` : '';
|
|
50
|
+
}
|
|
9
51
|
const run = (command, cwd, timeoutMs = 5 * 60_000) => {
|
|
10
52
|
const r = spawnSync(command, { cwd, shell: true, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 40 * 1024 * 1024 });
|
|
11
53
|
return { code: r.status ?? 1, out: `${r.stdout ?? ''}`, err: `${r.stderr ?? ''}` };
|
|
@@ -55,90 +97,94 @@ export function npmAudit(root) {
|
|
|
55
97
|
}
|
|
56
98
|
return { findings, problem: null };
|
|
57
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* What pip-audit found, given what it printed. Separated from running it so the shapes real
|
|
102
|
+
* pip-audit produces can be tested without the tool installed.
|
|
103
|
+
*/
|
|
104
|
+
export function pipFindings(report) {
|
|
105
|
+
const list = report?.dependencies ?? report;
|
|
106
|
+
return (Array.isArray(list) ? list : []).flatMap((dep) => (dep.vulns ?? []).map((v) => ({
|
|
107
|
+
package: String(dep.name ?? ''),
|
|
108
|
+
version: String(dep.version ?? ''),
|
|
109
|
+
severity: String(v.severity ?? 'unknown').toLowerCase(),
|
|
110
|
+
id: String(v.id ?? ''),
|
|
111
|
+
title: String(v.description ?? '').split('\n')[0].slice(0, 200),
|
|
112
|
+
fixedIn: (v.fix_versions ?? []).join(', '),
|
|
113
|
+
source: 'pip-audit',
|
|
114
|
+
dev: false,
|
|
115
|
+
})));
|
|
116
|
+
}
|
|
58
117
|
/** `pip-audit`, when the project is Python and the tool is installed. */
|
|
59
118
|
export function pipAudit(root) {
|
|
60
119
|
const python = ['requirements.txt', 'pyproject.toml', 'setup.py'].some((f) => existsSync(join(root, f)));
|
|
61
120
|
if (!python || !has('pip-audit', root))
|
|
62
|
-
return [];
|
|
63
|
-
const { out } = run('pip-audit -f json --progress-spinner off', root);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
121
|
+
return { findings: [], problem: null };
|
|
122
|
+
const { out, err } = run('pip-audit -f json --progress-spinner off', root);
|
|
123
|
+
return scanOutcome('pip-audit', out, err, { key: 'dependencies', orArray: true }, pipFindings);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* What osv-scanner found, given what it printed. Separated from running it for the same reason as
|
|
127
|
+
* `pipFindings`: the parsing is what needs testing, and the tool is rarely installed.
|
|
128
|
+
*/
|
|
129
|
+
export function osvFindings(report) {
|
|
130
|
+
const findings = [];
|
|
131
|
+
for (const result of report?.results ?? []) {
|
|
132
|
+
for (const pkg of result.packages ?? []) {
|
|
133
|
+
for (const v of pkg.vulnerabilities ?? []) {
|
|
134
|
+
findings.push({
|
|
135
|
+
package: String(pkg.package?.name ?? ''),
|
|
136
|
+
version: String(pkg.package?.version ?? ''),
|
|
137
|
+
severity: String(v.database_specific?.severity ?? v.severity?.[0]?.type ?? 'unknown').toLowerCase(),
|
|
138
|
+
id: String(v.id ?? ''),
|
|
139
|
+
title: String(v.summary ?? '').slice(0, 200),
|
|
140
|
+
fixedIn: '',
|
|
141
|
+
// osv-scanner's output says neither which version fixes it nor whether the project
|
|
142
|
+
// declares the package, so neither is claimed.
|
|
143
|
+
fixExists: false,
|
|
144
|
+
direct: false,
|
|
145
|
+
source: 'osv-scanner',
|
|
146
|
+
dev: false,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
80
150
|
}
|
|
151
|
+
return findings;
|
|
81
152
|
}
|
|
82
153
|
/** OSV-Scanner, which covers Go, Rust, Java, Ruby, PHP and more, when it is installed. */
|
|
83
154
|
export function osvScan(root) {
|
|
84
155
|
if (!has('osv-scanner', root))
|
|
85
|
-
return [];
|
|
86
|
-
const { out } = run(`osv-scanner --format json --recursive "${root}"`, root);
|
|
87
|
-
|
|
88
|
-
const report = JSON.parse(out);
|
|
89
|
-
const findings = [];
|
|
90
|
-
for (const result of report.results ?? []) {
|
|
91
|
-
for (const pkg of result.packages ?? []) {
|
|
92
|
-
for (const v of pkg.vulnerabilities ?? []) {
|
|
93
|
-
findings.push({
|
|
94
|
-
package: String(pkg.package?.name ?? ''),
|
|
95
|
-
version: String(pkg.package?.version ?? ''),
|
|
96
|
-
severity: String(v.database_specific?.severity ?? v.severity?.[0]?.type ?? 'unknown').toLowerCase(),
|
|
97
|
-
id: String(v.id ?? ''),
|
|
98
|
-
title: String(v.summary ?? '').slice(0, 200),
|
|
99
|
-
fixedIn: '',
|
|
100
|
-
// osv-scanner's output says neither which version fixes it nor whether the project
|
|
101
|
-
// declares the package, so neither is claimed.
|
|
102
|
-
fixExists: false,
|
|
103
|
-
direct: false,
|
|
104
|
-
source: 'osv-scanner',
|
|
105
|
-
dev: false,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return findings;
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
return [];
|
|
114
|
-
}
|
|
156
|
+
return { findings: [], problem: null };
|
|
157
|
+
const { out, err } = run(`osv-scanner --format json --recursive "${root}"`, root);
|
|
158
|
+
return scanOutcome('osv-scanner', out, err, { key: 'results' }, osvFindings);
|
|
115
159
|
}
|
|
116
160
|
export function auditDependencies(root) {
|
|
117
161
|
const ran = [];
|
|
118
162
|
const missing = [];
|
|
119
163
|
const findings = [];
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
164
|
+
// `ran` used to be pushed before the scanner was called, so a pip-audit that was installed and
|
|
165
|
+
// failed left "Checked: pip-audit" standing above zero findings. Measured on a project pinning
|
|
166
|
+
// requests==2.19.1 and flask==0.12.2 - both long known vulnerable - with a pip-audit that exited
|
|
167
|
+
// without JSON: the report read "Checked: secrets in the working tree, pip-audit. 0 need someone
|
|
168
|
+
// now." A scanner is named as having run only once it has returned something readable.
|
|
169
|
+
const record = (tool, outcome) => {
|
|
170
|
+
if (outcome.problem)
|
|
171
|
+
missing.push(`${tool} could not run: ${outcome.problem}`);
|
|
124
172
|
else
|
|
125
|
-
ran.push(
|
|
126
|
-
findings.push(...
|
|
173
|
+
ran.push(tool);
|
|
174
|
+
findings.push(...outcome.findings);
|
|
175
|
+
};
|
|
176
|
+
if (existsSync(join(root, 'package.json'))) {
|
|
177
|
+
record('npm audit', npmAudit(root));
|
|
127
178
|
}
|
|
128
179
|
const python = ['requirements.txt', 'pyproject.toml', 'setup.py'].some((f) => existsSync(join(root, f)));
|
|
129
180
|
if (python) {
|
|
130
|
-
if (has('pip-audit', root))
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
else {
|
|
181
|
+
if (has('pip-audit', root))
|
|
182
|
+
record('pip-audit', pipAudit(root));
|
|
183
|
+
else
|
|
135
184
|
missing.push('pip-audit (pip install pip-audit) for the Python dependencies');
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
if (has('osv-scanner', root)) {
|
|
139
|
-
ran.push('osv-scanner');
|
|
140
|
-
findings.push(...osvScan(root));
|
|
141
185
|
}
|
|
186
|
+
if (has('osv-scanner', root))
|
|
187
|
+
record('osv-scanner', osvScan(root));
|
|
142
188
|
else if (!existsSync(join(root, 'package.json')) && !python) {
|
|
143
189
|
missing.push('osv-scanner (https://osv.dev) for this project\'s ecosystem');
|
|
144
190
|
}
|
|
@@ -92,8 +92,13 @@ export async function runSecure(cmd) {
|
|
|
92
92
|
}
|
|
93
93
|
if (cmd.zapReport) {
|
|
94
94
|
const fromZap = readZapReport(resolve(cmd.zapReport));
|
|
95
|
-
checked
|
|
96
|
-
|
|
95
|
+
// Named as checked only once it has been read. "(0 finding(s))" above a file Tewip could not
|
|
96
|
+
// open is a measurement of nothing, and a mistyped path answered exactly like a clean scan.
|
|
97
|
+
if (fromZap.problem)
|
|
98
|
+
notChecked.push(`the ZAP report you asked for: ${fromZap.problem}`);
|
|
99
|
+
else
|
|
100
|
+
checked.push(`an OWASP ZAP report (${fromZap.findings.length} finding(s))`);
|
|
101
|
+
for (const finding of fromZap.findings)
|
|
97
102
|
items.push(triageWeb(finding, accepted));
|
|
98
103
|
}
|
|
99
104
|
else if (cmd.url) {
|
|
@@ -10,17 +10,22 @@ const RISK = { High: 'high', Medium: 'medium', Low: 'low', Informational: 'low'
|
|
|
10
10
|
/** Findings from a ZAP JSON report, in Tewip's own shape. */
|
|
11
11
|
export function readZapReport(path) {
|
|
12
12
|
if (!existsSync(path))
|
|
13
|
-
return [];
|
|
13
|
+
return { findings: [], problem: `there is no file at ${path}` };
|
|
14
14
|
let report;
|
|
15
15
|
try {
|
|
16
16
|
report = JSON.parse(readFileSync(path, 'utf8'));
|
|
17
17
|
}
|
|
18
18
|
catch {
|
|
19
|
-
return [];
|
|
19
|
+
return { findings: [], problem: `${path} is not JSON, so it is not a ZAP report Tewip can read` };
|
|
20
|
+
}
|
|
21
|
+
// ZAP always writes `site`, one entry per host it scanned, even when it found nothing. Anything
|
|
22
|
+
// without it is some other tool's JSON, or ZAP's own error body.
|
|
23
|
+
if (!Array.isArray(report?.site)) {
|
|
24
|
+
return { findings: [], problem: `${path} is JSON but not a ZAP report: it has no "site" list` };
|
|
20
25
|
}
|
|
21
26
|
const alerts = (report.site ?? []).flatMap((site) => site.alerts ?? []);
|
|
22
27
|
const strip = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
23
|
-
|
|
28
|
+
const findings = alerts.map((alert) => {
|
|
24
29
|
const risk = (alert.riskdesc ?? '').split(' ')[0];
|
|
25
30
|
const first = (alert.instances ?? [])[0] ?? {};
|
|
26
31
|
return {
|
|
@@ -32,6 +37,7 @@ export function readZapReport(path) {
|
|
|
32
37
|
next: strip(alert.solution ?? '').slice(0, 300) || 'see the ZAP report for the recommended fix',
|
|
33
38
|
};
|
|
34
39
|
});
|
|
40
|
+
return { findings, problem: null };
|
|
35
41
|
}
|
|
36
42
|
/** Whether a ZAP baseline scan can be run from here at all. */
|
|
37
43
|
export function zapAvailable(cwd) {
|
|
@@ -73,6 +73,30 @@ export function vouchAll(root, paths) {
|
|
|
73
73
|
}
|
|
74
74
|
const count = (all, verdict) => all.filter((j) => j.verdict === verdict).length;
|
|
75
75
|
export function renderVouch(files) {
|
|
76
|
+
// No test file was read, so there is nothing to say about tests.
|
|
77
|
+
//
|
|
78
|
+
// This printed the ordinary summary - "0 test(s) read in 0 file(s). **0 cannot fail at all.**" -
|
|
79
|
+
// followed by the heading **"Nothing here is unable to fail"** and the line "Every test read can
|
|
80
|
+
// be broken by a change to the code". All true of nothing, and all of it reads as a pass, in the
|
|
81
|
+
// command whose entire job is to find tests that cannot fail.
|
|
82
|
+
//
|
|
83
|
+
// The comment in `testFilesUnder` above already diagnosed this in Phase 36, when three languages
|
|
84
|
+
// were missing from the extension list and a file named outright came back as "0 test(s) read in
|
|
85
|
+
// 0 file(s) - which reads like a clean answer and is not one". The cause was fixed; the report
|
|
86
|
+
// was not, so every other way of reading nothing still produced a pass: the wrong folder, a
|
|
87
|
+
// framework Tewip does not recognise, or a project that genuinely has no tests yet.
|
|
88
|
+
if (files.length === 0) {
|
|
89
|
+
return [
|
|
90
|
+
'# Which of these tests could fail?',
|
|
91
|
+
'',
|
|
92
|
+
'**No test file was read, so nothing here has been checked.** This is not a report that your tests are sound - it is a report that Tewip found none to look at.',
|
|
93
|
+
'',
|
|
94
|
+
'Three things usually explain it: the path given holds no tests, the tests are somewhere Tewip was not pointed at (`tewip vouch <path>`), or they are written in a language it cannot read yet.',
|
|
95
|
+
'',
|
|
96
|
+
'It reads JavaScript, TypeScript, Python, Java, Kotlin, Scala, Groovy, Go, C#, F#, Ruby, PHP and Rust.',
|
|
97
|
+
'',
|
|
98
|
+
].join(String.fromCharCode(10));
|
|
99
|
+
}
|
|
76
100
|
const all = files.flatMap((f) => f.judgements);
|
|
77
101
|
const cannotFail = all.filter((j) => CANNOT_FAIL.includes(j.verdict));
|
|
78
102
|
const crashOnly = count(all, 'crash-only');
|
package/dist/scripts/explain.js
CHANGED
|
@@ -50,8 +50,19 @@ async function main() {
|
|
|
50
50
|
const index = new HistoryIndex([...pastRecords, ...imported.records], [...pastRuns, imported.summary]);
|
|
51
51
|
const items = await buildItems(imported.records, index, { dataset: `local: ${name}`, llm: llmFromEnv(), corrections: loadCorrections(historyDir) });
|
|
52
52
|
const tests = Object.keys(imported.summary.attempts).length;
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
// A report missing part of itself is not a report that nothing went wrong. This goes first,
|
|
54
|
+
// above any board and in place of any all-clear, because it changes what everything below means.
|
|
55
|
+
if (imported.incomplete.length > 0) {
|
|
56
|
+
console.log('**This run is incomplete.**');
|
|
57
|
+
for (const why of imported.incomplete)
|
|
58
|
+
console.log(`- ${why}`);
|
|
59
|
+
console.log('');
|
|
60
|
+
}
|
|
61
|
+
if (items.length === 0) {
|
|
62
|
+
console.log(imported.incomplete.length > 0
|
|
63
|
+
? `The ${tests} test(s) that did arrive all passed. That is not the same as the run passing, and Tewip cannot say whether the missing part did.`
|
|
64
|
+
: `All ${tests} test(s) passed. Nothing to explain.`);
|
|
65
|
+
}
|
|
55
66
|
else
|
|
56
67
|
console.log(sdetBoardView(items, runId, `local: ${name}`).markdown);
|
|
57
68
|
// Keep this run as history for the next one, with its diagnoses.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.70.
|
|
3
|
+
"version": "0.70.14",
|
|
4
4
|
"mcpName": "io.github.hmamut39/tewip",
|
|
5
5
|
"description": "Tewip finds why each failing test failed - real bug, flaky test, environment, test data or changed selector - for any stack, from the command line, CI, or your editor.",
|
|
6
6
|
"keywords": [
|