@hasna/testers 0.0.1 → 0.0.2
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/dist/cli/index.js +762 -13
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/schedules.d.ts +9 -0
- package/dist/db/schedules.d.ts.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +651 -7
- package/dist/lib/ai-client.d.ts.map +1 -1
- package/dist/lib/scheduler.d.ts +71 -0
- package/dist/lib/scheduler.d.ts.map +1 -0
- package/dist/mcp/index.js +701 -7
- package/dist/server/index.js +680 -7
- package/dist/types/index.d.ts +80 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -94,6 +94,26 @@ function screenshotFromRow(row) {
|
|
|
94
94
|
timestamp: row.timestamp
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
|
+
function scheduleFromRow(row) {
|
|
98
|
+
return {
|
|
99
|
+
id: row.id,
|
|
100
|
+
projectId: row.project_id,
|
|
101
|
+
name: row.name,
|
|
102
|
+
cronExpression: row.cron_expression,
|
|
103
|
+
url: row.url,
|
|
104
|
+
scenarioFilter: JSON.parse(row.scenario_filter),
|
|
105
|
+
model: row.model,
|
|
106
|
+
headed: row.headed === 1,
|
|
107
|
+
parallel: row.parallel,
|
|
108
|
+
timeoutMs: row.timeout_ms,
|
|
109
|
+
enabled: row.enabled === 1,
|
|
110
|
+
lastRunId: row.last_run_id,
|
|
111
|
+
lastRunAt: row.last_run_at,
|
|
112
|
+
nextRunAt: row.next_run_at,
|
|
113
|
+
createdAt: row.created_at,
|
|
114
|
+
updatedAt: row.updated_at
|
|
115
|
+
};
|
|
116
|
+
}
|
|
97
117
|
|
|
98
118
|
class ScenarioNotFoundError extends Error {
|
|
99
119
|
constructor(id) {
|
|
@@ -157,6 +177,13 @@ class AgentNotFoundError extends Error {
|
|
|
157
177
|
this.name = "AgentNotFoundError";
|
|
158
178
|
}
|
|
159
179
|
}
|
|
180
|
+
|
|
181
|
+
class ScheduleNotFoundError extends Error {
|
|
182
|
+
constructor(id) {
|
|
183
|
+
super(`Schedule not found: ${id}`);
|
|
184
|
+
this.name = "ScheduleNotFoundError";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
160
187
|
// src/db/database.ts
|
|
161
188
|
import { Database } from "bun:sqlite";
|
|
162
189
|
import { mkdirSync, existsSync } from "fs";
|
|
@@ -285,6 +312,30 @@ var MIGRATIONS = [
|
|
|
285
312
|
`
|
|
286
313
|
ALTER TABLE projects ADD COLUMN scenario_prefix TEXT DEFAULT 'TST';
|
|
287
314
|
ALTER TABLE projects ADD COLUMN scenario_counter INTEGER DEFAULT 0;
|
|
315
|
+
`,
|
|
316
|
+
`
|
|
317
|
+
CREATE TABLE IF NOT EXISTS schedules (
|
|
318
|
+
id TEXT PRIMARY KEY,
|
|
319
|
+
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
320
|
+
name TEXT NOT NULL,
|
|
321
|
+
cron_expression TEXT NOT NULL,
|
|
322
|
+
url TEXT NOT NULL,
|
|
323
|
+
scenario_filter TEXT NOT NULL DEFAULT '{}',
|
|
324
|
+
model TEXT,
|
|
325
|
+
headed INTEGER NOT NULL DEFAULT 0,
|
|
326
|
+
parallel INTEGER NOT NULL DEFAULT 1,
|
|
327
|
+
timeout_ms INTEGER,
|
|
328
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
329
|
+
last_run_id TEXT REFERENCES runs(id) ON DELETE SET NULL,
|
|
330
|
+
last_run_at TEXT,
|
|
331
|
+
next_run_at TEXT,
|
|
332
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
333
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
CREATE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
|
|
337
|
+
CREATE INDEX IF NOT EXISTS idx_schedules_enabled ON schedules(enabled);
|
|
338
|
+
CREATE INDEX IF NOT EXISTS idx_schedules_next_run ON schedules(next_run_at);
|
|
288
339
|
`
|
|
289
340
|
];
|
|
290
341
|
function applyMigrations(database) {
|
|
@@ -331,6 +382,7 @@ function resetDatabase() {
|
|
|
331
382
|
const database = getDatabase();
|
|
332
383
|
database.exec("DELETE FROM screenshots");
|
|
333
384
|
database.exec("DELETE FROM results");
|
|
385
|
+
database.exec("DELETE FROM schedules");
|
|
334
386
|
database.exec("DELETE FROM runs");
|
|
335
387
|
database.exec("DELETE FROM scenarios");
|
|
336
388
|
database.exec("DELETE FROM agents");
|
|
@@ -790,6 +842,131 @@ function listAgents() {
|
|
|
790
842
|
const rows = db2.query("SELECT * FROM agents ORDER BY created_at DESC").all();
|
|
791
843
|
return rows.map(agentFromRow);
|
|
792
844
|
}
|
|
845
|
+
// src/db/schedules.ts
|
|
846
|
+
function createSchedule(input) {
|
|
847
|
+
const db2 = getDatabase();
|
|
848
|
+
const id = uuid();
|
|
849
|
+
const timestamp = now();
|
|
850
|
+
db2.query(`
|
|
851
|
+
INSERT INTO schedules (id, project_id, name, cron_expression, url, scenario_filter, model, headed, parallel, timeout_ms, enabled, created_at, updated_at)
|
|
852
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
|
853
|
+
`).run(id, input.projectId ?? null, input.name, input.cronExpression, input.url, JSON.stringify(input.scenarioFilter ?? {}), input.model ?? null, input.headed ? 1 : 0, input.parallel ?? 1, input.timeoutMs ?? null, timestamp, timestamp);
|
|
854
|
+
return getSchedule(id);
|
|
855
|
+
}
|
|
856
|
+
function getSchedule(id) {
|
|
857
|
+
const db2 = getDatabase();
|
|
858
|
+
let row = db2.query("SELECT * FROM schedules WHERE id = ?").get(id);
|
|
859
|
+
if (row)
|
|
860
|
+
return scheduleFromRow(row);
|
|
861
|
+
const fullId = resolvePartialId("schedules", id);
|
|
862
|
+
if (fullId) {
|
|
863
|
+
row = db2.query("SELECT * FROM schedules WHERE id = ?").get(fullId);
|
|
864
|
+
if (row)
|
|
865
|
+
return scheduleFromRow(row);
|
|
866
|
+
}
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
function listSchedules(filter) {
|
|
870
|
+
const db2 = getDatabase();
|
|
871
|
+
const conditions = [];
|
|
872
|
+
const params = [];
|
|
873
|
+
if (filter?.projectId) {
|
|
874
|
+
conditions.push("project_id = ?");
|
|
875
|
+
params.push(filter.projectId);
|
|
876
|
+
}
|
|
877
|
+
if (filter?.enabled !== undefined) {
|
|
878
|
+
conditions.push("enabled = ?");
|
|
879
|
+
params.push(filter.enabled ? 1 : 0);
|
|
880
|
+
}
|
|
881
|
+
let sql = "SELECT * FROM schedules";
|
|
882
|
+
if (conditions.length > 0) {
|
|
883
|
+
sql += " WHERE " + conditions.join(" AND ");
|
|
884
|
+
}
|
|
885
|
+
sql += " ORDER BY created_at DESC";
|
|
886
|
+
if (filter?.limit) {
|
|
887
|
+
sql += " LIMIT ?";
|
|
888
|
+
params.push(filter.limit);
|
|
889
|
+
}
|
|
890
|
+
if (filter?.offset) {
|
|
891
|
+
sql += " OFFSET ?";
|
|
892
|
+
params.push(filter.offset);
|
|
893
|
+
}
|
|
894
|
+
const rows = db2.query(sql).all(...params);
|
|
895
|
+
return rows.map(scheduleFromRow);
|
|
896
|
+
}
|
|
897
|
+
function updateSchedule(id, input) {
|
|
898
|
+
const db2 = getDatabase();
|
|
899
|
+
const existing = getSchedule(id);
|
|
900
|
+
if (!existing) {
|
|
901
|
+
throw new ScheduleNotFoundError(id);
|
|
902
|
+
}
|
|
903
|
+
const sets = [];
|
|
904
|
+
const params = [];
|
|
905
|
+
if (input.name !== undefined) {
|
|
906
|
+
sets.push("name = ?");
|
|
907
|
+
params.push(input.name);
|
|
908
|
+
}
|
|
909
|
+
if (input.cronExpression !== undefined) {
|
|
910
|
+
sets.push("cron_expression = ?");
|
|
911
|
+
params.push(input.cronExpression);
|
|
912
|
+
}
|
|
913
|
+
if (input.url !== undefined) {
|
|
914
|
+
sets.push("url = ?");
|
|
915
|
+
params.push(input.url);
|
|
916
|
+
}
|
|
917
|
+
if (input.scenarioFilter !== undefined) {
|
|
918
|
+
sets.push("scenario_filter = ?");
|
|
919
|
+
params.push(JSON.stringify(input.scenarioFilter));
|
|
920
|
+
}
|
|
921
|
+
if (input.model !== undefined) {
|
|
922
|
+
sets.push("model = ?");
|
|
923
|
+
params.push(input.model);
|
|
924
|
+
}
|
|
925
|
+
if (input.headed !== undefined) {
|
|
926
|
+
sets.push("headed = ?");
|
|
927
|
+
params.push(input.headed ? 1 : 0);
|
|
928
|
+
}
|
|
929
|
+
if (input.parallel !== undefined) {
|
|
930
|
+
sets.push("parallel = ?");
|
|
931
|
+
params.push(input.parallel);
|
|
932
|
+
}
|
|
933
|
+
if (input.timeoutMs !== undefined) {
|
|
934
|
+
sets.push("timeout_ms = ?");
|
|
935
|
+
params.push(input.timeoutMs);
|
|
936
|
+
}
|
|
937
|
+
if (input.enabled !== undefined) {
|
|
938
|
+
sets.push("enabled = ?");
|
|
939
|
+
params.push(input.enabled ? 1 : 0);
|
|
940
|
+
}
|
|
941
|
+
if (sets.length === 0) {
|
|
942
|
+
return existing;
|
|
943
|
+
}
|
|
944
|
+
sets.push("updated_at = ?");
|
|
945
|
+
params.push(now());
|
|
946
|
+
params.push(existing.id);
|
|
947
|
+
db2.query(`UPDATE schedules SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
948
|
+
return getSchedule(existing.id);
|
|
949
|
+
}
|
|
950
|
+
function deleteSchedule(id) {
|
|
951
|
+
const db2 = getDatabase();
|
|
952
|
+
const schedule = getSchedule(id);
|
|
953
|
+
if (!schedule)
|
|
954
|
+
return false;
|
|
955
|
+
const result = db2.query("DELETE FROM schedules WHERE id = ?").run(schedule.id);
|
|
956
|
+
return result.changes > 0;
|
|
957
|
+
}
|
|
958
|
+
function getEnabledSchedules() {
|
|
959
|
+
const db2 = getDatabase();
|
|
960
|
+
const rows = db2.query("SELECT * FROM schedules WHERE enabled = 1 ORDER BY created_at DESC").all();
|
|
961
|
+
return rows.map(scheduleFromRow);
|
|
962
|
+
}
|
|
963
|
+
function updateLastRun(id, runId, nextRunAt) {
|
|
964
|
+
const db2 = getDatabase();
|
|
965
|
+
const timestamp = now();
|
|
966
|
+
db2.query(`
|
|
967
|
+
UPDATE schedules SET last_run_id = ?, last_run_at = ?, next_run_at = ?, updated_at = ? WHERE id = ?
|
|
968
|
+
`).run(runId, timestamp, nextRunAt, timestamp, id);
|
|
969
|
+
}
|
|
793
970
|
// src/lib/config.ts
|
|
794
971
|
import { homedir as homedir2 } from "os";
|
|
795
972
|
import { join as join2 } from "path";
|
|
@@ -1215,6 +1392,127 @@ var BROWSER_TOOLS = [
|
|
|
1215
1392
|
required: ["text"]
|
|
1216
1393
|
}
|
|
1217
1394
|
},
|
|
1395
|
+
{
|
|
1396
|
+
name: "scroll",
|
|
1397
|
+
description: "Scroll the page up or down by a given amount of pixels.",
|
|
1398
|
+
input_schema: {
|
|
1399
|
+
type: "object",
|
|
1400
|
+
properties: {
|
|
1401
|
+
direction: {
|
|
1402
|
+
type: "string",
|
|
1403
|
+
enum: ["up", "down"],
|
|
1404
|
+
description: "Direction to scroll."
|
|
1405
|
+
},
|
|
1406
|
+
amount: {
|
|
1407
|
+
type: "number",
|
|
1408
|
+
description: "Number of pixels to scroll (default: 500)."
|
|
1409
|
+
}
|
|
1410
|
+
},
|
|
1411
|
+
required: ["direction"]
|
|
1412
|
+
}
|
|
1413
|
+
},
|
|
1414
|
+
{
|
|
1415
|
+
name: "get_page_html",
|
|
1416
|
+
description: "Get simplified HTML of the page body content, truncated to 8000 characters.",
|
|
1417
|
+
input_schema: {
|
|
1418
|
+
type: "object",
|
|
1419
|
+
properties: {},
|
|
1420
|
+
required: []
|
|
1421
|
+
}
|
|
1422
|
+
},
|
|
1423
|
+
{
|
|
1424
|
+
name: "get_elements",
|
|
1425
|
+
description: "List elements matching a CSS selector with their text, tag name, and key attributes (max 20 results).",
|
|
1426
|
+
input_schema: {
|
|
1427
|
+
type: "object",
|
|
1428
|
+
properties: {
|
|
1429
|
+
selector: {
|
|
1430
|
+
type: "string",
|
|
1431
|
+
description: "CSS selector to match elements."
|
|
1432
|
+
}
|
|
1433
|
+
},
|
|
1434
|
+
required: ["selector"]
|
|
1435
|
+
}
|
|
1436
|
+
},
|
|
1437
|
+
{
|
|
1438
|
+
name: "wait_for_navigation",
|
|
1439
|
+
description: "Wait for page navigation/load to complete (network idle).",
|
|
1440
|
+
input_schema: {
|
|
1441
|
+
type: "object",
|
|
1442
|
+
properties: {
|
|
1443
|
+
timeout: {
|
|
1444
|
+
type: "number",
|
|
1445
|
+
description: "Maximum time to wait in milliseconds (default: 10000)."
|
|
1446
|
+
}
|
|
1447
|
+
},
|
|
1448
|
+
required: []
|
|
1449
|
+
}
|
|
1450
|
+
},
|
|
1451
|
+
{
|
|
1452
|
+
name: "get_page_title",
|
|
1453
|
+
description: "Get the document title of the current page.",
|
|
1454
|
+
input_schema: {
|
|
1455
|
+
type: "object",
|
|
1456
|
+
properties: {},
|
|
1457
|
+
required: []
|
|
1458
|
+
}
|
|
1459
|
+
},
|
|
1460
|
+
{
|
|
1461
|
+
name: "count_elements",
|
|
1462
|
+
description: "Count the number of elements matching a CSS selector.",
|
|
1463
|
+
input_schema: {
|
|
1464
|
+
type: "object",
|
|
1465
|
+
properties: {
|
|
1466
|
+
selector: {
|
|
1467
|
+
type: "string",
|
|
1468
|
+
description: "CSS selector to count matching elements."
|
|
1469
|
+
}
|
|
1470
|
+
},
|
|
1471
|
+
required: ["selector"]
|
|
1472
|
+
}
|
|
1473
|
+
},
|
|
1474
|
+
{
|
|
1475
|
+
name: "hover",
|
|
1476
|
+
description: "Hover over an element matching the given CSS selector.",
|
|
1477
|
+
input_schema: {
|
|
1478
|
+
type: "object",
|
|
1479
|
+
properties: {
|
|
1480
|
+
selector: {
|
|
1481
|
+
type: "string",
|
|
1482
|
+
description: "CSS selector of the element to hover over."
|
|
1483
|
+
}
|
|
1484
|
+
},
|
|
1485
|
+
required: ["selector"]
|
|
1486
|
+
}
|
|
1487
|
+
},
|
|
1488
|
+
{
|
|
1489
|
+
name: "check",
|
|
1490
|
+
description: "Check a checkbox matching the given CSS selector.",
|
|
1491
|
+
input_schema: {
|
|
1492
|
+
type: "object",
|
|
1493
|
+
properties: {
|
|
1494
|
+
selector: {
|
|
1495
|
+
type: "string",
|
|
1496
|
+
description: "CSS selector of the checkbox to check."
|
|
1497
|
+
}
|
|
1498
|
+
},
|
|
1499
|
+
required: ["selector"]
|
|
1500
|
+
}
|
|
1501
|
+
},
|
|
1502
|
+
{
|
|
1503
|
+
name: "uncheck",
|
|
1504
|
+
description: "Uncheck a checkbox matching the given CSS selector.",
|
|
1505
|
+
input_schema: {
|
|
1506
|
+
type: "object",
|
|
1507
|
+
properties: {
|
|
1508
|
+
selector: {
|
|
1509
|
+
type: "string",
|
|
1510
|
+
description: "CSS selector of the checkbox to uncheck."
|
|
1511
|
+
}
|
|
1512
|
+
},
|
|
1513
|
+
required: ["selector"]
|
|
1514
|
+
}
|
|
1515
|
+
},
|
|
1218
1516
|
{
|
|
1219
1517
|
name: "report_result",
|
|
1220
1518
|
description: "Report the final test result. Call this when you have completed testing the scenario. This MUST be the last tool you call.",
|
|
@@ -1346,6 +1644,113 @@ async function executeTool(page, screenshotter, toolName, toolInput, context) {
|
|
|
1346
1644
|
return { result: "false" };
|
|
1347
1645
|
}
|
|
1348
1646
|
}
|
|
1647
|
+
case "scroll": {
|
|
1648
|
+
const direction = toolInput.direction;
|
|
1649
|
+
const amount = typeof toolInput.amount === "number" ? toolInput.amount : 500;
|
|
1650
|
+
const scrollY = direction === "down" ? amount : -amount;
|
|
1651
|
+
await page.evaluate((y) => window.scrollBy(0, y), scrollY);
|
|
1652
|
+
const screenshot = await screenshotter.capture(page, {
|
|
1653
|
+
runId: context.runId,
|
|
1654
|
+
scenarioSlug: context.scenarioSlug,
|
|
1655
|
+
stepNumber: context.stepNumber,
|
|
1656
|
+
action: "scroll"
|
|
1657
|
+
});
|
|
1658
|
+
return {
|
|
1659
|
+
result: `Scrolled ${direction} by ${amount}px`,
|
|
1660
|
+
screenshot
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
case "get_page_html": {
|
|
1664
|
+
const html = await page.evaluate(() => document.body.innerHTML);
|
|
1665
|
+
const truncated = html.length > 8000 ? html.slice(0, 8000) + "..." : html;
|
|
1666
|
+
return {
|
|
1667
|
+
result: truncated
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
case "get_elements": {
|
|
1671
|
+
const selector = toolInput.selector;
|
|
1672
|
+
const allElements = await page.locator(selector).all();
|
|
1673
|
+
const elements = allElements.slice(0, 20);
|
|
1674
|
+
const results = [];
|
|
1675
|
+
for (let i = 0;i < elements.length; i++) {
|
|
1676
|
+
const el = elements[i];
|
|
1677
|
+
const tagName = await el.evaluate((e) => e.tagName.toLowerCase());
|
|
1678
|
+
const textContent = await el.textContent() ?? "";
|
|
1679
|
+
const trimmedText = textContent.trim().slice(0, 100);
|
|
1680
|
+
const id = await el.getAttribute("id");
|
|
1681
|
+
const className = await el.getAttribute("class");
|
|
1682
|
+
const href = await el.getAttribute("href");
|
|
1683
|
+
const type = await el.getAttribute("type");
|
|
1684
|
+
const placeholder = await el.getAttribute("placeholder");
|
|
1685
|
+
const ariaLabel = await el.getAttribute("aria-label");
|
|
1686
|
+
const attrs = [];
|
|
1687
|
+
if (id)
|
|
1688
|
+
attrs.push(`id="${id}"`);
|
|
1689
|
+
if (className)
|
|
1690
|
+
attrs.push(`class="${className}"`);
|
|
1691
|
+
if (href)
|
|
1692
|
+
attrs.push(`href="${href}"`);
|
|
1693
|
+
if (type)
|
|
1694
|
+
attrs.push(`type="${type}"`);
|
|
1695
|
+
if (placeholder)
|
|
1696
|
+
attrs.push(`placeholder="${placeholder}"`);
|
|
1697
|
+
if (ariaLabel)
|
|
1698
|
+
attrs.push(`aria-label="${ariaLabel}"`);
|
|
1699
|
+
results.push(`[${i}] <${tagName}${attrs.length ? " " + attrs.join(" ") : ""}> ${trimmedText}`);
|
|
1700
|
+
}
|
|
1701
|
+
return {
|
|
1702
|
+
result: results.length > 0 ? results.join(`
|
|
1703
|
+
`) : `No elements found matching "${selector}"`
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
case "wait_for_navigation": {
|
|
1707
|
+
const timeout = typeof toolInput.timeout === "number" ? toolInput.timeout : 1e4;
|
|
1708
|
+
await page.waitForLoadState("networkidle", { timeout });
|
|
1709
|
+
return {
|
|
1710
|
+
result: "Navigation/load completed"
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
case "get_page_title": {
|
|
1714
|
+
const title = await page.title();
|
|
1715
|
+
return {
|
|
1716
|
+
result: title || "(no title)"
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
case "count_elements": {
|
|
1720
|
+
const selector = toolInput.selector;
|
|
1721
|
+
const count = await page.locator(selector).count();
|
|
1722
|
+
return {
|
|
1723
|
+
result: `${count} element(s) matching "${selector}"`
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
case "hover": {
|
|
1727
|
+
const selector = toolInput.selector;
|
|
1728
|
+
await page.hover(selector);
|
|
1729
|
+
const screenshot = await screenshotter.capture(page, {
|
|
1730
|
+
runId: context.runId,
|
|
1731
|
+
scenarioSlug: context.scenarioSlug,
|
|
1732
|
+
stepNumber: context.stepNumber,
|
|
1733
|
+
action: "hover"
|
|
1734
|
+
});
|
|
1735
|
+
return {
|
|
1736
|
+
result: `Hovered over: ${selector}`,
|
|
1737
|
+
screenshot
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
case "check": {
|
|
1741
|
+
const selector = toolInput.selector;
|
|
1742
|
+
await page.check(selector);
|
|
1743
|
+
return {
|
|
1744
|
+
result: `Checked checkbox: ${selector}`
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
case "uncheck": {
|
|
1748
|
+
const selector = toolInput.selector;
|
|
1749
|
+
await page.uncheck(selector);
|
|
1750
|
+
return {
|
|
1751
|
+
result: `Unchecked checkbox: ${selector}`
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1349
1754
|
case "report_result": {
|
|
1350
1755
|
const status = toolInput.status;
|
|
1351
1756
|
const reasoning = toolInput.reasoning;
|
|
@@ -1372,13 +1777,26 @@ async function runAgentLoop(options) {
|
|
|
1372
1777
|
maxTurns = 30
|
|
1373
1778
|
} = options;
|
|
1374
1779
|
const systemPrompt = [
|
|
1375
|
-
"You are
|
|
1376
|
-
"
|
|
1377
|
-
"
|
|
1378
|
-
"
|
|
1379
|
-
"
|
|
1380
|
-
"
|
|
1381
|
-
|
|
1780
|
+
"You are an expert QA testing agent. Your job is to thoroughly test web application scenarios.",
|
|
1781
|
+
"You have browser tools to navigate, interact with, and inspect web pages.",
|
|
1782
|
+
"",
|
|
1783
|
+
"Strategy:",
|
|
1784
|
+
"1. First navigate to the target page and take a screenshot to understand the layout",
|
|
1785
|
+
"2. If you can't find an element, use get_elements or get_page_html to discover selectors",
|
|
1786
|
+
"3. Use scroll to discover content below the fold",
|
|
1787
|
+
"4. Use wait_for or wait_for_navigation after actions that trigger page loads",
|
|
1788
|
+
"5. Take screenshots after every meaningful state change",
|
|
1789
|
+
"6. Use assert_text and assert_visible to verify expected outcomes",
|
|
1790
|
+
"7. When done testing, call report_result with detailed pass/fail reasoning",
|
|
1791
|
+
"",
|
|
1792
|
+
"Tips:",
|
|
1793
|
+
"- Try multiple selector strategies: by text, by role, by class, by id",
|
|
1794
|
+
"- If a click triggers navigation, use wait_for_navigation after",
|
|
1795
|
+
"- For forms, fill all fields before submitting",
|
|
1796
|
+
"- Check for error messages after form submissions",
|
|
1797
|
+
"- Verify both positive and negative states"
|
|
1798
|
+
].join(`
|
|
1799
|
+
`);
|
|
1382
1800
|
const userParts = [
|
|
1383
1801
|
`**Scenario:** ${scenario.name}`,
|
|
1384
1802
|
`**Description:** ${scenario.description}`
|
|
@@ -2425,15 +2843,231 @@ function markTodoDone(taskId) {
|
|
|
2425
2843
|
db2.close();
|
|
2426
2844
|
}
|
|
2427
2845
|
}
|
|
2846
|
+
// src/lib/scheduler.ts
|
|
2847
|
+
function parseCronField(field, min, max) {
|
|
2848
|
+
const results = new Set;
|
|
2849
|
+
const parts = field.split(",");
|
|
2850
|
+
for (const part of parts) {
|
|
2851
|
+
const trimmed = part.trim();
|
|
2852
|
+
if (trimmed.includes("/")) {
|
|
2853
|
+
const slashParts = trimmed.split("/");
|
|
2854
|
+
const rangePart = slashParts[0] ?? "*";
|
|
2855
|
+
const stepStr = slashParts[1] ?? "1";
|
|
2856
|
+
const step = parseInt(stepStr, 10);
|
|
2857
|
+
if (isNaN(step) || step <= 0) {
|
|
2858
|
+
throw new Error(`Invalid step value in cron field: ${field}`);
|
|
2859
|
+
}
|
|
2860
|
+
let start;
|
|
2861
|
+
let end;
|
|
2862
|
+
if (rangePart === "*") {
|
|
2863
|
+
start = min;
|
|
2864
|
+
end = max;
|
|
2865
|
+
} else if (rangePart.includes("-")) {
|
|
2866
|
+
const dashParts = rangePart.split("-");
|
|
2867
|
+
start = parseInt(dashParts[0] ?? "0", 10);
|
|
2868
|
+
end = parseInt(dashParts[1] ?? "0", 10);
|
|
2869
|
+
} else {
|
|
2870
|
+
start = parseInt(rangePart, 10);
|
|
2871
|
+
end = max;
|
|
2872
|
+
}
|
|
2873
|
+
for (let i = start;i <= end; i += step) {
|
|
2874
|
+
if (i >= min && i <= max)
|
|
2875
|
+
results.add(i);
|
|
2876
|
+
}
|
|
2877
|
+
} else if (trimmed === "*") {
|
|
2878
|
+
for (let i = min;i <= max; i++) {
|
|
2879
|
+
results.add(i);
|
|
2880
|
+
}
|
|
2881
|
+
} else if (trimmed.includes("-")) {
|
|
2882
|
+
const dashParts = trimmed.split("-");
|
|
2883
|
+
const lo = parseInt(dashParts[0] ?? "0", 10);
|
|
2884
|
+
const hi = parseInt(dashParts[1] ?? "0", 10);
|
|
2885
|
+
if (isNaN(lo) || isNaN(hi)) {
|
|
2886
|
+
throw new Error(`Invalid range in cron field: ${field}`);
|
|
2887
|
+
}
|
|
2888
|
+
for (let i = lo;i <= hi; i++) {
|
|
2889
|
+
if (i >= min && i <= max)
|
|
2890
|
+
results.add(i);
|
|
2891
|
+
}
|
|
2892
|
+
} else {
|
|
2893
|
+
const val = parseInt(trimmed, 10);
|
|
2894
|
+
if (isNaN(val)) {
|
|
2895
|
+
throw new Error(`Invalid value in cron field: ${field}`);
|
|
2896
|
+
}
|
|
2897
|
+
if (val >= min && val <= max)
|
|
2898
|
+
results.add(val);
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
return Array.from(results).sort((a, b) => a - b);
|
|
2902
|
+
}
|
|
2903
|
+
function parseCron(expression) {
|
|
2904
|
+
const fields = expression.trim().split(/\s+/);
|
|
2905
|
+
if (fields.length !== 5) {
|
|
2906
|
+
throw new Error(`Invalid cron expression "${expression}": expected 5 fields, got ${fields.length}`);
|
|
2907
|
+
}
|
|
2908
|
+
return {
|
|
2909
|
+
minutes: parseCronField(fields[0], 0, 59),
|
|
2910
|
+
hours: parseCronField(fields[1], 0, 23),
|
|
2911
|
+
daysOfMonth: parseCronField(fields[2], 1, 31),
|
|
2912
|
+
months: parseCronField(fields[3], 1, 12),
|
|
2913
|
+
daysOfWeek: parseCronField(fields[4], 0, 6)
|
|
2914
|
+
};
|
|
2915
|
+
}
|
|
2916
|
+
function shouldRunAt(cronExpression, date) {
|
|
2917
|
+
const cron = parseCron(cronExpression);
|
|
2918
|
+
const minute = date.getMinutes();
|
|
2919
|
+
const hour = date.getHours();
|
|
2920
|
+
const dayOfMonth = date.getDate();
|
|
2921
|
+
const month = date.getMonth() + 1;
|
|
2922
|
+
const dayOfWeek = date.getDay();
|
|
2923
|
+
return cron.minutes.includes(minute) && cron.hours.includes(hour) && cron.daysOfMonth.includes(dayOfMonth) && cron.months.includes(month) && cron.daysOfWeek.includes(dayOfWeek);
|
|
2924
|
+
}
|
|
2925
|
+
function getNextRunTime(cronExpression, after) {
|
|
2926
|
+
parseCron(cronExpression);
|
|
2927
|
+
const start = after ? new Date(after.getTime()) : new Date;
|
|
2928
|
+
start.setSeconds(0, 0);
|
|
2929
|
+
start.setMinutes(start.getMinutes() + 1);
|
|
2930
|
+
const maxDate = new Date(start.getTime() + 366 * 24 * 60 * 60 * 1000);
|
|
2931
|
+
const cursor = new Date(start.getTime());
|
|
2932
|
+
while (cursor.getTime() <= maxDate.getTime()) {
|
|
2933
|
+
if (shouldRunAt(cronExpression, cursor)) {
|
|
2934
|
+
return cursor;
|
|
2935
|
+
}
|
|
2936
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
2937
|
+
}
|
|
2938
|
+
throw new Error(`No matching time found for cron expression "${cronExpression}" within 366 days`);
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
class Scheduler {
|
|
2942
|
+
interval = null;
|
|
2943
|
+
running = new Set;
|
|
2944
|
+
checkIntervalMs;
|
|
2945
|
+
onEvent;
|
|
2946
|
+
constructor(options) {
|
|
2947
|
+
this.checkIntervalMs = options?.checkIntervalMs ?? 60000;
|
|
2948
|
+
this.onEvent = options?.onEvent;
|
|
2949
|
+
}
|
|
2950
|
+
start() {
|
|
2951
|
+
if (this.interval)
|
|
2952
|
+
return;
|
|
2953
|
+
this.tick().catch(() => {});
|
|
2954
|
+
this.interval = setInterval(() => {
|
|
2955
|
+
this.tick().catch(() => {});
|
|
2956
|
+
}, this.checkIntervalMs);
|
|
2957
|
+
}
|
|
2958
|
+
stop() {
|
|
2959
|
+
if (this.interval) {
|
|
2960
|
+
clearInterval(this.interval);
|
|
2961
|
+
this.interval = null;
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
async tick() {
|
|
2965
|
+
const now2 = new Date;
|
|
2966
|
+
now2.setSeconds(0, 0);
|
|
2967
|
+
const schedules = getEnabledSchedules();
|
|
2968
|
+
for (const schedule of schedules) {
|
|
2969
|
+
if (this.running.has(schedule.id))
|
|
2970
|
+
continue;
|
|
2971
|
+
if (shouldRunAt(schedule.cronExpression, now2)) {
|
|
2972
|
+
this.running.add(schedule.id);
|
|
2973
|
+
this.emit({
|
|
2974
|
+
type: "schedule:triggered",
|
|
2975
|
+
scheduleId: schedule.id,
|
|
2976
|
+
scheduleName: schedule.name,
|
|
2977
|
+
timestamp: new Date().toISOString()
|
|
2978
|
+
});
|
|
2979
|
+
this.executeSchedule(schedule).then(({ runId }) => {
|
|
2980
|
+
const nextRun = getNextRunTime(schedule.cronExpression, new Date);
|
|
2981
|
+
updateLastRun(schedule.id, runId, nextRun.toISOString());
|
|
2982
|
+
this.emit({
|
|
2983
|
+
type: "schedule:completed",
|
|
2984
|
+
scheduleId: schedule.id,
|
|
2985
|
+
scheduleName: schedule.name,
|
|
2986
|
+
runId,
|
|
2987
|
+
timestamp: new Date().toISOString()
|
|
2988
|
+
});
|
|
2989
|
+
}).catch((err) => {
|
|
2990
|
+
this.emit({
|
|
2991
|
+
type: "schedule:failed",
|
|
2992
|
+
scheduleId: schedule.id,
|
|
2993
|
+
scheduleName: schedule.name,
|
|
2994
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2995
|
+
timestamp: new Date().toISOString()
|
|
2996
|
+
});
|
|
2997
|
+
}).finally(() => {
|
|
2998
|
+
this.running.delete(schedule.id);
|
|
2999
|
+
});
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
async runScheduleNow(scheduleId) {
|
|
3004
|
+
const schedule = getSchedule(scheduleId);
|
|
3005
|
+
if (!schedule) {
|
|
3006
|
+
throw new ScheduleNotFoundError(scheduleId);
|
|
3007
|
+
}
|
|
3008
|
+
this.running.add(schedule.id);
|
|
3009
|
+
this.emit({
|
|
3010
|
+
type: "schedule:triggered",
|
|
3011
|
+
scheduleId: schedule.id,
|
|
3012
|
+
scheduleName: schedule.name,
|
|
3013
|
+
timestamp: new Date().toISOString()
|
|
3014
|
+
});
|
|
3015
|
+
try {
|
|
3016
|
+
const { runId } = await this.executeSchedule(schedule);
|
|
3017
|
+
const nextRun = getNextRunTime(schedule.cronExpression, new Date);
|
|
3018
|
+
updateLastRun(schedule.id, runId, nextRun.toISOString());
|
|
3019
|
+
this.emit({
|
|
3020
|
+
type: "schedule:completed",
|
|
3021
|
+
scheduleId: schedule.id,
|
|
3022
|
+
scheduleName: schedule.name,
|
|
3023
|
+
runId,
|
|
3024
|
+
timestamp: new Date().toISOString()
|
|
3025
|
+
});
|
|
3026
|
+
} catch (err) {
|
|
3027
|
+
this.emit({
|
|
3028
|
+
type: "schedule:failed",
|
|
3029
|
+
scheduleId: schedule.id,
|
|
3030
|
+
scheduleName: schedule.name,
|
|
3031
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3032
|
+
timestamp: new Date().toISOString()
|
|
3033
|
+
});
|
|
3034
|
+
throw err;
|
|
3035
|
+
} finally {
|
|
3036
|
+
this.running.delete(schedule.id);
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
async executeSchedule(schedule) {
|
|
3040
|
+
const { run } = await runByFilter({
|
|
3041
|
+
url: schedule.url,
|
|
3042
|
+
model: schedule.model ?? undefined,
|
|
3043
|
+
headed: schedule.headed,
|
|
3044
|
+
parallel: schedule.parallel,
|
|
3045
|
+
timeout: schedule.timeoutMs ?? undefined,
|
|
3046
|
+
tags: schedule.scenarioFilter.tags,
|
|
3047
|
+
priority: schedule.scenarioFilter.priority,
|
|
3048
|
+
scenarioIds: schedule.scenarioFilter.scenarioIds
|
|
3049
|
+
});
|
|
3050
|
+
return { runId: run.id };
|
|
3051
|
+
}
|
|
3052
|
+
emit(event) {
|
|
3053
|
+
if (this.onEvent) {
|
|
3054
|
+
this.onEvent(event);
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
2428
3058
|
export {
|
|
2429
3059
|
uuid,
|
|
3060
|
+
updateSchedule,
|
|
2430
3061
|
updateScenario,
|
|
2431
3062
|
updateRun,
|
|
2432
3063
|
updateResult,
|
|
3064
|
+
updateLastRun,
|
|
2433
3065
|
taskToScenarioInput,
|
|
2434
3066
|
slugify,
|
|
3067
|
+
shouldRunAt,
|
|
2435
3068
|
shortUuid,
|
|
2436
3069
|
screenshotFromRow,
|
|
3070
|
+
scheduleFromRow,
|
|
2437
3071
|
scenarioFromRow,
|
|
2438
3072
|
runSingleScenario,
|
|
2439
3073
|
runFromRow,
|
|
@@ -2448,11 +3082,14 @@ export {
|
|
|
2448
3082
|
registerAgent,
|
|
2449
3083
|
pullTasks,
|
|
2450
3084
|
projectFromRow,
|
|
3085
|
+
parseCronField,
|
|
3086
|
+
parseCron,
|
|
2451
3087
|
onRunEvent,
|
|
2452
3088
|
now,
|
|
2453
3089
|
markTodoDone,
|
|
2454
3090
|
loadConfig,
|
|
2455
3091
|
listScreenshots,
|
|
3092
|
+
listSchedules,
|
|
2456
3093
|
listScenarios,
|
|
2457
3094
|
listRuns,
|
|
2458
3095
|
listResults,
|
|
@@ -2464,6 +3101,7 @@ export {
|
|
|
2464
3101
|
getScreenshotsByResult,
|
|
2465
3102
|
getScreenshotDir,
|
|
2466
3103
|
getScreenshot,
|
|
3104
|
+
getSchedule,
|
|
2467
3105
|
getScenarioByShortId,
|
|
2468
3106
|
getScenario,
|
|
2469
3107
|
getRun,
|
|
@@ -2472,7 +3110,9 @@ export {
|
|
|
2472
3110
|
getProjectByPath,
|
|
2473
3111
|
getProject,
|
|
2474
3112
|
getPage,
|
|
3113
|
+
getNextRunTime,
|
|
2475
3114
|
getExitCode,
|
|
3115
|
+
getEnabledSchedules,
|
|
2476
3116
|
getDefaultConfig,
|
|
2477
3117
|
getDatabase,
|
|
2478
3118
|
getAgentByName,
|
|
@@ -2487,9 +3127,11 @@ export {
|
|
|
2487
3127
|
executeTool,
|
|
2488
3128
|
ensureProject,
|
|
2489
3129
|
ensureDir,
|
|
3130
|
+
deleteSchedule,
|
|
2490
3131
|
deleteScenario,
|
|
2491
3132
|
deleteRun,
|
|
2492
3133
|
createScreenshot,
|
|
3134
|
+
createSchedule,
|
|
2493
3135
|
createScenario,
|
|
2494
3136
|
createRun,
|
|
2495
3137
|
createResult,
|
|
@@ -2502,6 +3144,8 @@ export {
|
|
|
2502
3144
|
VersionConflictError,
|
|
2503
3145
|
TodosConnectionError,
|
|
2504
3146
|
Screenshotter,
|
|
3147
|
+
Scheduler,
|
|
3148
|
+
ScheduleNotFoundError,
|
|
2505
3149
|
ScenarioNotFoundError,
|
|
2506
3150
|
RunNotFoundError,
|
|
2507
3151
|
ResultNotFoundError,
|