synapse-hub 2.4.1 → 2.4.3
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/.agents/rules/graphify.md +14 -14
- package/.agents/rules/mandatory-playwright-live.md +29 -29
- package/.agents/skills/bats-testing-patterns/resources/implementation-playbook.md +614 -614
- package/.agents/skills/clean-pom-crafter/examples/standard_test_case.groovy +28 -28
- package/.agents/skills/clean-pom-crafter/references/pom_patterns.groovy +138 -138
- package/.agents/skills/e2e-testing-patterns/resources/implementation-playbook.md +522 -522
- package/.agents/skills/failure-autopsy-and-healing/references/healing_matrix.md +64 -64
- package/.agents/skills/failure-autopsy-and-healing/references/qa_investigation_protocol.md +56 -56
- package/.agents/skills/failure-autopsy-and-healing/scripts/triage_failures.sh +98 -98
- package/.agents/skills/katalon-cli-runner/references/groovy-patterns.md +209 -209
- package/.agents/skills/katalon-cli-runner/references/katalon-api.md +155 -155
- package/.agents/skills/katalon-cli-runner/references/runner_commands.md +67 -67
- package/.agents/skills/playwright-live-interaction/references/advanced_superpowers.md +160 -160
- package/.agents/skills/playwright-live-interaction/references/compact_accessibility_snapshots.md +75 -75
- package/.agents/skills/playwright-live-interaction/references/recipes.md +388 -388
- package/.agents/skills/playwright-live-interaction/references/swarm_orchestration.md +134 -134
- package/.agents/skills/wcag-audit-patterns/resources/implementation-playbook.md +541 -541
- package/.agents/templates/completion_report.md +57 -57
- package/.agents/workflows/graphify.md +10 -10
- package/.cursor/rules/language.mdc +7 -7
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/benchmarks/qa_eval/dataset/api_contract_drift/qa-challenge-06_api_contract_drift.json +21 -21
- package/benchmarks/qa_eval/dataset/api_contract_drift/qa-challenge-07_api_contract_drift.json +21 -21
- package/benchmarks/qa_eval/dataset/api_contract_drift/qa-challenge-08_api_contract_drift.json +21 -21
- package/benchmarks/qa_eval/dataset/api_contract_drift/qa-challenge-09_api_contract_drift.json +21 -21
- package/benchmarks/qa_eval/dataset/api_contract_drift/qa-challenge-10_api_contract_drift.json +21 -21
- package/benchmarks/qa_eval/dataset/fake_pass_tautology/qa-challenge-21_fake_pass_tautology.json +20 -20
- package/benchmarks/qa_eval/dataset/fake_pass_tautology/qa-challenge-22_fake_pass_tautology.json +20 -20
- package/benchmarks/qa_eval/dataset/fake_pass_tautology/qa-challenge-23_fake_pass_tautology.json +20 -20
- package/benchmarks/qa_eval/dataset/fake_pass_tautology/qa-challenge-24_fake_pass_tautology.json +20 -20
- package/benchmarks/qa_eval/dataset/fake_pass_tautology/qa-challenge-25_fake_pass_tautology.json +20 -20
- package/benchmarks/qa_eval/dataset/locator_drift/qa-challenge-01_locator_drift.json +22 -22
- package/benchmarks/qa_eval/dataset/locator_drift/qa-challenge-02_locator_drift.json +22 -22
- package/benchmarks/qa_eval/dataset/locator_drift/qa-challenge-03_locator_drift.json +22 -22
- package/benchmarks/qa_eval/dataset/locator_drift/qa-challenge-04_locator_drift.json +22 -22
- package/benchmarks/qa_eval/dataset/locator_drift/qa-challenge-05_locator_drift.json +22 -22
- package/benchmarks/qa_eval/dataset/stale_and_mutation/qa-challenge-16_stale_and_mutation.json +21 -21
- package/benchmarks/qa_eval/dataset/stale_and_mutation/qa-challenge-17_stale_and_mutation.json +21 -21
- package/benchmarks/qa_eval/dataset/stale_and_mutation/qa-challenge-18_stale_and_mutation.json +21 -21
- package/benchmarks/qa_eval/dataset/stale_and_mutation/qa-challenge-19_stale_and_mutation.json +21 -21
- package/benchmarks/qa_eval/dataset/stale_and_mutation/qa-challenge-20_stale_and_mutation.json +22 -22
- package/benchmarks/qa_eval/dataset/timing_and_race/qa-challenge-11_timing_and_race.json +21 -21
- package/benchmarks/qa_eval/dataset/timing_and_race/qa-challenge-12_timing_and_race.json +21 -21
- package/benchmarks/qa_eval/dataset/timing_and_race/qa-challenge-13_timing_and_race.json +21 -21
- package/benchmarks/qa_eval/dataset/timing_and_race/qa-challenge-14_timing_and_race.json +21 -21
- package/benchmarks/qa_eval/dataset/timing_and_race/qa-challenge-15_timing_and_race.json +21 -21
- package/bin/agent-qa.js +0 -0
- package/docs/ARCHITECTURE.md +12 -1
- package/package.json +1 -1
- package/scripts/install.ps1 +112 -112
- package/scripts/lint_skills.ps1 +57 -57
- package/skills_manifest.json +1 -1
- package/synapse.config.json +1 -1
- package/tools/__init__.py +1 -1
- package/tools/agent_canary.py +501 -501
- package/tools/api_fuzzer.py +11 -2
- package/tools/browser_helpers.js +2 -2
- package/tools/bump_version.py +1 -0
- package/tools/check_env.py +71 -13
- package/tools/cli.py +2 -2
- package/tools/dashboard_generator.py +2 -2
- package/tools/e2e_flow_prober.sh +0 -0
- package/tools/generate_allure_report.sh +0 -0
- package/tools/generate_test_reports.sh +0 -0
- package/tools/mcp_excel_server.py +3 -1
- package/tools/mcp_excel_server.sh +0 -0
- package/tools/mcp_server.py +1 -1
- package/tools/pinpoint_failure.py +88 -88
- package/tools/setup_project.py +8 -0
- package/tools/update_sentinel.py +10 -9
- package/tools/verify_all.sh +0 -0
- package/tools/visual_som_helper.py +14 -3
- package/tools/welcome_page.py +1 -1
- package/benchmarks/qa_eval/reports/AB_METHODOLOGY_REPORT.md +0 -83
- package/benchmarks/qa_eval/reports/BENCHMARK_REPORT.md +0 -69
- package/benchmarks/qa_eval/reports/eval_results.json +0 -105
|
@@ -1,388 +1,388 @@
|
|
|
1
|
-
# Enterprise Playwright Code Recipes (20 Recipes)
|
|
2
|
-
|
|
3
|
-
All code recipes below execute directly via `browser_run_code_unsafe` with parameter `code: "async (page) => { ... }"`.
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## Recipe 1: Authentication & Session Verification
|
|
8
|
-
```javascript
|
|
9
|
-
async (page) => {
|
|
10
|
-
if (page.url().includes('/auth') || page.url().includes('/login')) {
|
|
11
|
-
const user = process.env.APP_USERNAME || 'your_username';
|
|
12
|
-
const pass = process.env.APP_PASSWORD || 'your_password';
|
|
13
|
-
await page.locator('input[formcontrolname="username"]').fill(user);
|
|
14
|
-
await page.locator('input[formcontrolname="password"]').fill(pass);
|
|
15
|
-
await page.locator('button[type="submit"]').click();
|
|
16
|
-
await page.waitForNavigation({ waitUntil: 'networkidle' });
|
|
17
|
-
}
|
|
18
|
-
return { currentUrl: page.url(), title: await page.title() };
|
|
19
|
-
}
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
## Recipe 2: Search, Filter & PrimeNG Table Verification (`p-table`)
|
|
23
|
-
```javascript
|
|
24
|
-
async (page) => {
|
|
25
|
-
const searchInput = page.locator('input.p-inputtext[placeholder*="Tìm kiếm"], input[placeholder*="tên"], input[placeholder*="mã"]').first();
|
|
26
|
-
await searchInput.fill('Hà Nội');
|
|
27
|
-
await searchInput.press('Enter');
|
|
28
|
-
await page.waitForTimeout(1000);
|
|
29
|
-
|
|
30
|
-
const rowCount = await page.locator('p-table table tbody tr').count();
|
|
31
|
-
const firstRowText = rowCount > 0 ? await page.locator('p-table table tbody tr').first().innerText() : '';
|
|
32
|
-
return { rowCount, firstRowText };
|
|
33
|
-
}
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
## Recipe 3: PrimeNG Dropdown & MultiSelect (`p-dropdown`, `p-multiselect`)
|
|
37
|
-
```javascript
|
|
38
|
-
async (page) => {
|
|
39
|
-
const dropdown = page.locator('p-dropdown[formcontrolname="status"], p-dropdown[name="status"]').first();
|
|
40
|
-
await dropdown.click();
|
|
41
|
-
await page.waitForTimeout(400);
|
|
42
|
-
|
|
43
|
-
const option = page.locator('.p-dropdown-panel .p-dropdown-item').filter({ hasText: 'Đang hoạt động' }).first();
|
|
44
|
-
await option.click();
|
|
45
|
-
await page.waitForTimeout(300);
|
|
46
|
-
|
|
47
|
-
return { selectedLabel: await dropdown.locator('.p-dropdown-label').innerText() };
|
|
48
|
-
}
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
## Recipe 4: Modal Dialogs & Popup Forms (`p-dialog`, `.card.popup-data`)
|
|
52
|
-
```javascript
|
|
53
|
-
async (page) => {
|
|
54
|
-
await page.evaluate(() => {
|
|
55
|
-
function setAngularInput(selector, val) {
|
|
56
|
-
const el = document.querySelector(selector);
|
|
57
|
-
if (el) {
|
|
58
|
-
el.value = val;
|
|
59
|
-
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
60
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
const testCode = 'CCN_' + Math.floor(Date.now() / 1000);
|
|
64
|
-
setAngularInput('input[formcontrolname="code"]', testCode);
|
|
65
|
-
setAngularInput('input[formcontrolname="name"]', 'Cụm Công Nghiệp ' + testCode);
|
|
66
|
-
setAngularInput('textarea[formcontrolname="address"]', 'Địa chỉ kiểm thử tự động');
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
const saveBtn = page.locator('button:has-text("Lưu"), button:has-text("Xác nhận"), button[title="Xác nhận"]').first();
|
|
70
|
-
const isEnabled = !(await saveBtn.isDisabled());
|
|
71
|
-
if (isEnabled) {
|
|
72
|
-
await saveBtn.click();
|
|
73
|
-
await page.waitForTimeout(1500);
|
|
74
|
-
}
|
|
75
|
-
return { isSaveButtonEnabled: isEnabled };
|
|
76
|
-
}
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
## Recipe 5: Confirm Dialog & Deletion (`p-confirmdialog`)
|
|
80
|
-
```javascript
|
|
81
|
-
async (page) => {
|
|
82
|
-
await page.locator('p-table tbody tr button.p-button-danger, p-table tbody tr button[icon*="trash"]').first().click();
|
|
83
|
-
await page.waitForTimeout(500);
|
|
84
|
-
|
|
85
|
-
const confirmBtn = page.locator('.p-confirm-dialog-accept, button:has-text("Đồng ý"), button:has-text("Xóa")').first();
|
|
86
|
-
await confirmBtn.click();
|
|
87
|
-
await page.waitForTimeout(1000);
|
|
88
|
-
|
|
89
|
-
const toastText = await page.locator('.p-toast-message-content').innerText().catch(() => 'No toast');
|
|
90
|
-
return { toastText };
|
|
91
|
-
}
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
## Recipe 6: File Upload & Excel Import (`input[type="file"]`)
|
|
95
|
-
```javascript
|
|
96
|
-
async (page, customPath = 'Data Files/sample_import.xlsx') => {
|
|
97
|
-
const fs = require('fs');
|
|
98
|
-
const filePath = fs.existsSync(customPath) ? customPath : 'Data Files/sample_import.xlsx';
|
|
99
|
-
await page.locator('input[type="file"]').setInputFiles(filePath);
|
|
100
|
-
await page.waitForTimeout(1000);
|
|
101
|
-
|
|
102
|
-
await page.locator('button:has-text("Tải lên"), button:has-text("Xác nhận")').first().click();
|
|
103
|
-
await page.waitForTimeout(2000);
|
|
104
|
-
return { status: "Uploaded", fileUsed: filePath };
|
|
105
|
-
}
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
## Recipe 7: OpenLayers GIS Canvas Interaction (`div.ol-viewport`)
|
|
109
|
-
```javascript
|
|
110
|
-
async (page) => {
|
|
111
|
-
const canvas = page.locator('div.ol-viewport canvas').first();
|
|
112
|
-
const box = await canvas.boundingBox();
|
|
113
|
-
const cx = box.x + box.width / 2;
|
|
114
|
-
const cy = box.y + box.height / 2;
|
|
115
|
-
|
|
116
|
-
// Click polygon at non-overlapping offset (-180, -80)
|
|
117
|
-
await page.mouse.click(cx - 180, cy - 80);
|
|
118
|
-
await page.waitForTimeout(400);
|
|
119
|
-
await page.mouse.click(cx - 150, cy - 80);
|
|
120
|
-
await page.waitForTimeout(400);
|
|
121
|
-
await page.mouse.click(cx - 150, cy - 50);
|
|
122
|
-
await page.waitForTimeout(400);
|
|
123
|
-
await page.mouse.dblclick(cx - 180, cy - 80);
|
|
124
|
-
await page.waitForTimeout(1000);
|
|
125
|
-
|
|
126
|
-
const result = await page.evaluate(() => {
|
|
127
|
-
const error = document.querySelector('.text-danger, .invalid-feedback, editor-widget');
|
|
128
|
-
return {
|
|
129
|
-
hasError: !!error,
|
|
130
|
-
errorText: error ? error.innerText : ''
|
|
131
|
-
};
|
|
132
|
-
});
|
|
133
|
-
return result;
|
|
134
|
-
}
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
## Recipe 8: Advanced OpenLayers Map Inspection & VN2000 Coordinates
|
|
138
|
-
```javascript
|
|
139
|
-
async (page) => {
|
|
140
|
-
return await page.evaluate(() => {
|
|
141
|
-
const viewport = document.querySelector('div.ol-viewport');
|
|
142
|
-
if (!viewport) return { error: "div.ol-viewport not found" };
|
|
143
|
-
|
|
144
|
-
const map = viewport['__ol_map__'] || (window.map instanceof Object ? window.map : null);
|
|
145
|
-
let mapInfo = {};
|
|
146
|
-
if (map && map.getView) {
|
|
147
|
-
const view = map.getView();
|
|
148
|
-
mapInfo = {
|
|
149
|
-
zoom: view.getZoom(),
|
|
150
|
-
center: view.getCenter(),
|
|
151
|
-
projection: view.getProjection() ? view.getProjection().getCode() : 'Unknown'
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
const toolbarButtons = Array.from(document.querySelectorAll('.ol-control button, .map-toolbar button, .gis-tools button'))
|
|
155
|
-
.map(b => ({ title: b.getAttribute('title') || b.innerText.trim(), className: b.className }));
|
|
156
|
-
return { mapInfo, toolbarButtons };
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
## Recipe 9: Two-Phase Search Workflow
|
|
162
|
-
```javascript
|
|
163
|
-
async (page) => {
|
|
164
|
-
const searchToggleBtn = page.locator('button.p-button-icon-only[icon*="search"], button[title*="Tìm kiếm"], button:has-text("Tìm kiếm")').first();
|
|
165
|
-
const searchInput = page.locator('input.p-inputtext[placeholder*="Tìm kiếm"], input[placeholder*="tên"], input[placeholder*="mã"]').first();
|
|
166
|
-
|
|
167
|
-
if (!(await searchInput.isVisible())) {
|
|
168
|
-
await searchToggleBtn.click();
|
|
169
|
-
await page.waitForTimeout(300);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const keyword = 'CCN_TEST_2026';
|
|
173
|
-
await searchInput.fill(keyword);
|
|
174
|
-
await searchInput.press('Enter');
|
|
175
|
-
await page.waitForTimeout(1000);
|
|
176
|
-
|
|
177
|
-
const rowCount = await page.locator('p-table table tbody tr').count();
|
|
178
|
-
const matched = rowCount > 0 && (await page.locator('p-table table tbody tr').first().innerText()).includes(keyword);
|
|
179
|
-
return { rowCount, matched };
|
|
180
|
-
}
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
## Recipe 10: Intercepting PrimeNG Toasts (`p-toast`)
|
|
184
|
-
```javascript
|
|
185
|
-
async (page) => {
|
|
186
|
-
const toastSelector = '.p-toast-message, .p-toast-message-content';
|
|
187
|
-
try {
|
|
188
|
-
const toast = await page.waitForSelector(toastSelector, { timeout: 4000 });
|
|
189
|
-
const text = await toast.innerText();
|
|
190
|
-
const isSuccess = (await toast.getAttribute('class') || '').includes('p-toast-message-success');
|
|
191
|
-
return { success: true, text, isSuccess };
|
|
192
|
-
} catch (e) {
|
|
193
|
-
return { success: false, message: 'Toast did not appear or expired too quickly' };
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
## Recipe 11: Record Deletion & Confirmation Dialog (`p-confirmdialog`)
|
|
199
|
-
```javascript
|
|
200
|
-
async (page, targetKeyword) => {
|
|
201
|
-
const searchInput = page.locator('input.p-inputtext[placeholder*="Tìm kiếm"], input[placeholder*="tên"], input[placeholder*="mã"]').first();
|
|
202
|
-
await searchInput.fill(targetKeyword);
|
|
203
|
-
await searchInput.press('Enter');
|
|
204
|
-
await page.waitForTimeout(1000);
|
|
205
|
-
|
|
206
|
-
const deleteBtn = page.locator('p-table tbody tr:first-child button.p-button-danger, p-table tbody tr:first-child button[icon*="trash"]').first();
|
|
207
|
-
await deleteBtn.click();
|
|
208
|
-
await page.waitForTimeout(500);
|
|
209
|
-
|
|
210
|
-
const confirmBtn = page.locator('.p-confirm-dialog-accept, .p-dialog-footer button:has-text("Xóa"), .p-dialog-footer button:has-text("Đồng ý"), button.p-button-danger:has-text("Xóa")').first();
|
|
211
|
-
await confirmBtn.waitFor({ state: 'visible', timeout: 3000 });
|
|
212
|
-
await confirmBtn.click();
|
|
213
|
-
await page.waitForTimeout(1500);
|
|
214
|
-
|
|
215
|
-
await searchInput.fill(targetKeyword);
|
|
216
|
-
await searchInput.press('Enter');
|
|
217
|
-
await page.waitForTimeout(1000);
|
|
218
|
-
const remainingRows = await page.locator('p-table table tbody tr').count();
|
|
219
|
-
const firstRowText = remainingRows > 0 ? await page.locator('p-table table tbody tr').first().innerText() : '';
|
|
220
|
-
const isDeleted = remainingRows === 0 || !firstRowText.includes(targetKeyword) || firstRowText.includes('Không có dữ liệu');
|
|
221
|
-
|
|
222
|
-
return { isDeleted, remainingRows, firstRowText };
|
|
223
|
-
}
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
## Recipe 12: PrimeNG Calendar Date Input (`p-calendar`)
|
|
227
|
-
```javascript
|
|
228
|
-
async (page) => {
|
|
229
|
-
const dateValue = '25/10/2026';
|
|
230
|
-
await page.evaluate(({ selector, val }) => {
|
|
231
|
-
const calInput = document.querySelector(selector);
|
|
232
|
-
if (calInput) {
|
|
233
|
-
calInput.value = val;
|
|
234
|
-
calInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
235
|
-
calInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
236
|
-
calInput.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
237
|
-
}
|
|
238
|
-
}, { selector: 'p-calendar input, input[formcontrolname*="date"], input[formcontrolname*="Date"]', val: dateValue });
|
|
239
|
-
|
|
240
|
-
await page.waitForTimeout(300);
|
|
241
|
-
return { status: 'Date filled', value: dateValue };
|
|
242
|
-
}
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
## Recipe 13: PrimeNG MultiSelect Dropdown (`p-multiselect`)
|
|
246
|
-
```javascript
|
|
247
|
-
async (page, optionsToSelect = ['Công nghiệp chế biến', 'Điện lực']) => {
|
|
248
|
-
const multiSelect = page.locator('p-multiselect').first();
|
|
249
|
-
await multiSelect.click();
|
|
250
|
-
await page.waitForSelector('.p-multiselect-panel', { state: 'visible', timeout: 3000 });
|
|
251
|
-
await page.waitForTimeout(300);
|
|
252
|
-
|
|
253
|
-
for (const optText of optionsToSelect) {
|
|
254
|
-
const item = page.locator('.p-multiselect-item').filter({ hasText: optText }).first();
|
|
255
|
-
if (await item.isVisible()) {
|
|
256
|
-
await item.click();
|
|
257
|
-
await page.waitForTimeout(200);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const closeBtn = page.locator('.p-multiselect-close').first();
|
|
262
|
-
if (await closeBtn.isVisible()) {
|
|
263
|
-
await closeBtn.click();
|
|
264
|
-
} else {
|
|
265
|
-
await page.keyboard.press('Escape');
|
|
266
|
-
}
|
|
267
|
-
await page.waitForTimeout(300);
|
|
268
|
-
return { selected: optionsToSelect };
|
|
269
|
-
}
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
## Recipe 14: Handling Stale Elements & Angular DOM Re-render Retries
|
|
273
|
-
```javascript
|
|
274
|
-
async (page, actionFn) => {
|
|
275
|
-
const maxRetries = 3;
|
|
276
|
-
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
277
|
-
try {
|
|
278
|
-
return await actionFn(page);
|
|
279
|
-
} catch (err) {
|
|
280
|
-
if (err.message.includes('Element is not attached to the DOM') || err.message.includes('stale') || attempt === maxRetries) {
|
|
281
|
-
if (attempt === maxRetries) throw err;
|
|
282
|
-
await page.waitForTimeout(500 * attempt);
|
|
283
|
-
} else {
|
|
284
|
-
throw err;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
## Recipe 15: Instant Angular Form Audit (Diagnose Disabled Save Button)
|
|
293
|
-
```javascript
|
|
294
|
-
async (page) => {
|
|
295
|
-
return await page.evaluate(() => {
|
|
296
|
-
const invalidInputs = Array.from(document.querySelectorAll('form .ng-invalid, input.ng-invalid, p-dropdown.ng-invalid, textarea.ng-invalid'));
|
|
297
|
-
return invalidInputs.map(el => ({
|
|
298
|
-
tag: el.tagName.toLowerCase(),
|
|
299
|
-
control: el.getAttribute('formcontrolname') || el.getAttribute('name') || el.id || 'unnamed',
|
|
300
|
-
placeholder: el.getAttribute('placeholder') || '',
|
|
301
|
-
currentValue: el.value || el.innerText.trim()
|
|
302
|
-
}));
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
## Recipe 16: Smart Action Batching (Atomic Execution with Pause-on-Failure)
|
|
308
|
-
```javascript
|
|
309
|
-
async (page) => {
|
|
310
|
-
return await page.evaluate(() => {
|
|
311
|
-
const steps = [
|
|
312
|
-
{ name: 'fill_code', fn: () => { const el = document.querySelector("input[formcontrolname='code']"); el.value = 'CCN_TEST'; el.dispatchEvent(new Event('input', {bubbles: true})); } },
|
|
313
|
-
{ name: 'fill_name', fn: () => { const el = document.querySelector("input[formcontrolname='name']"); el.value = 'Cụm CN Test'; el.dispatchEvent(new Event('input', {bubbles: true})); } },
|
|
314
|
-
{ name: 'submit', fn: () => { document.querySelector("button[type='submit']").click(); } }
|
|
315
|
-
];
|
|
316
|
-
|
|
317
|
-
for (let i = 0; i < steps.length; i++) {
|
|
318
|
-
try {
|
|
319
|
-
steps[i].fn();
|
|
320
|
-
} catch (err) {
|
|
321
|
-
return { status: 'FAILED', failedStep: i + 1, name: steps[i].name, error: err.message, url: location.href };
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
return { status: 'SUCCESS', totalSteps: steps.length };
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
## Recipe 17: Runtime Helper Injection (`window.__qa`)
|
|
330
|
-
```javascript
|
|
331
|
-
async (page) => {
|
|
332
|
-
// Injects the centralized browser helper tools/browser_helpers.js
|
|
333
|
-
const fs = require('fs');
|
|
334
|
-
const helperCode = fs.readFileSync('tools/browser_helpers.js', 'utf8');
|
|
335
|
-
await page.evaluate(helperCode);
|
|
336
|
-
return await page.evaluate(() => ({
|
|
337
|
-
version: window.__qa.__version,
|
|
338
|
-
auditResults: window.__qa.audit()
|
|
339
|
-
}));
|
|
340
|
-
}
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
## Recipe 18: Self-Healing Locator Fallback (`window.__qa.heal`)
|
|
344
|
-
```javascript
|
|
345
|
-
async (page, brokenXPath, expectedText) => {
|
|
346
|
-
return await page.evaluate(({ xpath, text }) => {
|
|
347
|
-
if (window.__qa) {
|
|
348
|
-
return window.__qa.heal(xpath, text);
|
|
349
|
-
}
|
|
350
|
-
// Fallback search
|
|
351
|
-
const buttons = Array.from(document.querySelectorAll('button')).filter(b => b.innerText.includes(text));
|
|
352
|
-
return {
|
|
353
|
-
candidates: buttons.map(b => `//button[contains(normalize-space(.), '${b.innerText.trim()}')]`)
|
|
354
|
-
};
|
|
355
|
-
}, { xpath: brokenXPath, text: expectedText });
|
|
356
|
-
}
|
|
357
|
-
```
|
|
358
|
-
|
|
359
|
-
## Recipe 19: Overlay-Proof Safe Click (`window.__qa.clickSafe`)
|
|
360
|
-
```javascript
|
|
361
|
-
async (page, selector) => {
|
|
362
|
-
return await page.evaluate((sel) => {
|
|
363
|
-
if (window.__qa) {
|
|
364
|
-
return window.__qa.clickSafe(sel);
|
|
365
|
-
}
|
|
366
|
-
const el = document.querySelector(sel);
|
|
367
|
-
if (!el) return false;
|
|
368
|
-
document.querySelectorAll('.p-component-overlay').forEach(o => o.style.pointerEvents = 'none');
|
|
369
|
-
el.click();
|
|
370
|
-
return true;
|
|
371
|
-
}, selector);
|
|
372
|
-
}
|
|
373
|
-
```
|
|
374
|
-
|
|
375
|
-
## Recipe 20: OpenLayers GIS Canvas Coordinate Inspector (`window.__qa.gisHUD`)
|
|
376
|
-
```javascript
|
|
377
|
-
async (page) => {
|
|
378
|
-
return await page.evaluate(() => {
|
|
379
|
-
if (window.__qa) {
|
|
380
|
-
return window.__qa.gisHUD();
|
|
381
|
-
}
|
|
382
|
-
const vp = document.querySelector('.ol-viewport');
|
|
383
|
-
if (!vp) return { status: 'NO_MAP' };
|
|
384
|
-
const r = vp.getBoundingClientRect();
|
|
385
|
-
return { width: r.width, height: r.height, safeCenter: { x: r.width / 2, y: r.height / 2 } };
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
```
|
|
1
|
+
# Enterprise Playwright Code Recipes (20 Recipes)
|
|
2
|
+
|
|
3
|
+
All code recipes below execute directly via `browser_run_code_unsafe` with parameter `code: "async (page) => { ... }"`.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Recipe 1: Authentication & Session Verification
|
|
8
|
+
```javascript
|
|
9
|
+
async (page) => {
|
|
10
|
+
if (page.url().includes('/auth') || page.url().includes('/login')) {
|
|
11
|
+
const user = process.env.APP_USERNAME || 'your_username';
|
|
12
|
+
const pass = process.env.APP_PASSWORD || 'your_password';
|
|
13
|
+
await page.locator('input[formcontrolname="username"]').fill(user);
|
|
14
|
+
await page.locator('input[formcontrolname="password"]').fill(pass);
|
|
15
|
+
await page.locator('button[type="submit"]').click();
|
|
16
|
+
await page.waitForNavigation({ waitUntil: 'networkidle' });
|
|
17
|
+
}
|
|
18
|
+
return { currentUrl: page.url(), title: await page.title() };
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Recipe 2: Search, Filter & PrimeNG Table Verification (`p-table`)
|
|
23
|
+
```javascript
|
|
24
|
+
async (page) => {
|
|
25
|
+
const searchInput = page.locator('input.p-inputtext[placeholder*="Tìm kiếm"], input[placeholder*="tên"], input[placeholder*="mã"]').first();
|
|
26
|
+
await searchInput.fill('Hà Nội');
|
|
27
|
+
await searchInput.press('Enter');
|
|
28
|
+
await page.waitForTimeout(1000);
|
|
29
|
+
|
|
30
|
+
const rowCount = await page.locator('p-table table tbody tr').count();
|
|
31
|
+
const firstRowText = rowCount > 0 ? await page.locator('p-table table tbody tr').first().innerText() : '';
|
|
32
|
+
return { rowCount, firstRowText };
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Recipe 3: PrimeNG Dropdown & MultiSelect (`p-dropdown`, `p-multiselect`)
|
|
37
|
+
```javascript
|
|
38
|
+
async (page) => {
|
|
39
|
+
const dropdown = page.locator('p-dropdown[formcontrolname="status"], p-dropdown[name="status"]').first();
|
|
40
|
+
await dropdown.click();
|
|
41
|
+
await page.waitForTimeout(400);
|
|
42
|
+
|
|
43
|
+
const option = page.locator('.p-dropdown-panel .p-dropdown-item').filter({ hasText: 'Đang hoạt động' }).first();
|
|
44
|
+
await option.click();
|
|
45
|
+
await page.waitForTimeout(300);
|
|
46
|
+
|
|
47
|
+
return { selectedLabel: await dropdown.locator('.p-dropdown-label').innerText() };
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Recipe 4: Modal Dialogs & Popup Forms (`p-dialog`, `.card.popup-data`)
|
|
52
|
+
```javascript
|
|
53
|
+
async (page) => {
|
|
54
|
+
await page.evaluate(() => {
|
|
55
|
+
function setAngularInput(selector, val) {
|
|
56
|
+
const el = document.querySelector(selector);
|
|
57
|
+
if (el) {
|
|
58
|
+
el.value = val;
|
|
59
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
60
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const testCode = 'CCN_' + Math.floor(Date.now() / 1000);
|
|
64
|
+
setAngularInput('input[formcontrolname="code"]', testCode);
|
|
65
|
+
setAngularInput('input[formcontrolname="name"]', 'Cụm Công Nghiệp ' + testCode);
|
|
66
|
+
setAngularInput('textarea[formcontrolname="address"]', 'Địa chỉ kiểm thử tự động');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const saveBtn = page.locator('button:has-text("Lưu"), button:has-text("Xác nhận"), button[title="Xác nhận"]').first();
|
|
70
|
+
const isEnabled = !(await saveBtn.isDisabled());
|
|
71
|
+
if (isEnabled) {
|
|
72
|
+
await saveBtn.click();
|
|
73
|
+
await page.waitForTimeout(1500);
|
|
74
|
+
}
|
|
75
|
+
return { isSaveButtonEnabled: isEnabled };
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Recipe 5: Confirm Dialog & Deletion (`p-confirmdialog`)
|
|
80
|
+
```javascript
|
|
81
|
+
async (page) => {
|
|
82
|
+
await page.locator('p-table tbody tr button.p-button-danger, p-table tbody tr button[icon*="trash"]').first().click();
|
|
83
|
+
await page.waitForTimeout(500);
|
|
84
|
+
|
|
85
|
+
const confirmBtn = page.locator('.p-confirm-dialog-accept, button:has-text("Đồng ý"), button:has-text("Xóa")').first();
|
|
86
|
+
await confirmBtn.click();
|
|
87
|
+
await page.waitForTimeout(1000);
|
|
88
|
+
|
|
89
|
+
const toastText = await page.locator('.p-toast-message-content').innerText().catch(() => 'No toast');
|
|
90
|
+
return { toastText };
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Recipe 6: File Upload & Excel Import (`input[type="file"]`)
|
|
95
|
+
```javascript
|
|
96
|
+
async (page, customPath = 'Data Files/sample_import.xlsx') => {
|
|
97
|
+
const fs = require('fs');
|
|
98
|
+
const filePath = fs.existsSync(customPath) ? customPath : 'Data Files/sample_import.xlsx';
|
|
99
|
+
await page.locator('input[type="file"]').setInputFiles(filePath);
|
|
100
|
+
await page.waitForTimeout(1000);
|
|
101
|
+
|
|
102
|
+
await page.locator('button:has-text("Tải lên"), button:has-text("Xác nhận")').first().click();
|
|
103
|
+
await page.waitForTimeout(2000);
|
|
104
|
+
return { status: "Uploaded", fileUsed: filePath };
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Recipe 7: OpenLayers GIS Canvas Interaction (`div.ol-viewport`)
|
|
109
|
+
```javascript
|
|
110
|
+
async (page) => {
|
|
111
|
+
const canvas = page.locator('div.ol-viewport canvas').first();
|
|
112
|
+
const box = await canvas.boundingBox();
|
|
113
|
+
const cx = box.x + box.width / 2;
|
|
114
|
+
const cy = box.y + box.height / 2;
|
|
115
|
+
|
|
116
|
+
// Click polygon at non-overlapping offset (-180, -80)
|
|
117
|
+
await page.mouse.click(cx - 180, cy - 80);
|
|
118
|
+
await page.waitForTimeout(400);
|
|
119
|
+
await page.mouse.click(cx - 150, cy - 80);
|
|
120
|
+
await page.waitForTimeout(400);
|
|
121
|
+
await page.mouse.click(cx - 150, cy - 50);
|
|
122
|
+
await page.waitForTimeout(400);
|
|
123
|
+
await page.mouse.dblclick(cx - 180, cy - 80);
|
|
124
|
+
await page.waitForTimeout(1000);
|
|
125
|
+
|
|
126
|
+
const result = await page.evaluate(() => {
|
|
127
|
+
const error = document.querySelector('.text-danger, .invalid-feedback, editor-widget');
|
|
128
|
+
return {
|
|
129
|
+
hasError: !!error,
|
|
130
|
+
errorText: error ? error.innerText : ''
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Recipe 8: Advanced OpenLayers Map Inspection & VN2000 Coordinates
|
|
138
|
+
```javascript
|
|
139
|
+
async (page) => {
|
|
140
|
+
return await page.evaluate(() => {
|
|
141
|
+
const viewport = document.querySelector('div.ol-viewport');
|
|
142
|
+
if (!viewport) return { error: "div.ol-viewport not found" };
|
|
143
|
+
|
|
144
|
+
const map = viewport['__ol_map__'] || (window.map instanceof Object ? window.map : null);
|
|
145
|
+
let mapInfo = {};
|
|
146
|
+
if (map && map.getView) {
|
|
147
|
+
const view = map.getView();
|
|
148
|
+
mapInfo = {
|
|
149
|
+
zoom: view.getZoom(),
|
|
150
|
+
center: view.getCenter(),
|
|
151
|
+
projection: view.getProjection() ? view.getProjection().getCode() : 'Unknown'
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const toolbarButtons = Array.from(document.querySelectorAll('.ol-control button, .map-toolbar button, .gis-tools button'))
|
|
155
|
+
.map(b => ({ title: b.getAttribute('title') || b.innerText.trim(), className: b.className }));
|
|
156
|
+
return { mapInfo, toolbarButtons };
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Recipe 9: Two-Phase Search Workflow
|
|
162
|
+
```javascript
|
|
163
|
+
async (page) => {
|
|
164
|
+
const searchToggleBtn = page.locator('button.p-button-icon-only[icon*="search"], button[title*="Tìm kiếm"], button:has-text("Tìm kiếm")').first();
|
|
165
|
+
const searchInput = page.locator('input.p-inputtext[placeholder*="Tìm kiếm"], input[placeholder*="tên"], input[placeholder*="mã"]').first();
|
|
166
|
+
|
|
167
|
+
if (!(await searchInput.isVisible())) {
|
|
168
|
+
await searchToggleBtn.click();
|
|
169
|
+
await page.waitForTimeout(300);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const keyword = 'CCN_TEST_2026';
|
|
173
|
+
await searchInput.fill(keyword);
|
|
174
|
+
await searchInput.press('Enter');
|
|
175
|
+
await page.waitForTimeout(1000);
|
|
176
|
+
|
|
177
|
+
const rowCount = await page.locator('p-table table tbody tr').count();
|
|
178
|
+
const matched = rowCount > 0 && (await page.locator('p-table table tbody tr').first().innerText()).includes(keyword);
|
|
179
|
+
return { rowCount, matched };
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Recipe 10: Intercepting PrimeNG Toasts (`p-toast`)
|
|
184
|
+
```javascript
|
|
185
|
+
async (page) => {
|
|
186
|
+
const toastSelector = '.p-toast-message, .p-toast-message-content';
|
|
187
|
+
try {
|
|
188
|
+
const toast = await page.waitForSelector(toastSelector, { timeout: 4000 });
|
|
189
|
+
const text = await toast.innerText();
|
|
190
|
+
const isSuccess = (await toast.getAttribute('class') || '').includes('p-toast-message-success');
|
|
191
|
+
return { success: true, text, isSuccess };
|
|
192
|
+
} catch (e) {
|
|
193
|
+
return { success: false, message: 'Toast did not appear or expired too quickly' };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Recipe 11: Record Deletion & Confirmation Dialog (`p-confirmdialog`)
|
|
199
|
+
```javascript
|
|
200
|
+
async (page, targetKeyword) => {
|
|
201
|
+
const searchInput = page.locator('input.p-inputtext[placeholder*="Tìm kiếm"], input[placeholder*="tên"], input[placeholder*="mã"]').first();
|
|
202
|
+
await searchInput.fill(targetKeyword);
|
|
203
|
+
await searchInput.press('Enter');
|
|
204
|
+
await page.waitForTimeout(1000);
|
|
205
|
+
|
|
206
|
+
const deleteBtn = page.locator('p-table tbody tr:first-child button.p-button-danger, p-table tbody tr:first-child button[icon*="trash"]').first();
|
|
207
|
+
await deleteBtn.click();
|
|
208
|
+
await page.waitForTimeout(500);
|
|
209
|
+
|
|
210
|
+
const confirmBtn = page.locator('.p-confirm-dialog-accept, .p-dialog-footer button:has-text("Xóa"), .p-dialog-footer button:has-text("Đồng ý"), button.p-button-danger:has-text("Xóa")').first();
|
|
211
|
+
await confirmBtn.waitFor({ state: 'visible', timeout: 3000 });
|
|
212
|
+
await confirmBtn.click();
|
|
213
|
+
await page.waitForTimeout(1500);
|
|
214
|
+
|
|
215
|
+
await searchInput.fill(targetKeyword);
|
|
216
|
+
await searchInput.press('Enter');
|
|
217
|
+
await page.waitForTimeout(1000);
|
|
218
|
+
const remainingRows = await page.locator('p-table table tbody tr').count();
|
|
219
|
+
const firstRowText = remainingRows > 0 ? await page.locator('p-table table tbody tr').first().innerText() : '';
|
|
220
|
+
const isDeleted = remainingRows === 0 || !firstRowText.includes(targetKeyword) || firstRowText.includes('Không có dữ liệu');
|
|
221
|
+
|
|
222
|
+
return { isDeleted, remainingRows, firstRowText };
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Recipe 12: PrimeNG Calendar Date Input (`p-calendar`)
|
|
227
|
+
```javascript
|
|
228
|
+
async (page) => {
|
|
229
|
+
const dateValue = '25/10/2026';
|
|
230
|
+
await page.evaluate(({ selector, val }) => {
|
|
231
|
+
const calInput = document.querySelector(selector);
|
|
232
|
+
if (calInput) {
|
|
233
|
+
calInput.value = val;
|
|
234
|
+
calInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
235
|
+
calInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
236
|
+
calInput.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
237
|
+
}
|
|
238
|
+
}, { selector: 'p-calendar input, input[formcontrolname*="date"], input[formcontrolname*="Date"]', val: dateValue });
|
|
239
|
+
|
|
240
|
+
await page.waitForTimeout(300);
|
|
241
|
+
return { status: 'Date filled', value: dateValue };
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Recipe 13: PrimeNG MultiSelect Dropdown (`p-multiselect`)
|
|
246
|
+
```javascript
|
|
247
|
+
async (page, optionsToSelect = ['Công nghiệp chế biến', 'Điện lực']) => {
|
|
248
|
+
const multiSelect = page.locator('p-multiselect').first();
|
|
249
|
+
await multiSelect.click();
|
|
250
|
+
await page.waitForSelector('.p-multiselect-panel', { state: 'visible', timeout: 3000 });
|
|
251
|
+
await page.waitForTimeout(300);
|
|
252
|
+
|
|
253
|
+
for (const optText of optionsToSelect) {
|
|
254
|
+
const item = page.locator('.p-multiselect-item').filter({ hasText: optText }).first();
|
|
255
|
+
if (await item.isVisible()) {
|
|
256
|
+
await item.click();
|
|
257
|
+
await page.waitForTimeout(200);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const closeBtn = page.locator('.p-multiselect-close').first();
|
|
262
|
+
if (await closeBtn.isVisible()) {
|
|
263
|
+
await closeBtn.click();
|
|
264
|
+
} else {
|
|
265
|
+
await page.keyboard.press('Escape');
|
|
266
|
+
}
|
|
267
|
+
await page.waitForTimeout(300);
|
|
268
|
+
return { selected: optionsToSelect };
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Recipe 14: Handling Stale Elements & Angular DOM Re-render Retries
|
|
273
|
+
```javascript
|
|
274
|
+
async (page, actionFn) => {
|
|
275
|
+
const maxRetries = 3;
|
|
276
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
277
|
+
try {
|
|
278
|
+
return await actionFn(page);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (err.message.includes('Element is not attached to the DOM') || err.message.includes('stale') || attempt === maxRetries) {
|
|
281
|
+
if (attempt === maxRetries) throw err;
|
|
282
|
+
await page.waitForTimeout(500 * attempt);
|
|
283
|
+
} else {
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
## Recipe 15: Instant Angular Form Audit (Diagnose Disabled Save Button)
|
|
293
|
+
```javascript
|
|
294
|
+
async (page) => {
|
|
295
|
+
return await page.evaluate(() => {
|
|
296
|
+
const invalidInputs = Array.from(document.querySelectorAll('form .ng-invalid, input.ng-invalid, p-dropdown.ng-invalid, textarea.ng-invalid'));
|
|
297
|
+
return invalidInputs.map(el => ({
|
|
298
|
+
tag: el.tagName.toLowerCase(),
|
|
299
|
+
control: el.getAttribute('formcontrolname') || el.getAttribute('name') || el.id || 'unnamed',
|
|
300
|
+
placeholder: el.getAttribute('placeholder') || '',
|
|
301
|
+
currentValue: el.value || el.innerText.trim()
|
|
302
|
+
}));
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
## Recipe 16: Smart Action Batching (Atomic Execution with Pause-on-Failure)
|
|
308
|
+
```javascript
|
|
309
|
+
async (page) => {
|
|
310
|
+
return await page.evaluate(() => {
|
|
311
|
+
const steps = [
|
|
312
|
+
{ name: 'fill_code', fn: () => { const el = document.querySelector("input[formcontrolname='code']"); el.value = 'CCN_TEST'; el.dispatchEvent(new Event('input', {bubbles: true})); } },
|
|
313
|
+
{ name: 'fill_name', fn: () => { const el = document.querySelector("input[formcontrolname='name']"); el.value = 'Cụm CN Test'; el.dispatchEvent(new Event('input', {bubbles: true})); } },
|
|
314
|
+
{ name: 'submit', fn: () => { document.querySelector("button[type='submit']").click(); } }
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
for (let i = 0; i < steps.length; i++) {
|
|
318
|
+
try {
|
|
319
|
+
steps[i].fn();
|
|
320
|
+
} catch (err) {
|
|
321
|
+
return { status: 'FAILED', failedStep: i + 1, name: steps[i].name, error: err.message, url: location.href };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { status: 'SUCCESS', totalSteps: steps.length };
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## Recipe 17: Runtime Helper Injection (`window.__qa`)
|
|
330
|
+
```javascript
|
|
331
|
+
async (page) => {
|
|
332
|
+
// Injects the centralized browser helper tools/browser_helpers.js
|
|
333
|
+
const fs = require('fs');
|
|
334
|
+
const helperCode = fs.readFileSync('tools/browser_helpers.js', 'utf8');
|
|
335
|
+
await page.evaluate(helperCode);
|
|
336
|
+
return await page.evaluate(() => ({
|
|
337
|
+
version: window.__qa.__version,
|
|
338
|
+
auditResults: window.__qa.audit()
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
## Recipe 18: Self-Healing Locator Fallback (`window.__qa.heal`)
|
|
344
|
+
```javascript
|
|
345
|
+
async (page, brokenXPath, expectedText) => {
|
|
346
|
+
return await page.evaluate(({ xpath, text }) => {
|
|
347
|
+
if (window.__qa) {
|
|
348
|
+
return window.__qa.heal(xpath, text);
|
|
349
|
+
}
|
|
350
|
+
// Fallback search
|
|
351
|
+
const buttons = Array.from(document.querySelectorAll('button')).filter(b => b.innerText.includes(text));
|
|
352
|
+
return {
|
|
353
|
+
candidates: buttons.map(b => `//button[contains(normalize-space(.), '${b.innerText.trim()}')]`)
|
|
354
|
+
};
|
|
355
|
+
}, { xpath: brokenXPath, text: expectedText });
|
|
356
|
+
}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
## Recipe 19: Overlay-Proof Safe Click (`window.__qa.clickSafe`)
|
|
360
|
+
```javascript
|
|
361
|
+
async (page, selector) => {
|
|
362
|
+
return await page.evaluate((sel) => {
|
|
363
|
+
if (window.__qa) {
|
|
364
|
+
return window.__qa.clickSafe(sel);
|
|
365
|
+
}
|
|
366
|
+
const el = document.querySelector(sel);
|
|
367
|
+
if (!el) return false;
|
|
368
|
+
document.querySelectorAll('.p-component-overlay').forEach(o => o.style.pointerEvents = 'none');
|
|
369
|
+
el.click();
|
|
370
|
+
return true;
|
|
371
|
+
}, selector);
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
## Recipe 20: OpenLayers GIS Canvas Coordinate Inspector (`window.__qa.gisHUD`)
|
|
376
|
+
```javascript
|
|
377
|
+
async (page) => {
|
|
378
|
+
return await page.evaluate(() => {
|
|
379
|
+
if (window.__qa) {
|
|
380
|
+
return window.__qa.gisHUD();
|
|
381
|
+
}
|
|
382
|
+
const vp = document.querySelector('.ol-viewport');
|
|
383
|
+
if (!vp) return { status: 'NO_MAP' };
|
|
384
|
+
const r = vp.getBoundingClientRect();
|
|
385
|
+
return { width: r.width, height: r.height, safeCenter: { x: r.width / 2, y: r.height / 2 } };
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
```
|