@ai-qa/workflow 2.0.11 → 2.0.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/.github/agents/playwright-test-generator.agent.md +49 -0
- package/.github/agents/playwright-test-healer.agent.md +32 -3
- package/.github/agents/playwright-test-planner.agent.md +26 -0
- package/.github/copilot-instructions.md +44 -2
- package/.opencode/agents/qa-generator.md +16 -0
- package/.opencode/agents/qa-healer.md +18 -0
- package/.opencode/agents/qa-planner.md +17 -0
- package/.opencode/rules.md +66 -2
- package/.qa-context/auth.json +29 -0
- package/.qa-context/heal-history.json +40 -0
- package/.qa-context/pipeline.json +34 -0
- package/.qa-context/selectors.json +64 -0
- package/.qa-context/traceability.json +30 -0
- package/README.md +399 -196
- package/ai-qa-workflow.js +82 -104
- package/install.js +8 -13
- package/opencode.json +41 -0
- package/package.json +6 -6
- package/prompting_template.md +283 -0
- package/qa-dashboard/app.js +1 -0
- package/qa-dashboard/routes/review.js +114 -0
- package/qa-dashboard/views/layouts/main.ejs +1 -0
- package/qa-dashboard/views/review.ejs +201 -0
- package/router.md +109 -29
- package/scripts/auth-manager.js +186 -0
- package/scripts/context-manager.js +226 -0
- package/scripts/executor.js +18 -7
- package/scripts/generator.js +18 -124
- package/scripts/healer.js +78 -157
- package/scripts/planner.js +18 -136
- package/scripts/reporter.js +21 -1
- package/scripts/utils.js +2 -0
- package/qa-dashboard/.env +0 -3
package/router.md
CHANGED
|
@@ -1,29 +1,109 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
1
|
+
# AI QA Engineer — Agent Router
|
|
2
|
+
|
|
3
|
+
You are an **AI QA Engineer** assigned to this project.
|
|
4
|
+
Your mission: transform user stories into tested, reliable software through structured QA.
|
|
5
|
+
|
|
6
|
+
You have **Playwright MCP** (browser automation), **Applitools MCP** (visual testing), and **GitHub MCP** (version control) tools.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Phase 0: Environment Check (Do This FIRST)
|
|
11
|
+
|
|
12
|
+
When you first open this project, **automatically run this check and report to the user**:
|
|
13
|
+
|
|
14
|
+
1. ✅ **Config**: Does `.qa-workflow.json` exist and have project name + URL configured?
|
|
15
|
+
2. ✅ **Context**: Does `docs/application-context.md` exist with content?
|
|
16
|
+
3. ✅ **Memory**: Does `.qa-context/` exist with pipeline.json, selectors.json, heal-history.json, traceability.json?
|
|
17
|
+
4. ✅ **Auth**: Does `.auth/credentials.json` exist with login credentials? Is `.auth/storage-state.json` valid?
|
|
18
|
+
4. ✅ **Playwright**: Is `@playwright/test` installed? (`npx playwright --version`)
|
|
19
|
+
5. ✅ **Chromium**: Is Chromium installed? (`npx playwright install --dry-run chromium`)
|
|
20
|
+
6. ✅ **MCP**: Is `@playwright/mcp` installed?
|
|
21
|
+
7. ✅ **Directories**: Do `user-story/`, `specs/`, `tests/`, `test-results/` exist?
|
|
22
|
+
8. ✅ **Dev server**: Is the app accessible at the configured URL?
|
|
23
|
+
9. ✅ **Applitools**: Is `APPLITOOLS_API_KEY` env var set? (needed for visual testing)
|
|
24
|
+
10. ✅ **GitHub**: Is `GITHUB_TOKEN` env var set? (needed for GitHub MCP)
|
|
25
|
+
|
|
26
|
+
Present a clean summary:
|
|
27
|
+
- **What's ready** ✅
|
|
28
|
+
- **What's missing** ❌ (with commands to fix)
|
|
29
|
+
- **What you need from the user** 📋 (user story, credentials, etc.)
|
|
30
|
+
- Then **stop and wait** for the user to give you a user story or direction.
|
|
31
|
+
|
|
32
|
+
**Do not skip this check.** The user needs to know what's missing before anything else.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Phase 1-5: Workflow
|
|
37
|
+
|
|
38
|
+
Once the user provides a user story or testing request, follow this workflow. **Every phase requires human approval before proceeding.**
|
|
39
|
+
|
|
40
|
+
### Phase 1: Plan & Explore
|
|
41
|
+
User says: *"Plan tests for this user story"*
|
|
42
|
+
1. Read `.github/agents/playwright-test-planner.agent.md`
|
|
43
|
+
2. Explore the app with Playwright MCP
|
|
44
|
+
3. Write test plan to `specs/`
|
|
45
|
+
4. **STOP** — present the plan to the user
|
|
46
|
+
5. Wait for approval before continuing
|
|
47
|
+
|
|
48
|
+
### Phase 2: Generate Tests
|
|
49
|
+
User says: *"Generate tests from the plan"*
|
|
50
|
+
1. Read `.github/agents/playwright-test-generator.agent.md`
|
|
51
|
+
2. Read `prompts/QAe2eprompt.md` for conventions
|
|
52
|
+
3. Write Playwright test files to `tests/`
|
|
53
|
+
4. **STOP** — present the generated tests to the user
|
|
54
|
+
5. Wait for approval before executing
|
|
55
|
+
|
|
56
|
+
### Phase 3: Execute
|
|
57
|
+
User says: *"Execute the tests"*
|
|
58
|
+
1. User runs `npm run qa:execute` (or you suggest it)
|
|
59
|
+
2. Check results — did they pass or fail?
|
|
60
|
+
|
|
61
|
+
### Phase 4: Heal Failures
|
|
62
|
+
If tests fail, user says: *"Fix the failing tests"*
|
|
63
|
+
1. Read `.github/agents/playwright-test-healer.agent.md`
|
|
64
|
+
2. Debug with Playwright MCP (`test_debug`, `browser_snapshot`, etc.)
|
|
65
|
+
3. Present diagnosis to the user (root cause + proposed 1-3 line fix)
|
|
66
|
+
4. **STOP** — wait for user to approve the fix
|
|
67
|
+
5. Apply fix and re-run
|
|
68
|
+
6. If still failing, mark `test.fixme()` and classify as defect
|
|
69
|
+
|
|
70
|
+
### Phase 5: Report
|
|
71
|
+
User says: *"Generate the report"*
|
|
72
|
+
1. User runs `npm run qa:report` (or you suggest it)
|
|
73
|
+
2. Present the report summary to the user
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## ⛔ Human Supervision (Mandatory)
|
|
78
|
+
|
|
79
|
+
| Phase | You do this | Then wait for approval |
|
|
80
|
+
|-------|------------|----------------------|
|
|
81
|
+
| Plan | Explore app, write plan | User reviews plan |
|
|
82
|
+
| Generate | Write Playwright tests | User reviews code |
|
|
83
|
+
| Execute | Run tests (or tell user to) | User checks results |
|
|
84
|
+
| Heal | Diagnose failure, propose fix | User approves fix |
|
|
85
|
+
| Report | Generate and present | User reviews |
|
|
86
|
+
|
|
87
|
+
**You never proceed to the next phase without explicit user approval.**
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 🧭 Routing Quick Reference
|
|
92
|
+
|
|
93
|
+
| User intent | Read this |
|
|
94
|
+
|------------|-----------|
|
|
95
|
+
| "Plan tests / Explore the app / Write a test plan" | `.github/agents/playwright-test-planner.agent.md` |
|
|
96
|
+
| "Generate tests / Write test code / Create spec files" | `.github/agents/playwright-test-generator.agent.md` + `prompts/QAe2eprompt.md` |
|
|
97
|
+
| "Fix failing tests / Debug / Heal" | `.github/agents/playwright-test-healer.agent.md` |
|
|
98
|
+
| General QA requests | `prompts/general_prompt.md` |
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## ⚠️ Technical Rules
|
|
103
|
+
|
|
104
|
+
- Never hallucinate CSS selectors — use Playwright MCP to find real ones
|
|
105
|
+
- Priority: `data-testid` > `aria-label` > `role` > `text` > `xpath` (last resort)
|
|
106
|
+
- 1 fix attempt max per test. If still failing, it's a defect.
|
|
107
|
+
- 1-3 line targeted edits only. No full file rewrites.
|
|
108
|
+
- If the dev server isn't running, warn the user before trying to connect.
|
|
109
|
+
- Token efficiency: keep responses focused and minimal.
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const { DIRS, ensureDir } = require('./utils');
|
|
4
|
+
|
|
5
|
+
const AUTH_CONFIG_PATH = path.join(DIRS.qaContext, 'auth.json');
|
|
6
|
+
const CREDENTIALS_PATH = path.join(DIRS.auth, 'credentials.json');
|
|
7
|
+
const STORAGE_PATH = path.join(DIRS.auth, 'storage-state.json');
|
|
8
|
+
|
|
9
|
+
function readJSON(filePath) {
|
|
10
|
+
if (!fs.existsSync(filePath)) return null;
|
|
11
|
+
try { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } catch { return null; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function writeJSON(filePath, data) {
|
|
15
|
+
ensureDir(path.dirname(filePath));
|
|
16
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const auth = {
|
|
20
|
+
|
|
21
|
+
// ── Auth Config (.qa-context/auth.json) ──
|
|
22
|
+
|
|
23
|
+
getConfig() {
|
|
24
|
+
return readJSON(AUTH_CONFIG_PATH) || {
|
|
25
|
+
methods: [],
|
|
26
|
+
session: { storage_path: STORAGE_PATH, last_refreshed: null },
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
saveConfig(config) {
|
|
31
|
+
writeJSON(AUTH_CONFIG_PATH, config);
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
updateMethod(method) {
|
|
35
|
+
const config = this.getConfig();
|
|
36
|
+
const idx = config.methods.findIndex(m => m.id === method.id);
|
|
37
|
+
if (idx >= 0) config.methods[idx] = method;
|
|
38
|
+
else config.methods.push(method);
|
|
39
|
+
this.saveConfig(config);
|
|
40
|
+
return config;
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
hasMethod() {
|
|
44
|
+
const config = this.getConfig();
|
|
45
|
+
return config.methods.length > 0;
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// ── Credentials (.auth/credentials.json) ──
|
|
49
|
+
|
|
50
|
+
saveCredentials(username, password, options = {}) {
|
|
51
|
+
const creds = { username, password, url: options.url || '', updated_at: new Date().toISOString() };
|
|
52
|
+
writeJSON(CREDENTIALS_PATH, creds);
|
|
53
|
+
|
|
54
|
+
const config = this.getConfig();
|
|
55
|
+
if (!config.methods.find(m => m.id === 'main-login')) {
|
|
56
|
+
this.updateMethod({
|
|
57
|
+
id: 'main-login',
|
|
58
|
+
type: 'form',
|
|
59
|
+
detected_at: new Date().toISOString(),
|
|
60
|
+
login_url: options.url || '/login',
|
|
61
|
+
fields: [
|
|
62
|
+
{ name: 'username', selector: '', type: 'text' },
|
|
63
|
+
{ name: 'password', selector: '', type: 'password' },
|
|
64
|
+
],
|
|
65
|
+
submit: '',
|
|
66
|
+
success_indicator: '',
|
|
67
|
+
last_verified: new Date().toISOString(),
|
|
68
|
+
status: 'configured',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return creds;
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
getCredentials() {
|
|
76
|
+
return readJSON(CREDENTIALS_PATH) || null;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
hasCredentials() {
|
|
80
|
+
return this.getCredentials() !== null;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
clearCredentials() {
|
|
84
|
+
if (fs.existsSync(CREDENTIALS_PATH)) fs.unlinkSync(CREDENTIALS_PATH);
|
|
85
|
+
if (fs.existsSync(STORAGE_PATH)) fs.unlinkSync(STORAGE_PATH);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
// ── Session (.auth/storage-state.json) ──
|
|
89
|
+
|
|
90
|
+
saveSession(page) {
|
|
91
|
+
throw new Error('saveSession requires Playwright page — use from AI agent or Playwright context');
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
getStorageStatePath() {
|
|
95
|
+
return STORAGE_PATH;
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
hasSession() {
|
|
99
|
+
return fs.existsSync(STORAGE_PATH);
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
clearSession() {
|
|
103
|
+
if (fs.existsSync(STORAGE_PATH)) fs.unlinkSync(STORAGE_PATH);
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
// ── Login Steps Generator ──
|
|
107
|
+
|
|
108
|
+
getLoginSteps(methodId) {
|
|
109
|
+
const config = this.getConfig();
|
|
110
|
+
const method = config.methods.find(m => m.id === (methodId || 'main-login'));
|
|
111
|
+
if (!method) return null;
|
|
112
|
+
|
|
113
|
+
const creds = this.getCredentials();
|
|
114
|
+
if (!creds) return null;
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
url: method.login_url || creds.url || '/login',
|
|
118
|
+
fields: method.fields.map(f => ({
|
|
119
|
+
selector: f.selector,
|
|
120
|
+
value: f.name === 'username' || f.name === 'email' ? creds.username : creds.password,
|
|
121
|
+
})),
|
|
122
|
+
submit: method.submit,
|
|
123
|
+
success: method.success_indicator,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
generateSetupCode() {
|
|
128
|
+
const config = this.getConfig();
|
|
129
|
+
const method = config.methods.find(m => m.id === 'main-login');
|
|
130
|
+
const creds = this.getCredentials();
|
|
131
|
+
if (!method || !creds) return null;
|
|
132
|
+
|
|
133
|
+
const storagePath = STORAGE_PATH.replace(/\\/g, '/');
|
|
134
|
+
const fields = method.fields.map(f => {
|
|
135
|
+
const val = f.name === 'username' || f.name === 'email' ? 'credentials.username' : 'credentials.password';
|
|
136
|
+
return ` await page.locator('${f.selector || ''}').fill(${val});`;
|
|
137
|
+
}).join('\n');
|
|
138
|
+
|
|
139
|
+
return `import { test as setup, expect } from '@playwright/test';
|
|
140
|
+
import path from 'path';
|
|
141
|
+
|
|
142
|
+
const AUTH_FILE = path.resolve(__dirname, '..', '${storagePath}');
|
|
143
|
+
const CREDENTIALS = require('${CREDENTIALS_PATH.replace(/\\/g, '/')}');
|
|
144
|
+
|
|
145
|
+
setup('authenticate', async ({ page }) => {
|
|
146
|
+
await page.goto('${method.login_url || creds.url || '/'}');
|
|
147
|
+
${fields}
|
|
148
|
+
await page.locator('${method.submit || ''}').click();
|
|
149
|
+
await page.waitForURL('**${method.success_indicator || ''}**');
|
|
150
|
+
await page.context().storageState({ path: AUTH_FILE });
|
|
151
|
+
});`;
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
// ── Auth Failure Detection ──
|
|
155
|
+
|
|
156
|
+
isAuthFailure(error) {
|
|
157
|
+
if (!error) return false;
|
|
158
|
+
const msg = typeof error === 'string' ? error : (error.message || error.error || '');
|
|
159
|
+
const patterns = [
|
|
160
|
+
'login', 'unauthorized', 'unauthenticated', '401', '403',
|
|
161
|
+
'session expired', 'invalid token', 'redirected to login',
|
|
162
|
+
'sign in', 'authentication required', 'credentials',
|
|
163
|
+
];
|
|
164
|
+
return patterns.some(p => msg.toLowerCase().includes(p));
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
// ── Init ──
|
|
168
|
+
|
|
169
|
+
init() {
|
|
170
|
+
ensureDir(DIRS.auth);
|
|
171
|
+
|
|
172
|
+
if (!fs.existsSync(AUTH_CONFIG_PATH)) {
|
|
173
|
+
this.saveConfig({
|
|
174
|
+
methods: [],
|
|
175
|
+
session: { storage_path: STORAGE_PATH, last_refreshed: null },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const gitignorePath = path.join(DIRS.auth, '.gitignore');
|
|
180
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
181
|
+
fs.writeFileSync(gitignorePath, '# Auth credentials and Playwright storage state\n*\n', 'utf-8');
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
module.exports = auth;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
|
|
4
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
5
|
+
const CONTEXT_DIR = path.join(ROOT, '.qa-context');
|
|
6
|
+
|
|
7
|
+
function ensureDir(dir) {
|
|
8
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readJSON(filePath) {
|
|
12
|
+
if (!fs.existsSync(filePath)) return null;
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
15
|
+
} catch { return null; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function writeJSON(filePath, data) {
|
|
19
|
+
ensureDir(path.dirname(filePath));
|
|
20
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function pipelinePath() { return path.join(CONTEXT_DIR, 'pipeline.json'); }
|
|
24
|
+
function selectorsPath() { return path.join(CONTEXT_DIR, 'selectors.json'); }
|
|
25
|
+
function healHistoryPath() { return path.join(CONTEXT_DIR, 'heal-history.json'); }
|
|
26
|
+
function traceabilityPath() { return path.join(CONTEXT_DIR, 'traceability.json'); }
|
|
27
|
+
|
|
28
|
+
const context = {
|
|
29
|
+
|
|
30
|
+
// ── Pipeline State ──
|
|
31
|
+
|
|
32
|
+
getPipeline() {
|
|
33
|
+
return readJSON(pipelinePath()) || {
|
|
34
|
+
project_path: '',
|
|
35
|
+
phases: { plan: { completed: [], in_progress: null }, generate: { completed: [], in_progress: null }, execute: { completed: [], in_progress: null }, heal: { completed: [], in_progress: null }, report: { completed: [], in_progress: null } },
|
|
36
|
+
current_story: null,
|
|
37
|
+
last_run_id: null,
|
|
38
|
+
updated_at: '',
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
setPipeline(updates) {
|
|
43
|
+
const data = this.getPipeline();
|
|
44
|
+
Object.assign(data, updates);
|
|
45
|
+
data.updated_at = new Date().toISOString();
|
|
46
|
+
writeJSON(pipelinePath(), data);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
phaseComplete(phase, item) {
|
|
50
|
+
const data = this.getPipeline();
|
|
51
|
+
if (!data.phases[phase]) data.phases[phase] = { completed: [], in_progress: null };
|
|
52
|
+
if (!data.phases[phase].completed.includes(item)) {
|
|
53
|
+
data.phases[phase].completed.push(item);
|
|
54
|
+
}
|
|
55
|
+
data.phases[phase].in_progress = null;
|
|
56
|
+
data.updated_at = new Date().toISOString();
|
|
57
|
+
writeJSON(pipelinePath(), data);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
phaseInProgress(phase, item) {
|
|
61
|
+
const data = this.getPipeline();
|
|
62
|
+
if (!data.phases[phase]) data.phases[phase] = { completed: [], in_progress: null };
|
|
63
|
+
data.phases[phase].in_progress = item;
|
|
64
|
+
data.updated_at = new Date().toISOString();
|
|
65
|
+
writeJSON(pipelinePath(), data);
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
setCurrentStory(story) {
|
|
69
|
+
this.setPipeline({ current_story: story });
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
setLastRun(runId) {
|
|
73
|
+
this.setPipeline({ last_run_id: runId });
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
// ── Selectors Memory ──
|
|
77
|
+
|
|
78
|
+
getSelectors() {
|
|
79
|
+
return readJSON(selectorsPath()) || [];
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
addSelector(page, element, selectorValue, options = {}) {
|
|
83
|
+
const selectors = this.getSelectors();
|
|
84
|
+
let entry = selectors.find(s => s.page === page && s.element === element);
|
|
85
|
+
if (!entry) {
|
|
86
|
+
entry = { page, element, selectors: [], recommended: selectorValue };
|
|
87
|
+
selectors.push(entry);
|
|
88
|
+
}
|
|
89
|
+
const existing = entry.selectors.find(s => s.value === selectorValue);
|
|
90
|
+
if (existing) {
|
|
91
|
+
existing.last_used = new Date().toISOString();
|
|
92
|
+
if (options.reliability !== undefined) existing.reliability = options.reliability;
|
|
93
|
+
if (options.healed) existing.healed = true;
|
|
94
|
+
if (options.original) existing.original = options.original;
|
|
95
|
+
} else {
|
|
96
|
+
entry.selectors.push({
|
|
97
|
+
value: selectorValue,
|
|
98
|
+
type: options.type || 'css',
|
|
99
|
+
reliability: options.reliability || 0.5,
|
|
100
|
+
healed: options.healed || false,
|
|
101
|
+
original: options.original || null,
|
|
102
|
+
strategy: options.strategy || null,
|
|
103
|
+
healed_at: options.healed_at || null,
|
|
104
|
+
run_id: options.run_id || null,
|
|
105
|
+
first_seen: new Date().toISOString(),
|
|
106
|
+
last_used: new Date().toISOString(),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
entry.selectors.sort((a, b) => b.reliability - a.reliability);
|
|
110
|
+
entry.recommended = entry.selectors[0].value;
|
|
111
|
+
writeJSON(selectorsPath(), selectors);
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
getRecommendedSelector(page, element) {
|
|
115
|
+
const selectors = this.getSelectors();
|
|
116
|
+
const entry = selectors.find(s => s.page === page && s.element === element);
|
|
117
|
+
return entry ? entry.recommended : null;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
updateSelectorReliability(page, element, selectorValue, succeeded) {
|
|
121
|
+
const selectors = this.getSelectors();
|
|
122
|
+
const entry = selectors.find(s => s.page === page && s.element === element);
|
|
123
|
+
if (!entry) return;
|
|
124
|
+
const sel = entry.selectors.find(s => s.value === selectorValue);
|
|
125
|
+
if (!sel) return;
|
|
126
|
+
sel.reliability = succeeded
|
|
127
|
+
? Math.min(1, sel.reliability + 0.1)
|
|
128
|
+
: Math.max(0, sel.reliability - 0.2);
|
|
129
|
+
sel.last_used = new Date().toISOString();
|
|
130
|
+
entry.selectors.sort((a, b) => b.reliability - a.reliability);
|
|
131
|
+
entry.recommended = entry.selectors[0].value;
|
|
132
|
+
writeJSON(selectorsPath(), selectors);
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
// ── Healing History ──
|
|
136
|
+
|
|
137
|
+
getHealHistory() {
|
|
138
|
+
return readJSON(healHistoryPath()) || [];
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
addHealAttempt(runId, story, test, element, attempts, success) {
|
|
142
|
+
const history = this.getHealHistory();
|
|
143
|
+
history.push({
|
|
144
|
+
run_id: runId,
|
|
145
|
+
story,
|
|
146
|
+
test,
|
|
147
|
+
element,
|
|
148
|
+
original_selector: attempts[0] && attempts[0].original ? attempts[0].original : null,
|
|
149
|
+
failure_reason: attempts[0] && attempts[0].error || null,
|
|
150
|
+
attempts: attempts.map(a => ({
|
|
151
|
+
selector: a.selector,
|
|
152
|
+
strategy: a.strategy || 'retry',
|
|
153
|
+
success: a.success,
|
|
154
|
+
duration_ms: a.duration_ms || 0,
|
|
155
|
+
})),
|
|
156
|
+
success,
|
|
157
|
+
duration_ms: attempts.reduce((s, a) => s + (a.duration_ms || 0), 0),
|
|
158
|
+
timestamp: new Date().toISOString(),
|
|
159
|
+
});
|
|
160
|
+
writeJSON(healHistoryPath(), history);
|
|
161
|
+
return history;
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
getHealHistoryForStory(story) {
|
|
165
|
+
return this.getHealHistory().filter(h => h.story === story);
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
// ── Traceability ──
|
|
169
|
+
|
|
170
|
+
getTraceability() {
|
|
171
|
+
return readJSON(traceabilityPath()) || { stories: {} };
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
addRunId(story, runId, success, testName) {
|
|
175
|
+
const data = this.getTraceability();
|
|
176
|
+
if (!data.stories[story]) {
|
|
177
|
+
data.stories[story] = { plan: null, spec: null, runs: [], latest_heal: null, status: 'pending' };
|
|
178
|
+
}
|
|
179
|
+
data.stories[story].runs.push({ run_id: runId, success, test: testName || null, timestamp: new Date().toISOString() });
|
|
180
|
+
data.stories[story].status = success ? 'stable' : 'unstable';
|
|
181
|
+
writeJSON(traceabilityPath(), data);
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
addPlan(story, planFile) {
|
|
185
|
+
const data = this.getTraceability();
|
|
186
|
+
if (!data.stories[story]) data.stories[story] = { plan: null, spec: null, runs: [], latest_heal: null, status: 'pending' };
|
|
187
|
+
data.stories[story].plan = planFile;
|
|
188
|
+
writeJSON(traceabilityPath(), data);
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
addSpec(story, specFile) {
|
|
192
|
+
const data = this.getTraceability();
|
|
193
|
+
if (!data.stories[story]) data.stories[story] = { plan: null, spec: null, runs: [], latest_heal: null, status: 'pending' };
|
|
194
|
+
data.stories[story].spec = specFile;
|
|
195
|
+
writeJSON(traceabilityPath(), data);
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
setHealed(story, runId) {
|
|
199
|
+
const data = this.getTraceability();
|
|
200
|
+
if (!data.stories[story]) data.stories[story] = { plan: null, spec: null, runs: [], latest_heal: null, status: 'pending' };
|
|
201
|
+
data.stories[story].latest_heal = runId;
|
|
202
|
+
writeJSON(traceabilityPath(), data);
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
// ── Init ──
|
|
206
|
+
|
|
207
|
+
init() {
|
|
208
|
+
ensureDir(CONTEXT_DIR);
|
|
209
|
+
for (const file of ['pipeline.json', 'selectors.json', 'heal-history.json', 'traceability.json']) {
|
|
210
|
+
const fp = path.join(CONTEXT_DIR, file);
|
|
211
|
+
if (!fs.existsSync(fp)) {
|
|
212
|
+
const defaults = {
|
|
213
|
+
'pipeline.json': { project_path: '', phases: { plan: { completed: [], in_progress: null }, generate: { completed: [], in_progress: null }, execute: { completed: [], in_progress: null }, heal: { completed: [], in_progress: null }, report: { completed: [], in_progress: null } }, current_story: null, last_run_id: null, updated_at: '' },
|
|
214
|
+
'selectors.json': [],
|
|
215
|
+
'heal-history.json': [],
|
|
216
|
+
'traceability.json': { stories: {} },
|
|
217
|
+
};
|
|
218
|
+
writeJSON(fp, defaults[file]);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
contextDir: CONTEXT_DIR,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
module.exports = context;
|
package/scripts/executor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { DIRS, CONFIG, ensureDir, timestamp, log, writeMarkdown } = require('./utils');
|
|
2
|
+
const context = require('./context-manager');
|
|
2
3
|
const path = require('path');
|
|
3
4
|
const fs = require('fs');
|
|
4
5
|
const { execSync, spawn } = require('child_process');
|
|
@@ -10,15 +11,18 @@ function executeTests(testName, options = {}) {
|
|
|
10
11
|
timeout = testCfg.timeout || 120000,
|
|
11
12
|
retries = testCfg.retries || 0,
|
|
12
13
|
workers = testCfg.workers || 1,
|
|
13
|
-
screenshots = 'off',
|
|
14
|
+
//screenshots = 'off',
|
|
14
15
|
} = options;
|
|
15
16
|
|
|
16
17
|
const runId = `run-${timestamp()}`;
|
|
17
18
|
const runDir = path.join(DIRS.testResults, runId);
|
|
18
19
|
ensureDir(runDir);
|
|
19
20
|
|
|
21
|
+
const storyName = testName ? testName.replace(/\.spec\.ts$/, '').split(/[\/\\]/).pop() : null;
|
|
22
|
+
|
|
20
23
|
log('EXECUTOR', `Run ID: ${runId}`);
|
|
21
24
|
log('EXECUTOR', `Test: ${testName || 'all tests'}`);
|
|
25
|
+
if (storyName) log('EXECUTOR', `Story: ${storyName}`);
|
|
22
26
|
|
|
23
27
|
const args = ['playwright', 'test'];
|
|
24
28
|
|
|
@@ -37,12 +41,7 @@ function executeTests(testName, options = {}) {
|
|
|
37
41
|
if (retries > 0) args.push('--retries', retries.toString());
|
|
38
42
|
if (workers) args.push('--workers', workers.toString());
|
|
39
43
|
if (timeout) args.push('--timeout', timeout.toString());
|
|
40
|
-
|
|
41
|
-
args.push('--screenshot', 'off');
|
|
42
|
-
}
|
|
43
|
-
if (screenshots === 'only-on-failure') {
|
|
44
|
-
args.push('--screenshot', 'only-on-failure');
|
|
45
|
-
}
|
|
44
|
+
// Screenshots configured in playwright.config.ts — not a valid CLI arg
|
|
46
45
|
|
|
47
46
|
const outputPath = path.join(runDir, 'execution-output.json');
|
|
48
47
|
const resultPath = path.join(runDir, 'execution-result.json');
|
|
@@ -64,6 +63,7 @@ function executeTests(testName, options = {}) {
|
|
|
64
63
|
|
|
65
64
|
const result = {
|
|
66
65
|
runId,
|
|
66
|
+
story: storyName,
|
|
67
67
|
test: testName || 'all',
|
|
68
68
|
success: true,
|
|
69
69
|
exitCode: 0,
|
|
@@ -75,6 +75,11 @@ function executeTests(testName, options = {}) {
|
|
|
75
75
|
};
|
|
76
76
|
|
|
77
77
|
writeMarkdown(resultPath, JSON.stringify(result, null, 2));
|
|
78
|
+
context.setLastRun(runId);
|
|
79
|
+
if (storyName) {
|
|
80
|
+
context.addRunId(storyName, runId, true, testName);
|
|
81
|
+
context.phaseComplete('execute', storyName);
|
|
82
|
+
}
|
|
78
83
|
log('EXECUTOR', `Tests passed (${result.duration}ms)`);
|
|
79
84
|
|
|
80
85
|
return result;
|
|
@@ -89,6 +94,7 @@ function executeTests(testName, options = {}) {
|
|
|
89
94
|
|
|
90
95
|
const result = {
|
|
91
96
|
runId,
|
|
97
|
+
story: storyName,
|
|
92
98
|
test: testName || 'all',
|
|
93
99
|
success: false,
|
|
94
100
|
exitCode: err.status || 1,
|
|
@@ -100,6 +106,11 @@ function executeTests(testName, options = {}) {
|
|
|
100
106
|
};
|
|
101
107
|
|
|
102
108
|
writeMarkdown(resultPath, JSON.stringify(result, null, 2));
|
|
109
|
+
context.setLastRun(runId);
|
|
110
|
+
if (storyName) {
|
|
111
|
+
context.addRunId(storyName, runId, false, testName);
|
|
112
|
+
context.phaseComplete('execute', storyName);
|
|
113
|
+
}
|
|
103
114
|
log('EXECUTOR', `Tests failed (${result.duration}ms) - ${failedTests.length} failure(s)`);
|
|
104
115
|
|
|
105
116
|
return result;
|