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.
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/bin/musubi-browser.js +457 -0
- package/bin/musubi-convert.js +179 -0
- package/bin/musubi-gui.js +270 -0
- package/package.json +13 -3
- package/src/agents/browser/action-executor.js +255 -0
- package/src/agents/browser/ai-comparator.js +255 -0
- package/src/agents/browser/context-manager.js +207 -0
- package/src/agents/browser/index.js +265 -0
- package/src/agents/browser/nl-parser.js +408 -0
- package/src/agents/browser/screenshot.js +174 -0
- package/src/agents/browser/test-generator.js +271 -0
- package/src/converters/index.js +285 -0
- package/src/converters/ir/types.js +508 -0
- package/src/converters/parsers/musubi-parser.js +759 -0
- package/src/converters/parsers/speckit-parser.js +1001 -0
- package/src/converters/writers/musubi-writer.js +808 -0
- package/src/converters/writers/speckit-writer.js +718 -0
- package/src/gui/public/index.html +856 -0
- package/src/gui/server.js +352 -0
- package/src/gui/services/file-watcher.js +119 -0
- package/src/gui/services/index.js +16 -0
- package/src/gui/services/project-scanner.js +547 -0
- package/src/gui/services/traceability-service.js +372 -0
- package/src/gui/services/workflow-service.js +242 -0
- package/src/templates/skills/browser-agent.md +164 -0
- package/src/templates/skills/web-gui.md +188 -0
|
@@ -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;
|