@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.
Files changed (67) hide show
  1. package/dist/agent/context/codecs/browser-state.codec.d.ts +33 -0
  2. package/dist/agent/context/codecs/browser-state.codec.d.ts.map +1 -0
  3. package/dist/agent/context/codecs/browser-state.codec.js +46 -0
  4. package/dist/agent/context/codecs/index.d.ts +3 -0
  5. package/dist/agent/context/codecs/index.d.ts.map +1 -0
  6. package/dist/agent/context/codecs/index.js +2 -0
  7. package/dist/agent/context/codecs/qa-scenario.codec.d.ts +21 -0
  8. package/dist/agent/context/codecs/qa-scenario.codec.d.ts.map +1 -0
  9. package/dist/agent/context/codecs/qa-scenario.codec.js +44 -0
  10. package/dist/agent/context/index.d.ts +4 -0
  11. package/dist/agent/context/index.d.ts.map +1 -0
  12. package/dist/agent/context/index.js +3 -0
  13. package/dist/agent/context/qa-context.service.d.ts +24 -0
  14. package/dist/agent/context/qa-context.service.d.ts.map +1 -0
  15. package/dist/agent/context/qa-context.service.js +53 -0
  16. package/dist/agent/context/qa-policy.d.ts +24 -0
  17. package/dist/agent/context/qa-policy.d.ts.map +1 -0
  18. package/dist/agent/context/qa-policy.js +47 -0
  19. package/dist/agent/loop.d.ts +28 -0
  20. package/dist/agent/loop.d.ts.map +1 -0
  21. package/dist/agent/loop.js +111 -0
  22. package/dist/agent/system-prompt.d.ts +2 -0
  23. package/dist/agent/system-prompt.d.ts.map +1 -0
  24. package/dist/agent/system-prompt.js +39 -0
  25. package/dist/agent/tools/index.d.ts +5 -0
  26. package/dist/agent/tools/index.d.ts.map +1 -0
  27. package/dist/agent/tools/index.js +152 -0
  28. package/dist/browser/controller.d.ts +82 -0
  29. package/dist/browser/controller.d.ts.map +1 -0
  30. package/dist/browser/controller.js +289 -0
  31. package/dist/index.d.ts +7 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +7 -0
  34. package/dist/report/builder.d.ts +23 -0
  35. package/dist/report/builder.d.ts.map +1 -0
  36. package/dist/report/builder.js +69 -0
  37. package/dist/runner.d.ts +10 -0
  38. package/dist/runner.d.ts.map +1 -0
  39. package/dist/runner.js +143 -0
  40. package/dist/types.d.ts +69 -0
  41. package/dist/types.d.ts.map +1 -0
  42. package/dist/types.js +1 -0
  43. package/dist/vision/analyzer.d.ts +8 -0
  44. package/dist/vision/analyzer.d.ts.map +1 -0
  45. package/dist/vision/analyzer.js +49 -0
  46. package/dist/vision/prompts.d.ts +2 -0
  47. package/dist/vision/prompts.d.ts.map +1 -0
  48. package/dist/vision/prompts.js +28 -0
  49. package/package.json +49 -0
  50. package/src/agent/context/codecs/browser-state.codec.ts +57 -0
  51. package/src/agent/context/codecs/index.ts +2 -0
  52. package/src/agent/context/codecs/qa-scenario.codec.ts +55 -0
  53. package/src/agent/context/index.ts +3 -0
  54. package/src/agent/context/qa-context.service.ts +90 -0
  55. package/src/agent/context/qa-policy.ts +54 -0
  56. package/src/agent/loop.ts +147 -0
  57. package/src/agent/system-prompt.ts +39 -0
  58. package/src/agent/tools/index.ts +162 -0
  59. package/src/browser/controller.ts +321 -0
  60. package/src/index.ts +19 -0
  61. package/src/report/builder.ts +94 -0
  62. package/src/runner.ts +166 -0
  63. package/src/types.ts +68 -0
  64. package/src/vision/analyzer.ts +61 -0
  65. package/src/vision/prompts.ts +28 -0
  66. package/tsconfig.json +20 -0
  67. 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';