musubi-sdd 2.2.0 → 3.0.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.
@@ -0,0 +1,255 @@
1
+ /**
2
+ * @fileoverview AI Comparator for Screenshot Comparison
3
+ * @module agents/browser/ai-comparator
4
+ */
5
+
6
+ const fs = require('fs-extra');
7
+ const path = require('path');
8
+
9
+ /**
10
+ * @typedef {Object} ComparisonResult
11
+ * @property {boolean} passed - Whether comparison passed
12
+ * @property {number} similarity - Similarity percentage (0-100)
13
+ * @property {Array<string>} differences - List of differences found
14
+ * @property {Object} details - Detailed comparison info
15
+ */
16
+
17
+ /**
18
+ * AI Comparator - Compares screenshots using AI vision models
19
+ */
20
+ class AIComparator {
21
+ /**
22
+ * Create a new AIComparator instance
23
+ * @param {Object} options - Configuration options
24
+ * @param {string} [options.model='gpt-4-vision-preview'] - Vision model to use
25
+ * @param {number} [options.threshold=0.95] - Similarity threshold (0-1)
26
+ * @param {string} [options.apiKey] - API key for the vision service
27
+ */
28
+ constructor(options = {}) {
29
+ this.model = options.model || 'gpt-4-vision-preview';
30
+ this.threshold = options.threshold || 0.95;
31
+ this.apiKey = options.apiKey || process.env.OPENAI_API_KEY;
32
+ }
33
+
34
+ /**
35
+ * Compare two screenshots
36
+ * @param {string} expectedPath - Path to expected screenshot
37
+ * @param {string} actualPath - Path to actual screenshot
38
+ * @param {Object} options - Comparison options
39
+ * @param {number} [options.threshold] - Override threshold
40
+ * @param {string} [options.description] - What to verify
41
+ * @returns {Promise<ComparisonResult>}
42
+ */
43
+ async compare(expectedPath, actualPath, options = {}) {
44
+ const threshold = options.threshold || this.threshold;
45
+ const description = options.description || 'Compare visual appearance';
46
+
47
+ // Check if files exist
48
+ if (!await fs.pathExists(expectedPath)) {
49
+ throw new Error(`Expected screenshot not found: ${expectedPath}`);
50
+ }
51
+ if (!await fs.pathExists(actualPath)) {
52
+ throw new Error(`Actual screenshot not found: ${actualPath}`);
53
+ }
54
+
55
+ // If no API key, use fallback comparison
56
+ if (!this.apiKey) {
57
+ return this.fallbackCompare(expectedPath, actualPath, threshold);
58
+ }
59
+
60
+ try {
61
+ // Read images as base64
62
+ const expectedBase64 = await this.imageToBase64(expectedPath);
63
+ const actualBase64 = await this.imageToBase64(actualPath);
64
+
65
+ // Call Vision API
66
+ const result = await this.callVisionAPI(expectedBase64, actualBase64, description);
67
+
68
+ return {
69
+ passed: result.similarity >= threshold * 100,
70
+ similarity: result.similarity,
71
+ differences: result.differences || [],
72
+ details: result.details || {},
73
+ threshold: threshold * 100,
74
+ };
75
+ } catch (error) {
76
+ console.warn(`Vision API error, using fallback: ${error.message}`);
77
+ return this.fallbackCompare(expectedPath, actualPath, threshold);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Convert image file to base64
83
+ * @param {string} imagePath
84
+ * @returns {Promise<string>}
85
+ */
86
+ async imageToBase64(imagePath) {
87
+ const buffer = await fs.readFile(imagePath);
88
+ return buffer.toString('base64');
89
+ }
90
+
91
+ /**
92
+ * Call the Vision API for comparison
93
+ * @param {string} expectedBase64
94
+ * @param {string} actualBase64
95
+ * @param {string} description
96
+ * @returns {Promise<Object>}
97
+ */
98
+ async callVisionAPI(expectedBase64, actualBase64, description) {
99
+ // OpenAI API call
100
+ const response = await fetch('https://api.openai.com/v1/chat/completions', {
101
+ method: 'POST',
102
+ headers: {
103
+ 'Content-Type': 'application/json',
104
+ 'Authorization': `Bearer ${this.apiKey}`,
105
+ },
106
+ body: JSON.stringify({
107
+ model: this.model,
108
+ messages: [
109
+ {
110
+ role: 'system',
111
+ content: `You are an expert at comparing web page screenshots.
112
+ Analyze the two images and provide:
113
+ 1. A similarity score from 0-100
114
+ 2. A list of visual differences
115
+ 3. Whether this is a critical difference
116
+
117
+ Respond in JSON format:
118
+ {
119
+ "similarity": <number 0-100>,
120
+ "differences": ["difference 1", "difference 2"],
121
+ "critical": <boolean>,
122
+ "details": { "layout": "...", "content": "...", "style": "..." }
123
+ }`,
124
+ },
125
+ {
126
+ role: 'user',
127
+ content: [
128
+ {
129
+ type: 'text',
130
+ text: `Compare these two screenshots. ${description}`,
131
+ },
132
+ {
133
+ type: 'image_url',
134
+ image_url: {
135
+ url: `data:image/png;base64,${expectedBase64}`,
136
+ detail: 'high',
137
+ },
138
+ },
139
+ {
140
+ type: 'image_url',
141
+ image_url: {
142
+ url: `data:image/png;base64,${actualBase64}`,
143
+ detail: 'high',
144
+ },
145
+ },
146
+ ],
147
+ },
148
+ ],
149
+ max_tokens: 1000,
150
+ }),
151
+ });
152
+
153
+ if (!response.ok) {
154
+ throw new Error(`Vision API error: ${response.status}`);
155
+ }
156
+
157
+ const data = await response.json();
158
+ const content = data.choices[0]?.message?.content;
159
+
160
+ try {
161
+ // Try to parse JSON from the response
162
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
163
+ if (jsonMatch) {
164
+ return JSON.parse(jsonMatch[0]);
165
+ }
166
+ } catch (e) {
167
+ // Parsing failed
168
+ }
169
+
170
+ // Return a default result if parsing failed
171
+ return {
172
+ similarity: 50,
173
+ differences: ['Unable to parse comparison result'],
174
+ critical: true,
175
+ details: { raw: content },
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Fallback comparison when Vision API is not available
181
+ * Uses file size comparison as a basic heuristic
182
+ * @param {string} expectedPath
183
+ * @param {string} actualPath
184
+ * @param {number} threshold
185
+ * @returns {Promise<ComparisonResult>}
186
+ */
187
+ async fallbackCompare(expectedPath, actualPath, threshold) {
188
+ const [expectedStat, actualStat] = await Promise.all([
189
+ fs.stat(expectedPath),
190
+ fs.stat(actualPath),
191
+ ]);
192
+
193
+ // Calculate size difference
194
+ const sizeDiff = Math.abs(expectedStat.size - actualStat.size);
195
+ const maxSize = Math.max(expectedStat.size, actualStat.size);
196
+ const sizeRatio = 1 - (sizeDiff / maxSize);
197
+
198
+ // Simple heuristic: if sizes are similar, likely similar images
199
+ // This is a rough approximation and should be replaced with actual image comparison
200
+ const similarity = Math.round(sizeRatio * 100);
201
+
202
+ return {
203
+ passed: similarity >= threshold * 100,
204
+ similarity,
205
+ differences: similarity < threshold * 100
206
+ ? [`File size differs by ${sizeDiff} bytes (${((1 - sizeRatio) * 100).toFixed(1)}%)`]
207
+ : [],
208
+ details: {
209
+ method: 'fallback-size-comparison',
210
+ expectedSize: expectedStat.size,
211
+ actualSize: actualStat.size,
212
+ note: 'Using fallback comparison. Set OPENAI_API_KEY for AI-powered comparison.',
213
+ },
214
+ threshold: threshold * 100,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Generate a comparison report
220
+ * @param {ComparisonResult} result
221
+ * @param {Object} options
222
+ * @returns {string}
223
+ */
224
+ generateReport(result, options = {}) {
225
+ const lines = [
226
+ '# Screenshot Comparison Report',
227
+ '',
228
+ `**Status**: ${result.passed ? '✅ PASSED' : '❌ FAILED'}`,
229
+ `**Similarity**: ${result.similarity}%`,
230
+ `**Threshold**: ${result.threshold}%`,
231
+ '',
232
+ ];
233
+
234
+ if (result.differences.length > 0) {
235
+ lines.push('## Differences Found');
236
+ lines.push('');
237
+ for (const diff of result.differences) {
238
+ lines.push(`- ${diff}`);
239
+ }
240
+ lines.push('');
241
+ }
242
+
243
+ if (result.details) {
244
+ lines.push('## Details');
245
+ lines.push('');
246
+ lines.push('```json');
247
+ lines.push(JSON.stringify(result.details, null, 2));
248
+ lines.push('```');
249
+ }
250
+
251
+ return lines.join('\n');
252
+ }
253
+ }
254
+
255
+ module.exports = AIComparator;
@@ -0,0 +1,207 @@
1
+ /**
2
+ * @fileoverview Context Manager for Browser Automation
3
+ * @module agents/browser/context-manager
4
+ */
5
+
6
+ /**
7
+ * Context Manager - Manages browser contexts, pages, and action history
8
+ */
9
+ class ContextManager {
10
+ constructor() {
11
+ this.browser = null;
12
+ this.contexts = new Map();
13
+ this.pages = new Map();
14
+ this.activeContextName = 'default';
15
+ this.actionHistory = [];
16
+ }
17
+
18
+ /**
19
+ * Initialize the context manager with a browser instance
20
+ * @param {import('playwright').Browser} browser
21
+ */
22
+ async initialize(browser) {
23
+ this.browser = browser;
24
+ await this.createContext('default');
25
+ }
26
+
27
+ /**
28
+ * Create a new browser context
29
+ * @param {string} name - Context name
30
+ * @param {Object} options - Context options
31
+ * @returns {Promise<import('playwright').BrowserContext>}
32
+ */
33
+ async createContext(name, options = {}) {
34
+ if (!this.browser) {
35
+ throw new Error('Browser not initialized');
36
+ }
37
+
38
+ const context = await this.browser.newContext({
39
+ viewport: options.viewport || { width: 1280, height: 720 },
40
+ userAgent: options.userAgent,
41
+ locale: options.locale || 'ja-JP',
42
+ timezoneId: options.timezoneId || 'Asia/Tokyo',
43
+ ...options,
44
+ });
45
+
46
+ this.contexts.set(name, context);
47
+ return context;
48
+ }
49
+
50
+ /**
51
+ * Get an existing context or create a new one
52
+ * @param {string} name - Context name
53
+ * @returns {Promise<import('playwright').BrowserContext>}
54
+ */
55
+ async getOrCreateContext(name = 'default') {
56
+ if (this.contexts.has(name)) {
57
+ return this.contexts.get(name);
58
+ }
59
+ return this.createContext(name);
60
+ }
61
+
62
+ /**
63
+ * Get an existing page or create a new one
64
+ * @param {string} contextName - Context name
65
+ * @returns {Promise<import('playwright').Page>}
66
+ */
67
+ async getOrCreatePage(contextName = 'default') {
68
+ const pageKey = `${contextName}:main`;
69
+
70
+ if (this.pages.has(pageKey)) {
71
+ return this.pages.get(pageKey);
72
+ }
73
+
74
+ const context = await this.getOrCreateContext(contextName);
75
+ const page = await context.newPage();
76
+
77
+ this.pages.set(pageKey, page);
78
+ return page;
79
+ }
80
+
81
+ /**
82
+ * Create a new page in a context
83
+ * @param {string} contextName - Context name
84
+ * @param {string} pageName - Page name
85
+ * @returns {Promise<import('playwright').Page>}
86
+ */
87
+ async createPage(contextName = 'default', pageName) {
88
+ const context = await this.getOrCreateContext(contextName);
89
+ const page = await context.newPage();
90
+
91
+ const pageKey = `${contextName}:${pageName}`;
92
+ this.pages.set(pageKey, page);
93
+
94
+ return page;
95
+ }
96
+
97
+ /**
98
+ * Get a specific page
99
+ * @param {string} contextName - Context name
100
+ * @param {string} pageName - Page name
101
+ * @returns {import('playwright').Page|undefined}
102
+ */
103
+ getPage(contextName = 'default', pageName = 'main') {
104
+ const pageKey = `${contextName}:${pageName}`;
105
+ return this.pages.get(pageKey);
106
+ }
107
+
108
+ /**
109
+ * Switch active context
110
+ * @param {string} name - Context name
111
+ */
112
+ setActiveContext(name) {
113
+ if (!this.contexts.has(name)) {
114
+ throw new Error(`Context "${name}" does not exist`);
115
+ }
116
+ this.activeContextName = name;
117
+ }
118
+
119
+ /**
120
+ * Get the active context name
121
+ * @returns {string}
122
+ */
123
+ getActiveContextName() {
124
+ return this.activeContextName;
125
+ }
126
+
127
+ /**
128
+ * Record an action to history
129
+ * @param {Object} action - Action object
130
+ * @param {Object} result - Action result
131
+ */
132
+ recordAction(action, result) {
133
+ this.actionHistory.push({
134
+ action,
135
+ result,
136
+ timestamp: Date.now(),
137
+ context: this.activeContextName,
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Get action history
143
+ * @returns {Array}
144
+ */
145
+ getActionHistory() {
146
+ return [...this.actionHistory];
147
+ }
148
+
149
+ /**
150
+ * Clear action history
151
+ */
152
+ clearHistory() {
153
+ this.actionHistory = [];
154
+ }
155
+
156
+ /**
157
+ * Close a specific context
158
+ * @param {string} name - Context name
159
+ */
160
+ async closeContext(name) {
161
+ const context = this.contexts.get(name);
162
+ if (context) {
163
+ await context.close();
164
+ this.contexts.delete(name);
165
+
166
+ // Remove associated pages
167
+ for (const [key, page] of this.pages.entries()) {
168
+ if (key.startsWith(`${name}:`)) {
169
+ this.pages.delete(key);
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Reset the context manager
177
+ */
178
+ reset() {
179
+ this.contexts.clear();
180
+ this.pages.clear();
181
+ this.activeContextName = 'default';
182
+ this.actionHistory = [];
183
+ this.browser = null;
184
+ }
185
+
186
+ /**
187
+ * Get all context names
188
+ * @returns {string[]}
189
+ */
190
+ getContextNames() {
191
+ return [...this.contexts.keys()];
192
+ }
193
+
194
+ /**
195
+ * Get all page names for a context
196
+ * @param {string} contextName
197
+ * @returns {string[]}
198
+ */
199
+ getPageNames(contextName = 'default') {
200
+ const prefix = `${contextName}:`;
201
+ return [...this.pages.keys()]
202
+ .filter(key => key.startsWith(prefix))
203
+ .map(key => key.slice(prefix.length));
204
+ }
205
+ }
206
+
207
+ module.exports = ContextManager;