qa-intelligence 1.0.0
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/README.md +184 -0
- package/dist/ai/failureAnalyzer.js +149 -0
- package/dist/ai/runAnalyzer.js +5 -0
- package/dist/cli/diff.js +41 -0
- package/dist/cli/history.js +77 -0
- package/dist/cli/index.js +31 -0
- package/dist/cli/postComment.js +67 -0
- package/dist/config/env.js +64 -0
- package/dist/lib/computeDiff.js +81 -0
- package/dist/lib/failureReader.js +107 -0
- package/dist/lib/format.js +61 -0
- package/dist/lib/types.js +2 -0
- package/dist/playwright/basePage.js +18 -0
- package/dist/playwright/baseTest.js +9 -0
- package/dist/playwright/globalSetup.js +14 -0
- package/dist/playwright/globalTeardown.js +46 -0
- package/dist/playwright/steps.js +7 -0
- package/dist/playwright/testHooks.js +53 -0
- package/dist/reporting/logger.js +27 -0
- package/package.json +66 -0
package/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# qa-intelligence
|
|
2
|
+
|
|
3
|
+
CI intelligence engine for Playwright test pipelines.
|
|
4
|
+
|
|
5
|
+
- AI-powered failure analysis
|
|
6
|
+
- PR failure diff (new, flaky, still failing, fixed)
|
|
7
|
+
- Flaky test detection (retry-aware)
|
|
8
|
+
- PR blocking on new non-flaky failures
|
|
9
|
+
- Recurrence tracking ("3rd time since...")
|
|
10
|
+
|
|
11
|
+
Use it **without the framework template** — install the package into any existing Playwright project.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install qa-intelligence @playwright/test
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Quick setup (existing project)
|
|
24
|
+
|
|
25
|
+
### 1. Environment (`.env`)
|
|
26
|
+
|
|
27
|
+
```env
|
|
28
|
+
BASE_URL=https://your-app.example.com
|
|
29
|
+
HEADLESS=true
|
|
30
|
+
PW_WORKERS=2
|
|
31
|
+
PW_RETRIES=1
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### 2. `playwright.config.ts`
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { defineConfig } from "@playwright/test";
|
|
38
|
+
import { env } from "qa-intelligence/config/env";
|
|
39
|
+
|
|
40
|
+
export default defineConfig({
|
|
41
|
+
testDir: "./tests",
|
|
42
|
+
retries: env.PW_RETRIES,
|
|
43
|
+
workers: env.PW_WORKERS,
|
|
44
|
+
globalSetup: require.resolve("qa-intelligence/playwright/globalSetup"),
|
|
45
|
+
globalTeardown: require.resolve("qa-intelligence/playwright/globalTeardown"),
|
|
46
|
+
use: {
|
|
47
|
+
baseURL: env.BASE_URL,
|
|
48
|
+
headless: env.HEADLESS,
|
|
49
|
+
trace: "on-first-retry",
|
|
50
|
+
screenshot: "only-on-failure",
|
|
51
|
+
video: "retain-on-failure",
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 3. Write tests
|
|
57
|
+
|
|
58
|
+
Always import `test` from the package — **not** directly from Playwright:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { test, expect } from "qa-intelligence/playwright";
|
|
62
|
+
|
|
63
|
+
test("user can login", async ({ page }) => {
|
|
64
|
+
await page.goto("/");
|
|
65
|
+
await expect(page).toHaveTitle(/My App/);
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Optional helpers:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { step } from "qa-intelligence/playwright/steps";
|
|
73
|
+
import { BasePage } from "qa-intelligence/playwright/basePage";
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 4. What you provide
|
|
77
|
+
|
|
78
|
+
| You write | Package provides |
|
|
79
|
+
|-----------|------------------|
|
|
80
|
+
| `tests/` | Test hooks, artifact capture |
|
|
81
|
+
| `.env` | Env validation |
|
|
82
|
+
| `playwright.config.ts` | `globalSetup`, `globalTeardown`, AI teardown |
|
|
83
|
+
| `.github/workflows/ci.yml` | `qa-intelligence-diff`, `qa-intelligence-history`, `qa-intelligence-comment` |
|
|
84
|
+
| Page objects (optional) | `BasePage`, `step()` |
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## CI setup (GitHub Actions)
|
|
89
|
+
|
|
90
|
+
### Option A: Copy the full workflow (recommended)
|
|
91
|
+
|
|
92
|
+
Copy `.github/workflows/ci.yml` from the [framework template](https://github.com/ardithaqi/qa-intelligence-framework/blob/master/.github/workflows/ci.yml) into your repo.
|
|
93
|
+
|
|
94
|
+
It includes everything wired together:
|
|
95
|
+
|
|
96
|
+
- Docker test execution
|
|
97
|
+
- Artifact upload on every run
|
|
98
|
+
- Baseline download from `main` on pull requests
|
|
99
|
+
- `qa-intelligence-diff`, `qa-intelligence-history`, `qa-intelligence-comment`
|
|
100
|
+
- PR blocking on new non-flaky failures
|
|
101
|
+
|
|
102
|
+
Then update:
|
|
103
|
+
|
|
104
|
+
- `BASE_URL` in the `docker run` step
|
|
105
|
+
- GitHub secrets (see below)
|
|
106
|
+
|
|
107
|
+
### Option B: Add to your existing workflow
|
|
108
|
+
|
|
109
|
+
If you already run Playwright in CI, add these steps **after** tests run and `artifacts/` are uploaded:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npm install qa-intelligence
|
|
113
|
+
npx qa-intelligence-diff --baseline baseline-artifacts --current artifacts
|
|
114
|
+
npx qa-intelligence-history
|
|
115
|
+
npx qa-intelligence-comment \
|
|
116
|
+
--diff failure-diff.json \
|
|
117
|
+
--repo owner/repo \
|
|
118
|
+
--pr 123 \
|
|
119
|
+
--token $GITHUB_TOKEN
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
You must also handle on your own:
|
|
123
|
+
|
|
124
|
+
- Uploading `artifacts/` after each run
|
|
125
|
+
- Downloading `baseline-artifacts/` from the target branch on PRs
|
|
126
|
+
|
|
127
|
+
See the [framework CI workflow](https://github.com/ardithaqi/qa-intelligence-framework/blob/master/.github/workflows/ci.yml) for reference.
|
|
128
|
+
|
|
129
|
+
### Required GitHub secrets
|
|
130
|
+
|
|
131
|
+
- `OPENAI_API_KEY` — enables AI failure analysis in teardown
|
|
132
|
+
- `TEST_USERNAME` / `TEST_PASSWORD` — if your tests need credentials
|
|
133
|
+
|
|
134
|
+
Set `AI_ANALYSIS=true` in CI when running tests inside Docker.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## PR behavior
|
|
139
|
+
|
|
140
|
+
| Failure type | Blocks PR? | Shown in comment |
|
|
141
|
+
|--------------|------------|------------------|
|
|
142
|
+
| New (not flaky) | Yes | New Issues |
|
|
143
|
+
| Flaky | No | Flaky |
|
|
144
|
+
| Still failing from base branch | No | Still Failing |
|
|
145
|
+
| Fixed since base branch | No | Fixed Issues |
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## CLI tools
|
|
150
|
+
|
|
151
|
+
| Command | Purpose |
|
|
152
|
+
|---------|---------|
|
|
153
|
+
| `qa-intelligence-diff` | Compare baseline vs current failures |
|
|
154
|
+
| `qa-intelligence-history` | Add recurrence tracking |
|
|
155
|
+
| `qa-intelligence-comment` | Post/update PR summary comment |
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Package exports
|
|
160
|
+
|
|
161
|
+
| Import path | What it gives you |
|
|
162
|
+
|-------------|-------------------|
|
|
163
|
+
| `qa-intelligence/playwright` | `test`, `expect`, `env` |
|
|
164
|
+
| `qa-intelligence/playwright/globalSetup` | Artifact run setup |
|
|
165
|
+
| `qa-intelligence/playwright/globalTeardown` | AI failure analysis |
|
|
166
|
+
| `qa-intelligence/playwright/basePage` | Base page object |
|
|
167
|
+
| `qa-intelligence/playwright/steps` | `step()` helper |
|
|
168
|
+
| `qa-intelligence/config/env` | Validated env config |
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Alternative: use the full template
|
|
173
|
+
|
|
174
|
+
If you prefer a ready-made project with examples, Docker, and CI pre-wired:
|
|
175
|
+
|
|
176
|
+
**[qa-intelligence-framework](https://github.com/ardithaqi/qa-intelligence-framework)** — click "Use this template".
|
|
177
|
+
|
|
178
|
+
The template uses this package under the hood.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.analyzeFailureFile = analyzeFailureFile;
|
|
7
|
+
exports.analyzeLatestFailure = analyzeLatestFailure;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const openai_1 = __importDefault(require("openai"));
|
|
11
|
+
function getOpenAIClient() {
|
|
12
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
13
|
+
if (!apiKey) {
|
|
14
|
+
console.warn("OPENAI_API_KEY is not set. Skipping AI failure analysis.");
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return new openai_1.default({ apiKey });
|
|
18
|
+
}
|
|
19
|
+
function findLatestMetaFile(root = "artifacts") {
|
|
20
|
+
if (!fs_1.default.existsSync(root))
|
|
21
|
+
return null;
|
|
22
|
+
let latestPath = null;
|
|
23
|
+
let latestMtime = -1;
|
|
24
|
+
function walk(dir) {
|
|
25
|
+
for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
26
|
+
const fullPath = path_1.default.join(dir, entry.name);
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
walk(fullPath);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (entry.name !== "meta.json")
|
|
32
|
+
continue;
|
|
33
|
+
const stats = fs_1.default.statSync(fullPath);
|
|
34
|
+
if (stats.mtimeMs > latestMtime) {
|
|
35
|
+
latestMtime = stats.mtimeMs;
|
|
36
|
+
latestPath = fullPath;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
walk(root);
|
|
41
|
+
return latestPath;
|
|
42
|
+
}
|
|
43
|
+
async function analyzeFailureFile(jsonFilePath) {
|
|
44
|
+
const openai = getOpenAIClient();
|
|
45
|
+
if (!openai)
|
|
46
|
+
return null;
|
|
47
|
+
const htmlPath = jsonFilePath.replace(".json", ".html");
|
|
48
|
+
const meta = JSON.parse(fs_1.default.readFileSync(jsonFilePath, "utf-8"));
|
|
49
|
+
const html = fs_1.default.existsSync(htmlPath)
|
|
50
|
+
? fs_1.default.readFileSync(htmlPath, "utf-8")
|
|
51
|
+
: "";
|
|
52
|
+
const prompt = `
|
|
53
|
+
You are a senior QA automation engineer analyzing a failed Playwright test.
|
|
54
|
+
|
|
55
|
+
STRICT RULES:
|
|
56
|
+
- Do NOT speculate about deployments, databases, or external systems.
|
|
57
|
+
- Do NOT guess about business logic changes.
|
|
58
|
+
- Base reasoning ONLY on stack trace, metadata, and DOM snapshot.
|
|
59
|
+
- If the original assertion is visible in the stack trace, extract it exactly as written.
|
|
60
|
+
- Remove absolute user directories such as /Users/... and return only project-relative path starting from tests/.
|
|
61
|
+
- If the stack trace includes the original source line (e.g. expect(x).toBe(y)), extract that exact line instead of Playwright's formatted error message.
|
|
62
|
+
- Never output expect(received).toBe(expected) if a concrete assertion exists.
|
|
63
|
+
- Prefer the real test assertion over Playwright’s generic expect(received).toBe(expected).
|
|
64
|
+
- failure_type MUST be exactly one of:
|
|
65
|
+
assertion_mismatch
|
|
66
|
+
selector_not_found
|
|
67
|
+
timeout
|
|
68
|
+
navigation_failure
|
|
69
|
+
environment_error
|
|
70
|
+
unknown
|
|
71
|
+
- Severity MUST be exactly one of: low, medium, high.
|
|
72
|
+
- Severity must follow this logic:
|
|
73
|
+
low for forced/debug assertions.
|
|
74
|
+
medium for deterministic assertion mismatches.
|
|
75
|
+
high only for navigation_failure, timeout, or environment_error.
|
|
76
|
+
- confidence must reflect how strongly the evidence supports the classification.
|
|
77
|
+
- For direct numeric assertion mismatches (e.g. expect(5).toBe(6)), confidence MUST be >= 90.
|
|
78
|
+
- expected and received must be numbers if numeric.
|
|
79
|
+
|
|
80
|
+
Failure metadata:
|
|
81
|
+
${JSON.stringify(meta, null, 2)}
|
|
82
|
+
|
|
83
|
+
Primary error:
|
|
84
|
+
${meta.errorMessage}
|
|
85
|
+
|
|
86
|
+
DOM snapshot (first 4000 chars):
|
|
87
|
+
${html.substring(0, 4000)}
|
|
88
|
+
|
|
89
|
+
Your response must contain TWO sections.
|
|
90
|
+
|
|
91
|
+
SECTION 1: HUMAN ANALYSIS
|
|
92
|
+
|
|
93
|
+
Format EXACTLY like this:
|
|
94
|
+
|
|
95
|
+
File: <relative path only>
|
|
96
|
+
Line: <number>
|
|
97
|
+
Assertion: <exact assertion from test file>
|
|
98
|
+
Expected: <value>
|
|
99
|
+
Received: <value>
|
|
100
|
+
|
|
101
|
+
Root cause:
|
|
102
|
+
Line 1: <what the assertion compares>
|
|
103
|
+
Line 2: <what was actually observed>
|
|
104
|
+
Line 3: <classification sentence>
|
|
105
|
+
|
|
106
|
+
No extra commentary.
|
|
107
|
+
No speculation.
|
|
108
|
+
No extra paragraphs.
|
|
109
|
+
|
|
110
|
+
SECTION 2: STRUCTURED_JSON
|
|
111
|
+
|
|
112
|
+
Return STRICTLY raw JSON only.
|
|
113
|
+
Do NOT wrap in markdown.
|
|
114
|
+
Do NOT add text before or after JSON.
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
"file": "relative/path/to/file",
|
|
118
|
+
"line": 0,
|
|
119
|
+
"failure_type": "assertion_mismatch | selector_not_found | timeout | navigation_failure | environment_error | unknown",
|
|
120
|
+
"expected": 0,
|
|
121
|
+
"received": 0,
|
|
122
|
+
"is_flaky_suspected": false,
|
|
123
|
+
"severity": "low | medium | high",
|
|
124
|
+
"confidence": 0
|
|
125
|
+
}
|
|
126
|
+
`;
|
|
127
|
+
const response = await openai.chat.completions.create({
|
|
128
|
+
model: "gpt-4o-mini",
|
|
129
|
+
messages: [{ role: "user", content: prompt }],
|
|
130
|
+
});
|
|
131
|
+
const content = response.choices[0]?.message?.content ?? null;
|
|
132
|
+
const usage = response.usage;
|
|
133
|
+
if (usage) {
|
|
134
|
+
console.log("Token usage:", usage);
|
|
135
|
+
// Rough estimate for gpt-4o-mini (adjust if pricing changes)
|
|
136
|
+
const estimatedCost = (usage.total_tokens / 1000) * 0.00015;
|
|
137
|
+
console.log(`Estimated cost (approx): $${estimatedCost.toFixed(6)}`);
|
|
138
|
+
}
|
|
139
|
+
return content;
|
|
140
|
+
}
|
|
141
|
+
async function analyzeLatestFailure() {
|
|
142
|
+
const latestMetaFile = findLatestMetaFile();
|
|
143
|
+
if (!latestMetaFile) {
|
|
144
|
+
console.log("No failure metadata found in artifacts.");
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
console.log(`Analyzing latest failure: ${latestMetaFile}`);
|
|
148
|
+
return analyzeFailureFile(latestMetaFile);
|
|
149
|
+
}
|
package/dist/cli/diff.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const computeDiff_1 = require("../lib/computeDiff");
|
|
9
|
+
function getArg(name) {
|
|
10
|
+
const idx = process.argv.indexOf(`--${name}`);
|
|
11
|
+
if (idx === -1)
|
|
12
|
+
return undefined;
|
|
13
|
+
return process.argv[idx + 1];
|
|
14
|
+
}
|
|
15
|
+
function getBoolArg(name, defaultVal) {
|
|
16
|
+
const v = getArg(name);
|
|
17
|
+
if (!v)
|
|
18
|
+
return defaultVal;
|
|
19
|
+
return v === "true" || v === "1" || v === "yes";
|
|
20
|
+
}
|
|
21
|
+
async function main() {
|
|
22
|
+
const baselineDir = getArg("baseline") ?? "baseline-artifacts";
|
|
23
|
+
const currentDir = getArg("current") ?? "artifacts";
|
|
24
|
+
const outFile = getArg("out") ?? "failure-diff.json";
|
|
25
|
+
const failOnBlocking = getBoolArg("fail-on-blocking", true);
|
|
26
|
+
const diff = (0, computeDiff_1.computeDiff)(baselineDir, currentDir);
|
|
27
|
+
fs_1.default.writeFileSync(outFile, JSON.stringify(diff, null, 2));
|
|
28
|
+
console.log(`Wrote diff to ${outFile}`);
|
|
29
|
+
console.log(`New failures: ${diff.newFailures.length}`);
|
|
30
|
+
console.log(`Blocking failures: ${diff.blockingFailures.length}`);
|
|
31
|
+
console.log(`Still failing: ${diff.unchangedFailures.length}`);
|
|
32
|
+
console.log(`Fixed failures: ${diff.fixedFailures.length}`);
|
|
33
|
+
if (failOnBlocking && diff.blockingFailures.length > 0) {
|
|
34
|
+
console.error(`Failing job because ${diff.blockingFailures.length} new non-flaky failure(s) were detected.`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
main().catch((e) => {
|
|
39
|
+
console.error(e);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* Enriches failure-diff with recurrence: first_seen, occurrence_count.
|
|
5
|
+
* Persists run history in a cache file for the next run.
|
|
6
|
+
*/
|
|
7
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
8
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
9
|
+
};
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const HISTORY_PATH = ".cache/failure-history.json";
|
|
14
|
+
const MAX_RUNS = 20;
|
|
15
|
+
function failureKey(f) {
|
|
16
|
+
return `${f.file}:${f.line}:${f.failure_type}`;
|
|
17
|
+
}
|
|
18
|
+
function loadHistory() {
|
|
19
|
+
if (!fs_1.default.existsSync(HISTORY_PATH))
|
|
20
|
+
return { runs: [] };
|
|
21
|
+
try {
|
|
22
|
+
const data = JSON.parse(fs_1.default.readFileSync(HISTORY_PATH, "utf8"));
|
|
23
|
+
return Array.isArray(data.runs) ? { runs: data.runs } : { runs: [] };
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { runs: [] };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function enrichFailures(failures, history, currentKeys) {
|
|
30
|
+
const runsIncludingThis = [...history.runs];
|
|
31
|
+
const thisRun = {
|
|
32
|
+
sha: process.env.GITHUB_SHA ?? "",
|
|
33
|
+
timestamp: new Date().toISOString(),
|
|
34
|
+
failureKeys: [...currentKeys],
|
|
35
|
+
};
|
|
36
|
+
runsIncludingThis.push(thisRun);
|
|
37
|
+
return failures.map((f) => {
|
|
38
|
+
const key = failureKey(f);
|
|
39
|
+
const enriched = { ...f };
|
|
40
|
+
const runsWithThis = runsIncludingThis.filter((r) => r.failureKeys.includes(key));
|
|
41
|
+
if (runsWithThis.length > 0) {
|
|
42
|
+
enriched.occurrence_count = runsWithThis.length;
|
|
43
|
+
enriched.first_seen = runsWithThis[0].timestamp;
|
|
44
|
+
}
|
|
45
|
+
return enriched;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function main() {
|
|
49
|
+
const diffPath = "failure-diff.json";
|
|
50
|
+
if (!fs_1.default.existsSync(diffPath)) {
|
|
51
|
+
console.log("No failure-diff.json, skipping history update.");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const diff = JSON.parse(fs_1.default.readFileSync(diffPath, "utf8"));
|
|
55
|
+
const { newFailures, unchangedFailures, fixedFailures } = diff;
|
|
56
|
+
const currentKeys = new Set();
|
|
57
|
+
for (const f of [...newFailures, ...unchangedFailures]) {
|
|
58
|
+
currentKeys.add(failureKey(f));
|
|
59
|
+
}
|
|
60
|
+
const history = loadHistory();
|
|
61
|
+
const enrichedNew = enrichFailures(newFailures, history, currentKeys);
|
|
62
|
+
const enrichedUnchanged = enrichFailures(unchangedFailures, history, currentKeys);
|
|
63
|
+
const enrichedFixed = enrichFailures(fixedFailures, history, currentKeys);
|
|
64
|
+
const updatedHistory = {
|
|
65
|
+
runs: [...history.runs, { sha: process.env.GITHUB_SHA ?? "", timestamp: new Date().toISOString(), failureKeys: [...currentKeys] }].slice(-MAX_RUNS),
|
|
66
|
+
};
|
|
67
|
+
fs_1.default.mkdirSync(path_1.default.dirname(HISTORY_PATH), { recursive: true });
|
|
68
|
+
fs_1.default.writeFileSync(HISTORY_PATH, JSON.stringify(updatedHistory, null, 2));
|
|
69
|
+
const result = {
|
|
70
|
+
newFailures: enrichedNew,
|
|
71
|
+
unchangedFailures: enrichedUnchanged,
|
|
72
|
+
fixedFailures: enrichedFixed,
|
|
73
|
+
};
|
|
74
|
+
fs_1.default.writeFileSync(diffPath, JSON.stringify(result, null, 2));
|
|
75
|
+
console.log("Updated failure-diff with recurrence; history runs:", updatedHistory.runs.length);
|
|
76
|
+
}
|
|
77
|
+
main();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
function getArg(name) {
|
|
6
|
+
const idx = process.argv.indexOf(`--${name}`);
|
|
7
|
+
if (idx === -1)
|
|
8
|
+
return undefined;
|
|
9
|
+
return process.argv[idx + 1];
|
|
10
|
+
}
|
|
11
|
+
async function main() {
|
|
12
|
+
const baseline = getArg("baseline") ?? "baseline-artifacts";
|
|
13
|
+
const current = getArg("current") ?? "artifacts";
|
|
14
|
+
const repo = getArg("repo");
|
|
15
|
+
const pr = getArg("pr");
|
|
16
|
+
const token = getArg("token");
|
|
17
|
+
console.log("Running failure diff...");
|
|
18
|
+
(0, child_process_1.execSync)(`node ${__dirname}/diff.js --baseline ${baseline} --current ${current} --out failure-diff.json`, {
|
|
19
|
+
stdio: "inherit"
|
|
20
|
+
});
|
|
21
|
+
if (repo && pr && token) {
|
|
22
|
+
console.log("Posting PR comment...");
|
|
23
|
+
(0, child_process_1.execSync)(`node ${__dirname}/postComment.js --diff failure-diff.json --repo ${repo} --pr ${pr} --token ${token}`, {
|
|
24
|
+
stdio: "inherit"
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
main().catch((e) => {
|
|
29
|
+
console.error(e);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const format_1 = require("../lib/format");
|
|
9
|
+
const rest_1 = require("@octokit/rest");
|
|
10
|
+
function getArg(name) {
|
|
11
|
+
const idx = process.argv.indexOf(`--${name}`);
|
|
12
|
+
if (idx === -1)
|
|
13
|
+
return undefined;
|
|
14
|
+
return process.argv[idx + 1];
|
|
15
|
+
}
|
|
16
|
+
async function main() {
|
|
17
|
+
const diffPath = getArg("diff");
|
|
18
|
+
const repoArg = getArg("repo");
|
|
19
|
+
const prArg = getArg("pr");
|
|
20
|
+
const token = getArg("token");
|
|
21
|
+
if (!diffPath || !repoArg || !prArg || !token) {
|
|
22
|
+
console.error("Usage: qa-intelligence-comment --diff <file> --repo <owner/repo> --pr <number> --token <token>");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const [owner, repo] = repoArg.split("/");
|
|
26
|
+
const issue_number = Number(prArg);
|
|
27
|
+
const raw = fs_1.default.readFileSync(diffPath, "utf8");
|
|
28
|
+
const diff = JSON.parse(raw);
|
|
29
|
+
if (!(0, format_1.hasFailureChanges)(diff)) {
|
|
30
|
+
console.log("No failure changes. Skipping comment.");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const body = (0, format_1.formatDiffComment)(diff);
|
|
34
|
+
const octokit = new rest_1.Octokit({ auth: token });
|
|
35
|
+
// Find existing comment to update
|
|
36
|
+
const { data: comments } = await octokit.issues.listComments({
|
|
37
|
+
owner,
|
|
38
|
+
repo,
|
|
39
|
+
issue_number,
|
|
40
|
+
per_page: 100,
|
|
41
|
+
});
|
|
42
|
+
const existing = comments.find((c) => c.user?.type === "Bot" &&
|
|
43
|
+
typeof c.body === "string" &&
|
|
44
|
+
c.body.startsWith("## AI Failure Diff Summary"));
|
|
45
|
+
if (existing) {
|
|
46
|
+
await octokit.issues.updateComment({
|
|
47
|
+
owner,
|
|
48
|
+
repo,
|
|
49
|
+
comment_id: existing.id,
|
|
50
|
+
body,
|
|
51
|
+
});
|
|
52
|
+
console.log("Updated existing AI diff comment.");
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
await octokit.issues.createComment({
|
|
56
|
+
owner,
|
|
57
|
+
repo,
|
|
58
|
+
issue_number,
|
|
59
|
+
body,
|
|
60
|
+
});
|
|
61
|
+
console.log("Created new AI diff comment.");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
main().catch((e) => {
|
|
65
|
+
console.error(e);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.env = void 0;
|
|
37
|
+
const dotenv = __importStar(require("dotenv"));
|
|
38
|
+
const zod_1 = require("zod");
|
|
39
|
+
dotenv.config();
|
|
40
|
+
const EnvSchema = zod_1.z.object({
|
|
41
|
+
ENV: zod_1.z.enum(["dev", "staging", "prod"]).default("dev"),
|
|
42
|
+
BASE_URL: zod_1.z.string().url(),
|
|
43
|
+
HEADLESS: zod_1.z
|
|
44
|
+
.string()
|
|
45
|
+
.transform((v) => v === "true")
|
|
46
|
+
.default(true),
|
|
47
|
+
PW_WORKERS: zod_1.z
|
|
48
|
+
.string()
|
|
49
|
+
.transform((v) => Number(v))
|
|
50
|
+
.pipe(zod_1.z.number().int().positive())
|
|
51
|
+
.default(2),
|
|
52
|
+
PW_RETRIES: zod_1.z
|
|
53
|
+
.string()
|
|
54
|
+
.transform((v) => Number(v))
|
|
55
|
+
.pipe(zod_1.z.number().int().nonnegative())
|
|
56
|
+
.default(1),
|
|
57
|
+
});
|
|
58
|
+
const parsed = EnvSchema.safeParse(process.env);
|
|
59
|
+
if (!parsed.success) {
|
|
60
|
+
console.error("Invalid environment variables:");
|
|
61
|
+
console.error(parsed.error.flatten().fieldErrors);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
exports.env = parsed.data;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.computeDiff = computeDiff;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
function extractTrailingJson(content) {
|
|
10
|
+
const trimmed = content.trimEnd();
|
|
11
|
+
for (let i = trimmed.lastIndexOf("{"); i >= 0; i = trimmed.lastIndexOf("{", i - 1)) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(trimmed.slice(i));
|
|
14
|
+
}
|
|
15
|
+
catch { }
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
function collectFailures(root) {
|
|
20
|
+
const failures = [];
|
|
21
|
+
if (!fs_1.default.existsSync(root))
|
|
22
|
+
return failures;
|
|
23
|
+
function walk(dir) {
|
|
24
|
+
for (const file of fs_1.default.readdirSync(dir)) {
|
|
25
|
+
const full = path_1.default.join(dir, file);
|
|
26
|
+
if (fs_1.default.statSync(full).isDirectory()) {
|
|
27
|
+
walk(full);
|
|
28
|
+
}
|
|
29
|
+
else if (file === "ai.txt") {
|
|
30
|
+
const content = fs_1.default.readFileSync(full, "utf8");
|
|
31
|
+
const data = extractTrailingJson(content);
|
|
32
|
+
if (!data)
|
|
33
|
+
continue;
|
|
34
|
+
failures.push({
|
|
35
|
+
file: data.file,
|
|
36
|
+
line: data.line,
|
|
37
|
+
failure_type: data.failure_type,
|
|
38
|
+
severity: data.severity,
|
|
39
|
+
confidence: data.confidence,
|
|
40
|
+
is_flaky_suspected: data.is_flaky_suspected ?? false
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
walk(root);
|
|
46
|
+
return failures;
|
|
47
|
+
}
|
|
48
|
+
function toMap(arr) {
|
|
49
|
+
const map = new Map();
|
|
50
|
+
for (const item of arr) {
|
|
51
|
+
const key = `${item.file}:${item.line}:${item.failure_type}`;
|
|
52
|
+
map.set(key, item);
|
|
53
|
+
}
|
|
54
|
+
return map;
|
|
55
|
+
}
|
|
56
|
+
function computeDiff(baselineDir, currentDir) {
|
|
57
|
+
const baseline = collectFailures(baselineDir);
|
|
58
|
+
const current = collectFailures(currentDir);
|
|
59
|
+
const baselineMap = toMap(baseline);
|
|
60
|
+
const currentMap = toMap(current);
|
|
61
|
+
const newFailures = [];
|
|
62
|
+
const unchangedFailures = [];
|
|
63
|
+
const fixedFailures = [];
|
|
64
|
+
for (const [key, value] of currentMap.entries()) {
|
|
65
|
+
if (!baselineMap.has(key))
|
|
66
|
+
newFailures.push(value);
|
|
67
|
+
else
|
|
68
|
+
unchangedFailures.push(value);
|
|
69
|
+
}
|
|
70
|
+
for (const [key, value] of baselineMap.entries()) {
|
|
71
|
+
if (!currentMap.has(key))
|
|
72
|
+
fixedFailures.push(value);
|
|
73
|
+
}
|
|
74
|
+
const blockingFailures = newFailures.filter((f) => !f.is_flaky_suspected);
|
|
75
|
+
return {
|
|
76
|
+
newFailures,
|
|
77
|
+
unchangedFailures,
|
|
78
|
+
fixedFailures,
|
|
79
|
+
blockingFailures
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.failureKey = failureKey;
|
|
7
|
+
exports.readFailures = readFailures;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
function isDirectory(p) {
|
|
11
|
+
return fs_1.default.existsSync(p) && fs_1.default.statSync(p).isDirectory();
|
|
12
|
+
}
|
|
13
|
+
function walkForAiTxt(dir, results = []) {
|
|
14
|
+
for (const entry of fs_1.default.readdirSync(dir)) {
|
|
15
|
+
const full = path_1.default.join(dir, entry);
|
|
16
|
+
const stat = fs_1.default.statSync(full);
|
|
17
|
+
if (stat.isDirectory()) {
|
|
18
|
+
walkForAiTxt(full, results);
|
|
19
|
+
}
|
|
20
|
+
else if (entry === "ai.txt" || entry === "failure-meta.json") {
|
|
21
|
+
results.push(full);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return results;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* ai.txt contains human section + raw JSON.
|
|
28
|
+
* We extract JSON by finding the first "{" and parsing the rest.
|
|
29
|
+
*/
|
|
30
|
+
function parseAiTxt(filePath) {
|
|
31
|
+
if (filePath.endsWith("failure-meta.json")) {
|
|
32
|
+
try {
|
|
33
|
+
const raw = JSON.parse(fs_1.default.readFileSync(filePath, "utf8"));
|
|
34
|
+
if (!raw || !Array.isArray(raw.failures) || raw.failures.length === 0) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const parsed = raw.failures[0];
|
|
38
|
+
return {
|
|
39
|
+
file: parsed.file,
|
|
40
|
+
line: parsed.line,
|
|
41
|
+
failure_type: parsed.failure_type,
|
|
42
|
+
expected: parsed.expected,
|
|
43
|
+
received: parsed.received,
|
|
44
|
+
is_flaky_suspected: Boolean(parsed.is_flaky_suspected),
|
|
45
|
+
severity: parsed.severity,
|
|
46
|
+
confidence: parsed.confidence,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const content = fs_1.default.readFileSync(filePath, "utf8");
|
|
54
|
+
const jsonStart = content.indexOf("{");
|
|
55
|
+
if (jsonStart === -1)
|
|
56
|
+
return null;
|
|
57
|
+
const jsonRaw = content.substring(jsonStart).trim();
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(jsonRaw);
|
|
60
|
+
// Minimal validation (avoid hard crash in CI)
|
|
61
|
+
if (!parsed ||
|
|
62
|
+
typeof parsed.file !== "string" ||
|
|
63
|
+
(typeof parsed.line !== "number" && typeof parsed.line !== "string") ||
|
|
64
|
+
typeof parsed.failure_type !== "string" ||
|
|
65
|
+
typeof parsed.severity !== "string" ||
|
|
66
|
+
typeof parsed.confidence !== "number") {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const lineNum = typeof parsed.line === "string" ? Number(parsed.line) : parsed.line;
|
|
70
|
+
const failure = {
|
|
71
|
+
file: parsed.file,
|
|
72
|
+
line: Number.isFinite(lineNum) ? lineNum : 0,
|
|
73
|
+
failure_type: parsed.failure_type,
|
|
74
|
+
expected: parsed.expected,
|
|
75
|
+
received: parsed.received,
|
|
76
|
+
is_flaky_suspected: Boolean(parsed.is_flaky_suspected),
|
|
77
|
+
severity: parsed.severity,
|
|
78
|
+
confidence: parsed.confidence,
|
|
79
|
+
};
|
|
80
|
+
return failure;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function failureKey(f) {
|
|
87
|
+
return `${f.file}:${f.line}:${f.failure_type}`;
|
|
88
|
+
}
|
|
89
|
+
function readFailures(options) {
|
|
90
|
+
const root = options.rootDir;
|
|
91
|
+
if (!isDirectory(root)) {
|
|
92
|
+
return { failures: [], byKey: new Map(), sourceFiles: [] };
|
|
93
|
+
}
|
|
94
|
+
const aiFiles = walkForAiTxt(root);
|
|
95
|
+
const failures = [];
|
|
96
|
+
const byKey = new Map();
|
|
97
|
+
for (const fp of aiFiles) {
|
|
98
|
+
const parsed = parseAiTxt(fp);
|
|
99
|
+
if (!parsed)
|
|
100
|
+
continue;
|
|
101
|
+
const key = failureKey(parsed);
|
|
102
|
+
failures.push(parsed);
|
|
103
|
+
// If duplicates, keep the latest encountered (fine for now)
|
|
104
|
+
byKey.set(key, parsed);
|
|
105
|
+
}
|
|
106
|
+
return { failures, byKey, sourceFiles: aiFiles };
|
|
107
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatDiffComment = formatDiffComment;
|
|
4
|
+
exports.hasFailureChanges = hasFailureChanges;
|
|
5
|
+
function recurrenceSuffix(f) {
|
|
6
|
+
if (f.occurrence_count == null)
|
|
7
|
+
return "";
|
|
8
|
+
const n = f.occurrence_count;
|
|
9
|
+
const date = f.first_seen
|
|
10
|
+
? new Date(f.first_seen).toISOString().slice(0, 10)
|
|
11
|
+
: "";
|
|
12
|
+
if (n === 1)
|
|
13
|
+
return " (first time)";
|
|
14
|
+
const ord = n % 10 === 1 && n % 100 !== 11
|
|
15
|
+
? "st"
|
|
16
|
+
: n % 10 === 2 && n % 100 !== 12
|
|
17
|
+
? "nd"
|
|
18
|
+
: n % 10 === 3 && n % 100 !== 13
|
|
19
|
+
? "rd"
|
|
20
|
+
: "th";
|
|
21
|
+
return date ? ` (${n}${ord} time since ${date})` : ` (${n}×)`;
|
|
22
|
+
}
|
|
23
|
+
function formatSection(title, list, includeSeverity = true) {
|
|
24
|
+
if (list.length === 0)
|
|
25
|
+
return "";
|
|
26
|
+
let section = `### ${title} (${list.length})\n\n`;
|
|
27
|
+
for (const item of list) {
|
|
28
|
+
const severityPart = includeSeverity
|
|
29
|
+
? ` | severity: ${item.severity}`
|
|
30
|
+
: "";
|
|
31
|
+
const recur = recurrenceSuffix(item);
|
|
32
|
+
section += `• ${item.file}:${item.line} | ${item.failure_type}${severityPart} | confidence: ${item.confidence}${recur}\n`;
|
|
33
|
+
}
|
|
34
|
+
return section + "\n";
|
|
35
|
+
}
|
|
36
|
+
function formatDiffComment(diff) {
|
|
37
|
+
const unchangedFailures = diff.unchangedFailures ?? [];
|
|
38
|
+
const newFailures = diff.newFailures ?? [];
|
|
39
|
+
const fixedFailures = diff.fixedFailures ?? [];
|
|
40
|
+
const flaky = newFailures.filter((f) => f.is_flaky_suspected);
|
|
41
|
+
const realNewFailures = newFailures.filter((f) => !f.is_flaky_suspected);
|
|
42
|
+
const commit = process.env.GITHUB_SHA?.slice(0, 7);
|
|
43
|
+
let body = "## AI Failure Diff Summary\n\n";
|
|
44
|
+
if (commit)
|
|
45
|
+
body += `Commit: ${commit}\n`;
|
|
46
|
+
body += "\n";
|
|
47
|
+
if (unchangedFailures.length > 0) {
|
|
48
|
+
body +=
|
|
49
|
+
"⚠️ **Existing issues** from the base branch are still failing on this branch. This PR was not blocked by them. Fix these on this branch or on the base branch to get to green.\n\n";
|
|
50
|
+
}
|
|
51
|
+
body += formatSection("New Issues", realNewFailures);
|
|
52
|
+
body += formatSection("Flaky", flaky, false);
|
|
53
|
+
body += formatSection("Still Failing", unchangedFailures);
|
|
54
|
+
body += formatSection("Fixed Issues", fixedFailures);
|
|
55
|
+
return body;
|
|
56
|
+
}
|
|
57
|
+
function hasFailureChanges(diff) {
|
|
58
|
+
return ((diff.newFailures?.length ?? 0) > 0 ||
|
|
59
|
+
(diff.unchangedFailures?.length ?? 0) > 0 ||
|
|
60
|
+
(diff.fixedFailures?.length ?? 0) > 0);
|
|
61
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BasePage = void 0;
|
|
4
|
+
const baseTest_1 = require("./baseTest");
|
|
5
|
+
const logger_1 = require("../reporting/logger");
|
|
6
|
+
class BasePage {
|
|
7
|
+
constructor(page) {
|
|
8
|
+
this.page = page;
|
|
9
|
+
}
|
|
10
|
+
async navigate(path) {
|
|
11
|
+
const url = path.startsWith("http")
|
|
12
|
+
? path
|
|
13
|
+
: `${baseTest_1.env.BASE_URL}${path}`;
|
|
14
|
+
logger_1.logger.info(`Navigating to: ${url}`);
|
|
15
|
+
await this.page.goto(url, { waitUntil: "networkidle" });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
exports.BasePage = BasePage;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.env = exports.expect = exports.test = void 0;
|
|
4
|
+
const test_1 = require("@playwright/test");
|
|
5
|
+
Object.defineProperty(exports, "expect", { enumerable: true, get: function () { return test_1.expect; } });
|
|
6
|
+
const env_1 = require("../config/env");
|
|
7
|
+
Object.defineProperty(exports, "env", { enumerable: true, get: function () { return env_1.env; } });
|
|
8
|
+
require("./testHooks");
|
|
9
|
+
exports.test = test_1.test;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.default = globalSetup;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
async function globalSetup() {
|
|
10
|
+
const runId = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
11
|
+
const runDir = path_1.default.join("artifacts", runId);
|
|
12
|
+
fs_1.default.mkdirSync(runDir, { recursive: true });
|
|
13
|
+
fs_1.default.writeFileSync(path_1.default.join("artifacts", ".current-run"), runDir);
|
|
14
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.default = globalTeardown;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const failureAnalyzer_1 = require("../ai/failureAnalyzer");
|
|
10
|
+
function findMetaFiles(dir, results = []) {
|
|
11
|
+
const entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
12
|
+
for (const entry of entries) {
|
|
13
|
+
const fullPath = path_1.default.join(dir, entry.name);
|
|
14
|
+
if (entry.isDirectory()) {
|
|
15
|
+
findMetaFiles(fullPath, results);
|
|
16
|
+
}
|
|
17
|
+
else if (entry.name === "meta.json") {
|
|
18
|
+
results.push(fullPath);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return results;
|
|
22
|
+
}
|
|
23
|
+
async function globalTeardown() {
|
|
24
|
+
if (process.env.AI_ANALYSIS !== "true")
|
|
25
|
+
return;
|
|
26
|
+
const runDir = fs_1.default.readFileSync(path_1.default.join("artifacts", ".current-run"), "utf-8");
|
|
27
|
+
if (!runDir || !fs_1.default.existsSync(runDir))
|
|
28
|
+
return;
|
|
29
|
+
const metaFiles = findMetaFiles(runDir);
|
|
30
|
+
if (metaFiles.length === 0)
|
|
31
|
+
return;
|
|
32
|
+
for (const metaPath of metaFiles) {
|
|
33
|
+
console.log(`Analyzing: ${metaPath}`);
|
|
34
|
+
try {
|
|
35
|
+
const analysis = await (0, failureAnalyzer_1.analyzeFailureFile)(metaPath);
|
|
36
|
+
if (!analysis)
|
|
37
|
+
continue;
|
|
38
|
+
const outputFile = metaPath.replace("meta.json", "ai.txt");
|
|
39
|
+
fs_1.default.writeFileSync(outputFile, analysis);
|
|
40
|
+
console.log(`Saved AI analysis: ${path_1.default.basename(outputFile)}\n`);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
console.error(`AI analysis failed for ${metaPath}:`, error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.test = void 0;
|
|
7
|
+
const test_1 = require("@playwright/test");
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const logger_1 = require("../reporting/logger");
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
exports.test = test_1.test;
|
|
12
|
+
exports.test.afterEach(async ({ page }, testInfo) => {
|
|
13
|
+
const logFile = (0, logger_1.getLogFilePath)();
|
|
14
|
+
if (fs_1.default.existsSync(logFile)) {
|
|
15
|
+
await testInfo.attach("run.log", {
|
|
16
|
+
path: logFile,
|
|
17
|
+
contentType: "text/plain",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const isRealFailure = testInfo.status !== testInfo.expectedStatus;
|
|
21
|
+
const isFlaky = testInfo.retry > 0 &&
|
|
22
|
+
testInfo.status === testInfo.expectedStatus;
|
|
23
|
+
if (isRealFailure || isFlaky) {
|
|
24
|
+
const screenshot = await page.screenshot({ fullPage: true });
|
|
25
|
+
await testInfo.attach("failure-screenshot", {
|
|
26
|
+
body: screenshot,
|
|
27
|
+
contentType: "image/png",
|
|
28
|
+
});
|
|
29
|
+
const runDir = fs_1.default.readFileSync(path_1.default.join("artifacts", ".current-run"), "utf-8");
|
|
30
|
+
const specName = path_1.default.basename(testInfo.file);
|
|
31
|
+
const safeTitle = testInfo.title.replace(/[^\w\d]/g, "_");
|
|
32
|
+
const attemptDir = `attempt-${testInfo.retry}`;
|
|
33
|
+
const testDir = path_1.default.join(runDir, specName, safeTitle, attemptDir);
|
|
34
|
+
fs_1.default.mkdirSync(testDir, { recursive: true });
|
|
35
|
+
const html = await page.content();
|
|
36
|
+
fs_1.default.writeFileSync(path_1.default.join(testDir, "dom.html"), html);
|
|
37
|
+
const severity = isFlaky ? "low" : "medium";
|
|
38
|
+
const meta = {
|
|
39
|
+
title: testInfo.title,
|
|
40
|
+
status: testInfo.status,
|
|
41
|
+
expectedStatus: testInfo.expectedStatus,
|
|
42
|
+
errorMessage: testInfo.error?.message,
|
|
43
|
+
stack: testInfo.error?.stack,
|
|
44
|
+
url: page.url(),
|
|
45
|
+
project: testInfo.project.name,
|
|
46
|
+
duration: testInfo.duration,
|
|
47
|
+
retry: testInfo.retry,
|
|
48
|
+
is_flaky_suspected: isFlaky,
|
|
49
|
+
severity: severity
|
|
50
|
+
};
|
|
51
|
+
fs_1.default.writeFileSync(path_1.default.join(testDir, "meta.json"), JSON.stringify(meta, null, 2));
|
|
52
|
+
}
|
|
53
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.logger = void 0;
|
|
7
|
+
exports.getLogFilePath = getLogFilePath;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const logsDir = path_1.default.resolve(process.cwd(), "artifacts/logs");
|
|
11
|
+
if (!fs_1.default.existsSync(logsDir))
|
|
12
|
+
fs_1.default.mkdirSync(logsDir, { recursive: true });
|
|
13
|
+
const logFile = path_1.default.join(logsDir, `run-${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
|
|
14
|
+
function write(level, message) {
|
|
15
|
+
const line = `${new Date().toISOString()} [${level}] ${message}\n`;
|
|
16
|
+
fs_1.default.appendFileSync(logFile, line);
|
|
17
|
+
process.stdout.write(line);
|
|
18
|
+
}
|
|
19
|
+
exports.logger = {
|
|
20
|
+
info: (msg) => write("INFO", msg),
|
|
21
|
+
warn: (msg) => write("WARN", msg),
|
|
22
|
+
error: (msg) => write("ERROR", msg),
|
|
23
|
+
debug: (msg) => write("DEBUG", msg),
|
|
24
|
+
};
|
|
25
|
+
function getLogFilePath() {
|
|
26
|
+
return logFile;
|
|
27
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "qa-intelligence",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"engines": {
|
|
5
|
+
"node": ">=18"
|
|
6
|
+
},
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"description": "CI intelligence engine for test pipelines. Detects regressions, flaky tests, and failure lifecycle in pull requests.",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"ci",
|
|
13
|
+
"qa",
|
|
14
|
+
"test-automation",
|
|
15
|
+
"playwright",
|
|
16
|
+
"regression-detection",
|
|
17
|
+
"flaky-tests",
|
|
18
|
+
"qa-intelligence"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/ardithaqi/qa-intelligence.git"
|
|
24
|
+
},
|
|
25
|
+
"type": "commonjs",
|
|
26
|
+
"main": "dist/index.js",
|
|
27
|
+
"exports": {
|
|
28
|
+
"./playwright": "./dist/playwright/baseTest.js",
|
|
29
|
+
"./playwright/globalSetup": "./dist/playwright/globalSetup.js",
|
|
30
|
+
"./playwright/globalTeardown": "./dist/playwright/globalTeardown.js",
|
|
31
|
+
"./playwright/basePage": "./dist/playwright/basePage.js",
|
|
32
|
+
"./playwright/steps": "./dist/playwright/steps.js",
|
|
33
|
+
"./config/env": "./dist/config/env.js"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@playwright/test": ">=1.40.0"
|
|
37
|
+
},
|
|
38
|
+
"bin": {
|
|
39
|
+
"qa-intelligence": "dist/cli/index.js",
|
|
40
|
+
"qa-intelligence-diff": "dist/cli/diff.js",
|
|
41
|
+
"qa-intelligence-history": "dist/cli/history.js",
|
|
42
|
+
"qa-intelligence-comment": "dist/cli/postComment.js"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc -p tsconfig.json",
|
|
46
|
+
"prepare": "npm run build",
|
|
47
|
+
"dev:diff": "ts-node src/cli/diff.ts --baseline baseline-artifacts --current artifacts --out failure-diff.json",
|
|
48
|
+
"dev:comment": "ts-node src/cli/postComment.ts --diff failure-diff.json --repo owner/repo --pr 1 --token TOKEN"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@octokit/rest": "^22.0.1",
|
|
52
|
+
"dotenv": "^17.3.1",
|
|
53
|
+
"openai": "^6.29.0",
|
|
54
|
+
"zod": "^4.3.6"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@playwright/test": "^1.58.2",
|
|
58
|
+
"@types/node": "^22.0.0",
|
|
59
|
+
"ts-node": "^10.9.2",
|
|
60
|
+
"tsx": "^4.21.0",
|
|
61
|
+
"typescript": "^5.9.3"
|
|
62
|
+
},
|
|
63
|
+
"files": [
|
|
64
|
+
"dist"
|
|
65
|
+
]
|
|
66
|
+
}
|