qa360 2.0.13 ā 2.1.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/dist/core/adapters/playwright-native-adapter.d.ts +121 -0
- package/dist/core/adapters/playwright-native-adapter.js +339 -0
- package/dist/core/adapters/playwright-ui.d.ts +38 -0
- package/dist/core/adapters/playwright-ui.js +164 -4
- package/dist/core/artifacts/index.d.ts +6 -0
- package/dist/core/artifacts/index.js +6 -0
- package/dist/core/artifacts/ui-artifacts.d.ts +133 -0
- package/dist/core/artifacts/ui-artifacts.js +304 -0
- package/dist/core/index.d.ts +6 -0
- package/dist/core/index.js +9 -0
- package/dist/core/parallel/index.d.ts +6 -0
- package/dist/core/parallel/index.js +6 -0
- package/dist/core/parallel/parallel-runner.d.ts +107 -0
- package/dist/core/parallel/parallel-runner.js +192 -0
- package/dist/core/reporting/html-reporter.d.ts +119 -0
- package/dist/core/reporting/html-reporter.js +737 -0
- package/dist/core/reporting/index.d.ts +6 -0
- package/dist/core/reporting/index.js +6 -0
- package/dist/core/runner/phase3-runner.js +5 -1
- package/dist/core/vault/cas.d.ts +5 -1
- package/dist/core/vault/cas.js +6 -0
- package/dist/core/visual/index.d.ts +6 -0
- package/dist/core/visual/index.js +6 -0
- package/dist/core/visual/visual-regression.d.ts +113 -0
- package/dist/core/visual/visual-regression.js +236 -0
- package/examples/README.md +38 -0
- package/examples/crawler.yml +38 -0
- package/examples/ui-advanced.yml +49 -0
- package/package.json +1 -1
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QA360 UI Artifacts Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages screenshots, videos, traces, and other test artifacts
|
|
5
|
+
* with content-addressable storage (CAS) integration
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, existsSync, writeFileSync, readFileSync, unlinkSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { ContentAddressableStorage } from '../vault/cas.js';
|
|
10
|
+
/**
|
|
11
|
+
* UI Artifacts Manager
|
|
12
|
+
*
|
|
13
|
+
* Handles:
|
|
14
|
+
* - Automatic screenshots (before/after steps, on error)
|
|
15
|
+
* - Video recording
|
|
16
|
+
* - Trace files
|
|
17
|
+
* - Network captures
|
|
18
|
+
* - Console logs
|
|
19
|
+
* - Storage in CAS for deduplication
|
|
20
|
+
*/
|
|
21
|
+
export class UIArtifactsManager {
|
|
22
|
+
artifactDir;
|
|
23
|
+
casDir;
|
|
24
|
+
currentRunId;
|
|
25
|
+
currentTestId;
|
|
26
|
+
artifacts = new Map();
|
|
27
|
+
cas;
|
|
28
|
+
constructor(artifactDir = '.qa360/artifacts/ui', casDir = '.qa360/runs/cas') {
|
|
29
|
+
this.artifactDir = artifactDir;
|
|
30
|
+
this.casDir = casDir;
|
|
31
|
+
this.currentRunId = `run-${Date.now()}`;
|
|
32
|
+
this.cas = new ContentAddressableStorage(casDir);
|
|
33
|
+
this.ensureDirectories();
|
|
34
|
+
}
|
|
35
|
+
ensureDirectories() {
|
|
36
|
+
const dirs = [
|
|
37
|
+
this.artifactDir,
|
|
38
|
+
`${this.artifactDir}/screenshots`,
|
|
39
|
+
`${this.artifactDir}/videos`,
|
|
40
|
+
`${this.artifactDir}/traces`,
|
|
41
|
+
`${this.artifactDir}/network`,
|
|
42
|
+
`${this.artifactDir}/console`,
|
|
43
|
+
`${this.artifactDir}/coverage`,
|
|
44
|
+
this.casDir,
|
|
45
|
+
`${this.casDir}/art`,
|
|
46
|
+
];
|
|
47
|
+
for (const dir of dirs) {
|
|
48
|
+
if (!existsSync(dir)) {
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Start a new test session
|
|
55
|
+
*/
|
|
56
|
+
startTest(testId) {
|
|
57
|
+
this.currentTestId = testId;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* End current test session
|
|
61
|
+
*/
|
|
62
|
+
endTest() {
|
|
63
|
+
this.currentTestId = undefined;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Take a screenshot and store it
|
|
67
|
+
*/
|
|
68
|
+
async takeScreenshot(page, // Playwright Page
|
|
69
|
+
options = {}, metadata) {
|
|
70
|
+
const testId = this.currentTestId || 'unknown';
|
|
71
|
+
const timestamp = new Date().toISOString();
|
|
72
|
+
const filename = `${testId}-${Date.now()}.png`;
|
|
73
|
+
const localPath = join(this.artifactDir, 'screenshots', filename);
|
|
74
|
+
// Take screenshot
|
|
75
|
+
const screenshot = await page.screenshot({
|
|
76
|
+
path: localPath,
|
|
77
|
+
type: options.type || 'png',
|
|
78
|
+
fullPage: options.fullPage || false,
|
|
79
|
+
animations: options.animations || 'disabled',
|
|
80
|
+
});
|
|
81
|
+
// Get image dimensions
|
|
82
|
+
const viewportSize = page.viewportSize();
|
|
83
|
+
const dimensions = await page.evaluate(() => ({
|
|
84
|
+
width: document.documentElement.scrollWidth,
|
|
85
|
+
height: document.documentElement.scrollHeight,
|
|
86
|
+
}));
|
|
87
|
+
// Calculate hash and store in CAS
|
|
88
|
+
const hash = ContentAddressableStorage.hashContent(screenshot);
|
|
89
|
+
const casPath = this.getCasPath(hash);
|
|
90
|
+
// Store in CAS if not exists
|
|
91
|
+
if (!existsSync(casPath)) {
|
|
92
|
+
writeFileSync(casPath, screenshot);
|
|
93
|
+
}
|
|
94
|
+
const artifact = {
|
|
95
|
+
type: 'screenshot',
|
|
96
|
+
format: options.type || 'png',
|
|
97
|
+
casPath,
|
|
98
|
+
localPath,
|
|
99
|
+
hash,
|
|
100
|
+
width: options.fullPage ? dimensions.width : (viewportSize?.width || 1280),
|
|
101
|
+
height: options.fullPage ? dimensions.height : (viewportSize?.height || 720),
|
|
102
|
+
size: screenshot.length,
|
|
103
|
+
metadata: {
|
|
104
|
+
testId,
|
|
105
|
+
timestamp,
|
|
106
|
+
type: 'screenshot',
|
|
107
|
+
...metadata,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
this.artifacts.set(`${testId}-screenshot-${Date.now()}`, artifact);
|
|
111
|
+
return artifact;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Take screenshot before a step
|
|
115
|
+
*/
|
|
116
|
+
async takeBeforeScreenshot(page, stepName, stepIndex) {
|
|
117
|
+
return this.takeScreenshot(page, {}, {
|
|
118
|
+
stepIndex,
|
|
119
|
+
stepName: `${stepName}-before`,
|
|
120
|
+
tags: ['before-step'],
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Take screenshot after a step
|
|
125
|
+
*/
|
|
126
|
+
async takeAfterScreenshot(page, stepName, stepIndex, success) {
|
|
127
|
+
return this.takeScreenshot(page, {}, {
|
|
128
|
+
stepIndex,
|
|
129
|
+
stepName: `${stepName}-after`,
|
|
130
|
+
status: success ? 'passed' : 'failed',
|
|
131
|
+
tags: ['after-step', success ? 'success' : 'failure'],
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Take screenshot on error
|
|
136
|
+
*/
|
|
137
|
+
async takeErrorScreenshot(page, error, stepName) {
|
|
138
|
+
const filename = `error-${Date.now()}.png`;
|
|
139
|
+
const localPath = join(this.artifactDir, 'screenshots', filename);
|
|
140
|
+
await page.screenshot({
|
|
141
|
+
path: localPath,
|
|
142
|
+
fullPage: true,
|
|
143
|
+
});
|
|
144
|
+
const screenshot = readFileSync(localPath);
|
|
145
|
+
const hash = ContentAddressableStorage.hashContent(screenshot);
|
|
146
|
+
const casPath = this.getCasPath(hash);
|
|
147
|
+
if (!existsSync(casPath)) {
|
|
148
|
+
writeFileSync(casPath, screenshot);
|
|
149
|
+
}
|
|
150
|
+
const artifact = {
|
|
151
|
+
type: 'screenshot',
|
|
152
|
+
format: 'png',
|
|
153
|
+
casPath,
|
|
154
|
+
localPath,
|
|
155
|
+
hash,
|
|
156
|
+
width: 0,
|
|
157
|
+
height: 0,
|
|
158
|
+
size: screenshot.length,
|
|
159
|
+
metadata: {
|
|
160
|
+
testId: this.currentTestId || 'unknown',
|
|
161
|
+
timestamp: new Date().toISOString(),
|
|
162
|
+
type: 'screenshot',
|
|
163
|
+
status: 'failed',
|
|
164
|
+
stepName: stepName || 'error',
|
|
165
|
+
tags: ['error', 'failure-screenshot'],
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
this.artifacts.set(`error-${Date.now()}`, artifact);
|
|
169
|
+
return artifact;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Save video artifact
|
|
173
|
+
*/
|
|
174
|
+
async saveVideo(videoPath, metadata) {
|
|
175
|
+
const testId = this.currentTestId || 'unknown';
|
|
176
|
+
const filename = `${testId}-${Date.now()}.webm`;
|
|
177
|
+
const localPath = join(this.artifactDir, 'videos', filename);
|
|
178
|
+
// Read video file
|
|
179
|
+
const video = readFileSync(videoPath);
|
|
180
|
+
const hash = ContentAddressableStorage.hashContent(video);
|
|
181
|
+
const casPath = this.getCasPath(hash);
|
|
182
|
+
// Store in CAS
|
|
183
|
+
if (!existsSync(casPath)) {
|
|
184
|
+
writeFileSync(casPath, video);
|
|
185
|
+
}
|
|
186
|
+
// Get video duration (basic estimate)
|
|
187
|
+
const size = video.length;
|
|
188
|
+
const duration = Math.round(size / 100000); // Rough estimate: 100KB per second
|
|
189
|
+
const artifact = {
|
|
190
|
+
type: 'video',
|
|
191
|
+
format: 'webm',
|
|
192
|
+
casPath,
|
|
193
|
+
localPath,
|
|
194
|
+
hash,
|
|
195
|
+
duration,
|
|
196
|
+
size,
|
|
197
|
+
metadata: {
|
|
198
|
+
testId,
|
|
199
|
+
timestamp: new Date().toISOString(),
|
|
200
|
+
type: 'video',
|
|
201
|
+
...metadata,
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
this.artifacts.set(`${testId}-video-${Date.now()}`, artifact);
|
|
205
|
+
return artifact;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Save trace artifact
|
|
209
|
+
*/
|
|
210
|
+
async saveTrace(tracePath, metadata) {
|
|
211
|
+
const testId = this.currentTestId || 'unknown';
|
|
212
|
+
const filename = `${testId}-${Date.now()}.zip`;
|
|
213
|
+
const localPath = join(this.artifactDir, 'traces', filename);
|
|
214
|
+
const trace = readFileSync(tracePath);
|
|
215
|
+
const hash = ContentAddressableStorage.hashContent(trace);
|
|
216
|
+
const casPath = this.getCasPath(hash);
|
|
217
|
+
if (!existsSync(casPath)) {
|
|
218
|
+
writeFileSync(casPath, trace);
|
|
219
|
+
}
|
|
220
|
+
const artifact = {
|
|
221
|
+
type: 'trace',
|
|
222
|
+
format: 'zip',
|
|
223
|
+
casPath,
|
|
224
|
+
localPath,
|
|
225
|
+
hash,
|
|
226
|
+
size: trace.length,
|
|
227
|
+
metadata: {
|
|
228
|
+
testId,
|
|
229
|
+
timestamp: new Date().toISOString(),
|
|
230
|
+
type: 'trace',
|
|
231
|
+
...metadata,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
this.artifacts.set(`${testId}-trace-${Date.now()}`, artifact);
|
|
235
|
+
return artifact;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Get artifact by ID
|
|
239
|
+
*/
|
|
240
|
+
getArtifact(id) {
|
|
241
|
+
return this.artifacts.get(id);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Get all artifacts for current test
|
|
245
|
+
*/
|
|
246
|
+
getTestArtifacts() {
|
|
247
|
+
const testId = this.currentTestId || 'unknown';
|
|
248
|
+
return Array.from(this.artifacts.values()).filter(a => a.metadata.testId === testId);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Get all artifacts by type
|
|
252
|
+
*/
|
|
253
|
+
getArtifactsByType(type) {
|
|
254
|
+
return Array.from(this.artifacts.values()).filter(a => a.type === type);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Clean up old artifacts
|
|
258
|
+
*/
|
|
259
|
+
cleanup(maxAge = 24 * 60 * 60 * 1000) {
|
|
260
|
+
const now = Date.now();
|
|
261
|
+
for (const [id, artifact] of this.artifacts.entries()) {
|
|
262
|
+
const artifactTime = new Date(artifact.metadata.timestamp).getTime();
|
|
263
|
+
if (now - artifactTime > maxAge) {
|
|
264
|
+
// Remove local file (keep in CAS)
|
|
265
|
+
if (existsSync(artifact.localPath)) {
|
|
266
|
+
unlinkSync(artifact.localPath);
|
|
267
|
+
}
|
|
268
|
+
this.artifacts.delete(id);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Generate CAS path from hash
|
|
274
|
+
*/
|
|
275
|
+
getCasPath(hash) {
|
|
276
|
+
// hash is already a hex string from SHA256
|
|
277
|
+
return join(this.casDir, 'art', hash.substring(0, 2), hash.substring(2, 4), hash.substring(4));
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Generate artifacts summary for reporting
|
|
281
|
+
*/
|
|
282
|
+
generateSummary() {
|
|
283
|
+
const screenshots = this.getArtifactsByType('screenshot').length;
|
|
284
|
+
const videos = this.getArtifactsByType('video').length;
|
|
285
|
+
const traces = this.getArtifactsByType('trace').length;
|
|
286
|
+
const totalSize = Array.from(this.artifacts.values()).reduce((sum, a) => sum + a.size, 0);
|
|
287
|
+
// Calculate CAS savings (unique hashes)
|
|
288
|
+
const uniqueHashes = new Set(Array.from(this.artifacts.values()).map(a => a.hash));
|
|
289
|
+
const casDedupSavings = totalSize - (uniqueHashes.size * 1000); // Rough estimate
|
|
290
|
+
return {
|
|
291
|
+
screenshots,
|
|
292
|
+
videos,
|
|
293
|
+
traces,
|
|
294
|
+
totalSize,
|
|
295
|
+
casDedupSavings,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Create a UI artifacts manager
|
|
301
|
+
*/
|
|
302
|
+
export function createUIArtifactsManager(artifactDir, casDir) {
|
|
303
|
+
return new UIArtifactsManager(artifactDir, casDir);
|
|
304
|
+
}
|
package/dist/core/index.d.ts
CHANGED
|
@@ -96,3 +96,9 @@ export * from './regression/index.js';
|
|
|
96
96
|
export * from './crawler/index.js';
|
|
97
97
|
export { AssertionsEngine, createAssertionsEngine, AssertionResult, AssertionGroupResult, AssertionRunOptions, AssertionError, SoftAssertionError, Assertion, AssertionOperator, AssertionSuite, } from './assertions/index.js';
|
|
98
98
|
export type { AssertionType as UiAssertionType } from './assertions/types.js';
|
|
99
|
+
export * from './artifacts/index.js';
|
|
100
|
+
export { HTMLReporter, generateHTMLReport } from './reporting/index.js';
|
|
101
|
+
export type { ReportData, TestReport, StepReport, } from './reporting/index.js';
|
|
102
|
+
export type { ScreenshotArtifact as ReportScreenshotArtifact, VideoArtifact as ReportVideoArtifact, TraceArtifact as ReportTraceArtifact, } from './reporting/index.js';
|
|
103
|
+
export * from './parallel/index.js';
|
|
104
|
+
export * from './visual/index.js';
|
package/dist/core/index.js
CHANGED
|
@@ -80,3 +80,12 @@ export * from './regression/index.js';
|
|
|
80
80
|
export * from './crawler/index.js';
|
|
81
81
|
// Assertions Engine (Vision 2.0 - UI Testing 100%)
|
|
82
82
|
export { AssertionsEngine, createAssertionsEngine, AssertionError, SoftAssertionError, } from './assertions/index.js';
|
|
83
|
+
// Artifacts Module (Vision 2.0 - UI Testing Artifacts)
|
|
84
|
+
export * from './artifacts/index.js';
|
|
85
|
+
// Reporting Module (Vision 2.0 - HTML Reports)
|
|
86
|
+
// Note: Using selective exports to avoid naming conflicts with artifacts module
|
|
87
|
+
export { HTMLReporter, generateHTMLReport } from './reporting/index.js';
|
|
88
|
+
// Parallel Module (Vision 2.0 - Parallel Test Execution)
|
|
89
|
+
export * from './parallel/index.js';
|
|
90
|
+
// Visual Regression Module (Vision 2.0 - Visual Testing)
|
|
91
|
+
export * from './visual/index.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QA360 Parallel Module
|
|
3
|
+
*
|
|
4
|
+
* Parallel test execution for faster feedback
|
|
5
|
+
*/
|
|
6
|
+
export { ParallelTestRunner, createParallelRunner, runParallelTests, type ParallelTestConfig, type ParallelTestResult, type ParallelRunResult, type WorkerMessage, type WorkerJob, } from './parallel-runner.js';
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QA360 Parallel Test Runner
|
|
3
|
+
*
|
|
4
|
+
* Runs multiple UI tests concurrently for faster execution.
|
|
5
|
+
* Supports worker pools, load balancing, and resource limits.
|
|
6
|
+
*/
|
|
7
|
+
export interface ParallelTestConfig {
|
|
8
|
+
/** Test definition to run */
|
|
9
|
+
test: any;
|
|
10
|
+
/** Target configuration */
|
|
11
|
+
target: any;
|
|
12
|
+
/** Worker timeout in ms */
|
|
13
|
+
timeout?: number;
|
|
14
|
+
/** Maximum concurrent workers (default: CPU count) */
|
|
15
|
+
maxWorkers?: number;
|
|
16
|
+
/** Number of retries for failed tests */
|
|
17
|
+
retries?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface ParallelTestResult {
|
|
20
|
+
test: any;
|
|
21
|
+
success: boolean;
|
|
22
|
+
duration: number;
|
|
23
|
+
error?: string;
|
|
24
|
+
artifacts?: any;
|
|
25
|
+
workerId?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface ParallelRunResult {
|
|
28
|
+
success: boolean;
|
|
29
|
+
totalTests: number;
|
|
30
|
+
passed: number;
|
|
31
|
+
failed: number;
|
|
32
|
+
skipped: number;
|
|
33
|
+
duration: number;
|
|
34
|
+
results: ParallelTestResult[];
|
|
35
|
+
workerStats: {
|
|
36
|
+
totalWorkers: number;
|
|
37
|
+
totalJobs: number;
|
|
38
|
+
avgJobDuration: number;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export interface WorkerMessage {
|
|
42
|
+
type: 'run' | 'result' | 'error' | 'timeout';
|
|
43
|
+
testId: string;
|
|
44
|
+
data?: any;
|
|
45
|
+
error?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface WorkerJob {
|
|
48
|
+
id: string;
|
|
49
|
+
test: any;
|
|
50
|
+
target: any;
|
|
51
|
+
retries: number;
|
|
52
|
+
workerId?: number;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Parallel Test Runner
|
|
56
|
+
*
|
|
57
|
+
* Executes multiple tests concurrently using worker threads.
|
|
58
|
+
* Each test runs in isolation with its own browser instance.
|
|
59
|
+
*/
|
|
60
|
+
export declare class ParallelTestRunner {
|
|
61
|
+
private maxWorkers;
|
|
62
|
+
private timeout;
|
|
63
|
+
private retries;
|
|
64
|
+
private workerPool;
|
|
65
|
+
private activeJobs;
|
|
66
|
+
constructor(config?: {
|
|
67
|
+
maxWorkers?: number;
|
|
68
|
+
timeout?: number;
|
|
69
|
+
retries?: number;
|
|
70
|
+
});
|
|
71
|
+
/**
|
|
72
|
+
* Run tests in parallel
|
|
73
|
+
*/
|
|
74
|
+
runParallel(tests: any[], target: any): Promise<ParallelRunResult>;
|
|
75
|
+
/**
|
|
76
|
+
* Run a single worker job
|
|
77
|
+
*/
|
|
78
|
+
private runWorker;
|
|
79
|
+
/**
|
|
80
|
+
* Sleep utility
|
|
81
|
+
*/
|
|
82
|
+
private sleep;
|
|
83
|
+
/**
|
|
84
|
+
* Clean up all workers
|
|
85
|
+
*/
|
|
86
|
+
cleanup(): Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Get optimal worker count based on resources
|
|
89
|
+
*/
|
|
90
|
+
static getOptimalWorkerCount(): number;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Create a parallel test runner
|
|
94
|
+
*/
|
|
95
|
+
export declare function createParallelRunner(config?: {
|
|
96
|
+
maxWorkers?: number;
|
|
97
|
+
timeout?: number;
|
|
98
|
+
retries?: number;
|
|
99
|
+
}): ParallelTestRunner;
|
|
100
|
+
/**
|
|
101
|
+
* Run tests in parallel (convenience function)
|
|
102
|
+
*/
|
|
103
|
+
export declare function runParallelTests(tests: any[], target: any, config?: {
|
|
104
|
+
maxWorkers?: number;
|
|
105
|
+
timeout?: number;
|
|
106
|
+
retries?: number;
|
|
107
|
+
}): Promise<ParallelRunResult>;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QA360 Parallel Test Runner
|
|
3
|
+
*
|
|
4
|
+
* Runs multiple UI tests concurrently for faster execution.
|
|
5
|
+
* Supports worker pools, load balancing, and resource limits.
|
|
6
|
+
*/
|
|
7
|
+
import { cpus } from 'os';
|
|
8
|
+
/**
|
|
9
|
+
* Parallel Test Runner
|
|
10
|
+
*
|
|
11
|
+
* Executes multiple tests concurrently using worker threads.
|
|
12
|
+
* Each test runs in isolation with its own browser instance.
|
|
13
|
+
*/
|
|
14
|
+
export class ParallelTestRunner {
|
|
15
|
+
maxWorkers;
|
|
16
|
+
timeout;
|
|
17
|
+
retries;
|
|
18
|
+
workerPool;
|
|
19
|
+
activeJobs;
|
|
20
|
+
constructor(config) {
|
|
21
|
+
this.maxWorkers = config?.maxWorkers || Math.max(2, cpus().length - 1);
|
|
22
|
+
this.timeout = config?.timeout || 60000; // 60s default per test
|
|
23
|
+
this.retries = config?.retries || 1;
|
|
24
|
+
this.workerPool = new Map();
|
|
25
|
+
this.activeJobs = new Map();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Run tests in parallel
|
|
29
|
+
*/
|
|
30
|
+
async runParallel(tests, target) {
|
|
31
|
+
const startTime = Date.now();
|
|
32
|
+
console.log(`\nš Parallel Test Runner`);
|
|
33
|
+
console.log(` Workers: ${this.maxWorkers}`);
|
|
34
|
+
console.log(` Tests: ${tests.length}`);
|
|
35
|
+
const results = [];
|
|
36
|
+
const jobs = tests.map((test, i) => ({
|
|
37
|
+
id: `test-${i}`,
|
|
38
|
+
test,
|
|
39
|
+
target,
|
|
40
|
+
retries: this.retries,
|
|
41
|
+
}));
|
|
42
|
+
// Process jobs in batches
|
|
43
|
+
let completed = 0;
|
|
44
|
+
let workerId = 0;
|
|
45
|
+
while (jobs.length > 0 || this.activeJobs.size > 0) {
|
|
46
|
+
// Start new jobs up to max workers
|
|
47
|
+
while (this.activeJobs.size < this.maxWorkers && jobs.length > 0) {
|
|
48
|
+
const job = jobs.shift();
|
|
49
|
+
job.workerId = workerId;
|
|
50
|
+
this.activeJobs.set(job.id, job);
|
|
51
|
+
this.runWorker(workerId, job)
|
|
52
|
+
.then((result) => {
|
|
53
|
+
results.push(result);
|
|
54
|
+
completed++;
|
|
55
|
+
const progress = Math.round((completed / tests.length) * 100);
|
|
56
|
+
console.log(` [${completed}/${tests.length}] ${progress}% - ${result.test.name || job.id}: ${result.success ? 'PASS' : 'FAIL'}`);
|
|
57
|
+
})
|
|
58
|
+
.catch((error) => {
|
|
59
|
+
results.push({
|
|
60
|
+
test: job.test,
|
|
61
|
+
success: false,
|
|
62
|
+
duration: 0,
|
|
63
|
+
error: error.message,
|
|
64
|
+
workerId,
|
|
65
|
+
});
|
|
66
|
+
completed++;
|
|
67
|
+
})
|
|
68
|
+
.finally(() => {
|
|
69
|
+
this.activeJobs.delete(job.id);
|
|
70
|
+
});
|
|
71
|
+
workerId = (workerId + 1) % this.maxWorkers;
|
|
72
|
+
}
|
|
73
|
+
// Wait a bit before checking again
|
|
74
|
+
await this.sleep(100);
|
|
75
|
+
}
|
|
76
|
+
const duration = Date.now() - startTime;
|
|
77
|
+
const passed = results.filter((r) => r.success).length;
|
|
78
|
+
const failed = results.filter((r) => !r.success).length;
|
|
79
|
+
const workerStats = {
|
|
80
|
+
totalWorkers: this.maxWorkers,
|
|
81
|
+
totalJobs: results.length,
|
|
82
|
+
avgJobDuration: results.length > 0 ? duration / results.length : 0,
|
|
83
|
+
};
|
|
84
|
+
console.log(`\nā
Parallel execution complete`);
|
|
85
|
+
console.log(` Duration: ${duration}ms`);
|
|
86
|
+
console.log(` Throughput: ${Math.round(results.length / (duration / 1000))} tests/sec`);
|
|
87
|
+
return {
|
|
88
|
+
success: failed === 0,
|
|
89
|
+
totalTests: results.length,
|
|
90
|
+
passed,
|
|
91
|
+
failed,
|
|
92
|
+
skipped: 0,
|
|
93
|
+
duration,
|
|
94
|
+
results,
|
|
95
|
+
workerStats,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Run a single worker job
|
|
100
|
+
*/
|
|
101
|
+
async runWorker(workerId, job) {
|
|
102
|
+
// For now, run in-process (worker threads setup is complex)
|
|
103
|
+
// In production, this would spawn a worker thread
|
|
104
|
+
const startTime = Date.now();
|
|
105
|
+
try {
|
|
106
|
+
// Import adapter dynamically to run test
|
|
107
|
+
const { PlaywrightNativeAdapter } = await import('../adapters/playwright-native-adapter.js');
|
|
108
|
+
const adapter = new PlaywrightNativeAdapter({
|
|
109
|
+
target: job.target,
|
|
110
|
+
timeout: this.timeout,
|
|
111
|
+
screenshots: 'only-on-failure',
|
|
112
|
+
video: 'never',
|
|
113
|
+
trace: 'never',
|
|
114
|
+
});
|
|
115
|
+
const result = await adapter.runTest(job.test);
|
|
116
|
+
// Retry on failure
|
|
117
|
+
if (!result.success && job.retries > 0) {
|
|
118
|
+
console.log(` š Retrying ${job.test.name || job.id} (${job.retries} retries left)`);
|
|
119
|
+
job.retries--;
|
|
120
|
+
return this.runWorker(workerId, job);
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
test: job.test,
|
|
124
|
+
success: result.success,
|
|
125
|
+
duration: Date.now() - startTime,
|
|
126
|
+
error: result.error,
|
|
127
|
+
artifacts: result.artifacts,
|
|
128
|
+
workerId,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
// Retry on error
|
|
133
|
+
if (job.retries > 0) {
|
|
134
|
+
console.log(` š Retrying ${job.test.name || job.id} after error (${job.retries} retries left)`);
|
|
135
|
+
job.retries--;
|
|
136
|
+
return this.runWorker(workerId, job);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
test: job.test,
|
|
140
|
+
success: false,
|
|
141
|
+
duration: Date.now() - startTime,
|
|
142
|
+
error: error instanceof Error ? error.message : String(error),
|
|
143
|
+
workerId,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Sleep utility
|
|
149
|
+
*/
|
|
150
|
+
sleep(ms) {
|
|
151
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Clean up all workers
|
|
155
|
+
*/
|
|
156
|
+
async cleanup() {
|
|
157
|
+
for (const [id, worker] of this.workerPool) {
|
|
158
|
+
await worker.terminate();
|
|
159
|
+
this.workerPool.delete(id);
|
|
160
|
+
}
|
|
161
|
+
this.activeJobs.clear();
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Get optimal worker count based on resources
|
|
165
|
+
*/
|
|
166
|
+
static getOptimalWorkerCount() {
|
|
167
|
+
const cpuCount = cpus().length;
|
|
168
|
+
const memGB = require('os').totalmem() / (1024 ** 3);
|
|
169
|
+
// Each worker uses ~500MB RAM
|
|
170
|
+
const memBasedWorkers = Math.floor(memGB / 0.5);
|
|
171
|
+
const cpuBasedWorkers = Math.max(2, cpuCount - 1);
|
|
172
|
+
return Math.min(cpuBasedWorkers, memBasedWorkers);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Create a parallel test runner
|
|
177
|
+
*/
|
|
178
|
+
export function createParallelRunner(config) {
|
|
179
|
+
return new ParallelTestRunner(config);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Run tests in parallel (convenience function)
|
|
183
|
+
*/
|
|
184
|
+
export async function runParallelTests(tests, target, config) {
|
|
185
|
+
const runner = new ParallelTestRunner(config);
|
|
186
|
+
try {
|
|
187
|
+
return await runner.runParallel(tests, target);
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
await runner.cleanup();
|
|
191
|
+
}
|
|
192
|
+
}
|