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 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
+ }
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ require("dotenv/config");
4
+ const failureAnalyzer_1 = require("./failureAnalyzer");
5
+ (0, failureAnalyzer_1.analyzeLatestFailure)().catch(console.error);
@@ -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,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.step = step;
4
+ const test_1 = require("@playwright/test");
5
+ async function step(name, fn) {
6
+ return await test_1.test.step(name, fn);
7
+ }
@@ -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
+ }