@hasna/testers 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -2
- package/dist/cli/index.js +1845 -429
- package/dist/db/auth-presets.d.ts +20 -0
- package/dist/db/auth-presets.d.ts.map +1 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/screenshots.d.ts +3 -0
- package/dist/db/screenshots.d.ts.map +1 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1815 -358
- package/dist/lib/ai-client.d.ts +6 -0
- package/dist/lib/ai-client.d.ts.map +1 -1
- package/dist/lib/costs.d.ts +36 -0
- package/dist/lib/costs.d.ts.map +1 -0
- package/dist/lib/diff.d.ts +25 -0
- package/dist/lib/diff.d.ts.map +1 -0
- package/dist/lib/init.d.ts +28 -0
- package/dist/lib/init.d.ts.map +1 -0
- package/dist/lib/report.d.ts +4 -0
- package/dist/lib/report.d.ts.map +1 -0
- package/dist/lib/runner.d.ts +12 -0
- package/dist/lib/runner.d.ts.map +1 -1
- package/dist/lib/screenshotter.d.ts +27 -25
- package/dist/lib/screenshotter.d.ts.map +1 -1
- package/dist/lib/smoke.d.ts +25 -0
- package/dist/lib/smoke.d.ts.map +1 -0
- package/dist/lib/templates.d.ts +5 -0
- package/dist/lib/templates.d.ts.map +1 -0
- package/dist/lib/watch.d.ts +9 -0
- package/dist/lib/watch.d.ts.map +1 -0
- package/dist/lib/webhooks.d.ts +41 -0
- package/dist/lib/webhooks.d.ts.map +1 -0
- package/dist/mcp/index.js +221 -31
- package/dist/server/index.js +138 -18
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -17,6 +17,16 @@ var __toESM = (mod, isNodeMode, target) => {
|
|
|
17
17
|
return to;
|
|
18
18
|
};
|
|
19
19
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
20
|
+
var __export = (target, all) => {
|
|
21
|
+
for (var name in all)
|
|
22
|
+
__defProp(target, name, {
|
|
23
|
+
get: all[name],
|
|
24
|
+
enumerable: true,
|
|
25
|
+
configurable: true,
|
|
26
|
+
set: (newValue) => all[name] = () => newValue
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
20
30
|
var __require = import.meta.require;
|
|
21
31
|
|
|
22
32
|
// node_modules/commander/lib/error.js
|
|
@@ -2053,33 +2063,7 @@ var require_commander = __commonJS((exports) => {
|
|
|
2053
2063
|
exports.InvalidOptionArgumentError = InvalidArgumentError;
|
|
2054
2064
|
});
|
|
2055
2065
|
|
|
2056
|
-
// node_modules/commander/esm.mjs
|
|
2057
|
-
var import__ = __toESM(require_commander(), 1);
|
|
2058
|
-
var {
|
|
2059
|
-
program,
|
|
2060
|
-
createCommand,
|
|
2061
|
-
createArgument,
|
|
2062
|
-
createOption,
|
|
2063
|
-
CommanderError,
|
|
2064
|
-
InvalidArgumentError,
|
|
2065
|
-
InvalidOptionArgumentError,
|
|
2066
|
-
Command,
|
|
2067
|
-
Argument,
|
|
2068
|
-
Option,
|
|
2069
|
-
Help
|
|
2070
|
-
} = import__.default;
|
|
2071
|
-
|
|
2072
|
-
// src/cli/index.tsx
|
|
2073
|
-
import chalk2 from "chalk";
|
|
2074
|
-
import { readFileSync as readFileSync2, readdirSync, writeFileSync } from "fs";
|
|
2075
|
-
import { join as join5, resolve } from "path";
|
|
2076
|
-
|
|
2077
2066
|
// src/types/index.ts
|
|
2078
|
-
var MODEL_MAP = {
|
|
2079
|
-
quick: "claude-haiku-4-5-20251001",
|
|
2080
|
-
thorough: "claude-sonnet-4-6-20260311",
|
|
2081
|
-
deep: "claude-opus-4-6-20260311"
|
|
2082
|
-
};
|
|
2083
2067
|
function projectFromRow(row) {
|
|
2084
2068
|
return {
|
|
2085
2069
|
id: row.id,
|
|
@@ -2155,7 +2139,10 @@ function screenshotFromRow(row) {
|
|
|
2155
2139
|
filePath: row.file_path,
|
|
2156
2140
|
width: row.width,
|
|
2157
2141
|
height: row.height,
|
|
2158
|
-
timestamp: row.timestamp
|
|
2142
|
+
timestamp: row.timestamp,
|
|
2143
|
+
description: row.description,
|
|
2144
|
+
pageUrl: row.page_url,
|
|
2145
|
+
thumbnailPath: row.thumbnail_path
|
|
2159
2146
|
};
|
|
2160
2147
|
}
|
|
2161
2148
|
function scheduleFromRow(row) {
|
|
@@ -2178,46 +2165,50 @@ function scheduleFromRow(row) {
|
|
|
2178
2165
|
updatedAt: row.updated_at
|
|
2179
2166
|
};
|
|
2180
2167
|
}
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
class
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
}
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
}
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2168
|
+
var MODEL_MAP, VersionConflictError, BrowserError, AIClientError, TodosConnectionError, ScheduleNotFoundError;
|
|
2169
|
+
var init_types = __esm(() => {
|
|
2170
|
+
MODEL_MAP = {
|
|
2171
|
+
quick: "claude-haiku-4-5-20251001",
|
|
2172
|
+
thorough: "claude-sonnet-4-6-20260311",
|
|
2173
|
+
deep: "claude-opus-4-6-20260311"
|
|
2174
|
+
};
|
|
2175
|
+
VersionConflictError = class VersionConflictError extends Error {
|
|
2176
|
+
constructor(entity, id) {
|
|
2177
|
+
super(`Version conflict on ${entity}: ${id}`);
|
|
2178
|
+
this.name = "VersionConflictError";
|
|
2179
|
+
}
|
|
2180
|
+
};
|
|
2181
|
+
BrowserError = class BrowserError extends Error {
|
|
2182
|
+
constructor(message) {
|
|
2183
|
+
super(message);
|
|
2184
|
+
this.name = "BrowserError";
|
|
2185
|
+
}
|
|
2186
|
+
};
|
|
2187
|
+
AIClientError = class AIClientError extends Error {
|
|
2188
|
+
constructor(message) {
|
|
2189
|
+
super(message);
|
|
2190
|
+
this.name = "AIClientError";
|
|
2191
|
+
}
|
|
2192
|
+
};
|
|
2193
|
+
TodosConnectionError = class TodosConnectionError extends Error {
|
|
2194
|
+
constructor(message) {
|
|
2195
|
+
super(message);
|
|
2196
|
+
this.name = "TodosConnectionError";
|
|
2197
|
+
}
|
|
2198
|
+
};
|
|
2199
|
+
ScheduleNotFoundError = class ScheduleNotFoundError extends Error {
|
|
2200
|
+
constructor(id) {
|
|
2201
|
+
super(`Schedule not found: ${id}`);
|
|
2202
|
+
this.name = "ScheduleNotFoundError";
|
|
2203
|
+
}
|
|
2204
|
+
};
|
|
2205
|
+
});
|
|
2214
2206
|
|
|
2215
2207
|
// src/db/database.ts
|
|
2216
2208
|
import { Database } from "bun:sqlite";
|
|
2217
2209
|
import { mkdirSync, existsSync } from "fs";
|
|
2218
2210
|
import { dirname, join } from "path";
|
|
2219
2211
|
import { homedir } from "os";
|
|
2220
|
-
var db = null;
|
|
2221
2212
|
function now() {
|
|
2222
2213
|
return new Date().toISOString();
|
|
2223
2214
|
}
|
|
@@ -2236,8 +2227,50 @@ function resolveDbPath() {
|
|
|
2236
2227
|
mkdirSync(dir, { recursive: true });
|
|
2237
2228
|
return join(dir, "testers.db");
|
|
2238
2229
|
}
|
|
2239
|
-
|
|
2240
|
-
|
|
2230
|
+
function applyMigrations(database) {
|
|
2231
|
+
const applied = database.query("SELECT id FROM _migrations ORDER BY id").all();
|
|
2232
|
+
const appliedIds = new Set(applied.map((r) => r.id));
|
|
2233
|
+
for (let i = 0;i < MIGRATIONS.length; i++) {
|
|
2234
|
+
const migrationId = i + 1;
|
|
2235
|
+
if (appliedIds.has(migrationId))
|
|
2236
|
+
continue;
|
|
2237
|
+
const migration = MIGRATIONS[i];
|
|
2238
|
+
database.exec(migration);
|
|
2239
|
+
database.query("INSERT INTO _migrations (id, applied_at) VALUES (?, ?)").run(migrationId, now());
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
function getDatabase() {
|
|
2243
|
+
if (db)
|
|
2244
|
+
return db;
|
|
2245
|
+
const dbPath = resolveDbPath();
|
|
2246
|
+
const dir = dirname(dbPath);
|
|
2247
|
+
if (dbPath !== ":memory:" && !existsSync(dir)) {
|
|
2248
|
+
mkdirSync(dir, { recursive: true });
|
|
2249
|
+
}
|
|
2250
|
+
db = new Database(dbPath);
|
|
2251
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
2252
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
2253
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
2254
|
+
db.exec(`
|
|
2255
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
2256
|
+
id INTEGER PRIMARY KEY,
|
|
2257
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
2258
|
+
);
|
|
2259
|
+
`);
|
|
2260
|
+
applyMigrations(db);
|
|
2261
|
+
return db;
|
|
2262
|
+
}
|
|
2263
|
+
function resolvePartialId(table, partialId) {
|
|
2264
|
+
const database = getDatabase();
|
|
2265
|
+
const rows = database.query(`SELECT id FROM ${table} WHERE id LIKE ? || '%'`).all(partialId);
|
|
2266
|
+
if (rows.length === 1)
|
|
2267
|
+
return rows[0].id;
|
|
2268
|
+
return null;
|
|
2269
|
+
}
|
|
2270
|
+
var db = null, MIGRATIONS;
|
|
2271
|
+
var init_database = __esm(() => {
|
|
2272
|
+
MIGRATIONS = [
|
|
2273
|
+
`
|
|
2241
2274
|
CREATE TABLE IF NOT EXISTS projects (
|
|
2242
2275
|
id TEXT PRIMARY KEY,
|
|
2243
2276
|
name TEXT NOT NULL UNIQUE,
|
|
@@ -2326,7 +2359,7 @@ var MIGRATIONS = [
|
|
|
2326
2359
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
2327
2360
|
);
|
|
2328
2361
|
`,
|
|
2329
|
-
|
|
2362
|
+
`
|
|
2330
2363
|
CREATE INDEX IF NOT EXISTS idx_scenarios_project ON scenarios(project_id);
|
|
2331
2364
|
CREATE INDEX IF NOT EXISTS idx_scenarios_priority ON scenarios(priority);
|
|
2332
2365
|
CREATE INDEX IF NOT EXISTS idx_scenarios_short_id ON scenarios(short_id);
|
|
@@ -2337,11 +2370,11 @@ var MIGRATIONS = [
|
|
|
2337
2370
|
CREATE INDEX IF NOT EXISTS idx_results_status ON results(status);
|
|
2338
2371
|
CREATE INDEX IF NOT EXISTS idx_screenshots_result ON screenshots(result_id);
|
|
2339
2372
|
`,
|
|
2340
|
-
|
|
2373
|
+
`
|
|
2341
2374
|
ALTER TABLE projects ADD COLUMN scenario_prefix TEXT DEFAULT 'TST';
|
|
2342
2375
|
ALTER TABLE projects ADD COLUMN scenario_counter INTEGER DEFAULT 0;
|
|
2343
2376
|
`,
|
|
2344
|
-
|
|
2377
|
+
`
|
|
2345
2378
|
CREATE TABLE IF NOT EXISTS schedules (
|
|
2346
2379
|
id TEXT PRIMARY KEY,
|
|
2347
2380
|
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
@@ -2364,95 +2397,71 @@ var MIGRATIONS = [
|
|
|
2364
2397
|
CREATE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
|
|
2365
2398
|
CREATE INDEX IF NOT EXISTS idx_schedules_enabled ON schedules(enabled);
|
|
2366
2399
|
CREATE INDEX IF NOT EXISTS idx_schedules_next_run ON schedules(next_run_at);
|
|
2400
|
+
`,
|
|
2401
|
+
`
|
|
2402
|
+
ALTER TABLE screenshots ADD COLUMN description TEXT;
|
|
2403
|
+
ALTER TABLE screenshots ADD COLUMN page_url TEXT;
|
|
2404
|
+
ALTER TABLE screenshots ADD COLUMN thumbnail_path TEXT;
|
|
2405
|
+
`,
|
|
2406
|
+
`
|
|
2407
|
+
CREATE TABLE IF NOT EXISTS auth_presets (
|
|
2408
|
+
id TEXT PRIMARY KEY,
|
|
2409
|
+
name TEXT NOT NULL UNIQUE,
|
|
2410
|
+
email TEXT NOT NULL,
|
|
2411
|
+
password TEXT NOT NULL,
|
|
2412
|
+
login_path TEXT DEFAULT '/login',
|
|
2413
|
+
metadata TEXT DEFAULT '{}',
|
|
2414
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
2415
|
+
);
|
|
2416
|
+
`,
|
|
2417
|
+
`
|
|
2418
|
+
CREATE TABLE IF NOT EXISTS webhooks (
|
|
2419
|
+
id TEXT PRIMARY KEY,
|
|
2420
|
+
url TEXT NOT NULL,
|
|
2421
|
+
events TEXT NOT NULL DEFAULT '["failed"]',
|
|
2422
|
+
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
2423
|
+
secret TEXT,
|
|
2424
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
2425
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
2426
|
+
);
|
|
2427
|
+
CREATE INDEX IF NOT EXISTS idx_webhooks_active ON webhooks(active);
|
|
2367
2428
|
`
|
|
2368
|
-
];
|
|
2369
|
-
|
|
2370
|
-
const applied = database.query("SELECT id FROM _migrations ORDER BY id").all();
|
|
2371
|
-
const appliedIds = new Set(applied.map((r) => r.id));
|
|
2372
|
-
for (let i = 0;i < MIGRATIONS.length; i++) {
|
|
2373
|
-
const migrationId = i + 1;
|
|
2374
|
-
if (appliedIds.has(migrationId))
|
|
2375
|
-
continue;
|
|
2376
|
-
const migration = MIGRATIONS[i];
|
|
2377
|
-
database.exec(migration);
|
|
2378
|
-
database.query("INSERT INTO _migrations (id, applied_at) VALUES (?, ?)").run(migrationId, now());
|
|
2379
|
-
}
|
|
2380
|
-
}
|
|
2381
|
-
function getDatabase() {
|
|
2382
|
-
if (db)
|
|
2383
|
-
return db;
|
|
2384
|
-
const dbPath = resolveDbPath();
|
|
2385
|
-
const dir = dirname(dbPath);
|
|
2386
|
-
if (dbPath !== ":memory:" && !existsSync(dir)) {
|
|
2387
|
-
mkdirSync(dir, { recursive: true });
|
|
2388
|
-
}
|
|
2389
|
-
db = new Database(dbPath);
|
|
2390
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
2391
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
2392
|
-
db.exec("PRAGMA busy_timeout = 5000");
|
|
2393
|
-
db.exec(`
|
|
2394
|
-
CREATE TABLE IF NOT EXISTS _migrations (
|
|
2395
|
-
id INTEGER PRIMARY KEY,
|
|
2396
|
-
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
2397
|
-
);
|
|
2398
|
-
`);
|
|
2399
|
-
applyMigrations(db);
|
|
2400
|
-
return db;
|
|
2401
|
-
}
|
|
2402
|
-
function resolvePartialId(table, partialId) {
|
|
2403
|
-
const database = getDatabase();
|
|
2404
|
-
const rows = database.query(`SELECT id FROM ${table} WHERE id LIKE ? || '%'`).all(partialId);
|
|
2405
|
-
if (rows.length === 1)
|
|
2406
|
-
return rows[0].id;
|
|
2407
|
-
return null;
|
|
2408
|
-
}
|
|
2429
|
+
];
|
|
2430
|
+
});
|
|
2409
2431
|
|
|
2410
|
-
// src/db/
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
}
|
|
2421
|
-
return shortUuid();
|
|
2422
|
-
}
|
|
2423
|
-
function createScenario(input) {
|
|
2432
|
+
// src/db/runs.ts
|
|
2433
|
+
var exports_runs = {};
|
|
2434
|
+
__export(exports_runs, {
|
|
2435
|
+
updateRun: () => updateRun,
|
|
2436
|
+
listRuns: () => listRuns,
|
|
2437
|
+
getRun: () => getRun,
|
|
2438
|
+
deleteRun: () => deleteRun,
|
|
2439
|
+
createRun: () => createRun
|
|
2440
|
+
});
|
|
2441
|
+
function createRun(input) {
|
|
2424
2442
|
const db2 = getDatabase();
|
|
2425
2443
|
const id = uuid();
|
|
2426
|
-
const short_id = nextShortId(input.projectId);
|
|
2427
2444
|
const timestamp = now();
|
|
2428
2445
|
db2.query(`
|
|
2429
|
-
INSERT INTO
|
|
2430
|
-
VALUES (?, ?,
|
|
2431
|
-
`).run(id,
|
|
2432
|
-
return
|
|
2446
|
+
INSERT INTO runs (id, project_id, status, url, model, headed, parallel, total, passed, failed, started_at, finished_at, metadata)
|
|
2447
|
+
VALUES (?, ?, 'pending', ?, ?, ?, ?, 0, 0, 0, ?, NULL, ?)
|
|
2448
|
+
`).run(id, input.projectId ?? null, input.url, input.model, input.headed ? 1 : 0, input.parallel ?? 1, timestamp, input.model ? JSON.stringify({}) : null);
|
|
2449
|
+
return getRun(id);
|
|
2433
2450
|
}
|
|
2434
|
-
function
|
|
2451
|
+
function getRun(id) {
|
|
2435
2452
|
const db2 = getDatabase();
|
|
2436
|
-
let row = db2.query("SELECT * FROM
|
|
2437
|
-
if (row)
|
|
2438
|
-
return scenarioFromRow(row);
|
|
2439
|
-
row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(id);
|
|
2453
|
+
let row = db2.query("SELECT * FROM runs WHERE id = ?").get(id);
|
|
2440
2454
|
if (row)
|
|
2441
|
-
return
|
|
2442
|
-
const fullId = resolvePartialId("
|
|
2455
|
+
return runFromRow(row);
|
|
2456
|
+
const fullId = resolvePartialId("runs", id);
|
|
2443
2457
|
if (fullId) {
|
|
2444
|
-
row = db2.query("SELECT * FROM
|
|
2458
|
+
row = db2.query("SELECT * FROM runs WHERE id = ?").get(fullId);
|
|
2445
2459
|
if (row)
|
|
2446
|
-
return
|
|
2460
|
+
return runFromRow(row);
|
|
2447
2461
|
}
|
|
2448
2462
|
return null;
|
|
2449
2463
|
}
|
|
2450
|
-
function
|
|
2451
|
-
const db2 = getDatabase();
|
|
2452
|
-
const row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(shortId);
|
|
2453
|
-
return row ? scenarioFromRow(row) : null;
|
|
2454
|
-
}
|
|
2455
|
-
function listScenarios(filter) {
|
|
2464
|
+
function listRuns(filter) {
|
|
2456
2465
|
const db2 = getDatabase();
|
|
2457
2466
|
const conditions = [];
|
|
2458
2467
|
const params = [];
|
|
@@ -2460,26 +2469,15 @@ function listScenarios(filter) {
|
|
|
2460
2469
|
conditions.push("project_id = ?");
|
|
2461
2470
|
params.push(filter.projectId);
|
|
2462
2471
|
}
|
|
2463
|
-
if (filter?.
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
params.push(`%"${tag}"%`);
|
|
2467
|
-
}
|
|
2468
|
-
}
|
|
2469
|
-
if (filter?.priority) {
|
|
2470
|
-
conditions.push("priority = ?");
|
|
2471
|
-
params.push(filter.priority);
|
|
2472
|
-
}
|
|
2473
|
-
if (filter?.search) {
|
|
2474
|
-
conditions.push("(name LIKE ? OR description LIKE ?)");
|
|
2475
|
-
const term = `%${filter.search}%`;
|
|
2476
|
-
params.push(term, term);
|
|
2472
|
+
if (filter?.status) {
|
|
2473
|
+
conditions.push("status = ?");
|
|
2474
|
+
params.push(filter.status);
|
|
2477
2475
|
}
|
|
2478
|
-
let sql = "SELECT * FROM
|
|
2476
|
+
let sql = "SELECT * FROM runs";
|
|
2479
2477
|
if (conditions.length > 0) {
|
|
2480
2478
|
sql += " WHERE " + conditions.join(" AND ");
|
|
2481
2479
|
}
|
|
2482
|
-
sql += " ORDER BY
|
|
2480
|
+
sql += " ORDER BY started_at DESC";
|
|
2483
2481
|
if (filter?.limit) {
|
|
2484
2482
|
sql += " LIMIT ?";
|
|
2485
2483
|
params.push(filter.limit);
|
|
@@ -2489,112 +2487,149 @@ function listScenarios(filter) {
|
|
|
2489
2487
|
params.push(filter.offset);
|
|
2490
2488
|
}
|
|
2491
2489
|
const rows = db2.query(sql).all(...params);
|
|
2492
|
-
return rows.map(
|
|
2490
|
+
return rows.map(runFromRow);
|
|
2493
2491
|
}
|
|
2494
|
-
function
|
|
2492
|
+
function updateRun(id, updates) {
|
|
2495
2493
|
const db2 = getDatabase();
|
|
2496
|
-
const existing =
|
|
2494
|
+
const existing = getRun(id);
|
|
2497
2495
|
if (!existing) {
|
|
2498
|
-
throw new Error(`
|
|
2499
|
-
}
|
|
2500
|
-
if (existing.version !== version) {
|
|
2501
|
-
throw new VersionConflictError("scenario", existing.id);
|
|
2496
|
+
throw new Error(`Run not found: ${id}`);
|
|
2502
2497
|
}
|
|
2503
2498
|
const sets = [];
|
|
2504
2499
|
const params = [];
|
|
2505
|
-
if (
|
|
2506
|
-
sets.push("
|
|
2507
|
-
params.push(
|
|
2508
|
-
}
|
|
2509
|
-
if (input.description !== undefined) {
|
|
2510
|
-
sets.push("description = ?");
|
|
2511
|
-
params.push(input.description);
|
|
2500
|
+
if (updates.status !== undefined) {
|
|
2501
|
+
sets.push("status = ?");
|
|
2502
|
+
params.push(updates.status);
|
|
2512
2503
|
}
|
|
2513
|
-
if (
|
|
2514
|
-
sets.push("
|
|
2515
|
-
params.push(
|
|
2504
|
+
if (updates.url !== undefined) {
|
|
2505
|
+
sets.push("url = ?");
|
|
2506
|
+
params.push(updates.url);
|
|
2516
2507
|
}
|
|
2517
|
-
if (
|
|
2518
|
-
sets.push("
|
|
2519
|
-
params.push(
|
|
2508
|
+
if (updates.model !== undefined) {
|
|
2509
|
+
sets.push("model = ?");
|
|
2510
|
+
params.push(updates.model);
|
|
2520
2511
|
}
|
|
2521
|
-
if (
|
|
2522
|
-
sets.push("
|
|
2523
|
-
params.push(
|
|
2512
|
+
if (updates.headed !== undefined) {
|
|
2513
|
+
sets.push("headed = ?");
|
|
2514
|
+
params.push(updates.headed);
|
|
2524
2515
|
}
|
|
2525
|
-
if (
|
|
2526
|
-
sets.push("
|
|
2527
|
-
params.push(
|
|
2516
|
+
if (updates.parallel !== undefined) {
|
|
2517
|
+
sets.push("parallel = ?");
|
|
2518
|
+
params.push(updates.parallel);
|
|
2528
2519
|
}
|
|
2529
|
-
if (
|
|
2530
|
-
sets.push("
|
|
2531
|
-
params.push(
|
|
2520
|
+
if (updates.total !== undefined) {
|
|
2521
|
+
sets.push("total = ?");
|
|
2522
|
+
params.push(updates.total);
|
|
2532
2523
|
}
|
|
2533
|
-
if (
|
|
2534
|
-
sets.push("
|
|
2535
|
-
params.push(
|
|
2524
|
+
if (updates.passed !== undefined) {
|
|
2525
|
+
sets.push("passed = ?");
|
|
2526
|
+
params.push(updates.passed);
|
|
2536
2527
|
}
|
|
2537
|
-
if (
|
|
2538
|
-
sets.push("
|
|
2539
|
-
params.push(
|
|
2528
|
+
if (updates.failed !== undefined) {
|
|
2529
|
+
sets.push("failed = ?");
|
|
2530
|
+
params.push(updates.failed);
|
|
2540
2531
|
}
|
|
2541
|
-
if (
|
|
2542
|
-
sets.push("
|
|
2543
|
-
params.push(
|
|
2532
|
+
if (updates.started_at !== undefined) {
|
|
2533
|
+
sets.push("started_at = ?");
|
|
2534
|
+
params.push(updates.started_at);
|
|
2544
2535
|
}
|
|
2545
|
-
if (
|
|
2536
|
+
if (updates.finished_at !== undefined) {
|
|
2537
|
+
sets.push("finished_at = ?");
|
|
2538
|
+
params.push(updates.finished_at);
|
|
2539
|
+
}
|
|
2540
|
+
if (updates.metadata !== undefined) {
|
|
2546
2541
|
sets.push("metadata = ?");
|
|
2547
|
-
params.push(
|
|
2542
|
+
params.push(updates.metadata);
|
|
2548
2543
|
}
|
|
2549
2544
|
if (sets.length === 0) {
|
|
2550
2545
|
return existing;
|
|
2551
2546
|
}
|
|
2552
|
-
sets.push("version = ?");
|
|
2553
|
-
params.push(version + 1);
|
|
2554
|
-
sets.push("updated_at = ?");
|
|
2555
|
-
params.push(now());
|
|
2556
2547
|
params.push(existing.id);
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
if (result.changes === 0) {
|
|
2560
|
-
throw new VersionConflictError("scenario", existing.id);
|
|
2561
|
-
}
|
|
2562
|
-
return getScenario(existing.id);
|
|
2548
|
+
db2.query(`UPDATE runs SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
2549
|
+
return getRun(existing.id);
|
|
2563
2550
|
}
|
|
2564
|
-
function
|
|
2551
|
+
function deleteRun(id) {
|
|
2565
2552
|
const db2 = getDatabase();
|
|
2566
|
-
const
|
|
2567
|
-
if (!
|
|
2553
|
+
const run = getRun(id);
|
|
2554
|
+
if (!run)
|
|
2568
2555
|
return false;
|
|
2569
|
-
const result = db2.query("DELETE FROM
|
|
2556
|
+
const result = db2.query("DELETE FROM runs WHERE id = ?").run(run.id);
|
|
2570
2557
|
return result.changes > 0;
|
|
2571
2558
|
}
|
|
2559
|
+
var init_runs = __esm(() => {
|
|
2560
|
+
init_types();
|
|
2561
|
+
init_database();
|
|
2562
|
+
});
|
|
2572
2563
|
|
|
2573
|
-
//
|
|
2574
|
-
|
|
2564
|
+
// node_modules/commander/esm.mjs
|
|
2565
|
+
var import__ = __toESM(require_commander(), 1);
|
|
2566
|
+
var {
|
|
2567
|
+
program,
|
|
2568
|
+
createCommand,
|
|
2569
|
+
createArgument,
|
|
2570
|
+
createOption,
|
|
2571
|
+
CommanderError,
|
|
2572
|
+
InvalidArgumentError,
|
|
2573
|
+
InvalidOptionArgumentError,
|
|
2574
|
+
Command,
|
|
2575
|
+
Argument,
|
|
2576
|
+
Option,
|
|
2577
|
+
Help
|
|
2578
|
+
} = import__.default;
|
|
2579
|
+
|
|
2580
|
+
// src/cli/index.tsx
|
|
2581
|
+
import chalk4 from "chalk";
|
|
2582
|
+
import { readFileSync as readFileSync4, readdirSync, writeFileSync as writeFileSync3 } from "fs";
|
|
2583
|
+
import { join as join6, resolve } from "path";
|
|
2584
|
+
|
|
2585
|
+
// src/db/scenarios.ts
|
|
2586
|
+
init_types();
|
|
2587
|
+
init_database();
|
|
2588
|
+
function nextShortId(projectId) {
|
|
2589
|
+
const db2 = getDatabase();
|
|
2590
|
+
if (projectId) {
|
|
2591
|
+
const project = db2.query("SELECT scenario_prefix, scenario_counter FROM projects WHERE id = ?").get(projectId);
|
|
2592
|
+
if (project) {
|
|
2593
|
+
const next = project.scenario_counter + 1;
|
|
2594
|
+
db2.query("UPDATE projects SET scenario_counter = ? WHERE id = ?").run(next, projectId);
|
|
2595
|
+
return `${project.scenario_prefix}-${next}`;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
return shortUuid();
|
|
2599
|
+
}
|
|
2600
|
+
function createScenario(input) {
|
|
2575
2601
|
const db2 = getDatabase();
|
|
2576
2602
|
const id = uuid();
|
|
2603
|
+
const short_id = nextShortId(input.projectId);
|
|
2577
2604
|
const timestamp = now();
|
|
2578
2605
|
db2.query(`
|
|
2579
|
-
INSERT INTO
|
|
2580
|
-
VALUES (?, ?,
|
|
2581
|
-
`).run(id, input.projectId ?? null, input.
|
|
2582
|
-
return
|
|
2606
|
+
INSERT INTO scenarios (id, short_id, project_id, name, description, steps, tags, priority, model, timeout_ms, target_path, requires_auth, auth_config, metadata, version, created_at, updated_at)
|
|
2607
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
|
2608
|
+
`).run(id, short_id, input.projectId ?? null, input.name, input.description, JSON.stringify(input.steps ?? []), JSON.stringify(input.tags ?? []), input.priority ?? "medium", input.model ?? null, input.timeoutMs ?? null, input.targetPath ?? null, input.requiresAuth ? 1 : 0, input.authConfig ? JSON.stringify(input.authConfig) : null, input.metadata ? JSON.stringify(input.metadata) : null, timestamp, timestamp);
|
|
2609
|
+
return getScenario(id);
|
|
2583
2610
|
}
|
|
2584
|
-
function
|
|
2611
|
+
function getScenario(id) {
|
|
2585
2612
|
const db2 = getDatabase();
|
|
2586
|
-
let row = db2.query("SELECT * FROM
|
|
2613
|
+
let row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(id);
|
|
2587
2614
|
if (row)
|
|
2588
|
-
return
|
|
2589
|
-
|
|
2615
|
+
return scenarioFromRow(row);
|
|
2616
|
+
row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(id);
|
|
2617
|
+
if (row)
|
|
2618
|
+
return scenarioFromRow(row);
|
|
2619
|
+
const fullId = resolvePartialId("scenarios", id);
|
|
2590
2620
|
if (fullId) {
|
|
2591
|
-
row = db2.query("SELECT * FROM
|
|
2621
|
+
row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(fullId);
|
|
2592
2622
|
if (row)
|
|
2593
|
-
return
|
|
2623
|
+
return scenarioFromRow(row);
|
|
2594
2624
|
}
|
|
2595
2625
|
return null;
|
|
2596
2626
|
}
|
|
2597
|
-
function
|
|
2627
|
+
function getScenarioByShortId(shortId) {
|
|
2628
|
+
const db2 = getDatabase();
|
|
2629
|
+
const row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(shortId);
|
|
2630
|
+
return row ? scenarioFromRow(row) : null;
|
|
2631
|
+
}
|
|
2632
|
+
function listScenarios(filter) {
|
|
2598
2633
|
const db2 = getDatabase();
|
|
2599
2634
|
const conditions = [];
|
|
2600
2635
|
const params = [];
|
|
@@ -2602,15 +2637,26 @@ function listRuns(filter) {
|
|
|
2602
2637
|
conditions.push("project_id = ?");
|
|
2603
2638
|
params.push(filter.projectId);
|
|
2604
2639
|
}
|
|
2605
|
-
if (filter?.
|
|
2606
|
-
|
|
2607
|
-
|
|
2640
|
+
if (filter?.tags && filter.tags.length > 0) {
|
|
2641
|
+
for (const tag of filter.tags) {
|
|
2642
|
+
conditions.push("tags LIKE ?");
|
|
2643
|
+
params.push(`%"${tag}"%`);
|
|
2644
|
+
}
|
|
2608
2645
|
}
|
|
2609
|
-
|
|
2646
|
+
if (filter?.priority) {
|
|
2647
|
+
conditions.push("priority = ?");
|
|
2648
|
+
params.push(filter.priority);
|
|
2649
|
+
}
|
|
2650
|
+
if (filter?.search) {
|
|
2651
|
+
conditions.push("(name LIKE ? OR description LIKE ?)");
|
|
2652
|
+
const term = `%${filter.search}%`;
|
|
2653
|
+
params.push(term, term);
|
|
2654
|
+
}
|
|
2655
|
+
let sql = "SELECT * FROM scenarios";
|
|
2610
2656
|
if (conditions.length > 0) {
|
|
2611
2657
|
sql += " WHERE " + conditions.join(" AND ");
|
|
2612
2658
|
}
|
|
2613
|
-
sql += " ORDER BY
|
|
2659
|
+
sql += " ORDER BY created_at DESC";
|
|
2614
2660
|
if (filter?.limit) {
|
|
2615
2661
|
sql += " LIMIT ?";
|
|
2616
2662
|
params.push(filter.limit);
|
|
@@ -2620,69 +2666,93 @@ function listRuns(filter) {
|
|
|
2620
2666
|
params.push(filter.offset);
|
|
2621
2667
|
}
|
|
2622
2668
|
const rows = db2.query(sql).all(...params);
|
|
2623
|
-
return rows.map(
|
|
2669
|
+
return rows.map(scenarioFromRow);
|
|
2624
2670
|
}
|
|
2625
|
-
function
|
|
2671
|
+
function updateScenario(id, input, version) {
|
|
2626
2672
|
const db2 = getDatabase();
|
|
2627
|
-
const existing =
|
|
2673
|
+
const existing = getScenario(id);
|
|
2628
2674
|
if (!existing) {
|
|
2629
|
-
throw new Error(`
|
|
2675
|
+
throw new Error(`Scenario not found: ${id}`);
|
|
2676
|
+
}
|
|
2677
|
+
if (existing.version !== version) {
|
|
2678
|
+
throw new VersionConflictError("scenario", existing.id);
|
|
2630
2679
|
}
|
|
2631
2680
|
const sets = [];
|
|
2632
2681
|
const params = [];
|
|
2633
|
-
if (
|
|
2634
|
-
sets.push("
|
|
2635
|
-
params.push(
|
|
2682
|
+
if (input.name !== undefined) {
|
|
2683
|
+
sets.push("name = ?");
|
|
2684
|
+
params.push(input.name);
|
|
2636
2685
|
}
|
|
2637
|
-
if (
|
|
2638
|
-
sets.push("
|
|
2639
|
-
params.push(
|
|
2686
|
+
if (input.description !== undefined) {
|
|
2687
|
+
sets.push("description = ?");
|
|
2688
|
+
params.push(input.description);
|
|
2640
2689
|
}
|
|
2641
|
-
if (
|
|
2642
|
-
sets.push("
|
|
2643
|
-
params.push(
|
|
2690
|
+
if (input.steps !== undefined) {
|
|
2691
|
+
sets.push("steps = ?");
|
|
2692
|
+
params.push(JSON.stringify(input.steps));
|
|
2644
2693
|
}
|
|
2645
|
-
if (
|
|
2646
|
-
sets.push("
|
|
2647
|
-
params.push(
|
|
2694
|
+
if (input.tags !== undefined) {
|
|
2695
|
+
sets.push("tags = ?");
|
|
2696
|
+
params.push(JSON.stringify(input.tags));
|
|
2648
2697
|
}
|
|
2649
|
-
if (
|
|
2650
|
-
sets.push("
|
|
2651
|
-
params.push(
|
|
2698
|
+
if (input.priority !== undefined) {
|
|
2699
|
+
sets.push("priority = ?");
|
|
2700
|
+
params.push(input.priority);
|
|
2652
2701
|
}
|
|
2653
|
-
if (
|
|
2654
|
-
sets.push("
|
|
2655
|
-
params.push(
|
|
2702
|
+
if (input.model !== undefined) {
|
|
2703
|
+
sets.push("model = ?");
|
|
2704
|
+
params.push(input.model);
|
|
2656
2705
|
}
|
|
2657
|
-
if (
|
|
2658
|
-
sets.push("
|
|
2659
|
-
params.push(
|
|
2706
|
+
if (input.timeoutMs !== undefined) {
|
|
2707
|
+
sets.push("timeout_ms = ?");
|
|
2708
|
+
params.push(input.timeoutMs);
|
|
2660
2709
|
}
|
|
2661
|
-
if (
|
|
2662
|
-
sets.push("
|
|
2663
|
-
params.push(
|
|
2710
|
+
if (input.targetPath !== undefined) {
|
|
2711
|
+
sets.push("target_path = ?");
|
|
2712
|
+
params.push(input.targetPath);
|
|
2664
2713
|
}
|
|
2665
|
-
if (
|
|
2666
|
-
sets.push("
|
|
2667
|
-
params.push(
|
|
2714
|
+
if (input.requiresAuth !== undefined) {
|
|
2715
|
+
sets.push("requires_auth = ?");
|
|
2716
|
+
params.push(input.requiresAuth ? 1 : 0);
|
|
2668
2717
|
}
|
|
2669
|
-
if (
|
|
2670
|
-
sets.push("
|
|
2671
|
-
params.push(
|
|
2718
|
+
if (input.authConfig !== undefined) {
|
|
2719
|
+
sets.push("auth_config = ?");
|
|
2720
|
+
params.push(JSON.stringify(input.authConfig));
|
|
2672
2721
|
}
|
|
2673
|
-
if (
|
|
2722
|
+
if (input.metadata !== undefined) {
|
|
2674
2723
|
sets.push("metadata = ?");
|
|
2675
|
-
params.push(
|
|
2724
|
+
params.push(JSON.stringify(input.metadata));
|
|
2676
2725
|
}
|
|
2677
2726
|
if (sets.length === 0) {
|
|
2678
2727
|
return existing;
|
|
2679
2728
|
}
|
|
2729
|
+
sets.push("version = ?");
|
|
2730
|
+
params.push(version + 1);
|
|
2731
|
+
sets.push("updated_at = ?");
|
|
2732
|
+
params.push(now());
|
|
2680
2733
|
params.push(existing.id);
|
|
2681
|
-
|
|
2682
|
-
|
|
2734
|
+
params.push(version);
|
|
2735
|
+
const result = db2.query(`UPDATE scenarios SET ${sets.join(", ")} WHERE id = ? AND version = ?`).run(...params);
|
|
2736
|
+
if (result.changes === 0) {
|
|
2737
|
+
throw new VersionConflictError("scenario", existing.id);
|
|
2738
|
+
}
|
|
2739
|
+
return getScenario(existing.id);
|
|
2683
2740
|
}
|
|
2741
|
+
function deleteScenario(id) {
|
|
2742
|
+
const db2 = getDatabase();
|
|
2743
|
+
const scenario = getScenario(id);
|
|
2744
|
+
if (!scenario)
|
|
2745
|
+
return false;
|
|
2746
|
+
const result = db2.query("DELETE FROM scenarios WHERE id = ?").run(scenario.id);
|
|
2747
|
+
return result.changes > 0;
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
// src/cli/index.tsx
|
|
2751
|
+
init_runs();
|
|
2684
2752
|
|
|
2685
2753
|
// src/db/results.ts
|
|
2754
|
+
init_types();
|
|
2755
|
+
init_database();
|
|
2686
2756
|
function createResult(input) {
|
|
2687
2757
|
const db2 = getDatabase();
|
|
2688
2758
|
const id = uuid();
|
|
@@ -2759,14 +2829,16 @@ function getResultsByRun(runId) {
|
|
|
2759
2829
|
}
|
|
2760
2830
|
|
|
2761
2831
|
// src/db/screenshots.ts
|
|
2832
|
+
init_types();
|
|
2833
|
+
init_database();
|
|
2762
2834
|
function createScreenshot(input) {
|
|
2763
2835
|
const db2 = getDatabase();
|
|
2764
2836
|
const id = uuid();
|
|
2765
2837
|
const timestamp = now();
|
|
2766
2838
|
db2.query(`
|
|
2767
|
-
INSERT INTO screenshots (id, result_id, step_number, action, file_path, width, height, timestamp)
|
|
2768
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
2769
|
-
`).run(id, input.resultId, input.stepNumber, input.action, input.filePath, input.width, input.height, timestamp);
|
|
2839
|
+
INSERT INTO screenshots (id, result_id, step_number, action, file_path, width, height, timestamp, description, page_url, thumbnail_path)
|
|
2840
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2841
|
+
`).run(id, input.resultId, input.stepNumber, input.action, input.filePath, input.width, input.height, timestamp, input.description ?? null, input.pageUrl ?? null, input.thumbnailPath ?? null);
|
|
2770
2842
|
return getScreenshot(id);
|
|
2771
2843
|
}
|
|
2772
2844
|
function getScreenshot(id) {
|
|
@@ -2780,7 +2852,11 @@ function listScreenshots(resultId) {
|
|
|
2780
2852
|
return rows.map(screenshotFromRow);
|
|
2781
2853
|
}
|
|
2782
2854
|
|
|
2855
|
+
// src/lib/runner.ts
|
|
2856
|
+
init_runs();
|
|
2857
|
+
|
|
2783
2858
|
// src/lib/browser.ts
|
|
2859
|
+
init_types();
|
|
2784
2860
|
import { chromium } from "playwright";
|
|
2785
2861
|
import { execSync } from "child_process";
|
|
2786
2862
|
var DEFAULT_VIEWPORT = { width: 1280, height: 720 };
|
|
@@ -2835,7 +2911,7 @@ async function installBrowser() {
|
|
|
2835
2911
|
}
|
|
2836
2912
|
|
|
2837
2913
|
// src/lib/screenshotter.ts
|
|
2838
|
-
import { mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
|
|
2914
|
+
import { mkdirSync as mkdirSync2, existsSync as existsSync2, writeFileSync } from "fs";
|
|
2839
2915
|
import { join as join2 } from "path";
|
|
2840
2916
|
import { homedir as homedir2 } from "os";
|
|
2841
2917
|
function slugify(text) {
|
|
@@ -2844,16 +2920,51 @@ function slugify(text) {
|
|
|
2844
2920
|
function generateFilename(stepNumber, action) {
|
|
2845
2921
|
const padded = String(stepNumber).padStart(3, "0");
|
|
2846
2922
|
const slug = slugify(action);
|
|
2847
|
-
return `${padded}
|
|
2923
|
+
return `${padded}_${slug}.png`;
|
|
2924
|
+
}
|
|
2925
|
+
function formatDate(date) {
|
|
2926
|
+
return date.toISOString().slice(0, 10);
|
|
2848
2927
|
}
|
|
2849
|
-
function
|
|
2850
|
-
return
|
|
2928
|
+
function formatTime(date) {
|
|
2929
|
+
return date.toISOString().slice(11, 19).replace(/:/g, "-");
|
|
2930
|
+
}
|
|
2931
|
+
function getScreenshotDir(baseDir, runId, scenarioSlug, projectName, timestamp) {
|
|
2932
|
+
const now2 = timestamp ?? new Date;
|
|
2933
|
+
const project = projectName ?? "default";
|
|
2934
|
+
const dateDir = formatDate(now2);
|
|
2935
|
+
const timeDir = `${formatTime(now2)}_${runId.slice(0, 8)}`;
|
|
2936
|
+
return join2(baseDir, project, dateDir, timeDir, scenarioSlug);
|
|
2851
2937
|
}
|
|
2852
2938
|
function ensureDir(dirPath) {
|
|
2853
2939
|
if (!existsSync2(dirPath)) {
|
|
2854
2940
|
mkdirSync2(dirPath, { recursive: true });
|
|
2855
2941
|
}
|
|
2856
2942
|
}
|
|
2943
|
+
function writeMetaSidecar(screenshotPath, meta) {
|
|
2944
|
+
const metaPath = screenshotPath.replace(/\.png$/, ".meta.json").replace(/\.jpeg$/, ".meta.json");
|
|
2945
|
+
try {
|
|
2946
|
+
writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf-8");
|
|
2947
|
+
} catch {}
|
|
2948
|
+
}
|
|
2949
|
+
async function generateThumbnail(page, screenshotDir, filename) {
|
|
2950
|
+
try {
|
|
2951
|
+
const thumbDir = join2(screenshotDir, "_thumbnail");
|
|
2952
|
+
ensureDir(thumbDir);
|
|
2953
|
+
const thumbFilename = filename.replace(/\.(png|jpeg)$/, ".thumb.$1");
|
|
2954
|
+
const thumbPath = join2(thumbDir, thumbFilename);
|
|
2955
|
+
const viewport = page.viewportSize();
|
|
2956
|
+
if (viewport) {
|
|
2957
|
+
await page.screenshot({
|
|
2958
|
+
path: thumbPath,
|
|
2959
|
+
type: "png",
|
|
2960
|
+
clip: { x: 0, y: 0, width: Math.min(viewport.width, 1280), height: Math.min(viewport.height, 720) }
|
|
2961
|
+
});
|
|
2962
|
+
}
|
|
2963
|
+
return thumbPath;
|
|
2964
|
+
} catch {
|
|
2965
|
+
return null;
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2857
2968
|
var DEFAULT_BASE_DIR = join2(homedir2(), ".testers", "screenshots");
|
|
2858
2969
|
|
|
2859
2970
|
class Screenshotter {
|
|
@@ -2861,15 +2972,20 @@ class Screenshotter {
|
|
|
2861
2972
|
format;
|
|
2862
2973
|
quality;
|
|
2863
2974
|
fullPage;
|
|
2975
|
+
projectName;
|
|
2976
|
+
runTimestamp;
|
|
2864
2977
|
constructor(options = {}) {
|
|
2865
2978
|
this.baseDir = options.baseDir ?? DEFAULT_BASE_DIR;
|
|
2866
2979
|
this.format = options.format ?? "png";
|
|
2867
2980
|
this.quality = options.quality ?? 90;
|
|
2868
2981
|
this.fullPage = options.fullPage ?? false;
|
|
2982
|
+
this.projectName = options.projectName ?? "default";
|
|
2983
|
+
this.runTimestamp = new Date;
|
|
2869
2984
|
}
|
|
2870
2985
|
async capture(page, options) {
|
|
2871
|
-
const
|
|
2872
|
-
const
|
|
2986
|
+
const action = options.description ?? options.action;
|
|
2987
|
+
const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug, this.projectName, this.runTimestamp);
|
|
2988
|
+
const filename = generateFilename(options.stepNumber, action);
|
|
2873
2989
|
const filePath = join2(dir, filename);
|
|
2874
2990
|
ensureDir(dir);
|
|
2875
2991
|
await page.screenshot({
|
|
@@ -2879,16 +2995,32 @@ class Screenshotter {
|
|
|
2879
2995
|
quality: this.format === "jpeg" ? this.quality : undefined
|
|
2880
2996
|
});
|
|
2881
2997
|
const viewport = page.viewportSize() ?? { width: 0, height: 0 };
|
|
2998
|
+
const pageUrl = page.url();
|
|
2999
|
+
const timestamp = new Date().toISOString();
|
|
3000
|
+
writeMetaSidecar(filePath, {
|
|
3001
|
+
stepNumber: options.stepNumber,
|
|
3002
|
+
action: options.action,
|
|
3003
|
+
description: options.description ?? null,
|
|
3004
|
+
pageUrl,
|
|
3005
|
+
viewport,
|
|
3006
|
+
timestamp,
|
|
3007
|
+
filePath
|
|
3008
|
+
});
|
|
3009
|
+
const thumbnailPath = await generateThumbnail(page, dir, filename);
|
|
2882
3010
|
return {
|
|
2883
3011
|
filePath,
|
|
2884
3012
|
width: viewport.width,
|
|
2885
3013
|
height: viewport.height,
|
|
2886
|
-
timestamp
|
|
3014
|
+
timestamp,
|
|
3015
|
+
description: options.description ?? null,
|
|
3016
|
+
pageUrl,
|
|
3017
|
+
thumbnailPath
|
|
2887
3018
|
};
|
|
2888
3019
|
}
|
|
2889
3020
|
async captureFullPage(page, options) {
|
|
2890
|
-
const
|
|
2891
|
-
const
|
|
3021
|
+
const action = options.description ?? options.action;
|
|
3022
|
+
const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug, this.projectName, this.runTimestamp);
|
|
3023
|
+
const filename = generateFilename(options.stepNumber, action);
|
|
2892
3024
|
const filePath = join2(dir, filename);
|
|
2893
3025
|
ensureDir(dir);
|
|
2894
3026
|
await page.screenshot({
|
|
@@ -2898,16 +3030,32 @@ class Screenshotter {
|
|
|
2898
3030
|
quality: this.format === "jpeg" ? this.quality : undefined
|
|
2899
3031
|
});
|
|
2900
3032
|
const viewport = page.viewportSize() ?? { width: 0, height: 0 };
|
|
3033
|
+
const pageUrl = page.url();
|
|
3034
|
+
const timestamp = new Date().toISOString();
|
|
3035
|
+
writeMetaSidecar(filePath, {
|
|
3036
|
+
stepNumber: options.stepNumber,
|
|
3037
|
+
action: options.action,
|
|
3038
|
+
description: options.description ?? null,
|
|
3039
|
+
pageUrl,
|
|
3040
|
+
viewport,
|
|
3041
|
+
timestamp,
|
|
3042
|
+
filePath
|
|
3043
|
+
});
|
|
3044
|
+
const thumbnailPath = await generateThumbnail(page, dir, filename);
|
|
2901
3045
|
return {
|
|
2902
3046
|
filePath,
|
|
2903
3047
|
width: viewport.width,
|
|
2904
3048
|
height: viewport.height,
|
|
2905
|
-
timestamp
|
|
3049
|
+
timestamp,
|
|
3050
|
+
description: options.description ?? null,
|
|
3051
|
+
pageUrl,
|
|
3052
|
+
thumbnailPath
|
|
2906
3053
|
};
|
|
2907
3054
|
}
|
|
2908
3055
|
async captureElement(page, selector, options) {
|
|
2909
|
-
const
|
|
2910
|
-
const
|
|
3056
|
+
const action = options.description ?? options.action;
|
|
3057
|
+
const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug, this.projectName, this.runTimestamp);
|
|
3058
|
+
const filename = generateFilename(options.stepNumber, action);
|
|
2911
3059
|
const filePath = join2(dir, filename);
|
|
2912
3060
|
ensureDir(dir);
|
|
2913
3061
|
await page.locator(selector).screenshot({
|
|
@@ -2916,16 +3064,31 @@ class Screenshotter {
|
|
|
2916
3064
|
quality: this.format === "jpeg" ? this.quality : undefined
|
|
2917
3065
|
});
|
|
2918
3066
|
const viewport = page.viewportSize() ?? { width: 0, height: 0 };
|
|
3067
|
+
const pageUrl = page.url();
|
|
3068
|
+
const timestamp = new Date().toISOString();
|
|
3069
|
+
writeMetaSidecar(filePath, {
|
|
3070
|
+
stepNumber: options.stepNumber,
|
|
3071
|
+
action: options.action,
|
|
3072
|
+
description: options.description ?? null,
|
|
3073
|
+
pageUrl,
|
|
3074
|
+
viewport,
|
|
3075
|
+
timestamp,
|
|
3076
|
+
filePath
|
|
3077
|
+
});
|
|
2919
3078
|
return {
|
|
2920
3079
|
filePath,
|
|
2921
3080
|
width: viewport.width,
|
|
2922
3081
|
height: viewport.height,
|
|
2923
|
-
timestamp
|
|
3082
|
+
timestamp,
|
|
3083
|
+
description: options.description ?? null,
|
|
3084
|
+
pageUrl,
|
|
3085
|
+
thumbnailPath: null
|
|
2924
3086
|
};
|
|
2925
3087
|
}
|
|
2926
3088
|
}
|
|
2927
3089
|
|
|
2928
3090
|
// src/lib/ai-client.ts
|
|
3091
|
+
init_types();
|
|
2929
3092
|
import Anthropic from "@anthropic-ai/sdk";
|
|
2930
3093
|
function resolveModel(nameOrPreset) {
|
|
2931
3094
|
if (nameOrPreset in MODEL_MAP) {
|
|
@@ -3604,6 +3767,7 @@ function createClient(apiKey) {
|
|
|
3604
3767
|
}
|
|
3605
3768
|
|
|
3606
3769
|
// src/lib/config.ts
|
|
3770
|
+
init_types();
|
|
3607
3771
|
import { homedir as homedir3 } from "os";
|
|
3608
3772
|
import { join as join3 } from "path";
|
|
3609
3773
|
import { readFileSync, existsSync as existsSync3 } from "fs";
|
|
@@ -3703,7 +3867,10 @@ async function runSingleScenario(scenario, runId, options) {
|
|
|
3703
3867
|
action: ss.action,
|
|
3704
3868
|
filePath: ss.filePath,
|
|
3705
3869
|
width: ss.width,
|
|
3706
|
-
height: ss.height
|
|
3870
|
+
height: ss.height,
|
|
3871
|
+
description: ss.description,
|
|
3872
|
+
pageUrl: ss.pageUrl,
|
|
3873
|
+
thumbnailPath: ss.thumbnailPath
|
|
3707
3874
|
});
|
|
3708
3875
|
emit({ type: "screenshot:captured", screenshotPath: ss.filePath, scenarioId: scenario.id, runId });
|
|
3709
3876
|
}
|
|
@@ -3801,6 +3968,79 @@ async function runByFilter(options) {
|
|
|
3801
3968
|
}
|
|
3802
3969
|
return runBatch(scenarios, options);
|
|
3803
3970
|
}
|
|
3971
|
+
function startRunAsync(options) {
|
|
3972
|
+
const config = loadConfig();
|
|
3973
|
+
const model = resolveModel(options.model ?? config.defaultModel);
|
|
3974
|
+
let scenarios;
|
|
3975
|
+
if (options.scenarioIds && options.scenarioIds.length > 0) {
|
|
3976
|
+
const all = listScenarios({ projectId: options.projectId });
|
|
3977
|
+
scenarios = all.filter((s) => options.scenarioIds.includes(s.id) || options.scenarioIds.includes(s.shortId));
|
|
3978
|
+
} else {
|
|
3979
|
+
scenarios = listScenarios({
|
|
3980
|
+
projectId: options.projectId,
|
|
3981
|
+
tags: options.tags,
|
|
3982
|
+
priority: options.priority
|
|
3983
|
+
});
|
|
3984
|
+
}
|
|
3985
|
+
const parallel = options.parallel ?? 1;
|
|
3986
|
+
const run = createRun({
|
|
3987
|
+
url: options.url,
|
|
3988
|
+
model,
|
|
3989
|
+
headed: options.headed,
|
|
3990
|
+
parallel,
|
|
3991
|
+
projectId: options.projectId
|
|
3992
|
+
});
|
|
3993
|
+
if (scenarios.length === 0) {
|
|
3994
|
+
updateRun(run.id, { status: "passed", total: 0, finished_at: new Date().toISOString() });
|
|
3995
|
+
return { runId: run.id, scenarioCount: 0 };
|
|
3996
|
+
}
|
|
3997
|
+
updateRun(run.id, { status: "running", total: scenarios.length });
|
|
3998
|
+
(async () => {
|
|
3999
|
+
const results = [];
|
|
4000
|
+
try {
|
|
4001
|
+
if (parallel <= 1) {
|
|
4002
|
+
for (const scenario of scenarios) {
|
|
4003
|
+
const result = await runSingleScenario(scenario, run.id, options);
|
|
4004
|
+
results.push(result);
|
|
4005
|
+
}
|
|
4006
|
+
} else {
|
|
4007
|
+
const queue = [...scenarios];
|
|
4008
|
+
const running = [];
|
|
4009
|
+
const processNext = async () => {
|
|
4010
|
+
const scenario = queue.shift();
|
|
4011
|
+
if (!scenario)
|
|
4012
|
+
return;
|
|
4013
|
+
const result = await runSingleScenario(scenario, run.id, options);
|
|
4014
|
+
results.push(result);
|
|
4015
|
+
await processNext();
|
|
4016
|
+
};
|
|
4017
|
+
const workers = Math.min(parallel, scenarios.length);
|
|
4018
|
+
for (let i = 0;i < workers; i++) {
|
|
4019
|
+
running.push(processNext());
|
|
4020
|
+
}
|
|
4021
|
+
await Promise.all(running);
|
|
4022
|
+
}
|
|
4023
|
+
const passed = results.filter((r) => r.status === "passed").length;
|
|
4024
|
+
const failed = results.filter((r) => r.status === "failed" || r.status === "error").length;
|
|
4025
|
+
updateRun(run.id, {
|
|
4026
|
+
status: failed > 0 ? "failed" : "passed",
|
|
4027
|
+
passed,
|
|
4028
|
+
failed,
|
|
4029
|
+
total: scenarios.length,
|
|
4030
|
+
finished_at: new Date().toISOString()
|
|
4031
|
+
});
|
|
4032
|
+
emit({ type: "run:complete", runId: run.id });
|
|
4033
|
+
} catch (error) {
|
|
4034
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
4035
|
+
updateRun(run.id, {
|
|
4036
|
+
status: "failed",
|
|
4037
|
+
finished_at: new Date().toISOString()
|
|
4038
|
+
});
|
|
4039
|
+
emit({ type: "run:complete", runId: run.id, error: errorMsg });
|
|
4040
|
+
}
|
|
4041
|
+
})();
|
|
4042
|
+
return { runId: run.id, scenarioCount: scenarios.length };
|
|
4043
|
+
}
|
|
3804
4044
|
function estimateCost(model, tokens) {
|
|
3805
4045
|
const costs = {
|
|
3806
4046
|
"claude-haiku-4-5-20251001": 0.1,
|
|
@@ -3970,6 +4210,7 @@ import { Database as Database2 } from "bun:sqlite";
|
|
|
3970
4210
|
import { existsSync as existsSync4 } from "fs";
|
|
3971
4211
|
import { join as join4 } from "path";
|
|
3972
4212
|
import { homedir as homedir4 } from "os";
|
|
4213
|
+
init_types();
|
|
3973
4214
|
function resolveTodosDbPath() {
|
|
3974
4215
|
const envPath = process.env["TODOS_DB_PATH"];
|
|
3975
4216
|
if (envPath)
|
|
@@ -4059,46 +4300,880 @@ function importFromTodos(options = {}) {
|
|
|
4059
4300
|
skipped++;
|
|
4060
4301
|
continue;
|
|
4061
4302
|
}
|
|
4062
|
-
const input = taskToScenarioInput(task, options.projectId);
|
|
4063
|
-
createScenario(input);
|
|
4064
|
-
imported++;
|
|
4303
|
+
const input = taskToScenarioInput(task, options.projectId);
|
|
4304
|
+
createScenario(input);
|
|
4305
|
+
imported++;
|
|
4306
|
+
}
|
|
4307
|
+
return { imported, skipped };
|
|
4308
|
+
}
|
|
4309
|
+
|
|
4310
|
+
// src/lib/init.ts
|
|
4311
|
+
import { existsSync as existsSync5, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
|
|
4312
|
+
import { join as join5, basename } from "path";
|
|
4313
|
+
import { homedir as homedir5 } from "os";
|
|
4314
|
+
|
|
4315
|
+
// src/db/projects.ts
|
|
4316
|
+
init_types();
|
|
4317
|
+
init_database();
|
|
4318
|
+
function createProject(input) {
|
|
4319
|
+
const db2 = getDatabase();
|
|
4320
|
+
const id = uuid();
|
|
4321
|
+
const timestamp = now();
|
|
4322
|
+
db2.query(`
|
|
4323
|
+
INSERT INTO projects (id, name, path, description, created_at, updated_at)
|
|
4324
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
4325
|
+
`).run(id, input.name, input.path ?? null, input.description ?? null, timestamp, timestamp);
|
|
4326
|
+
return getProject(id);
|
|
4327
|
+
}
|
|
4328
|
+
function getProject(id) {
|
|
4329
|
+
const db2 = getDatabase();
|
|
4330
|
+
const row = db2.query("SELECT * FROM projects WHERE id = ?").get(id);
|
|
4331
|
+
return row ? projectFromRow(row) : null;
|
|
4332
|
+
}
|
|
4333
|
+
function listProjects() {
|
|
4334
|
+
const db2 = getDatabase();
|
|
4335
|
+
const rows = db2.query("SELECT * FROM projects ORDER BY created_at DESC").all();
|
|
4336
|
+
return rows.map(projectFromRow);
|
|
4337
|
+
}
|
|
4338
|
+
function ensureProject(name, path) {
|
|
4339
|
+
const db2 = getDatabase();
|
|
4340
|
+
const byPath = db2.query("SELECT * FROM projects WHERE path = ?").get(path);
|
|
4341
|
+
if (byPath)
|
|
4342
|
+
return projectFromRow(byPath);
|
|
4343
|
+
const byName = db2.query("SELECT * FROM projects WHERE name = ?").get(name);
|
|
4344
|
+
if (byName)
|
|
4345
|
+
return projectFromRow(byName);
|
|
4346
|
+
return createProject({ name, path });
|
|
4347
|
+
}
|
|
4348
|
+
|
|
4349
|
+
// src/lib/init.ts
|
|
4350
|
+
function detectFramework(dir) {
|
|
4351
|
+
const pkgPath = join5(dir, "package.json");
|
|
4352
|
+
if (!existsSync5(pkgPath))
|
|
4353
|
+
return null;
|
|
4354
|
+
let pkg;
|
|
4355
|
+
try {
|
|
4356
|
+
pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
4357
|
+
} catch {
|
|
4358
|
+
return null;
|
|
4359
|
+
}
|
|
4360
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
4361
|
+
const depNames = Object.keys(allDeps);
|
|
4362
|
+
const features = [];
|
|
4363
|
+
const hasAuth = depNames.some((d) => d === "next-auth" || d.startsWith("@auth/") || d === "passport" || d === "lucia");
|
|
4364
|
+
if (hasAuth)
|
|
4365
|
+
features.push("hasAuth");
|
|
4366
|
+
const hasForms = depNames.some((d) => d === "react-hook-form" || d === "formik" || d === "zod");
|
|
4367
|
+
if (hasForms)
|
|
4368
|
+
features.push("hasForms");
|
|
4369
|
+
if ("next" in allDeps) {
|
|
4370
|
+
return { name: "Next.js", defaultUrl: "http://localhost:3000", features };
|
|
4371
|
+
}
|
|
4372
|
+
if ("vite" in allDeps) {
|
|
4373
|
+
return { name: "Vite", defaultUrl: "http://localhost:5173", features };
|
|
4374
|
+
}
|
|
4375
|
+
if (depNames.some((d) => d.startsWith("@remix-run"))) {
|
|
4376
|
+
return { name: "Remix", defaultUrl: "http://localhost:3000", features };
|
|
4377
|
+
}
|
|
4378
|
+
if ("nuxt" in allDeps) {
|
|
4379
|
+
return { name: "Nuxt", defaultUrl: "http://localhost:3000", features };
|
|
4380
|
+
}
|
|
4381
|
+
if (depNames.some((d) => d.startsWith("svelte") || d === "@sveltejs/kit")) {
|
|
4382
|
+
return { name: "SvelteKit", defaultUrl: "http://localhost:5173", features };
|
|
4383
|
+
}
|
|
4384
|
+
if (depNames.some((d) => d.startsWith("@angular"))) {
|
|
4385
|
+
return { name: "Angular", defaultUrl: "http://localhost:4200", features };
|
|
4386
|
+
}
|
|
4387
|
+
if ("express" in allDeps) {
|
|
4388
|
+
return { name: "Express", defaultUrl: "http://localhost:3000", features };
|
|
4389
|
+
}
|
|
4390
|
+
return null;
|
|
4391
|
+
}
|
|
4392
|
+
function getStarterScenarios(framework, projectId) {
|
|
4393
|
+
const scenarios = [
|
|
4394
|
+
{
|
|
4395
|
+
name: "Landing page loads",
|
|
4396
|
+
description: "Navigate to the landing page and verify it loads correctly with no console errors. Check that the main heading, navigation, and primary CTA are visible.",
|
|
4397
|
+
tags: ["smoke"],
|
|
4398
|
+
priority: "high",
|
|
4399
|
+
projectId
|
|
4400
|
+
},
|
|
4401
|
+
{
|
|
4402
|
+
name: "Navigation works",
|
|
4403
|
+
description: "Click through main navigation links and verify each page loads without errors.",
|
|
4404
|
+
tags: ["smoke"],
|
|
4405
|
+
priority: "medium",
|
|
4406
|
+
projectId
|
|
4407
|
+
},
|
|
4408
|
+
{
|
|
4409
|
+
name: "No console errors",
|
|
4410
|
+
description: "Navigate through the main pages and check the browser console for any JavaScript errors or warnings.",
|
|
4411
|
+
tags: ["smoke"],
|
|
4412
|
+
priority: "high",
|
|
4413
|
+
projectId
|
|
4414
|
+
}
|
|
4415
|
+
];
|
|
4416
|
+
if (framework.features.includes("hasAuth")) {
|
|
4417
|
+
scenarios.push({
|
|
4418
|
+
name: "Login flow",
|
|
4419
|
+
description: "Navigate to the login page, enter valid credentials, and verify successful authentication and redirect.",
|
|
4420
|
+
tags: ["auth"],
|
|
4421
|
+
priority: "critical",
|
|
4422
|
+
projectId
|
|
4423
|
+
}, {
|
|
4424
|
+
name: "Signup flow",
|
|
4425
|
+
description: "Navigate to the signup page, fill in registration details, and verify account creation succeeds.",
|
|
4426
|
+
tags: ["auth"],
|
|
4427
|
+
priority: "medium",
|
|
4428
|
+
projectId
|
|
4429
|
+
});
|
|
4430
|
+
}
|
|
4431
|
+
if (framework.features.includes("hasForms")) {
|
|
4432
|
+
scenarios.push({
|
|
4433
|
+
name: "Form validation",
|
|
4434
|
+
description: "Submit forms with empty/invalid data and verify validation errors appear correctly.",
|
|
4435
|
+
tags: ["forms"],
|
|
4436
|
+
priority: "medium",
|
|
4437
|
+
projectId
|
|
4438
|
+
});
|
|
4439
|
+
}
|
|
4440
|
+
return scenarios;
|
|
4441
|
+
}
|
|
4442
|
+
function initProject(options) {
|
|
4443
|
+
const dir = options.dir ?? process.cwd();
|
|
4444
|
+
const name = options.name ?? basename(dir);
|
|
4445
|
+
const framework = detectFramework(dir);
|
|
4446
|
+
const url = options.url ?? framework?.defaultUrl ?? "http://localhost:3000";
|
|
4447
|
+
const projectPath = options.path ?? dir;
|
|
4448
|
+
const project = ensureProject(name, projectPath);
|
|
4449
|
+
const starterInputs = getStarterScenarios(framework ?? { name: "Unknown", features: [] }, project.id);
|
|
4450
|
+
const scenarios = starterInputs.map((input) => createScenario(input));
|
|
4451
|
+
const configDir = join5(homedir5(), ".testers");
|
|
4452
|
+
const configPath = join5(configDir, "config.json");
|
|
4453
|
+
if (!existsSync5(configDir)) {
|
|
4454
|
+
mkdirSync3(configDir, { recursive: true });
|
|
4455
|
+
}
|
|
4456
|
+
let config = {};
|
|
4457
|
+
if (existsSync5(configPath)) {
|
|
4458
|
+
try {
|
|
4459
|
+
config = JSON.parse(readFileSync2(configPath, "utf-8"));
|
|
4460
|
+
} catch {}
|
|
4461
|
+
}
|
|
4462
|
+
config.activeProject = project.id;
|
|
4463
|
+
writeFileSync2(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
4464
|
+
return { project, scenarios, framework, url };
|
|
4465
|
+
}
|
|
4466
|
+
|
|
4467
|
+
// src/lib/smoke.ts
|
|
4468
|
+
init_runs();
|
|
4469
|
+
var SMOKE_DESCRIPTION = `You are performing an autonomous smoke test of this web application. Your job is to explore as many pages as possible and find issues. Follow these instructions:
|
|
4470
|
+
|
|
4471
|
+
1. Start at the given URL and take a screenshot
|
|
4472
|
+
2. Find all visible navigation links and click through each one
|
|
4473
|
+
3. On each page: check for visible error messages, broken layouts, missing images
|
|
4474
|
+
4. Use get_page_html to check for error indicators in the HTML
|
|
4475
|
+
5. Try clicking the main interactive elements (buttons, links, forms)
|
|
4476
|
+
6. Keep track of every page you visit
|
|
4477
|
+
7. After exploring at least 5 different pages (or all available pages), report your findings
|
|
4478
|
+
|
|
4479
|
+
In your report_result, include:
|
|
4480
|
+
- Total pages visited
|
|
4481
|
+
- Any JavaScript errors you noticed
|
|
4482
|
+
- Any broken links (pages that show 404 or error)
|
|
4483
|
+
- Any visual issues (broken layouts, missing images, overlapping text)
|
|
4484
|
+
- Any forms that don't work
|
|
4485
|
+
- Rate each issue as critical/high/medium/low severity`;
|
|
4486
|
+
async function runSmoke(options) {
|
|
4487
|
+
const config = loadConfig();
|
|
4488
|
+
const model = resolveModel(options.model ?? config.defaultModel);
|
|
4489
|
+
const scenario = createScenario({
|
|
4490
|
+
name: "Smoke Test",
|
|
4491
|
+
description: SMOKE_DESCRIPTION,
|
|
4492
|
+
tags: ["smoke", "auto"],
|
|
4493
|
+
priority: "high",
|
|
4494
|
+
projectId: options.projectId
|
|
4495
|
+
});
|
|
4496
|
+
const run = createRun({
|
|
4497
|
+
url: options.url,
|
|
4498
|
+
model,
|
|
4499
|
+
headed: options.headed,
|
|
4500
|
+
parallel: 1,
|
|
4501
|
+
projectId: options.projectId
|
|
4502
|
+
});
|
|
4503
|
+
updateRun(run.id, { status: "running", total: 1 });
|
|
4504
|
+
let result;
|
|
4505
|
+
try {
|
|
4506
|
+
result = await runSingleScenario(scenario, run.id, {
|
|
4507
|
+
url: options.url,
|
|
4508
|
+
model: options.model,
|
|
4509
|
+
headed: options.headed,
|
|
4510
|
+
timeout: options.timeout,
|
|
4511
|
+
projectId: options.projectId,
|
|
4512
|
+
apiKey: options.apiKey
|
|
4513
|
+
});
|
|
4514
|
+
const finalStatus = result.status === "passed" ? "passed" : "failed";
|
|
4515
|
+
updateRun(run.id, {
|
|
4516
|
+
status: finalStatus,
|
|
4517
|
+
passed: result.status === "passed" ? 1 : 0,
|
|
4518
|
+
failed: result.status === "passed" ? 0 : 1,
|
|
4519
|
+
total: 1,
|
|
4520
|
+
finished_at: new Date().toISOString()
|
|
4521
|
+
});
|
|
4522
|
+
} catch (error) {
|
|
4523
|
+
updateRun(run.id, {
|
|
4524
|
+
status: "failed",
|
|
4525
|
+
failed: 1,
|
|
4526
|
+
total: 1,
|
|
4527
|
+
finished_at: new Date().toISOString()
|
|
4528
|
+
});
|
|
4529
|
+
throw error;
|
|
4530
|
+
} finally {
|
|
4531
|
+
deleteScenario(scenario.id);
|
|
4532
|
+
}
|
|
4533
|
+
const issues = parseSmokeIssues(result.reasoning ?? "");
|
|
4534
|
+
const pagesVisited = extractPagesVisited(result.reasoning ?? "");
|
|
4535
|
+
const { getRun: getRun2 } = await Promise.resolve().then(() => (init_runs(), exports_runs));
|
|
4536
|
+
const finalRun = getRun2(run.id);
|
|
4537
|
+
return {
|
|
4538
|
+
run: finalRun,
|
|
4539
|
+
result,
|
|
4540
|
+
pagesVisited,
|
|
4541
|
+
issuesFound: issues
|
|
4542
|
+
};
|
|
4543
|
+
}
|
|
4544
|
+
var SEVERITY_PATTERN = /\b(CRITICAL|HIGH|MEDIUM|LOW)\b[:\s-]*(.+)/gi;
|
|
4545
|
+
var PAGES_VISITED_PATTERN = /(\d+)\s*(?:pages?\s*visited|pages?\s*explored|pages?\s*checked|total\s*pages?)/i;
|
|
4546
|
+
var URL_PATTERN = /https?:\/\/[^\s,)]+/g;
|
|
4547
|
+
var ISSUE_TYPE_MAP = {
|
|
4548
|
+
javascript: "js-error",
|
|
4549
|
+
"js error": "js-error",
|
|
4550
|
+
"js-error": "js-error",
|
|
4551
|
+
"console error": "js-error",
|
|
4552
|
+
"404": "404",
|
|
4553
|
+
"not found": "404",
|
|
4554
|
+
"broken link": "broken-link",
|
|
4555
|
+
"dead link": "broken-link",
|
|
4556
|
+
"broken image": "broken-image",
|
|
4557
|
+
"missing image": "broken-image",
|
|
4558
|
+
visual: "visual",
|
|
4559
|
+
layout: "visual",
|
|
4560
|
+
overlap: "visual",
|
|
4561
|
+
"broken layout": "visual",
|
|
4562
|
+
performance: "performance",
|
|
4563
|
+
slow: "performance"
|
|
4564
|
+
};
|
|
4565
|
+
function inferIssueType(text) {
|
|
4566
|
+
const lower = text.toLowerCase();
|
|
4567
|
+
for (const [keyword, type] of Object.entries(ISSUE_TYPE_MAP)) {
|
|
4568
|
+
if (lower.includes(keyword))
|
|
4569
|
+
return type;
|
|
4570
|
+
}
|
|
4571
|
+
return "visual";
|
|
4572
|
+
}
|
|
4573
|
+
function extractUrl(text, fallback = "") {
|
|
4574
|
+
const match = text.match(URL_PATTERN);
|
|
4575
|
+
return match ? match[0] : fallback;
|
|
4576
|
+
}
|
|
4577
|
+
function parseSmokeIssues(reasoning) {
|
|
4578
|
+
const issues = [];
|
|
4579
|
+
const seen = new Set;
|
|
4580
|
+
let match;
|
|
4581
|
+
const severityRegex = new RegExp(SEVERITY_PATTERN.source, "gi");
|
|
4582
|
+
while ((match = severityRegex.exec(reasoning)) !== null) {
|
|
4583
|
+
const severity = match[1].toLowerCase();
|
|
4584
|
+
const description = match[2].trim();
|
|
4585
|
+
const key = `${severity}:${description.slice(0, 80)}`;
|
|
4586
|
+
if (seen.has(key))
|
|
4587
|
+
continue;
|
|
4588
|
+
seen.add(key);
|
|
4589
|
+
issues.push({
|
|
4590
|
+
type: inferIssueType(description),
|
|
4591
|
+
severity,
|
|
4592
|
+
description,
|
|
4593
|
+
url: extractUrl(description)
|
|
4594
|
+
});
|
|
4595
|
+
}
|
|
4596
|
+
const bulletLines = reasoning.split(`
|
|
4597
|
+
`).filter((line) => /^\s*[-*]\s/.test(line) && /\b(error|broken|missing|404|fail|issue|bug|problem)\b/i.test(line));
|
|
4598
|
+
for (const line of bulletLines) {
|
|
4599
|
+
const cleaned = line.replace(/^\s*[-*]\s*/, "").trim();
|
|
4600
|
+
const key = `bullet:${cleaned.slice(0, 80)}`;
|
|
4601
|
+
if (seen.has(key))
|
|
4602
|
+
continue;
|
|
4603
|
+
seen.add(key);
|
|
4604
|
+
let severity = "medium";
|
|
4605
|
+
if (/\bcritical\b/i.test(cleaned))
|
|
4606
|
+
severity = "critical";
|
|
4607
|
+
else if (/\bhigh\b/i.test(cleaned))
|
|
4608
|
+
severity = "high";
|
|
4609
|
+
else if (/\blow\b/i.test(cleaned))
|
|
4610
|
+
severity = "low";
|
|
4611
|
+
else if (/\b(error|fail|broken|crash)\b/i.test(cleaned))
|
|
4612
|
+
severity = "high";
|
|
4613
|
+
issues.push({
|
|
4614
|
+
type: inferIssueType(cleaned),
|
|
4615
|
+
severity,
|
|
4616
|
+
description: cleaned,
|
|
4617
|
+
url: extractUrl(cleaned)
|
|
4618
|
+
});
|
|
4619
|
+
}
|
|
4620
|
+
return issues;
|
|
4621
|
+
}
|
|
4622
|
+
function extractPagesVisited(reasoning) {
|
|
4623
|
+
const match = reasoning.match(PAGES_VISITED_PATTERN);
|
|
4624
|
+
if (match)
|
|
4625
|
+
return parseInt(match[1], 10);
|
|
4626
|
+
const urls = reasoning.match(URL_PATTERN);
|
|
4627
|
+
if (urls) {
|
|
4628
|
+
const unique = new Set(urls.map((u) => new URL(u).pathname));
|
|
4629
|
+
return unique.size;
|
|
4630
|
+
}
|
|
4631
|
+
return 0;
|
|
4632
|
+
}
|
|
4633
|
+
var SEVERITY_COLORS = {
|
|
4634
|
+
critical: (t) => `\x1B[41m\x1B[37m ${t} \x1B[0m`,
|
|
4635
|
+
high: (t) => `\x1B[31m${t}\x1B[0m`,
|
|
4636
|
+
medium: (t) => `\x1B[33m${t}\x1B[0m`,
|
|
4637
|
+
low: (t) => `\x1B[36m${t}\x1B[0m`
|
|
4638
|
+
};
|
|
4639
|
+
var SEVERITY_ORDER = ["critical", "high", "medium", "low"];
|
|
4640
|
+
function formatSmokeReport(result) {
|
|
4641
|
+
const lines = [];
|
|
4642
|
+
const url = result.run.url;
|
|
4643
|
+
lines.push("");
|
|
4644
|
+
lines.push(`\x1B[1m Smoke Test Report \x1B[2m- ${url}\x1B[0m`);
|
|
4645
|
+
lines.push(` ${"\u2500".repeat(60)}`);
|
|
4646
|
+
const issueCount = result.issuesFound.length;
|
|
4647
|
+
const criticalCount = result.issuesFound.filter((i) => i.severity === "critical").length;
|
|
4648
|
+
const highCount = result.issuesFound.filter((i) => i.severity === "high").length;
|
|
4649
|
+
lines.push("");
|
|
4650
|
+
lines.push(` Pages visited: \x1B[1m${result.pagesVisited}\x1B[0m`);
|
|
4651
|
+
lines.push(` Issues found: \x1B[1m${issueCount}\x1B[0m`);
|
|
4652
|
+
lines.push(` Duration: ${result.result.durationMs ? `${(result.result.durationMs / 1000).toFixed(1)}s` : "N/A"}`);
|
|
4653
|
+
lines.push(` Model: ${result.run.model}`);
|
|
4654
|
+
lines.push(` Tokens used: ${result.result.tokensUsed}`);
|
|
4655
|
+
if (issueCount > 0) {
|
|
4656
|
+
lines.push("");
|
|
4657
|
+
lines.push(`\x1B[1m Issues\x1B[0m`);
|
|
4658
|
+
lines.push("");
|
|
4659
|
+
for (const severity of SEVERITY_ORDER) {
|
|
4660
|
+
const group = result.issuesFound.filter((i) => i.severity === severity);
|
|
4661
|
+
if (group.length === 0)
|
|
4662
|
+
continue;
|
|
4663
|
+
const badge = SEVERITY_COLORS[severity](severity.toUpperCase());
|
|
4664
|
+
lines.push(` ${badge}`);
|
|
4665
|
+
for (const issue of group) {
|
|
4666
|
+
const urlSuffix = issue.url ? ` \x1B[2m(${issue.url})\x1B[0m` : "";
|
|
4667
|
+
lines.push(` - ${issue.description}${urlSuffix}`);
|
|
4668
|
+
}
|
|
4669
|
+
lines.push("");
|
|
4670
|
+
}
|
|
4671
|
+
}
|
|
4672
|
+
lines.push(` ${"\u2500".repeat(60)}`);
|
|
4673
|
+
const hasCritical = criticalCount > 0 || highCount > 0;
|
|
4674
|
+
if (hasCritical) {
|
|
4675
|
+
lines.push(` Verdict: \x1B[31m\x1B[1mFAIL\x1B[0m \x1B[2m(${criticalCount} critical, ${highCount} high severity issues)\x1B[0m`);
|
|
4676
|
+
} else if (issueCount > 0) {
|
|
4677
|
+
lines.push(` Verdict: \x1B[33m\x1B[1mWARN\x1B[0m \x1B[2m(${issueCount} issues found, none critical/high)\x1B[0m`);
|
|
4678
|
+
} else {
|
|
4679
|
+
lines.push(` Verdict: \x1B[32m\x1B[1mPASS\x1B[0m \x1B[2m(no issues found)\x1B[0m`);
|
|
4680
|
+
}
|
|
4681
|
+
lines.push("");
|
|
4682
|
+
return lines.join(`
|
|
4683
|
+
`);
|
|
4684
|
+
}
|
|
4685
|
+
|
|
4686
|
+
// src/lib/diff.ts
|
|
4687
|
+
init_runs();
|
|
4688
|
+
import chalk2 from "chalk";
|
|
4689
|
+
function diffRuns(runId1, runId2) {
|
|
4690
|
+
const run1 = getRun(runId1);
|
|
4691
|
+
if (!run1) {
|
|
4692
|
+
throw new Error(`Run not found: ${runId1}`);
|
|
4693
|
+
}
|
|
4694
|
+
const run2 = getRun(runId2);
|
|
4695
|
+
if (!run2) {
|
|
4696
|
+
throw new Error(`Run not found: ${runId2}`);
|
|
4697
|
+
}
|
|
4698
|
+
const results1 = getResultsByRun(run1.id);
|
|
4699
|
+
const results2 = getResultsByRun(run2.id);
|
|
4700
|
+
const map1 = new Map;
|
|
4701
|
+
for (const r of results1) {
|
|
4702
|
+
map1.set(r.scenarioId, r);
|
|
4703
|
+
}
|
|
4704
|
+
const map2 = new Map;
|
|
4705
|
+
for (const r of results2) {
|
|
4706
|
+
map2.set(r.scenarioId, r);
|
|
4707
|
+
}
|
|
4708
|
+
const allScenarioIds = new Set([...map1.keys(), ...map2.keys()]);
|
|
4709
|
+
const regressions = [];
|
|
4710
|
+
const fixes = [];
|
|
4711
|
+
const unchanged = [];
|
|
4712
|
+
const newScenarios = [];
|
|
4713
|
+
const removedScenarios = [];
|
|
4714
|
+
for (const scenarioId of allScenarioIds) {
|
|
4715
|
+
const r1 = map1.get(scenarioId) ?? null;
|
|
4716
|
+
const r2 = map2.get(scenarioId) ?? null;
|
|
4717
|
+
const scenario = getScenario(scenarioId);
|
|
4718
|
+
const diff = {
|
|
4719
|
+
scenarioId,
|
|
4720
|
+
scenarioName: scenario?.name ?? null,
|
|
4721
|
+
scenarioShortId: scenario?.shortId ?? null,
|
|
4722
|
+
status1: r1?.status ?? null,
|
|
4723
|
+
status2: r2?.status ?? null,
|
|
4724
|
+
duration1: r1?.durationMs ?? null,
|
|
4725
|
+
duration2: r2?.durationMs ?? null,
|
|
4726
|
+
tokens1: r1?.tokensUsed ?? null,
|
|
4727
|
+
tokens2: r2?.tokensUsed ?? null
|
|
4728
|
+
};
|
|
4729
|
+
if (!r1 && r2) {
|
|
4730
|
+
newScenarios.push(diff);
|
|
4731
|
+
} else if (r1 && !r2) {
|
|
4732
|
+
removedScenarios.push(diff);
|
|
4733
|
+
} else if (r1 && r2) {
|
|
4734
|
+
const wasPass = r1.status === "passed";
|
|
4735
|
+
const nowPass = r2.status === "passed";
|
|
4736
|
+
const wasFail = r1.status === "failed" || r1.status === "error";
|
|
4737
|
+
const nowFail = r2.status === "failed" || r2.status === "error";
|
|
4738
|
+
if (wasPass && nowFail) {
|
|
4739
|
+
regressions.push(diff);
|
|
4740
|
+
} else if (wasFail && nowPass) {
|
|
4741
|
+
fixes.push(diff);
|
|
4742
|
+
} else {
|
|
4743
|
+
unchanged.push(diff);
|
|
4744
|
+
}
|
|
4745
|
+
}
|
|
4746
|
+
}
|
|
4747
|
+
return { run1, run2, regressions, fixes, unchanged, newScenarios, removedScenarios };
|
|
4748
|
+
}
|
|
4749
|
+
function formatScenarioLabel(diff) {
|
|
4750
|
+
if (diff.scenarioShortId && diff.scenarioName) {
|
|
4751
|
+
return `${diff.scenarioShortId}: ${diff.scenarioName}`;
|
|
4752
|
+
}
|
|
4753
|
+
if (diff.scenarioName) {
|
|
4754
|
+
return diff.scenarioName;
|
|
4755
|
+
}
|
|
4756
|
+
return diff.scenarioId.slice(0, 8);
|
|
4757
|
+
}
|
|
4758
|
+
function formatDuration(ms) {
|
|
4759
|
+
if (ms === null)
|
|
4760
|
+
return "-";
|
|
4761
|
+
if (ms < 1000)
|
|
4762
|
+
return `${ms}ms`;
|
|
4763
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
4764
|
+
}
|
|
4765
|
+
function formatDurationComparison(d1, d2) {
|
|
4766
|
+
const s1 = formatDuration(d1);
|
|
4767
|
+
const s2 = formatDuration(d2);
|
|
4768
|
+
if (d1 !== null && d2 !== null) {
|
|
4769
|
+
const delta = d2 - d1;
|
|
4770
|
+
const sign = delta > 0 ? "+" : "";
|
|
4771
|
+
return `${s1} -> ${s2} (${sign}${formatDuration(delta)})`;
|
|
4772
|
+
}
|
|
4773
|
+
return `${s1} -> ${s2}`;
|
|
4774
|
+
}
|
|
4775
|
+
function formatDiffTerminal(diff) {
|
|
4776
|
+
const lines = [];
|
|
4777
|
+
lines.push("");
|
|
4778
|
+
lines.push(chalk2.bold(" Run Comparison"));
|
|
4779
|
+
lines.push(` Run 1: ${chalk2.dim(diff.run1.id.slice(0, 8))} (${diff.run1.status}) \u2014 ${diff.run1.startedAt}`);
|
|
4780
|
+
lines.push(` Run 2: ${chalk2.dim(diff.run2.id.slice(0, 8))} (${diff.run2.status}) \u2014 ${diff.run2.startedAt}`);
|
|
4781
|
+
lines.push("");
|
|
4782
|
+
if (diff.regressions.length > 0) {
|
|
4783
|
+
lines.push(chalk2.red.bold(` Regressions (${diff.regressions.length}):`));
|
|
4784
|
+
for (const d of diff.regressions) {
|
|
4785
|
+
const label = formatScenarioLabel(d);
|
|
4786
|
+
const dur = formatDurationComparison(d.duration1, d.duration2);
|
|
4787
|
+
lines.push(chalk2.red(` \u2B07 ${label} ${d.status1} -> ${d.status2} ${chalk2.dim(dur)}`));
|
|
4788
|
+
}
|
|
4789
|
+
lines.push("");
|
|
4790
|
+
}
|
|
4791
|
+
if (diff.fixes.length > 0) {
|
|
4792
|
+
lines.push(chalk2.green.bold(` Fixes (${diff.fixes.length}):`));
|
|
4793
|
+
for (const d of diff.fixes) {
|
|
4794
|
+
const label = formatScenarioLabel(d);
|
|
4795
|
+
const dur = formatDurationComparison(d.duration1, d.duration2);
|
|
4796
|
+
lines.push(chalk2.green(` \u2B06 ${label} ${d.status1} -> ${d.status2} ${chalk2.dim(dur)}`));
|
|
4797
|
+
}
|
|
4798
|
+
lines.push("");
|
|
4799
|
+
}
|
|
4800
|
+
if (diff.unchanged.length > 0) {
|
|
4801
|
+
lines.push(chalk2.dim(` Unchanged (${diff.unchanged.length}):`));
|
|
4802
|
+
for (const d of diff.unchanged) {
|
|
4803
|
+
const label = formatScenarioLabel(d);
|
|
4804
|
+
const dur = formatDurationComparison(d.duration1, d.duration2);
|
|
4805
|
+
lines.push(chalk2.dim(` = ${label} ${d.status2} ${dur}`));
|
|
4806
|
+
}
|
|
4807
|
+
lines.push("");
|
|
4808
|
+
}
|
|
4809
|
+
if (diff.newScenarios.length > 0) {
|
|
4810
|
+
lines.push(chalk2.cyan(` New in run 2 (${diff.newScenarios.length}):`));
|
|
4811
|
+
for (const d of diff.newScenarios) {
|
|
4812
|
+
const label = formatScenarioLabel(d);
|
|
4813
|
+
lines.push(chalk2.cyan(` + ${label} ${d.status2}`));
|
|
4814
|
+
}
|
|
4815
|
+
lines.push("");
|
|
4816
|
+
}
|
|
4817
|
+
if (diff.removedScenarios.length > 0) {
|
|
4818
|
+
lines.push(chalk2.yellow(` Removed from run 2 (${diff.removedScenarios.length}):`));
|
|
4819
|
+
for (const d of diff.removedScenarios) {
|
|
4820
|
+
const label = formatScenarioLabel(d);
|
|
4821
|
+
lines.push(chalk2.yellow(` - ${label} was ${d.status1}`));
|
|
4822
|
+
}
|
|
4823
|
+
lines.push("");
|
|
4824
|
+
}
|
|
4825
|
+
lines.push(chalk2.bold(` Summary: ${diff.regressions.length} regressions, ${diff.fixes.length} fixes, ${diff.unchanged.length} unchanged`));
|
|
4826
|
+
lines.push("");
|
|
4827
|
+
return lines.join(`
|
|
4828
|
+
`);
|
|
4829
|
+
}
|
|
4830
|
+
function formatDiffJSON(diff) {
|
|
4831
|
+
return JSON.stringify(diff, null, 2);
|
|
4832
|
+
}
|
|
4833
|
+
|
|
4834
|
+
// src/lib/report.ts
|
|
4835
|
+
init_runs();
|
|
4836
|
+
import { readFileSync as readFileSync3, existsSync as existsSync6 } from "fs";
|
|
4837
|
+
function imageToBase64(filePath) {
|
|
4838
|
+
if (!filePath || !existsSync6(filePath))
|
|
4839
|
+
return "";
|
|
4840
|
+
try {
|
|
4841
|
+
const buffer = readFileSync3(filePath);
|
|
4842
|
+
const base64 = buffer.toString("base64");
|
|
4843
|
+
return `data:image/png;base64,${base64}`;
|
|
4844
|
+
} catch {
|
|
4845
|
+
return "";
|
|
4846
|
+
}
|
|
4847
|
+
}
|
|
4848
|
+
function escapeHtml(text) {
|
|
4849
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
4850
|
+
}
|
|
4851
|
+
function formatDuration2(ms) {
|
|
4852
|
+
if (ms < 1000)
|
|
4853
|
+
return `${ms}ms`;
|
|
4854
|
+
if (ms < 60000)
|
|
4855
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
4856
|
+
const mins = Math.floor(ms / 60000);
|
|
4857
|
+
const secs = (ms % 60000 / 1000).toFixed(0);
|
|
4858
|
+
return `${mins}m ${secs}s`;
|
|
4859
|
+
}
|
|
4860
|
+
function formatCost(cents) {
|
|
4861
|
+
if (cents < 1)
|
|
4862
|
+
return `$${(cents / 100).toFixed(4)}`;
|
|
4863
|
+
return `$${(cents / 100).toFixed(2)}`;
|
|
4864
|
+
}
|
|
4865
|
+
function statusBadge(status) {
|
|
4866
|
+
const colors = {
|
|
4867
|
+
passed: { bg: "#22c55e", text: "#000" },
|
|
4868
|
+
failed: { bg: "#ef4444", text: "#fff" },
|
|
4869
|
+
error: { bg: "#eab308", text: "#000" },
|
|
4870
|
+
skipped: { bg: "#6b7280", text: "#fff" }
|
|
4871
|
+
};
|
|
4872
|
+
const c = colors[status] ?? { bg: "#6b7280", text: "#fff" };
|
|
4873
|
+
const label = status.toUpperCase();
|
|
4874
|
+
return `<span style="display:inline-block;padding:2px 10px;border-radius:4px;font-size:12px;font-weight:700;background:${c.bg};color:${c.text};letter-spacing:0.5px;">${label}</span>`;
|
|
4875
|
+
}
|
|
4876
|
+
function renderScreenshots(screenshots) {
|
|
4877
|
+
if (screenshots.length === 0)
|
|
4878
|
+
return "";
|
|
4879
|
+
let html = `<div style="display:flex;flex-wrap:wrap;gap:12px;margin-top:12px;">`;
|
|
4880
|
+
for (let i = 0;i < screenshots.length; i++) {
|
|
4881
|
+
const ss = screenshots[i];
|
|
4882
|
+
const dataUri = imageToBase64(ss.filePath);
|
|
4883
|
+
const checkId = `ss-${ss.id}`;
|
|
4884
|
+
if (dataUri) {
|
|
4885
|
+
html += `
|
|
4886
|
+
<div style="flex:0 0 auto;">
|
|
4887
|
+
<input type="checkbox" id="${checkId}" style="display:none;" />
|
|
4888
|
+
<label for="${checkId}" style="cursor:pointer;">
|
|
4889
|
+
<img src="${dataUri}" alt="Step ${ss.stepNumber}: ${escapeHtml(ss.action)}"
|
|
4890
|
+
style="max-width:200px;max-height:150px;border-radius:6px;border:1px solid #262626;display:block;" />
|
|
4891
|
+
</label>
|
|
4892
|
+
<div style="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:1000;display:none;align-items:center;justify-content:center;">
|
|
4893
|
+
<label for="${checkId}" style="position:absolute;top:0;left:0;width:100%;height:100%;cursor:pointer;"></label>
|
|
4894
|
+
<img src="${dataUri}" alt="Step ${ss.stepNumber}: ${escapeHtml(ss.action)}"
|
|
4895
|
+
style="max-width:600px;max-height:90vh;border-radius:8px;position:relative;z-index:1001;" />
|
|
4896
|
+
</div>
|
|
4897
|
+
<div style="font-size:11px;color:#888;margin-top:4px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
|
4898
|
+
${ss.stepNumber}. ${escapeHtml(ss.action)}
|
|
4899
|
+
</div>
|
|
4900
|
+
</div>`;
|
|
4901
|
+
} else {
|
|
4902
|
+
html += `
|
|
4903
|
+
<div style="flex:0 0 auto;width:200px;height:150px;background:#1a1a1a;border:1px dashed #333;border-radius:6px;display:flex;align-items:center;justify-content:center;color:#555;font-size:12px;">
|
|
4904
|
+
Screenshot not found
|
|
4905
|
+
<div style="font-size:11px;color:#888;margin-top:4px;">${ss.stepNumber}. ${escapeHtml(ss.action)}</div>
|
|
4906
|
+
</div>`;
|
|
4907
|
+
}
|
|
4065
4908
|
}
|
|
4066
|
-
|
|
4909
|
+
html += `</div>`;
|
|
4910
|
+
return html;
|
|
4067
4911
|
}
|
|
4912
|
+
function generateHtmlReport(runId) {
|
|
4913
|
+
const run = getRun(runId);
|
|
4914
|
+
if (!run)
|
|
4915
|
+
throw new Error(`Run not found: ${runId}`);
|
|
4916
|
+
const results = getResultsByRun(run.id);
|
|
4917
|
+
const resultData = [];
|
|
4918
|
+
for (const result of results) {
|
|
4919
|
+
const screenshots = listScreenshots(result.id);
|
|
4920
|
+
const scenario = getScenario(result.scenarioId);
|
|
4921
|
+
resultData.push({
|
|
4922
|
+
result,
|
|
4923
|
+
scenarioName: scenario?.name ?? "Unknown Scenario",
|
|
4924
|
+
scenarioShortId: scenario?.shortId ?? result.scenarioId.slice(0, 8),
|
|
4925
|
+
screenshots
|
|
4926
|
+
});
|
|
4927
|
+
}
|
|
4928
|
+
const passedCount = results.filter((r) => r.status === "passed").length;
|
|
4929
|
+
const failedCount = results.filter((r) => r.status === "failed").length;
|
|
4930
|
+
const errorCount = results.filter((r) => r.status === "error").length;
|
|
4931
|
+
const totalCount = results.length;
|
|
4932
|
+
const totalTokens = results.reduce((sum, r) => sum + r.tokensUsed, 0);
|
|
4933
|
+
const totalCostCents = results.reduce((sum, r) => sum + r.costCents, 0);
|
|
4934
|
+
const totalDurationMs = run.finishedAt && run.startedAt ? new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime() : results.reduce((sum, r) => sum + r.durationMs, 0);
|
|
4935
|
+
const generatedAt = new Date().toISOString();
|
|
4936
|
+
let resultCards = "";
|
|
4937
|
+
for (const { result, scenarioName, scenarioShortId, screenshots } of resultData) {
|
|
4938
|
+
resultCards += `
|
|
4939
|
+
<div style="background:#141414;border:1px solid #262626;border-radius:8px;padding:20px;margin-bottom:16px;">
|
|
4940
|
+
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
|
|
4941
|
+
${statusBadge(result.status)}
|
|
4942
|
+
<span style="font-size:16px;font-weight:600;color:#e5e5e5;">${escapeHtml(scenarioName)}</span>
|
|
4943
|
+
<span style="font-size:12px;color:#666;font-family:monospace;">${escapeHtml(scenarioShortId)}</span>
|
|
4944
|
+
</div>
|
|
4068
4945
|
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4946
|
+
${result.reasoning ? `<div style="color:#a3a3a3;font-size:14px;line-height:1.6;margin-bottom:12px;padding:12px;background:#0d0d0d;border-radius:6px;border-left:3px solid #333;">${escapeHtml(result.reasoning)}</div>` : ""}
|
|
4947
|
+
|
|
4948
|
+
${result.error ? `<div style="color:#ef4444;font-size:13px;margin-bottom:12px;padding:12px;background:#1a0a0a;border-radius:6px;border-left:3px solid #ef4444;font-family:monospace;">${escapeHtml(result.error)}</div>` : ""}
|
|
4949
|
+
|
|
4950
|
+
<div style="display:flex;gap:24px;font-size:13px;color:#888;">
|
|
4951
|
+
<span>Duration: <span style="color:#d4d4d4;">${formatDuration2(result.durationMs)}</span></span>
|
|
4952
|
+
<span>Steps: <span style="color:#d4d4d4;">${result.stepsCompleted}/${result.stepsTotal}</span></span>
|
|
4953
|
+
<span>Tokens: <span style="color:#d4d4d4;">${result.tokensUsed.toLocaleString()}</span></span>
|
|
4954
|
+
<span>Cost: <span style="color:#d4d4d4;">${formatCost(result.costCents)}</span></span>
|
|
4955
|
+
<span>Model: <span style="color:#d4d4d4;">${escapeHtml(result.model)}</span></span>
|
|
4956
|
+
</div>
|
|
4957
|
+
|
|
4958
|
+
${renderScreenshots(screenshots)}
|
|
4959
|
+
</div>`;
|
|
4960
|
+
}
|
|
4961
|
+
return `<!DOCTYPE html>
|
|
4962
|
+
<html lang="en">
|
|
4963
|
+
<head>
|
|
4964
|
+
<meta charset="UTF-8" />
|
|
4965
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
4966
|
+
<title>Test Report - ${escapeHtml(run.id.slice(0, 8))}</title>
|
|
4967
|
+
<style>
|
|
4968
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
4969
|
+
body { background: #0a0a0a; color: #e5e5e5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 40px 20px; }
|
|
4970
|
+
.container { max-width: 960px; margin: 0 auto; }
|
|
4971
|
+
input[type="checkbox"]:checked ~ div:last-of-type { display: flex !important; }
|
|
4972
|
+
</style>
|
|
4973
|
+
</head>
|
|
4974
|
+
<body>
|
|
4975
|
+
<div class="container">
|
|
4976
|
+
<!-- Header -->
|
|
4977
|
+
<div style="margin-bottom:32px;">
|
|
4978
|
+
<h1 style="font-size:28px;font-weight:700;margin-bottom:8px;color:#fff;">Test Report</h1>
|
|
4979
|
+
<div style="display:flex;flex-wrap:wrap;gap:24px;font-size:14px;color:#888;">
|
|
4980
|
+
<span>Run: <span style="color:#d4d4d4;font-family:monospace;">${escapeHtml(run.id.slice(0, 8))}</span></span>
|
|
4981
|
+
<span>URL: <a href="${escapeHtml(run.url)}" style="color:#60a5fa;text-decoration:none;">${escapeHtml(run.url)}</a></span>
|
|
4982
|
+
<span>Model: <span style="color:#d4d4d4;">${escapeHtml(run.model)}</span></span>
|
|
4983
|
+
<span>Date: <span style="color:#d4d4d4;">${escapeHtml(run.startedAt)}</span></span>
|
|
4984
|
+
<span>Duration: <span style="color:#d4d4d4;">${formatDuration2(totalDurationMs)}</span></span>
|
|
4985
|
+
<span>Status: ${statusBadge(run.status)}</span>
|
|
4986
|
+
</div>
|
|
4987
|
+
</div>
|
|
4988
|
+
|
|
4989
|
+
<!-- Summary Bar -->
|
|
4990
|
+
<div style="display:flex;gap:16px;margin-bottom:32px;">
|
|
4991
|
+
<div style="flex:1;background:#141414;border:1px solid #262626;border-radius:8px;padding:16px;text-align:center;">
|
|
4992
|
+
<div style="font-size:28px;font-weight:700;color:#e5e5e5;">${totalCount}</div>
|
|
4993
|
+
<div style="font-size:12px;color:#888;margin-top:4px;">TOTAL</div>
|
|
4994
|
+
</div>
|
|
4995
|
+
<div style="flex:1;background:#141414;border:1px solid #262626;border-radius:8px;padding:16px;text-align:center;">
|
|
4996
|
+
<div style="font-size:28px;font-weight:700;color:#22c55e;">${passedCount}</div>
|
|
4997
|
+
<div style="font-size:12px;color:#888;margin-top:4px;">PASSED</div>
|
|
4998
|
+
</div>
|
|
4999
|
+
<div style="flex:1;background:#141414;border:1px solid #262626;border-radius:8px;padding:16px;text-align:center;">
|
|
5000
|
+
<div style="font-size:28px;font-weight:700;color:#ef4444;">${failedCount}</div>
|
|
5001
|
+
<div style="font-size:12px;color:#888;margin-top:4px;">FAILED</div>
|
|
5002
|
+
</div>
|
|
5003
|
+
${errorCount > 0 ? `
|
|
5004
|
+
<div style="flex:1;background:#141414;border:1px solid #262626;border-radius:8px;padding:16px;text-align:center;">
|
|
5005
|
+
<div style="font-size:28px;font-weight:700;color:#eab308;">${errorCount}</div>
|
|
5006
|
+
<div style="font-size:12px;color:#888;margin-top:4px;">ERRORS</div>
|
|
5007
|
+
</div>` : ""}
|
|
5008
|
+
</div>
|
|
5009
|
+
|
|
5010
|
+
<!-- Results -->
|
|
5011
|
+
${resultCards}
|
|
5012
|
+
|
|
5013
|
+
<!-- Footer -->
|
|
5014
|
+
<div style="margin-top:32px;padding-top:20px;border-top:1px solid #262626;display:flex;justify-content:space-between;font-size:13px;color:#666;">
|
|
5015
|
+
<div>
|
|
5016
|
+
Total tokens: ${totalTokens.toLocaleString()} | Total cost: ${formatCost(totalCostCents)}
|
|
5017
|
+
</div>
|
|
5018
|
+
<div>
|
|
5019
|
+
Generated: ${escapeHtml(generatedAt)}
|
|
5020
|
+
</div>
|
|
5021
|
+
</div>
|
|
5022
|
+
</div>
|
|
5023
|
+
</body>
|
|
5024
|
+
</html>`;
|
|
4079
5025
|
}
|
|
4080
|
-
function
|
|
4081
|
-
const
|
|
4082
|
-
|
|
4083
|
-
|
|
5026
|
+
function generateLatestReport() {
|
|
5027
|
+
const runs = listRuns({ limit: 1 });
|
|
5028
|
+
if (runs.length === 0)
|
|
5029
|
+
throw new Error("No runs found");
|
|
5030
|
+
return generateHtmlReport(runs[0].id);
|
|
4084
5031
|
}
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
5032
|
+
|
|
5033
|
+
// src/lib/costs.ts
|
|
5034
|
+
init_database();
|
|
5035
|
+
import chalk3 from "chalk";
|
|
5036
|
+
function getDateFilter(period) {
|
|
5037
|
+
switch (period) {
|
|
5038
|
+
case "day":
|
|
5039
|
+
return "AND r.created_at >= date('now', 'start of day')";
|
|
5040
|
+
case "week":
|
|
5041
|
+
return "AND r.created_at >= date('now', '-7 days')";
|
|
5042
|
+
case "month":
|
|
5043
|
+
return "AND r.created_at >= date('now', '-30 days')";
|
|
5044
|
+
case "all":
|
|
5045
|
+
return "";
|
|
5046
|
+
}
|
|
4089
5047
|
}
|
|
4090
|
-
function
|
|
5048
|
+
function getPeriodDays(period) {
|
|
5049
|
+
switch (period) {
|
|
5050
|
+
case "day":
|
|
5051
|
+
return 1;
|
|
5052
|
+
case "week":
|
|
5053
|
+
return 7;
|
|
5054
|
+
case "month":
|
|
5055
|
+
return 30;
|
|
5056
|
+
case "all":
|
|
5057
|
+
return 30;
|
|
5058
|
+
}
|
|
5059
|
+
}
|
|
5060
|
+
function getCostSummary(options) {
|
|
4091
5061
|
const db2 = getDatabase();
|
|
4092
|
-
const
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
const
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
5062
|
+
const period = options?.period ?? "month";
|
|
5063
|
+
const projectId = options?.projectId;
|
|
5064
|
+
const dateFilter = getDateFilter(period);
|
|
5065
|
+
const projectFilter = projectId ? "AND ru.project_id = ?" : "";
|
|
5066
|
+
const projectParams = projectId ? [projectId] : [];
|
|
5067
|
+
const totalsRow = db2.query(`SELECT
|
|
5068
|
+
COALESCE(SUM(r.cost_cents), 0) as total_cost,
|
|
5069
|
+
COALESCE(SUM(r.tokens_used), 0) as total_tokens,
|
|
5070
|
+
COUNT(DISTINCT r.run_id) as run_count
|
|
5071
|
+
FROM results r
|
|
5072
|
+
JOIN runs ru ON r.run_id = ru.id
|
|
5073
|
+
WHERE 1=1 ${dateFilter} ${projectFilter}`).get(...projectParams);
|
|
5074
|
+
const modelRows = db2.query(`SELECT
|
|
5075
|
+
r.model,
|
|
5076
|
+
COALESCE(SUM(r.cost_cents), 0) as cost_cents,
|
|
5077
|
+
COALESCE(SUM(r.tokens_used), 0) as tokens,
|
|
5078
|
+
COUNT(DISTINCT r.run_id) as runs
|
|
5079
|
+
FROM results r
|
|
5080
|
+
JOIN runs ru ON r.run_id = ru.id
|
|
5081
|
+
WHERE 1=1 ${dateFilter} ${projectFilter}
|
|
5082
|
+
GROUP BY r.model
|
|
5083
|
+
ORDER BY cost_cents DESC`).all(...projectParams);
|
|
5084
|
+
const byModel = {};
|
|
5085
|
+
for (const row of modelRows) {
|
|
5086
|
+
byModel[row.model] = {
|
|
5087
|
+
costCents: row.cost_cents,
|
|
5088
|
+
tokens: row.tokens,
|
|
5089
|
+
runs: row.runs
|
|
5090
|
+
};
|
|
5091
|
+
}
|
|
5092
|
+
const scenarioRows = db2.query(`SELECT
|
|
5093
|
+
r.scenario_id,
|
|
5094
|
+
COALESCE(s.name, r.scenario_id) as name,
|
|
5095
|
+
COALESCE(SUM(r.cost_cents), 0) as cost_cents,
|
|
5096
|
+
COALESCE(SUM(r.tokens_used), 0) as tokens,
|
|
5097
|
+
COUNT(DISTINCT r.run_id) as runs
|
|
5098
|
+
FROM results r
|
|
5099
|
+
JOIN runs ru ON r.run_id = ru.id
|
|
5100
|
+
LEFT JOIN scenarios s ON r.scenario_id = s.id
|
|
5101
|
+
WHERE 1=1 ${dateFilter} ${projectFilter}
|
|
5102
|
+
GROUP BY r.scenario_id
|
|
5103
|
+
ORDER BY cost_cents DESC
|
|
5104
|
+
LIMIT 10`).all(...projectParams);
|
|
5105
|
+
const byScenario = scenarioRows.map((row) => ({
|
|
5106
|
+
scenarioId: row.scenario_id,
|
|
5107
|
+
name: row.name,
|
|
5108
|
+
costCents: row.cost_cents,
|
|
5109
|
+
tokens: row.tokens,
|
|
5110
|
+
runs: row.runs
|
|
5111
|
+
}));
|
|
5112
|
+
const runCount = totalsRow.run_count;
|
|
5113
|
+
const avgCostPerRun = runCount > 0 ? totalsRow.total_cost / runCount : 0;
|
|
5114
|
+
const periodDays = getPeriodDays(period);
|
|
5115
|
+
const estimatedMonthlyCents = periodDays > 0 ? totalsRow.total_cost / periodDays * 30 : 0;
|
|
5116
|
+
return {
|
|
5117
|
+
period,
|
|
5118
|
+
totalCostCents: totalsRow.total_cost,
|
|
5119
|
+
totalTokens: totalsRow.total_tokens,
|
|
5120
|
+
runCount,
|
|
5121
|
+
byModel,
|
|
5122
|
+
byScenario,
|
|
5123
|
+
avgCostPerRun,
|
|
5124
|
+
estimatedMonthlyCents
|
|
5125
|
+
};
|
|
5126
|
+
}
|
|
5127
|
+
function formatDollars(cents) {
|
|
5128
|
+
return `$${(cents / 100).toFixed(2)}`;
|
|
5129
|
+
}
|
|
5130
|
+
function formatTokens(tokens) {
|
|
5131
|
+
if (tokens >= 1e6)
|
|
5132
|
+
return `${(tokens / 1e6).toFixed(1)}M`;
|
|
5133
|
+
if (tokens >= 1000)
|
|
5134
|
+
return `${(tokens / 1000).toFixed(1)}K`;
|
|
5135
|
+
return String(tokens);
|
|
5136
|
+
}
|
|
5137
|
+
function formatCostsTerminal(summary) {
|
|
5138
|
+
const lines = [];
|
|
5139
|
+
lines.push("");
|
|
5140
|
+
lines.push(chalk3.bold(` Cost Summary (${summary.period})`));
|
|
5141
|
+
lines.push("");
|
|
5142
|
+
lines.push(` Total: ${chalk3.yellow(formatDollars(summary.totalCostCents))} (${formatTokens(summary.totalTokens)} tokens across ${summary.runCount} runs)`);
|
|
5143
|
+
lines.push(` Avg/run: ${chalk3.yellow(formatDollars(summary.avgCostPerRun))}`);
|
|
5144
|
+
lines.push(` Est/month: ${chalk3.yellow(formatDollars(summary.estimatedMonthlyCents))}`);
|
|
5145
|
+
const modelEntries = Object.entries(summary.byModel);
|
|
5146
|
+
if (modelEntries.length > 0) {
|
|
5147
|
+
lines.push("");
|
|
5148
|
+
lines.push(chalk3.bold(" By Model"));
|
|
5149
|
+
lines.push(` ${"Model".padEnd(40)} ${"Cost".padEnd(12)} ${"Tokens".padEnd(12)} Runs`);
|
|
5150
|
+
lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)}`);
|
|
5151
|
+
for (const [model, data] of modelEntries) {
|
|
5152
|
+
lines.push(` ${model.padEnd(40)} ${formatDollars(data.costCents).padEnd(12)} ${formatTokens(data.tokens).padEnd(12)} ${data.runs}`);
|
|
5153
|
+
}
|
|
5154
|
+
}
|
|
5155
|
+
if (summary.byScenario.length > 0) {
|
|
5156
|
+
lines.push("");
|
|
5157
|
+
lines.push(chalk3.bold(" Top Scenarios by Cost"));
|
|
5158
|
+
lines.push(` ${"Scenario".padEnd(40)} ${"Cost".padEnd(12)} ${"Tokens".padEnd(12)} Runs`);
|
|
5159
|
+
lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)}`);
|
|
5160
|
+
for (const s of summary.byScenario) {
|
|
5161
|
+
const label = s.name.length > 38 ? s.name.slice(0, 35) + "..." : s.name;
|
|
5162
|
+
lines.push(` ${label.padEnd(40)} ${formatDollars(s.costCents).padEnd(12)} ${formatTokens(s.tokens).padEnd(12)} ${s.runs}`);
|
|
5163
|
+
}
|
|
5164
|
+
}
|
|
5165
|
+
lines.push("");
|
|
5166
|
+
return lines.join(`
|
|
5167
|
+
`);
|
|
5168
|
+
}
|
|
5169
|
+
function formatCostsJSON(summary) {
|
|
5170
|
+
return JSON.stringify(summary, null, 2);
|
|
4099
5171
|
}
|
|
4100
5172
|
|
|
4101
5173
|
// src/db/schedules.ts
|
|
5174
|
+
init_database();
|
|
5175
|
+
init_types();
|
|
5176
|
+
init_database();
|
|
4102
5177
|
function createSchedule(input) {
|
|
4103
5178
|
const db2 = getDatabase();
|
|
4104
5179
|
const id = uuid();
|
|
@@ -4212,16 +5287,89 @@ function deleteSchedule(id) {
|
|
|
4212
5287
|
return result.changes > 0;
|
|
4213
5288
|
}
|
|
4214
5289
|
|
|
5290
|
+
// src/lib/templates.ts
|
|
5291
|
+
var SCENARIO_TEMPLATES = {
|
|
5292
|
+
auth: [
|
|
5293
|
+
{ name: "Login with valid credentials", description: "Navigate to the login page, enter valid credentials, submit the form, and verify redirect to authenticated area. Check that user menu/avatar is visible.", tags: ["auth", "smoke"], priority: "critical", requiresAuth: false, steps: ["Navigate to login page", "Enter email and password", "Submit login form", "Verify redirect to dashboard/home", "Verify user menu or avatar is visible"] },
|
|
5294
|
+
{ name: "Signup flow", description: "Navigate to signup page, fill all required fields with valid data, submit, and verify account creation succeeds.", tags: ["auth"], priority: "high", steps: ["Navigate to signup page", "Fill all required fields", "Submit registration form", "Verify success message or redirect"] },
|
|
5295
|
+
{ name: "Logout flow", description: "While authenticated, find and click the logout button/link, verify redirect to public page.", tags: ["auth"], priority: "medium", requiresAuth: true, steps: ["Click user menu or profile", "Click logout", "Verify redirect to login or home page"] }
|
|
5296
|
+
],
|
|
5297
|
+
crud: [
|
|
5298
|
+
{ name: "Create new item", description: "Navigate to the create form, fill all fields, submit, and verify the new item appears in the list.", tags: ["crud"], priority: "high", steps: ["Navigate to the list/index page", "Click create/add button", "Fill all required fields", "Submit the form", "Verify new item appears in list"] },
|
|
5299
|
+
{ name: "Read/view item details", description: "Click on an existing item to view its details page. Verify all fields are displayed correctly.", tags: ["crud"], priority: "medium", steps: ["Navigate to list page", "Click on an item", "Verify detail page shows all fields"] },
|
|
5300
|
+
{ name: "Update existing item", description: "Edit an existing item, change some fields, save, and verify changes persisted.", tags: ["crud"], priority: "high", steps: ["Navigate to item detail", "Click edit button", "Modify fields", "Save changes", "Verify updated values"] },
|
|
5301
|
+
{ name: "Delete item", description: "Delete an existing item and verify it's removed from the list.", tags: ["crud"], priority: "medium", steps: ["Navigate to list page", "Click delete on an item", "Confirm deletion", "Verify item removed from list"] }
|
|
5302
|
+
],
|
|
5303
|
+
forms: [
|
|
5304
|
+
{ name: "Form validation - empty submission", description: "Submit a form with all fields empty and verify validation errors appear for required fields.", tags: ["forms", "validation"], priority: "high", steps: ["Navigate to form page", "Click submit without filling fields", "Verify validation errors appear for each required field"] },
|
|
5305
|
+
{ name: "Form validation - invalid data", description: "Submit a form with invalid data (bad email, short password, etc) and verify appropriate error messages.", tags: ["forms", "validation"], priority: "high", steps: ["Navigate to form page", "Enter invalid email format", "Enter too-short password", "Submit form", "Verify specific validation error messages"] },
|
|
5306
|
+
{ name: "Form successful submission", description: "Fill form with valid data, submit, and verify success state (redirect, success message, or data saved).", tags: ["forms"], priority: "high", steps: ["Navigate to form page", "Fill all fields with valid data", "Submit form", "Verify success state"] }
|
|
5307
|
+
],
|
|
5308
|
+
nav: [
|
|
5309
|
+
{ name: "Main navigation links work", description: "Click through each main navigation link and verify each page loads correctly without errors.", tags: ["navigation", "smoke"], priority: "high", steps: ["Click each nav link", "Verify page loads", "Verify no error states", "Verify breadcrumbs if present"] },
|
|
5310
|
+
{ name: "Mobile navigation", description: "At mobile viewport, verify hamburger menu opens, navigation links are accessible, and pages load correctly.", tags: ["navigation", "responsive"], priority: "medium", steps: ["Resize to mobile viewport", "Click hamburger/menu icon", "Verify nav links appear", "Click a nav link", "Verify page loads"] }
|
|
5311
|
+
],
|
|
5312
|
+
a11y: [
|
|
5313
|
+
{ name: "Keyboard navigation", description: "Navigate the page using only keyboard (Tab, Enter, Escape). Verify all interactive elements are reachable and focusable.", tags: ["a11y", "keyboard"], priority: "high", steps: ["Press Tab to move through elements", "Verify focus indicators are visible", "Press Enter on buttons/links", "Verify actions trigger correctly", "Press Escape to close modals/dropdowns"] },
|
|
5314
|
+
{ name: "Image alt text", description: "Check that all images have meaningful alt text attributes.", tags: ["a11y"], priority: "medium", steps: ["Find all images on the page", "Check each image has an alt attribute", "Verify alt text is descriptive, not empty or generic"] }
|
|
5315
|
+
]
|
|
5316
|
+
};
|
|
5317
|
+
function getTemplate(name) {
|
|
5318
|
+
return SCENARIO_TEMPLATES[name] ?? null;
|
|
5319
|
+
}
|
|
5320
|
+
function listTemplateNames() {
|
|
5321
|
+
return Object.keys(SCENARIO_TEMPLATES);
|
|
5322
|
+
}
|
|
5323
|
+
|
|
5324
|
+
// src/db/auth-presets.ts
|
|
5325
|
+
init_database();
|
|
5326
|
+
function fromRow(row) {
|
|
5327
|
+
return {
|
|
5328
|
+
id: row.id,
|
|
5329
|
+
name: row.name,
|
|
5330
|
+
email: row.email,
|
|
5331
|
+
password: row.password,
|
|
5332
|
+
loginPath: row.login_path,
|
|
5333
|
+
metadata: JSON.parse(row.metadata),
|
|
5334
|
+
createdAt: row.created_at
|
|
5335
|
+
};
|
|
5336
|
+
}
|
|
5337
|
+
function createAuthPreset(input) {
|
|
5338
|
+
const db2 = getDatabase();
|
|
5339
|
+
const id = uuid();
|
|
5340
|
+
const timestamp = now();
|
|
5341
|
+
db2.query(`
|
|
5342
|
+
INSERT INTO auth_presets (id, name, email, password, login_path, metadata, created_at)
|
|
5343
|
+
VALUES (?, ?, ?, ?, ?, '{}', ?)
|
|
5344
|
+
`).run(id, input.name, input.email, input.password, input.loginPath ?? "/login", timestamp);
|
|
5345
|
+
return getAuthPreset(input.name);
|
|
5346
|
+
}
|
|
5347
|
+
function getAuthPreset(name) {
|
|
5348
|
+
const db2 = getDatabase();
|
|
5349
|
+
const row = db2.query("SELECT * FROM auth_presets WHERE name = ?").get(name);
|
|
5350
|
+
return row ? fromRow(row) : null;
|
|
5351
|
+
}
|
|
5352
|
+
function listAuthPresets() {
|
|
5353
|
+
const db2 = getDatabase();
|
|
5354
|
+
const rows = db2.query("SELECT * FROM auth_presets ORDER BY created_at DESC").all();
|
|
5355
|
+
return rows.map(fromRow);
|
|
5356
|
+
}
|
|
5357
|
+
function deleteAuthPreset(name) {
|
|
5358
|
+
const db2 = getDatabase();
|
|
5359
|
+
const result = db2.query("DELETE FROM auth_presets WHERE name = ?").run(name);
|
|
5360
|
+
return result.changes > 0;
|
|
5361
|
+
}
|
|
5362
|
+
|
|
4215
5363
|
// src/cli/index.tsx
|
|
4216
|
-
import { existsSync as
|
|
5364
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync4 } from "fs";
|
|
4217
5365
|
var program2 = new Command;
|
|
4218
5366
|
program2.name("testers").version("0.0.1").description("AI-powered browser testing CLI");
|
|
4219
|
-
var CONFIG_DIR2 =
|
|
4220
|
-
var CONFIG_PATH2 =
|
|
5367
|
+
var CONFIG_DIR2 = join6(process.env["HOME"] ?? "~", ".testers");
|
|
5368
|
+
var CONFIG_PATH2 = join6(CONFIG_DIR2, "config.json");
|
|
4221
5369
|
function getActiveProject() {
|
|
4222
5370
|
try {
|
|
4223
|
-
if (
|
|
4224
|
-
const raw = JSON.parse(
|
|
5371
|
+
if (existsSync7(CONFIG_PATH2)) {
|
|
5372
|
+
const raw = JSON.parse(readFileSync4(CONFIG_PATH2, "utf-8"));
|
|
4225
5373
|
return raw.activeProject ?? undefined;
|
|
4226
5374
|
}
|
|
4227
5375
|
} catch {}
|
|
@@ -4236,8 +5384,21 @@ program2.command("add <name>").description("Create a new test scenario").option(
|
|
|
4236
5384
|
}, []).option("-t, --tag <tag>", "Tag (repeatable)", (val, acc) => {
|
|
4237
5385
|
acc.push(val);
|
|
4238
5386
|
return acc;
|
|
4239
|
-
}, []).option("-p, --priority <level>", "Priority level", "medium").option("-m, --model <model>", "AI model to use").option("--path <path>", "Target path on the URL").option("--auth", "Requires authentication", false).option("--timeout <ms>", "Timeout in milliseconds").option("--project <id>", "Project ID").action((name, opts) => {
|
|
5387
|
+
}, []).option("-p, --priority <level>", "Priority level", "medium").option("-m, --model <model>", "AI model to use").option("--path <path>", "Target path on the URL").option("--auth", "Requires authentication", false).option("--timeout <ms>", "Timeout in milliseconds").option("--project <id>", "Project ID").option("--template <name>", "Seed scenarios from a template (auth, crud, forms, nav, a11y)").action((name, opts) => {
|
|
4240
5388
|
try {
|
|
5389
|
+
if (opts.template) {
|
|
5390
|
+
const template = getTemplate(opts.template);
|
|
5391
|
+
if (!template) {
|
|
5392
|
+
console.error(chalk4.red(`Unknown template: ${opts.template}. Available: ${listTemplateNames().join(", ")}`));
|
|
5393
|
+
process.exit(1);
|
|
5394
|
+
}
|
|
5395
|
+
const projectId2 = resolveProject(opts.project);
|
|
5396
|
+
for (const input of template) {
|
|
5397
|
+
const s = createScenario({ ...input, projectId: projectId2 });
|
|
5398
|
+
console.log(chalk4.green(` Created ${s.shortId}: ${s.name}`));
|
|
5399
|
+
}
|
|
5400
|
+
return;
|
|
5401
|
+
}
|
|
4241
5402
|
const projectId = resolveProject(opts.project);
|
|
4242
5403
|
const scenario = createScenario({
|
|
4243
5404
|
name,
|
|
@@ -4251,9 +5412,9 @@ program2.command("add <name>").description("Create a new test scenario").option(
|
|
|
4251
5412
|
timeoutMs: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
|
|
4252
5413
|
projectId
|
|
4253
5414
|
});
|
|
4254
|
-
console.log(
|
|
5415
|
+
console.log(chalk4.green(`Created scenario ${chalk4.bold(scenario.shortId)}: ${scenario.name}`));
|
|
4255
5416
|
} catch (error) {
|
|
4256
|
-
console.error(
|
|
5417
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4257
5418
|
process.exit(1);
|
|
4258
5419
|
}
|
|
4259
5420
|
});
|
|
@@ -4267,7 +5428,7 @@ program2.command("list").description("List test scenarios").option("-t, --tag <t
|
|
|
4267
5428
|
});
|
|
4268
5429
|
console.log(formatScenarioList(scenarios));
|
|
4269
5430
|
} catch (error) {
|
|
4270
|
-
console.error(
|
|
5431
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4271
5432
|
process.exit(1);
|
|
4272
5433
|
}
|
|
4273
5434
|
});
|
|
@@ -4275,33 +5436,33 @@ program2.command("show <id>").description("Show scenario details").action((id) =
|
|
|
4275
5436
|
try {
|
|
4276
5437
|
const scenario = getScenario(id) ?? getScenarioByShortId(id);
|
|
4277
5438
|
if (!scenario) {
|
|
4278
|
-
console.error(
|
|
5439
|
+
console.error(chalk4.red(`Scenario not found: ${id}`));
|
|
4279
5440
|
process.exit(1);
|
|
4280
5441
|
}
|
|
4281
5442
|
console.log("");
|
|
4282
|
-
console.log(
|
|
5443
|
+
console.log(chalk4.bold(` Scenario ${scenario.shortId}`));
|
|
4283
5444
|
console.log(` Name: ${scenario.name}`);
|
|
4284
|
-
console.log(` ID: ${
|
|
5445
|
+
console.log(` ID: ${chalk4.dim(scenario.id)}`);
|
|
4285
5446
|
console.log(` Description: ${scenario.description}`);
|
|
4286
5447
|
console.log(` Priority: ${scenario.priority}`);
|
|
4287
|
-
console.log(` Model: ${scenario.model ??
|
|
4288
|
-
console.log(` Tags: ${scenario.tags.length > 0 ? scenario.tags.join(", ") :
|
|
4289
|
-
console.log(` Path: ${scenario.targetPath ??
|
|
5448
|
+
console.log(` Model: ${scenario.model ?? chalk4.dim("default")}`);
|
|
5449
|
+
console.log(` Tags: ${scenario.tags.length > 0 ? scenario.tags.join(", ") : chalk4.dim("none")}`);
|
|
5450
|
+
console.log(` Path: ${scenario.targetPath ?? chalk4.dim("none")}`);
|
|
4290
5451
|
console.log(` Auth: ${scenario.requiresAuth ? "yes" : "no"}`);
|
|
4291
|
-
console.log(` Timeout: ${scenario.timeoutMs ? `${scenario.timeoutMs}ms` :
|
|
5452
|
+
console.log(` Timeout: ${scenario.timeoutMs ? `${scenario.timeoutMs}ms` : chalk4.dim("default")}`);
|
|
4292
5453
|
console.log(` Version: ${scenario.version}`);
|
|
4293
5454
|
console.log(` Created: ${scenario.createdAt}`);
|
|
4294
5455
|
console.log(` Updated: ${scenario.updatedAt}`);
|
|
4295
5456
|
if (scenario.steps.length > 0) {
|
|
4296
5457
|
console.log("");
|
|
4297
|
-
console.log(
|
|
5458
|
+
console.log(chalk4.bold(" Steps:"));
|
|
4298
5459
|
for (let i = 0;i < scenario.steps.length; i++) {
|
|
4299
5460
|
console.log(` ${i + 1}. ${scenario.steps[i]}`);
|
|
4300
5461
|
}
|
|
4301
5462
|
}
|
|
4302
5463
|
console.log("");
|
|
4303
5464
|
} catch (error) {
|
|
4304
|
-
console.error(
|
|
5465
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4305
5466
|
process.exit(1);
|
|
4306
5467
|
}
|
|
4307
5468
|
});
|
|
@@ -4315,7 +5476,7 @@ program2.command("update <id>").description("Update a scenario").option("-n, --n
|
|
|
4315
5476
|
try {
|
|
4316
5477
|
const scenario = getScenario(id) ?? getScenarioByShortId(id);
|
|
4317
5478
|
if (!scenario) {
|
|
4318
|
-
console.error(
|
|
5479
|
+
console.error(chalk4.red(`Scenario not found: ${id}`));
|
|
4319
5480
|
process.exit(1);
|
|
4320
5481
|
}
|
|
4321
5482
|
const updated = updateScenario(scenario.id, {
|
|
@@ -4326,9 +5487,9 @@ program2.command("update <id>").description("Update a scenario").option("-n, --n
|
|
|
4326
5487
|
priority: opts.priority,
|
|
4327
5488
|
model: opts.model
|
|
4328
5489
|
}, scenario.version);
|
|
4329
|
-
console.log(
|
|
5490
|
+
console.log(chalk4.green(`Updated scenario ${chalk4.bold(updated.shortId)}: ${updated.name}`));
|
|
4330
5491
|
} catch (error) {
|
|
4331
|
-
console.error(
|
|
5492
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4332
5493
|
process.exit(1);
|
|
4333
5494
|
}
|
|
4334
5495
|
});
|
|
@@ -4336,27 +5497,52 @@ program2.command("delete <id>").description("Delete a scenario").action((id) =>
|
|
|
4336
5497
|
try {
|
|
4337
5498
|
const scenario = getScenario(id) ?? getScenarioByShortId(id);
|
|
4338
5499
|
if (!scenario) {
|
|
4339
|
-
console.error(
|
|
5500
|
+
console.error(chalk4.red(`Scenario not found: ${id}`));
|
|
4340
5501
|
process.exit(1);
|
|
4341
5502
|
}
|
|
4342
5503
|
const deleted = deleteScenario(scenario.id);
|
|
4343
5504
|
if (deleted) {
|
|
4344
|
-
console.log(
|
|
5505
|
+
console.log(chalk4.green(`Deleted scenario ${scenario.shortId}: ${scenario.name}`));
|
|
4345
5506
|
} else {
|
|
4346
|
-
console.error(
|
|
5507
|
+
console.error(chalk4.red(`Failed to delete scenario: ${id}`));
|
|
4347
5508
|
process.exit(1);
|
|
4348
5509
|
}
|
|
4349
5510
|
} catch (error) {
|
|
4350
|
-
console.error(
|
|
5511
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4351
5512
|
process.exit(1);
|
|
4352
5513
|
}
|
|
4353
5514
|
});
|
|
4354
5515
|
program2.command("run <url> [description]").description("Run test scenarios against a URL").option("-t, --tag <tag>", "Filter by tag (repeatable)", (val, acc) => {
|
|
4355
5516
|
acc.push(val);
|
|
4356
5517
|
return acc;
|
|
4357
|
-
}, []).option("-s, --scenario <id>", "Run specific scenario ID").option("-p, --priority <level>", "Filter by priority").option("--headed", "Run browser in headed mode", false).option("-m, --model <model>", "AI model to use").option("--parallel <n>", "Number of parallel browsers", "1").option("--json", "Output results as JSON", false).option("-o, --output <filepath>", "Write JSON results to file").option("--timeout <ms>", "Timeout in milliseconds").option("--from-todos", "Import scenarios from todos before running", false).option("--project <id>", "Project ID").action(async (url, description, opts) => {
|
|
5518
|
+
}, []).option("-s, --scenario <id>", "Run specific scenario ID").option("-p, --priority <level>", "Filter by priority").option("--headed", "Run browser in headed mode", false).option("-m, --model <model>", "AI model to use").option("--parallel <n>", "Number of parallel browsers", "1").option("--json", "Output results as JSON", false).option("-o, --output <filepath>", "Write JSON results to file").option("--timeout <ms>", "Timeout in milliseconds").option("--from-todos", "Import scenarios from todos before running", false).option("--project <id>", "Project ID").option("-b, --background", "Start run in background and return immediately", false).action(async (url, description, opts) => {
|
|
4358
5519
|
try {
|
|
4359
5520
|
const projectId = resolveProject(opts.project);
|
|
5521
|
+
if (opts.fromTodos) {
|
|
5522
|
+
const result = importFromTodos({ projectId });
|
|
5523
|
+
console.log(chalk4.blue(`Imported ${result.imported} scenarios from todos (${result.skipped} skipped)`));
|
|
5524
|
+
}
|
|
5525
|
+
if (opts.background) {
|
|
5526
|
+
if (description) {
|
|
5527
|
+
createScenario({ name: description, description, tags: ["ad-hoc"], projectId });
|
|
5528
|
+
}
|
|
5529
|
+
const { runId, scenarioCount } = startRunAsync({
|
|
5530
|
+
url,
|
|
5531
|
+
tags: opts.tag.length > 0 ? opts.tag : undefined,
|
|
5532
|
+
scenarioIds: opts.scenario ? [opts.scenario] : undefined,
|
|
5533
|
+
priority: opts.priority,
|
|
5534
|
+
model: opts.model,
|
|
5535
|
+
headed: opts.headed,
|
|
5536
|
+
parallel: parseInt(opts.parallel, 10),
|
|
5537
|
+
timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
|
|
5538
|
+
projectId
|
|
5539
|
+
});
|
|
5540
|
+
console.log(chalk4.green(`Run started in background: ${chalk4.bold(runId.slice(0, 8))}`));
|
|
5541
|
+
console.log(chalk4.dim(` Scenarios: ${scenarioCount}`));
|
|
5542
|
+
console.log(chalk4.dim(` URL: ${url}`));
|
|
5543
|
+
console.log(chalk4.dim(` Check progress: testers results ${runId.slice(0, 8)}`));
|
|
5544
|
+
process.exit(0);
|
|
5545
|
+
}
|
|
4360
5546
|
if (description) {
|
|
4361
5547
|
const scenario = createScenario({
|
|
4362
5548
|
name: description,
|
|
@@ -4376,8 +5562,8 @@ program2.command("run <url> [description]").description("Run test scenarios agai
|
|
|
4376
5562
|
if (opts.json || opts.output) {
|
|
4377
5563
|
const jsonOutput = formatJSON(run2, results2);
|
|
4378
5564
|
if (opts.output) {
|
|
4379
|
-
|
|
4380
|
-
console.log(
|
|
5565
|
+
writeFileSync3(opts.output, jsonOutput, "utf-8");
|
|
5566
|
+
console.log(chalk4.green(`Results written to ${opts.output}`));
|
|
4381
5567
|
}
|
|
4382
5568
|
if (opts.json) {
|
|
4383
5569
|
console.log(jsonOutput);
|
|
@@ -4387,10 +5573,6 @@ program2.command("run <url> [description]").description("Run test scenarios agai
|
|
|
4387
5573
|
}
|
|
4388
5574
|
process.exit(getExitCode(run2));
|
|
4389
5575
|
}
|
|
4390
|
-
if (opts.fromTodos) {
|
|
4391
|
-
const result = importFromTodos({ projectId });
|
|
4392
|
-
console.log(chalk2.blue(`Imported ${result.imported} scenarios from todos (${result.skipped} skipped)`));
|
|
4393
|
-
}
|
|
4394
5576
|
const { run, results } = await runByFilter({
|
|
4395
5577
|
url,
|
|
4396
5578
|
tags: opts.tag.length > 0 ? opts.tag : undefined,
|
|
@@ -4405,8 +5587,8 @@ program2.command("run <url> [description]").description("Run test scenarios agai
|
|
|
4405
5587
|
if (opts.json || opts.output) {
|
|
4406
5588
|
const jsonOutput = formatJSON(run, results);
|
|
4407
5589
|
if (opts.output) {
|
|
4408
|
-
|
|
4409
|
-
console.log(
|
|
5590
|
+
writeFileSync3(opts.output, jsonOutput, "utf-8");
|
|
5591
|
+
console.log(chalk4.green(`Results written to ${opts.output}`));
|
|
4410
5592
|
}
|
|
4411
5593
|
if (opts.json) {
|
|
4412
5594
|
console.log(jsonOutput);
|
|
@@ -4416,7 +5598,7 @@ program2.command("run <url> [description]").description("Run test scenarios agai
|
|
|
4416
5598
|
}
|
|
4417
5599
|
process.exit(getExitCode(run));
|
|
4418
5600
|
} catch (error) {
|
|
4419
|
-
console.error(
|
|
5601
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4420
5602
|
process.exit(1);
|
|
4421
5603
|
}
|
|
4422
5604
|
});
|
|
@@ -4428,7 +5610,7 @@ program2.command("runs").description("List past test runs").option("--status <st
|
|
|
4428
5610
|
});
|
|
4429
5611
|
console.log(formatRunList(runs));
|
|
4430
5612
|
} catch (error) {
|
|
4431
|
-
console.error(
|
|
5613
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4432
5614
|
process.exit(1);
|
|
4433
5615
|
}
|
|
4434
5616
|
});
|
|
@@ -4436,13 +5618,13 @@ program2.command("results <run-id>").description("Show results for a test run").
|
|
|
4436
5618
|
try {
|
|
4437
5619
|
const run = getRun(runId);
|
|
4438
5620
|
if (!run) {
|
|
4439
|
-
console.error(
|
|
5621
|
+
console.error(chalk4.red(`Run not found: ${runId}`));
|
|
4440
5622
|
process.exit(1);
|
|
4441
5623
|
}
|
|
4442
5624
|
const results = getResultsByRun(run.id);
|
|
4443
5625
|
console.log(formatTerminal(run, results));
|
|
4444
5626
|
} catch (error) {
|
|
4445
|
-
console.error(
|
|
5627
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4446
5628
|
process.exit(1);
|
|
4447
5629
|
}
|
|
4448
5630
|
});
|
|
@@ -4453,23 +5635,23 @@ program2.command("screenshots <id>").description("List screenshots for a run or
|
|
|
4453
5635
|
const results = getResultsByRun(run.id);
|
|
4454
5636
|
let total = 0;
|
|
4455
5637
|
console.log("");
|
|
4456
|
-
console.log(
|
|
5638
|
+
console.log(chalk4.bold(` Screenshots for run ${run.id.slice(0, 8)}`));
|
|
4457
5639
|
console.log("");
|
|
4458
5640
|
for (const result of results) {
|
|
4459
5641
|
const screenshots2 = listScreenshots(result.id);
|
|
4460
5642
|
if (screenshots2.length > 0) {
|
|
4461
5643
|
const scenario = getScenario(result.scenarioId);
|
|
4462
5644
|
const label = scenario ? `${scenario.shortId}: ${scenario.name}` : result.scenarioId.slice(0, 8);
|
|
4463
|
-
console.log(
|
|
5645
|
+
console.log(chalk4.bold(` ${label}`));
|
|
4464
5646
|
for (const ss of screenshots2) {
|
|
4465
|
-
console.log(` ${
|
|
5647
|
+
console.log(` ${chalk4.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk4.dim(ss.filePath)}`);
|
|
4466
5648
|
total++;
|
|
4467
5649
|
}
|
|
4468
5650
|
console.log("");
|
|
4469
5651
|
}
|
|
4470
5652
|
}
|
|
4471
5653
|
if (total === 0) {
|
|
4472
|
-
console.log(
|
|
5654
|
+
console.log(chalk4.dim(" No screenshots found."));
|
|
4473
5655
|
console.log("");
|
|
4474
5656
|
}
|
|
4475
5657
|
return;
|
|
@@ -4477,18 +5659,18 @@ program2.command("screenshots <id>").description("List screenshots for a run or
|
|
|
4477
5659
|
const screenshots = listScreenshots(id);
|
|
4478
5660
|
if (screenshots.length > 0) {
|
|
4479
5661
|
console.log("");
|
|
4480
|
-
console.log(
|
|
5662
|
+
console.log(chalk4.bold(` Screenshots for result ${id.slice(0, 8)}`));
|
|
4481
5663
|
console.log("");
|
|
4482
5664
|
for (const ss of screenshots) {
|
|
4483
|
-
console.log(` ${
|
|
5665
|
+
console.log(` ${chalk4.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk4.dim(ss.filePath)}`);
|
|
4484
5666
|
}
|
|
4485
5667
|
console.log("");
|
|
4486
5668
|
return;
|
|
4487
5669
|
}
|
|
4488
|
-
console.error(
|
|
5670
|
+
console.error(chalk4.red(`No screenshots found for: ${id}`));
|
|
4489
5671
|
process.exit(1);
|
|
4490
5672
|
} catch (error) {
|
|
4491
|
-
console.error(
|
|
5673
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4492
5674
|
process.exit(1);
|
|
4493
5675
|
}
|
|
4494
5676
|
});
|
|
@@ -4497,12 +5679,12 @@ program2.command("import <dir>").description("Import markdown test files as scen
|
|
|
4497
5679
|
const absDir = resolve(dir);
|
|
4498
5680
|
const files = readdirSync(absDir).filter((f) => f.endsWith(".md"));
|
|
4499
5681
|
if (files.length === 0) {
|
|
4500
|
-
console.log(
|
|
5682
|
+
console.log(chalk4.dim("No .md files found in directory."));
|
|
4501
5683
|
return;
|
|
4502
5684
|
}
|
|
4503
5685
|
let imported = 0;
|
|
4504
5686
|
for (const file of files) {
|
|
4505
|
-
const content =
|
|
5687
|
+
const content = readFileSync4(join6(absDir, file), "utf-8");
|
|
4506
5688
|
const lines = content.split(`
|
|
4507
5689
|
`);
|
|
4508
5690
|
let name = file.replace(/\.md$/, "");
|
|
@@ -4527,13 +5709,13 @@ program2.command("import <dir>").description("Import markdown test files as scen
|
|
|
4527
5709
|
description: descriptionLines.join(" ") || name,
|
|
4528
5710
|
steps
|
|
4529
5711
|
});
|
|
4530
|
-
console.log(
|
|
5712
|
+
console.log(chalk4.green(` Imported ${chalk4.bold(scenario.shortId)}: ${scenario.name}`));
|
|
4531
5713
|
imported++;
|
|
4532
5714
|
}
|
|
4533
5715
|
console.log("");
|
|
4534
|
-
console.log(
|
|
5716
|
+
console.log(chalk4.green(`Imported ${imported} scenario(s) from ${absDir}`));
|
|
4535
5717
|
} catch (error) {
|
|
4536
|
-
console.error(
|
|
5718
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4537
5719
|
process.exit(1);
|
|
4538
5720
|
}
|
|
4539
5721
|
});
|
|
@@ -4542,7 +5724,7 @@ program2.command("config").description("Show current configuration").action(() =
|
|
|
4542
5724
|
const config = loadConfig();
|
|
4543
5725
|
console.log(JSON.stringify(config, null, 2));
|
|
4544
5726
|
} catch (error) {
|
|
4545
|
-
console.error(
|
|
5727
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4546
5728
|
process.exit(1);
|
|
4547
5729
|
}
|
|
4548
5730
|
});
|
|
@@ -4550,27 +5732,27 @@ program2.command("status").description("Show database and auth status").action((
|
|
|
4550
5732
|
try {
|
|
4551
5733
|
const config = loadConfig();
|
|
4552
5734
|
const hasApiKey = !!config.anthropicApiKey || !!process.env["ANTHROPIC_API_KEY"];
|
|
4553
|
-
const dbPath =
|
|
5735
|
+
const dbPath = join6(process.env["HOME"] ?? "~", ".testers", "testers.db");
|
|
4554
5736
|
console.log("");
|
|
4555
|
-
console.log(
|
|
5737
|
+
console.log(chalk4.bold(" Open Testers Status"));
|
|
4556
5738
|
console.log("");
|
|
4557
|
-
console.log(` ANTHROPIC_API_KEY: ${hasApiKey ?
|
|
5739
|
+
console.log(` ANTHROPIC_API_KEY: ${hasApiKey ? chalk4.green("set") : chalk4.red("not set")}`);
|
|
4558
5740
|
console.log(` Database: ${dbPath}`);
|
|
4559
5741
|
console.log(` Default model: ${config.defaultModel}`);
|
|
4560
5742
|
console.log(` Screenshots dir: ${config.screenshots.dir}`);
|
|
4561
5743
|
console.log("");
|
|
4562
5744
|
} catch (error) {
|
|
4563
|
-
console.error(
|
|
5745
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4564
5746
|
process.exit(1);
|
|
4565
5747
|
}
|
|
4566
5748
|
});
|
|
4567
5749
|
program2.command("install-browser").description("Install Playwright Chromium browser").action(async () => {
|
|
4568
5750
|
try {
|
|
4569
|
-
console.log(
|
|
5751
|
+
console.log(chalk4.blue("Installing Playwright Chromium..."));
|
|
4570
5752
|
await installBrowser();
|
|
4571
|
-
console.log(
|
|
5753
|
+
console.log(chalk4.green("Browser installed successfully."));
|
|
4572
5754
|
} catch (error) {
|
|
4573
|
-
console.error(
|
|
5755
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4574
5756
|
process.exit(1);
|
|
4575
5757
|
}
|
|
4576
5758
|
});
|
|
@@ -4582,9 +5764,9 @@ projectCmd.command("create <name>").description("Create a new project").option("
|
|
|
4582
5764
|
path: opts.path,
|
|
4583
5765
|
description: opts.description
|
|
4584
5766
|
});
|
|
4585
|
-
console.log(
|
|
5767
|
+
console.log(chalk4.green(`Created project ${chalk4.bold(project.name)} (${project.id})`));
|
|
4586
5768
|
} catch (error) {
|
|
4587
|
-
console.error(
|
|
5769
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4588
5770
|
process.exit(1);
|
|
4589
5771
|
}
|
|
4590
5772
|
});
|
|
@@ -4592,20 +5774,20 @@ projectCmd.command("list").description("List all projects").action(() => {
|
|
|
4592
5774
|
try {
|
|
4593
5775
|
const projects = listProjects();
|
|
4594
5776
|
if (projects.length === 0) {
|
|
4595
|
-
console.log(
|
|
5777
|
+
console.log(chalk4.dim("No projects found."));
|
|
4596
5778
|
return;
|
|
4597
5779
|
}
|
|
4598
5780
|
console.log("");
|
|
4599
|
-
console.log(
|
|
5781
|
+
console.log(chalk4.bold(" Projects"));
|
|
4600
5782
|
console.log("");
|
|
4601
5783
|
console.log(` ${"ID".padEnd(38)} ${"Name".padEnd(24)} ${"Path".padEnd(30)} Created`);
|
|
4602
5784
|
console.log(` ${"\u2500".repeat(38)} ${"\u2500".repeat(24)} ${"\u2500".repeat(30)} ${"\u2500".repeat(20)}`);
|
|
4603
5785
|
for (const p of projects) {
|
|
4604
|
-
console.log(` ${p.id.padEnd(38)} ${p.name.padEnd(24)} ${(p.path ??
|
|
5786
|
+
console.log(` ${p.id.padEnd(38)} ${p.name.padEnd(24)} ${(p.path ?? chalk4.dim("\u2014")).toString().padEnd(30)} ${p.createdAt}`);
|
|
4605
5787
|
}
|
|
4606
5788
|
console.log("");
|
|
4607
5789
|
} catch (error) {
|
|
4608
|
-
console.error(
|
|
5790
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4609
5791
|
process.exit(1);
|
|
4610
5792
|
}
|
|
4611
5793
|
});
|
|
@@ -4613,39 +5795,39 @@ projectCmd.command("show <id>").description("Show project details").action((id)
|
|
|
4613
5795
|
try {
|
|
4614
5796
|
const project = getProject(id);
|
|
4615
5797
|
if (!project) {
|
|
4616
|
-
console.error(
|
|
5798
|
+
console.error(chalk4.red(`Project not found: ${id}`));
|
|
4617
5799
|
process.exit(1);
|
|
4618
5800
|
}
|
|
4619
5801
|
console.log("");
|
|
4620
|
-
console.log(
|
|
5802
|
+
console.log(chalk4.bold(` Project: ${project.name}`));
|
|
4621
5803
|
console.log(` ID: ${project.id}`);
|
|
4622
|
-
console.log(` Path: ${project.path ??
|
|
4623
|
-
console.log(` Description: ${project.description ??
|
|
5804
|
+
console.log(` Path: ${project.path ?? chalk4.dim("none")}`);
|
|
5805
|
+
console.log(` Description: ${project.description ?? chalk4.dim("none")}`);
|
|
4624
5806
|
console.log(` Created: ${project.createdAt}`);
|
|
4625
5807
|
console.log(` Updated: ${project.updatedAt}`);
|
|
4626
5808
|
console.log("");
|
|
4627
5809
|
} catch (error) {
|
|
4628
|
-
console.error(
|
|
5810
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4629
5811
|
process.exit(1);
|
|
4630
5812
|
}
|
|
4631
5813
|
});
|
|
4632
5814
|
projectCmd.command("use <name>").description("Set active project (find or create)").action((name) => {
|
|
4633
5815
|
try {
|
|
4634
5816
|
const project = ensureProject(name, process.cwd());
|
|
4635
|
-
if (!
|
|
4636
|
-
|
|
5817
|
+
if (!existsSync7(CONFIG_DIR2)) {
|
|
5818
|
+
mkdirSync4(CONFIG_DIR2, { recursive: true });
|
|
4637
5819
|
}
|
|
4638
5820
|
let config = {};
|
|
4639
|
-
if (
|
|
5821
|
+
if (existsSync7(CONFIG_PATH2)) {
|
|
4640
5822
|
try {
|
|
4641
|
-
config = JSON.parse(
|
|
5823
|
+
config = JSON.parse(readFileSync4(CONFIG_PATH2, "utf-8"));
|
|
4642
5824
|
} catch {}
|
|
4643
5825
|
}
|
|
4644
5826
|
config.activeProject = project.id;
|
|
4645
|
-
|
|
4646
|
-
console.log(
|
|
5827
|
+
writeFileSync3(CONFIG_PATH2, JSON.stringify(config, null, 2), "utf-8");
|
|
5828
|
+
console.log(chalk4.green(`Active project set to ${chalk4.bold(project.name)} (${project.id})`));
|
|
4647
5829
|
} catch (error) {
|
|
4648
|
-
console.error(
|
|
5830
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4649
5831
|
process.exit(1);
|
|
4650
5832
|
}
|
|
4651
5833
|
});
|
|
@@ -4670,12 +5852,12 @@ scheduleCmd.command("create <name>").description("Create a new schedule").requir
|
|
|
4670
5852
|
timeoutMs: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
|
|
4671
5853
|
projectId
|
|
4672
5854
|
});
|
|
4673
|
-
console.log(
|
|
5855
|
+
console.log(chalk4.green(`Created schedule ${chalk4.bold(schedule.name)} (${schedule.id})`));
|
|
4674
5856
|
if (schedule.nextRunAt) {
|
|
4675
|
-
console.log(
|
|
5857
|
+
console.log(chalk4.dim(` Next run at: ${schedule.nextRunAt}`));
|
|
4676
5858
|
}
|
|
4677
5859
|
} catch (error) {
|
|
4678
|
-
console.error(
|
|
5860
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4679
5861
|
process.exit(1);
|
|
4680
5862
|
}
|
|
4681
5863
|
});
|
|
@@ -4687,23 +5869,23 @@ scheduleCmd.command("list").description("List schedules").option("--project <id>
|
|
|
4687
5869
|
enabled: opts.enabled ? true : undefined
|
|
4688
5870
|
});
|
|
4689
5871
|
if (schedules.length === 0) {
|
|
4690
|
-
console.log(
|
|
5872
|
+
console.log(chalk4.dim("No schedules found."));
|
|
4691
5873
|
return;
|
|
4692
5874
|
}
|
|
4693
5875
|
console.log("");
|
|
4694
|
-
console.log(
|
|
5876
|
+
console.log(chalk4.bold(" Schedules"));
|
|
4695
5877
|
console.log("");
|
|
4696
5878
|
console.log(` ${"Name".padEnd(20)} ${"Cron".padEnd(18)} ${"URL".padEnd(30)} ${"Enabled".padEnd(9)} ${"Next Run".padEnd(22)} Last Run`);
|
|
4697
5879
|
console.log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(18)} ${"\u2500".repeat(30)} ${"\u2500".repeat(9)} ${"\u2500".repeat(22)} ${"\u2500".repeat(22)}`);
|
|
4698
5880
|
for (const s of schedules) {
|
|
4699
|
-
const enabled = s.enabled ?
|
|
4700
|
-
const nextRun = s.nextRunAt ??
|
|
4701
|
-
const lastRun = s.lastRunAt ??
|
|
5881
|
+
const enabled = s.enabled ? chalk4.green("yes") : chalk4.red("no");
|
|
5882
|
+
const nextRun = s.nextRunAt ?? chalk4.dim("\u2014");
|
|
5883
|
+
const lastRun = s.lastRunAt ?? chalk4.dim("\u2014");
|
|
4702
5884
|
console.log(` ${s.name.padEnd(20)} ${s.cronExpression.padEnd(18)} ${s.url.padEnd(30)} ${enabled.toString().padEnd(9)} ${nextRun.toString().padEnd(22)} ${lastRun}`);
|
|
4703
5885
|
}
|
|
4704
5886
|
console.log("");
|
|
4705
5887
|
} catch (error) {
|
|
4706
|
-
console.error(
|
|
5888
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4707
5889
|
process.exit(1);
|
|
4708
5890
|
}
|
|
4709
5891
|
});
|
|
@@ -4711,47 +5893,47 @@ scheduleCmd.command("show <id>").description("Show schedule details").action((id
|
|
|
4711
5893
|
try {
|
|
4712
5894
|
const schedule = getSchedule(id);
|
|
4713
5895
|
if (!schedule) {
|
|
4714
|
-
console.error(
|
|
5896
|
+
console.error(chalk4.red(`Schedule not found: ${id}`));
|
|
4715
5897
|
process.exit(1);
|
|
4716
5898
|
}
|
|
4717
5899
|
console.log("");
|
|
4718
|
-
console.log(
|
|
5900
|
+
console.log(chalk4.bold(` Schedule: ${schedule.name}`));
|
|
4719
5901
|
console.log(` ID: ${schedule.id}`);
|
|
4720
5902
|
console.log(` Cron: ${schedule.cronExpression}`);
|
|
4721
5903
|
console.log(` URL: ${schedule.url}`);
|
|
4722
|
-
console.log(` Enabled: ${schedule.enabled ?
|
|
4723
|
-
console.log(` Model: ${schedule.model ??
|
|
5904
|
+
console.log(` Enabled: ${schedule.enabled ? chalk4.green("yes") : chalk4.red("no")}`);
|
|
5905
|
+
console.log(` Model: ${schedule.model ?? chalk4.dim("default")}`);
|
|
4724
5906
|
console.log(` Headed: ${schedule.headed ? "yes" : "no"}`);
|
|
4725
5907
|
console.log(` Parallel: ${schedule.parallel}`);
|
|
4726
|
-
console.log(` Timeout: ${schedule.timeoutMs ? `${schedule.timeoutMs}ms` :
|
|
4727
|
-
console.log(` Project: ${schedule.projectId ??
|
|
5908
|
+
console.log(` Timeout: ${schedule.timeoutMs ? `${schedule.timeoutMs}ms` : chalk4.dim("default")}`);
|
|
5909
|
+
console.log(` Project: ${schedule.projectId ?? chalk4.dim("none")}`);
|
|
4728
5910
|
console.log(` Filter: ${JSON.stringify(schedule.scenarioFilter)}`);
|
|
4729
|
-
console.log(` Next run: ${schedule.nextRunAt ??
|
|
4730
|
-
console.log(` Last run: ${schedule.lastRunAt ??
|
|
4731
|
-
console.log(` Last run ID: ${schedule.lastRunId ??
|
|
5911
|
+
console.log(` Next run: ${schedule.nextRunAt ?? chalk4.dim("not scheduled")}`);
|
|
5912
|
+
console.log(` Last run: ${schedule.lastRunAt ?? chalk4.dim("never")}`);
|
|
5913
|
+
console.log(` Last run ID: ${schedule.lastRunId ?? chalk4.dim("none")}`);
|
|
4732
5914
|
console.log(` Created: ${schedule.createdAt}`);
|
|
4733
5915
|
console.log(` Updated: ${schedule.updatedAt}`);
|
|
4734
5916
|
console.log("");
|
|
4735
5917
|
} catch (error) {
|
|
4736
|
-
console.error(
|
|
5918
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4737
5919
|
process.exit(1);
|
|
4738
5920
|
}
|
|
4739
5921
|
});
|
|
4740
5922
|
scheduleCmd.command("enable <id>").description("Enable a schedule").action((id) => {
|
|
4741
5923
|
try {
|
|
4742
5924
|
const schedule = updateSchedule(id, { enabled: true });
|
|
4743
|
-
console.log(
|
|
5925
|
+
console.log(chalk4.green(`Enabled schedule ${chalk4.bold(schedule.name)}`));
|
|
4744
5926
|
} catch (error) {
|
|
4745
|
-
console.error(
|
|
5927
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4746
5928
|
process.exit(1);
|
|
4747
5929
|
}
|
|
4748
5930
|
});
|
|
4749
5931
|
scheduleCmd.command("disable <id>").description("Disable a schedule").action((id) => {
|
|
4750
5932
|
try {
|
|
4751
5933
|
const schedule = updateSchedule(id, { enabled: false });
|
|
4752
|
-
console.log(
|
|
5934
|
+
console.log(chalk4.green(`Disabled schedule ${chalk4.bold(schedule.name)}`));
|
|
4753
5935
|
} catch (error) {
|
|
4754
|
-
console.error(
|
|
5936
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4755
5937
|
process.exit(1);
|
|
4756
5938
|
}
|
|
4757
5939
|
});
|
|
@@ -4759,13 +5941,13 @@ scheduleCmd.command("delete <id>").description("Delete a schedule").action((id)
|
|
|
4759
5941
|
try {
|
|
4760
5942
|
const deleted = deleteSchedule(id);
|
|
4761
5943
|
if (deleted) {
|
|
4762
|
-
console.log(
|
|
5944
|
+
console.log(chalk4.green(`Deleted schedule: ${id}`));
|
|
4763
5945
|
} else {
|
|
4764
|
-
console.error(
|
|
5946
|
+
console.error(chalk4.red(`Schedule not found: ${id}`));
|
|
4765
5947
|
process.exit(1);
|
|
4766
5948
|
}
|
|
4767
5949
|
} catch (error) {
|
|
4768
|
-
console.error(
|
|
5950
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4769
5951
|
process.exit(1);
|
|
4770
5952
|
}
|
|
4771
5953
|
});
|
|
@@ -4773,11 +5955,11 @@ scheduleCmd.command("run <id>").description("Manually trigger a schedule").optio
|
|
|
4773
5955
|
try {
|
|
4774
5956
|
const schedule = getSchedule(id);
|
|
4775
5957
|
if (!schedule) {
|
|
4776
|
-
console.error(
|
|
5958
|
+
console.error(chalk4.red(`Schedule not found: ${id}`));
|
|
4777
5959
|
process.exit(1);
|
|
4778
5960
|
return;
|
|
4779
5961
|
}
|
|
4780
|
-
console.log(
|
|
5962
|
+
console.log(chalk4.blue(`Running schedule ${chalk4.bold(schedule.name)} against ${schedule.url}...`));
|
|
4781
5963
|
const { run, results } = await runByFilter({
|
|
4782
5964
|
url: schedule.url,
|
|
4783
5965
|
tags: schedule.scenarioFilter.tags,
|
|
@@ -4796,15 +5978,15 @@ scheduleCmd.command("run <id>").description("Manually trigger a schedule").optio
|
|
|
4796
5978
|
}
|
|
4797
5979
|
process.exit(getExitCode(run));
|
|
4798
5980
|
} catch (error) {
|
|
4799
|
-
console.error(
|
|
5981
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4800
5982
|
process.exit(1);
|
|
4801
5983
|
}
|
|
4802
5984
|
});
|
|
4803
5985
|
program2.command("daemon").description("Start the scheduler daemon").option("--interval <seconds>", "Check interval in seconds", "60").action(async (opts) => {
|
|
4804
5986
|
try {
|
|
4805
5987
|
const intervalMs = parseInt(opts.interval, 10) * 1000;
|
|
4806
|
-
console.log(
|
|
4807
|
-
console.log(
|
|
5988
|
+
console.log(chalk4.blue("Scheduler daemon started. Press Ctrl+C to stop."));
|
|
5989
|
+
console.log(chalk4.dim(` Check interval: ${opts.interval}s`));
|
|
4808
5990
|
let running = true;
|
|
4809
5991
|
const checkAndRun = async () => {
|
|
4810
5992
|
while (running) {
|
|
@@ -4813,7 +5995,7 @@ program2.command("daemon").description("Start the scheduler daemon").option("--i
|
|
|
4813
5995
|
const now2 = new Date().toISOString();
|
|
4814
5996
|
for (const schedule of schedules) {
|
|
4815
5997
|
if (schedule.nextRunAt && schedule.nextRunAt <= now2) {
|
|
4816
|
-
console.log(
|
|
5998
|
+
console.log(chalk4.blue(`[${new Date().toISOString()}] Triggering schedule: ${schedule.name}`));
|
|
4817
5999
|
try {
|
|
4818
6000
|
const { run } = await runByFilter({
|
|
4819
6001
|
url: schedule.url,
|
|
@@ -4826,35 +6008,269 @@ program2.command("daemon").description("Start the scheduler daemon").option("--i
|
|
|
4826
6008
|
timeout: schedule.timeoutMs ?? undefined,
|
|
4827
6009
|
projectId: schedule.projectId ?? undefined
|
|
4828
6010
|
});
|
|
4829
|
-
const statusColor = run.status === "passed" ?
|
|
6011
|
+
const statusColor = run.status === "passed" ? chalk4.green : chalk4.red;
|
|
4830
6012
|
console.log(` ${statusColor(run.status)} \u2014 ${run.passed}/${run.total} passed`);
|
|
4831
6013
|
updateSchedule(schedule.id, {});
|
|
4832
6014
|
} catch (err) {
|
|
4833
|
-
console.error(
|
|
6015
|
+
console.error(chalk4.red(` Error running schedule ${schedule.name}: ${err instanceof Error ? err.message : String(err)}`));
|
|
4834
6016
|
}
|
|
4835
6017
|
}
|
|
4836
6018
|
}
|
|
4837
6019
|
} catch (err) {
|
|
4838
|
-
console.error(
|
|
6020
|
+
console.error(chalk4.red(`Daemon error: ${err instanceof Error ? err.message : String(err)}`));
|
|
4839
6021
|
}
|
|
4840
6022
|
await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
|
|
4841
6023
|
}
|
|
4842
6024
|
};
|
|
4843
6025
|
process.on("SIGINT", () => {
|
|
4844
|
-
console.log(
|
|
6026
|
+
console.log(chalk4.yellow(`
|
|
4845
6027
|
Shutting down scheduler daemon...`));
|
|
4846
6028
|
running = false;
|
|
4847
6029
|
process.exit(0);
|
|
4848
6030
|
});
|
|
4849
6031
|
process.on("SIGTERM", () => {
|
|
4850
|
-
console.log(
|
|
6032
|
+
console.log(chalk4.yellow(`
|
|
4851
6033
|
Shutting down scheduler daemon...`));
|
|
4852
6034
|
running = false;
|
|
4853
6035
|
process.exit(0);
|
|
4854
6036
|
});
|
|
4855
6037
|
await checkAndRun();
|
|
4856
6038
|
} catch (error) {
|
|
4857
|
-
console.error(
|
|
6039
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6040
|
+
process.exit(1);
|
|
6041
|
+
}
|
|
6042
|
+
});
|
|
6043
|
+
program2.command("init").description("Initialize a new testing project").option("-n, --name <name>", "Project name").option("-u, --url <url>", "Base URL").option("-p, --path <path>", "Project path").action((opts) => {
|
|
6044
|
+
try {
|
|
6045
|
+
const { project, scenarios, framework } = initProject({
|
|
6046
|
+
name: opts.name,
|
|
6047
|
+
url: opts.url,
|
|
6048
|
+
path: opts.path
|
|
6049
|
+
});
|
|
6050
|
+
console.log("");
|
|
6051
|
+
console.log(chalk4.bold(" Project initialized!"));
|
|
6052
|
+
console.log("");
|
|
6053
|
+
if (framework) {
|
|
6054
|
+
console.log(` Framework: ${chalk4.cyan(framework.name)}`);
|
|
6055
|
+
if (framework.features.length > 0) {
|
|
6056
|
+
console.log(` Features: ${chalk4.dim(framework.features.join(", "))}`);
|
|
6057
|
+
}
|
|
6058
|
+
} else {
|
|
6059
|
+
console.log(` Framework: ${chalk4.dim("not detected")}`);
|
|
6060
|
+
}
|
|
6061
|
+
console.log(` Project: ${chalk4.green(project.name)} ${chalk4.dim(`(${project.id})`)}`);
|
|
6062
|
+
console.log(` Scenarios: ${chalk4.green(String(scenarios.length))} starter scenarios created`);
|
|
6063
|
+
console.log("");
|
|
6064
|
+
for (const s of scenarios) {
|
|
6065
|
+
console.log(` ${chalk4.dim(s.shortId)} ${s.name} ${chalk4.dim(`[${s.tags.join(", ")}]`)}`);
|
|
6066
|
+
}
|
|
6067
|
+
console.log("");
|
|
6068
|
+
console.log(chalk4.bold(" Next steps:"));
|
|
6069
|
+
console.log(` 1. Start your dev server`);
|
|
6070
|
+
console.log(` 2. Run ${chalk4.cyan("testers run <url>")} to execute tests`);
|
|
6071
|
+
console.log(` 3. Add more scenarios with ${chalk4.cyan("testers add <name>")}`);
|
|
6072
|
+
console.log("");
|
|
6073
|
+
} catch (error) {
|
|
6074
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6075
|
+
process.exit(1);
|
|
6076
|
+
}
|
|
6077
|
+
});
|
|
6078
|
+
program2.command("replay <run-id>").description("Re-run all scenarios from a previous run").option("-u, --url <url>", "Override URL").option("-m, --model <model>", "Override model").option("--headed", "Run headed", false).option("--json", "JSON output", false).option("--parallel <n>", "Parallel count", "1").action(async (runId, opts) => {
|
|
6079
|
+
try {
|
|
6080
|
+
const originalRun = getRun(runId);
|
|
6081
|
+
if (!originalRun) {
|
|
6082
|
+
console.error(chalk4.red(`Run not found: ${runId}`));
|
|
6083
|
+
process.exit(1);
|
|
6084
|
+
}
|
|
6085
|
+
const originalResults = getResultsByRun(originalRun.id);
|
|
6086
|
+
const scenarioIds = originalResults.map((r) => r.scenarioId);
|
|
6087
|
+
if (scenarioIds.length === 0) {
|
|
6088
|
+
console.log(chalk4.dim("No scenarios to replay."));
|
|
6089
|
+
return;
|
|
6090
|
+
}
|
|
6091
|
+
console.log(chalk4.blue(`Replaying ${scenarioIds.length} scenarios from run ${originalRun.id.slice(0, 8)}...`));
|
|
6092
|
+
const { run, results } = await runByFilter({
|
|
6093
|
+
url: opts.url ?? originalRun.url,
|
|
6094
|
+
scenarioIds,
|
|
6095
|
+
model: opts.model,
|
|
6096
|
+
headed: opts.headed,
|
|
6097
|
+
parallel: parseInt(opts.parallel, 10)
|
|
6098
|
+
});
|
|
6099
|
+
if (opts.json) {
|
|
6100
|
+
console.log(formatJSON(run, results));
|
|
6101
|
+
} else {
|
|
6102
|
+
console.log(formatTerminal(run, results));
|
|
6103
|
+
}
|
|
6104
|
+
process.exit(getExitCode(run));
|
|
6105
|
+
} catch (error) {
|
|
6106
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6107
|
+
process.exit(1);
|
|
6108
|
+
}
|
|
6109
|
+
});
|
|
6110
|
+
program2.command("retry <run-id>").description("Re-run only failed scenarios from a previous run").option("-u, --url <url>", "Override URL").option("-m, --model <model>", "Override model").option("--headed", "Run headed", false).option("--json", "JSON output", false).option("--parallel <n>", "Parallel count", "1").action(async (runId, opts) => {
|
|
6111
|
+
try {
|
|
6112
|
+
const originalRun = getRun(runId);
|
|
6113
|
+
if (!originalRun) {
|
|
6114
|
+
console.error(chalk4.red(`Run not found: ${runId}`));
|
|
6115
|
+
process.exit(1);
|
|
6116
|
+
}
|
|
6117
|
+
const originalResults = getResultsByRun(originalRun.id);
|
|
6118
|
+
const failedScenarioIds = originalResults.filter((r) => r.status === "failed" || r.status === "error").map((r) => r.scenarioId);
|
|
6119
|
+
if (failedScenarioIds.length === 0) {
|
|
6120
|
+
console.log(chalk4.green("No failed scenarios to retry. All passed!"));
|
|
6121
|
+
return;
|
|
6122
|
+
}
|
|
6123
|
+
console.log(chalk4.blue(`Retrying ${failedScenarioIds.length} failed scenarios from run ${originalRun.id.slice(0, 8)}...`));
|
|
6124
|
+
const { run, results } = await runByFilter({
|
|
6125
|
+
url: opts.url ?? originalRun.url,
|
|
6126
|
+
scenarioIds: failedScenarioIds,
|
|
6127
|
+
model: opts.model,
|
|
6128
|
+
headed: opts.headed,
|
|
6129
|
+
parallel: parseInt(opts.parallel, 10)
|
|
6130
|
+
});
|
|
6131
|
+
if (!opts.json) {
|
|
6132
|
+
console.log("");
|
|
6133
|
+
console.log(chalk4.bold(" Comparison with original run:"));
|
|
6134
|
+
for (const result of results) {
|
|
6135
|
+
const original = originalResults.find((r) => r.scenarioId === result.scenarioId);
|
|
6136
|
+
if (original) {
|
|
6137
|
+
const changed = original.status !== result.status;
|
|
6138
|
+
const arrow = changed ? chalk4.yellow(`${original.status} \u2192 ${result.status}`) : chalk4.dim(`${result.status} (unchanged)`);
|
|
6139
|
+
const icon = result.status === "passed" ? chalk4.green("\u2713") : chalk4.red("\u2717");
|
|
6140
|
+
console.log(` ${icon} ${result.scenarioId.slice(0, 8)}: ${arrow}`);
|
|
6141
|
+
}
|
|
6142
|
+
}
|
|
6143
|
+
console.log("");
|
|
6144
|
+
}
|
|
6145
|
+
if (opts.json) {
|
|
6146
|
+
console.log(formatJSON(run, results));
|
|
6147
|
+
} else {
|
|
6148
|
+
console.log(formatTerminal(run, results));
|
|
6149
|
+
}
|
|
6150
|
+
process.exit(getExitCode(run));
|
|
6151
|
+
} catch (error) {
|
|
6152
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6153
|
+
process.exit(1);
|
|
6154
|
+
}
|
|
6155
|
+
});
|
|
6156
|
+
program2.command("smoke <url>").description("Run autonomous smoke test").option("-m, --model <model>", "AI model").option("--headed", "Watch browser", false).option("--timeout <ms>", "Timeout in milliseconds").option("--json", "JSON output", false).option("--project <id>", "Project ID").action(async (url, opts) => {
|
|
6157
|
+
try {
|
|
6158
|
+
const projectId = resolveProject(opts.project);
|
|
6159
|
+
console.log(chalk4.blue(`Running smoke test against ${chalk4.bold(url)}...`));
|
|
6160
|
+
console.log("");
|
|
6161
|
+
const smokeResult = await runSmoke({
|
|
6162
|
+
url,
|
|
6163
|
+
model: opts.model,
|
|
6164
|
+
headed: opts.headed,
|
|
6165
|
+
timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
|
|
6166
|
+
projectId
|
|
6167
|
+
});
|
|
6168
|
+
if (opts.json) {
|
|
6169
|
+
console.log(JSON.stringify({
|
|
6170
|
+
run: smokeResult.run,
|
|
6171
|
+
result: smokeResult.result,
|
|
6172
|
+
pagesVisited: smokeResult.pagesVisited,
|
|
6173
|
+
issues: smokeResult.issuesFound
|
|
6174
|
+
}, null, 2));
|
|
6175
|
+
} else {
|
|
6176
|
+
console.log(formatSmokeReport(smokeResult));
|
|
6177
|
+
}
|
|
6178
|
+
const hasCritical = smokeResult.issuesFound.some((i) => i.severity === "critical" || i.severity === "high");
|
|
6179
|
+
process.exit(hasCritical ? 1 : 0);
|
|
6180
|
+
} catch (error) {
|
|
6181
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6182
|
+
process.exit(1);
|
|
6183
|
+
}
|
|
6184
|
+
});
|
|
6185
|
+
program2.command("diff <run1> <run2>").description("Compare two test runs").option("--json", "JSON output", false).action((run1, run2, opts) => {
|
|
6186
|
+
try {
|
|
6187
|
+
const diff = diffRuns(run1, run2);
|
|
6188
|
+
if (opts.json) {
|
|
6189
|
+
console.log(formatDiffJSON(diff));
|
|
6190
|
+
} else {
|
|
6191
|
+
console.log(formatDiffTerminal(diff));
|
|
6192
|
+
}
|
|
6193
|
+
process.exit(diff.regressions.length > 0 ? 1 : 0);
|
|
6194
|
+
} catch (error) {
|
|
6195
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6196
|
+
process.exit(1);
|
|
6197
|
+
}
|
|
6198
|
+
});
|
|
6199
|
+
program2.command("report [run-id]").description("Generate HTML test report").option("--latest", "Use most recent run", false).option("-o, --output <file>", "Output file path", "report.html").action((runId, opts) => {
|
|
6200
|
+
try {
|
|
6201
|
+
let html;
|
|
6202
|
+
if (opts.latest || !runId) {
|
|
6203
|
+
html = generateLatestReport();
|
|
6204
|
+
} else {
|
|
6205
|
+
html = generateHtmlReport(runId);
|
|
6206
|
+
}
|
|
6207
|
+
writeFileSync3(opts.output, html, "utf-8");
|
|
6208
|
+
console.log(chalk4.green(`Report generated: ${opts.output}`));
|
|
6209
|
+
} catch (error) {
|
|
6210
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6211
|
+
process.exit(1);
|
|
6212
|
+
}
|
|
6213
|
+
});
|
|
6214
|
+
var authCmd = program2.command("auth").description("Manage auth presets");
|
|
6215
|
+
authCmd.command("add <name>").description("Create an auth preset").requiredOption("--email <email>", "Login email").requiredOption("--password <password>", "Login password").option("--login-path <path>", "Login page path", "/login").action((name, opts) => {
|
|
6216
|
+
try {
|
|
6217
|
+
const preset = createAuthPreset({
|
|
6218
|
+
name,
|
|
6219
|
+
email: opts.email,
|
|
6220
|
+
password: opts.password,
|
|
6221
|
+
loginPath: opts.loginPath
|
|
6222
|
+
});
|
|
6223
|
+
console.log(chalk4.green(`Created auth preset ${chalk4.bold(preset.name)} (${preset.email})`));
|
|
6224
|
+
} catch (error) {
|
|
6225
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6226
|
+
process.exit(1);
|
|
6227
|
+
}
|
|
6228
|
+
});
|
|
6229
|
+
authCmd.command("list").description("List auth presets").action(() => {
|
|
6230
|
+
try {
|
|
6231
|
+
const presets = listAuthPresets();
|
|
6232
|
+
if (presets.length === 0) {
|
|
6233
|
+
console.log(chalk4.dim("No auth presets found."));
|
|
6234
|
+
return;
|
|
6235
|
+
}
|
|
6236
|
+
console.log("");
|
|
6237
|
+
console.log(chalk4.bold(" Auth Presets"));
|
|
6238
|
+
console.log("");
|
|
6239
|
+
console.log(` ${"Name".padEnd(20)} ${"Email".padEnd(30)} ${"Login Path".padEnd(15)} Created`);
|
|
6240
|
+
console.log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(30)} ${"\u2500".repeat(15)} ${"\u2500".repeat(22)}`);
|
|
6241
|
+
for (const p of presets) {
|
|
6242
|
+
console.log(` ${p.name.padEnd(20)} ${p.email.padEnd(30)} ${p.loginPath.padEnd(15)} ${p.createdAt}`);
|
|
6243
|
+
}
|
|
6244
|
+
console.log("");
|
|
6245
|
+
} catch (error) {
|
|
6246
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6247
|
+
process.exit(1);
|
|
6248
|
+
}
|
|
6249
|
+
});
|
|
6250
|
+
authCmd.command("delete <name>").description("Delete an auth preset").action((name) => {
|
|
6251
|
+
try {
|
|
6252
|
+
const deleted = deleteAuthPreset(name);
|
|
6253
|
+
if (deleted) {
|
|
6254
|
+
console.log(chalk4.green(`Deleted auth preset: ${name}`));
|
|
6255
|
+
} else {
|
|
6256
|
+
console.error(chalk4.red(`Auth preset not found: ${name}`));
|
|
6257
|
+
process.exit(1);
|
|
6258
|
+
}
|
|
6259
|
+
} catch (error) {
|
|
6260
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6261
|
+
process.exit(1);
|
|
6262
|
+
}
|
|
6263
|
+
});
|
|
6264
|
+
program2.command("costs").description("Show cost tracking and budget status").option("--project <id>", "Project ID").option("--period <period>", "Time period", "month").option("--json", "JSON output", false).action((opts) => {
|
|
6265
|
+
try {
|
|
6266
|
+
const summary = getCostSummary({ projectId: resolveProject(opts.project), period: opts.period });
|
|
6267
|
+
if (opts.json) {
|
|
6268
|
+
console.log(formatCostsJSON(summary));
|
|
6269
|
+
} else {
|
|
6270
|
+
console.log(formatCostsTerminal(summary));
|
|
6271
|
+
}
|
|
6272
|
+
} catch (error) {
|
|
6273
|
+
console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4858
6274
|
process.exit(1);
|
|
4859
6275
|
}
|
|
4860
6276
|
});
|