@hasna/testers 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -1
- package/dist/cli/index.js +2444 -373
- 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/schedules.d.ts +9 -0
- package/dist/db/schedules.d.ts.map +1 -0
- package/dist/db/screenshots.d.ts +3 -0
- package/dist/db/screenshots.d.ts.map +1 -1
- package/dist/index.d.ts +21 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2426 -399
- 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.map +1 -1
- package/dist/lib/scheduler.d.ts +71 -0
- package/dist/lib/scheduler.d.ts.map +1 -0
- 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 +839 -25
- package/dist/server/index.js +818 -25
- package/dist/types/index.d.ts +86 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/server/index.js
CHANGED
|
@@ -77,7 +77,30 @@ function screenshotFromRow(row) {
|
|
|
77
77
|
filePath: row.file_path,
|
|
78
78
|
width: row.width,
|
|
79
79
|
height: row.height,
|
|
80
|
-
timestamp: row.timestamp
|
|
80
|
+
timestamp: row.timestamp,
|
|
81
|
+
description: row.description,
|
|
82
|
+
pageUrl: row.page_url,
|
|
83
|
+
thumbnailPath: row.thumbnail_path
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function scheduleFromRow(row) {
|
|
87
|
+
return {
|
|
88
|
+
id: row.id,
|
|
89
|
+
projectId: row.project_id,
|
|
90
|
+
name: row.name,
|
|
91
|
+
cronExpression: row.cron_expression,
|
|
92
|
+
url: row.url,
|
|
93
|
+
scenarioFilter: JSON.parse(row.scenario_filter),
|
|
94
|
+
model: row.model,
|
|
95
|
+
headed: row.headed === 1,
|
|
96
|
+
parallel: row.parallel,
|
|
97
|
+
timeoutMs: row.timeout_ms,
|
|
98
|
+
enabled: row.enabled === 1,
|
|
99
|
+
lastRunId: row.last_run_id,
|
|
100
|
+
lastRunAt: row.last_run_at,
|
|
101
|
+
nextRunAt: row.next_run_at,
|
|
102
|
+
createdAt: row.created_at,
|
|
103
|
+
updatedAt: row.updated_at
|
|
81
104
|
};
|
|
82
105
|
}
|
|
83
106
|
class VersionConflictError extends Error {
|
|
@@ -100,6 +123,12 @@ class AIClientError extends Error {
|
|
|
100
123
|
this.name = "AIClientError";
|
|
101
124
|
}
|
|
102
125
|
}
|
|
126
|
+
class ScheduleNotFoundError extends Error {
|
|
127
|
+
constructor(id) {
|
|
128
|
+
super(`Schedule not found: ${id}`);
|
|
129
|
+
this.name = "ScheduleNotFoundError";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
103
132
|
|
|
104
133
|
// src/db/database.ts
|
|
105
134
|
import { Database } from "bun:sqlite";
|
|
@@ -229,6 +258,58 @@ var MIGRATIONS = [
|
|
|
229
258
|
`
|
|
230
259
|
ALTER TABLE projects ADD COLUMN scenario_prefix TEXT DEFAULT 'TST';
|
|
231
260
|
ALTER TABLE projects ADD COLUMN scenario_counter INTEGER DEFAULT 0;
|
|
261
|
+
`,
|
|
262
|
+
`
|
|
263
|
+
CREATE TABLE IF NOT EXISTS schedules (
|
|
264
|
+
id TEXT PRIMARY KEY,
|
|
265
|
+
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
266
|
+
name TEXT NOT NULL,
|
|
267
|
+
cron_expression TEXT NOT NULL,
|
|
268
|
+
url TEXT NOT NULL,
|
|
269
|
+
scenario_filter TEXT NOT NULL DEFAULT '{}',
|
|
270
|
+
model TEXT,
|
|
271
|
+
headed INTEGER NOT NULL DEFAULT 0,
|
|
272
|
+
parallel INTEGER NOT NULL DEFAULT 1,
|
|
273
|
+
timeout_ms INTEGER,
|
|
274
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
275
|
+
last_run_id TEXT REFERENCES runs(id) ON DELETE SET NULL,
|
|
276
|
+
last_run_at TEXT,
|
|
277
|
+
next_run_at TEXT,
|
|
278
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
279
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
CREATE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
|
|
283
|
+
CREATE INDEX IF NOT EXISTS idx_schedules_enabled ON schedules(enabled);
|
|
284
|
+
CREATE INDEX IF NOT EXISTS idx_schedules_next_run ON schedules(next_run_at);
|
|
285
|
+
`,
|
|
286
|
+
`
|
|
287
|
+
ALTER TABLE screenshots ADD COLUMN description TEXT;
|
|
288
|
+
ALTER TABLE screenshots ADD COLUMN page_url TEXT;
|
|
289
|
+
ALTER TABLE screenshots ADD COLUMN thumbnail_path TEXT;
|
|
290
|
+
`,
|
|
291
|
+
`
|
|
292
|
+
CREATE TABLE IF NOT EXISTS auth_presets (
|
|
293
|
+
id TEXT PRIMARY KEY,
|
|
294
|
+
name TEXT NOT NULL UNIQUE,
|
|
295
|
+
email TEXT NOT NULL,
|
|
296
|
+
password TEXT NOT NULL,
|
|
297
|
+
login_path TEXT DEFAULT '/login',
|
|
298
|
+
metadata TEXT DEFAULT '{}',
|
|
299
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
300
|
+
);
|
|
301
|
+
`,
|
|
302
|
+
`
|
|
303
|
+
CREATE TABLE IF NOT EXISTS webhooks (
|
|
304
|
+
id TEXT PRIMARY KEY,
|
|
305
|
+
url TEXT NOT NULL,
|
|
306
|
+
events TEXT NOT NULL DEFAULT '["failed"]',
|
|
307
|
+
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
308
|
+
secret TEXT,
|
|
309
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
310
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
311
|
+
);
|
|
312
|
+
CREATE INDEX IF NOT EXISTS idx_webhooks_active ON webhooks(active);
|
|
232
313
|
`
|
|
233
314
|
];
|
|
234
315
|
function applyMigrations(database) {
|
|
@@ -629,9 +710,9 @@ function createScreenshot(input) {
|
|
|
629
710
|
const id = uuid();
|
|
630
711
|
const timestamp = now();
|
|
631
712
|
db2.query(`
|
|
632
|
-
INSERT INTO screenshots (id, result_id, step_number, action, file_path, width, height, timestamp)
|
|
633
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
634
|
-
`).run(id, input.resultId, input.stepNumber, input.action, input.filePath, input.width, input.height, timestamp);
|
|
713
|
+
INSERT INTO screenshots (id, result_id, step_number, action, file_path, width, height, timestamp, description, page_url, thumbnail_path)
|
|
714
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
715
|
+
`).run(id, input.resultId, input.stepNumber, input.action, input.filePath, input.width, input.height, timestamp, input.description ?? null, input.pageUrl ?? null, input.thumbnailPath ?? null);
|
|
635
716
|
return getScreenshot(id);
|
|
636
717
|
}
|
|
637
718
|
function getScreenshot(id) {
|
|
@@ -689,7 +770,7 @@ async function closeBrowser(browser) {
|
|
|
689
770
|
}
|
|
690
771
|
|
|
691
772
|
// src/lib/screenshotter.ts
|
|
692
|
-
import { mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
|
|
773
|
+
import { mkdirSync as mkdirSync2, existsSync as existsSync2, writeFileSync } from "fs";
|
|
693
774
|
import { join as join2 } from "path";
|
|
694
775
|
import { homedir as homedir2 } from "os";
|
|
695
776
|
function slugify(text) {
|
|
@@ -698,16 +779,51 @@ function slugify(text) {
|
|
|
698
779
|
function generateFilename(stepNumber, action) {
|
|
699
780
|
const padded = String(stepNumber).padStart(3, "0");
|
|
700
781
|
const slug = slugify(action);
|
|
701
|
-
return `${padded}
|
|
782
|
+
return `${padded}_${slug}.png`;
|
|
783
|
+
}
|
|
784
|
+
function formatDate(date) {
|
|
785
|
+
return date.toISOString().slice(0, 10);
|
|
786
|
+
}
|
|
787
|
+
function formatTime(date) {
|
|
788
|
+
return date.toISOString().slice(11, 19).replace(/:/g, "-");
|
|
702
789
|
}
|
|
703
|
-
function getScreenshotDir(baseDir, runId, scenarioSlug) {
|
|
704
|
-
|
|
790
|
+
function getScreenshotDir(baseDir, runId, scenarioSlug, projectName, timestamp) {
|
|
791
|
+
const now2 = timestamp ?? new Date;
|
|
792
|
+
const project = projectName ?? "default";
|
|
793
|
+
const dateDir = formatDate(now2);
|
|
794
|
+
const timeDir = `${formatTime(now2)}_${runId.slice(0, 8)}`;
|
|
795
|
+
return join2(baseDir, project, dateDir, timeDir, scenarioSlug);
|
|
705
796
|
}
|
|
706
797
|
function ensureDir(dirPath) {
|
|
707
798
|
if (!existsSync2(dirPath)) {
|
|
708
799
|
mkdirSync2(dirPath, { recursive: true });
|
|
709
800
|
}
|
|
710
801
|
}
|
|
802
|
+
function writeMetaSidecar(screenshotPath, meta) {
|
|
803
|
+
const metaPath = screenshotPath.replace(/\.png$/, ".meta.json").replace(/\.jpeg$/, ".meta.json");
|
|
804
|
+
try {
|
|
805
|
+
writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf-8");
|
|
806
|
+
} catch {}
|
|
807
|
+
}
|
|
808
|
+
async function generateThumbnail(page, screenshotDir, filename) {
|
|
809
|
+
try {
|
|
810
|
+
const thumbDir = join2(screenshotDir, "_thumbnail");
|
|
811
|
+
ensureDir(thumbDir);
|
|
812
|
+
const thumbFilename = filename.replace(/\.(png|jpeg)$/, ".thumb.$1");
|
|
813
|
+
const thumbPath = join2(thumbDir, thumbFilename);
|
|
814
|
+
const viewport = page.viewportSize();
|
|
815
|
+
if (viewport) {
|
|
816
|
+
await page.screenshot({
|
|
817
|
+
path: thumbPath,
|
|
818
|
+
type: "png",
|
|
819
|
+
clip: { x: 0, y: 0, width: Math.min(viewport.width, 1280), height: Math.min(viewport.height, 720) }
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
return thumbPath;
|
|
823
|
+
} catch {
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
711
827
|
var DEFAULT_BASE_DIR = join2(homedir2(), ".testers", "screenshots");
|
|
712
828
|
|
|
713
829
|
class Screenshotter {
|
|
@@ -715,15 +831,20 @@ class Screenshotter {
|
|
|
715
831
|
format;
|
|
716
832
|
quality;
|
|
717
833
|
fullPage;
|
|
834
|
+
projectName;
|
|
835
|
+
runTimestamp;
|
|
718
836
|
constructor(options = {}) {
|
|
719
837
|
this.baseDir = options.baseDir ?? DEFAULT_BASE_DIR;
|
|
720
838
|
this.format = options.format ?? "png";
|
|
721
839
|
this.quality = options.quality ?? 90;
|
|
722
840
|
this.fullPage = options.fullPage ?? false;
|
|
841
|
+
this.projectName = options.projectName ?? "default";
|
|
842
|
+
this.runTimestamp = new Date;
|
|
723
843
|
}
|
|
724
844
|
async capture(page, options) {
|
|
725
|
-
const
|
|
726
|
-
const
|
|
845
|
+
const action = options.description ?? options.action;
|
|
846
|
+
const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug, this.projectName, this.runTimestamp);
|
|
847
|
+
const filename = generateFilename(options.stepNumber, action);
|
|
727
848
|
const filePath = join2(dir, filename);
|
|
728
849
|
ensureDir(dir);
|
|
729
850
|
await page.screenshot({
|
|
@@ -733,16 +854,32 @@ class Screenshotter {
|
|
|
733
854
|
quality: this.format === "jpeg" ? this.quality : undefined
|
|
734
855
|
});
|
|
735
856
|
const viewport = page.viewportSize() ?? { width: 0, height: 0 };
|
|
857
|
+
const pageUrl = page.url();
|
|
858
|
+
const timestamp = new Date().toISOString();
|
|
859
|
+
writeMetaSidecar(filePath, {
|
|
860
|
+
stepNumber: options.stepNumber,
|
|
861
|
+
action: options.action,
|
|
862
|
+
description: options.description ?? null,
|
|
863
|
+
pageUrl,
|
|
864
|
+
viewport,
|
|
865
|
+
timestamp,
|
|
866
|
+
filePath
|
|
867
|
+
});
|
|
868
|
+
const thumbnailPath = await generateThumbnail(page, dir, filename);
|
|
736
869
|
return {
|
|
737
870
|
filePath,
|
|
738
871
|
width: viewport.width,
|
|
739
872
|
height: viewport.height,
|
|
740
|
-
timestamp
|
|
873
|
+
timestamp,
|
|
874
|
+
description: options.description ?? null,
|
|
875
|
+
pageUrl,
|
|
876
|
+
thumbnailPath
|
|
741
877
|
};
|
|
742
878
|
}
|
|
743
879
|
async captureFullPage(page, options) {
|
|
744
|
-
const
|
|
745
|
-
const
|
|
880
|
+
const action = options.description ?? options.action;
|
|
881
|
+
const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug, this.projectName, this.runTimestamp);
|
|
882
|
+
const filename = generateFilename(options.stepNumber, action);
|
|
746
883
|
const filePath = join2(dir, filename);
|
|
747
884
|
ensureDir(dir);
|
|
748
885
|
await page.screenshot({
|
|
@@ -752,16 +889,32 @@ class Screenshotter {
|
|
|
752
889
|
quality: this.format === "jpeg" ? this.quality : undefined
|
|
753
890
|
});
|
|
754
891
|
const viewport = page.viewportSize() ?? { width: 0, height: 0 };
|
|
892
|
+
const pageUrl = page.url();
|
|
893
|
+
const timestamp = new Date().toISOString();
|
|
894
|
+
writeMetaSidecar(filePath, {
|
|
895
|
+
stepNumber: options.stepNumber,
|
|
896
|
+
action: options.action,
|
|
897
|
+
description: options.description ?? null,
|
|
898
|
+
pageUrl,
|
|
899
|
+
viewport,
|
|
900
|
+
timestamp,
|
|
901
|
+
filePath
|
|
902
|
+
});
|
|
903
|
+
const thumbnailPath = await generateThumbnail(page, dir, filename);
|
|
755
904
|
return {
|
|
756
905
|
filePath,
|
|
757
906
|
width: viewport.width,
|
|
758
907
|
height: viewport.height,
|
|
759
|
-
timestamp
|
|
908
|
+
timestamp,
|
|
909
|
+
description: options.description ?? null,
|
|
910
|
+
pageUrl,
|
|
911
|
+
thumbnailPath
|
|
760
912
|
};
|
|
761
913
|
}
|
|
762
914
|
async captureElement(page, selector, options) {
|
|
763
|
-
const
|
|
764
|
-
const
|
|
915
|
+
const action = options.description ?? options.action;
|
|
916
|
+
const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug, this.projectName, this.runTimestamp);
|
|
917
|
+
const filename = generateFilename(options.stepNumber, action);
|
|
765
918
|
const filePath = join2(dir, filename);
|
|
766
919
|
ensureDir(dir);
|
|
767
920
|
await page.locator(selector).screenshot({
|
|
@@ -770,11 +923,25 @@ class Screenshotter {
|
|
|
770
923
|
quality: this.format === "jpeg" ? this.quality : undefined
|
|
771
924
|
});
|
|
772
925
|
const viewport = page.viewportSize() ?? { width: 0, height: 0 };
|
|
926
|
+
const pageUrl = page.url();
|
|
927
|
+
const timestamp = new Date().toISOString();
|
|
928
|
+
writeMetaSidecar(filePath, {
|
|
929
|
+
stepNumber: options.stepNumber,
|
|
930
|
+
action: options.action,
|
|
931
|
+
description: options.description ?? null,
|
|
932
|
+
pageUrl,
|
|
933
|
+
viewport,
|
|
934
|
+
timestamp,
|
|
935
|
+
filePath
|
|
936
|
+
});
|
|
773
937
|
return {
|
|
774
938
|
filePath,
|
|
775
939
|
width: viewport.width,
|
|
776
940
|
height: viewport.height,
|
|
777
|
-
timestamp
|
|
941
|
+
timestamp,
|
|
942
|
+
description: options.description ?? null,
|
|
943
|
+
pageUrl,
|
|
944
|
+
thumbnailPath: null
|
|
778
945
|
};
|
|
779
946
|
}
|
|
780
947
|
}
|
|
@@ -950,6 +1117,127 @@ var BROWSER_TOOLS = [
|
|
|
950
1117
|
required: ["text"]
|
|
951
1118
|
}
|
|
952
1119
|
},
|
|
1120
|
+
{
|
|
1121
|
+
name: "scroll",
|
|
1122
|
+
description: "Scroll the page up or down by a given amount of pixels.",
|
|
1123
|
+
input_schema: {
|
|
1124
|
+
type: "object",
|
|
1125
|
+
properties: {
|
|
1126
|
+
direction: {
|
|
1127
|
+
type: "string",
|
|
1128
|
+
enum: ["up", "down"],
|
|
1129
|
+
description: "Direction to scroll."
|
|
1130
|
+
},
|
|
1131
|
+
amount: {
|
|
1132
|
+
type: "number",
|
|
1133
|
+
description: "Number of pixels to scroll (default: 500)."
|
|
1134
|
+
}
|
|
1135
|
+
},
|
|
1136
|
+
required: ["direction"]
|
|
1137
|
+
}
|
|
1138
|
+
},
|
|
1139
|
+
{
|
|
1140
|
+
name: "get_page_html",
|
|
1141
|
+
description: "Get simplified HTML of the page body content, truncated to 8000 characters.",
|
|
1142
|
+
input_schema: {
|
|
1143
|
+
type: "object",
|
|
1144
|
+
properties: {},
|
|
1145
|
+
required: []
|
|
1146
|
+
}
|
|
1147
|
+
},
|
|
1148
|
+
{
|
|
1149
|
+
name: "get_elements",
|
|
1150
|
+
description: "List elements matching a CSS selector with their text, tag name, and key attributes (max 20 results).",
|
|
1151
|
+
input_schema: {
|
|
1152
|
+
type: "object",
|
|
1153
|
+
properties: {
|
|
1154
|
+
selector: {
|
|
1155
|
+
type: "string",
|
|
1156
|
+
description: "CSS selector to match elements."
|
|
1157
|
+
}
|
|
1158
|
+
},
|
|
1159
|
+
required: ["selector"]
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
{
|
|
1163
|
+
name: "wait_for_navigation",
|
|
1164
|
+
description: "Wait for page navigation/load to complete (network idle).",
|
|
1165
|
+
input_schema: {
|
|
1166
|
+
type: "object",
|
|
1167
|
+
properties: {
|
|
1168
|
+
timeout: {
|
|
1169
|
+
type: "number",
|
|
1170
|
+
description: "Maximum time to wait in milliseconds (default: 10000)."
|
|
1171
|
+
}
|
|
1172
|
+
},
|
|
1173
|
+
required: []
|
|
1174
|
+
}
|
|
1175
|
+
},
|
|
1176
|
+
{
|
|
1177
|
+
name: "get_page_title",
|
|
1178
|
+
description: "Get the document title of the current page.",
|
|
1179
|
+
input_schema: {
|
|
1180
|
+
type: "object",
|
|
1181
|
+
properties: {},
|
|
1182
|
+
required: []
|
|
1183
|
+
}
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
name: "count_elements",
|
|
1187
|
+
description: "Count the number of elements matching a CSS selector.",
|
|
1188
|
+
input_schema: {
|
|
1189
|
+
type: "object",
|
|
1190
|
+
properties: {
|
|
1191
|
+
selector: {
|
|
1192
|
+
type: "string",
|
|
1193
|
+
description: "CSS selector to count matching elements."
|
|
1194
|
+
}
|
|
1195
|
+
},
|
|
1196
|
+
required: ["selector"]
|
|
1197
|
+
}
|
|
1198
|
+
},
|
|
1199
|
+
{
|
|
1200
|
+
name: "hover",
|
|
1201
|
+
description: "Hover over an element matching the given CSS selector.",
|
|
1202
|
+
input_schema: {
|
|
1203
|
+
type: "object",
|
|
1204
|
+
properties: {
|
|
1205
|
+
selector: {
|
|
1206
|
+
type: "string",
|
|
1207
|
+
description: "CSS selector of the element to hover over."
|
|
1208
|
+
}
|
|
1209
|
+
},
|
|
1210
|
+
required: ["selector"]
|
|
1211
|
+
}
|
|
1212
|
+
},
|
|
1213
|
+
{
|
|
1214
|
+
name: "check",
|
|
1215
|
+
description: "Check a checkbox matching the given CSS selector.",
|
|
1216
|
+
input_schema: {
|
|
1217
|
+
type: "object",
|
|
1218
|
+
properties: {
|
|
1219
|
+
selector: {
|
|
1220
|
+
type: "string",
|
|
1221
|
+
description: "CSS selector of the checkbox to check."
|
|
1222
|
+
}
|
|
1223
|
+
},
|
|
1224
|
+
required: ["selector"]
|
|
1225
|
+
}
|
|
1226
|
+
},
|
|
1227
|
+
{
|
|
1228
|
+
name: "uncheck",
|
|
1229
|
+
description: "Uncheck a checkbox matching the given CSS selector.",
|
|
1230
|
+
input_schema: {
|
|
1231
|
+
type: "object",
|
|
1232
|
+
properties: {
|
|
1233
|
+
selector: {
|
|
1234
|
+
type: "string",
|
|
1235
|
+
description: "CSS selector of the checkbox to uncheck."
|
|
1236
|
+
}
|
|
1237
|
+
},
|
|
1238
|
+
required: ["selector"]
|
|
1239
|
+
}
|
|
1240
|
+
},
|
|
953
1241
|
{
|
|
954
1242
|
name: "report_result",
|
|
955
1243
|
description: "Report the final test result. Call this when you have completed testing the scenario. This MUST be the last tool you call.",
|
|
@@ -1081,6 +1369,113 @@ async function executeTool(page, screenshotter, toolName, toolInput, context) {
|
|
|
1081
1369
|
return { result: "false" };
|
|
1082
1370
|
}
|
|
1083
1371
|
}
|
|
1372
|
+
case "scroll": {
|
|
1373
|
+
const direction = toolInput.direction;
|
|
1374
|
+
const amount = typeof toolInput.amount === "number" ? toolInput.amount : 500;
|
|
1375
|
+
const scrollY = direction === "down" ? amount : -amount;
|
|
1376
|
+
await page.evaluate((y) => window.scrollBy(0, y), scrollY);
|
|
1377
|
+
const screenshot = await screenshotter.capture(page, {
|
|
1378
|
+
runId: context.runId,
|
|
1379
|
+
scenarioSlug: context.scenarioSlug,
|
|
1380
|
+
stepNumber: context.stepNumber,
|
|
1381
|
+
action: "scroll"
|
|
1382
|
+
});
|
|
1383
|
+
return {
|
|
1384
|
+
result: `Scrolled ${direction} by ${amount}px`,
|
|
1385
|
+
screenshot
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
case "get_page_html": {
|
|
1389
|
+
const html = await page.evaluate(() => document.body.innerHTML);
|
|
1390
|
+
const truncated = html.length > 8000 ? html.slice(0, 8000) + "..." : html;
|
|
1391
|
+
return {
|
|
1392
|
+
result: truncated
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
case "get_elements": {
|
|
1396
|
+
const selector = toolInput.selector;
|
|
1397
|
+
const allElements = await page.locator(selector).all();
|
|
1398
|
+
const elements = allElements.slice(0, 20);
|
|
1399
|
+
const results = [];
|
|
1400
|
+
for (let i = 0;i < elements.length; i++) {
|
|
1401
|
+
const el = elements[i];
|
|
1402
|
+
const tagName = await el.evaluate((e) => e.tagName.toLowerCase());
|
|
1403
|
+
const textContent = await el.textContent() ?? "";
|
|
1404
|
+
const trimmedText = textContent.trim().slice(0, 100);
|
|
1405
|
+
const id = await el.getAttribute("id");
|
|
1406
|
+
const className = await el.getAttribute("class");
|
|
1407
|
+
const href = await el.getAttribute("href");
|
|
1408
|
+
const type = await el.getAttribute("type");
|
|
1409
|
+
const placeholder = await el.getAttribute("placeholder");
|
|
1410
|
+
const ariaLabel = await el.getAttribute("aria-label");
|
|
1411
|
+
const attrs = [];
|
|
1412
|
+
if (id)
|
|
1413
|
+
attrs.push(`id="${id}"`);
|
|
1414
|
+
if (className)
|
|
1415
|
+
attrs.push(`class="${className}"`);
|
|
1416
|
+
if (href)
|
|
1417
|
+
attrs.push(`href="${href}"`);
|
|
1418
|
+
if (type)
|
|
1419
|
+
attrs.push(`type="${type}"`);
|
|
1420
|
+
if (placeholder)
|
|
1421
|
+
attrs.push(`placeholder="${placeholder}"`);
|
|
1422
|
+
if (ariaLabel)
|
|
1423
|
+
attrs.push(`aria-label="${ariaLabel}"`);
|
|
1424
|
+
results.push(`[${i}] <${tagName}${attrs.length ? " " + attrs.join(" ") : ""}> ${trimmedText}`);
|
|
1425
|
+
}
|
|
1426
|
+
return {
|
|
1427
|
+
result: results.length > 0 ? results.join(`
|
|
1428
|
+
`) : `No elements found matching "${selector}"`
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
case "wait_for_navigation": {
|
|
1432
|
+
const timeout = typeof toolInput.timeout === "number" ? toolInput.timeout : 1e4;
|
|
1433
|
+
await page.waitForLoadState("networkidle", { timeout });
|
|
1434
|
+
return {
|
|
1435
|
+
result: "Navigation/load completed"
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
case "get_page_title": {
|
|
1439
|
+
const title = await page.title();
|
|
1440
|
+
return {
|
|
1441
|
+
result: title || "(no title)"
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
case "count_elements": {
|
|
1445
|
+
const selector = toolInput.selector;
|
|
1446
|
+
const count = await page.locator(selector).count();
|
|
1447
|
+
return {
|
|
1448
|
+
result: `${count} element(s) matching "${selector}"`
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
case "hover": {
|
|
1452
|
+
const selector = toolInput.selector;
|
|
1453
|
+
await page.hover(selector);
|
|
1454
|
+
const screenshot = await screenshotter.capture(page, {
|
|
1455
|
+
runId: context.runId,
|
|
1456
|
+
scenarioSlug: context.scenarioSlug,
|
|
1457
|
+
stepNumber: context.stepNumber,
|
|
1458
|
+
action: "hover"
|
|
1459
|
+
});
|
|
1460
|
+
return {
|
|
1461
|
+
result: `Hovered over: ${selector}`,
|
|
1462
|
+
screenshot
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
case "check": {
|
|
1466
|
+
const selector = toolInput.selector;
|
|
1467
|
+
await page.check(selector);
|
|
1468
|
+
return {
|
|
1469
|
+
result: `Checked checkbox: ${selector}`
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
case "uncheck": {
|
|
1473
|
+
const selector = toolInput.selector;
|
|
1474
|
+
await page.uncheck(selector);
|
|
1475
|
+
return {
|
|
1476
|
+
result: `Unchecked checkbox: ${selector}`
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1084
1479
|
case "report_result": {
|
|
1085
1480
|
const status = toolInput.status;
|
|
1086
1481
|
const reasoning = toolInput.reasoning;
|
|
@@ -1107,13 +1502,26 @@ async function runAgentLoop(options) {
|
|
|
1107
1502
|
maxTurns = 30
|
|
1108
1503
|
} = options;
|
|
1109
1504
|
const systemPrompt = [
|
|
1110
|
-
"You are
|
|
1111
|
-
"
|
|
1112
|
-
"
|
|
1113
|
-
"
|
|
1114
|
-
"
|
|
1115
|
-
"
|
|
1116
|
-
|
|
1505
|
+
"You are an expert QA testing agent. Your job is to thoroughly test web application scenarios.",
|
|
1506
|
+
"You have browser tools to navigate, interact with, and inspect web pages.",
|
|
1507
|
+
"",
|
|
1508
|
+
"Strategy:",
|
|
1509
|
+
"1. First navigate to the target page and take a screenshot to understand the layout",
|
|
1510
|
+
"2. If you can't find an element, use get_elements or get_page_html to discover selectors",
|
|
1511
|
+
"3. Use scroll to discover content below the fold",
|
|
1512
|
+
"4. Use wait_for or wait_for_navigation after actions that trigger page loads",
|
|
1513
|
+
"5. Take screenshots after every meaningful state change",
|
|
1514
|
+
"6. Use assert_text and assert_visible to verify expected outcomes",
|
|
1515
|
+
"7. When done testing, call report_result with detailed pass/fail reasoning",
|
|
1516
|
+
"",
|
|
1517
|
+
"Tips:",
|
|
1518
|
+
"- Try multiple selector strategies: by text, by role, by class, by id",
|
|
1519
|
+
"- If a click triggers navigation, use wait_for_navigation after",
|
|
1520
|
+
"- For forms, fill all fields before submitting",
|
|
1521
|
+
"- Check for error messages after form submissions",
|
|
1522
|
+
"- Verify both positive and negative states"
|
|
1523
|
+
].join(`
|
|
1524
|
+
`);
|
|
1117
1525
|
const userParts = [
|
|
1118
1526
|
`**Scenario:** ${scenario.name}`,
|
|
1119
1527
|
`**Description:** ${scenario.description}`
|
|
@@ -1316,7 +1724,10 @@ async function runSingleScenario(scenario, runId, options) {
|
|
|
1316
1724
|
action: ss.action,
|
|
1317
1725
|
filePath: ss.filePath,
|
|
1318
1726
|
width: ss.width,
|
|
1319
|
-
height: ss.height
|
|
1727
|
+
height: ss.height,
|
|
1728
|
+
description: ss.description,
|
|
1729
|
+
pageUrl: ss.pageUrl,
|
|
1730
|
+
thumbnailPath: ss.thumbnailPath
|
|
1320
1731
|
});
|
|
1321
1732
|
emit({ type: "screenshot:captured", screenshotPath: ss.filePath, scenarioId: scenario.id, runId });
|
|
1322
1733
|
}
|
|
@@ -1424,6 +1835,345 @@ function estimateCost(model, tokens) {
|
|
|
1424
1835
|
return tokens / 1e6 * costPer1M * 100;
|
|
1425
1836
|
}
|
|
1426
1837
|
|
|
1838
|
+
// src/db/schedules.ts
|
|
1839
|
+
function createSchedule(input) {
|
|
1840
|
+
const db2 = getDatabase();
|
|
1841
|
+
const id = uuid();
|
|
1842
|
+
const timestamp = now();
|
|
1843
|
+
db2.query(`
|
|
1844
|
+
INSERT INTO schedules (id, project_id, name, cron_expression, url, scenario_filter, model, headed, parallel, timeout_ms, enabled, created_at, updated_at)
|
|
1845
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
|
1846
|
+
`).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);
|
|
1847
|
+
return getSchedule(id);
|
|
1848
|
+
}
|
|
1849
|
+
function getSchedule(id) {
|
|
1850
|
+
const db2 = getDatabase();
|
|
1851
|
+
let row = db2.query("SELECT * FROM schedules WHERE id = ?").get(id);
|
|
1852
|
+
if (row)
|
|
1853
|
+
return scheduleFromRow(row);
|
|
1854
|
+
const fullId = resolvePartialId("schedules", id);
|
|
1855
|
+
if (fullId) {
|
|
1856
|
+
row = db2.query("SELECT * FROM schedules WHERE id = ?").get(fullId);
|
|
1857
|
+
if (row)
|
|
1858
|
+
return scheduleFromRow(row);
|
|
1859
|
+
}
|
|
1860
|
+
return null;
|
|
1861
|
+
}
|
|
1862
|
+
function listSchedules(filter) {
|
|
1863
|
+
const db2 = getDatabase();
|
|
1864
|
+
const conditions = [];
|
|
1865
|
+
const params = [];
|
|
1866
|
+
if (filter?.projectId) {
|
|
1867
|
+
conditions.push("project_id = ?");
|
|
1868
|
+
params.push(filter.projectId);
|
|
1869
|
+
}
|
|
1870
|
+
if (filter?.enabled !== undefined) {
|
|
1871
|
+
conditions.push("enabled = ?");
|
|
1872
|
+
params.push(filter.enabled ? 1 : 0);
|
|
1873
|
+
}
|
|
1874
|
+
let sql = "SELECT * FROM schedules";
|
|
1875
|
+
if (conditions.length > 0) {
|
|
1876
|
+
sql += " WHERE " + conditions.join(" AND ");
|
|
1877
|
+
}
|
|
1878
|
+
sql += " ORDER BY created_at DESC";
|
|
1879
|
+
if (filter?.limit) {
|
|
1880
|
+
sql += " LIMIT ?";
|
|
1881
|
+
params.push(filter.limit);
|
|
1882
|
+
}
|
|
1883
|
+
if (filter?.offset) {
|
|
1884
|
+
sql += " OFFSET ?";
|
|
1885
|
+
params.push(filter.offset);
|
|
1886
|
+
}
|
|
1887
|
+
const rows = db2.query(sql).all(...params);
|
|
1888
|
+
return rows.map(scheduleFromRow);
|
|
1889
|
+
}
|
|
1890
|
+
function updateSchedule(id, input) {
|
|
1891
|
+
const db2 = getDatabase();
|
|
1892
|
+
const existing = getSchedule(id);
|
|
1893
|
+
if (!existing) {
|
|
1894
|
+
throw new ScheduleNotFoundError(id);
|
|
1895
|
+
}
|
|
1896
|
+
const sets = [];
|
|
1897
|
+
const params = [];
|
|
1898
|
+
if (input.name !== undefined) {
|
|
1899
|
+
sets.push("name = ?");
|
|
1900
|
+
params.push(input.name);
|
|
1901
|
+
}
|
|
1902
|
+
if (input.cronExpression !== undefined) {
|
|
1903
|
+
sets.push("cron_expression = ?");
|
|
1904
|
+
params.push(input.cronExpression);
|
|
1905
|
+
}
|
|
1906
|
+
if (input.url !== undefined) {
|
|
1907
|
+
sets.push("url = ?");
|
|
1908
|
+
params.push(input.url);
|
|
1909
|
+
}
|
|
1910
|
+
if (input.scenarioFilter !== undefined) {
|
|
1911
|
+
sets.push("scenario_filter = ?");
|
|
1912
|
+
params.push(JSON.stringify(input.scenarioFilter));
|
|
1913
|
+
}
|
|
1914
|
+
if (input.model !== undefined) {
|
|
1915
|
+
sets.push("model = ?");
|
|
1916
|
+
params.push(input.model);
|
|
1917
|
+
}
|
|
1918
|
+
if (input.headed !== undefined) {
|
|
1919
|
+
sets.push("headed = ?");
|
|
1920
|
+
params.push(input.headed ? 1 : 0);
|
|
1921
|
+
}
|
|
1922
|
+
if (input.parallel !== undefined) {
|
|
1923
|
+
sets.push("parallel = ?");
|
|
1924
|
+
params.push(input.parallel);
|
|
1925
|
+
}
|
|
1926
|
+
if (input.timeoutMs !== undefined) {
|
|
1927
|
+
sets.push("timeout_ms = ?");
|
|
1928
|
+
params.push(input.timeoutMs);
|
|
1929
|
+
}
|
|
1930
|
+
if (input.enabled !== undefined) {
|
|
1931
|
+
sets.push("enabled = ?");
|
|
1932
|
+
params.push(input.enabled ? 1 : 0);
|
|
1933
|
+
}
|
|
1934
|
+
if (sets.length === 0) {
|
|
1935
|
+
return existing;
|
|
1936
|
+
}
|
|
1937
|
+
sets.push("updated_at = ?");
|
|
1938
|
+
params.push(now());
|
|
1939
|
+
params.push(existing.id);
|
|
1940
|
+
db2.query(`UPDATE schedules SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
1941
|
+
return getSchedule(existing.id);
|
|
1942
|
+
}
|
|
1943
|
+
function deleteSchedule(id) {
|
|
1944
|
+
const db2 = getDatabase();
|
|
1945
|
+
const schedule = getSchedule(id);
|
|
1946
|
+
if (!schedule)
|
|
1947
|
+
return false;
|
|
1948
|
+
const result = db2.query("DELETE FROM schedules WHERE id = ?").run(schedule.id);
|
|
1949
|
+
return result.changes > 0;
|
|
1950
|
+
}
|
|
1951
|
+
function getEnabledSchedules() {
|
|
1952
|
+
const db2 = getDatabase();
|
|
1953
|
+
const rows = db2.query("SELECT * FROM schedules WHERE enabled = 1 ORDER BY created_at DESC").all();
|
|
1954
|
+
return rows.map(scheduleFromRow);
|
|
1955
|
+
}
|
|
1956
|
+
function updateLastRun(id, runId, nextRunAt) {
|
|
1957
|
+
const db2 = getDatabase();
|
|
1958
|
+
const timestamp = now();
|
|
1959
|
+
db2.query(`
|
|
1960
|
+
UPDATE schedules SET last_run_id = ?, last_run_at = ?, next_run_at = ?, updated_at = ? WHERE id = ?
|
|
1961
|
+
`).run(runId, timestamp, nextRunAt, timestamp, id);
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
// src/lib/scheduler.ts
|
|
1965
|
+
function parseCronField(field, min, max) {
|
|
1966
|
+
const results = new Set;
|
|
1967
|
+
const parts = field.split(",");
|
|
1968
|
+
for (const part of parts) {
|
|
1969
|
+
const trimmed = part.trim();
|
|
1970
|
+
if (trimmed.includes("/")) {
|
|
1971
|
+
const slashParts = trimmed.split("/");
|
|
1972
|
+
const rangePart = slashParts[0] ?? "*";
|
|
1973
|
+
const stepStr = slashParts[1] ?? "1";
|
|
1974
|
+
const step = parseInt(stepStr, 10);
|
|
1975
|
+
if (isNaN(step) || step <= 0) {
|
|
1976
|
+
throw new Error(`Invalid step value in cron field: ${field}`);
|
|
1977
|
+
}
|
|
1978
|
+
let start;
|
|
1979
|
+
let end;
|
|
1980
|
+
if (rangePart === "*") {
|
|
1981
|
+
start = min;
|
|
1982
|
+
end = max;
|
|
1983
|
+
} else if (rangePart.includes("-")) {
|
|
1984
|
+
const dashParts = rangePart.split("-");
|
|
1985
|
+
start = parseInt(dashParts[0] ?? "0", 10);
|
|
1986
|
+
end = parseInt(dashParts[1] ?? "0", 10);
|
|
1987
|
+
} else {
|
|
1988
|
+
start = parseInt(rangePart, 10);
|
|
1989
|
+
end = max;
|
|
1990
|
+
}
|
|
1991
|
+
for (let i = start;i <= end; i += step) {
|
|
1992
|
+
if (i >= min && i <= max)
|
|
1993
|
+
results.add(i);
|
|
1994
|
+
}
|
|
1995
|
+
} else if (trimmed === "*") {
|
|
1996
|
+
for (let i = min;i <= max; i++) {
|
|
1997
|
+
results.add(i);
|
|
1998
|
+
}
|
|
1999
|
+
} else if (trimmed.includes("-")) {
|
|
2000
|
+
const dashParts = trimmed.split("-");
|
|
2001
|
+
const lo = parseInt(dashParts[0] ?? "0", 10);
|
|
2002
|
+
const hi = parseInt(dashParts[1] ?? "0", 10);
|
|
2003
|
+
if (isNaN(lo) || isNaN(hi)) {
|
|
2004
|
+
throw new Error(`Invalid range in cron field: ${field}`);
|
|
2005
|
+
}
|
|
2006
|
+
for (let i = lo;i <= hi; i++) {
|
|
2007
|
+
if (i >= min && i <= max)
|
|
2008
|
+
results.add(i);
|
|
2009
|
+
}
|
|
2010
|
+
} else {
|
|
2011
|
+
const val = parseInt(trimmed, 10);
|
|
2012
|
+
if (isNaN(val)) {
|
|
2013
|
+
throw new Error(`Invalid value in cron field: ${field}`);
|
|
2014
|
+
}
|
|
2015
|
+
if (val >= min && val <= max)
|
|
2016
|
+
results.add(val);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
return Array.from(results).sort((a, b) => a - b);
|
|
2020
|
+
}
|
|
2021
|
+
function parseCron(expression) {
|
|
2022
|
+
const fields = expression.trim().split(/\s+/);
|
|
2023
|
+
if (fields.length !== 5) {
|
|
2024
|
+
throw new Error(`Invalid cron expression "${expression}": expected 5 fields, got ${fields.length}`);
|
|
2025
|
+
}
|
|
2026
|
+
return {
|
|
2027
|
+
minutes: parseCronField(fields[0], 0, 59),
|
|
2028
|
+
hours: parseCronField(fields[1], 0, 23),
|
|
2029
|
+
daysOfMonth: parseCronField(fields[2], 1, 31),
|
|
2030
|
+
months: parseCronField(fields[3], 1, 12),
|
|
2031
|
+
daysOfWeek: parseCronField(fields[4], 0, 6)
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
function shouldRunAt(cronExpression, date) {
|
|
2035
|
+
const cron = parseCron(cronExpression);
|
|
2036
|
+
const minute = date.getMinutes();
|
|
2037
|
+
const hour = date.getHours();
|
|
2038
|
+
const dayOfMonth = date.getDate();
|
|
2039
|
+
const month = date.getMonth() + 1;
|
|
2040
|
+
const dayOfWeek = date.getDay();
|
|
2041
|
+
return cron.minutes.includes(minute) && cron.hours.includes(hour) && cron.daysOfMonth.includes(dayOfMonth) && cron.months.includes(month) && cron.daysOfWeek.includes(dayOfWeek);
|
|
2042
|
+
}
|
|
2043
|
+
function getNextRunTime(cronExpression, after) {
|
|
2044
|
+
parseCron(cronExpression);
|
|
2045
|
+
const start = after ? new Date(after.getTime()) : new Date;
|
|
2046
|
+
start.setSeconds(0, 0);
|
|
2047
|
+
start.setMinutes(start.getMinutes() + 1);
|
|
2048
|
+
const maxDate = new Date(start.getTime() + 366 * 24 * 60 * 60 * 1000);
|
|
2049
|
+
const cursor = new Date(start.getTime());
|
|
2050
|
+
while (cursor.getTime() <= maxDate.getTime()) {
|
|
2051
|
+
if (shouldRunAt(cronExpression, cursor)) {
|
|
2052
|
+
return cursor;
|
|
2053
|
+
}
|
|
2054
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
2055
|
+
}
|
|
2056
|
+
throw new Error(`No matching time found for cron expression "${cronExpression}" within 366 days`);
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
class Scheduler {
|
|
2060
|
+
interval = null;
|
|
2061
|
+
running = new Set;
|
|
2062
|
+
checkIntervalMs;
|
|
2063
|
+
onEvent;
|
|
2064
|
+
constructor(options) {
|
|
2065
|
+
this.checkIntervalMs = options?.checkIntervalMs ?? 60000;
|
|
2066
|
+
this.onEvent = options?.onEvent;
|
|
2067
|
+
}
|
|
2068
|
+
start() {
|
|
2069
|
+
if (this.interval)
|
|
2070
|
+
return;
|
|
2071
|
+
this.tick().catch(() => {});
|
|
2072
|
+
this.interval = setInterval(() => {
|
|
2073
|
+
this.tick().catch(() => {});
|
|
2074
|
+
}, this.checkIntervalMs);
|
|
2075
|
+
}
|
|
2076
|
+
stop() {
|
|
2077
|
+
if (this.interval) {
|
|
2078
|
+
clearInterval(this.interval);
|
|
2079
|
+
this.interval = null;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
async tick() {
|
|
2083
|
+
const now2 = new Date;
|
|
2084
|
+
now2.setSeconds(0, 0);
|
|
2085
|
+
const schedules = getEnabledSchedules();
|
|
2086
|
+
for (const schedule of schedules) {
|
|
2087
|
+
if (this.running.has(schedule.id))
|
|
2088
|
+
continue;
|
|
2089
|
+
if (shouldRunAt(schedule.cronExpression, now2)) {
|
|
2090
|
+
this.running.add(schedule.id);
|
|
2091
|
+
this.emit({
|
|
2092
|
+
type: "schedule:triggered",
|
|
2093
|
+
scheduleId: schedule.id,
|
|
2094
|
+
scheduleName: schedule.name,
|
|
2095
|
+
timestamp: new Date().toISOString()
|
|
2096
|
+
});
|
|
2097
|
+
this.executeSchedule(schedule).then(({ runId }) => {
|
|
2098
|
+
const nextRun = getNextRunTime(schedule.cronExpression, new Date);
|
|
2099
|
+
updateLastRun(schedule.id, runId, nextRun.toISOString());
|
|
2100
|
+
this.emit({
|
|
2101
|
+
type: "schedule:completed",
|
|
2102
|
+
scheduleId: schedule.id,
|
|
2103
|
+
scheduleName: schedule.name,
|
|
2104
|
+
runId,
|
|
2105
|
+
timestamp: new Date().toISOString()
|
|
2106
|
+
});
|
|
2107
|
+
}).catch((err) => {
|
|
2108
|
+
this.emit({
|
|
2109
|
+
type: "schedule:failed",
|
|
2110
|
+
scheduleId: schedule.id,
|
|
2111
|
+
scheduleName: schedule.name,
|
|
2112
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2113
|
+
timestamp: new Date().toISOString()
|
|
2114
|
+
});
|
|
2115
|
+
}).finally(() => {
|
|
2116
|
+
this.running.delete(schedule.id);
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
async runScheduleNow(scheduleId) {
|
|
2122
|
+
const schedule = getSchedule(scheduleId);
|
|
2123
|
+
if (!schedule) {
|
|
2124
|
+
throw new ScheduleNotFoundError(scheduleId);
|
|
2125
|
+
}
|
|
2126
|
+
this.running.add(schedule.id);
|
|
2127
|
+
this.emit({
|
|
2128
|
+
type: "schedule:triggered",
|
|
2129
|
+
scheduleId: schedule.id,
|
|
2130
|
+
scheduleName: schedule.name,
|
|
2131
|
+
timestamp: new Date().toISOString()
|
|
2132
|
+
});
|
|
2133
|
+
try {
|
|
2134
|
+
const { runId } = await this.executeSchedule(schedule);
|
|
2135
|
+
const nextRun = getNextRunTime(schedule.cronExpression, new Date);
|
|
2136
|
+
updateLastRun(schedule.id, runId, nextRun.toISOString());
|
|
2137
|
+
this.emit({
|
|
2138
|
+
type: "schedule:completed",
|
|
2139
|
+
scheduleId: schedule.id,
|
|
2140
|
+
scheduleName: schedule.name,
|
|
2141
|
+
runId,
|
|
2142
|
+
timestamp: new Date().toISOString()
|
|
2143
|
+
});
|
|
2144
|
+
} catch (err) {
|
|
2145
|
+
this.emit({
|
|
2146
|
+
type: "schedule:failed",
|
|
2147
|
+
scheduleId: schedule.id,
|
|
2148
|
+
scheduleName: schedule.name,
|
|
2149
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2150
|
+
timestamp: new Date().toISOString()
|
|
2151
|
+
});
|
|
2152
|
+
throw err;
|
|
2153
|
+
} finally {
|
|
2154
|
+
this.running.delete(schedule.id);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
async executeSchedule(schedule) {
|
|
2158
|
+
const { run } = await runByFilter({
|
|
2159
|
+
url: schedule.url,
|
|
2160
|
+
model: schedule.model ?? undefined,
|
|
2161
|
+
headed: schedule.headed,
|
|
2162
|
+
parallel: schedule.parallel,
|
|
2163
|
+
timeout: schedule.timeoutMs ?? undefined,
|
|
2164
|
+
tags: schedule.scenarioFilter.tags,
|
|
2165
|
+
priority: schedule.scenarioFilter.priority,
|
|
2166
|
+
scenarioIds: schedule.scenarioFilter.scenarioIds
|
|
2167
|
+
});
|
|
2168
|
+
return { runId: run.id };
|
|
2169
|
+
}
|
|
2170
|
+
emit(event) {
|
|
2171
|
+
if (this.onEvent) {
|
|
2172
|
+
this.onEvent(event);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
|
|
1427
2177
|
// src/server/index.ts
|
|
1428
2178
|
function parseUrl(req) {
|
|
1429
2179
|
const url = new URL(req.url);
|
|
@@ -1603,6 +2353,49 @@ async function handleRequest(req) {
|
|
|
1603
2353
|
}
|
|
1604
2354
|
});
|
|
1605
2355
|
}
|
|
2356
|
+
if (pathname === "/api/schedules" && method === "GET") {
|
|
2357
|
+
const projectId = searchParams.get("projectId") ?? undefined;
|
|
2358
|
+
const enabled = searchParams.get("enabled");
|
|
2359
|
+
const limit = searchParams.get("limit");
|
|
2360
|
+
const schedules = listSchedules({
|
|
2361
|
+
projectId,
|
|
2362
|
+
enabled: enabled !== null ? enabled === "true" : undefined,
|
|
2363
|
+
limit: limit ? parseInt(limit, 10) : undefined
|
|
2364
|
+
});
|
|
2365
|
+
return jsonResponse(schedules);
|
|
2366
|
+
}
|
|
2367
|
+
if (pathname === "/api/schedules" && method === "POST") {
|
|
2368
|
+
try {
|
|
2369
|
+
const body = await req.json();
|
|
2370
|
+
const schedule = createSchedule(body);
|
|
2371
|
+
const nextRun = getNextRunTime(schedule.cronExpression);
|
|
2372
|
+
return jsonResponse({ ...schedule, nextRunAt: nextRun.toISOString() }, 201);
|
|
2373
|
+
} catch (e) {
|
|
2374
|
+
return errorResponse(e instanceof Error ? e.message : String(e), 400);
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
const scheduleMatch = pathname.match(/^\/api\/schedules\/([^/]+)$/);
|
|
2378
|
+
if (scheduleMatch && method === "GET") {
|
|
2379
|
+
const schedule = getSchedule(scheduleMatch[1]);
|
|
2380
|
+
if (!schedule)
|
|
2381
|
+
return errorResponse("Schedule not found", 404);
|
|
2382
|
+
return jsonResponse(schedule);
|
|
2383
|
+
}
|
|
2384
|
+
if (scheduleMatch && method === "PUT") {
|
|
2385
|
+
try {
|
|
2386
|
+
const body = await req.json();
|
|
2387
|
+
const schedule = updateSchedule(scheduleMatch[1], body);
|
|
2388
|
+
return jsonResponse(schedule);
|
|
2389
|
+
} catch (e) {
|
|
2390
|
+
return errorResponse(e instanceof Error ? e.message : String(e), 400);
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
if (scheduleMatch && method === "DELETE") {
|
|
2394
|
+
const deleted = deleteSchedule(scheduleMatch[1]);
|
|
2395
|
+
if (!deleted)
|
|
2396
|
+
return errorResponse("Schedule not found", 404);
|
|
2397
|
+
return jsonResponse({ deleted: true });
|
|
2398
|
+
}
|
|
1606
2399
|
if (!pathname.startsWith("/api")) {
|
|
1607
2400
|
const dashboardDir = join4(import.meta.dir, "..", "..", "dashboard", "dist");
|
|
1608
2401
|
if (!existsSync4(dashboardDir)) {
|