@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/mcp/index.js
CHANGED
|
@@ -4080,7 +4080,30 @@ function screenshotFromRow(row) {
|
|
|
4080
4080
|
filePath: row.file_path,
|
|
4081
4081
|
width: row.width,
|
|
4082
4082
|
height: row.height,
|
|
4083
|
-
timestamp: row.timestamp
|
|
4083
|
+
timestamp: row.timestamp,
|
|
4084
|
+
description: row.description,
|
|
4085
|
+
pageUrl: row.page_url,
|
|
4086
|
+
thumbnailPath: row.thumbnail_path
|
|
4087
|
+
};
|
|
4088
|
+
}
|
|
4089
|
+
function scheduleFromRow(row) {
|
|
4090
|
+
return {
|
|
4091
|
+
id: row.id,
|
|
4092
|
+
projectId: row.project_id,
|
|
4093
|
+
name: row.name,
|
|
4094
|
+
cronExpression: row.cron_expression,
|
|
4095
|
+
url: row.url,
|
|
4096
|
+
scenarioFilter: JSON.parse(row.scenario_filter),
|
|
4097
|
+
model: row.model,
|
|
4098
|
+
headed: row.headed === 1,
|
|
4099
|
+
parallel: row.parallel,
|
|
4100
|
+
timeoutMs: row.timeout_ms,
|
|
4101
|
+
enabled: row.enabled === 1,
|
|
4102
|
+
lastRunId: row.last_run_id,
|
|
4103
|
+
lastRunAt: row.last_run_at,
|
|
4104
|
+
nextRunAt: row.next_run_at,
|
|
4105
|
+
createdAt: row.created_at,
|
|
4106
|
+
updatedAt: row.updated_at
|
|
4084
4107
|
};
|
|
4085
4108
|
}
|
|
4086
4109
|
class VersionConflictError extends Error {
|
|
@@ -4110,6 +4133,12 @@ class TodosConnectionError extends Error {
|
|
|
4110
4133
|
this.name = "TodosConnectionError";
|
|
4111
4134
|
}
|
|
4112
4135
|
}
|
|
4136
|
+
class ScheduleNotFoundError extends Error {
|
|
4137
|
+
constructor(id) {
|
|
4138
|
+
super(`Schedule not found: ${id}`);
|
|
4139
|
+
this.name = "ScheduleNotFoundError";
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4113
4142
|
|
|
4114
4143
|
// src/db/database.ts
|
|
4115
4144
|
import { Database } from "bun:sqlite";
|
|
@@ -4239,6 +4268,58 @@ var MIGRATIONS = [
|
|
|
4239
4268
|
`
|
|
4240
4269
|
ALTER TABLE projects ADD COLUMN scenario_prefix TEXT DEFAULT 'TST';
|
|
4241
4270
|
ALTER TABLE projects ADD COLUMN scenario_counter INTEGER DEFAULT 0;
|
|
4271
|
+
`,
|
|
4272
|
+
`
|
|
4273
|
+
CREATE TABLE IF NOT EXISTS schedules (
|
|
4274
|
+
id TEXT PRIMARY KEY,
|
|
4275
|
+
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
4276
|
+
name TEXT NOT NULL,
|
|
4277
|
+
cron_expression TEXT NOT NULL,
|
|
4278
|
+
url TEXT NOT NULL,
|
|
4279
|
+
scenario_filter TEXT NOT NULL DEFAULT '{}',
|
|
4280
|
+
model TEXT,
|
|
4281
|
+
headed INTEGER NOT NULL DEFAULT 0,
|
|
4282
|
+
parallel INTEGER NOT NULL DEFAULT 1,
|
|
4283
|
+
timeout_ms INTEGER,
|
|
4284
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
4285
|
+
last_run_id TEXT REFERENCES runs(id) ON DELETE SET NULL,
|
|
4286
|
+
last_run_at TEXT,
|
|
4287
|
+
next_run_at TEXT,
|
|
4288
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
4289
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
4290
|
+
);
|
|
4291
|
+
|
|
4292
|
+
CREATE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
|
|
4293
|
+
CREATE INDEX IF NOT EXISTS idx_schedules_enabled ON schedules(enabled);
|
|
4294
|
+
CREATE INDEX IF NOT EXISTS idx_schedules_next_run ON schedules(next_run_at);
|
|
4295
|
+
`,
|
|
4296
|
+
`
|
|
4297
|
+
ALTER TABLE screenshots ADD COLUMN description TEXT;
|
|
4298
|
+
ALTER TABLE screenshots ADD COLUMN page_url TEXT;
|
|
4299
|
+
ALTER TABLE screenshots ADD COLUMN thumbnail_path TEXT;
|
|
4300
|
+
`,
|
|
4301
|
+
`
|
|
4302
|
+
CREATE TABLE IF NOT EXISTS auth_presets (
|
|
4303
|
+
id TEXT PRIMARY KEY,
|
|
4304
|
+
name TEXT NOT NULL UNIQUE,
|
|
4305
|
+
email TEXT NOT NULL,
|
|
4306
|
+
password TEXT NOT NULL,
|
|
4307
|
+
login_path TEXT DEFAULT '/login',
|
|
4308
|
+
metadata TEXT DEFAULT '{}',
|
|
4309
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
4310
|
+
);
|
|
4311
|
+
`,
|
|
4312
|
+
`
|
|
4313
|
+
CREATE TABLE IF NOT EXISTS webhooks (
|
|
4314
|
+
id TEXT PRIMARY KEY,
|
|
4315
|
+
url TEXT NOT NULL,
|
|
4316
|
+
events TEXT NOT NULL DEFAULT '["failed"]',
|
|
4317
|
+
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
4318
|
+
secret TEXT,
|
|
4319
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
4320
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
4321
|
+
);
|
|
4322
|
+
CREATE INDEX IF NOT EXISTS idx_webhooks_active ON webhooks(active);
|
|
4242
4323
|
`
|
|
4243
4324
|
];
|
|
4244
4325
|
function applyMigrations(database) {
|
|
@@ -4636,9 +4717,9 @@ function createScreenshot(input) {
|
|
|
4636
4717
|
const id = uuid();
|
|
4637
4718
|
const timestamp = now();
|
|
4638
4719
|
db2.query(`
|
|
4639
|
-
INSERT INTO screenshots (id, result_id, step_number, action, file_path, width, height, timestamp)
|
|
4640
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
4641
|
-
`).run(id, input.resultId, input.stepNumber, input.action, input.filePath, input.width, input.height, timestamp);
|
|
4720
|
+
INSERT INTO screenshots (id, result_id, step_number, action, file_path, width, height, timestamp, description, page_url, thumbnail_path)
|
|
4721
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
4722
|
+
`).run(id, input.resultId, input.stepNumber, input.action, input.filePath, input.width, input.height, timestamp, input.description ?? null, input.pageUrl ?? null, input.thumbnailPath ?? null);
|
|
4642
4723
|
return getScreenshot(id);
|
|
4643
4724
|
}
|
|
4644
4725
|
function getScreenshot(id) {
|
|
@@ -4755,7 +4836,7 @@ async function closeBrowser(browser) {
|
|
|
4755
4836
|
}
|
|
4756
4837
|
|
|
4757
4838
|
// src/lib/screenshotter.ts
|
|
4758
|
-
import { mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
|
|
4839
|
+
import { mkdirSync as mkdirSync2, existsSync as existsSync2, writeFileSync } from "fs";
|
|
4759
4840
|
import { join as join2 } from "path";
|
|
4760
4841
|
import { homedir as homedir2 } from "os";
|
|
4761
4842
|
function slugify(text) {
|
|
@@ -4764,16 +4845,51 @@ function slugify(text) {
|
|
|
4764
4845
|
function generateFilename(stepNumber, action) {
|
|
4765
4846
|
const padded = String(stepNumber).padStart(3, "0");
|
|
4766
4847
|
const slug = slugify(action);
|
|
4767
|
-
return `${padded}
|
|
4848
|
+
return `${padded}_${slug}.png`;
|
|
4849
|
+
}
|
|
4850
|
+
function formatDate(date) {
|
|
4851
|
+
return date.toISOString().slice(0, 10);
|
|
4768
4852
|
}
|
|
4769
|
-
function
|
|
4770
|
-
return
|
|
4853
|
+
function formatTime(date) {
|
|
4854
|
+
return date.toISOString().slice(11, 19).replace(/:/g, "-");
|
|
4855
|
+
}
|
|
4856
|
+
function getScreenshotDir(baseDir, runId, scenarioSlug, projectName, timestamp) {
|
|
4857
|
+
const now2 = timestamp ?? new Date;
|
|
4858
|
+
const project = projectName ?? "default";
|
|
4859
|
+
const dateDir = formatDate(now2);
|
|
4860
|
+
const timeDir = `${formatTime(now2)}_${runId.slice(0, 8)}`;
|
|
4861
|
+
return join2(baseDir, project, dateDir, timeDir, scenarioSlug);
|
|
4771
4862
|
}
|
|
4772
4863
|
function ensureDir(dirPath) {
|
|
4773
4864
|
if (!existsSync2(dirPath)) {
|
|
4774
4865
|
mkdirSync2(dirPath, { recursive: true });
|
|
4775
4866
|
}
|
|
4776
4867
|
}
|
|
4868
|
+
function writeMetaSidecar(screenshotPath, meta) {
|
|
4869
|
+
const metaPath = screenshotPath.replace(/\.png$/, ".meta.json").replace(/\.jpeg$/, ".meta.json");
|
|
4870
|
+
try {
|
|
4871
|
+
writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf-8");
|
|
4872
|
+
} catch {}
|
|
4873
|
+
}
|
|
4874
|
+
async function generateThumbnail(page, screenshotDir, filename) {
|
|
4875
|
+
try {
|
|
4876
|
+
const thumbDir = join2(screenshotDir, "_thumbnail");
|
|
4877
|
+
ensureDir(thumbDir);
|
|
4878
|
+
const thumbFilename = filename.replace(/\.(png|jpeg)$/, ".thumb.$1");
|
|
4879
|
+
const thumbPath = join2(thumbDir, thumbFilename);
|
|
4880
|
+
const viewport = page.viewportSize();
|
|
4881
|
+
if (viewport) {
|
|
4882
|
+
await page.screenshot({
|
|
4883
|
+
path: thumbPath,
|
|
4884
|
+
type: "png",
|
|
4885
|
+
clip: { x: 0, y: 0, width: Math.min(viewport.width, 1280), height: Math.min(viewport.height, 720) }
|
|
4886
|
+
});
|
|
4887
|
+
}
|
|
4888
|
+
return thumbPath;
|
|
4889
|
+
} catch {
|
|
4890
|
+
return null;
|
|
4891
|
+
}
|
|
4892
|
+
}
|
|
4777
4893
|
var DEFAULT_BASE_DIR = join2(homedir2(), ".testers", "screenshots");
|
|
4778
4894
|
|
|
4779
4895
|
class Screenshotter {
|
|
@@ -4781,15 +4897,20 @@ class Screenshotter {
|
|
|
4781
4897
|
format;
|
|
4782
4898
|
quality;
|
|
4783
4899
|
fullPage;
|
|
4900
|
+
projectName;
|
|
4901
|
+
runTimestamp;
|
|
4784
4902
|
constructor(options = {}) {
|
|
4785
4903
|
this.baseDir = options.baseDir ?? DEFAULT_BASE_DIR;
|
|
4786
4904
|
this.format = options.format ?? "png";
|
|
4787
4905
|
this.quality = options.quality ?? 90;
|
|
4788
4906
|
this.fullPage = options.fullPage ?? false;
|
|
4907
|
+
this.projectName = options.projectName ?? "default";
|
|
4908
|
+
this.runTimestamp = new Date;
|
|
4789
4909
|
}
|
|
4790
4910
|
async capture(page, options) {
|
|
4791
|
-
const
|
|
4792
|
-
const
|
|
4911
|
+
const action = options.description ?? options.action;
|
|
4912
|
+
const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug, this.projectName, this.runTimestamp);
|
|
4913
|
+
const filename = generateFilename(options.stepNumber, action);
|
|
4793
4914
|
const filePath = join2(dir, filename);
|
|
4794
4915
|
ensureDir(dir);
|
|
4795
4916
|
await page.screenshot({
|
|
@@ -4799,16 +4920,32 @@ class Screenshotter {
|
|
|
4799
4920
|
quality: this.format === "jpeg" ? this.quality : undefined
|
|
4800
4921
|
});
|
|
4801
4922
|
const viewport = page.viewportSize() ?? { width: 0, height: 0 };
|
|
4923
|
+
const pageUrl = page.url();
|
|
4924
|
+
const timestamp = new Date().toISOString();
|
|
4925
|
+
writeMetaSidecar(filePath, {
|
|
4926
|
+
stepNumber: options.stepNumber,
|
|
4927
|
+
action: options.action,
|
|
4928
|
+
description: options.description ?? null,
|
|
4929
|
+
pageUrl,
|
|
4930
|
+
viewport,
|
|
4931
|
+
timestamp,
|
|
4932
|
+
filePath
|
|
4933
|
+
});
|
|
4934
|
+
const thumbnailPath = await generateThumbnail(page, dir, filename);
|
|
4802
4935
|
return {
|
|
4803
4936
|
filePath,
|
|
4804
4937
|
width: viewport.width,
|
|
4805
4938
|
height: viewport.height,
|
|
4806
|
-
timestamp
|
|
4939
|
+
timestamp,
|
|
4940
|
+
description: options.description ?? null,
|
|
4941
|
+
pageUrl,
|
|
4942
|
+
thumbnailPath
|
|
4807
4943
|
};
|
|
4808
4944
|
}
|
|
4809
4945
|
async captureFullPage(page, options) {
|
|
4810
|
-
const
|
|
4811
|
-
const
|
|
4946
|
+
const action = options.description ?? options.action;
|
|
4947
|
+
const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug, this.projectName, this.runTimestamp);
|
|
4948
|
+
const filename = generateFilename(options.stepNumber, action);
|
|
4812
4949
|
const filePath = join2(dir, filename);
|
|
4813
4950
|
ensureDir(dir);
|
|
4814
4951
|
await page.screenshot({
|
|
@@ -4818,16 +4955,32 @@ class Screenshotter {
|
|
|
4818
4955
|
quality: this.format === "jpeg" ? this.quality : undefined
|
|
4819
4956
|
});
|
|
4820
4957
|
const viewport = page.viewportSize() ?? { width: 0, height: 0 };
|
|
4958
|
+
const pageUrl = page.url();
|
|
4959
|
+
const timestamp = new Date().toISOString();
|
|
4960
|
+
writeMetaSidecar(filePath, {
|
|
4961
|
+
stepNumber: options.stepNumber,
|
|
4962
|
+
action: options.action,
|
|
4963
|
+
description: options.description ?? null,
|
|
4964
|
+
pageUrl,
|
|
4965
|
+
viewport,
|
|
4966
|
+
timestamp,
|
|
4967
|
+
filePath
|
|
4968
|
+
});
|
|
4969
|
+
const thumbnailPath = await generateThumbnail(page, dir, filename);
|
|
4821
4970
|
return {
|
|
4822
4971
|
filePath,
|
|
4823
4972
|
width: viewport.width,
|
|
4824
4973
|
height: viewport.height,
|
|
4825
|
-
timestamp
|
|
4974
|
+
timestamp,
|
|
4975
|
+
description: options.description ?? null,
|
|
4976
|
+
pageUrl,
|
|
4977
|
+
thumbnailPath
|
|
4826
4978
|
};
|
|
4827
4979
|
}
|
|
4828
4980
|
async captureElement(page, selector, options) {
|
|
4829
|
-
const
|
|
4830
|
-
const
|
|
4981
|
+
const action = options.description ?? options.action;
|
|
4982
|
+
const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug, this.projectName, this.runTimestamp);
|
|
4983
|
+
const filename = generateFilename(options.stepNumber, action);
|
|
4831
4984
|
const filePath = join2(dir, filename);
|
|
4832
4985
|
ensureDir(dir);
|
|
4833
4986
|
await page.locator(selector).screenshot({
|
|
@@ -4836,11 +4989,25 @@ class Screenshotter {
|
|
|
4836
4989
|
quality: this.format === "jpeg" ? this.quality : undefined
|
|
4837
4990
|
});
|
|
4838
4991
|
const viewport = page.viewportSize() ?? { width: 0, height: 0 };
|
|
4992
|
+
const pageUrl = page.url();
|
|
4993
|
+
const timestamp = new Date().toISOString();
|
|
4994
|
+
writeMetaSidecar(filePath, {
|
|
4995
|
+
stepNumber: options.stepNumber,
|
|
4996
|
+
action: options.action,
|
|
4997
|
+
description: options.description ?? null,
|
|
4998
|
+
pageUrl,
|
|
4999
|
+
viewport,
|
|
5000
|
+
timestamp,
|
|
5001
|
+
filePath
|
|
5002
|
+
});
|
|
4839
5003
|
return {
|
|
4840
5004
|
filePath,
|
|
4841
5005
|
width: viewport.width,
|
|
4842
5006
|
height: viewport.height,
|
|
4843
|
-
timestamp
|
|
5007
|
+
timestamp,
|
|
5008
|
+
description: options.description ?? null,
|
|
5009
|
+
pageUrl,
|
|
5010
|
+
thumbnailPath: null
|
|
4844
5011
|
};
|
|
4845
5012
|
}
|
|
4846
5013
|
}
|
|
@@ -5016,6 +5183,127 @@ var BROWSER_TOOLS = [
|
|
|
5016
5183
|
required: ["text"]
|
|
5017
5184
|
}
|
|
5018
5185
|
},
|
|
5186
|
+
{
|
|
5187
|
+
name: "scroll",
|
|
5188
|
+
description: "Scroll the page up or down by a given amount of pixels.",
|
|
5189
|
+
input_schema: {
|
|
5190
|
+
type: "object",
|
|
5191
|
+
properties: {
|
|
5192
|
+
direction: {
|
|
5193
|
+
type: "string",
|
|
5194
|
+
enum: ["up", "down"],
|
|
5195
|
+
description: "Direction to scroll."
|
|
5196
|
+
},
|
|
5197
|
+
amount: {
|
|
5198
|
+
type: "number",
|
|
5199
|
+
description: "Number of pixels to scroll (default: 500)."
|
|
5200
|
+
}
|
|
5201
|
+
},
|
|
5202
|
+
required: ["direction"]
|
|
5203
|
+
}
|
|
5204
|
+
},
|
|
5205
|
+
{
|
|
5206
|
+
name: "get_page_html",
|
|
5207
|
+
description: "Get simplified HTML of the page body content, truncated to 8000 characters.",
|
|
5208
|
+
input_schema: {
|
|
5209
|
+
type: "object",
|
|
5210
|
+
properties: {},
|
|
5211
|
+
required: []
|
|
5212
|
+
}
|
|
5213
|
+
},
|
|
5214
|
+
{
|
|
5215
|
+
name: "get_elements",
|
|
5216
|
+
description: "List elements matching a CSS selector with their text, tag name, and key attributes (max 20 results).",
|
|
5217
|
+
input_schema: {
|
|
5218
|
+
type: "object",
|
|
5219
|
+
properties: {
|
|
5220
|
+
selector: {
|
|
5221
|
+
type: "string",
|
|
5222
|
+
description: "CSS selector to match elements."
|
|
5223
|
+
}
|
|
5224
|
+
},
|
|
5225
|
+
required: ["selector"]
|
|
5226
|
+
}
|
|
5227
|
+
},
|
|
5228
|
+
{
|
|
5229
|
+
name: "wait_for_navigation",
|
|
5230
|
+
description: "Wait for page navigation/load to complete (network idle).",
|
|
5231
|
+
input_schema: {
|
|
5232
|
+
type: "object",
|
|
5233
|
+
properties: {
|
|
5234
|
+
timeout: {
|
|
5235
|
+
type: "number",
|
|
5236
|
+
description: "Maximum time to wait in milliseconds (default: 10000)."
|
|
5237
|
+
}
|
|
5238
|
+
},
|
|
5239
|
+
required: []
|
|
5240
|
+
}
|
|
5241
|
+
},
|
|
5242
|
+
{
|
|
5243
|
+
name: "get_page_title",
|
|
5244
|
+
description: "Get the document title of the current page.",
|
|
5245
|
+
input_schema: {
|
|
5246
|
+
type: "object",
|
|
5247
|
+
properties: {},
|
|
5248
|
+
required: []
|
|
5249
|
+
}
|
|
5250
|
+
},
|
|
5251
|
+
{
|
|
5252
|
+
name: "count_elements",
|
|
5253
|
+
description: "Count the number of elements matching a CSS selector.",
|
|
5254
|
+
input_schema: {
|
|
5255
|
+
type: "object",
|
|
5256
|
+
properties: {
|
|
5257
|
+
selector: {
|
|
5258
|
+
type: "string",
|
|
5259
|
+
description: "CSS selector to count matching elements."
|
|
5260
|
+
}
|
|
5261
|
+
},
|
|
5262
|
+
required: ["selector"]
|
|
5263
|
+
}
|
|
5264
|
+
},
|
|
5265
|
+
{
|
|
5266
|
+
name: "hover",
|
|
5267
|
+
description: "Hover over an element matching the given CSS selector.",
|
|
5268
|
+
input_schema: {
|
|
5269
|
+
type: "object",
|
|
5270
|
+
properties: {
|
|
5271
|
+
selector: {
|
|
5272
|
+
type: "string",
|
|
5273
|
+
description: "CSS selector of the element to hover over."
|
|
5274
|
+
}
|
|
5275
|
+
},
|
|
5276
|
+
required: ["selector"]
|
|
5277
|
+
}
|
|
5278
|
+
},
|
|
5279
|
+
{
|
|
5280
|
+
name: "check",
|
|
5281
|
+
description: "Check a checkbox matching the given CSS selector.",
|
|
5282
|
+
input_schema: {
|
|
5283
|
+
type: "object",
|
|
5284
|
+
properties: {
|
|
5285
|
+
selector: {
|
|
5286
|
+
type: "string",
|
|
5287
|
+
description: "CSS selector of the checkbox to check."
|
|
5288
|
+
}
|
|
5289
|
+
},
|
|
5290
|
+
required: ["selector"]
|
|
5291
|
+
}
|
|
5292
|
+
},
|
|
5293
|
+
{
|
|
5294
|
+
name: "uncheck",
|
|
5295
|
+
description: "Uncheck a checkbox matching the given CSS selector.",
|
|
5296
|
+
input_schema: {
|
|
5297
|
+
type: "object",
|
|
5298
|
+
properties: {
|
|
5299
|
+
selector: {
|
|
5300
|
+
type: "string",
|
|
5301
|
+
description: "CSS selector of the checkbox to uncheck."
|
|
5302
|
+
}
|
|
5303
|
+
},
|
|
5304
|
+
required: ["selector"]
|
|
5305
|
+
}
|
|
5306
|
+
},
|
|
5019
5307
|
{
|
|
5020
5308
|
name: "report_result",
|
|
5021
5309
|
description: "Report the final test result. Call this when you have completed testing the scenario. This MUST be the last tool you call.",
|
|
@@ -5147,6 +5435,113 @@ async function executeTool(page, screenshotter, toolName, toolInput, context) {
|
|
|
5147
5435
|
return { result: "false" };
|
|
5148
5436
|
}
|
|
5149
5437
|
}
|
|
5438
|
+
case "scroll": {
|
|
5439
|
+
const direction = toolInput.direction;
|
|
5440
|
+
const amount = typeof toolInput.amount === "number" ? toolInput.amount : 500;
|
|
5441
|
+
const scrollY = direction === "down" ? amount : -amount;
|
|
5442
|
+
await page.evaluate((y) => window.scrollBy(0, y), scrollY);
|
|
5443
|
+
const screenshot = await screenshotter.capture(page, {
|
|
5444
|
+
runId: context.runId,
|
|
5445
|
+
scenarioSlug: context.scenarioSlug,
|
|
5446
|
+
stepNumber: context.stepNumber,
|
|
5447
|
+
action: "scroll"
|
|
5448
|
+
});
|
|
5449
|
+
return {
|
|
5450
|
+
result: `Scrolled ${direction} by ${amount}px`,
|
|
5451
|
+
screenshot
|
|
5452
|
+
};
|
|
5453
|
+
}
|
|
5454
|
+
case "get_page_html": {
|
|
5455
|
+
const html = await page.evaluate(() => document.body.innerHTML);
|
|
5456
|
+
const truncated = html.length > 8000 ? html.slice(0, 8000) + "..." : html;
|
|
5457
|
+
return {
|
|
5458
|
+
result: truncated
|
|
5459
|
+
};
|
|
5460
|
+
}
|
|
5461
|
+
case "get_elements": {
|
|
5462
|
+
const selector = toolInput.selector;
|
|
5463
|
+
const allElements = await page.locator(selector).all();
|
|
5464
|
+
const elements = allElements.slice(0, 20);
|
|
5465
|
+
const results = [];
|
|
5466
|
+
for (let i = 0;i < elements.length; i++) {
|
|
5467
|
+
const el = elements[i];
|
|
5468
|
+
const tagName = await el.evaluate((e) => e.tagName.toLowerCase());
|
|
5469
|
+
const textContent = await el.textContent() ?? "";
|
|
5470
|
+
const trimmedText = textContent.trim().slice(0, 100);
|
|
5471
|
+
const id = await el.getAttribute("id");
|
|
5472
|
+
const className = await el.getAttribute("class");
|
|
5473
|
+
const href = await el.getAttribute("href");
|
|
5474
|
+
const type = await el.getAttribute("type");
|
|
5475
|
+
const placeholder = await el.getAttribute("placeholder");
|
|
5476
|
+
const ariaLabel = await el.getAttribute("aria-label");
|
|
5477
|
+
const attrs = [];
|
|
5478
|
+
if (id)
|
|
5479
|
+
attrs.push(`id="${id}"`);
|
|
5480
|
+
if (className)
|
|
5481
|
+
attrs.push(`class="${className}"`);
|
|
5482
|
+
if (href)
|
|
5483
|
+
attrs.push(`href="${href}"`);
|
|
5484
|
+
if (type)
|
|
5485
|
+
attrs.push(`type="${type}"`);
|
|
5486
|
+
if (placeholder)
|
|
5487
|
+
attrs.push(`placeholder="${placeholder}"`);
|
|
5488
|
+
if (ariaLabel)
|
|
5489
|
+
attrs.push(`aria-label="${ariaLabel}"`);
|
|
5490
|
+
results.push(`[${i}] <${tagName}${attrs.length ? " " + attrs.join(" ") : ""}> ${trimmedText}`);
|
|
5491
|
+
}
|
|
5492
|
+
return {
|
|
5493
|
+
result: results.length > 0 ? results.join(`
|
|
5494
|
+
`) : `No elements found matching "${selector}"`
|
|
5495
|
+
};
|
|
5496
|
+
}
|
|
5497
|
+
case "wait_for_navigation": {
|
|
5498
|
+
const timeout = typeof toolInput.timeout === "number" ? toolInput.timeout : 1e4;
|
|
5499
|
+
await page.waitForLoadState("networkidle", { timeout });
|
|
5500
|
+
return {
|
|
5501
|
+
result: "Navigation/load completed"
|
|
5502
|
+
};
|
|
5503
|
+
}
|
|
5504
|
+
case "get_page_title": {
|
|
5505
|
+
const title = await page.title();
|
|
5506
|
+
return {
|
|
5507
|
+
result: title || "(no title)"
|
|
5508
|
+
};
|
|
5509
|
+
}
|
|
5510
|
+
case "count_elements": {
|
|
5511
|
+
const selector = toolInput.selector;
|
|
5512
|
+
const count = await page.locator(selector).count();
|
|
5513
|
+
return {
|
|
5514
|
+
result: `${count} element(s) matching "${selector}"`
|
|
5515
|
+
};
|
|
5516
|
+
}
|
|
5517
|
+
case "hover": {
|
|
5518
|
+
const selector = toolInput.selector;
|
|
5519
|
+
await page.hover(selector);
|
|
5520
|
+
const screenshot = await screenshotter.capture(page, {
|
|
5521
|
+
runId: context.runId,
|
|
5522
|
+
scenarioSlug: context.scenarioSlug,
|
|
5523
|
+
stepNumber: context.stepNumber,
|
|
5524
|
+
action: "hover"
|
|
5525
|
+
});
|
|
5526
|
+
return {
|
|
5527
|
+
result: `Hovered over: ${selector}`,
|
|
5528
|
+
screenshot
|
|
5529
|
+
};
|
|
5530
|
+
}
|
|
5531
|
+
case "check": {
|
|
5532
|
+
const selector = toolInput.selector;
|
|
5533
|
+
await page.check(selector);
|
|
5534
|
+
return {
|
|
5535
|
+
result: `Checked checkbox: ${selector}`
|
|
5536
|
+
};
|
|
5537
|
+
}
|
|
5538
|
+
case "uncheck": {
|
|
5539
|
+
const selector = toolInput.selector;
|
|
5540
|
+
await page.uncheck(selector);
|
|
5541
|
+
return {
|
|
5542
|
+
result: `Unchecked checkbox: ${selector}`
|
|
5543
|
+
};
|
|
5544
|
+
}
|
|
5150
5545
|
case "report_result": {
|
|
5151
5546
|
const status = toolInput.status;
|
|
5152
5547
|
const reasoning = toolInput.reasoning;
|
|
@@ -5173,13 +5568,26 @@ async function runAgentLoop(options) {
|
|
|
5173
5568
|
maxTurns = 30
|
|
5174
5569
|
} = options;
|
|
5175
5570
|
const systemPrompt = [
|
|
5176
|
-
"You are
|
|
5177
|
-
"
|
|
5178
|
-
"
|
|
5179
|
-
"
|
|
5180
|
-
"
|
|
5181
|
-
"
|
|
5182
|
-
|
|
5571
|
+
"You are an expert QA testing agent. Your job is to thoroughly test web application scenarios.",
|
|
5572
|
+
"You have browser tools to navigate, interact with, and inspect web pages.",
|
|
5573
|
+
"",
|
|
5574
|
+
"Strategy:",
|
|
5575
|
+
"1. First navigate to the target page and take a screenshot to understand the layout",
|
|
5576
|
+
"2. If you can't find an element, use get_elements or get_page_html to discover selectors",
|
|
5577
|
+
"3. Use scroll to discover content below the fold",
|
|
5578
|
+
"4. Use wait_for or wait_for_navigation after actions that trigger page loads",
|
|
5579
|
+
"5. Take screenshots after every meaningful state change",
|
|
5580
|
+
"6. Use assert_text and assert_visible to verify expected outcomes",
|
|
5581
|
+
"7. When done testing, call report_result with detailed pass/fail reasoning",
|
|
5582
|
+
"",
|
|
5583
|
+
"Tips:",
|
|
5584
|
+
"- Try multiple selector strategies: by text, by role, by class, by id",
|
|
5585
|
+
"- If a click triggers navigation, use wait_for_navigation after",
|
|
5586
|
+
"- For forms, fill all fields before submitting",
|
|
5587
|
+
"- Check for error messages after form submissions",
|
|
5588
|
+
"- Verify both positive and negative states"
|
|
5589
|
+
].join(`
|
|
5590
|
+
`);
|
|
5183
5591
|
const userParts = [
|
|
5184
5592
|
`**Scenario:** ${scenario.name}`,
|
|
5185
5593
|
`**Description:** ${scenario.description}`
|
|
@@ -5382,7 +5790,10 @@ async function runSingleScenario(scenario, runId, options) {
|
|
|
5382
5790
|
action: ss.action,
|
|
5383
5791
|
filePath: ss.filePath,
|
|
5384
5792
|
width: ss.width,
|
|
5385
|
-
height: ss.height
|
|
5793
|
+
height: ss.height,
|
|
5794
|
+
description: ss.description,
|
|
5795
|
+
pageUrl: ss.pageUrl,
|
|
5796
|
+
thumbnailPath: ss.thumbnailPath
|
|
5386
5797
|
});
|
|
5387
5798
|
emit({ type: "screenshot:captured", screenshotPath: ss.filePath, scenarioId: scenario.id, runId });
|
|
5388
5799
|
}
|
|
@@ -5591,6 +6002,345 @@ function importFromTodos(options = {}) {
|
|
|
5591
6002
|
return { imported, skipped };
|
|
5592
6003
|
}
|
|
5593
6004
|
|
|
6005
|
+
// src/db/schedules.ts
|
|
6006
|
+
function createSchedule(input) {
|
|
6007
|
+
const db2 = getDatabase();
|
|
6008
|
+
const id = uuid();
|
|
6009
|
+
const timestamp = now();
|
|
6010
|
+
db2.query(`
|
|
6011
|
+
INSERT INTO schedules (id, project_id, name, cron_expression, url, scenario_filter, model, headed, parallel, timeout_ms, enabled, created_at, updated_at)
|
|
6012
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
|
6013
|
+
`).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);
|
|
6014
|
+
return getSchedule(id);
|
|
6015
|
+
}
|
|
6016
|
+
function getSchedule(id) {
|
|
6017
|
+
const db2 = getDatabase();
|
|
6018
|
+
let row = db2.query("SELECT * FROM schedules WHERE id = ?").get(id);
|
|
6019
|
+
if (row)
|
|
6020
|
+
return scheduleFromRow(row);
|
|
6021
|
+
const fullId = resolvePartialId("schedules", id);
|
|
6022
|
+
if (fullId) {
|
|
6023
|
+
row = db2.query("SELECT * FROM schedules WHERE id = ?").get(fullId);
|
|
6024
|
+
if (row)
|
|
6025
|
+
return scheduleFromRow(row);
|
|
6026
|
+
}
|
|
6027
|
+
return null;
|
|
6028
|
+
}
|
|
6029
|
+
function listSchedules(filter) {
|
|
6030
|
+
const db2 = getDatabase();
|
|
6031
|
+
const conditions = [];
|
|
6032
|
+
const params = [];
|
|
6033
|
+
if (filter?.projectId) {
|
|
6034
|
+
conditions.push("project_id = ?");
|
|
6035
|
+
params.push(filter.projectId);
|
|
6036
|
+
}
|
|
6037
|
+
if (filter?.enabled !== undefined) {
|
|
6038
|
+
conditions.push("enabled = ?");
|
|
6039
|
+
params.push(filter.enabled ? 1 : 0);
|
|
6040
|
+
}
|
|
6041
|
+
let sql = "SELECT * FROM schedules";
|
|
6042
|
+
if (conditions.length > 0) {
|
|
6043
|
+
sql += " WHERE " + conditions.join(" AND ");
|
|
6044
|
+
}
|
|
6045
|
+
sql += " ORDER BY created_at DESC";
|
|
6046
|
+
if (filter?.limit) {
|
|
6047
|
+
sql += " LIMIT ?";
|
|
6048
|
+
params.push(filter.limit);
|
|
6049
|
+
}
|
|
6050
|
+
if (filter?.offset) {
|
|
6051
|
+
sql += " OFFSET ?";
|
|
6052
|
+
params.push(filter.offset);
|
|
6053
|
+
}
|
|
6054
|
+
const rows = db2.query(sql).all(...params);
|
|
6055
|
+
return rows.map(scheduleFromRow);
|
|
6056
|
+
}
|
|
6057
|
+
function updateSchedule(id, input) {
|
|
6058
|
+
const db2 = getDatabase();
|
|
6059
|
+
const existing = getSchedule(id);
|
|
6060
|
+
if (!existing) {
|
|
6061
|
+
throw new ScheduleNotFoundError(id);
|
|
6062
|
+
}
|
|
6063
|
+
const sets = [];
|
|
6064
|
+
const params = [];
|
|
6065
|
+
if (input.name !== undefined) {
|
|
6066
|
+
sets.push("name = ?");
|
|
6067
|
+
params.push(input.name);
|
|
6068
|
+
}
|
|
6069
|
+
if (input.cronExpression !== undefined) {
|
|
6070
|
+
sets.push("cron_expression = ?");
|
|
6071
|
+
params.push(input.cronExpression);
|
|
6072
|
+
}
|
|
6073
|
+
if (input.url !== undefined) {
|
|
6074
|
+
sets.push("url = ?");
|
|
6075
|
+
params.push(input.url);
|
|
6076
|
+
}
|
|
6077
|
+
if (input.scenarioFilter !== undefined) {
|
|
6078
|
+
sets.push("scenario_filter = ?");
|
|
6079
|
+
params.push(JSON.stringify(input.scenarioFilter));
|
|
6080
|
+
}
|
|
6081
|
+
if (input.model !== undefined) {
|
|
6082
|
+
sets.push("model = ?");
|
|
6083
|
+
params.push(input.model);
|
|
6084
|
+
}
|
|
6085
|
+
if (input.headed !== undefined) {
|
|
6086
|
+
sets.push("headed = ?");
|
|
6087
|
+
params.push(input.headed ? 1 : 0);
|
|
6088
|
+
}
|
|
6089
|
+
if (input.parallel !== undefined) {
|
|
6090
|
+
sets.push("parallel = ?");
|
|
6091
|
+
params.push(input.parallel);
|
|
6092
|
+
}
|
|
6093
|
+
if (input.timeoutMs !== undefined) {
|
|
6094
|
+
sets.push("timeout_ms = ?");
|
|
6095
|
+
params.push(input.timeoutMs);
|
|
6096
|
+
}
|
|
6097
|
+
if (input.enabled !== undefined) {
|
|
6098
|
+
sets.push("enabled = ?");
|
|
6099
|
+
params.push(input.enabled ? 1 : 0);
|
|
6100
|
+
}
|
|
6101
|
+
if (sets.length === 0) {
|
|
6102
|
+
return existing;
|
|
6103
|
+
}
|
|
6104
|
+
sets.push("updated_at = ?");
|
|
6105
|
+
params.push(now());
|
|
6106
|
+
params.push(existing.id);
|
|
6107
|
+
db2.query(`UPDATE schedules SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
6108
|
+
return getSchedule(existing.id);
|
|
6109
|
+
}
|
|
6110
|
+
function deleteSchedule(id) {
|
|
6111
|
+
const db2 = getDatabase();
|
|
6112
|
+
const schedule = getSchedule(id);
|
|
6113
|
+
if (!schedule)
|
|
6114
|
+
return false;
|
|
6115
|
+
const result = db2.query("DELETE FROM schedules WHERE id = ?").run(schedule.id);
|
|
6116
|
+
return result.changes > 0;
|
|
6117
|
+
}
|
|
6118
|
+
function getEnabledSchedules() {
|
|
6119
|
+
const db2 = getDatabase();
|
|
6120
|
+
const rows = db2.query("SELECT * FROM schedules WHERE enabled = 1 ORDER BY created_at DESC").all();
|
|
6121
|
+
return rows.map(scheduleFromRow);
|
|
6122
|
+
}
|
|
6123
|
+
function updateLastRun(id, runId, nextRunAt) {
|
|
6124
|
+
const db2 = getDatabase();
|
|
6125
|
+
const timestamp = now();
|
|
6126
|
+
db2.query(`
|
|
6127
|
+
UPDATE schedules SET last_run_id = ?, last_run_at = ?, next_run_at = ?, updated_at = ? WHERE id = ?
|
|
6128
|
+
`).run(runId, timestamp, nextRunAt, timestamp, id);
|
|
6129
|
+
}
|
|
6130
|
+
|
|
6131
|
+
// src/lib/scheduler.ts
|
|
6132
|
+
function parseCronField(field, min, max) {
|
|
6133
|
+
const results = new Set;
|
|
6134
|
+
const parts = field.split(",");
|
|
6135
|
+
for (const part of parts) {
|
|
6136
|
+
const trimmed = part.trim();
|
|
6137
|
+
if (trimmed.includes("/")) {
|
|
6138
|
+
const slashParts = trimmed.split("/");
|
|
6139
|
+
const rangePart = slashParts[0] ?? "*";
|
|
6140
|
+
const stepStr = slashParts[1] ?? "1";
|
|
6141
|
+
const step = parseInt(stepStr, 10);
|
|
6142
|
+
if (isNaN(step) || step <= 0) {
|
|
6143
|
+
throw new Error(`Invalid step value in cron field: ${field}`);
|
|
6144
|
+
}
|
|
6145
|
+
let start;
|
|
6146
|
+
let end;
|
|
6147
|
+
if (rangePart === "*") {
|
|
6148
|
+
start = min;
|
|
6149
|
+
end = max;
|
|
6150
|
+
} else if (rangePart.includes("-")) {
|
|
6151
|
+
const dashParts = rangePart.split("-");
|
|
6152
|
+
start = parseInt(dashParts[0] ?? "0", 10);
|
|
6153
|
+
end = parseInt(dashParts[1] ?? "0", 10);
|
|
6154
|
+
} else {
|
|
6155
|
+
start = parseInt(rangePart, 10);
|
|
6156
|
+
end = max;
|
|
6157
|
+
}
|
|
6158
|
+
for (let i = start;i <= end; i += step) {
|
|
6159
|
+
if (i >= min && i <= max)
|
|
6160
|
+
results.add(i);
|
|
6161
|
+
}
|
|
6162
|
+
} else if (trimmed === "*") {
|
|
6163
|
+
for (let i = min;i <= max; i++) {
|
|
6164
|
+
results.add(i);
|
|
6165
|
+
}
|
|
6166
|
+
} else if (trimmed.includes("-")) {
|
|
6167
|
+
const dashParts = trimmed.split("-");
|
|
6168
|
+
const lo = parseInt(dashParts[0] ?? "0", 10);
|
|
6169
|
+
const hi = parseInt(dashParts[1] ?? "0", 10);
|
|
6170
|
+
if (isNaN(lo) || isNaN(hi)) {
|
|
6171
|
+
throw new Error(`Invalid range in cron field: ${field}`);
|
|
6172
|
+
}
|
|
6173
|
+
for (let i = lo;i <= hi; i++) {
|
|
6174
|
+
if (i >= min && i <= max)
|
|
6175
|
+
results.add(i);
|
|
6176
|
+
}
|
|
6177
|
+
} else {
|
|
6178
|
+
const val = parseInt(trimmed, 10);
|
|
6179
|
+
if (isNaN(val)) {
|
|
6180
|
+
throw new Error(`Invalid value in cron field: ${field}`);
|
|
6181
|
+
}
|
|
6182
|
+
if (val >= min && val <= max)
|
|
6183
|
+
results.add(val);
|
|
6184
|
+
}
|
|
6185
|
+
}
|
|
6186
|
+
return Array.from(results).sort((a, b) => a - b);
|
|
6187
|
+
}
|
|
6188
|
+
function parseCron(expression) {
|
|
6189
|
+
const fields = expression.trim().split(/\s+/);
|
|
6190
|
+
if (fields.length !== 5) {
|
|
6191
|
+
throw new Error(`Invalid cron expression "${expression}": expected 5 fields, got ${fields.length}`);
|
|
6192
|
+
}
|
|
6193
|
+
return {
|
|
6194
|
+
minutes: parseCronField(fields[0], 0, 59),
|
|
6195
|
+
hours: parseCronField(fields[1], 0, 23),
|
|
6196
|
+
daysOfMonth: parseCronField(fields[2], 1, 31),
|
|
6197
|
+
months: parseCronField(fields[3], 1, 12),
|
|
6198
|
+
daysOfWeek: parseCronField(fields[4], 0, 6)
|
|
6199
|
+
};
|
|
6200
|
+
}
|
|
6201
|
+
function shouldRunAt(cronExpression, date) {
|
|
6202
|
+
const cron = parseCron(cronExpression);
|
|
6203
|
+
const minute = date.getMinutes();
|
|
6204
|
+
const hour = date.getHours();
|
|
6205
|
+
const dayOfMonth = date.getDate();
|
|
6206
|
+
const month = date.getMonth() + 1;
|
|
6207
|
+
const dayOfWeek = date.getDay();
|
|
6208
|
+
return cron.minutes.includes(minute) && cron.hours.includes(hour) && cron.daysOfMonth.includes(dayOfMonth) && cron.months.includes(month) && cron.daysOfWeek.includes(dayOfWeek);
|
|
6209
|
+
}
|
|
6210
|
+
function getNextRunTime(cronExpression, after) {
|
|
6211
|
+
parseCron(cronExpression);
|
|
6212
|
+
const start = after ? new Date(after.getTime()) : new Date;
|
|
6213
|
+
start.setSeconds(0, 0);
|
|
6214
|
+
start.setMinutes(start.getMinutes() + 1);
|
|
6215
|
+
const maxDate = new Date(start.getTime() + 366 * 24 * 60 * 60 * 1000);
|
|
6216
|
+
const cursor = new Date(start.getTime());
|
|
6217
|
+
while (cursor.getTime() <= maxDate.getTime()) {
|
|
6218
|
+
if (shouldRunAt(cronExpression, cursor)) {
|
|
6219
|
+
return cursor;
|
|
6220
|
+
}
|
|
6221
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
6222
|
+
}
|
|
6223
|
+
throw new Error(`No matching time found for cron expression "${cronExpression}" within 366 days`);
|
|
6224
|
+
}
|
|
6225
|
+
|
|
6226
|
+
class Scheduler {
|
|
6227
|
+
interval = null;
|
|
6228
|
+
running = new Set;
|
|
6229
|
+
checkIntervalMs;
|
|
6230
|
+
onEvent;
|
|
6231
|
+
constructor(options) {
|
|
6232
|
+
this.checkIntervalMs = options?.checkIntervalMs ?? 60000;
|
|
6233
|
+
this.onEvent = options?.onEvent;
|
|
6234
|
+
}
|
|
6235
|
+
start() {
|
|
6236
|
+
if (this.interval)
|
|
6237
|
+
return;
|
|
6238
|
+
this.tick().catch(() => {});
|
|
6239
|
+
this.interval = setInterval(() => {
|
|
6240
|
+
this.tick().catch(() => {});
|
|
6241
|
+
}, this.checkIntervalMs);
|
|
6242
|
+
}
|
|
6243
|
+
stop() {
|
|
6244
|
+
if (this.interval) {
|
|
6245
|
+
clearInterval(this.interval);
|
|
6246
|
+
this.interval = null;
|
|
6247
|
+
}
|
|
6248
|
+
}
|
|
6249
|
+
async tick() {
|
|
6250
|
+
const now2 = new Date;
|
|
6251
|
+
now2.setSeconds(0, 0);
|
|
6252
|
+
const schedules = getEnabledSchedules();
|
|
6253
|
+
for (const schedule of schedules) {
|
|
6254
|
+
if (this.running.has(schedule.id))
|
|
6255
|
+
continue;
|
|
6256
|
+
if (shouldRunAt(schedule.cronExpression, now2)) {
|
|
6257
|
+
this.running.add(schedule.id);
|
|
6258
|
+
this.emit({
|
|
6259
|
+
type: "schedule:triggered",
|
|
6260
|
+
scheduleId: schedule.id,
|
|
6261
|
+
scheduleName: schedule.name,
|
|
6262
|
+
timestamp: new Date().toISOString()
|
|
6263
|
+
});
|
|
6264
|
+
this.executeSchedule(schedule).then(({ runId }) => {
|
|
6265
|
+
const nextRun = getNextRunTime(schedule.cronExpression, new Date);
|
|
6266
|
+
updateLastRun(schedule.id, runId, nextRun.toISOString());
|
|
6267
|
+
this.emit({
|
|
6268
|
+
type: "schedule:completed",
|
|
6269
|
+
scheduleId: schedule.id,
|
|
6270
|
+
scheduleName: schedule.name,
|
|
6271
|
+
runId,
|
|
6272
|
+
timestamp: new Date().toISOString()
|
|
6273
|
+
});
|
|
6274
|
+
}).catch((err) => {
|
|
6275
|
+
this.emit({
|
|
6276
|
+
type: "schedule:failed",
|
|
6277
|
+
scheduleId: schedule.id,
|
|
6278
|
+
scheduleName: schedule.name,
|
|
6279
|
+
error: err instanceof Error ? err.message : String(err),
|
|
6280
|
+
timestamp: new Date().toISOString()
|
|
6281
|
+
});
|
|
6282
|
+
}).finally(() => {
|
|
6283
|
+
this.running.delete(schedule.id);
|
|
6284
|
+
});
|
|
6285
|
+
}
|
|
6286
|
+
}
|
|
6287
|
+
}
|
|
6288
|
+
async runScheduleNow(scheduleId) {
|
|
6289
|
+
const schedule = getSchedule(scheduleId);
|
|
6290
|
+
if (!schedule) {
|
|
6291
|
+
throw new ScheduleNotFoundError(scheduleId);
|
|
6292
|
+
}
|
|
6293
|
+
this.running.add(schedule.id);
|
|
6294
|
+
this.emit({
|
|
6295
|
+
type: "schedule:triggered",
|
|
6296
|
+
scheduleId: schedule.id,
|
|
6297
|
+
scheduleName: schedule.name,
|
|
6298
|
+
timestamp: new Date().toISOString()
|
|
6299
|
+
});
|
|
6300
|
+
try {
|
|
6301
|
+
const { runId } = await this.executeSchedule(schedule);
|
|
6302
|
+
const nextRun = getNextRunTime(schedule.cronExpression, new Date);
|
|
6303
|
+
updateLastRun(schedule.id, runId, nextRun.toISOString());
|
|
6304
|
+
this.emit({
|
|
6305
|
+
type: "schedule:completed",
|
|
6306
|
+
scheduleId: schedule.id,
|
|
6307
|
+
scheduleName: schedule.name,
|
|
6308
|
+
runId,
|
|
6309
|
+
timestamp: new Date().toISOString()
|
|
6310
|
+
});
|
|
6311
|
+
} catch (err) {
|
|
6312
|
+
this.emit({
|
|
6313
|
+
type: "schedule:failed",
|
|
6314
|
+
scheduleId: schedule.id,
|
|
6315
|
+
scheduleName: schedule.name,
|
|
6316
|
+
error: err instanceof Error ? err.message : String(err),
|
|
6317
|
+
timestamp: new Date().toISOString()
|
|
6318
|
+
});
|
|
6319
|
+
throw err;
|
|
6320
|
+
} finally {
|
|
6321
|
+
this.running.delete(schedule.id);
|
|
6322
|
+
}
|
|
6323
|
+
}
|
|
6324
|
+
async executeSchedule(schedule) {
|
|
6325
|
+
const { run } = await runByFilter({
|
|
6326
|
+
url: schedule.url,
|
|
6327
|
+
model: schedule.model ?? undefined,
|
|
6328
|
+
headed: schedule.headed,
|
|
6329
|
+
parallel: schedule.parallel,
|
|
6330
|
+
timeout: schedule.timeoutMs ?? undefined,
|
|
6331
|
+
tags: schedule.scenarioFilter.tags,
|
|
6332
|
+
priority: schedule.scenarioFilter.priority,
|
|
6333
|
+
scenarioIds: schedule.scenarioFilter.scenarioIds
|
|
6334
|
+
});
|
|
6335
|
+
return { runId: run.id };
|
|
6336
|
+
}
|
|
6337
|
+
emit(event) {
|
|
6338
|
+
if (this.onEvent) {
|
|
6339
|
+
this.onEvent(event);
|
|
6340
|
+
}
|
|
6341
|
+
}
|
|
6342
|
+
}
|
|
6343
|
+
|
|
5594
6344
|
// src/mcp/index.ts
|
|
5595
6345
|
var server = new McpServer({
|
|
5596
6346
|
name: "testers-mcp",
|
|
@@ -5893,6 +6643,70 @@ server.tool("get_status", "Get system status: DB path, API key, scenario and run
|
|
|
5893
6643
|
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
5894
6644
|
}
|
|
5895
6645
|
});
|
|
6646
|
+
server.tool("create_schedule", {
|
|
6647
|
+
name: exports_external.string().describe("Schedule name"),
|
|
6648
|
+
cronExpression: exports_external.string().describe("Cron expression (5-field)"),
|
|
6649
|
+
url: exports_external.string().describe("Target URL to test"),
|
|
6650
|
+
tags: exports_external.array(exports_external.string()).optional().describe("Filter scenarios by tags"),
|
|
6651
|
+
priority: exports_external.string().optional().describe("Filter scenarios by priority"),
|
|
6652
|
+
model: exports_external.string().optional().describe("AI model"),
|
|
6653
|
+
headed: exports_external.boolean().optional().describe("Run headed"),
|
|
6654
|
+
parallel: exports_external.number().optional().describe("Parallel count"),
|
|
6655
|
+
projectId: exports_external.string().optional().describe("Project ID")
|
|
6656
|
+
}, async (params) => {
|
|
6657
|
+
try {
|
|
6658
|
+
const schedule = createSchedule({
|
|
6659
|
+
name: params.name,
|
|
6660
|
+
cronExpression: params.cronExpression,
|
|
6661
|
+
url: params.url,
|
|
6662
|
+
scenarioFilter: { tags: params.tags, priority: params.priority },
|
|
6663
|
+
model: params.model,
|
|
6664
|
+
headed: params.headed,
|
|
6665
|
+
parallel: params.parallel,
|
|
6666
|
+
projectId: params.projectId
|
|
6667
|
+
});
|
|
6668
|
+
const nextRun = getNextRunTime(schedule.cronExpression);
|
|
6669
|
+
return { content: [{ type: "text", text: `Schedule created: ${schedule.id.slice(0, 8)} | ${schedule.name} | cron: ${schedule.cronExpression} | next: ${nextRun.toISOString()}` }] };
|
|
6670
|
+
} catch (e) {
|
|
6671
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
6672
|
+
}
|
|
6673
|
+
});
|
|
6674
|
+
server.tool("list_schedules", {
|
|
6675
|
+
projectId: exports_external.string().optional(),
|
|
6676
|
+
enabled: exports_external.boolean().optional(),
|
|
6677
|
+
limit: exports_external.number().optional()
|
|
6678
|
+
}, async (params) => {
|
|
6679
|
+
const schedules = listSchedules({ projectId: params.projectId, enabled: params.enabled, limit: params.limit });
|
|
6680
|
+
if (schedules.length === 0)
|
|
6681
|
+
return { content: [{ type: "text", text: "No schedules found." }] };
|
|
6682
|
+
const lines = schedules.map((s) => `${s.id.slice(0, 8)} | ${s.name} | ${s.cronExpression} | ${s.url} | ${s.enabled ? "enabled" : "disabled"} | next: ${s.nextRunAt ?? "N/A"}`);
|
|
6683
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
6684
|
+
`) }] };
|
|
6685
|
+
});
|
|
6686
|
+
server.tool("enable_schedule", { id: exports_external.string().describe("Schedule ID") }, async (params) => {
|
|
6687
|
+
try {
|
|
6688
|
+
const schedule = updateSchedule(params.id, { enabled: true });
|
|
6689
|
+
return { content: [{ type: "text", text: `Schedule ${schedule.name} enabled.` }] };
|
|
6690
|
+
} catch (e) {
|
|
6691
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
6692
|
+
}
|
|
6693
|
+
});
|
|
6694
|
+
server.tool("disable_schedule", { id: exports_external.string().describe("Schedule ID") }, async (params) => {
|
|
6695
|
+
try {
|
|
6696
|
+
const schedule = updateSchedule(params.id, { enabled: false });
|
|
6697
|
+
return { content: [{ type: "text", text: `Schedule ${schedule.name} disabled.` }] };
|
|
6698
|
+
} catch (e) {
|
|
6699
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
6700
|
+
}
|
|
6701
|
+
});
|
|
6702
|
+
server.tool("delete_schedule", { id: exports_external.string().describe("Schedule ID") }, async (params) => {
|
|
6703
|
+
try {
|
|
6704
|
+
const deleted = deleteSchedule(params.id);
|
|
6705
|
+
return { content: [{ type: "text", text: deleted ? "Schedule deleted." : "Schedule not found." }] };
|
|
6706
|
+
} catch (e) {
|
|
6707
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
6708
|
+
}
|
|
6709
|
+
});
|
|
5896
6710
|
async function main() {
|
|
5897
6711
|
const transport = new StdioServerTransport;
|
|
5898
6712
|
await server.connect(transport);
|