@diyor28/qa-core 0.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/agent/context/codecs/browser-state.codec.d.ts +33 -0
- package/dist/agent/context/codecs/browser-state.codec.d.ts.map +1 -0
- package/dist/agent/context/codecs/browser-state.codec.js +46 -0
- package/dist/agent/context/codecs/index.d.ts +3 -0
- package/dist/agent/context/codecs/index.d.ts.map +1 -0
- package/dist/agent/context/codecs/index.js +2 -0
- package/dist/agent/context/codecs/qa-scenario.codec.d.ts +21 -0
- package/dist/agent/context/codecs/qa-scenario.codec.d.ts.map +1 -0
- package/dist/agent/context/codecs/qa-scenario.codec.js +44 -0
- package/dist/agent/context/index.d.ts +4 -0
- package/dist/agent/context/index.d.ts.map +1 -0
- package/dist/agent/context/index.js +3 -0
- package/dist/agent/context/qa-context.service.d.ts +24 -0
- package/dist/agent/context/qa-context.service.d.ts.map +1 -0
- package/dist/agent/context/qa-context.service.js +53 -0
- package/dist/agent/context/qa-policy.d.ts +24 -0
- package/dist/agent/context/qa-policy.d.ts.map +1 -0
- package/dist/agent/context/qa-policy.js +47 -0
- package/dist/agent/loop.d.ts +28 -0
- package/dist/agent/loop.d.ts.map +1 -0
- package/dist/agent/loop.js +111 -0
- package/dist/agent/system-prompt.d.ts +2 -0
- package/dist/agent/system-prompt.d.ts.map +1 -0
- package/dist/agent/system-prompt.js +39 -0
- package/dist/agent/tools/index.d.ts +5 -0
- package/dist/agent/tools/index.d.ts.map +1 -0
- package/dist/agent/tools/index.js +152 -0
- package/dist/browser/controller.d.ts +82 -0
- package/dist/browser/controller.d.ts.map +1 -0
- package/dist/browser/controller.js +289 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/report/builder.d.ts +23 -0
- package/dist/report/builder.d.ts.map +1 -0
- package/dist/report/builder.js +69 -0
- package/dist/runner.d.ts +10 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +143 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/vision/analyzer.d.ts +8 -0
- package/dist/vision/analyzer.d.ts.map +1 -0
- package/dist/vision/analyzer.js +49 -0
- package/dist/vision/prompts.d.ts +2 -0
- package/dist/vision/prompts.d.ts.map +1 -0
- package/dist/vision/prompts.js +28 -0
- package/package.json +49 -0
- package/src/agent/context/codecs/browser-state.codec.ts +57 -0
- package/src/agent/context/codecs/index.ts +2 -0
- package/src/agent/context/codecs/qa-scenario.codec.ts +55 -0
- package/src/agent/context/index.ts +3 -0
- package/src/agent/context/qa-context.service.ts +90 -0
- package/src/agent/context/qa-policy.ts +54 -0
- package/src/agent/loop.ts +147 -0
- package/src/agent/system-prompt.ts +39 -0
- package/src/agent/tools/index.ts +162 -0
- package/src/browser/controller.ts +321 -0
- package/src/index.ts +19 -0
- package/src/report/builder.ts +94 -0
- package/src/runner.ts +166 -0
- package/src/types.ts +68 -0
- package/src/vision/analyzer.ts +61 -0
- package/src/vision/prompts.ts +28 -0
- package/tsconfig.json +20 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
2
|
+
import { generateText } from 'ai';
|
|
3
|
+
import type { BrowserController } from '../browser/controller.js';
|
|
4
|
+
import type { VisionAnalyzer } from '../vision/analyzer.js';
|
|
5
|
+
import type { QaFinding, QaProgressEvent } from '../types.js';
|
|
6
|
+
import { createAgentTools } from './tools/index.js';
|
|
7
|
+
import { buildSystemPrompt } from './system-prompt.js';
|
|
8
|
+
import { QaContextService } from './context/index.js';
|
|
9
|
+
|
|
10
|
+
export interface AgentLoopConfig {
|
|
11
|
+
cerebrasApiKey: string;
|
|
12
|
+
browserController: BrowserController;
|
|
13
|
+
visionAnalyzer: VisionAnalyzer;
|
|
14
|
+
scenarioName: string;
|
|
15
|
+
scenarioContent: string;
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
defectsToCheck: string[];
|
|
18
|
+
maxIterations?: number;
|
|
19
|
+
onProgress?: (event: QaProgressEvent) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AgentLoopResult {
|
|
23
|
+
findings: QaFinding[];
|
|
24
|
+
summary: string;
|
|
25
|
+
status: 'passed' | 'failed' | 'issues_found';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class AgentLoop {
|
|
29
|
+
private config: AgentLoopConfig;
|
|
30
|
+
private contextService: QaContextService;
|
|
31
|
+
|
|
32
|
+
constructor(config: AgentLoopConfig) {
|
|
33
|
+
this.config = config;
|
|
34
|
+
this.contextService = new QaContextService();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async run(): Promise<AgentLoopResult> {
|
|
38
|
+
const cerebras = createOpenAI({
|
|
39
|
+
baseURL: 'https://inference.cerebras.ai/v1',
|
|
40
|
+
apiKey: this.config.cerebrasApiKey,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const model = cerebras('glm-4.7');
|
|
44
|
+
|
|
45
|
+
const tools = createAgentTools(this.config.browserController, this.config.visionAnalyzer);
|
|
46
|
+
const systemPrompt = buildSystemPrompt(this.config.defectsToCheck, this.config.scenarioContent);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
this.emitProgress('step_started', { step: 'agent_execution', message: 'Starting QA agent loop' });
|
|
50
|
+
|
|
51
|
+
const result = await generateText({
|
|
52
|
+
model,
|
|
53
|
+
system: systemPrompt,
|
|
54
|
+
prompt: `Execute the test scenario. Start by navigating to the base URL: ${this.config.baseUrl}`,
|
|
55
|
+
tools,
|
|
56
|
+
maxSteps: this.config.maxIterations || 15,
|
|
57
|
+
temperature: 0.15,
|
|
58
|
+
maxTokens: 1000,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this.emitProgress('step_completed', {
|
|
62
|
+
step: 'agent_execution',
|
|
63
|
+
message: 'QA agent loop completed',
|
|
64
|
+
usage: result.usage,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Parse findings from agent's final response
|
|
68
|
+
const findings = this.parseFindingsFromResponse(result.text);
|
|
69
|
+
|
|
70
|
+
// Determine status
|
|
71
|
+
let status: 'passed' | 'failed' | 'issues_found' = 'passed';
|
|
72
|
+
if (findings.some((f) => f.severity === 'critical' || f.severity === 'high')) {
|
|
73
|
+
status = 'failed';
|
|
74
|
+
} else if (findings.length > 0) {
|
|
75
|
+
status = 'issues_found';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
findings,
|
|
80
|
+
summary: result.text,
|
|
81
|
+
status,
|
|
82
|
+
};
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error('[AgentLoop] Error during execution:', error);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
findings: [
|
|
88
|
+
{
|
|
89
|
+
severity: 'critical',
|
|
90
|
+
title: 'Agent execution failed',
|
|
91
|
+
description: `Error during test execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
92
|
+
reproSteps: ['Agent loop encountered an error'],
|
|
93
|
+
category: 'functional',
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
summary: `Test execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
97
|
+
status: 'failed',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private parseFindingsFromResponse(responseText: string): QaFinding[] {
|
|
103
|
+
const findings: QaFinding[] = [];
|
|
104
|
+
|
|
105
|
+
// Look for structured findings in the response
|
|
106
|
+
// Pattern 1: JSON findings array
|
|
107
|
+
const jsonMatch = responseText.match(/\{[\s\S]*"findings"[\s\S]*\}/);
|
|
108
|
+
if (jsonMatch) {
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
111
|
+
if (Array.isArray(parsed.findings)) {
|
|
112
|
+
findings.push(...parsed.findings);
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Not valid JSON, continue
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Pattern 2: Look for severity keywords in text
|
|
120
|
+
const severityPattern = /(critical|high|medium|low)\s*(issue|bug|defect|problem):\s*(.+)/gi;
|
|
121
|
+
let match;
|
|
122
|
+
while ((match = severityPattern.exec(responseText)) !== null) {
|
|
123
|
+
const severity = match[1].toLowerCase() as 'low' | 'medium' | 'high' | 'critical';
|
|
124
|
+
const description = match[3];
|
|
125
|
+
|
|
126
|
+
findings.push({
|
|
127
|
+
severity,
|
|
128
|
+
title: description.substring(0, 100),
|
|
129
|
+
description,
|
|
130
|
+
reproSteps: ['Extracted from agent response'],
|
|
131
|
+
category: 'functional',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return findings;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private emitProgress(type: QaProgressEvent['type'], data: Record<string, unknown>) {
|
|
139
|
+
if (this.config.onProgress) {
|
|
140
|
+
this.config.onProgress({
|
|
141
|
+
type,
|
|
142
|
+
data,
|
|
143
|
+
timestamp: Date.now(),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function buildSystemPrompt(defectsToCheck: string[], scenarioContent: string): string {
|
|
2
|
+
return `You are a QA testing agent executing browser-based test scenarios.
|
|
3
|
+
|
|
4
|
+
## Your Mission
|
|
5
|
+
Execute the test scenario step-by-step, validate behavior, and report any issues discovered.
|
|
6
|
+
|
|
7
|
+
## Execution Guidelines
|
|
8
|
+
1. Start by navigating to the base URL
|
|
9
|
+
2. Follow the scenario instructions precisely
|
|
10
|
+
3. After each significant action (navigation, form submission), take a screenshot
|
|
11
|
+
4. Use the analyze_ui_ux tool after taking screenshots to check for visual/UX issues
|
|
12
|
+
5. Verify expected outcomes with assertions
|
|
13
|
+
6. When you find an issue:
|
|
14
|
+
- Take a screenshot
|
|
15
|
+
- Document expected vs actual behavior with severity (low, medium, high, critical)
|
|
16
|
+
- Provide clear reproduction steps
|
|
17
|
+
7. Use parallel tool calls when actions are independent (e.g., multiple assertions)
|
|
18
|
+
|
|
19
|
+
## Defects to Check
|
|
20
|
+
${defectsToCheck.map((d) => `- ${d}`).join('\n')}
|
|
21
|
+
|
|
22
|
+
## Important Rules
|
|
23
|
+
- Use getByRole/getByText locators when possible (more reliable than CSS selectors)
|
|
24
|
+
- Wait for elements before interacting
|
|
25
|
+
- Take screenshots at key checkpoints for visual verification
|
|
26
|
+
- Request UX analysis on key screens using analyze_ui_ux tool
|
|
27
|
+
- If a tool fails, analyze the error and try an alternative approach (different selector, wait longer, etc.)
|
|
28
|
+
|
|
29
|
+
## On Completion
|
|
30
|
+
When you've completed all scenario steps, respond with a summary of:
|
|
31
|
+
- Test result (passed/failed)
|
|
32
|
+
- Issues discovered with severity and details
|
|
33
|
+
- Overall assessment
|
|
34
|
+
|
|
35
|
+
Do not make additional tool calls after providing your final summary.
|
|
36
|
+
|
|
37
|
+
## Scenario to Execute
|
|
38
|
+
${scenarioContent}`;
|
|
39
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { CoreTool } from 'ai';
|
|
3
|
+
import type { BrowserController } from '../../browser/controller.js';
|
|
4
|
+
import type { VisionAnalyzer } from '../../vision/analyzer.js';
|
|
5
|
+
|
|
6
|
+
export function createAgentTools(
|
|
7
|
+
browserController: BrowserController,
|
|
8
|
+
visionAnalyzer: VisionAnalyzer
|
|
9
|
+
): Record<string, CoreTool> {
|
|
10
|
+
return {
|
|
11
|
+
navigate: {
|
|
12
|
+
description: 'Navigate to a URL',
|
|
13
|
+
parameters: z.object({
|
|
14
|
+
url: z.string().describe('The URL to navigate to'),
|
|
15
|
+
}),
|
|
16
|
+
execute: async ({ url }: { url: string }) => {
|
|
17
|
+
return await browserController.navigate(url);
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
click: {
|
|
21
|
+
description: 'Click an element by selector or text. Use role:button, text:Login, or CSS selectors.',
|
|
22
|
+
parameters: z.object({
|
|
23
|
+
selector: z.string().describe('CSS selector, role:type, or text:content to find the element'),
|
|
24
|
+
}),
|
|
25
|
+
execute: async ({ selector }: { selector: string }) => {
|
|
26
|
+
return await browserController.click(selector);
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
fill: {
|
|
30
|
+
description: 'Fill an input field with a value',
|
|
31
|
+
parameters: z.object({
|
|
32
|
+
selector: z.string().describe('Selector for the input field'),
|
|
33
|
+
value: z.string().describe('Value to fill'),
|
|
34
|
+
}),
|
|
35
|
+
execute: async ({ selector, value }: { selector: string; value: string }) => {
|
|
36
|
+
return await browserController.fill(selector, value);
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
type: {
|
|
40
|
+
description: 'Type text into an element with key events (simulates typing)',
|
|
41
|
+
parameters: z.object({
|
|
42
|
+
selector: z.string().describe('Selector for the element'),
|
|
43
|
+
text: z.string().describe('Text to type'),
|
|
44
|
+
}),
|
|
45
|
+
execute: async ({ selector, text }: { selector: string; text: string }) => {
|
|
46
|
+
return await browserController.type(selector, text);
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
screenshot: {
|
|
50
|
+
description: 'Take a screenshot with a descriptive name',
|
|
51
|
+
parameters: z.object({
|
|
52
|
+
name: z.string().describe('Descriptive name for the screenshot (e.g., "login-page", "after-submit")'),
|
|
53
|
+
context: z.string().optional().describe('What is happening in this screenshot'),
|
|
54
|
+
}),
|
|
55
|
+
execute: async ({ name, context }: { name: string; context?: string }) => {
|
|
56
|
+
return await browserController.screenshot(name, context);
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
analyze_ui_ux: {
|
|
60
|
+
description: 'Analyze the current or a specific screenshot for UX/UI issues using AI vision. Returns findings about visual overflow, poor contrast, bad UX, and accessibility.',
|
|
61
|
+
parameters: z.object({
|
|
62
|
+
screenshotName: z
|
|
63
|
+
.string()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe('Name of specific screenshot to analyze. If omitted, analyzes the most recent screenshot.'),
|
|
66
|
+
focus: z
|
|
67
|
+
.enum(['visual', 'accessibility', 'ux', 'all'])
|
|
68
|
+
.optional()
|
|
69
|
+
.default('all')
|
|
70
|
+
.describe('What aspect to focus the analysis on'),
|
|
71
|
+
}),
|
|
72
|
+
execute: async ({ screenshotName, focus }: { screenshotName?: string; focus?: string }) => {
|
|
73
|
+
// Get screenshot from history
|
|
74
|
+
let screenshot = screenshotName
|
|
75
|
+
? browserController.getScreenshotByName(screenshotName)
|
|
76
|
+
: browserController.getLatestScreenshot();
|
|
77
|
+
|
|
78
|
+
if (!screenshot) {
|
|
79
|
+
// No screenshot available, take one automatically
|
|
80
|
+
await browserController.screenshot('auto-capture', 'Auto-captured for UX analysis');
|
|
81
|
+
screenshot = browserController.getLatestScreenshot();
|
|
82
|
+
if (!screenshot) {
|
|
83
|
+
return { error: 'Failed to capture screenshot' };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Analyze with Gemini
|
|
88
|
+
const findings = await visionAnalyzer.analyzeScreenshot(screenshot.buffer, screenshot.context, screenshot.url);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
findings,
|
|
92
|
+
analyzed_screenshot: screenshot.name,
|
|
93
|
+
analyzed_url: screenshot.url,
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
assert_visible: {
|
|
98
|
+
description: 'Assert that an element is visible on the page',
|
|
99
|
+
parameters: z.object({
|
|
100
|
+
selector: z.string().describe('Selector for the element'),
|
|
101
|
+
}),
|
|
102
|
+
execute: async ({ selector }: { selector: string }) => {
|
|
103
|
+
return await browserController.assertVisible(selector);
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
assert_text: {
|
|
107
|
+
description: 'Assert that an element contains specific text',
|
|
108
|
+
parameters: z.object({
|
|
109
|
+
selector: z.string().describe('Selector for the element'),
|
|
110
|
+
expected: z.string().describe('Expected text content'),
|
|
111
|
+
}),
|
|
112
|
+
execute: async ({ selector, expected }: { selector: string; expected: string }) => {
|
|
113
|
+
return await browserController.assertText(selector, expected);
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
get_text: {
|
|
117
|
+
description: 'Get text content from an element',
|
|
118
|
+
parameters: z.object({
|
|
119
|
+
selector: z.string().describe('Selector for the element'),
|
|
120
|
+
}),
|
|
121
|
+
execute: async ({ selector }: { selector: string }) => {
|
|
122
|
+
return await browserController.getText(selector);
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
wait_for: {
|
|
126
|
+
description: 'Wait for an element to appear on the page',
|
|
127
|
+
parameters: z.object({
|
|
128
|
+
selector: z.string().describe('Selector for the element'),
|
|
129
|
+
timeout: z.number().optional().default(5000).describe('Timeout in milliseconds'),
|
|
130
|
+
}),
|
|
131
|
+
execute: async ({ selector, timeout }: { selector: string; timeout?: number }) => {
|
|
132
|
+
return await browserController.waitFor(selector, timeout);
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
scroll: {
|
|
136
|
+
description: 'Scroll the page up or down',
|
|
137
|
+
parameters: z.object({
|
|
138
|
+
direction: z.enum(['up', 'down']).describe('Scroll direction'),
|
|
139
|
+
amount: z.number().optional().describe('Scroll amount in pixels (default: 500)'),
|
|
140
|
+
}),
|
|
141
|
+
execute: async ({ direction, amount }: { direction: 'up' | 'down'; amount?: number }) => {
|
|
142
|
+
return await browserController.scroll(direction, amount);
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
evaluate_js: {
|
|
146
|
+
description: 'Execute JavaScript code in the browser context',
|
|
147
|
+
parameters: z.object({
|
|
148
|
+
code: z.string().describe('JavaScript code to execute'),
|
|
149
|
+
}),
|
|
150
|
+
execute: async ({ code }: { code: string }) => {
|
|
151
|
+
return await browserController.evaluateJs(code);
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
accessibility_snapshot: {
|
|
155
|
+
description: 'Capture an accessibility tree snapshot for analysis',
|
|
156
|
+
parameters: z.object({}),
|
|
157
|
+
execute: async () => {
|
|
158
|
+
return await browserController.accessibilitySnapshot();
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import type { Browser, BrowserContext, Page } from 'playwright';
|
|
2
|
+
import { chromium } from 'playwright';
|
|
3
|
+
import type { ScreenshotEntry } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export interface BrowserControllerConfig {
|
|
6
|
+
headless?: boolean;
|
|
7
|
+
viewport: { width: number; height: number };
|
|
8
|
+
enableTrace?: boolean;
|
|
9
|
+
traceDir?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class BrowserController {
|
|
13
|
+
private browser: Browser | null = null;
|
|
14
|
+
private context: BrowserContext | null = null;
|
|
15
|
+
private page: Page | null = null;
|
|
16
|
+
private screenshotHistory: ScreenshotEntry[] = [];
|
|
17
|
+
private config: BrowserControllerConfig;
|
|
18
|
+
|
|
19
|
+
constructor(config: BrowserControllerConfig) {
|
|
20
|
+
this.config = config;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async launch(): Promise<void> {
|
|
24
|
+
this.browser = await chromium.launch({
|
|
25
|
+
headless: this.config.headless ?? true,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
this.context = await this.browser.newContext({
|
|
29
|
+
viewport: this.config.viewport,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (this.config.enableTrace) {
|
|
33
|
+
await this.context.tracing.start({
|
|
34
|
+
screenshots: true,
|
|
35
|
+
snapshots: true,
|
|
36
|
+
sources: true,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.page = await this.context.newPage();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async close(): Promise<void> {
|
|
44
|
+
if (this.config.enableTrace && this.context) {
|
|
45
|
+
const tracePath = `${this.config.traceDir}/trace-${Date.now()}.zip`;
|
|
46
|
+
await this.context.tracing.stop({ path: tracePath });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await this.context?.close();
|
|
50
|
+
await this.browser?.close();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async navigate(url: string): Promise<{ success: boolean; message: string }> {
|
|
54
|
+
if (!this.page) throw new Error('Browser not launched');
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
58
|
+
return { success: true, message: `Navigated to ${url}` };
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
message: `Failed to navigate to ${url}: ${error instanceof Error ? error.message : String(error)}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async click(selector: string): Promise<{ success: boolean; message: string }> {
|
|
68
|
+
if (!this.page) throw new Error('Browser not launched');
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
// Try intelligent locator strategies
|
|
72
|
+
let element = null;
|
|
73
|
+
|
|
74
|
+
// Strategy 1: getByRole
|
|
75
|
+
if (selector.includes('role:')) {
|
|
76
|
+
const role = selector.replace('role:', '');
|
|
77
|
+
element = this.page.getByRole(role as any);
|
|
78
|
+
}
|
|
79
|
+
// Strategy 2: getByText
|
|
80
|
+
else if (selector.includes('text:')) {
|
|
81
|
+
const text = selector.replace('text:', '');
|
|
82
|
+
element = this.page.getByText(text);
|
|
83
|
+
}
|
|
84
|
+
// Strategy 3: CSS/XPath
|
|
85
|
+
else {
|
|
86
|
+
element = this.page.locator(selector);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await element.click({ timeout: 10000 });
|
|
90
|
+
return { success: true, message: `Clicked ${selector}` };
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// Retry with alternative strategies
|
|
93
|
+
try {
|
|
94
|
+
await this.page.locator(selector).first().click({ timeout: 5000 });
|
|
95
|
+
return { success: true, message: `Clicked ${selector} (fallback)` };
|
|
96
|
+
} catch {
|
|
97
|
+
return {
|
|
98
|
+
success: false,
|
|
99
|
+
message: `Failed to click ${selector}: ${error instanceof Error ? error.message : String(error)}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async fill(selector: string, value: string): Promise<{ success: boolean; message: string }> {
|
|
106
|
+
if (!this.page) throw new Error('Browser not launched');
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await this.page.locator(selector).fill(value, { timeout: 10000 });
|
|
110
|
+
return { success: true, message: `Filled ${selector} with "${value}"` };
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return {
|
|
113
|
+
success: false,
|
|
114
|
+
message: `Failed to fill ${selector}: ${error instanceof Error ? error.message : String(error)}`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async type(selector: string, text: string): Promise<{ success: boolean; message: string }> {
|
|
120
|
+
if (!this.page) throw new Error('Browser not launched');
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
await this.page.locator(selector).pressSequentially(text, { timeout: 10000 });
|
|
124
|
+
return { success: true, message: `Typed "${text}" into ${selector}` };
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return {
|
|
127
|
+
success: false,
|
|
128
|
+
message: `Failed to type into ${selector}: ${error instanceof Error ? error.message : String(error)}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async screenshot(name: string, context?: string): Promise<{ success: boolean; message: string; screenshotName: string }> {
|
|
134
|
+
if (!this.page) throw new Error('Browser not launched');
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const buffer = await this.page.screenshot({ fullPage: false });
|
|
138
|
+
const url = this.page.url();
|
|
139
|
+
|
|
140
|
+
this.screenshotHistory.push({
|
|
141
|
+
timestamp: Date.now(),
|
|
142
|
+
name,
|
|
143
|
+
buffer,
|
|
144
|
+
context: context || `Screenshot: ${name}`,
|
|
145
|
+
url,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return { success: true, message: `Captured screenshot: ${name}`, screenshotName: name };
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return {
|
|
151
|
+
success: false,
|
|
152
|
+
message: `Failed to capture screenshot: ${error instanceof Error ? error.message : String(error)}`,
|
|
153
|
+
screenshotName: name,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async assertVisible(selector: string): Promise<{ success: boolean; message: string }> {
|
|
159
|
+
if (!this.page) throw new Error('Browser not launched');
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const element = this.page.locator(selector);
|
|
163
|
+
await element.waitFor({ state: 'visible', timeout: 10000 });
|
|
164
|
+
const isVisible = await element.isVisible();
|
|
165
|
+
|
|
166
|
+
if (isVisible) {
|
|
167
|
+
return { success: true, message: `Element ${selector} is visible` };
|
|
168
|
+
} else {
|
|
169
|
+
return { success: false, message: `Element ${selector} is not visible` };
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return {
|
|
173
|
+
success: false,
|
|
174
|
+
message: `Element ${selector} not found or not visible: ${error instanceof Error ? error.message : String(error)}`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async assertText(selector: string, expected: string): Promise<{ success: boolean; message: string }> {
|
|
180
|
+
if (!this.page) throw new Error('Browser not launched');
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const element = this.page.locator(selector);
|
|
184
|
+
const actual = await element.textContent();
|
|
185
|
+
|
|
186
|
+
if (actual?.includes(expected)) {
|
|
187
|
+
return { success: true, message: `Text "${expected}" found in ${selector}` };
|
|
188
|
+
} else {
|
|
189
|
+
return {
|
|
190
|
+
success: false,
|
|
191
|
+
message: `Expected "${expected}" but got "${actual}" in ${selector}`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
} catch (error) {
|
|
195
|
+
return {
|
|
196
|
+
success: false,
|
|
197
|
+
message: `Failed to assert text in ${selector}: ${error instanceof Error ? error.message : String(error)}`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async waitFor(selector: string, timeout: number = 5000): Promise<{ success: boolean; message: string }> {
|
|
203
|
+
if (!this.page) throw new Error('Browser not launched');
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await this.page.locator(selector).waitFor({ state: 'visible', timeout });
|
|
207
|
+
return { success: true, message: `Element ${selector} appeared` };
|
|
208
|
+
} catch (error) {
|
|
209
|
+
return {
|
|
210
|
+
success: false,
|
|
211
|
+
message: `Element ${selector} did not appear within ${timeout}ms`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async getText(selector: string): Promise<{ success: boolean; message: string; text?: string }> {
|
|
217
|
+
if (!this.page) throw new Error('Browser not launched');
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const text = await this.page.locator(selector).textContent();
|
|
221
|
+
return { success: true, message: `Got text from ${selector}`, text: text || '' };
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return {
|
|
224
|
+
success: false,
|
|
225
|
+
message: `Failed to get text from ${selector}: ${error instanceof Error ? error.message : String(error)}`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async scroll(direction: 'up' | 'down', amount?: number): Promise<{ success: boolean; message: string }> {
|
|
231
|
+
if (!this.page) throw new Error('Browser not launched');
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const delta = amount || 500;
|
|
235
|
+
const scrollAmount = direction === 'down' ? delta : -delta;
|
|
236
|
+
|
|
237
|
+
await this.page.evaluate((scroll) => {
|
|
238
|
+
window.scrollBy(0, scroll);
|
|
239
|
+
}, scrollAmount);
|
|
240
|
+
|
|
241
|
+
return { success: true, message: `Scrolled ${direction} by ${Math.abs(scrollAmount)}px` };
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return {
|
|
244
|
+
success: false,
|
|
245
|
+
message: `Failed to scroll: ${error instanceof Error ? error.message : String(error)}`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async evaluateJs(code: string): Promise<{ success: boolean; message: string; result?: any }> {
|
|
251
|
+
if (!this.page) throw new Error('Browser not launched');
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const result = await this.page.evaluate((jsCode) => {
|
|
255
|
+
return eval(jsCode);
|
|
256
|
+
}, code);
|
|
257
|
+
|
|
258
|
+
return { success: true, message: 'JavaScript executed', result };
|
|
259
|
+
} catch (error) {
|
|
260
|
+
return {
|
|
261
|
+
success: false,
|
|
262
|
+
message: `Failed to execute JavaScript: ${error instanceof Error ? error.message : String(error)}`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async accessibilitySnapshot(): Promise<{ success: boolean; message: string; snapshot?: any }> {
|
|
268
|
+
if (!this.page) throw new Error('Browser not launched');
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
// Get ARIA tree via evaluate
|
|
272
|
+
const snapshot = await this.page.evaluate(() => {
|
|
273
|
+
const getAriaTree = (element: Element): any => {
|
|
274
|
+
const role = element.getAttribute('role');
|
|
275
|
+
const ariaLabel = element.getAttribute('aria-label');
|
|
276
|
+
const ariaLabelledby = element.getAttribute('aria-labelledby');
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
role: role || element.tagName.toLowerCase(),
|
|
280
|
+
name: ariaLabel || ariaLabelledby || element.textContent?.substring(0, 50),
|
|
281
|
+
children: Array.from(element.children).map((child) => getAriaTree(child)),
|
|
282
|
+
};
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
return getAriaTree(document.body);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
return { success: true, message: 'Accessibility snapshot captured', snapshot };
|
|
289
|
+
} catch (error) {
|
|
290
|
+
return {
|
|
291
|
+
success: false,
|
|
292
|
+
message: `Failed to capture accessibility snapshot: ${error instanceof Error ? error.message : String(error)}`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Screenshot history access methods
|
|
298
|
+
getLatestScreenshot(): ScreenshotEntry | null {
|
|
299
|
+
return this.screenshotHistory[this.screenshotHistory.length - 1] || null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
getScreenshotByName(name: string): ScreenshotEntry | null {
|
|
303
|
+
return this.screenshotHistory.find((s) => s.name === name) || null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
getAllScreenshots(): ScreenshotEntry[] {
|
|
307
|
+
return [...this.screenshotHistory];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
getCurrentUrl(): string {
|
|
311
|
+
return this.page?.url() || '';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
getViewport(): { width: number; height: number } {
|
|
315
|
+
return this.config.viewport;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
getScreenshotCount(): number {
|
|
319
|
+
return this.screenshotHistory.length;
|
|
320
|
+
}
|
|
321
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Main exports
|
|
2
|
+
export { QaRunner } from './runner.js';
|
|
3
|
+
|
|
4
|
+
// Type exports
|
|
5
|
+
export type {
|
|
6
|
+
QaScenario,
|
|
7
|
+
QaFinding,
|
|
8
|
+
QaArtifact,
|
|
9
|
+
QaReport,
|
|
10
|
+
QaProgressEvent,
|
|
11
|
+
QaRunnerConfig,
|
|
12
|
+
ScreenshotEntry,
|
|
13
|
+
} from './types.js';
|
|
14
|
+
|
|
15
|
+
// Component exports (for advanced usage)
|
|
16
|
+
export { BrowserController } from './browser/controller.js';
|
|
17
|
+
export { VisionAnalyzer } from './vision/analyzer.js';
|
|
18
|
+
export { AgentLoop } from './agent/loop.js';
|
|
19
|
+
export { ReportBuilder } from './report/builder.js';
|