@_deep4wee/agent-lens 1.0.1
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/LICENSE +21 -0
- package/README.md +221 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1853 -0
- package/dist/cli.js.map +1 -0
- package/dist/cli.mjs +1924 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.d.mts +170 -0
- package/dist/index.d.ts +170 -0
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +19 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +60 -0
- package/scripts/postinstall.js +47 -0
- package/skills/agent-lens/SKILL.md +200 -0
- package/skills/agent-lens/examples/01-instant-verification-snap.md +53 -0
- package/skills/agent-lens/examples/02-dev-server-live-testing.md +62 -0
- package/skills/agent-lens/examples/03-component-isolation-and-animations.md +53 -0
- package/skills/agent-lens/examples/04-desktop-native-testing.md +68 -0
- package/skills/agent-lens/examples/05-clean-teardown-and-sandboxing.md +68 -0
- package/skills/agent-lens/examples/06-state-testing-with-mock-ipc.md +62 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1853 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/app/cli.ts
|
|
27
|
+
var import_path8 = __toESM(require("path"));
|
|
28
|
+
var import_fs8 = __toESM(require("fs"));
|
|
29
|
+
|
|
30
|
+
// src/features/runner/runner.ts
|
|
31
|
+
var import_path6 = __toESM(require("path"));
|
|
32
|
+
var import_fs6 = __toESM(require("fs"));
|
|
33
|
+
|
|
34
|
+
// src/shared/api/dsl.ts
|
|
35
|
+
var VIEWPORT_PRESETS = {
|
|
36
|
+
/** Minimum supported window size (e.g., ) */
|
|
37
|
+
MIN_SUPPORTED: { name: "min-supported", width: 1024, height: 768 },
|
|
38
|
+
/** Standard default window size (e.g., ) */
|
|
39
|
+
DEFAULT: { name: "default", width: 1200, height: 800 },
|
|
40
|
+
/** Wide screen for checking grids and tables */
|
|
41
|
+
WIDE: { name: "wide", width: 1600, height: 900 },
|
|
42
|
+
/** Full HD */
|
|
43
|
+
FULL_HD: { name: "full-hd", width: 1920, height: 1080 }
|
|
44
|
+
};
|
|
45
|
+
function defineVisualTest(scenario) {
|
|
46
|
+
return scenario;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/features/capture/capture.ts
|
|
50
|
+
var import_fs = __toESM(require("fs"));
|
|
51
|
+
var import_path = __toESM(require("path"));
|
|
52
|
+
var CaptureEngine = class {
|
|
53
|
+
outputDir;
|
|
54
|
+
currentStepIndex = 0;
|
|
55
|
+
recordedSnapshots = [];
|
|
56
|
+
constructor(outputDir) {
|
|
57
|
+
this.outputDir = outputDir;
|
|
58
|
+
if (!import_fs.default.existsSync(this.outputDir)) {
|
|
59
|
+
import_fs.default.mkdirSync(this.outputDir, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
getSnapshots() {
|
|
63
|
+
return this.recordedSnapshots;
|
|
64
|
+
}
|
|
65
|
+
getOutputDir() {
|
|
66
|
+
return this.outputDir;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
*/
|
|
70
|
+
async takeSnapshot(page, name, viewport, options2) {
|
|
71
|
+
this.currentStepIndex += 1;
|
|
72
|
+
const paddedIndex = String(this.currentStepIndex).padStart(2, "0");
|
|
73
|
+
const sanitizedName = name.replace(/[^a-zA-Z0-9_\-]/g, "_");
|
|
74
|
+
const fileName = `${paddedIndex}_${sanitizedName}_${viewport.width}x${viewport.height}.png`;
|
|
75
|
+
const filePath = import_path.default.join(this.outputDir, fileName);
|
|
76
|
+
if (options2?.selector) {
|
|
77
|
+
const element = await page.waitForSelector(options2.selector, { timeout: 5e3 });
|
|
78
|
+
await element.screenshot({ path: filePath });
|
|
79
|
+
} else {
|
|
80
|
+
await page.screenshot({ path: filePath, fullPage: options2?.fullPage ?? false });
|
|
81
|
+
}
|
|
82
|
+
const metadata = {
|
|
83
|
+
index: this.currentStepIndex,
|
|
84
|
+
name,
|
|
85
|
+
fileName,
|
|
86
|
+
filePath,
|
|
87
|
+
relativeUri: `./${fileName}`,
|
|
88
|
+
viewport,
|
|
89
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
90
|
+
selector: options2?.selector
|
|
91
|
+
};
|
|
92
|
+
this.recordedSnapshots.push(metadata);
|
|
93
|
+
return metadata;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
*/
|
|
97
|
+
async takeBurst(page, name, viewport, options2) {
|
|
98
|
+
this.currentStepIndex += 1;
|
|
99
|
+
const stepIndex = this.currentStepIndex;
|
|
100
|
+
const paddedIndex = String(stepIndex).padStart(2, "0");
|
|
101
|
+
const sanitizedName = name.replace(/[^a-zA-Z0-9_\-]/g, "_");
|
|
102
|
+
const duration = Math.max(options2.durationMs, 50);
|
|
103
|
+
const interval = Math.max(options2.intervalMs ?? 80, 20);
|
|
104
|
+
const totalFrames = Math.ceil(duration / interval);
|
|
105
|
+
const burstSnapshots = [];
|
|
106
|
+
const burstGroup = sanitizedName;
|
|
107
|
+
for (let frame = 0; frame <= totalFrames; frame++) {
|
|
108
|
+
const elapsedMs = frame * interval;
|
|
109
|
+
const paddedFrame = String(frame + 1).padStart(2, "0");
|
|
110
|
+
const fileName = `${paddedIndex}_burst_${sanitizedName}_f${paddedFrame}_${elapsedMs}ms.png`;
|
|
111
|
+
const filePath = import_path.default.join(this.outputDir, fileName);
|
|
112
|
+
if (options2.selector) {
|
|
113
|
+
const element = await page.$(options2.selector);
|
|
114
|
+
if (element) {
|
|
115
|
+
await element.screenshot({ path: filePath });
|
|
116
|
+
} else {
|
|
117
|
+
await page.screenshot({ path: filePath });
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
await page.screenshot({ path: filePath });
|
|
121
|
+
}
|
|
122
|
+
const meta = {
|
|
123
|
+
index: stepIndex,
|
|
124
|
+
name: `${name} (frame ${frame + 1}, +${elapsedMs}ms)`,
|
|
125
|
+
fileName,
|
|
126
|
+
filePath,
|
|
127
|
+
relativeUri: `./${fileName}`,
|
|
128
|
+
viewport,
|
|
129
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
130
|
+
isBurstFrame: true,
|
|
131
|
+
burstGroup,
|
|
132
|
+
frameIndex: frame + 1,
|
|
133
|
+
selector: options2.selector
|
|
134
|
+
};
|
|
135
|
+
burstSnapshots.push(meta);
|
|
136
|
+
this.recordedSnapshots.push(meta);
|
|
137
|
+
if (frame < totalFrames) {
|
|
138
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return burstSnapshots;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// src/features/reporter/reporter.ts
|
|
146
|
+
var import_fs2 = __toESM(require("fs"));
|
|
147
|
+
var import_path2 = __toESM(require("path"));
|
|
148
|
+
var VisualReporter = class {
|
|
149
|
+
static generateReport(data) {
|
|
150
|
+
const {
|
|
151
|
+
scenario,
|
|
152
|
+
snapshots,
|
|
153
|
+
consoleErrors,
|
|
154
|
+
consoleWarnings,
|
|
155
|
+
outputDir,
|
|
156
|
+
targetMode,
|
|
157
|
+
durationMs
|
|
158
|
+
} = data;
|
|
159
|
+
const manifestPath = import_path2.default.join(outputDir, "manifest.json");
|
|
160
|
+
const reportPath = import_path2.default.join(outputDir, "report.md");
|
|
161
|
+
const manifest = {
|
|
162
|
+
scenarioId: scenario.id,
|
|
163
|
+
title: scenario.title,
|
|
164
|
+
description: scenario.description,
|
|
165
|
+
targetMode,
|
|
166
|
+
durationMs,
|
|
167
|
+
totalSnapshots: snapshots.length,
|
|
168
|
+
consoleErrors: consoleErrors.length,
|
|
169
|
+
consoleWarnings: consoleWarnings.length,
|
|
170
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
171
|
+
snapshots,
|
|
172
|
+
errors: consoleErrors,
|
|
173
|
+
warnings: consoleWarnings
|
|
174
|
+
};
|
|
175
|
+
import_fs2.default.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
|
|
176
|
+
const rows = [];
|
|
177
|
+
const regularSnapshots = snapshots.filter((s) => !s.isBurstFrame);
|
|
178
|
+
const burstGroups = /* @__PURE__ */ new Map();
|
|
179
|
+
snapshots.filter((s) => s.isBurstFrame && s.burstGroup).forEach((s) => {
|
|
180
|
+
const list = burstGroups.get(s.burstGroup) || [];
|
|
181
|
+
list.push(s);
|
|
182
|
+
burstGroups.set(s.burstGroup, list);
|
|
183
|
+
});
|
|
184
|
+
for (const snap of regularSnapshots) {
|
|
185
|
+
const fileUri = `file:///${snap.filePath.replace(/\\/g, "/")}`;
|
|
186
|
+
rows.push(
|
|
187
|
+
`| **${String(snap.index).padStart(2, "0")}** | \`${snap.viewport.width}x${snap.viewport.height}\` | ${snap.name} | [${snap.fileName}](${fileUri}) |`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
let burstSections = "";
|
|
191
|
+
if (burstGroups.size > 0) {
|
|
192
|
+
burstSections += `
|
|
193
|
+
### \u{1F3AC} Animation Bursts
|
|
194
|
+
|
|
195
|
+
`;
|
|
196
|
+
for (const [group, frames] of burstGroups.entries()) {
|
|
197
|
+
burstSections += `#### Animation: \`${group}\` (${frames.length} frames)
|
|
198
|
+
|
|
199
|
+
`;
|
|
200
|
+
burstSections += `| Frame | Viewport | File | Preview |
|
|
201
|
+
| :--- | :--- | :--- | :--- |
|
|
202
|
+
`;
|
|
203
|
+
for (const frame of frames) {
|
|
204
|
+
const fileUri = `file:///${frame.filePath.replace(/\\/g, "/")}`;
|
|
205
|
+
burstSections += `| Frame ${frame.frameIndex} | \`${frame.viewport.width}x${frame.viewport.height}\` | [${frame.fileName}](${fileUri}) |  |
|
|
206
|
+
`;
|
|
207
|
+
}
|
|
208
|
+
burstSections += `
|
|
209
|
+
`;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const healthStatus = this.getHealthStatus(consoleErrors, consoleWarnings);
|
|
213
|
+
let consoleSections = "";
|
|
214
|
+
if (consoleErrors.length > 0) {
|
|
215
|
+
consoleSections += `
|
|
216
|
+
## \u{1F534} Console Errors (${consoleErrors.length})
|
|
217
|
+
|
|
218
|
+
`;
|
|
219
|
+
consoleSections += `> [!CAUTION]
|
|
220
|
+
> Found **${consoleErrors.length}** JavaScript errors during the test. This might indicate broken components, missing data, or unhandled exceptions.
|
|
221
|
+
|
|
222
|
+
`;
|
|
223
|
+
consoleSections += `| # | Time | Error | URL |
|
|
224
|
+
| :-: | :--- | :--- | :--- |
|
|
225
|
+
`;
|
|
226
|
+
consoleErrors.forEach((err, i) => {
|
|
227
|
+
const time = err.timestamp.split("T")[1]?.slice(0, 8) || "";
|
|
228
|
+
const text = err.text.replace(/\|/g, "\\|").slice(0, 200);
|
|
229
|
+
const url = err.url.replace(/\|/g, "\\|");
|
|
230
|
+
consoleSections += `| ${i + 1} | \`${time}\` | ${text} | \`${url}\` |
|
|
231
|
+
`;
|
|
232
|
+
});
|
|
233
|
+
const withStack = consoleErrors.filter((e) => e.stack);
|
|
234
|
+
if (withStack.length > 0) {
|
|
235
|
+
consoleSections += `
|
|
236
|
+
<details>
|
|
237
|
+
<summary>\u{1F4CB} Stack Traces (${withStack.length})</summary>
|
|
238
|
+
|
|
239
|
+
`;
|
|
240
|
+
withStack.forEach((err, i) => {
|
|
241
|
+
consoleSections += `**Error ${i + 1}:** \`${err.text.slice(0, 100)}\`
|
|
242
|
+
\`\`\`
|
|
243
|
+
${err.stack}
|
|
244
|
+
\`\`\`
|
|
245
|
+
|
|
246
|
+
`;
|
|
247
|
+
});
|
|
248
|
+
consoleSections += `</details>
|
|
249
|
+
`;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (consoleWarnings.length > 0) {
|
|
253
|
+
consoleSections += `
|
|
254
|
+
## \u{1F7E1} Console Warnings (${consoleWarnings.length})
|
|
255
|
+
|
|
256
|
+
`;
|
|
257
|
+
consoleSections += `| # | Warning |
|
|
258
|
+
| :-: | :--- |
|
|
259
|
+
`;
|
|
260
|
+
consoleWarnings.slice(0, 20).forEach((warn, i) => {
|
|
261
|
+
const text = warn.text.replace(/\|/g, "\\|").slice(0, 200);
|
|
262
|
+
consoleSections += `| ${i + 1} | ${text} |
|
|
263
|
+
`;
|
|
264
|
+
});
|
|
265
|
+
if (consoleWarnings.length > 20) {
|
|
266
|
+
consoleSections += `
|
|
267
|
+
*...and ${consoleWarnings.length - 20} more warnings (full list in manifest.json)*
|
|
268
|
+
`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const reportContent = `# \u{1F4F8} Visual Test Report: ${scenario.title}
|
|
272
|
+
|
|
273
|
+
> **Scenario ID:** \`${scenario.id}\`
|
|
274
|
+
> **Target Mode:** \`${targetMode.toUpperCase()}\` (${targetMode === "desktop" ? "Native Desktop App / WebView2" : "Fast Web Preview"})
|
|
275
|
+
> **Execution Duration:** ${(durationMs / 1e3).toFixed(2)} s
|
|
276
|
+
> **Total Snapshots Captured:** ${snapshots.length}
|
|
277
|
+
> **Health Status:** ${healthStatus}
|
|
278
|
+
> **Artifacts Folder:** \`${outputDir}\`
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## \u{1F5BC}\uFE0F Primary Snapshot Overview
|
|
283
|
+
|
|
284
|
+
| # | Viewport | Step Description | File Reference |
|
|
285
|
+
| :-: | :--- | :--- | :--- |
|
|
286
|
+
${rows.join("\n")}
|
|
287
|
+
|
|
288
|
+
${burstSections}
|
|
289
|
+
${consoleSections}
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## \u{1F4CB} AI Agent Verification Checklist:
|
|
293
|
+
- [ ] **Console Errors**: ${consoleErrors.length === 0 ? "\u2705 No errors found" : `\u274C ${consoleErrors.length} errors \u2014 MUST REVIEW`}
|
|
294
|
+
- [ ] **Responsiveness at \`1024x768\`**: Elements do not overflow the screen, no unwanted horizontal scroll.
|
|
295
|
+
- [ ] **Typography & Spacing**: Spacing matches the design system and layout grids.
|
|
296
|
+
- [ ] **Color Palette & Theme**: Background tints and button accent colors match the concept.
|
|
297
|
+
- [ ] **Component States**: Modals open centered, dropdowns do not overlap with other layers (z-index).
|
|
298
|
+
- [ ] **Localization**: Verify that there are no raw i18n keys (text with dots like \`sidebar.home\` instead of "Home").
|
|
299
|
+
|
|
300
|
+
`;
|
|
301
|
+
import_fs2.default.writeFileSync(reportPath, reportContent, "utf-8");
|
|
302
|
+
return reportPath;
|
|
303
|
+
}
|
|
304
|
+
static getHealthStatus(errors, warnings) {
|
|
305
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
306
|
+
return "\u2705 Healthy \u2014 no errors or warnings";
|
|
307
|
+
}
|
|
308
|
+
if (errors.length === 0 && warnings.length > 0) {
|
|
309
|
+
return `\u26A0\uFE0F Warnings (${warnings.length}) \u2014 no critical errors`;
|
|
310
|
+
}
|
|
311
|
+
return `\u274C ERRORS (${errors.length} errors, ${warnings.length} warnings) \u2014 requires attention`;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// src/features/console-tracker/consoleTracker.ts
|
|
316
|
+
var ConsoleTracker = class {
|
|
317
|
+
entries = [];
|
|
318
|
+
attached = false;
|
|
319
|
+
/**
|
|
320
|
+
*/
|
|
321
|
+
attach(page) {
|
|
322
|
+
if (this.attached) return;
|
|
323
|
+
this.attached = true;
|
|
324
|
+
page.on("console", (msg) => {
|
|
325
|
+
const type = msg.type();
|
|
326
|
+
const level = this.mapConsoleType(type);
|
|
327
|
+
const entry = {
|
|
328
|
+
level,
|
|
329
|
+
text: msg.text(),
|
|
330
|
+
url: page.url(),
|
|
331
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
332
|
+
};
|
|
333
|
+
this.entries.push(entry);
|
|
334
|
+
if (level === "error") {
|
|
335
|
+
console.log(`\u{1F534} [Console ERROR] ${msg.text()}`);
|
|
336
|
+
} else if (level === "warning") {
|
|
337
|
+
if (!this.isIgnoredWarning(msg.text())) {
|
|
338
|
+
console.log(`\u{1F7E1} [Console WARN] ${msg.text()}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
page.on("pageerror", (error) => {
|
|
343
|
+
const entry = {
|
|
344
|
+
level: "error",
|
|
345
|
+
text: error.message,
|
|
346
|
+
url: page.url(),
|
|
347
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
348
|
+
stack: error.stack
|
|
349
|
+
};
|
|
350
|
+
this.entries.push(entry);
|
|
351
|
+
console.log(`\u{1F4A5} [Page ERROR] ${error.message}`);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
getErrors() {
|
|
355
|
+
return this.entries.filter((e) => e.level === "error");
|
|
356
|
+
}
|
|
357
|
+
getWarnings() {
|
|
358
|
+
return this.entries.filter((e) => e.level === "warning").filter((e) => !this.isIgnoredWarning(e.text));
|
|
359
|
+
}
|
|
360
|
+
getAll() {
|
|
361
|
+
return [...this.entries];
|
|
362
|
+
}
|
|
363
|
+
get errorCount() {
|
|
364
|
+
return this.getErrors().length;
|
|
365
|
+
}
|
|
366
|
+
get warningCount() {
|
|
367
|
+
return this.getWarnings().length;
|
|
368
|
+
}
|
|
369
|
+
get hasErrors() {
|
|
370
|
+
return this.errorCount > 0;
|
|
371
|
+
}
|
|
372
|
+
clear() {
|
|
373
|
+
this.entries = [];
|
|
374
|
+
}
|
|
375
|
+
mapConsoleType(type) {
|
|
376
|
+
switch (type) {
|
|
377
|
+
case "error":
|
|
378
|
+
return "error";
|
|
379
|
+
case "warning":
|
|
380
|
+
return "warning";
|
|
381
|
+
case "info":
|
|
382
|
+
return "info";
|
|
383
|
+
case "debug":
|
|
384
|
+
return "debug";
|
|
385
|
+
default:
|
|
386
|
+
return "log";
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
*/
|
|
391
|
+
isIgnoredWarning(text) {
|
|
392
|
+
const ignoredPatterns = [
|
|
393
|
+
"findDOMNode is deprecated",
|
|
394
|
+
// Chromium DevTools
|
|
395
|
+
"DevTools",
|
|
396
|
+
// Playwright injection
|
|
397
|
+
"__playwright",
|
|
398
|
+
"[vite]",
|
|
399
|
+
// React 18 hydration warnings
|
|
400
|
+
"Extra attributes from the server",
|
|
401
|
+
"Download the React DevTools"
|
|
402
|
+
];
|
|
403
|
+
return ignoredPatterns.some((pattern) => text.includes(pattern));
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// src/shared/drivers/desktopDriver.ts
|
|
408
|
+
var import_child_process = require("child_process");
|
|
409
|
+
var import_http = __toESM(require("http"));
|
|
410
|
+
var import_path3 = __toESM(require("path"));
|
|
411
|
+
var import_fs3 = __toESM(require("fs"));
|
|
412
|
+
|
|
413
|
+
// src/shared/lib/playwrightLoader.ts
|
|
414
|
+
var import_playwright = require("playwright");
|
|
415
|
+
|
|
416
|
+
// src/shared/drivers/desktopDriver.ts
|
|
417
|
+
var DesktopDriver = class {
|
|
418
|
+
port;
|
|
419
|
+
executablePath;
|
|
420
|
+
autoLaunch;
|
|
421
|
+
args;
|
|
422
|
+
env;
|
|
423
|
+
cwd;
|
|
424
|
+
childProcess = null;
|
|
425
|
+
browser = null;
|
|
426
|
+
context = null;
|
|
427
|
+
page = null;
|
|
428
|
+
processStderr = "";
|
|
429
|
+
processExited = false;
|
|
430
|
+
exitCode = null;
|
|
431
|
+
constructor(options2) {
|
|
432
|
+
this.port = options2?.port || 9222;
|
|
433
|
+
this.autoLaunch = options2?.autoLaunch ?? true;
|
|
434
|
+
this.executablePath = options2?.executablePath;
|
|
435
|
+
this.args = options2?.args || [];
|
|
436
|
+
this.env = options2?.env || {};
|
|
437
|
+
this.cwd = options2?.cwd;
|
|
438
|
+
}
|
|
439
|
+
async isPortAvailable() {
|
|
440
|
+
return new Promise((resolve) => {
|
|
441
|
+
const req = import_http.default.get(`http://127.0.0.1:${this.port}/json/version`, (res) => {
|
|
442
|
+
resolve(res.statusCode === 200);
|
|
443
|
+
});
|
|
444
|
+
req.on("error", () => resolve(false));
|
|
445
|
+
req.setTimeout(800, () => {
|
|
446
|
+
req.destroy();
|
|
447
|
+
resolve(false);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
async waitForPort(timeoutMs = 25e3) {
|
|
452
|
+
const startTime = Date.now();
|
|
453
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
454
|
+
if (this.processExited) {
|
|
455
|
+
throw new Error(
|
|
456
|
+
`[DesktopDriver] Process terminated prematurely with exit code ${this.exitCode}.
|
|
457
|
+
Stderr: ${this.processStderr.trim() || "(none)"}`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
if (await this.isPortAvailable()) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
464
|
+
}
|
|
465
|
+
throw new Error(
|
|
466
|
+
`Timeout waiting for WebView2/Chromium CDP port ${this.port}. Last stderr:
|
|
467
|
+
${this.processStderr.trim() || "(no stderr output)"}`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
async start(initialViewport = { width: 1200, height: 800 }) {
|
|
471
|
+
const alreadyRunning = await this.isPortAvailable();
|
|
472
|
+
if (!alreadyRunning) {
|
|
473
|
+
if (!this.autoLaunch) {
|
|
474
|
+
throw new Error(
|
|
475
|
+
`App is not running on port ${this.port} and autoLaunch is false. Start app with WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=${this.port}`
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
if (!this.executablePath) {
|
|
479
|
+
throw new Error(
|
|
480
|
+
`[DesktopDriver] No executablePath provided and nothing is running on port ${this.port}. Specify --exe=<path> in CLI or executablePath in config.`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
const resolvedExe = import_path3.default.resolve(process.cwd(), this.executablePath);
|
|
484
|
+
if (!import_fs3.default.existsSync(resolvedExe)) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`[DesktopDriver] Desktop executable not found at: ${resolvedExe}. Please build your native project first.`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
console.log(`[DesktopDriver] Launching: ${resolvedExe}`);
|
|
490
|
+
const mergedEnv = {
|
|
491
|
+
...process.env,
|
|
492
|
+
...this.env,
|
|
493
|
+
WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: `--remote-debugging-port=${this.port}`
|
|
494
|
+
};
|
|
495
|
+
this.processStderr = "";
|
|
496
|
+
this.processExited = false;
|
|
497
|
+
this.exitCode = null;
|
|
498
|
+
this.childProcess = (0, import_child_process.spawn)(resolvedExe, this.args, {
|
|
499
|
+
env: mergedEnv,
|
|
500
|
+
cwd: this.cwd || import_path3.default.dirname(resolvedExe),
|
|
501
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
502
|
+
detached: false
|
|
503
|
+
});
|
|
504
|
+
this.childProcess.stderr?.on("data", (chunk) => {
|
|
505
|
+
this.processStderr += chunk.toString();
|
|
506
|
+
});
|
|
507
|
+
this.childProcess.on("exit", (code) => {
|
|
508
|
+
this.processExited = true;
|
|
509
|
+
this.exitCode = code;
|
|
510
|
+
});
|
|
511
|
+
this.childProcess.on("error", (err) => {
|
|
512
|
+
console.error("[DesktopDriver] Failed to spawn process:", err);
|
|
513
|
+
});
|
|
514
|
+
console.log(`[DesktopDriver] Waiting for CDP debugging port on ${this.port}...`);
|
|
515
|
+
await this.waitForPort();
|
|
516
|
+
} else {
|
|
517
|
+
console.log(`[DesktopDriver] Attached to already running process on port ${this.port}`);
|
|
518
|
+
}
|
|
519
|
+
console.log(`[DesktopDriver] Connecting Playwright CDP to http://127.0.0.1:${this.port}...`);
|
|
520
|
+
this.browser = await import_playwright.chromium.connectOverCDP(`http://127.0.0.1:${this.port}`);
|
|
521
|
+
const contexts = this.browser.contexts();
|
|
522
|
+
this.context = contexts[0] || await this.browser.newContext();
|
|
523
|
+
const pages = this.context.pages();
|
|
524
|
+
if (pages.length > 0) {
|
|
525
|
+
this.page = pages[0];
|
|
526
|
+
} else {
|
|
527
|
+
this.page = await this.context.waitForEvent("page", { timeout: 1e4 });
|
|
528
|
+
}
|
|
529
|
+
if (initialViewport) {
|
|
530
|
+
await this.page.setViewportSize(initialViewport).catch(() => {
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
return { page: this.page, context: this.context, browser: this.browser };
|
|
534
|
+
}
|
|
535
|
+
async stop() {
|
|
536
|
+
if (this.browser) {
|
|
537
|
+
await this.browser.close().catch(() => {
|
|
538
|
+
});
|
|
539
|
+
this.browser = null;
|
|
540
|
+
}
|
|
541
|
+
if (this.childProcess && !this.childProcess.killed) {
|
|
542
|
+
console.log("[DesktopDriver] Terminating spawned desktop process...");
|
|
543
|
+
try {
|
|
544
|
+
if (process.platform === "win32" && this.childProcess.pid) {
|
|
545
|
+
(0, import_child_process.spawn)("taskkill", ["/pid", String(this.childProcess.pid), "/T", "/F"], { stdio: "ignore" });
|
|
546
|
+
} else {
|
|
547
|
+
this.childProcess.kill("SIGTERM");
|
|
548
|
+
}
|
|
549
|
+
} catch {
|
|
550
|
+
}
|
|
551
|
+
this.childProcess = null;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
// src/shared/drivers/previewDriver.ts
|
|
557
|
+
var import_http2 = __toESM(require("http"));
|
|
558
|
+
var import_fs5 = __toESM(require("fs"));
|
|
559
|
+
var import_path5 = __toESM(require("path"));
|
|
560
|
+
|
|
561
|
+
// src/features/mock-ipc/mockIpc.ts
|
|
562
|
+
var MockIpcRegistry = class {
|
|
563
|
+
mocks = /* @__PURE__ */ new Map();
|
|
564
|
+
set(action, data, options2) {
|
|
565
|
+
this.mocks.set(action, {
|
|
566
|
+
action,
|
|
567
|
+
data,
|
|
568
|
+
type: options2?.type ?? "SUCCESS",
|
|
569
|
+
delayMs: options2?.delayMs ?? 20
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
setBatch(entries) {
|
|
573
|
+
for (const entry of entries) {
|
|
574
|
+
this.set(entry.action, entry.data, { type: entry.type, delayMs: entry.delayMs });
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
remove(action) {
|
|
578
|
+
this.mocks.delete(action);
|
|
579
|
+
}
|
|
580
|
+
clear() {
|
|
581
|
+
this.mocks.clear();
|
|
582
|
+
}
|
|
583
|
+
get(action) {
|
|
584
|
+
return this.mocks.get(action) ?? null;
|
|
585
|
+
}
|
|
586
|
+
toSerializable() {
|
|
587
|
+
const result = {};
|
|
588
|
+
for (const [action, entry] of this.mocks.entries()) {
|
|
589
|
+
result[action] = {
|
|
590
|
+
data: entry.data,
|
|
591
|
+
type: entry.type ?? "SUCCESS",
|
|
592
|
+
delayMs: entry.delayMs ?? 20
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
return result;
|
|
596
|
+
}
|
|
597
|
+
get size() {
|
|
598
|
+
return this.mocks.size;
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
function generateMockIpcScript(registry) {
|
|
602
|
+
const mocksJson = JSON.stringify(registry.toSerializable());
|
|
603
|
+
return `
|
|
604
|
+
(() => {
|
|
605
|
+
const __mockTable = ${mocksJson};
|
|
606
|
+
|
|
607
|
+
window.__visualRunnerMocks = __mockTable;
|
|
608
|
+
|
|
609
|
+
// Generic IPC mock bridge for modern web applications
|
|
610
|
+
window.__mockIpc = {
|
|
611
|
+
invoke: (action, payload) => {
|
|
612
|
+
return new Promise((resolve, reject) => {
|
|
613
|
+
const mock = window.__visualRunnerMocks[action];
|
|
614
|
+
|
|
615
|
+
if (mock) {
|
|
616
|
+
setTimeout(() => {
|
|
617
|
+
if (mock.type === 'ERROR') {
|
|
618
|
+
reject(new Error(mock.data));
|
|
619
|
+
} else {
|
|
620
|
+
resolve(mock.data);
|
|
621
|
+
}
|
|
622
|
+
}, mock.delayMs || 20);
|
|
623
|
+
} else {
|
|
624
|
+
console.warn('[Mock IPC] No mock for action:', action, '\u2014 returning empty SUCCESS');
|
|
625
|
+
setTimeout(() => resolve(null), 20);
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// Legacy fallback for generic window.external
|
|
632
|
+
if (!window.external) {
|
|
633
|
+
window.external = {};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
window.external.sendMessage = (msg) => {
|
|
637
|
+
try {
|
|
638
|
+
const parsed = JSON.parse(msg);
|
|
639
|
+
const action = parsed.Action || parsed.action;
|
|
640
|
+
const id = parsed.Id || parsed.id;
|
|
641
|
+
|
|
642
|
+
const mock = window.__visualRunnerMocks[action];
|
|
643
|
+
|
|
644
|
+
if (mock) {
|
|
645
|
+
const response = { Id: id, Type: mock.type || 'SUCCESS', Data: mock.data };
|
|
646
|
+
setTimeout(() => {
|
|
647
|
+
const cb = window.__mockCallback;
|
|
648
|
+
if (cb) cb(JSON.stringify(response));
|
|
649
|
+
}, mock.delayMs || 20);
|
|
650
|
+
} else {
|
|
651
|
+
setTimeout(() => {
|
|
652
|
+
const cb = window.__mockCallback;
|
|
653
|
+
if (cb) cb(JSON.stringify({ Id: id, Type: 'SUCCESS', Data: null }));
|
|
654
|
+
}, 20);
|
|
655
|
+
}
|
|
656
|
+
} catch (e) {
|
|
657
|
+
console.error('[Mock IPC] Failed to process legacy message:', e);
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
window.external.receiveMessage = (callback) => {
|
|
662
|
+
window.__mockCallback = callback;
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
console.log('[Visual Runner] Mock IPC bridge initialized with', Object.keys(window.__visualRunnerMocks).length, 'mocked actions');
|
|
666
|
+
})();
|
|
667
|
+
`;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/shared/lib/config.ts
|
|
671
|
+
var import_fs4 = __toESM(require("fs"));
|
|
672
|
+
var import_path4 = __toESM(require("path"));
|
|
673
|
+
function loadConfig(cwd = process.cwd()) {
|
|
674
|
+
const configPath = import_path4.default.join(cwd, "agent-lens.json");
|
|
675
|
+
if (import_fs4.default.existsSync(configPath)) {
|
|
676
|
+
try {
|
|
677
|
+
const raw = import_fs4.default.readFileSync(configPath, "utf8");
|
|
678
|
+
return JSON.parse(raw);
|
|
679
|
+
} catch (e) {
|
|
680
|
+
console.warn(`\u26A0\uFE0F Warning: Failed to parse agent-lens.json:`, e);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
const pkgPath = import_path4.default.join(cwd, "package.json");
|
|
684
|
+
if (import_fs4.default.existsSync(pkgPath)) {
|
|
685
|
+
try {
|
|
686
|
+
const raw = import_fs4.default.readFileSync(pkgPath, "utf8");
|
|
687
|
+
const pkg = JSON.parse(raw);
|
|
688
|
+
if (pkg.agentLens && typeof pkg.agentLens === "object") {
|
|
689
|
+
return pkg.agentLens;
|
|
690
|
+
}
|
|
691
|
+
} catch {
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return {};
|
|
695
|
+
}
|
|
696
|
+
function resolveWwwrootDir(customPath, cwd = process.cwd()) {
|
|
697
|
+
if (customPath) {
|
|
698
|
+
return import_path4.default.resolve(cwd, customPath);
|
|
699
|
+
}
|
|
700
|
+
const standardDirs = [
|
|
701
|
+
"dist",
|
|
702
|
+
"build",
|
|
703
|
+
"wwwroot",
|
|
704
|
+
"Frontend/dist",
|
|
705
|
+
"frontend/dist",
|
|
706
|
+
"client/dist",
|
|
707
|
+
"web/dist",
|
|
708
|
+
"ui/dist"
|
|
709
|
+
];
|
|
710
|
+
for (const rel of standardDirs) {
|
|
711
|
+
const candidate = import_path4.default.resolve(cwd, rel);
|
|
712
|
+
if (import_fs4.default.existsSync(candidate) && import_fs4.default.existsSync(import_path4.default.join(candidate, "index.html"))) {
|
|
713
|
+
return candidate;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const foundDeepWwwroot = findDeepIndexHtmlDir(cwd, 4);
|
|
717
|
+
if (foundDeepWwwroot) {
|
|
718
|
+
return foundDeepWwwroot;
|
|
719
|
+
}
|
|
720
|
+
for (const rel of standardDirs) {
|
|
721
|
+
const candidate = import_path4.default.resolve(cwd, rel);
|
|
722
|
+
if (import_fs4.default.existsSync(candidate)) {
|
|
723
|
+
return candidate;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return import_path4.default.resolve(cwd, "dist");
|
|
727
|
+
}
|
|
728
|
+
function detectStartCwd(customCwd, rootCwd = process.cwd()) {
|
|
729
|
+
if (customCwd) {
|
|
730
|
+
return import_path4.default.resolve(rootCwd, customCwd);
|
|
731
|
+
}
|
|
732
|
+
const subdirectories = ["Frontend", "frontend", "client", "web", "ui", "app"];
|
|
733
|
+
for (const sub of subdirectories) {
|
|
734
|
+
const subPkg = import_path4.default.join(rootCwd, sub, "package.json");
|
|
735
|
+
if (import_fs4.default.existsSync(subPkg)) {
|
|
736
|
+
try {
|
|
737
|
+
const json = JSON.parse(import_fs4.default.readFileSync(subPkg, "utf8"));
|
|
738
|
+
if (json.scripts && (json.scripts.dev || json.scripts.start || json.scripts.build)) {
|
|
739
|
+
return import_path4.default.join(rootCwd, sub);
|
|
740
|
+
}
|
|
741
|
+
} catch {
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return rootCwd;
|
|
746
|
+
}
|
|
747
|
+
function findDeepIndexHtmlDir(dir, maxDepth, currentDepth = 0) {
|
|
748
|
+
if (currentDepth > maxDepth || !import_fs4.default.existsSync(dir)) return null;
|
|
749
|
+
try {
|
|
750
|
+
const entries = import_fs4.default.readdirSync(dir, { withFileTypes: true });
|
|
751
|
+
for (const entry of entries) {
|
|
752
|
+
if (entry.isDirectory()) {
|
|
753
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "artifacts") {
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
const subDir = import_path4.default.join(dir, entry.name);
|
|
757
|
+
if (entry.name === "wwwroot" && import_fs4.default.existsSync(import_path4.default.join(subDir, "index.html"))) {
|
|
758
|
+
return subDir;
|
|
759
|
+
}
|
|
760
|
+
const found = findDeepIndexHtmlDir(subDir, maxDepth, currentDepth + 1);
|
|
761
|
+
if (found) return found;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
} catch {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// src/shared/drivers/previewDriver.ts
|
|
771
|
+
var MIME_TYPES = {
|
|
772
|
+
".html": "text/html; charset=utf-8",
|
|
773
|
+
".js": "application/javascript; charset=utf-8",
|
|
774
|
+
".css": "text/css; charset=utf-8",
|
|
775
|
+
".json": "application/json; charset=utf-8",
|
|
776
|
+
".png": "image/png",
|
|
777
|
+
".jpg": "image/jpeg",
|
|
778
|
+
".jpeg": "image/jpeg",
|
|
779
|
+
".gif": "image/gif",
|
|
780
|
+
".svg": "image/svg+xml",
|
|
781
|
+
".ico": "image/x-icon",
|
|
782
|
+
".woff": "font/woff",
|
|
783
|
+
".woff2": "font/woff2",
|
|
784
|
+
".ttf": "font/ttf"
|
|
785
|
+
};
|
|
786
|
+
var PreviewDriver = class {
|
|
787
|
+
server = null;
|
|
788
|
+
browser = null;
|
|
789
|
+
context = null;
|
|
790
|
+
page = null;
|
|
791
|
+
serverPort = 0;
|
|
792
|
+
options;
|
|
793
|
+
_baseUrl = "";
|
|
794
|
+
mockRegistry;
|
|
795
|
+
constructor(options2) {
|
|
796
|
+
this.options = options2 || {};
|
|
797
|
+
if (!this.options.url) {
|
|
798
|
+
this.options.wwwrootDir = resolveWwwrootDir(this.options.wwwrootDir);
|
|
799
|
+
}
|
|
800
|
+
this.mockRegistry = new MockIpcRegistry();
|
|
801
|
+
}
|
|
802
|
+
get baseUrl() {
|
|
803
|
+
return this._baseUrl;
|
|
804
|
+
}
|
|
805
|
+
async start(initialViewport = { width: 1200, height: 800 }) {
|
|
806
|
+
let targetUrl = this.options.url;
|
|
807
|
+
if (!targetUrl) {
|
|
808
|
+
if (!import_fs5.default.existsSync(this.options.wwwrootDir)) {
|
|
809
|
+
throw new Error(`Directory not found at: ${this.options.wwwrootDir}. Please build your frontend project first or pass --url=<url>.`);
|
|
810
|
+
}
|
|
811
|
+
await new Promise((resolve, reject) => {
|
|
812
|
+
this.server = import_http2.default.createServer((req, res) => {
|
|
813
|
+
let reqUrl = req.url?.split("?")[0] || "/";
|
|
814
|
+
if (reqUrl === "/") reqUrl = "/index.html";
|
|
815
|
+
let safePath = import_path5.default.normalize(import_path5.default.join(this.options.wwwrootDir, reqUrl));
|
|
816
|
+
if (!safePath.startsWith(this.options.wwwrootDir)) {
|
|
817
|
+
res.writeHead(403);
|
|
818
|
+
return res.end("Forbidden");
|
|
819
|
+
}
|
|
820
|
+
if (!import_fs5.default.existsSync(safePath) || import_fs5.default.statSync(safePath).isDirectory()) {
|
|
821
|
+
safePath = import_path5.default.join(this.options.wwwrootDir, "index.html");
|
|
822
|
+
}
|
|
823
|
+
const ext = import_path5.default.extname(safePath).toLowerCase();
|
|
824
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
825
|
+
try {
|
|
826
|
+
const content = import_fs5.default.readFileSync(safePath);
|
|
827
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
828
|
+
res.end(content);
|
|
829
|
+
} catch (e) {
|
|
830
|
+
res.writeHead(500);
|
|
831
|
+
res.end(`Server error: ${e.message}`);
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
this.server.listen(0, "127.0.0.1", () => {
|
|
835
|
+
const addr = this.server?.address();
|
|
836
|
+
if (typeof addr === "object" && addr?.port) {
|
|
837
|
+
this.serverPort = addr.port;
|
|
838
|
+
this._baseUrl = `http://127.0.0.1:${this.serverPort}`;
|
|
839
|
+
resolve();
|
|
840
|
+
} else {
|
|
841
|
+
reject(new Error("Failed to acquire port for static preview server"));
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
targetUrl = `${this._baseUrl}/index.html`;
|
|
846
|
+
} else {
|
|
847
|
+
this._baseUrl = targetUrl;
|
|
848
|
+
console.log(`\u{1F310} [PreviewDriver] Connecting directly to live URL: ${targetUrl}`);
|
|
849
|
+
}
|
|
850
|
+
this.browser = await import_playwright.chromium.launch({
|
|
851
|
+
headless: !this.options.headed,
|
|
852
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"]
|
|
853
|
+
});
|
|
854
|
+
this.context = await this.browser.newContext({
|
|
855
|
+
viewport: initialViewport,
|
|
856
|
+
deviceScaleFactor: 1
|
|
857
|
+
});
|
|
858
|
+
if (this.mockRegistry.size > 0) {
|
|
859
|
+
const mockScript = generateMockIpcScript(this.mockRegistry);
|
|
860
|
+
await this.context.addInitScript(mockScript);
|
|
861
|
+
}
|
|
862
|
+
this.page = await this.context.newPage();
|
|
863
|
+
await this.page.goto(targetUrl, { waitUntil: "domcontentloaded" });
|
|
864
|
+
return { page: this.page, context: this.context, browser: this.browser };
|
|
865
|
+
}
|
|
866
|
+
async updateMockIpc(action, data, options2) {
|
|
867
|
+
if (!this.page) {
|
|
868
|
+
throw new Error("PreviewDriver not started. Call start() first.");
|
|
869
|
+
}
|
|
870
|
+
this.mockRegistry.set(action, data, options2);
|
|
871
|
+
await this.page.evaluate(
|
|
872
|
+
({ action: action2, mock }) => {
|
|
873
|
+
if (!window.__visualRunnerMocks) {
|
|
874
|
+
window.__visualRunnerMocks = {};
|
|
875
|
+
}
|
|
876
|
+
window.__visualRunnerMocks[action2] = mock;
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
action,
|
|
880
|
+
mock: {
|
|
881
|
+
data,
|
|
882
|
+
type: options2?.type ?? "SUCCESS",
|
|
883
|
+
delayMs: options2?.delayMs ?? 20
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
async stop() {
|
|
889
|
+
if (this.context) {
|
|
890
|
+
await this.context.close().catch(() => {
|
|
891
|
+
});
|
|
892
|
+
this.context = null;
|
|
893
|
+
}
|
|
894
|
+
if (this.browser) {
|
|
895
|
+
await this.browser.close().catch(() => {
|
|
896
|
+
});
|
|
897
|
+
this.browser = null;
|
|
898
|
+
}
|
|
899
|
+
if (this.server) {
|
|
900
|
+
await new Promise((resolve) => {
|
|
901
|
+
this.server?.close(() => resolve());
|
|
902
|
+
});
|
|
903
|
+
this.server = null;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
// src/shared/lib/processManager.ts
|
|
909
|
+
var import_child_process2 = require("child_process");
|
|
910
|
+
var import_tree_kill = __toESM(require("tree-kill"));
|
|
911
|
+
var ProcessManager = class {
|
|
912
|
+
child = null;
|
|
913
|
+
stderrOutput = "";
|
|
914
|
+
stdoutOutput = "";
|
|
915
|
+
hasExited = false;
|
|
916
|
+
exitCode = null;
|
|
917
|
+
/**
|
|
918
|
+
* Spawns a background process (e.g. dev server, backend, or app)
|
|
919
|
+
*/
|
|
920
|
+
async start(command, options2) {
|
|
921
|
+
this.stderrOutput = "";
|
|
922
|
+
this.stdoutOutput = "";
|
|
923
|
+
this.hasExited = false;
|
|
924
|
+
this.exitCode = null;
|
|
925
|
+
const cwd = options2?.cwd || process.cwd();
|
|
926
|
+
const env = { ...process.env, ...options2?.env };
|
|
927
|
+
console.log(`\u{1F680} [ProcessManager] Starting command: "${command}" in ${cwd}`);
|
|
928
|
+
this.child = (0, import_child_process2.spawn)(command, {
|
|
929
|
+
cwd,
|
|
930
|
+
env,
|
|
931
|
+
shell: options2?.shell ?? true,
|
|
932
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
933
|
+
});
|
|
934
|
+
this.child.stdout?.on("data", (chunk) => {
|
|
935
|
+
const str = chunk.toString();
|
|
936
|
+
this.stdoutOutput += str;
|
|
937
|
+
});
|
|
938
|
+
this.child.stderr?.on("data", (chunk) => {
|
|
939
|
+
const str = chunk.toString();
|
|
940
|
+
this.stderrOutput += str;
|
|
941
|
+
});
|
|
942
|
+
this.child.on("exit", (code) => {
|
|
943
|
+
this.hasExited = true;
|
|
944
|
+
this.exitCode = code;
|
|
945
|
+
});
|
|
946
|
+
this.child.on("error", (err) => {
|
|
947
|
+
console.error(`\u274C [ProcessManager] Failed to start command: "${command}":`, err.message);
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Polls a URL until it starts responding or until timeout is reached.
|
|
952
|
+
*/
|
|
953
|
+
async waitForUrl(url, timeoutMs = 3e4) {
|
|
954
|
+
const startTime = Date.now();
|
|
955
|
+
console.log(`\u23F3 [ProcessManager] Waiting for URL to become available: ${url} (timeout: ${timeoutMs / 1e3}s)...`);
|
|
956
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
957
|
+
if (this.hasExited && this.exitCode !== 0) {
|
|
958
|
+
throw new Error(
|
|
959
|
+
`[ProcessManager] Process exited prematurely with code ${this.exitCode}.
|
|
960
|
+
Stderr:
|
|
961
|
+
${this.stderrOutput.trim() || "(no stderr)"}
|
|
962
|
+
Stdout:
|
|
963
|
+
${this.stdoutOutput.slice(-500).trim()}`
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
try {
|
|
967
|
+
const response = await fetch(url, { method: "GET", signal: AbortSignal.timeout(2e3) });
|
|
968
|
+
if (response.status) {
|
|
969
|
+
console.log(`\u2705 [ProcessManager] Server responded with status ${response.status} at ${url}`);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
} catch {
|
|
973
|
+
}
|
|
974
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
975
|
+
}
|
|
976
|
+
throw new Error(
|
|
977
|
+
`[ProcessManager] Timeout (${timeoutMs / 1e3}s) waiting for server at ${url}.
|
|
978
|
+
Last stdout:
|
|
979
|
+
${this.stdoutOutput.slice(-500).trim()}
|
|
980
|
+
Last stderr:
|
|
981
|
+
${this.stderrOutput.trim()}`
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Gracefully and forcefully kills the process and all its children.
|
|
986
|
+
*/
|
|
987
|
+
async stop() {
|
|
988
|
+
if (!this.child || !this.child.pid || this.hasExited) {
|
|
989
|
+
this.child = null;
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const pid = this.child.pid;
|
|
993
|
+
console.log(`\u{1F6D1} [ProcessManager] Terminating process tree (PID: ${pid})...`);
|
|
994
|
+
await new Promise((resolve) => {
|
|
995
|
+
(0, import_tree_kill.default)(pid, "SIGKILL", (err) => {
|
|
996
|
+
if (err) {
|
|
997
|
+
if (process.platform === "win32") {
|
|
998
|
+
try {
|
|
999
|
+
(0, import_child_process2.spawn)("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
1000
|
+
} catch {
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
resolve();
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
this.child = null;
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
// src/features/runner/runner.ts
|
|
1012
|
+
async function runVisualScenario(options2) {
|
|
1013
|
+
const { scenario } = options2;
|
|
1014
|
+
const targetMode = options2.targetMode || "preview";
|
|
1015
|
+
const startTime = Date.now();
|
|
1016
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
1017
|
+
const artifactsRoot = options2.artifactsRoot || import_path6.default.resolve(process.cwd(), "artifacts");
|
|
1018
|
+
if (options2.cleanArtifacts && import_fs6.default.existsSync(artifactsRoot)) {
|
|
1019
|
+
console.log(`\u{1F9F9} [Clean Artifacts] Purging previous artifact runs in ${artifactsRoot}...`);
|
|
1020
|
+
const entries = import_fs6.default.readdirSync(artifactsRoot, { withFileTypes: true });
|
|
1021
|
+
for (const entry of entries) {
|
|
1022
|
+
import_fs6.default.rmSync(import_path6.default.join(artifactsRoot, entry.name), { recursive: true, force: true });
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
const scenarioArtifactsDir = import_path6.default.join(artifactsRoot, `${scenario.id}_${timestamp}`);
|
|
1026
|
+
if (!import_fs6.default.existsSync(scenarioArtifactsDir)) {
|
|
1027
|
+
import_fs6.default.mkdirSync(scenarioArtifactsDir, { recursive: true });
|
|
1028
|
+
}
|
|
1029
|
+
const captureEngine = new CaptureEngine(scenarioArtifactsDir);
|
|
1030
|
+
const consoleTracker = new ConsoleTracker();
|
|
1031
|
+
const defaultViewport = scenario.viewports?.[0] || VIEWPORT_PRESETS.DEFAULT;
|
|
1032
|
+
let currentViewport = { width: defaultViewport.width, height: defaultViewport.height };
|
|
1033
|
+
let desktopDriver = null;
|
|
1034
|
+
let previewDriver = null;
|
|
1035
|
+
let processManager = null;
|
|
1036
|
+
try {
|
|
1037
|
+
let page;
|
|
1038
|
+
let context;
|
|
1039
|
+
console.log(`
|
|
1040
|
+
========================================`);
|
|
1041
|
+
console.log(`\u{1F680} Starting Visual Test: ${scenario.title}`);
|
|
1042
|
+
console.log(`\u{1F3AF} Mode: ${targetMode.toUpperCase()}`);
|
|
1043
|
+
console.log(`\u{1F4C1} Artifacts: ${scenarioArtifactsDir}`);
|
|
1044
|
+
console.log(`========================================
|
|
1045
|
+
`);
|
|
1046
|
+
if (options2.startCommand) {
|
|
1047
|
+
processManager = new ProcessManager();
|
|
1048
|
+
await processManager.start(options2.startCommand, { cwd: options2.startCwd });
|
|
1049
|
+
const waitTarget = options2.url || "http://localhost:5173";
|
|
1050
|
+
await processManager.waitForUrl(waitTarget);
|
|
1051
|
+
}
|
|
1052
|
+
if (typeof scenario.setup === "function") {
|
|
1053
|
+
console.log(`\u{1F527} [Scenario Setup] Executing setup hook...`);
|
|
1054
|
+
await scenario.setup();
|
|
1055
|
+
}
|
|
1056
|
+
if (targetMode === "desktop") {
|
|
1057
|
+
desktopDriver = new DesktopDriver({
|
|
1058
|
+
port: options2.port || 9222,
|
|
1059
|
+
autoLaunch: options2.autoLaunchDesktop ?? true,
|
|
1060
|
+
executablePath: options2.executablePath,
|
|
1061
|
+
args: options2.desktopArgs,
|
|
1062
|
+
env: options2.desktopEnv
|
|
1063
|
+
});
|
|
1064
|
+
const res = await desktopDriver.start(currentViewport);
|
|
1065
|
+
page = res.page;
|
|
1066
|
+
context = res.context;
|
|
1067
|
+
} else {
|
|
1068
|
+
previewDriver = new PreviewDriver({
|
|
1069
|
+
wwwrootDir: options2.wwwrootDir,
|
|
1070
|
+
url: options2.url,
|
|
1071
|
+
headed: options2.headed
|
|
1072
|
+
});
|
|
1073
|
+
const mergedMocks = [...options2.globalMocks || [], ...scenario.mockIpc || []];
|
|
1074
|
+
if (mergedMocks.length > 0) {
|
|
1075
|
+
console.log(`\u{1F4E6} [Mock IPC] Applying ${mergedMocks.length} mocks`);
|
|
1076
|
+
previewDriver.mockRegistry.setBatch(mergedMocks);
|
|
1077
|
+
}
|
|
1078
|
+
const res = await previewDriver.start(currentViewport);
|
|
1079
|
+
page = res.page;
|
|
1080
|
+
context = res.context;
|
|
1081
|
+
}
|
|
1082
|
+
consoleTracker.attach(page);
|
|
1083
|
+
console.log(`\u{1F50D} [Console Tracker] Attached \u2014 errors and warnings will be captured
|
|
1084
|
+
`);
|
|
1085
|
+
const doNavigate = async (route) => {
|
|
1086
|
+
if (route.startsWith("http://") || route.startsWith("https://")) {
|
|
1087
|
+
await page.goto(route, { waitUntil: "domcontentloaded" });
|
|
1088
|
+
await page.waitForTimeout(300);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
if (route.startsWith("#")) {
|
|
1092
|
+
await page.evaluate((r) => {
|
|
1093
|
+
window.location.hash = r;
|
|
1094
|
+
}, route);
|
|
1095
|
+
await page.waitForTimeout(300);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
if (previewDriver?.baseUrl && options2.url) {
|
|
1099
|
+
try {
|
|
1100
|
+
const fullUrl = new URL(route, previewDriver.baseUrl).toString();
|
|
1101
|
+
await page.goto(fullUrl, { waitUntil: "domcontentloaded" });
|
|
1102
|
+
await page.waitForTimeout(300);
|
|
1103
|
+
return;
|
|
1104
|
+
} catch {
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
const routePath = route.startsWith("/") ? route : `/${route}`;
|
|
1108
|
+
await page.evaluate((r) => {
|
|
1109
|
+
if (window.location.hash !== void 0) {
|
|
1110
|
+
window.location.hash = r;
|
|
1111
|
+
}
|
|
1112
|
+
}, routePath);
|
|
1113
|
+
await page.waitForTimeout(300);
|
|
1114
|
+
};
|
|
1115
|
+
if (scenario.route) {
|
|
1116
|
+
console.log(`[Runner] Navigating to route: ${scenario.route}`);
|
|
1117
|
+
await doNavigate(scenario.route);
|
|
1118
|
+
}
|
|
1119
|
+
const ctx = {
|
|
1120
|
+
page,
|
|
1121
|
+
context,
|
|
1122
|
+
targetMode,
|
|
1123
|
+
currentViewport,
|
|
1124
|
+
capture: async (name, opts) => {
|
|
1125
|
+
console.log(`\u{1F4F8} [Snapshot] ${name} (${currentViewport.width}x${currentViewport.height})`);
|
|
1126
|
+
return await captureEngine.takeSnapshot(page, name, currentViewport, opts);
|
|
1127
|
+
},
|
|
1128
|
+
captureBurst: async (name, opts) => {
|
|
1129
|
+
console.log(`\u{1F3AC} [Burst] ${name} (duration: ${opts.durationMs}ms, interval: ${opts.intervalMs ?? 80}ms)`);
|
|
1130
|
+
return await captureEngine.takeBurst(page, name, currentViewport, opts);
|
|
1131
|
+
},
|
|
1132
|
+
navigate: async (route) => {
|
|
1133
|
+
console.log(`\u{1F9ED} [Navigate] ${route}`);
|
|
1134
|
+
await doNavigate(route);
|
|
1135
|
+
},
|
|
1136
|
+
// ─── Viewport ───
|
|
1137
|
+
resize: async (width, height) => {
|
|
1138
|
+
console.log(`\u{1F4D0} [Resize] ${width}x${height}`);
|
|
1139
|
+
currentViewport = { width, height };
|
|
1140
|
+
ctx.currentViewport = currentViewport;
|
|
1141
|
+
await page.setViewportSize(currentViewport);
|
|
1142
|
+
await page.waitForTimeout(200);
|
|
1143
|
+
},
|
|
1144
|
+
setPreset: async (preset) => {
|
|
1145
|
+
console.log(`\u{1F4D0} [Preset] ${preset.name} (${preset.width}x${preset.height})`);
|
|
1146
|
+
await ctx.resize(preset.width, preset.height);
|
|
1147
|
+
},
|
|
1148
|
+
resizeToFit: async (selector, padding = 0) => {
|
|
1149
|
+
let boundingBox;
|
|
1150
|
+
if (selector) {
|
|
1151
|
+
const el = await page.$(selector);
|
|
1152
|
+
if (el) {
|
|
1153
|
+
boundingBox = await el.boundingBox();
|
|
1154
|
+
}
|
|
1155
|
+
} else {
|
|
1156
|
+
boundingBox = await page.evaluate(() => {
|
|
1157
|
+
return {
|
|
1158
|
+
width: document.documentElement.scrollWidth,
|
|
1159
|
+
height: document.documentElement.scrollHeight
|
|
1160
|
+
};
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
if (boundingBox) {
|
|
1164
|
+
const newWidth = Math.ceil(boundingBox.width) + padding * 2;
|
|
1165
|
+
const newHeight = Math.ceil(boundingBox.height) + padding * 2;
|
|
1166
|
+
console.log(`\u{1F4D0} [ResizeToFit] ${selector || "body"} -> ${newWidth}x${newHeight}`);
|
|
1167
|
+
await ctx.resize(newWidth, newHeight);
|
|
1168
|
+
} else {
|
|
1169
|
+
console.log(`\u26A0\uFE0F [ResizeToFit] Element ${selector} not found or has no bounding box.`);
|
|
1170
|
+
}
|
|
1171
|
+
},
|
|
1172
|
+
wait: async (ms) => {
|
|
1173
|
+
await page.waitForTimeout(ms);
|
|
1174
|
+
},
|
|
1175
|
+
waitForSelector: async (selector, timeoutMs = 5e3) => {
|
|
1176
|
+
await page.waitForSelector(selector, { timeout: timeoutMs });
|
|
1177
|
+
},
|
|
1178
|
+
click: async (selector) => {
|
|
1179
|
+
console.log(`\u{1F5B1}\uFE0F [Click] ${selector}`);
|
|
1180
|
+
await page.click(selector);
|
|
1181
|
+
},
|
|
1182
|
+
rightClick: async (selector) => {
|
|
1183
|
+
console.log(`\u{1F5B1}\uFE0F [RightClick] ${selector}`);
|
|
1184
|
+
await page.click(selector, { button: "right" });
|
|
1185
|
+
},
|
|
1186
|
+
type: async (selector, text) => {
|
|
1187
|
+
console.log(`\u2328\uFE0F [Type] ${selector} -> "${text}"`);
|
|
1188
|
+
await page.fill(selector, text);
|
|
1189
|
+
},
|
|
1190
|
+
selectOption: async (selector, value) => {
|
|
1191
|
+
console.log(`\u2705 [Select] ${selector} -> "${value}"`);
|
|
1192
|
+
await page.selectOption(selector, value);
|
|
1193
|
+
},
|
|
1194
|
+
hover: async (selector) => {
|
|
1195
|
+
console.log(`\u{1F446} [Hover] ${selector}`);
|
|
1196
|
+
await page.hover(selector);
|
|
1197
|
+
},
|
|
1198
|
+
scroll: async (selector, deltaY) => {
|
|
1199
|
+
console.log(`\u{1F4DC} [Scroll] ${selector} by ${deltaY}px`);
|
|
1200
|
+
await page.evaluate(({ sel, dY }) => {
|
|
1201
|
+
const el = document.querySelector(sel);
|
|
1202
|
+
if (el) {
|
|
1203
|
+
el.scrollTop += dY;
|
|
1204
|
+
} else {
|
|
1205
|
+
window.scrollBy(0, dY);
|
|
1206
|
+
}
|
|
1207
|
+
}, { sel: selector, dY: deltaY });
|
|
1208
|
+
await page.waitForTimeout(100);
|
|
1209
|
+
},
|
|
1210
|
+
log: (msg) => {
|
|
1211
|
+
console.log(`\u2139\uFE0F [Scenario] ${msg}`);
|
|
1212
|
+
},
|
|
1213
|
+
// ─── Mock IPC ───
|
|
1214
|
+
setMockIpc: async (action, data, mockOptions) => {
|
|
1215
|
+
if (targetMode === "desktop") {
|
|
1216
|
+
console.log(`\u26A0\uFE0F [Mock IPC] setMockIpc ignored in desktop mode (real backend handles IPC)`);
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
if (!previewDriver) {
|
|
1220
|
+
console.log(`\u26A0\uFE0F [Mock IPC] PreviewDriver not available`);
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
console.log(`\u{1F4E6} [Mock IPC] Set ${action} -> ${typeof data === "string" ? data : JSON.stringify(data).slice(0, 80)}...`);
|
|
1224
|
+
await previewDriver.updateMockIpc(action, data, mockOptions);
|
|
1225
|
+
},
|
|
1226
|
+
getConsoleErrors: () => consoleTracker.getErrors(),
|
|
1227
|
+
getConsoleWarnings: () => consoleTracker.getWarnings(),
|
|
1228
|
+
hasConsoleErrors: () => consoleTracker.hasErrors,
|
|
1229
|
+
// ─── DOM Assertions ───
|
|
1230
|
+
readText: async (selector) => {
|
|
1231
|
+
const text = await page.textContent(selector);
|
|
1232
|
+
return text ? text.trim() : null;
|
|
1233
|
+
},
|
|
1234
|
+
getPageText: async () => {
|
|
1235
|
+
return await page.evaluate(() => document.body.innerText || "");
|
|
1236
|
+
},
|
|
1237
|
+
isVisible: async (selector) => {
|
|
1238
|
+
try {
|
|
1239
|
+
const element = await page.$(selector);
|
|
1240
|
+
if (!element) return false;
|
|
1241
|
+
return await element.isVisible();
|
|
1242
|
+
} catch {
|
|
1243
|
+
return false;
|
|
1244
|
+
}
|
|
1245
|
+
},
|
|
1246
|
+
getElementCount: async (selector) => {
|
|
1247
|
+
const elements = await page.$$(selector);
|
|
1248
|
+
return elements.length;
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
await scenario.run(ctx);
|
|
1252
|
+
const durationMs = Date.now() - startTime;
|
|
1253
|
+
const snapshots = captureEngine.getSnapshots();
|
|
1254
|
+
const consoleErrors = consoleTracker.getErrors();
|
|
1255
|
+
const consoleWarnings = consoleTracker.getWarnings();
|
|
1256
|
+
if (consoleErrors.length > 0) {
|
|
1257
|
+
console.log(`
|
|
1258
|
+
\u{1F534} Console Errors: ${consoleErrors.length}`);
|
|
1259
|
+
consoleErrors.forEach((err, i) => console.log(` ${i + 1}. ${err.text.slice(0, 120)}`));
|
|
1260
|
+
}
|
|
1261
|
+
if (consoleWarnings.length > 0) {
|
|
1262
|
+
console.log(`
|
|
1263
|
+
\u{1F7E1} Console Warnings: ${consoleWarnings.length}`);
|
|
1264
|
+
}
|
|
1265
|
+
const reportPath = VisualReporter.generateReport({
|
|
1266
|
+
scenario,
|
|
1267
|
+
snapshots,
|
|
1268
|
+
consoleErrors,
|
|
1269
|
+
consoleWarnings,
|
|
1270
|
+
outputDir: scenarioArtifactsDir,
|
|
1271
|
+
targetMode,
|
|
1272
|
+
durationMs
|
|
1273
|
+
});
|
|
1274
|
+
const syncLatest = (repPath, snaps) => {
|
|
1275
|
+
try {
|
|
1276
|
+
const latestDir = import_path6.default.join(artifactsRoot, "latest");
|
|
1277
|
+
if (import_fs6.default.existsSync(latestDir)) {
|
|
1278
|
+
import_fs6.default.rmSync(latestDir, { recursive: true, force: true });
|
|
1279
|
+
}
|
|
1280
|
+
import_fs6.default.mkdirSync(latestDir, { recursive: true });
|
|
1281
|
+
import_fs6.default.copyFileSync(repPath, import_path6.default.join(latestDir, "report.md"));
|
|
1282
|
+
const manifestSrc = import_path6.default.join(scenarioArtifactsDir, "manifest.json");
|
|
1283
|
+
if (import_fs6.default.existsSync(manifestSrc)) {
|
|
1284
|
+
import_fs6.default.copyFileSync(manifestSrc, import_path6.default.join(latestDir, "manifest.json"));
|
|
1285
|
+
}
|
|
1286
|
+
for (const snap of snaps) {
|
|
1287
|
+
if (snap.filePath && import_fs6.default.existsSync(snap.filePath)) {
|
|
1288
|
+
import_fs6.default.copyFileSync(snap.filePath, import_path6.default.join(latestDir, snap.fileName));
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
return import_path6.default.join(latestDir, "report.md");
|
|
1292
|
+
} catch {
|
|
1293
|
+
return void 0;
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1296
|
+
const latestReport = syncLatest(reportPath, snapshots);
|
|
1297
|
+
console.log(`
|
|
1298
|
+
\u2705 Visual Test Completed Successfully!`);
|
|
1299
|
+
console.log(`\u{1F4CA} Captured Snapshots: ${snapshots.length}`);
|
|
1300
|
+
console.log(`\u{1F534} Console Errors: ${consoleErrors.length}`);
|
|
1301
|
+
console.log(`\u{1F7E1} Console Warnings: ${consoleWarnings.length}`);
|
|
1302
|
+
console.log(`\u{1F4C4} Markdown Report: ${reportPath}`);
|
|
1303
|
+
if (latestReport) {
|
|
1304
|
+
console.log(`\u{1F4CC} Latest Report: ${latestReport}`);
|
|
1305
|
+
}
|
|
1306
|
+
return {
|
|
1307
|
+
scenarioId: scenario.id,
|
|
1308
|
+
targetMode,
|
|
1309
|
+
success: true,
|
|
1310
|
+
totalSnapshots: snapshots.length,
|
|
1311
|
+
consoleErrors: consoleErrors.length,
|
|
1312
|
+
consoleWarnings: consoleWarnings.length,
|
|
1313
|
+
reportPath,
|
|
1314
|
+
artifactsDir: scenarioArtifactsDir
|
|
1315
|
+
};
|
|
1316
|
+
} catch (err) {
|
|
1317
|
+
console.error(`
|
|
1318
|
+
\u274C Visual Test Failed: ${err.message}`);
|
|
1319
|
+
const durationMs = Date.now() - startTime;
|
|
1320
|
+
const snapshots = captureEngine.getSnapshots();
|
|
1321
|
+
const consoleErrors = consoleTracker.getErrors();
|
|
1322
|
+
const consoleWarnings = consoleTracker.getWarnings();
|
|
1323
|
+
const reportPath = VisualReporter.generateReport({
|
|
1324
|
+
scenario,
|
|
1325
|
+
snapshots,
|
|
1326
|
+
consoleErrors,
|
|
1327
|
+
consoleWarnings,
|
|
1328
|
+
outputDir: scenarioArtifactsDir,
|
|
1329
|
+
targetMode,
|
|
1330
|
+
durationMs
|
|
1331
|
+
});
|
|
1332
|
+
try {
|
|
1333
|
+
const latestDir = import_path6.default.join(artifactsRoot, "latest");
|
|
1334
|
+
if (import_fs6.default.existsSync(latestDir)) {
|
|
1335
|
+
import_fs6.default.rmSync(latestDir, { recursive: true, force: true });
|
|
1336
|
+
}
|
|
1337
|
+
import_fs6.default.mkdirSync(latestDir, { recursive: true });
|
|
1338
|
+
import_fs6.default.copyFileSync(reportPath, import_path6.default.join(latestDir, "report.md"));
|
|
1339
|
+
} catch {
|
|
1340
|
+
}
|
|
1341
|
+
return {
|
|
1342
|
+
scenarioId: scenario.id,
|
|
1343
|
+
targetMode,
|
|
1344
|
+
success: false,
|
|
1345
|
+
totalSnapshots: snapshots.length,
|
|
1346
|
+
consoleErrors: consoleErrors.length,
|
|
1347
|
+
consoleWarnings: consoleWarnings.length,
|
|
1348
|
+
reportPath,
|
|
1349
|
+
artifactsDir: scenarioArtifactsDir,
|
|
1350
|
+
error: err.message
|
|
1351
|
+
};
|
|
1352
|
+
} finally {
|
|
1353
|
+
if (typeof scenario.teardown === "function") {
|
|
1354
|
+
try {
|
|
1355
|
+
console.log(`\u{1F9F9} [Scenario Teardown] Executing teardown hook...`);
|
|
1356
|
+
await scenario.teardown();
|
|
1357
|
+
} catch (teardownErr) {
|
|
1358
|
+
console.error(`\u26A0\uFE0F [Scenario Teardown] Error during teardown:`, teardownErr.message);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
if (options2.cleanPaths && options2.cleanPaths.length > 0) {
|
|
1362
|
+
for (const cleanTarget of options2.cleanPaths) {
|
|
1363
|
+
try {
|
|
1364
|
+
const resolvedCleanPath = import_path6.default.resolve(process.cwd(), cleanTarget);
|
|
1365
|
+
if (import_fs6.default.existsSync(resolvedCleanPath)) {
|
|
1366
|
+
console.log(`\u{1F9F9} [Auto-Cleanup] Removing: ${resolvedCleanPath}`);
|
|
1367
|
+
import_fs6.default.rmSync(resolvedCleanPath, { recursive: true, force: true });
|
|
1368
|
+
}
|
|
1369
|
+
} catch (cleanErr) {
|
|
1370
|
+
console.error(`\u26A0\uFE0F [Auto-Cleanup] Failed to remove ${cleanTarget}:`, cleanErr.message);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
if (!options2.detach) {
|
|
1375
|
+
if (desktopDriver) await desktopDriver.stop();
|
|
1376
|
+
if (previewDriver) await previewDriver.stop();
|
|
1377
|
+
if (processManager) await processManager.stop();
|
|
1378
|
+
} else {
|
|
1379
|
+
console.log("\u{1F517} [Runner] Detach mode active, leaving browser/app open.");
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// src/app/cli.ts
|
|
1385
|
+
var import_jiti = require("jiti");
|
|
1386
|
+
|
|
1387
|
+
// src/features/snap/snap.ts
|
|
1388
|
+
var import_path7 = __toESM(require("path"));
|
|
1389
|
+
var import_fs7 = __toESM(require("fs"));
|
|
1390
|
+
var PRESET_MAP = {
|
|
1391
|
+
default: VIEWPORT_PRESETS.DEFAULT,
|
|
1392
|
+
desktop: VIEWPORT_PRESETS.DEFAULT,
|
|
1393
|
+
min: VIEWPORT_PRESETS.MIN_SUPPORTED,
|
|
1394
|
+
"min-supported": VIEWPORT_PRESETS.MIN_SUPPORTED,
|
|
1395
|
+
wide: VIEWPORT_PRESETS.WIDE,
|
|
1396
|
+
mobile: { name: "mobile", width: 375, height: 667 },
|
|
1397
|
+
tablet: { name: "tablet", width: 768, height: 1024 },
|
|
1398
|
+
"full-hd": VIEWPORT_PRESETS.FULL_HD
|
|
1399
|
+
};
|
|
1400
|
+
function parseViewportPresets(raw) {
|
|
1401
|
+
if (!raw || raw.length === 0) {
|
|
1402
|
+
return [VIEWPORT_PRESETS.DEFAULT, PRESET_MAP.mobile];
|
|
1403
|
+
}
|
|
1404
|
+
const presets = [];
|
|
1405
|
+
for (const item of raw) {
|
|
1406
|
+
const lower = item.toLowerCase().trim();
|
|
1407
|
+
if (PRESET_MAP[lower]) {
|
|
1408
|
+
presets.push(PRESET_MAP[lower]);
|
|
1409
|
+
} else if (lower.includes("x")) {
|
|
1410
|
+
const [w, h] = lower.split("x").map((n) => parseInt(n, 10));
|
|
1411
|
+
if (!isNaN(w) && !isNaN(h)) {
|
|
1412
|
+
presets.push({ name: `${w}x${h}`, width: w, height: h });
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
return presets.length > 0 ? presets : [VIEWPORT_PRESETS.DEFAULT, PRESET_MAP.mobile];
|
|
1417
|
+
}
|
|
1418
|
+
async function isPortResponding(url) {
|
|
1419
|
+
try {
|
|
1420
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(800) });
|
|
1421
|
+
return Boolean(res.status);
|
|
1422
|
+
} catch {
|
|
1423
|
+
return false;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
async function runQuickSnap(options2) {
|
|
1427
|
+
let targetUrl = options2.url;
|
|
1428
|
+
let useStaticPreview = false;
|
|
1429
|
+
let wwwrootDir;
|
|
1430
|
+
if (!targetUrl && !options2.start) {
|
|
1431
|
+
if (await isPortResponding("http://localhost:5173")) {
|
|
1432
|
+
targetUrl = "http://localhost:5173";
|
|
1433
|
+
console.log(`\u{1F310} [Quick Snap] Detected active dev server on http://localhost:5173`);
|
|
1434
|
+
} else if (await isPortResponding("http://localhost:3000")) {
|
|
1435
|
+
targetUrl = "http://localhost:3000";
|
|
1436
|
+
console.log(`\u{1F310} [Quick Snap] Detected active dev server on http://localhost:3000`);
|
|
1437
|
+
} else {
|
|
1438
|
+
const candidateDir = resolveWwwrootDir();
|
|
1439
|
+
if (import_fs7.default.existsSync(candidateDir) && import_fs7.default.existsSync(import_path7.default.join(candidateDir, "index.html"))) {
|
|
1440
|
+
useStaticPreview = true;
|
|
1441
|
+
wwwrootDir = candidateDir;
|
|
1442
|
+
console.log(`\u{1F4E6} [Quick Snap] No active server found. Detected built static directory at "${candidateDir}". Launching static preview...`);
|
|
1443
|
+
} else {
|
|
1444
|
+
console.error(`
|
|
1445
|
+
\u274C [Quick Snap] No active server found on http://localhost:5173 or :3000, and no static build found.`);
|
|
1446
|
+
console.log(`\u{1F4A1} Suggested actions:`);
|
|
1447
|
+
console.log(` 1. Pass a start command: npx agent-lens snap --start="npm run dev"`);
|
|
1448
|
+
console.log(` 2. Specify your URL: npx agent-lens snap --url=http://localhost:8080`);
|
|
1449
|
+
console.log(` 3. Build your static app: npm run build
|
|
1450
|
+
`);
|
|
1451
|
+
return false;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
} else if (!targetUrl && options2.start) {
|
|
1455
|
+
targetUrl = "http://localhost:5173";
|
|
1456
|
+
}
|
|
1457
|
+
const waitMs = options2.waitMs ?? 1e3;
|
|
1458
|
+
const snapshotPrefix = options2.name || "quick_snap";
|
|
1459
|
+
const targetViewports = parseViewportPresets(options2.viewports);
|
|
1460
|
+
console.log(`
|
|
1461
|
+
\u{1F4F8} [Quick Snap] Preparing instant verification for: ${targetUrl || wwwrootDir}`);
|
|
1462
|
+
console.log(`\u{1F4D0} [Quick Snap] Testing ${targetViewports.length} viewports: ${targetViewports.map((v) => `${v.name} (${v.width}x${v.height})`).join(", ")}`);
|
|
1463
|
+
if (options2.selector) {
|
|
1464
|
+
console.log(`\u{1F3AF} [Quick Snap] Focused element selector: "${options2.selector}"`);
|
|
1465
|
+
}
|
|
1466
|
+
const snapScenario = defineVisualTest({
|
|
1467
|
+
id: "quick-snap",
|
|
1468
|
+
title: `Quick Verification: ${targetUrl || "Static Preview"}`,
|
|
1469
|
+
description: `One-shot automated snapshot and console health check`,
|
|
1470
|
+
route: targetUrl || "/",
|
|
1471
|
+
viewports: targetViewports,
|
|
1472
|
+
run: async (ctx) => {
|
|
1473
|
+
ctx.log(`Waiting ${waitMs}ms for page stabilization...`);
|
|
1474
|
+
await ctx.wait(waitMs);
|
|
1475
|
+
for (let i = 0; i < targetViewports.length; i++) {
|
|
1476
|
+
const vp = targetViewports[i];
|
|
1477
|
+
const stepNum = String(i + 1).padStart(2, "0");
|
|
1478
|
+
ctx.log(`Switching viewport to: ${vp.name} (${vp.width}x${vp.height})`);
|
|
1479
|
+
await ctx.setPreset(vp);
|
|
1480
|
+
await ctx.wait(200);
|
|
1481
|
+
await ctx.capture(`${stepNum}_${snapshotPrefix}_${vp.name}`);
|
|
1482
|
+
}
|
|
1483
|
+
if (options2.selector) {
|
|
1484
|
+
ctx.log(`Focusing on selector: "${options2.selector}"`);
|
|
1485
|
+
try {
|
|
1486
|
+
await ctx.setPreset(VIEWPORT_PRESETS.DEFAULT);
|
|
1487
|
+
await ctx.resizeToFit(options2.selector, 15);
|
|
1488
|
+
await ctx.capture(`99_${snapshotPrefix}_element_focus`, { selector: options2.selector });
|
|
1489
|
+
} catch (err) {
|
|
1490
|
+
ctx.log(`\u26A0\uFE0F Could not isolate selector "${options2.selector}": ${err.message}`);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
const errors = ctx.getConsoleErrors();
|
|
1494
|
+
if (errors.length > 0) {
|
|
1495
|
+
ctx.log(`\u{1F6A8} Caught ${errors.length} console errors during snap check!`);
|
|
1496
|
+
} else {
|
|
1497
|
+
ctx.log(`\u2705 Clean run: No console errors detected.`);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
const result = await runVisualScenario({
|
|
1502
|
+
scenario: snapScenario,
|
|
1503
|
+
targetMode: options2.mode || "preview",
|
|
1504
|
+
url: useStaticPreview ? void 0 : targetUrl,
|
|
1505
|
+
wwwrootDir: useStaticPreview ? wwwrootDir : void 0,
|
|
1506
|
+
startCommand: options2.start,
|
|
1507
|
+
startCwd: options2.startCwd,
|
|
1508
|
+
executablePath: options2.exe,
|
|
1509
|
+
cleanPaths: options2.clean,
|
|
1510
|
+
cleanArtifacts: options2.cleanArtifacts,
|
|
1511
|
+
port: options2.port,
|
|
1512
|
+
headed: options2.headed,
|
|
1513
|
+
detach: options2.detach,
|
|
1514
|
+
artifactsRoot: options2.outDir ? import_path7.default.resolve(process.cwd(), options2.outDir) : void 0
|
|
1515
|
+
});
|
|
1516
|
+
console.log(`
|
|
1517
|
+
========================================`);
|
|
1518
|
+
console.log(`\u{1F3C1} Quick Snap Finished!`);
|
|
1519
|
+
console.log(`\u{1F4F8} Snapshots: ${result.totalSnapshots}`);
|
|
1520
|
+
console.log(`\u{1F534} Console Errors: ${result.consoleErrors}`);
|
|
1521
|
+
console.log(`\u{1F7E1} Console Warnings: ${result.consoleWarnings}`);
|
|
1522
|
+
console.log(`\u{1F4C4} Report: ${result.reportPath}`);
|
|
1523
|
+
console.log(`========================================
|
|
1524
|
+
`);
|
|
1525
|
+
return result.success && result.consoleErrors === 0;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// src/app/cli.ts
|
|
1529
|
+
var fileConfig = loadConfig();
|
|
1530
|
+
var args = process.argv.slice(2);
|
|
1531
|
+
if (args[0] === "init") {
|
|
1532
|
+
initScenarioTemplate();
|
|
1533
|
+
process.exit(0);
|
|
1534
|
+
}
|
|
1535
|
+
var isSnapCommand = args[0] === "snap";
|
|
1536
|
+
var effectiveArgs = isSnapCommand ? args.slice(1) : args;
|
|
1537
|
+
var options = {
|
|
1538
|
+
mode: fileConfig.mode || "preview",
|
|
1539
|
+
port: fileConfig.port || 9222,
|
|
1540
|
+
headed: fileConfig.headed ?? false,
|
|
1541
|
+
detach: fileConfig.detach ?? false,
|
|
1542
|
+
build: fileConfig.buildCommand || false,
|
|
1543
|
+
start: fileConfig.startCommand,
|
|
1544
|
+
startCwd: fileConfig.startCwd,
|
|
1545
|
+
cleanArtifacts: fileConfig.cleanArtifacts ?? false,
|
|
1546
|
+
url: fileConfig.url,
|
|
1547
|
+
exe: fileConfig.executablePath,
|
|
1548
|
+
clean: Array.isArray(fileConfig.clean) ? fileConfig.clean : fileConfig.clean ? [fileConfig.clean] : void 0,
|
|
1549
|
+
dir: fileConfig.scenarios,
|
|
1550
|
+
wwwroot: fileConfig.wwwroot,
|
|
1551
|
+
outDir: fileConfig.outDir
|
|
1552
|
+
};
|
|
1553
|
+
for (const arg of effectiveArgs) {
|
|
1554
|
+
if (arg === "--help" || arg === "-h") {
|
|
1555
|
+
options.help = true;
|
|
1556
|
+
} else if (arg.startsWith("--scenario=")) {
|
|
1557
|
+
options.scenario = arg.split("=")[1];
|
|
1558
|
+
} else if (arg === "--all") {
|
|
1559
|
+
options.all = true;
|
|
1560
|
+
} else if (arg.startsWith("--mode=")) {
|
|
1561
|
+
const mode = arg.split("=")[1].toLowerCase();
|
|
1562
|
+
if (mode === "desktop" || mode === "preview") {
|
|
1563
|
+
options.mode = mode;
|
|
1564
|
+
}
|
|
1565
|
+
} else if (arg.startsWith("--port=")) {
|
|
1566
|
+
options.port = parseInt(arg.split("=")[1], 10) || 9222;
|
|
1567
|
+
} else if (arg === "--headed") {
|
|
1568
|
+
options.headed = true;
|
|
1569
|
+
} else if (arg === "--detach") {
|
|
1570
|
+
options.detach = true;
|
|
1571
|
+
} else if (arg === "--build") {
|
|
1572
|
+
options.build = true;
|
|
1573
|
+
} else if (arg.startsWith("--build=")) {
|
|
1574
|
+
options.build = arg.slice("--build=".length);
|
|
1575
|
+
} else if (arg.startsWith("--start=")) {
|
|
1576
|
+
options.start = arg.slice("--start=".length);
|
|
1577
|
+
} else if (arg.startsWith("--start-cwd=") || arg.startsWith("--cwd=")) {
|
|
1578
|
+
options.startCwd = arg.split("=")[1];
|
|
1579
|
+
} else if (arg === "--clean-artifacts") {
|
|
1580
|
+
options.cleanArtifacts = true;
|
|
1581
|
+
} else if (arg.startsWith("--url=")) {
|
|
1582
|
+
options.url = arg.split("=")[1];
|
|
1583
|
+
} else if (arg.startsWith("--selector=")) {
|
|
1584
|
+
options.selector = arg.split("=")[1];
|
|
1585
|
+
} else if (arg.startsWith("--viewports=")) {
|
|
1586
|
+
options.viewports = arg.split("=")[1].split(",").map((v) => v.trim()).filter(Boolean);
|
|
1587
|
+
} else if (arg.startsWith("--wait=")) {
|
|
1588
|
+
options.waitMs = parseInt(arg.split("=")[1], 10);
|
|
1589
|
+
} else if (arg.startsWith("--name=")) {
|
|
1590
|
+
options.name = arg.split("=")[1];
|
|
1591
|
+
} else if (arg.startsWith("--exe=") || arg.startsWith("--executable=")) {
|
|
1592
|
+
options.exe = arg.split("=")[1];
|
|
1593
|
+
} else if (arg.startsWith("--clean=") || arg.startsWith("--cleanup=")) {
|
|
1594
|
+
const rawPaths = arg.split("=")[1];
|
|
1595
|
+
options.clean = rawPaths.split(",").map((p) => p.trim()).filter(Boolean);
|
|
1596
|
+
} else if (arg.startsWith("--dir=")) {
|
|
1597
|
+
options.dir = arg.split("=")[1];
|
|
1598
|
+
} else if (arg.startsWith("--wwwroot=")) {
|
|
1599
|
+
options.wwwroot = arg.split("=")[1];
|
|
1600
|
+
} else if (arg.startsWith("--outDir=") || arg.startsWith("--folder=")) {
|
|
1601
|
+
options.outDir = arg.split("=")[1];
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
if (options.start && !options.startCwd) {
|
|
1605
|
+
options.startCwd = detectStartCwd(options.startCwd);
|
|
1606
|
+
}
|
|
1607
|
+
function printHelp() {
|
|
1608
|
+
console.log(`
|
|
1609
|
+
\u{1F441}\uFE0F AgentLens - Visual UI Self-Verification for AI Agents
|
|
1610
|
+
|
|
1611
|
+
Usage:
|
|
1612
|
+
npx agent-lens snap [options] Instant one-shot visual & console check (no test files needed)
|
|
1613
|
+
npx agent-lens [options] Run scripted scenario tests from scenarios/
|
|
1614
|
+
npx agent-lens init Generate starter scenario template & mocks
|
|
1615
|
+
|
|
1616
|
+
Commands:
|
|
1617
|
+
snap Take immediate multi-viewport screenshots of a URL & check console errors
|
|
1618
|
+
init Generate starter template in scenarios/template.scenario.ts and mocks.ts
|
|
1619
|
+
|
|
1620
|
+
Options:
|
|
1621
|
+
--url=<url> Target URL to test (e.g. http://localhost:5173 or http://localhost:3000)
|
|
1622
|
+
--start="<cmd>" Launch dev server or backend process before testing (e.g. --start="npm run dev")
|
|
1623
|
+
--start-cwd=<path> Directory to execute --start command in (e.g. --start-cwd=./Frontend)
|
|
1624
|
+
--clean-artifacts Purge previous test artifacts to prevent folder bloat
|
|
1625
|
+
--selector=<css> Target a specific element to focus on / resize-to-fit
|
|
1626
|
+
--viewports=<list> Comma-separated viewport presets (default: desktop,mobile; or 1200x800,375x667)
|
|
1627
|
+
--wait=<ms> Wait time in milliseconds after loading before snapshotting [default: 1000]
|
|
1628
|
+
--name=<prefix> Custom name prefix for captured snapshots [default: quick_snap]
|
|
1629
|
+
--scenario=<name> Run specific scenario by name (e.g. --scenario=smoke)
|
|
1630
|
+
--all Run all discovered scenarios
|
|
1631
|
+
--mode=<mode> Engine mode: 'preview' (Web/Vite/Live) or 'desktop' (WebView2/CDP) [default: preview]
|
|
1632
|
+
--exe=<path> Path to native executable for desktop mode (e.g. --exe=bin/MyApp.exe)
|
|
1633
|
+
--port=<port> CDP remote debugging port [default: 9222]
|
|
1634
|
+
--build[=<cmd>] Run build command before testing (e.g. --build="dotnet build" or npm run build)
|
|
1635
|
+
--clean=<paths> Comma-separated paths to safely delete upon test exit (e.g. --clean="./temp,./cache")
|
|
1636
|
+
--headed Show Chromium browser window
|
|
1637
|
+
--detach Keep browser/app open after finishing
|
|
1638
|
+
--dir=<path> Custom scenarios directory [default: scenarios]
|
|
1639
|
+
--wwwroot=<path> Custom directory for static fallback mode [default: dist]
|
|
1640
|
+
--folder=<path> Directory to save visual artifacts/reports (also --outDir)
|
|
1641
|
+
--help, -h Show this help message
|
|
1642
|
+
`);
|
|
1643
|
+
}
|
|
1644
|
+
function initScenarioTemplate() {
|
|
1645
|
+
const targetDir = import_path8.default.resolve(process.cwd(), "scenarios");
|
|
1646
|
+
if (!import_fs8.default.existsSync(targetDir)) {
|
|
1647
|
+
import_fs8.default.mkdirSync(targetDir, { recursive: true });
|
|
1648
|
+
}
|
|
1649
|
+
const templatePath = import_path8.default.join(targetDir, "template.scenario.ts");
|
|
1650
|
+
if (!import_fs8.default.existsSync(templatePath)) {
|
|
1651
|
+
const templateContent = `import { defineVisualTest, VIEWPORT_PRESETS } from 'agent-lens';
|
|
1652
|
+
|
|
1653
|
+
export default defineVisualTest({
|
|
1654
|
+
id: 'template-check',
|
|
1655
|
+
title: 'Basic UI Smoke & Responsiveness Check',
|
|
1656
|
+
route: '/',
|
|
1657
|
+
// Optional setup hook before test runs
|
|
1658
|
+
setup: async () => {
|
|
1659
|
+
// prepare test files or folders
|
|
1660
|
+
},
|
|
1661
|
+
run: async (ctx) => {
|
|
1662
|
+
ctx.log('Initial page render');
|
|
1663
|
+
await ctx.capture('01_initial_state');
|
|
1664
|
+
|
|
1665
|
+
// Test adaptive layout
|
|
1666
|
+
await ctx.setPreset(VIEWPORT_PRESETS.MIN_SUPPORTED);
|
|
1667
|
+
await ctx.capture('02_compact_view');
|
|
1668
|
+
|
|
1669
|
+
// Check for JavaScript / React runtime errors
|
|
1670
|
+
const errors = ctx.getConsoleErrors();
|
|
1671
|
+
if (errors.length > 0) {
|
|
1672
|
+
ctx.log(\`\u26A0\uFE0F Warning: Caught \${errors.length} console errors!\`);
|
|
1673
|
+
}
|
|
1674
|
+
},
|
|
1675
|
+
// Optional teardown hook guaranteed to run on exit
|
|
1676
|
+
teardown: async () => {
|
|
1677
|
+
// clean up temporary test files or state
|
|
1678
|
+
}
|
|
1679
|
+
});
|
|
1680
|
+
`;
|
|
1681
|
+
import_fs8.default.writeFileSync(templatePath, templateContent, "utf8");
|
|
1682
|
+
console.log(`\u2705 Starter scenario generated at: ${templatePath}`);
|
|
1683
|
+
} else {
|
|
1684
|
+
console.log(`\u2139\uFE0F Template already exists at: ${templatePath}`);
|
|
1685
|
+
}
|
|
1686
|
+
const mocksPath = import_path8.default.join(targetDir, "mocks.ts");
|
|
1687
|
+
if (!import_fs8.default.existsSync(mocksPath)) {
|
|
1688
|
+
const mocksContent = `/**
|
|
1689
|
+
* Global IPC & API Mocks
|
|
1690
|
+
*
|
|
1691
|
+
* Export an array of base mocks to satisfy root application state on boot.
|
|
1692
|
+
*/
|
|
1693
|
+
export default [
|
|
1694
|
+
{ action: 'GET_PREFS', data: { theme: 'dark', language: 'en' } },
|
|
1695
|
+
{ action: 'GET_USER_PROFILE', data: { id: 1, name: 'Agent', role: 'admin' } }
|
|
1696
|
+
];
|
|
1697
|
+
`;
|
|
1698
|
+
import_fs8.default.writeFileSync(mocksPath, mocksContent, "utf8");
|
|
1699
|
+
console.log(`\u2705 Base global mocks generated at: ${mocksPath}`);
|
|
1700
|
+
}
|
|
1701
|
+
console.log(`\u{1F449} Run tests with: npx agent-lens --scenario=template --mode=preview`);
|
|
1702
|
+
}
|
|
1703
|
+
if (options.help) {
|
|
1704
|
+
printHelp();
|
|
1705
|
+
process.exit(0);
|
|
1706
|
+
}
|
|
1707
|
+
async function findScenarios(dir, specificName) {
|
|
1708
|
+
const results = [];
|
|
1709
|
+
if (!import_fs8.default.existsSync(dir)) return results;
|
|
1710
|
+
const items = import_fs8.default.readdirSync(dir, { withFileTypes: true });
|
|
1711
|
+
for (const item of items) {
|
|
1712
|
+
const fullPath = import_path8.default.join(dir, item.name);
|
|
1713
|
+
if (item.isDirectory()) {
|
|
1714
|
+
results.push(...await findScenarios(fullPath, specificName));
|
|
1715
|
+
} else if (item.name.endsWith(".scenario.ts") || item.name.endsWith(".scenario.js")) {
|
|
1716
|
+
if (!specificName || item.name.startsWith(specificName)) {
|
|
1717
|
+
results.push(fullPath);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
return results;
|
|
1722
|
+
}
|
|
1723
|
+
function resolveScenariosDirectory(customDir) {
|
|
1724
|
+
if (customDir) {
|
|
1725
|
+
return import_path8.default.resolve(process.cwd(), customDir);
|
|
1726
|
+
}
|
|
1727
|
+
const candidates = [
|
|
1728
|
+
"scenarios",
|
|
1729
|
+
"tests/visual",
|
|
1730
|
+
"tests/scenarios",
|
|
1731
|
+
"test/scenarios",
|
|
1732
|
+
"src/scenarios"
|
|
1733
|
+
];
|
|
1734
|
+
for (const c of candidates) {
|
|
1735
|
+
const p = import_path8.default.resolve(process.cwd(), c);
|
|
1736
|
+
if (import_fs8.default.existsSync(p)) {
|
|
1737
|
+
return p;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
return import_path8.default.resolve(process.cwd(), "scenarios");
|
|
1741
|
+
}
|
|
1742
|
+
async function main() {
|
|
1743
|
+
if (isSnapCommand || options.url && !options.scenario && !options.all) {
|
|
1744
|
+
const snapSuccess = await runQuickSnap({
|
|
1745
|
+
url: options.url,
|
|
1746
|
+
start: options.start,
|
|
1747
|
+
startCwd: options.startCwd,
|
|
1748
|
+
selector: options.selector,
|
|
1749
|
+
viewports: options.viewports,
|
|
1750
|
+
waitMs: options.waitMs,
|
|
1751
|
+
name: options.name,
|
|
1752
|
+
clean: options.clean,
|
|
1753
|
+
cleanArtifacts: options.cleanArtifacts,
|
|
1754
|
+
headed: options.headed,
|
|
1755
|
+
detach: options.detach,
|
|
1756
|
+
outDir: options.outDir,
|
|
1757
|
+
mode: options.mode,
|
|
1758
|
+
exe: options.exe,
|
|
1759
|
+
port: options.port
|
|
1760
|
+
});
|
|
1761
|
+
process.exit(snapSuccess ? 0 : 1);
|
|
1762
|
+
}
|
|
1763
|
+
if (!options.scenario && !options.all) {
|
|
1764
|
+
console.error("\u274C Please specify a scenario (--scenario=name), run --all, or use: npx agent-lens snap --url=http://localhost:5173");
|
|
1765
|
+
printHelp();
|
|
1766
|
+
process.exit(1);
|
|
1767
|
+
}
|
|
1768
|
+
const scenariosDir = resolveScenariosDirectory(options.dir);
|
|
1769
|
+
const scenarioPaths = await findScenarios(scenariosDir, options.scenario);
|
|
1770
|
+
if (scenarioPaths.length === 0) {
|
|
1771
|
+
console.error(`\u274C No scenarios found in ${scenariosDir}`);
|
|
1772
|
+
console.log(`\u{1F4A1} Generate a template with: npx agent-lens init`);
|
|
1773
|
+
process.exit(1);
|
|
1774
|
+
}
|
|
1775
|
+
const shouldBuild = options.build || fileConfig.autoBuild;
|
|
1776
|
+
if (shouldBuild) {
|
|
1777
|
+
const buildCmd = typeof options.build === "string" ? options.build : fileConfig.buildCommand || "npm run build";
|
|
1778
|
+
console.log(`
|
|
1779
|
+
\u{1F528} [Build] Running build process: "${buildCmd}"...`);
|
|
1780
|
+
const { execSync } = require("child_process");
|
|
1781
|
+
try {
|
|
1782
|
+
execSync(buildCmd, { stdio: "inherit", cwd: options.startCwd || process.cwd() });
|
|
1783
|
+
} catch (e) {
|
|
1784
|
+
console.error(`\u274C Build failed: ${buildCmd}`);
|
|
1785
|
+
process.exit(1);
|
|
1786
|
+
}
|
|
1787
|
+
} else if (options.mode === "preview" && !options.url && !options.start) {
|
|
1788
|
+
console.log(`\u26A0\uFE0F Warning: Running without --build or --url flag. Make sure your frontend is built!`);
|
|
1789
|
+
}
|
|
1790
|
+
console.log(`\u{1F4CB} Found ${scenarioPaths.length} scenario(s) in: ${scenariosDir}`);
|
|
1791
|
+
const jiti = (0, import_jiti.createJiti)(process.cwd());
|
|
1792
|
+
let globalMocks = [];
|
|
1793
|
+
const mockCandidates = [
|
|
1794
|
+
import_path8.default.join(scenariosDir, "mocks.ts"),
|
|
1795
|
+
import_path8.default.join(scenariosDir, "mocks.js"),
|
|
1796
|
+
import_path8.default.join(process.cwd(), "mocks.ts"),
|
|
1797
|
+
import_path8.default.join(process.cwd(), "mocks.js")
|
|
1798
|
+
];
|
|
1799
|
+
for (const mocksPath of mockCandidates) {
|
|
1800
|
+
if (import_fs8.default.existsSync(mocksPath)) {
|
|
1801
|
+
try {
|
|
1802
|
+
const m = await jiti.import(mocksPath);
|
|
1803
|
+
globalMocks = m.default || m.mocks || [];
|
|
1804
|
+
console.log(`\u{1F30D} Loaded ${globalMocks.length} global mock(s) from ${import_path8.default.basename(mocksPath)}`);
|
|
1805
|
+
break;
|
|
1806
|
+
} catch (err) {
|
|
1807
|
+
console.warn(`\u26A0\uFE0F Failed to load global mocks from ${mocksPath}:`, err);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
let allSuccess = true;
|
|
1812
|
+
for (const scenarioPath of scenarioPaths) {
|
|
1813
|
+
try {
|
|
1814
|
+
const scenarioModule = await jiti.import(scenarioPath);
|
|
1815
|
+
const scenario = scenarioModule.default || scenarioModule.scenario;
|
|
1816
|
+
if (!scenario || typeof scenario.run !== "function") {
|
|
1817
|
+
console.error(`\u26A0\uFE0F Skipped: file ${import_path8.default.basename(scenarioPath)} does not export a VisualScenario object by default.`);
|
|
1818
|
+
continue;
|
|
1819
|
+
}
|
|
1820
|
+
const result = await runVisualScenario({
|
|
1821
|
+
scenario,
|
|
1822
|
+
targetMode: options.mode,
|
|
1823
|
+
url: options.url,
|
|
1824
|
+
startCommand: options.start,
|
|
1825
|
+
startCwd: options.startCwd,
|
|
1826
|
+
cleanArtifacts: options.cleanArtifacts,
|
|
1827
|
+
port: options.port,
|
|
1828
|
+
headed: options.headed,
|
|
1829
|
+
detach: options.detach,
|
|
1830
|
+
executablePath: options.exe,
|
|
1831
|
+
cleanPaths: options.clean,
|
|
1832
|
+
desktopEnv: fileConfig.env,
|
|
1833
|
+
wwwrootDir: options.wwwroot ? resolveWwwrootDir(options.wwwroot) : void 0,
|
|
1834
|
+
artifactsRoot: options.outDir ? import_path8.default.resolve(process.cwd(), options.outDir) : void 0,
|
|
1835
|
+
globalMocks
|
|
1836
|
+
});
|
|
1837
|
+
if (!result.success) {
|
|
1838
|
+
allSuccess = false;
|
|
1839
|
+
}
|
|
1840
|
+
} catch (err) {
|
|
1841
|
+
console.error(`\u274C Failed to load scenario ${import_path8.default.basename(scenarioPath)}:`, err);
|
|
1842
|
+
allSuccess = false;
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
if (!allSuccess) {
|
|
1846
|
+
process.exit(1);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
main().catch((err) => {
|
|
1850
|
+
console.error("Fatal error:", err);
|
|
1851
|
+
process.exit(1);
|
|
1852
|
+
});
|
|
1853
|
+
//# sourceMappingURL=cli.js.map
|