mycontext-cli 4.0.0 → 4.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/README.md +56 -114
- package/dist/cli.js +67 -0
- package/dist/cli.js.map +1 -1
- package/dist/clients/MyContextAIClient.d.ts.map +1 -1
- package/dist/clients/MyContextAIClient.js +12 -0
- package/dist/clients/MyContextAIClient.js.map +1 -1
- package/dist/commands/agent.d.ts +22 -0
- package/dist/commands/agent.d.ts.map +1 -0
- package/dist/commands/agent.js +245 -0
- package/dist/commands/agent.js.map +1 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +18 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +0 -9
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/sync-readme.d.ts +14 -0
- package/dist/commands/sync-readme.d.ts.map +1 -0
- package/dist/commands/sync-readme.js +131 -0
- package/dist/commands/sync-readme.js.map +1 -0
- package/dist/commands/test.d.ts +76 -0
- package/dist/commands/test.d.ts.map +1 -0
- package/dist/commands/test.js +361 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/mcp/browser-test-runner.d.ts +89 -0
- package/dist/mcp/browser-test-runner.d.ts.map +1 -0
- package/dist/mcp/browser-test-runner.js +786 -0
- package/dist/mcp/browser-test-runner.js.map +1 -0
- package/dist/mcp/test-mission-manager.d.ts +82 -0
- package/dist/mcp/test-mission-manager.d.ts.map +1 -0
- package/dist/mcp/test-mission-manager.js +327 -0
- package/dist/mcp/test-mission-manager.js.map +1 -0
- package/dist/mcp/test-reporter.d.ts +54 -0
- package/dist/mcp/test-reporter.d.ts.map +1 -0
- package/dist/mcp/test-reporter.js +358 -0
- package/dist/mcp/test-reporter.js.map +1 -0
- package/dist/mcp/testing-server.d.ts +36 -0
- package/dist/mcp/testing-server.d.ts.map +1 -0
- package/dist/mcp/testing-server.js +516 -0
- package/dist/mcp/testing-server.js.map +1 -0
- package/dist/package.json +6 -2
- package/dist/services/ContextService.d.ts +38 -0
- package/dist/services/ContextService.d.ts.map +1 -0
- package/dist/services/ContextService.js +104 -0
- package/dist/services/ContextService.js.map +1 -0
- package/dist/services/ProbeManager.d.ts +32 -0
- package/dist/services/ProbeManager.d.ts.map +1 -0
- package/dist/services/ProbeManager.js +116 -0
- package/dist/services/ProbeManager.js.map +1 -0
- package/dist/types/design-pipeline.d.ts +6 -0
- package/dist/types/design-pipeline.d.ts.map +1 -1
- package/dist/types/flow-testing.d.ts +179 -0
- package/dist/types/flow-testing.d.ts.map +1 -0
- package/dist/types/flow-testing.js +7 -0
- package/dist/types/flow-testing.js.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/designManifestManager.d.ts +4 -0
- package/dist/utils/designManifestManager.d.ts.map +1 -1
- package/dist/utils/designManifestManager.js +161 -0
- package/dist/utils/designManifestManager.js.map +1 -1
- package/dist/utils/githubModelsClient.d.ts.map +1 -1
- package/dist/utils/githubModelsClient.js +6 -2
- package/dist/utils/githubModelsClient.js.map +1 -1
- package/dist/utils/mcpTools.d.ts +21 -21
- package/package.json +6 -2
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Browser Test Runner
|
|
4
|
+
*
|
|
5
|
+
* Executes test missions using Playwright + AI-powered navigation.
|
|
6
|
+
* The AI agent autonomously navigates the UI, makes decisions, and validates outcomes.
|
|
7
|
+
*/
|
|
8
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
|
+
if (k2 === undefined) k2 = k;
|
|
10
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
11
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
12
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(o, k2, desc);
|
|
15
|
+
}) : (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
o[k2] = m[k];
|
|
18
|
+
}));
|
|
19
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
20
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
21
|
+
}) : function(o, v) {
|
|
22
|
+
o["default"] = v;
|
|
23
|
+
});
|
|
24
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
25
|
+
var ownKeys = function(o) {
|
|
26
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
27
|
+
var ar = [];
|
|
28
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
29
|
+
return ar;
|
|
30
|
+
};
|
|
31
|
+
return ownKeys(o);
|
|
32
|
+
};
|
|
33
|
+
return function (mod) {
|
|
34
|
+
if (mod && mod.__esModule) return mod;
|
|
35
|
+
var result = {};
|
|
36
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
37
|
+
__setModuleDefault(result, mod);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
})();
|
|
41
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
42
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
43
|
+
};
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
exports.BrowserTestRunner = void 0;
|
|
46
|
+
const playwright_1 = require("playwright");
|
|
47
|
+
const path = __importStar(require("path"));
|
|
48
|
+
const fs = __importStar(require("fs-extra"));
|
|
49
|
+
const uuid_1 = require("uuid");
|
|
50
|
+
const ContextService_1 = require("../services/ContextService");
|
|
51
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
52
|
+
class BrowserTestRunner {
|
|
53
|
+
constructor(projectPath) {
|
|
54
|
+
this.projectPath = projectPath;
|
|
55
|
+
this.screenshotsDir = path.join(projectPath, ".mycontext", "test-screenshots");
|
|
56
|
+
fs.ensureDirSync(this.screenshotsDir);
|
|
57
|
+
// Initialize AI client (will use the project's configured provider)
|
|
58
|
+
this.aiClient = this.initializeAIClient();
|
|
59
|
+
this.contextService = new ContextService_1.ContextService(projectPath);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Initialize AI client from project config
|
|
63
|
+
*/
|
|
64
|
+
initializeAIClient() {
|
|
65
|
+
// Import and initialize the provider chain
|
|
66
|
+
// This will check for available keys (MyContext, OpenAI, Claude, XAI)
|
|
67
|
+
try {
|
|
68
|
+
const { getProviderChain } = require("../clients/ProviderChain");
|
|
69
|
+
return getProviderChain();
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
throw new Error("Failed to initialize AI client. Please configure an AI provider.");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Run a test mission
|
|
77
|
+
*/
|
|
78
|
+
async runTest(mission, config = { headless: true }) {
|
|
79
|
+
const executionId = (0, uuid_1.v4)();
|
|
80
|
+
const startTime = Date.now();
|
|
81
|
+
const steps = [];
|
|
82
|
+
let status = "running";
|
|
83
|
+
let error = null;
|
|
84
|
+
console.log(chalk_1.default.blue(`\n🧪 Running test: ${mission.name}`));
|
|
85
|
+
console.log(chalk_1.default.gray(`Mission: ${mission.mission}`));
|
|
86
|
+
try {
|
|
87
|
+
// Launch browser
|
|
88
|
+
await this.launchBrowser(config);
|
|
89
|
+
// Create new page
|
|
90
|
+
const page = await this.context.newPage();
|
|
91
|
+
// Set viewport if specified
|
|
92
|
+
if (config.viewport) {
|
|
93
|
+
await page.setViewportSize(config.viewport);
|
|
94
|
+
}
|
|
95
|
+
// Start URL (use from config or mission)
|
|
96
|
+
const startUrl = config.baseUrl || mission.sourceFlow || "http://localhost:3000";
|
|
97
|
+
console.log(chalk_1.default.gray(`Starting at: ${startUrl}`));
|
|
98
|
+
await page.goto(startUrl);
|
|
99
|
+
// Take initial screenshot
|
|
100
|
+
const initialScreenshot = await this.takeScreenshot(page, executionId, "initial");
|
|
101
|
+
// Execute mission using AI
|
|
102
|
+
const executionSteps = await this.executeWithAI(page, mission, executionId);
|
|
103
|
+
steps.push(...executionSteps);
|
|
104
|
+
// Validate results
|
|
105
|
+
const validationResults = await this.validateMission(page, mission, executionId);
|
|
106
|
+
// Determine final status
|
|
107
|
+
const allValidationsPassed = validationResults.every((v) => v.passed);
|
|
108
|
+
status = allValidationsPassed ? "passed" : "failed";
|
|
109
|
+
// Take final screenshot
|
|
110
|
+
const finalScreenshot = await this.takeScreenshot(page, executionId, "final");
|
|
111
|
+
console.log(status === "passed"
|
|
112
|
+
? chalk_1.default.green(`✅ Test passed!`)
|
|
113
|
+
: chalk_1.default.red(`❌ Test failed!`));
|
|
114
|
+
const endTime = Date.now();
|
|
115
|
+
// Milestone 4 & 5: State Attestation & Zero-Drift Synthesis
|
|
116
|
+
const successfulGravityChecks = steps.filter(s => s.success && !s.action.startsWith('REJECTED')).length;
|
|
117
|
+
const totalGravityAttempts = steps.length;
|
|
118
|
+
const narrativeCompliance = totalGravityAttempts > 0 ? successfulGravityChecks / totalGravityAttempts : 1.0;
|
|
119
|
+
const driftAlerts = [];
|
|
120
|
+
if (status === 'failed') {
|
|
121
|
+
driftAlerts.push({
|
|
122
|
+
type: 'objective',
|
|
123
|
+
severity: 'high',
|
|
124
|
+
message: 'Final state does not match expected outcome',
|
|
125
|
+
expected: mission.expectedOutcome,
|
|
126
|
+
actual: 'Validation failed on current page',
|
|
127
|
+
timestamp: new Date().toISOString()
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// Check for rejected intents
|
|
131
|
+
steps.filter(s => s.action.startsWith('REJECTED')).forEach(s => {
|
|
132
|
+
driftAlerts.push({
|
|
133
|
+
type: 'logic',
|
|
134
|
+
severity: 'medium',
|
|
135
|
+
message: `Intent rejected by Hard Gravity Engine: ${s.intent}`,
|
|
136
|
+
expected: 'Aligned intent',
|
|
137
|
+
actual: s.intent,
|
|
138
|
+
timestamp: s.timestamp
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
const result = {
|
|
142
|
+
missionId: mission.id,
|
|
143
|
+
executionId,
|
|
144
|
+
status,
|
|
145
|
+
startedAt: new Date(startTime).toISOString(),
|
|
146
|
+
completedAt: new Date(endTime).toISOString(),
|
|
147
|
+
duration: endTime - startTime,
|
|
148
|
+
steps,
|
|
149
|
+
validationResults,
|
|
150
|
+
finalState: {
|
|
151
|
+
url: page.url(),
|
|
152
|
+
screenshot: finalScreenshot,
|
|
153
|
+
dom: await this.getSimplifiedDOM(page),
|
|
154
|
+
},
|
|
155
|
+
aiNotes: await this.generateAINotes(mission, steps, validationResults),
|
|
156
|
+
driftAnalysis: {
|
|
157
|
+
narrativeCompliance,
|
|
158
|
+
alerts: driftAlerts
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
await page.close();
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
console.error(chalk_1.default.red(`❌ Test error: ${err.message}`));
|
|
166
|
+
error = {
|
|
167
|
+
message: err.message,
|
|
168
|
+
stack: err.stack,
|
|
169
|
+
};
|
|
170
|
+
const endTime = Date.now();
|
|
171
|
+
return {
|
|
172
|
+
missionId: mission.id,
|
|
173
|
+
executionId,
|
|
174
|
+
status: "error",
|
|
175
|
+
startedAt: new Date(startTime).toISOString(),
|
|
176
|
+
completedAt: new Date(endTime).toISOString(),
|
|
177
|
+
duration: endTime - startTime,
|
|
178
|
+
steps,
|
|
179
|
+
validationResults: [],
|
|
180
|
+
finalState: {
|
|
181
|
+
url: "",
|
|
182
|
+
screenshot: undefined,
|
|
183
|
+
},
|
|
184
|
+
error,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
await this.closeBrowser();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Execute mission using AI
|
|
193
|
+
*/
|
|
194
|
+
async executeWithAI(page, mission, executionId) {
|
|
195
|
+
const steps = [];
|
|
196
|
+
let stepOrder = 0;
|
|
197
|
+
// Load manifest for gravity (Milestone 2)
|
|
198
|
+
await this.contextService.initialize();
|
|
199
|
+
const manifest = this.contextService.getManifest();
|
|
200
|
+
const primeObjective = manifest?.phases.functional_summary.core_purpose || mission.mission;
|
|
201
|
+
// AI prompt to understand the mission
|
|
202
|
+
const systemPrompt = `You are an AI agent performing UI testing.
|
|
203
|
+
|
|
204
|
+
HARD GRAVITY (PRIME OBJECTIVE):
|
|
205
|
+
"${primeObjective}"
|
|
206
|
+
|
|
207
|
+
Your mission is:
|
|
208
|
+
"${mission.mission}"
|
|
209
|
+
|
|
210
|
+
Expected outcome: "${mission.expectedOutcome}"
|
|
211
|
+
|
|
212
|
+
You can navigate the UI by:
|
|
213
|
+
1. Clicking elements (buttons, links, etc.)
|
|
214
|
+
2. Filling forms
|
|
215
|
+
3. Navigating to URLs
|
|
216
|
+
4. Waiting for elements to appear
|
|
217
|
+
|
|
218
|
+
Analyze the current page and decide what action to take next to accomplish the mission.
|
|
219
|
+
Be specific about selectors (use text content, aria-labels, or role attributes when possible).
|
|
220
|
+
|
|
221
|
+
Current URL: ${page.url()}`;
|
|
222
|
+
// Get page content for AI
|
|
223
|
+
const pageContent = await this.getPageContentForAI(page);
|
|
224
|
+
console.log(chalk_1.default.yellow(`\n🤖 AI analyzing page...`));
|
|
225
|
+
// Main execution loop
|
|
226
|
+
let maxSteps = 20; // Prevent infinite loops
|
|
227
|
+
let missionComplete = false;
|
|
228
|
+
while (!missionComplete && maxSteps > 0) {
|
|
229
|
+
maxSteps--;
|
|
230
|
+
// Ask AI what to do next
|
|
231
|
+
const aiResponse = await this.askAI(systemPrompt, pageContent, steps, mission);
|
|
232
|
+
if (!aiResponse || aiResponse.action === "complete") {
|
|
233
|
+
missionComplete = true;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
// Milestone 2: Hard Gravity Check
|
|
237
|
+
const grounding = await this.contextService.validateIntent(aiResponse.intent || "");
|
|
238
|
+
if (!grounding.valid) {
|
|
239
|
+
console.log(chalk_1.default.red(`\n🛑 GRAVITY INTERVENTION: Intent deviates from Prime Objective!`));
|
|
240
|
+
console.log(chalk_1.default.yellow(`REASON: ${grounding.reason}`));
|
|
241
|
+
// Add a "failed" step to record the deviation
|
|
242
|
+
steps.push({
|
|
243
|
+
id: (0, uuid_1.v4)(),
|
|
244
|
+
order: stepOrder++,
|
|
245
|
+
action: `REJECTED: ${aiResponse.action}`,
|
|
246
|
+
intent: aiResponse.intent,
|
|
247
|
+
timestamp: new Date().toISOString(),
|
|
248
|
+
success: false,
|
|
249
|
+
error: `Gravity Intervention: ${grounding.reason}`
|
|
250
|
+
});
|
|
251
|
+
// Prompt AI to rethink with gravity awareness
|
|
252
|
+
const correctionPrompt = `${systemPrompt}\n\n⚠️ PREVIOUS INTENT REJECTED: "${aiResponse.intent}"\nREASON: ${grounding.reason}\n\nPlease rethink your action to stay anchored to the Prime Objective.`;
|
|
253
|
+
const retryResponse = await this.askAI(correctionPrompt, pageContent, steps, mission);
|
|
254
|
+
if (!retryResponse || retryResponse.intent === aiResponse.intent) {
|
|
255
|
+
console.log(chalk_1.default.red(`❌ AI failed to align with Gravity. Stopping.`));
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
// Swap response for the corrected one
|
|
259
|
+
aiResponse.action = retryResponse.action;
|
|
260
|
+
aiResponse.selector = retryResponse.selector;
|
|
261
|
+
aiResponse.value = retryResponse.value;
|
|
262
|
+
aiResponse.intent = retryResponse.intent;
|
|
263
|
+
}
|
|
264
|
+
// Execute the AI's decision
|
|
265
|
+
const step = await this.executeAction(page, aiResponse, stepOrder++, executionId, mission);
|
|
266
|
+
steps.push(step);
|
|
267
|
+
console.log(chalk_1.default.cyan(` Step ${step.order + 1}: ${step.action}`));
|
|
268
|
+
if (!step.success) {
|
|
269
|
+
console.log(chalk_1.default.red(` Failed: ${step.error}`));
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
// Wait a bit for page to settle
|
|
273
|
+
await page.waitForTimeout(1000);
|
|
274
|
+
// Update page content for next iteration
|
|
275
|
+
const newPageContent = await this.getPageContentForAI(page);
|
|
276
|
+
if (newPageContent === pageContent) {
|
|
277
|
+
// Page hasn't changed, might be stuck
|
|
278
|
+
console.log(chalk_1.default.yellow(` ⚠️ Page unchanged, stopping`));
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return steps;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Ask AI for next action
|
|
286
|
+
*/
|
|
287
|
+
async askAI(systemPrompt, pageContent, previousSteps, mission) {
|
|
288
|
+
try {
|
|
289
|
+
// Prepare test data context
|
|
290
|
+
const testDataContext = mission.testData
|
|
291
|
+
? `
|
|
292
|
+
Available Test Data:
|
|
293
|
+
- CV File: ${mission.testData.cvFileName || 'sample-cv.pdf'}
|
|
294
|
+
- Update Notes: ${mission.testData.updateNotes || 'N/A'}
|
|
295
|
+
- Job Description: ${mission.testData.jobDescription ? 'Available' : 'N/A'}
|
|
296
|
+
`
|
|
297
|
+
: '';
|
|
298
|
+
const prompt = `${systemPrompt}
|
|
299
|
+
|
|
300
|
+
Page Content:
|
|
301
|
+
${pageContent}
|
|
302
|
+
|
|
303
|
+
Previous Steps:
|
|
304
|
+
${previousSteps.map((s) => `${s.order + 1}. ${s.action} - ${s.success ? "success" : "failed"}`).join("\n")}
|
|
305
|
+
${testDataContext}
|
|
306
|
+
|
|
307
|
+
What should be the next action? Respond in JSON format:
|
|
308
|
+
{
|
|
309
|
+
"action": "click|fill|goto|wait|upload|complete",
|
|
310
|
+
"selector": "element selector - use one of these formats:",
|
|
311
|
+
"value": "value for fill/upload action",
|
|
312
|
+
"intent": "why you're taking this action"
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
SELECTOR FORMATS (choose the most specific one that works):
|
|
316
|
+
1. Text-based (preferred for buttons/links): "Button text" or "Link text"
|
|
317
|
+
2. Playwright has-text: "button:has-text('Click me')" or "a:has-text('Learn more')"
|
|
318
|
+
3. Standard CSS: "#id", ".class", "button[type='submit']"
|
|
319
|
+
4. ARIA attributes: "[aria-label='Close']", "[role='button']"
|
|
320
|
+
|
|
321
|
+
EXAMPLES:
|
|
322
|
+
- Click a button: { "action": "click", "selector": "Refresh My CV", "intent": "switching to refresh mode" }
|
|
323
|
+
- Fill input: { "action": "fill", "selector": "input[type='email']", "value": "test@email.com", "intent": "entering email" }
|
|
324
|
+
- Upload file: { "action": "upload", "value": "sample-cv.pdf", "intent": "uploading CV file" }
|
|
325
|
+
- Navigate: { "action": "goto", "value": "/dashboard", "intent": "going to dashboard" }
|
|
326
|
+
- Wait: { "action": "wait", "intent": "waiting for page to load" }
|
|
327
|
+
|
|
328
|
+
IMPORTANT FOR FILE UPLOADS:
|
|
329
|
+
- Use "upload" action with the filename from Available Test Data
|
|
330
|
+
- Don't try to click file inputs - they're often hidden
|
|
331
|
+
- The system will automatically find the file input and upload the file
|
|
332
|
+
|
|
333
|
+
If the mission is complete, respond with { "action": "complete" }`;
|
|
334
|
+
const response = await this.aiClient.generateText(prompt, {
|
|
335
|
+
maxTokens: 500,
|
|
336
|
+
temperature: 0.2, // Lower temperature for more deterministic actions
|
|
337
|
+
});
|
|
338
|
+
// Parse JSON from response
|
|
339
|
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
|
340
|
+
if (jsonMatch) {
|
|
341
|
+
return JSON.parse(jsonMatch[0]);
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
console.error("AI decision error:", error);
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Infer what value to use for a field based on test data
|
|
352
|
+
*/
|
|
353
|
+
inferFieldValue(fieldSelector, aiProvidedValue, mission) {
|
|
354
|
+
// If AI provided a value and it's not a placeholder, use it
|
|
355
|
+
if (aiProvidedValue && !aiProvidedValue.startsWith('[USE ')) {
|
|
356
|
+
return aiProvidedValue;
|
|
357
|
+
}
|
|
358
|
+
// Try to match field to test data
|
|
359
|
+
if (mission.testData) {
|
|
360
|
+
const testData = mission.testData;
|
|
361
|
+
const lowerSelector = fieldSelector.toLowerCase();
|
|
362
|
+
// Match based on field purpose
|
|
363
|
+
if ((lowerSelector.includes('note') || lowerSelector.includes('update')) && testData.updateNotes) {
|
|
364
|
+
return testData.updateNotes;
|
|
365
|
+
}
|
|
366
|
+
if ((lowerSelector.includes('job') || lowerSelector.includes('description')) && testData.jobDescription) {
|
|
367
|
+
return testData.jobDescription;
|
|
368
|
+
}
|
|
369
|
+
if (lowerSelector.includes('email') && testData.email) {
|
|
370
|
+
return testData.email;
|
|
371
|
+
}
|
|
372
|
+
if (lowerSelector.includes('password') && testData.password) {
|
|
373
|
+
return testData.password;
|
|
374
|
+
}
|
|
375
|
+
if (lowerSelector.includes('name') && testData.name) {
|
|
376
|
+
return testData.name;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// Fallback: use AI-provided value or empty string
|
|
380
|
+
return aiProvidedValue || '';
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Execute an action on the page
|
|
384
|
+
*/
|
|
385
|
+
async executeAction(page, aiDecision, order, executionId, mission) {
|
|
386
|
+
const step = {
|
|
387
|
+
id: (0, uuid_1.v4)(),
|
|
388
|
+
order,
|
|
389
|
+
action: `${aiDecision.action}: ${aiDecision.selector || aiDecision.value || ""}`,
|
|
390
|
+
intent: aiDecision.intent || "",
|
|
391
|
+
timestamp: new Date().toISOString(),
|
|
392
|
+
success: false,
|
|
393
|
+
};
|
|
394
|
+
try {
|
|
395
|
+
switch (aiDecision.action) {
|
|
396
|
+
case "click":
|
|
397
|
+
// Try multiple strategies to find the element
|
|
398
|
+
const clickSelector = await this.findElement(page, aiDecision.selector);
|
|
399
|
+
if (clickSelector) {
|
|
400
|
+
await page.click(clickSelector);
|
|
401
|
+
step.metadata = { clickedElement: clickSelector };
|
|
402
|
+
step.success = true;
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
step.error = `Element not found: ${aiDecision.selector}`;
|
|
406
|
+
}
|
|
407
|
+
break;
|
|
408
|
+
case "fill":
|
|
409
|
+
const fillSelector = await this.findElement(page, aiDecision.selector);
|
|
410
|
+
if (fillSelector) {
|
|
411
|
+
// Infer the correct value to use (from test data or AI)
|
|
412
|
+
const valueToUse = this.inferFieldValue(aiDecision.selector, aiDecision.value, mission);
|
|
413
|
+
await page.fill(fillSelector, valueToUse);
|
|
414
|
+
step.metadata = {
|
|
415
|
+
elementSelector: fillSelector,
|
|
416
|
+
inputValue: valueToUse,
|
|
417
|
+
};
|
|
418
|
+
step.success = true;
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
step.error = `Element not found: ${aiDecision.selector}`;
|
|
422
|
+
}
|
|
423
|
+
break;
|
|
424
|
+
case "goto":
|
|
425
|
+
await page.goto(aiDecision.value);
|
|
426
|
+
step.metadata = { navigationTarget: aiDecision.value };
|
|
427
|
+
step.success = true;
|
|
428
|
+
break;
|
|
429
|
+
case "wait":
|
|
430
|
+
await page.waitForTimeout(2000);
|
|
431
|
+
step.success = true;
|
|
432
|
+
break;
|
|
433
|
+
case "upload":
|
|
434
|
+
// Handle file upload (works with hidden file inputs)
|
|
435
|
+
try {
|
|
436
|
+
const fileInput = await page.$('input[type="file"]');
|
|
437
|
+
if (fileInput && aiDecision.value) {
|
|
438
|
+
// Construct file path from test-fixtures directory
|
|
439
|
+
const filePath = path.join(this.projectPath, 'test-fixtures', aiDecision.value);
|
|
440
|
+
// Check if file exists
|
|
441
|
+
if (!fs.existsSync(filePath)) {
|
|
442
|
+
step.error = `File not found: ${filePath}`;
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
await fileInput.setInputFiles(filePath);
|
|
446
|
+
step.metadata = {
|
|
447
|
+
uploadedFile: aiDecision.value,
|
|
448
|
+
filePath: filePath
|
|
449
|
+
};
|
|
450
|
+
step.success = true;
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
step.error = `File input not found or no file specified`;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
step.error = `Upload failed: ${error.message}`;
|
|
458
|
+
}
|
|
459
|
+
break;
|
|
460
|
+
default:
|
|
461
|
+
step.error = `Unknown action: ${aiDecision.action}`;
|
|
462
|
+
}
|
|
463
|
+
// Take screenshot after action
|
|
464
|
+
step.screenshot = await this.takeScreenshot(page, executionId, `step-${order}`);
|
|
465
|
+
step.metadata = {
|
|
466
|
+
...step.metadata,
|
|
467
|
+
url: page.url(),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
step.error = error.message;
|
|
472
|
+
step.success = false;
|
|
473
|
+
}
|
|
474
|
+
return step;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Convert jQuery-style or ambiguous selectors to Playwright-compatible selectors
|
|
478
|
+
*/
|
|
479
|
+
convertToPlaywrightSelector(selector) {
|
|
480
|
+
const candidates = [];
|
|
481
|
+
// Pattern 1: jQuery :contains() -> Playwright :has-text() or text=
|
|
482
|
+
// e.g., "button:contains('Refresh My CV')" -> "button:has-text('Refresh My CV')"
|
|
483
|
+
const containsMatch = selector.match(/^([a-z]+):contains\(['"](.+)['"]\)$/i);
|
|
484
|
+
if (containsMatch) {
|
|
485
|
+
const [, element, text] = containsMatch;
|
|
486
|
+
candidates.push(`${element}:has-text("${text}")`);
|
|
487
|
+
candidates.push(`${element} >> text="${text}"`);
|
|
488
|
+
candidates.push(`${element}:text("${text}")`);
|
|
489
|
+
return candidates;
|
|
490
|
+
}
|
|
491
|
+
// Pattern 2: Plain text -> multiple strategies
|
|
492
|
+
// If it looks like plain text (no CSS selector chars)
|
|
493
|
+
if (!/[#.\[\]:>~+]/.test(selector)) {
|
|
494
|
+
candidates.push(`text="${selector}"`); // Exact text
|
|
495
|
+
candidates.push(`text=/${selector}/i`); // Case-insensitive
|
|
496
|
+
candidates.push(`button:has-text("${selector}")`); // Button with text
|
|
497
|
+
candidates.push(`a:has-text("${selector}")`); // Link with text
|
|
498
|
+
candidates.push(`[aria-label="${selector}"]`); // ARIA label
|
|
499
|
+
return candidates;
|
|
500
|
+
}
|
|
501
|
+
// Pattern 3: Already looks like a valid CSS selector
|
|
502
|
+
candidates.push(selector);
|
|
503
|
+
return candidates;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Try to find form inputs using intelligent strategies
|
|
507
|
+
*/
|
|
508
|
+
async tryFormInputStrategies(page, selector) {
|
|
509
|
+
// Extract potential field keywords from selector
|
|
510
|
+
const lowerSelector = selector.toLowerCase();
|
|
511
|
+
const keywords = lowerSelector.match(/\b(update|notes|email|password|name|description|job|message|comment|text)\b/);
|
|
512
|
+
if (!keywords) {
|
|
513
|
+
// If selector looks like it might be for a textarea or input, try generic strategies
|
|
514
|
+
if (lowerSelector.includes('textarea') || lowerSelector.includes('input')) {
|
|
515
|
+
// Try to find any visible textarea or input
|
|
516
|
+
const visibleTextarea = await page.$('textarea:visible');
|
|
517
|
+
if (visibleTextarea)
|
|
518
|
+
return 'textarea:visible';
|
|
519
|
+
const visibleInput = await page.$('input[type="text"]:visible');
|
|
520
|
+
if (visibleInput)
|
|
521
|
+
return 'input[type="text"]:visible';
|
|
522
|
+
}
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
const keyword = keywords[0];
|
|
526
|
+
// Strategy 1: Try by placeholder (case-insensitive)
|
|
527
|
+
try {
|
|
528
|
+
const byPlaceholder = `[placeholder*="${keyword}" i]`;
|
|
529
|
+
if (await page.$(byPlaceholder))
|
|
530
|
+
return byPlaceholder;
|
|
531
|
+
}
|
|
532
|
+
catch { }
|
|
533
|
+
// Strategy 2: Try by name attribute
|
|
534
|
+
try {
|
|
535
|
+
const byName = `[name*="${keyword}" i]`;
|
|
536
|
+
if (await page.$(byName))
|
|
537
|
+
return byName;
|
|
538
|
+
}
|
|
539
|
+
catch { }
|
|
540
|
+
// Strategy 3: Try by id attribute
|
|
541
|
+
try {
|
|
542
|
+
const byId = `[id*="${keyword}" i]`;
|
|
543
|
+
if (await page.$(byId))
|
|
544
|
+
return byId;
|
|
545
|
+
}
|
|
546
|
+
catch { }
|
|
547
|
+
// Strategy 4: Find textarea with nearby label containing keyword
|
|
548
|
+
try {
|
|
549
|
+
const textareas = await page.$$('textarea');
|
|
550
|
+
for (const textarea of textareas) {
|
|
551
|
+
if (await textarea.isVisible()) {
|
|
552
|
+
// Check if there's a label near this textarea
|
|
553
|
+
const textareaId = await textarea.getAttribute('id');
|
|
554
|
+
if (textareaId) {
|
|
555
|
+
const label = await page.$(`label[for="${textareaId}"]`);
|
|
556
|
+
if (label) {
|
|
557
|
+
const labelText = await label.textContent();
|
|
558
|
+
if (labelText && labelText.toLowerCase().includes(keyword)) {
|
|
559
|
+
return `#${textareaId}`;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// If no specific match, return first visible textarea for notes/updates
|
|
564
|
+
if (['note', 'update', 'message', 'comment'].includes(keyword)) {
|
|
565
|
+
return 'textarea:visible';
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
catch { }
|
|
571
|
+
// Strategy 5: Try input by type for specific keywords
|
|
572
|
+
try {
|
|
573
|
+
if (keyword === 'email') {
|
|
574
|
+
if (await page.$('input[type="email"]'))
|
|
575
|
+
return 'input[type="email"]';
|
|
576
|
+
}
|
|
577
|
+
if (keyword === 'password') {
|
|
578
|
+
if (await page.$('input[type="password"]'))
|
|
579
|
+
return 'input[type="password"]';
|
|
580
|
+
}
|
|
581
|
+
if (['name', 'text', 'description'].includes(keyword)) {
|
|
582
|
+
if (await page.$('input[type="text"]:visible'))
|
|
583
|
+
return 'input[type="text"]:visible';
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch { }
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Find element using multiple strategies
|
|
591
|
+
*/
|
|
592
|
+
async findElement(page, selector) {
|
|
593
|
+
// Convert selector to candidate selectors
|
|
594
|
+
const candidates = this.convertToPlaywrightSelector(selector);
|
|
595
|
+
// Try each candidate
|
|
596
|
+
for (const candidate of candidates) {
|
|
597
|
+
try {
|
|
598
|
+
const element = await page.$(candidate);
|
|
599
|
+
if (element) {
|
|
600
|
+
return candidate;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
// Selector syntax error, try next candidate
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// Additional fallback: Try with partial text matching
|
|
609
|
+
try {
|
|
610
|
+
const partialText = `text=/${selector.replace(/['"]/g, '')}/i`;
|
|
611
|
+
if (await page.$(partialText)) {
|
|
612
|
+
return partialText;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
// Ignore errors
|
|
617
|
+
}
|
|
618
|
+
// NEW: Try form input strategies if still no match
|
|
619
|
+
const formStrategy = await this.tryFormInputStrategies(page, selector);
|
|
620
|
+
if (formStrategy) {
|
|
621
|
+
return formStrategy;
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Validate mission completion
|
|
627
|
+
*/
|
|
628
|
+
async validateMission(page, mission, executionId) {
|
|
629
|
+
const results = [];
|
|
630
|
+
if (!mission.validationRules || mission.validationRules.length === 0) {
|
|
631
|
+
// No explicit validation rules, assume success if we got here
|
|
632
|
+
return [];
|
|
633
|
+
}
|
|
634
|
+
for (const rule of mission.validationRules) {
|
|
635
|
+
const result = {
|
|
636
|
+
rule,
|
|
637
|
+
passed: false,
|
|
638
|
+
message: "",
|
|
639
|
+
timestamp: new Date().toISOString(),
|
|
640
|
+
};
|
|
641
|
+
try {
|
|
642
|
+
switch (rule.type) {
|
|
643
|
+
case "url-match":
|
|
644
|
+
const currentUrl = page.url();
|
|
645
|
+
result.passed = currentUrl.includes(rule.expectedValue || "");
|
|
646
|
+
result.actualValue = currentUrl;
|
|
647
|
+
result.message = result.passed
|
|
648
|
+
? `URL matches: ${currentUrl}`
|
|
649
|
+
: `URL doesn't match. Expected: ${rule.expectedValue}, Got: ${currentUrl}`;
|
|
650
|
+
break;
|
|
651
|
+
case "element-exists":
|
|
652
|
+
const element = await page.$(rule.selector || "");
|
|
653
|
+
result.passed = element !== null;
|
|
654
|
+
result.message = result.passed
|
|
655
|
+
? `Element exists: ${rule.selector}`
|
|
656
|
+
: `Element not found: ${rule.selector}`;
|
|
657
|
+
break;
|
|
658
|
+
case "text-contains":
|
|
659
|
+
const content = await page.textContent("body");
|
|
660
|
+
result.passed = content?.includes(rule.expectedValue || "") || false;
|
|
661
|
+
result.message = result.passed
|
|
662
|
+
? `Text found: ${rule.expectedValue}`
|
|
663
|
+
: `Text not found: ${rule.expectedValue}`;
|
|
664
|
+
break;
|
|
665
|
+
case "element-visible":
|
|
666
|
+
const visibleElement = await page.$(rule.selector || "");
|
|
667
|
+
result.passed =
|
|
668
|
+
visibleElement !== null &&
|
|
669
|
+
(await visibleElement.isVisible());
|
|
670
|
+
result.message = result.passed
|
|
671
|
+
? `Element visible: ${rule.selector}`
|
|
672
|
+
: `Element not visible: ${rule.selector}`;
|
|
673
|
+
break;
|
|
674
|
+
default:
|
|
675
|
+
result.message = `Unknown validation type: ${rule.type}`;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
result.message = `Validation error: ${error.message}`;
|
|
680
|
+
}
|
|
681
|
+
results.push(result);
|
|
682
|
+
}
|
|
683
|
+
return results;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Get simplified DOM for AI analysis
|
|
687
|
+
*/
|
|
688
|
+
async getSimplifiedDOM(page) {
|
|
689
|
+
try {
|
|
690
|
+
return await page.evaluate(() => {
|
|
691
|
+
const body = document.body;
|
|
692
|
+
// Get interactive elements
|
|
693
|
+
const buttons = Array.from(document.querySelectorAll("button")).map((b) => `Button: ${b.textContent?.trim()}`);
|
|
694
|
+
const links = Array.from(document.querySelectorAll("a")).map((a) => `Link: ${a.textContent?.trim()}`);
|
|
695
|
+
const inputs = Array.from(document.querySelectorAll("input")).map((i) => `Input: ${i.type} (${i.placeholder || i.name})`);
|
|
696
|
+
return [
|
|
697
|
+
`Interactive elements:`,
|
|
698
|
+
...buttons.slice(0, 10),
|
|
699
|
+
...links.slice(0, 10),
|
|
700
|
+
...inputs.slice(0, 10),
|
|
701
|
+
].join("\n");
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
catch (error) {
|
|
705
|
+
return "";
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Get page content formatted for AI
|
|
710
|
+
*/
|
|
711
|
+
async getPageContentForAI(page) {
|
|
712
|
+
const title = await page.title();
|
|
713
|
+
const url = page.url();
|
|
714
|
+
const dom = await this.getSimplifiedDOM(page);
|
|
715
|
+
return `Title: ${title}
|
|
716
|
+
URL: ${url}
|
|
717
|
+
${dom}`;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Generate AI notes about the test execution
|
|
721
|
+
*/
|
|
722
|
+
async generateAINotes(mission, steps, validations) {
|
|
723
|
+
const successfulSteps = steps.filter((s) => s.success).length;
|
|
724
|
+
const failedSteps = steps.filter((s) => !s.success).length;
|
|
725
|
+
const passedValidations = validations.filter((v) => v.passed).length;
|
|
726
|
+
return `Executed ${steps.length} steps (${successfulSteps} successful, ${failedSteps} failed). Validated ${passedValidations}/${validations.length} criteria.`;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Take screenshot
|
|
730
|
+
*/
|
|
731
|
+
async takeScreenshot(page, executionId, name) {
|
|
732
|
+
try {
|
|
733
|
+
const filename = `${executionId}-${name}.png`;
|
|
734
|
+
const filepath = path.join(this.screenshotsDir, filename);
|
|
735
|
+
await page.screenshot({ path: filepath, fullPage: true });
|
|
736
|
+
return filepath;
|
|
737
|
+
}
|
|
738
|
+
catch (error) {
|
|
739
|
+
return "";
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Launch browser
|
|
744
|
+
*/
|
|
745
|
+
async launchBrowser(config) {
|
|
746
|
+
this.browser = await playwright_1.chromium.launch({
|
|
747
|
+
headless: config.headless !== false,
|
|
748
|
+
slowMo: config.slowMo || 0,
|
|
749
|
+
});
|
|
750
|
+
this.context = await this.browser.newContext({
|
|
751
|
+
viewport: config.viewport || { width: 1280, height: 720 },
|
|
752
|
+
recordVideo: config.recordVideo
|
|
753
|
+
? { dir: path.join(this.screenshotsDir, "videos") }
|
|
754
|
+
: undefined,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Close browser
|
|
759
|
+
*/
|
|
760
|
+
async closeBrowser() {
|
|
761
|
+
if (this.context) {
|
|
762
|
+
await this.context.close();
|
|
763
|
+
}
|
|
764
|
+
if (this.browser) {
|
|
765
|
+
await this.browser.close();
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Start interactive recording mode
|
|
770
|
+
*/
|
|
771
|
+
async startRecording(name, startUrl) {
|
|
772
|
+
// TODO: Implement interactive recording
|
|
773
|
+
// This would open a browser and observe user actions
|
|
774
|
+
throw new Error("Interactive recording not yet implemented");
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Start watch mode
|
|
778
|
+
*/
|
|
779
|
+
async startWatchMode(missions, watchPaths) {
|
|
780
|
+
// TODO: Implement watch mode
|
|
781
|
+
// This would watch files and re-run tests on changes
|
|
782
|
+
throw new Error("Watch mode not yet implemented");
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
exports.BrowserTestRunner = BrowserTestRunner;
|
|
786
|
+
//# sourceMappingURL=browser-test-runner.js.map
|