@assrt-ai/assrt 0.3.3
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/chunk-PZ5LLIRQ.mjs +2256 -0
- package/cli.mjs +279 -0
- package/index.mjs +20 -0
- package/mcp/server.mjs +508 -0
- package/package.json +49 -0
package/mcp/server.mjs
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
McpBrowserManager,
|
|
4
|
+
TestAgent,
|
|
5
|
+
getCredential,
|
|
6
|
+
shutdownTelemetry,
|
|
7
|
+
trackEvent
|
|
8
|
+
} from "../chunk-PZ5LLIRQ.mjs";
|
|
9
|
+
|
|
10
|
+
// src/mcp/server.ts
|
|
11
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { mkdirSync, writeFileSync, readdirSync } from "fs";
|
|
15
|
+
import { join, basename } from "path";
|
|
16
|
+
import { tmpdir } from "os";
|
|
17
|
+
import { execSync } from "child_process";
|
|
18
|
+
function generateVideoPlayerHtml(videoFilename, testUrl, passedCount, failedCount, durationSec) {
|
|
19
|
+
return `<!DOCTYPE html>
|
|
20
|
+
<html lang="en">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="UTF-8">
|
|
23
|
+
<title>Assrt Test Recording</title>
|
|
24
|
+
<style>
|
|
25
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
26
|
+
body { background: #0a0a0f; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; flex-direction: column; height: 100vh; padding: 8px; }
|
|
27
|
+
.header { width: 100%; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; padding: 0 8px; flex-shrink: 0; }
|
|
28
|
+
.brand { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
|
|
29
|
+
.brand span { color: #22c55e; }
|
|
30
|
+
.meta { display: flex; gap: 16px; font-size: 13px; color: #9ca3af; }
|
|
31
|
+
.meta .pass { color: #22c55e; font-weight: 600; }
|
|
32
|
+
.meta .fail { color: #ef4444; font-weight: 600; }
|
|
33
|
+
.video-wrap { width: 100%; flex: 1; min-height: 0; background: #111118; border-radius: 12px; overflow: hidden; border: 1px solid #1f1f2e; display: flex; flex-direction: column; }
|
|
34
|
+
video { width: 100%; flex: 1; min-height: 0; object-fit: contain; display: block; }
|
|
35
|
+
.controls { padding: 8px 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; flex-shrink: 0; }
|
|
36
|
+
.speed-group { display: flex; gap: 4px; }
|
|
37
|
+
.speed-btn { background: #1a1a26; border: 1px solid #2a2a3a; color: #9ca3af; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.15s; }
|
|
38
|
+
.speed-btn:hover { background: #252536; color: #e5e7eb; }
|
|
39
|
+
.speed-btn.active { background: #22c55e; color: #0a0a0f; border-color: #22c55e; font-weight: 700; }
|
|
40
|
+
.hint { margin-left: auto; font-size: 12px; color: #6b7280; }
|
|
41
|
+
kbd { background: #1a1a26; border: 1px solid #2a2a3a; border-radius: 4px; padding: 1px 6px; font-size: 11px; font-family: inherit; }
|
|
42
|
+
</style>
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
<div class="header">
|
|
46
|
+
<div class="brand">assrt<span>.</span></div>
|
|
47
|
+
<div class="meta">
|
|
48
|
+
<span>${testUrl}</span>
|
|
49
|
+
<span class="pass">${passedCount} passed</span>
|
|
50
|
+
${failedCount > 0 ? `<span class="fail">${failedCount} failed</span>` : ""}
|
|
51
|
+
<span>${durationSec}s</span>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="video-wrap">
|
|
55
|
+
<video id="v" controls autoplay muted>
|
|
56
|
+
<source src="${videoFilename}" type="video/webm">
|
|
57
|
+
</video>
|
|
58
|
+
<div class="controls">
|
|
59
|
+
<div class="speed-group">
|
|
60
|
+
<button class="speed-btn" data-speed="1">1x</button>
|
|
61
|
+
<button class="speed-btn" data-speed="2">2x</button>
|
|
62
|
+
<button class="speed-btn" data-speed="3">3x</button>
|
|
63
|
+
<button class="speed-btn active" data-speed="5">5x</button>
|
|
64
|
+
<button class="speed-btn" data-speed="10">10x</button>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="hint"><kbd>Space</kbd> play/pause <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd><kbd>5</kbd> speed <kbd>\u2190</kbd><kbd>\u2192</kbd> seek 5s</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<script>
|
|
70
|
+
const v = document.getElementById('v');
|
|
71
|
+
const btns = document.querySelectorAll('.speed-btn');
|
|
72
|
+
function setSpeed(s) {
|
|
73
|
+
v.playbackRate = s;
|
|
74
|
+
btns.forEach(b => b.classList.toggle('active', +b.dataset.speed === s));
|
|
75
|
+
}
|
|
76
|
+
v.addEventListener('loadeddata', () => setSpeed(5));
|
|
77
|
+
btns.forEach(b => b.addEventListener('click', () => setSpeed(+b.dataset.speed)));
|
|
78
|
+
document.addEventListener('keydown', e => {
|
|
79
|
+
if (e.key === ' ') { e.preventDefault(); v.paused ? v.play() : v.pause(); }
|
|
80
|
+
if (e.key === 'ArrowLeft') { v.currentTime = Math.max(0, v.currentTime - 5); }
|
|
81
|
+
if (e.key === 'ArrowRight') { v.currentTime += 5; }
|
|
82
|
+
const speedMap = { '1': 1, '2': 2, '3': 3, '5': 5, '0': 10 };
|
|
83
|
+
if (speedMap[e.key]) setSpeed(speedMap[e.key]);
|
|
84
|
+
});
|
|
85
|
+
</script>
|
|
86
|
+
</body>
|
|
87
|
+
</html>`;
|
|
88
|
+
}
|
|
89
|
+
var PLAN_SYSTEM_PROMPT = `You are a Senior QA Engineer generating test cases for an AI browser agent. The agent can: navigate URLs, click buttons/links by text or selector, type into inputs, scroll, press keys, and make assertions. It CANNOT: resize the browser, test network errors, inspect CSS, or run JavaScript.
|
|
90
|
+
|
|
91
|
+
## Output Format
|
|
92
|
+
Generate test cases in this EXACT format:
|
|
93
|
+
|
|
94
|
+
#Case 1: [short action-oriented name]
|
|
95
|
+
[Step-by-step instructions the agent can execute. Be SPECIFIC about what to click, what to type, and what to verify.]
|
|
96
|
+
|
|
97
|
+
#Case 2: [short action-oriented name]
|
|
98
|
+
[Step-by-step instructions...]
|
|
99
|
+
|
|
100
|
+
## CRITICAL Rules for Executable Tests
|
|
101
|
+
1. **Each case must be SELF-CONTAINED** \u2014 do not assume previous cases ran. If a test needs login, include the login steps.
|
|
102
|
+
2. **Be specific about selectors** \u2014 say "click the Login button" not "navigate to login". Say "type test@email.com into the email field" not "fill in credentials".
|
|
103
|
+
3. **Verify observable things** \u2014 check for visible text, page titles, URLs, element presence. NOT for CSS, colors, performance, or responsive layout.
|
|
104
|
+
4. **Keep cases SHORT** \u2014 3-5 actions max per case. A focused test that passes is better than a complex one that fails.
|
|
105
|
+
5. **Avoid testing what you can't see** \u2014 don't generate cases for features behind authentication unless there's a visible signup/login form.
|
|
106
|
+
6. **Generate 5-8 cases max** \u2014 focused on the MOST IMPORTANT user flows visible on the page.`;
|
|
107
|
+
var DIAGNOSE_SYSTEM_PROMPT = `You are a senior QA engineer and debugging expert. You are given a failing test case report from an automated web testing agent. Your job is to:
|
|
108
|
+
|
|
109
|
+
1. **Diagnose** the root cause \u2014 is it a bug in the application, a flawed test, or an environment issue?
|
|
110
|
+
2. **Provide a fix** \u2014 give a concrete, actionable solution:
|
|
111
|
+
- If the app has a bug: describe what the app should do differently
|
|
112
|
+
- If the test is flawed: provide a corrected test scenario in the exact #Case format
|
|
113
|
+
- If it's an environment issue: explain what needs to change
|
|
114
|
+
3. **Provide a corrected test scenario** if the test itself needs adjustment
|
|
115
|
+
|
|
116
|
+
## Output Format
|
|
117
|
+
|
|
118
|
+
### Root Cause
|
|
119
|
+
[1-2 sentences identifying the core issue]
|
|
120
|
+
|
|
121
|
+
### Analysis
|
|
122
|
+
[3-5 sentences explaining what went wrong and why]
|
|
123
|
+
|
|
124
|
+
### Recommended Fix
|
|
125
|
+
[Concrete steps to fix the issue]
|
|
126
|
+
|
|
127
|
+
### Corrected Test Scenario
|
|
128
|
+
#Case 1: [corrected case name]
|
|
129
|
+
[corrected steps that will pass]
|
|
130
|
+
|
|
131
|
+
## Rules
|
|
132
|
+
- Be specific \u2014 reference actual elements, URLs, error messages from the report
|
|
133
|
+
- If the test failed because a feature doesn't exist, say so clearly
|
|
134
|
+
- If the test steps are valid but the app is broken, focus on the app issue
|
|
135
|
+
- Keep it concise and actionable \u2014 no filler`;
|
|
136
|
+
var SERVER_INSTRUCTIONS = `You are connected to Assrt, an AI-powered QA testing server that runs real browser tests against web applications.
|
|
137
|
+
|
|
138
|
+
## When to use Assrt
|
|
139
|
+
|
|
140
|
+
**Proactively use Assrt after any user-facing change.** Do not wait for the user to ask for testing. Specifically:
|
|
141
|
+
|
|
142
|
+
1. **After implementing a feature or bug fix** that touches UI, routes, forms, or user flows: run \`assrt_test\` against the local dev server to verify the change works end-to-end in a real browser.
|
|
143
|
+
2. **Before committing code**: if the changes affect anything a user would see or interact with, generate a quick test with \`assrt_plan\` or write a targeted test case and run it with \`assrt_test\`.
|
|
144
|
+
3. **When a test fails**: use \`assrt_diagnose\` to understand root cause before attempting a fix. Do not guess.
|
|
145
|
+
|
|
146
|
+
## How to use the tools
|
|
147
|
+
|
|
148
|
+
- **assrt_test**: The primary tool. Pass a URL (usually http://localhost:3000 or whatever the dev server is) and a test plan. Returns structured pass/fail results with screenshots showing the browser at each step.
|
|
149
|
+
- **assrt_plan**: Use when you need test cases but don't have them. Navigates to the URL, analyzes the page, and generates executable test scenarios.
|
|
150
|
+
- **assrt_diagnose**: Use after a failed test. Pass the URL, the scenario that failed, and the error. Returns root cause analysis and a corrected test.
|
|
151
|
+
|
|
152
|
+
## Important
|
|
153
|
+
|
|
154
|
+
- Always include the correct local dev server URL. Check package.json scripts or running processes to find it.
|
|
155
|
+
- Test plans use \`#Case N: name\` format. Each case should be self-contained (3-5 steps).
|
|
156
|
+
- The browser runs headless at 1280x720. Screenshots are returned as images in the response.
|
|
157
|
+
- If the dev server is not running, start it first before calling assrt_test.`;
|
|
158
|
+
var server = new McpServer(
|
|
159
|
+
{ name: "assrt", version: "0.2.0" },
|
|
160
|
+
{ instructions: SERVER_INSTRUCTIONS }
|
|
161
|
+
);
|
|
162
|
+
server.tool(
|
|
163
|
+
"assrt_test",
|
|
164
|
+
"Run AI-powered QA test scenarios against a URL. Returns a structured report with pass/fail results, assertions, and improvement suggestions.",
|
|
165
|
+
{
|
|
166
|
+
url: z.string().describe("URL to test (e.g. http://localhost:3000)"),
|
|
167
|
+
plan: z.string().describe("Test scenarios in text format. Use #Case N: format for multiple scenarios."),
|
|
168
|
+
model: z.string().optional().describe("LLM model override (default: claude-haiku-4-5-20251001)"),
|
|
169
|
+
autoOpenPlayer: z.boolean().optional().describe("Auto-open the video player in the browser when test completes (default: true)")
|
|
170
|
+
},
|
|
171
|
+
async ({ url, plan, model, autoOpenPlayer }) => {
|
|
172
|
+
const shouldAutoOpen = autoOpenPlayer !== false;
|
|
173
|
+
const credential = getCredential();
|
|
174
|
+
const runId = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
175
|
+
const runDir = join(tmpdir(), "assrt", runId);
|
|
176
|
+
const screenshotDir = join(runDir, "screenshots");
|
|
177
|
+
mkdirSync(screenshotDir, { recursive: true });
|
|
178
|
+
const logs = [];
|
|
179
|
+
const allEvents = [];
|
|
180
|
+
const improvements = [];
|
|
181
|
+
const screenshots = [];
|
|
182
|
+
let currentStep = 0;
|
|
183
|
+
let currentAction = "";
|
|
184
|
+
let currentDescription = "";
|
|
185
|
+
let screenshotIndex = 0;
|
|
186
|
+
const emit = (type, data) => {
|
|
187
|
+
const time = (/* @__PURE__ */ new Date()).toISOString();
|
|
188
|
+
allEvents.push({ time, type, data: type === "screenshot" ? { step: currentStep, action: currentAction } : data });
|
|
189
|
+
if (type === "status") logs.push(`[${time}] [status] ${data.message}`);
|
|
190
|
+
else if (type === "step") {
|
|
191
|
+
currentStep = data.id || currentStep;
|
|
192
|
+
currentAction = data.action || "";
|
|
193
|
+
currentDescription = data.description || "";
|
|
194
|
+
logs.push(`[${time}] [step ${currentStep}] (${currentAction}) ${currentDescription} \u2014 ${data.status || "running"}`);
|
|
195
|
+
} else if (type === "reasoning") {
|
|
196
|
+
logs.push(`[${time}] [reasoning] ${data.text}`);
|
|
197
|
+
} else if (type === "assertion") {
|
|
198
|
+
const icon = data.passed ? "PASS" : "FAIL";
|
|
199
|
+
logs.push(`[${time}] [${icon}] ${data.description}${data.evidence ? ` \u2014 ${data.evidence}` : ""}`);
|
|
200
|
+
} else if (type === "scenario_start") {
|
|
201
|
+
logs.push(`[${time}] [scenario_start] ${data.name}`);
|
|
202
|
+
} else if (type === "scenario_complete") {
|
|
203
|
+
const result = data.passed ? "PASSED" : "FAILED";
|
|
204
|
+
logs.push(`[${time}] [${result}] ${data.name}`);
|
|
205
|
+
} else if (type === "improvement_suggestion") {
|
|
206
|
+
logs.push(`[${time}] [issue] ${data.severity}: ${data.title} \u2014 ${data.description}`);
|
|
207
|
+
improvements.push({ title: data.title, severity: data.severity, description: data.description, suggestion: data.suggestion });
|
|
208
|
+
} else if (type === "screenshot" && data.base64) {
|
|
209
|
+
const filename = `${String(screenshotIndex).padStart(2, "0")}_step${currentStep}_${currentAction || "init"}.png`;
|
|
210
|
+
const filepath = join(screenshotDir, filename);
|
|
211
|
+
try {
|
|
212
|
+
writeFileSync(filepath, Buffer.from(data.base64, "base64"));
|
|
213
|
+
} catch {
|
|
214
|
+
}
|
|
215
|
+
screenshotIndex++;
|
|
216
|
+
const last = screenshots[screenshots.length - 1];
|
|
217
|
+
if (last && last.step === currentStep) {
|
|
218
|
+
last.base64 = data.base64;
|
|
219
|
+
last.file = filepath;
|
|
220
|
+
} else {
|
|
221
|
+
screenshots.push({
|
|
222
|
+
step: currentStep,
|
|
223
|
+
action: currentAction,
|
|
224
|
+
description: currentDescription,
|
|
225
|
+
base64: data.base64,
|
|
226
|
+
file: filepath
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (type === "status" || type === "scenario_start") {
|
|
231
|
+
server.server.sendLoggingMessage({
|
|
232
|
+
level: "info",
|
|
233
|
+
data: type === "status" ? data.message : `Starting scenario: ${data.name}`
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
const t0 = Date.now();
|
|
238
|
+
const videoDir = join(runDir, "video");
|
|
239
|
+
const agent = new TestAgent(credential.token, emit, model, "anthropic", null, "local", credential.type, videoDir);
|
|
240
|
+
const report = await agent.run(url, plan);
|
|
241
|
+
await agent.close();
|
|
242
|
+
const logContent = logs.join("\n");
|
|
243
|
+
const logFile = join(runDir, "execution.log");
|
|
244
|
+
try {
|
|
245
|
+
writeFileSync(logFile, logContent);
|
|
246
|
+
} catch {
|
|
247
|
+
}
|
|
248
|
+
const eventsFile = join(runDir, "events.json");
|
|
249
|
+
try {
|
|
250
|
+
writeFileSync(eventsFile, JSON.stringify(allEvents, null, 2));
|
|
251
|
+
} catch {
|
|
252
|
+
}
|
|
253
|
+
let videoFile = null;
|
|
254
|
+
let videoPlayerFile = null;
|
|
255
|
+
let videoPlayerUrl = null;
|
|
256
|
+
try {
|
|
257
|
+
const videoFiles = readdirSync(videoDir).filter((f) => f.endsWith(".webm"));
|
|
258
|
+
if (videoFiles.length > 0) {
|
|
259
|
+
videoFile = join(videoDir, videoFiles[0]);
|
|
260
|
+
videoPlayerFile = join(videoDir, "player.html");
|
|
261
|
+
writeFileSync(videoPlayerFile, generateVideoPlayerHtml(
|
|
262
|
+
basename(videoFiles[0]),
|
|
263
|
+
url,
|
|
264
|
+
report.passedCount,
|
|
265
|
+
report.failedCount,
|
|
266
|
+
+(report.totalDuration / 1e3).toFixed(1)
|
|
267
|
+
));
|
|
268
|
+
try {
|
|
269
|
+
const http = await import("http");
|
|
270
|
+
const fs = await import("fs");
|
|
271
|
+
const path = await import("path");
|
|
272
|
+
const srv = http.createServer((req, res) => {
|
|
273
|
+
const filePath = join(videoDir, path.basename(req.url || "/"));
|
|
274
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
275
|
+
const mime = { ".html": "text/html", ".webm": "video/webm", ".mp4": "video/mp4" };
|
|
276
|
+
try {
|
|
277
|
+
const data = fs.readFileSync(filePath);
|
|
278
|
+
res.writeHead(200, { "Content-Type": mime[ext] || "application/octet-stream" });
|
|
279
|
+
res.end(data);
|
|
280
|
+
} catch {
|
|
281
|
+
res.writeHead(404);
|
|
282
|
+
res.end("Not found");
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
await new Promise((resolve) => {
|
|
286
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
287
|
+
const port = srv.address().port;
|
|
288
|
+
videoPlayerUrl = `http://127.0.0.1:${port}/player.html`;
|
|
289
|
+
if (shouldAutoOpen) {
|
|
290
|
+
try {
|
|
291
|
+
execSync(`open "${videoPlayerUrl}"`);
|
|
292
|
+
} catch {
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
setTimeout(() => {
|
|
296
|
+
try {
|
|
297
|
+
srv.close();
|
|
298
|
+
} catch {
|
|
299
|
+
}
|
|
300
|
+
}, 6e5).unref();
|
|
301
|
+
resolve();
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
srv.unref();
|
|
305
|
+
} catch {
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch {
|
|
309
|
+
}
|
|
310
|
+
const summary = {
|
|
311
|
+
passed: report.failedCount === 0,
|
|
312
|
+
passedCount: report.passedCount,
|
|
313
|
+
failedCount: report.failedCount,
|
|
314
|
+
duration: +(report.totalDuration / 1e3).toFixed(1),
|
|
315
|
+
screenshotCount: screenshots.length,
|
|
316
|
+
artifactsDir: runDir,
|
|
317
|
+
logFile,
|
|
318
|
+
videoFile,
|
|
319
|
+
videoPlayerFile,
|
|
320
|
+
videoPlayerUrl,
|
|
321
|
+
scenarios: report.scenarios.map((s) => ({
|
|
322
|
+
name: s.name,
|
|
323
|
+
passed: s.passed,
|
|
324
|
+
summary: s.summary,
|
|
325
|
+
assertions: s.assertions.map((a) => ({
|
|
326
|
+
description: a.description,
|
|
327
|
+
passed: a.passed,
|
|
328
|
+
evidence: a.evidence
|
|
329
|
+
}))
|
|
330
|
+
})),
|
|
331
|
+
improvements
|
|
332
|
+
};
|
|
333
|
+
const screenshotFiles = screenshots.map((ss) => ({
|
|
334
|
+
step: ss.step,
|
|
335
|
+
action: ss.action,
|
|
336
|
+
description: ss.description,
|
|
337
|
+
file: ss.file
|
|
338
|
+
}));
|
|
339
|
+
summary.screenshots = screenshotFiles;
|
|
340
|
+
const content = [
|
|
341
|
+
{ type: "text", text: JSON.stringify(summary, null, 2) }
|
|
342
|
+
];
|
|
343
|
+
trackEvent("assrt_test_run", {
|
|
344
|
+
url,
|
|
345
|
+
model: model || "default",
|
|
346
|
+
passed: report.failedCount === 0,
|
|
347
|
+
passedCount: report.passedCount,
|
|
348
|
+
failedCount: report.failedCount,
|
|
349
|
+
duration_s: +((Date.now() - t0) / 1e3).toFixed(1),
|
|
350
|
+
screenshotCount: screenshots.length,
|
|
351
|
+
scenarioCount: report.scenarios.length,
|
|
352
|
+
source: "mcp"
|
|
353
|
+
});
|
|
354
|
+
return { content };
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
server.tool(
|
|
358
|
+
"assrt_plan",
|
|
359
|
+
"Auto-generate QA test scenarios by analyzing a URL. Launches a browser, takes screenshots, and uses AI to create executable test cases.",
|
|
360
|
+
{
|
|
361
|
+
url: z.string().describe("URL to analyze (e.g. http://localhost:3000)"),
|
|
362
|
+
model: z.string().optional().describe("LLM model override for plan generation")
|
|
363
|
+
},
|
|
364
|
+
async ({ url, model }) => {
|
|
365
|
+
const t0 = Date.now();
|
|
366
|
+
const credential = getCredential();
|
|
367
|
+
const Anthropic = (await import("@anthropic-ai/sdk")).default;
|
|
368
|
+
const anthropic = new Anthropic({
|
|
369
|
+
authToken: credential.token,
|
|
370
|
+
defaultHeaders: { "anthropic-beta": "oauth-2025-04-20" }
|
|
371
|
+
});
|
|
372
|
+
const browser = new McpBrowserManager();
|
|
373
|
+
try {
|
|
374
|
+
server.server.sendLoggingMessage({ level: "info", data: "Launching local browser..." });
|
|
375
|
+
await browser.launchLocal();
|
|
376
|
+
server.server.sendLoggingMessage({ level: "info", data: `Navigating to ${url}...` });
|
|
377
|
+
await browser.navigate(url);
|
|
378
|
+
const screenshot1 = await browser.screenshot();
|
|
379
|
+
const snapshotText1 = await browser.snapshot();
|
|
380
|
+
await browser.scroll(0, 800);
|
|
381
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
382
|
+
const screenshot2 = await browser.screenshot();
|
|
383
|
+
const snapshotText2 = await browser.snapshot();
|
|
384
|
+
await browser.scroll(0, 800);
|
|
385
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
386
|
+
const screenshot3 = await browser.screenshot();
|
|
387
|
+
const snapshotText3 = await browser.snapshot();
|
|
388
|
+
await browser.close();
|
|
389
|
+
const allText = [snapshotText1, snapshotText2, snapshotText3].join("\n\n").slice(0, 8e3);
|
|
390
|
+
server.server.sendLoggingMessage({ level: "info", data: "Generating test plan with AI..." });
|
|
391
|
+
const contentParts = [];
|
|
392
|
+
for (const img of [screenshot1, screenshot2, screenshot3]) {
|
|
393
|
+
if (img) {
|
|
394
|
+
contentParts.push({
|
|
395
|
+
type: "image",
|
|
396
|
+
source: { type: "base64", media_type: "image/jpeg", data: img }
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
contentParts.push({
|
|
401
|
+
type: "text",
|
|
402
|
+
text: `Analyze this web application and generate a comprehensive test plan.
|
|
403
|
+
|
|
404
|
+
**URL:** ${url}
|
|
405
|
+
|
|
406
|
+
**Visible Text Content:**
|
|
407
|
+
${allText}
|
|
408
|
+
|
|
409
|
+
Based on the screenshots and page analysis above, generate comprehensive test cases for this web application.`
|
|
410
|
+
});
|
|
411
|
+
const response = await anthropic.messages.create({
|
|
412
|
+
model: model || "claude-haiku-4-5-20251001",
|
|
413
|
+
max_tokens: 4096,
|
|
414
|
+
system: PLAN_SYSTEM_PROMPT,
|
|
415
|
+
messages: [{ role: "user", content: contentParts }]
|
|
416
|
+
});
|
|
417
|
+
const plan = response.content.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
418
|
+
trackEvent("assrt_plan_run", {
|
|
419
|
+
url,
|
|
420
|
+
model: model || "default",
|
|
421
|
+
duration_s: +((Date.now() - t0) / 1e3).toFixed(1),
|
|
422
|
+
source: "mcp"
|
|
423
|
+
});
|
|
424
|
+
return {
|
|
425
|
+
content: [
|
|
426
|
+
{
|
|
427
|
+
type: "text",
|
|
428
|
+
text: JSON.stringify({ plan, url }, null, 2)
|
|
429
|
+
}
|
|
430
|
+
]
|
|
431
|
+
};
|
|
432
|
+
} catch (err) {
|
|
433
|
+
try {
|
|
434
|
+
await browser.close();
|
|
435
|
+
} catch {
|
|
436
|
+
}
|
|
437
|
+
trackEvent("assrt_plan_error", { url, error: err.message?.slice(0, 200), source: "mcp" });
|
|
438
|
+
throw err;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
);
|
|
442
|
+
server.tool(
|
|
443
|
+
"assrt_diagnose",
|
|
444
|
+
"Diagnose a failed test scenario. Analyzes the failure and suggests fixes for both application bugs and flawed tests.",
|
|
445
|
+
{
|
|
446
|
+
url: z.string().describe("URL that was tested"),
|
|
447
|
+
scenario: z.string().describe("The test scenario that failed"),
|
|
448
|
+
error: z.string().describe("The failure description, evidence, or error message")
|
|
449
|
+
},
|
|
450
|
+
async ({ url, scenario, error }) => {
|
|
451
|
+
const t0 = Date.now();
|
|
452
|
+
const credential = getCredential();
|
|
453
|
+
const Anthropic = (await import("@anthropic-ai/sdk")).default;
|
|
454
|
+
const anthropic = new Anthropic({
|
|
455
|
+
authToken: credential.token,
|
|
456
|
+
defaultHeaders: { "anthropic-beta": "oauth-2025-04-20" }
|
|
457
|
+
});
|
|
458
|
+
const debugPrompt = `## Failed Test Report
|
|
459
|
+
|
|
460
|
+
**URL:** ${url}
|
|
461
|
+
|
|
462
|
+
**Test Scenario:**
|
|
463
|
+
${scenario}
|
|
464
|
+
|
|
465
|
+
**Failure:**
|
|
466
|
+
${error}
|
|
467
|
+
|
|
468
|
+
Please diagnose this failure and provide a corrected test scenario.`;
|
|
469
|
+
const response = await anthropic.messages.create({
|
|
470
|
+
model: "claude-haiku-4-5-20251001",
|
|
471
|
+
max_tokens: 4096,
|
|
472
|
+
system: DIAGNOSE_SYSTEM_PROMPT,
|
|
473
|
+
messages: [{ role: "user", content: debugPrompt }]
|
|
474
|
+
});
|
|
475
|
+
const diagnosis = response.content.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
476
|
+
trackEvent("assrt_diagnose_run", {
|
|
477
|
+
url,
|
|
478
|
+
duration_s: +((Date.now() - t0) / 1e3).toFixed(1),
|
|
479
|
+
source: "mcp"
|
|
480
|
+
});
|
|
481
|
+
return {
|
|
482
|
+
content: [
|
|
483
|
+
{
|
|
484
|
+
type: "text",
|
|
485
|
+
text: JSON.stringify({ diagnosis, url, scenario }, null, 2)
|
|
486
|
+
}
|
|
487
|
+
]
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
);
|
|
491
|
+
async function main() {
|
|
492
|
+
trackEvent("mcp_server_start", { source: "mcp" }, { dedupeDaily: true });
|
|
493
|
+
const transport = new StdioServerTransport();
|
|
494
|
+
await server.connect(transport);
|
|
495
|
+
console.error("[assrt-mcp] server started, waiting for JSON-RPC on stdin");
|
|
496
|
+
process.on("SIGINT", async () => {
|
|
497
|
+
await shutdownTelemetry();
|
|
498
|
+
process.exit(0);
|
|
499
|
+
});
|
|
500
|
+
process.on("SIGTERM", async () => {
|
|
501
|
+
await shutdownTelemetry();
|
|
502
|
+
process.exit(0);
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
main().catch((err) => {
|
|
506
|
+
console.error(`[assrt-mcp] fatal: ${err.message || err}`);
|
|
507
|
+
process.exit(1);
|
|
508
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@assrt-ai/assrt",
|
|
3
|
+
"version": "0.3.3",
|
|
4
|
+
"description": "AI-powered QA testing from the command line and as an MCP server for coding agents",
|
|
5
|
+
"keywords": ["testing", "qa", "ai", "mcp", "playwright", "browser", "claude", "automation"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Assrt <matt@assrt.ai>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/assrt-ai/assrt-mcp.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://assrt.ai",
|
|
13
|
+
"bin": {
|
|
14
|
+
"assrt": "cli.mjs",
|
|
15
|
+
"assrt-mcp": "mcp/server.mjs"
|
|
16
|
+
},
|
|
17
|
+
"main": "index.mjs",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./index.mjs",
|
|
20
|
+
"./cli": "./cli.mjs",
|
|
21
|
+
"./mcp": "./mcp/server.mjs"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"cli.mjs",
|
|
29
|
+
"index.mjs",
|
|
30
|
+
"mcp/",
|
|
31
|
+
"chunk-*.mjs"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"postinstall": "node cli.mjs setup 2>/dev/null || true"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@anthropic-ai/sdk": "^0.39.0",
|
|
38
|
+
"@google/genai": "^1.46.0",
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
40
|
+
"@playwright/mcp": "^0.0.70",
|
|
41
|
+
"undici": "^7.24.7",
|
|
42
|
+
"posthog-node": "^5.21.2",
|
|
43
|
+
"ws": "^8.20.0"
|
|
44
|
+
},
|
|
45
|
+
"optionalDependencies": {
|
|
46
|
+
"freestyle-sandboxes": "^0.1.39",
|
|
47
|
+
"@freestyle-sh/with-nodejs": "^0.2.8"
|
|
48
|
+
}
|
|
49
|
+
}
|