@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 CHANGED
@@ -2080,6 +2080,16 @@ var MODEL_MAP = {
2080
2080
  thorough: "claude-sonnet-4-6-20260311",
2081
2081
  deep: "claude-opus-4-6-20260311"
2082
2082
  };
2083
+ function projectFromRow(row) {
2084
+ return {
2085
+ id: row.id,
2086
+ name: row.name,
2087
+ path: row.path,
2088
+ description: row.description,
2089
+ createdAt: row.created_at,
2090
+ updatedAt: row.updated_at
2091
+ };
2092
+ }
2083
2093
  function scenarioFromRow(row) {
2084
2094
  return {
2085
2095
  id: row.id,
@@ -2148,6 +2158,26 @@ function screenshotFromRow(row) {
2148
2158
  timestamp: row.timestamp
2149
2159
  };
2150
2160
  }
2161
+ function scheduleFromRow(row) {
2162
+ return {
2163
+ id: row.id,
2164
+ projectId: row.project_id,
2165
+ name: row.name,
2166
+ cronExpression: row.cron_expression,
2167
+ url: row.url,
2168
+ scenarioFilter: JSON.parse(row.scenario_filter),
2169
+ model: row.model,
2170
+ headed: row.headed === 1,
2171
+ parallel: row.parallel,
2172
+ timeoutMs: row.timeout_ms,
2173
+ enabled: row.enabled === 1,
2174
+ lastRunId: row.last_run_id,
2175
+ lastRunAt: row.last_run_at,
2176
+ nextRunAt: row.next_run_at,
2177
+ createdAt: row.created_at,
2178
+ updatedAt: row.updated_at
2179
+ };
2180
+ }
2151
2181
  class VersionConflictError extends Error {
2152
2182
  constructor(entity, id) {
2153
2183
  super(`Version conflict on ${entity}: ${id}`);
@@ -2175,6 +2205,12 @@ class TodosConnectionError extends Error {
2175
2205
  this.name = "TodosConnectionError";
2176
2206
  }
2177
2207
  }
2208
+ class ScheduleNotFoundError extends Error {
2209
+ constructor(id) {
2210
+ super(`Schedule not found: ${id}`);
2211
+ this.name = "ScheduleNotFoundError";
2212
+ }
2213
+ }
2178
2214
 
2179
2215
  // src/db/database.ts
2180
2216
  import { Database } from "bun:sqlite";
@@ -2304,6 +2340,30 @@ var MIGRATIONS = [
2304
2340
  `
2305
2341
  ALTER TABLE projects ADD COLUMN scenario_prefix TEXT DEFAULT 'TST';
2306
2342
  ALTER TABLE projects ADD COLUMN scenario_counter INTEGER DEFAULT 0;
2343
+ `,
2344
+ `
2345
+ CREATE TABLE IF NOT EXISTS schedules (
2346
+ id TEXT PRIMARY KEY,
2347
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
2348
+ name TEXT NOT NULL,
2349
+ cron_expression TEXT NOT NULL,
2350
+ url TEXT NOT NULL,
2351
+ scenario_filter TEXT NOT NULL DEFAULT '{}',
2352
+ model TEXT,
2353
+ headed INTEGER NOT NULL DEFAULT 0,
2354
+ parallel INTEGER NOT NULL DEFAULT 1,
2355
+ timeout_ms INTEGER,
2356
+ enabled INTEGER NOT NULL DEFAULT 1,
2357
+ last_run_id TEXT REFERENCES runs(id) ON DELETE SET NULL,
2358
+ last_run_at TEXT,
2359
+ next_run_at TEXT,
2360
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
2361
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
2362
+ );
2363
+
2364
+ CREATE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
2365
+ CREATE INDEX IF NOT EXISTS idx_schedules_enabled ON schedules(enabled);
2366
+ CREATE INDEX IF NOT EXISTS idx_schedules_next_run ON schedules(next_run_at);
2307
2367
  `
2308
2368
  ];
2309
2369
  function applyMigrations(database) {
@@ -3036,6 +3096,127 @@ var BROWSER_TOOLS = [
3036
3096
  required: ["text"]
3037
3097
  }
3038
3098
  },
3099
+ {
3100
+ name: "scroll",
3101
+ description: "Scroll the page up or down by a given amount of pixels.",
3102
+ input_schema: {
3103
+ type: "object",
3104
+ properties: {
3105
+ direction: {
3106
+ type: "string",
3107
+ enum: ["up", "down"],
3108
+ description: "Direction to scroll."
3109
+ },
3110
+ amount: {
3111
+ type: "number",
3112
+ description: "Number of pixels to scroll (default: 500)."
3113
+ }
3114
+ },
3115
+ required: ["direction"]
3116
+ }
3117
+ },
3118
+ {
3119
+ name: "get_page_html",
3120
+ description: "Get simplified HTML of the page body content, truncated to 8000 characters.",
3121
+ input_schema: {
3122
+ type: "object",
3123
+ properties: {},
3124
+ required: []
3125
+ }
3126
+ },
3127
+ {
3128
+ name: "get_elements",
3129
+ description: "List elements matching a CSS selector with their text, tag name, and key attributes (max 20 results).",
3130
+ input_schema: {
3131
+ type: "object",
3132
+ properties: {
3133
+ selector: {
3134
+ type: "string",
3135
+ description: "CSS selector to match elements."
3136
+ }
3137
+ },
3138
+ required: ["selector"]
3139
+ }
3140
+ },
3141
+ {
3142
+ name: "wait_for_navigation",
3143
+ description: "Wait for page navigation/load to complete (network idle).",
3144
+ input_schema: {
3145
+ type: "object",
3146
+ properties: {
3147
+ timeout: {
3148
+ type: "number",
3149
+ description: "Maximum time to wait in milliseconds (default: 10000)."
3150
+ }
3151
+ },
3152
+ required: []
3153
+ }
3154
+ },
3155
+ {
3156
+ name: "get_page_title",
3157
+ description: "Get the document title of the current page.",
3158
+ input_schema: {
3159
+ type: "object",
3160
+ properties: {},
3161
+ required: []
3162
+ }
3163
+ },
3164
+ {
3165
+ name: "count_elements",
3166
+ description: "Count the number of elements matching a CSS selector.",
3167
+ input_schema: {
3168
+ type: "object",
3169
+ properties: {
3170
+ selector: {
3171
+ type: "string",
3172
+ description: "CSS selector to count matching elements."
3173
+ }
3174
+ },
3175
+ required: ["selector"]
3176
+ }
3177
+ },
3178
+ {
3179
+ name: "hover",
3180
+ description: "Hover over an element matching the given CSS selector.",
3181
+ input_schema: {
3182
+ type: "object",
3183
+ properties: {
3184
+ selector: {
3185
+ type: "string",
3186
+ description: "CSS selector of the element to hover over."
3187
+ }
3188
+ },
3189
+ required: ["selector"]
3190
+ }
3191
+ },
3192
+ {
3193
+ name: "check",
3194
+ description: "Check a checkbox matching the given CSS selector.",
3195
+ input_schema: {
3196
+ type: "object",
3197
+ properties: {
3198
+ selector: {
3199
+ type: "string",
3200
+ description: "CSS selector of the checkbox to check."
3201
+ }
3202
+ },
3203
+ required: ["selector"]
3204
+ }
3205
+ },
3206
+ {
3207
+ name: "uncheck",
3208
+ description: "Uncheck a checkbox matching the given CSS selector.",
3209
+ input_schema: {
3210
+ type: "object",
3211
+ properties: {
3212
+ selector: {
3213
+ type: "string",
3214
+ description: "CSS selector of the checkbox to uncheck."
3215
+ }
3216
+ },
3217
+ required: ["selector"]
3218
+ }
3219
+ },
3039
3220
  {
3040
3221
  name: "report_result",
3041
3222
  description: "Report the final test result. Call this when you have completed testing the scenario. This MUST be the last tool you call.",
@@ -3167,6 +3348,113 @@ async function executeTool(page, screenshotter, toolName, toolInput, context) {
3167
3348
  return { result: "false" };
3168
3349
  }
3169
3350
  }
3351
+ case "scroll": {
3352
+ const direction = toolInput.direction;
3353
+ const amount = typeof toolInput.amount === "number" ? toolInput.amount : 500;
3354
+ const scrollY = direction === "down" ? amount : -amount;
3355
+ await page.evaluate((y) => window.scrollBy(0, y), scrollY);
3356
+ const screenshot = await screenshotter.capture(page, {
3357
+ runId: context.runId,
3358
+ scenarioSlug: context.scenarioSlug,
3359
+ stepNumber: context.stepNumber,
3360
+ action: "scroll"
3361
+ });
3362
+ return {
3363
+ result: `Scrolled ${direction} by ${amount}px`,
3364
+ screenshot
3365
+ };
3366
+ }
3367
+ case "get_page_html": {
3368
+ const html = await page.evaluate(() => document.body.innerHTML);
3369
+ const truncated = html.length > 8000 ? html.slice(0, 8000) + "..." : html;
3370
+ return {
3371
+ result: truncated
3372
+ };
3373
+ }
3374
+ case "get_elements": {
3375
+ const selector = toolInput.selector;
3376
+ const allElements = await page.locator(selector).all();
3377
+ const elements = allElements.slice(0, 20);
3378
+ const results = [];
3379
+ for (let i = 0;i < elements.length; i++) {
3380
+ const el = elements[i];
3381
+ const tagName = await el.evaluate((e) => e.tagName.toLowerCase());
3382
+ const textContent = await el.textContent() ?? "";
3383
+ const trimmedText = textContent.trim().slice(0, 100);
3384
+ const id = await el.getAttribute("id");
3385
+ const className = await el.getAttribute("class");
3386
+ const href = await el.getAttribute("href");
3387
+ const type = await el.getAttribute("type");
3388
+ const placeholder = await el.getAttribute("placeholder");
3389
+ const ariaLabel = await el.getAttribute("aria-label");
3390
+ const attrs = [];
3391
+ if (id)
3392
+ attrs.push(`id="${id}"`);
3393
+ if (className)
3394
+ attrs.push(`class="${className}"`);
3395
+ if (href)
3396
+ attrs.push(`href="${href}"`);
3397
+ if (type)
3398
+ attrs.push(`type="${type}"`);
3399
+ if (placeholder)
3400
+ attrs.push(`placeholder="${placeholder}"`);
3401
+ if (ariaLabel)
3402
+ attrs.push(`aria-label="${ariaLabel}"`);
3403
+ results.push(`[${i}] <${tagName}${attrs.length ? " " + attrs.join(" ") : ""}> ${trimmedText}`);
3404
+ }
3405
+ return {
3406
+ result: results.length > 0 ? results.join(`
3407
+ `) : `No elements found matching "${selector}"`
3408
+ };
3409
+ }
3410
+ case "wait_for_navigation": {
3411
+ const timeout = typeof toolInput.timeout === "number" ? toolInput.timeout : 1e4;
3412
+ await page.waitForLoadState("networkidle", { timeout });
3413
+ return {
3414
+ result: "Navigation/load completed"
3415
+ };
3416
+ }
3417
+ case "get_page_title": {
3418
+ const title = await page.title();
3419
+ return {
3420
+ result: title || "(no title)"
3421
+ };
3422
+ }
3423
+ case "count_elements": {
3424
+ const selector = toolInput.selector;
3425
+ const count = await page.locator(selector).count();
3426
+ return {
3427
+ result: `${count} element(s) matching "${selector}"`
3428
+ };
3429
+ }
3430
+ case "hover": {
3431
+ const selector = toolInput.selector;
3432
+ await page.hover(selector);
3433
+ const screenshot = await screenshotter.capture(page, {
3434
+ runId: context.runId,
3435
+ scenarioSlug: context.scenarioSlug,
3436
+ stepNumber: context.stepNumber,
3437
+ action: "hover"
3438
+ });
3439
+ return {
3440
+ result: `Hovered over: ${selector}`,
3441
+ screenshot
3442
+ };
3443
+ }
3444
+ case "check": {
3445
+ const selector = toolInput.selector;
3446
+ await page.check(selector);
3447
+ return {
3448
+ result: `Checked checkbox: ${selector}`
3449
+ };
3450
+ }
3451
+ case "uncheck": {
3452
+ const selector = toolInput.selector;
3453
+ await page.uncheck(selector);
3454
+ return {
3455
+ result: `Unchecked checkbox: ${selector}`
3456
+ };
3457
+ }
3170
3458
  case "report_result": {
3171
3459
  const status = toolInput.status;
3172
3460
  const reasoning = toolInput.reasoning;
@@ -3193,13 +3481,26 @@ async function runAgentLoop(options) {
3193
3481
  maxTurns = 30
3194
3482
  } = options;
3195
3483
  const systemPrompt = [
3196
- "You are a QA testing agent. Test the following scenario by interacting with the browser.",
3197
- "Use the provided tools to navigate, click, fill forms, and verify results.",
3198
- "When done, call report_result with your findings.",
3199
- "Be methodical: navigate to the target page first, then follow the test steps.",
3200
- "If a step fails, try reasonable alternatives before reporting failure.",
3201
- "Always report a final result \u2014 never leave a test incomplete."
3202
- ].join(" ");
3484
+ "You are an expert QA testing agent. Your job is to thoroughly test web application scenarios.",
3485
+ "You have browser tools to navigate, interact with, and inspect web pages.",
3486
+ "",
3487
+ "Strategy:",
3488
+ "1. First navigate to the target page and take a screenshot to understand the layout",
3489
+ "2. If you can't find an element, use get_elements or get_page_html to discover selectors",
3490
+ "3. Use scroll to discover content below the fold",
3491
+ "4. Use wait_for or wait_for_navigation after actions that trigger page loads",
3492
+ "5. Take screenshots after every meaningful state change",
3493
+ "6. Use assert_text and assert_visible to verify expected outcomes",
3494
+ "7. When done testing, call report_result with detailed pass/fail reasoning",
3495
+ "",
3496
+ "Tips:",
3497
+ "- Try multiple selector strategies: by text, by role, by class, by id",
3498
+ "- If a click triggers navigation, use wait_for_navigation after",
3499
+ "- For forms, fill all fields before submitting",
3500
+ "- Check for error messages after form submissions",
3501
+ "- Verify both positive and negative states"
3502
+ ].join(`
3503
+ `);
3203
3504
  const userParts = [
3204
3505
  `**Scenario:** ${scenario.name}`,
3205
3506
  `**Description:** ${scenario.description}`
@@ -3765,17 +4066,179 @@ function importFromTodos(options = {}) {
3765
4066
  return { imported, skipped };
3766
4067
  }
3767
4068
 
4069
+ // src/db/projects.ts
4070
+ function createProject(input) {
4071
+ const db2 = getDatabase();
4072
+ const id = uuid();
4073
+ const timestamp = now();
4074
+ db2.query(`
4075
+ INSERT INTO projects (id, name, path, description, created_at, updated_at)
4076
+ VALUES (?, ?, ?, ?, ?, ?)
4077
+ `).run(id, input.name, input.path ?? null, input.description ?? null, timestamp, timestamp);
4078
+ return getProject(id);
4079
+ }
4080
+ function getProject(id) {
4081
+ const db2 = getDatabase();
4082
+ const row = db2.query("SELECT * FROM projects WHERE id = ?").get(id);
4083
+ return row ? projectFromRow(row) : null;
4084
+ }
4085
+ function listProjects() {
4086
+ const db2 = getDatabase();
4087
+ const rows = db2.query("SELECT * FROM projects ORDER BY created_at DESC").all();
4088
+ return rows.map(projectFromRow);
4089
+ }
4090
+ function ensureProject(name, path) {
4091
+ const db2 = getDatabase();
4092
+ const byPath = db2.query("SELECT * FROM projects WHERE path = ?").get(path);
4093
+ if (byPath)
4094
+ return projectFromRow(byPath);
4095
+ const byName = db2.query("SELECT * FROM projects WHERE name = ?").get(name);
4096
+ if (byName)
4097
+ return projectFromRow(byName);
4098
+ return createProject({ name, path });
4099
+ }
4100
+
4101
+ // src/db/schedules.ts
4102
+ function createSchedule(input) {
4103
+ const db2 = getDatabase();
4104
+ const id = uuid();
4105
+ const timestamp = now();
4106
+ db2.query(`
4107
+ INSERT INTO schedules (id, project_id, name, cron_expression, url, scenario_filter, model, headed, parallel, timeout_ms, enabled, created_at, updated_at)
4108
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
4109
+ `).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);
4110
+ return getSchedule(id);
4111
+ }
4112
+ function getSchedule(id) {
4113
+ const db2 = getDatabase();
4114
+ let row = db2.query("SELECT * FROM schedules WHERE id = ?").get(id);
4115
+ if (row)
4116
+ return scheduleFromRow(row);
4117
+ const fullId = resolvePartialId("schedules", id);
4118
+ if (fullId) {
4119
+ row = db2.query("SELECT * FROM schedules WHERE id = ?").get(fullId);
4120
+ if (row)
4121
+ return scheduleFromRow(row);
4122
+ }
4123
+ return null;
4124
+ }
4125
+ function listSchedules(filter) {
4126
+ const db2 = getDatabase();
4127
+ const conditions = [];
4128
+ const params = [];
4129
+ if (filter?.projectId) {
4130
+ conditions.push("project_id = ?");
4131
+ params.push(filter.projectId);
4132
+ }
4133
+ if (filter?.enabled !== undefined) {
4134
+ conditions.push("enabled = ?");
4135
+ params.push(filter.enabled ? 1 : 0);
4136
+ }
4137
+ let sql = "SELECT * FROM schedules";
4138
+ if (conditions.length > 0) {
4139
+ sql += " WHERE " + conditions.join(" AND ");
4140
+ }
4141
+ sql += " ORDER BY created_at DESC";
4142
+ if (filter?.limit) {
4143
+ sql += " LIMIT ?";
4144
+ params.push(filter.limit);
4145
+ }
4146
+ if (filter?.offset) {
4147
+ sql += " OFFSET ?";
4148
+ params.push(filter.offset);
4149
+ }
4150
+ const rows = db2.query(sql).all(...params);
4151
+ return rows.map(scheduleFromRow);
4152
+ }
4153
+ function updateSchedule(id, input) {
4154
+ const db2 = getDatabase();
4155
+ const existing = getSchedule(id);
4156
+ if (!existing) {
4157
+ throw new ScheduleNotFoundError(id);
4158
+ }
4159
+ const sets = [];
4160
+ const params = [];
4161
+ if (input.name !== undefined) {
4162
+ sets.push("name = ?");
4163
+ params.push(input.name);
4164
+ }
4165
+ if (input.cronExpression !== undefined) {
4166
+ sets.push("cron_expression = ?");
4167
+ params.push(input.cronExpression);
4168
+ }
4169
+ if (input.url !== undefined) {
4170
+ sets.push("url = ?");
4171
+ params.push(input.url);
4172
+ }
4173
+ if (input.scenarioFilter !== undefined) {
4174
+ sets.push("scenario_filter = ?");
4175
+ params.push(JSON.stringify(input.scenarioFilter));
4176
+ }
4177
+ if (input.model !== undefined) {
4178
+ sets.push("model = ?");
4179
+ params.push(input.model);
4180
+ }
4181
+ if (input.headed !== undefined) {
4182
+ sets.push("headed = ?");
4183
+ params.push(input.headed ? 1 : 0);
4184
+ }
4185
+ if (input.parallel !== undefined) {
4186
+ sets.push("parallel = ?");
4187
+ params.push(input.parallel);
4188
+ }
4189
+ if (input.timeoutMs !== undefined) {
4190
+ sets.push("timeout_ms = ?");
4191
+ params.push(input.timeoutMs);
4192
+ }
4193
+ if (input.enabled !== undefined) {
4194
+ sets.push("enabled = ?");
4195
+ params.push(input.enabled ? 1 : 0);
4196
+ }
4197
+ if (sets.length === 0) {
4198
+ return existing;
4199
+ }
4200
+ sets.push("updated_at = ?");
4201
+ params.push(now());
4202
+ params.push(existing.id);
4203
+ db2.query(`UPDATE schedules SET ${sets.join(", ")} WHERE id = ?`).run(...params);
4204
+ return getSchedule(existing.id);
4205
+ }
4206
+ function deleteSchedule(id) {
4207
+ const db2 = getDatabase();
4208
+ const schedule = getSchedule(id);
4209
+ if (!schedule)
4210
+ return false;
4211
+ const result = db2.query("DELETE FROM schedules WHERE id = ?").run(schedule.id);
4212
+ return result.changes > 0;
4213
+ }
4214
+
3768
4215
  // src/cli/index.tsx
4216
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3 } from "fs";
3769
4217
  var program2 = new Command;
3770
4218
  program2.name("testers").version("0.0.1").description("AI-powered browser testing CLI");
4219
+ var CONFIG_DIR2 = join5(process.env["HOME"] ?? "~", ".testers");
4220
+ var CONFIG_PATH2 = join5(CONFIG_DIR2, "config.json");
4221
+ function getActiveProject() {
4222
+ try {
4223
+ if (existsSync5(CONFIG_PATH2)) {
4224
+ const raw = JSON.parse(readFileSync2(CONFIG_PATH2, "utf-8"));
4225
+ return raw.activeProject ?? undefined;
4226
+ }
4227
+ } catch {}
4228
+ return;
4229
+ }
4230
+ function resolveProject(optProject) {
4231
+ return optProject ?? getActiveProject();
4232
+ }
3771
4233
  program2.command("add <name>").description("Create a new test scenario").option("-d, --description <text>", "Scenario description", "").option("-s, --steps <step>", "Test step (repeatable)", (val, acc) => {
3772
4234
  acc.push(val);
3773
4235
  return acc;
3774
4236
  }, []).option("-t, --tag <tag>", "Tag (repeatable)", (val, acc) => {
3775
4237
  acc.push(val);
3776
4238
  return acc;
3777
- }, []).option("-p, --priority <level>", "Priority level", "medium").option("-m, --model <model>", "AI model to use").option("--path <path>", "Target path on the URL").option("--auth", "Requires authentication", false).option("--timeout <ms>", "Timeout in milliseconds").action((name, opts) => {
4239
+ }, []).option("-p, --priority <level>", "Priority level", "medium").option("-m, --model <model>", "AI model to use").option("--path <path>", "Target path on the URL").option("--auth", "Requires authentication", false).option("--timeout <ms>", "Timeout in milliseconds").option("--project <id>", "Project ID").action((name, opts) => {
3778
4240
  try {
4241
+ const projectId = resolveProject(opts.project);
3779
4242
  const scenario = createScenario({
3780
4243
  name,
3781
4244
  description: opts.description || name,
@@ -3785,7 +4248,8 @@ program2.command("add <name>").description("Create a new test scenario").option(
3785
4248
  model: opts.model,
3786
4249
  targetPath: opts.path,
3787
4250
  requiresAuth: opts.auth,
3788
- timeoutMs: opts.timeout ? parseInt(opts.timeout, 10) : undefined
4251
+ timeoutMs: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
4252
+ projectId
3789
4253
  });
3790
4254
  console.log(chalk2.green(`Created scenario ${chalk2.bold(scenario.shortId)}: ${scenario.name}`));
3791
4255
  } catch (error) {
@@ -3892,12 +4356,13 @@ program2.command("run <url> [description]").description("Run test scenarios agai
3892
4356
  return acc;
3893
4357
  }, []).option("-s, --scenario <id>", "Run specific scenario ID").option("-p, --priority <level>", "Filter by priority").option("--headed", "Run browser in headed mode", false).option("-m, --model <model>", "AI model to use").option("--parallel <n>", "Number of parallel browsers", "1").option("--json", "Output results as JSON", false).option("-o, --output <filepath>", "Write JSON results to file").option("--timeout <ms>", "Timeout in milliseconds").option("--from-todos", "Import scenarios from todos before running", false).option("--project <id>", "Project ID").action(async (url, description, opts) => {
3894
4358
  try {
4359
+ const projectId = resolveProject(opts.project);
3895
4360
  if (description) {
3896
4361
  const scenario = createScenario({
3897
4362
  name: description,
3898
4363
  description,
3899
4364
  tags: ["ad-hoc"],
3900
- projectId: opts.project
4365
+ projectId
3901
4366
  });
3902
4367
  const { run: run2, results: results2 } = await runByFilter({
3903
4368
  url,
@@ -3906,7 +4371,7 @@ program2.command("run <url> [description]").description("Run test scenarios agai
3906
4371
  headed: opts.headed,
3907
4372
  parallel: parseInt(opts.parallel, 10),
3908
4373
  timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
3909
- projectId: opts.project
4374
+ projectId
3910
4375
  });
3911
4376
  if (opts.json || opts.output) {
3912
4377
  const jsonOutput = formatJSON(run2, results2);
@@ -3923,7 +4388,7 @@ program2.command("run <url> [description]").description("Run test scenarios agai
3923
4388
  process.exit(getExitCode(run2));
3924
4389
  }
3925
4390
  if (opts.fromTodos) {
3926
- const result = importFromTodos({ projectId: opts.project });
4391
+ const result = importFromTodos({ projectId });
3927
4392
  console.log(chalk2.blue(`Imported ${result.imported} scenarios from todos (${result.skipped} skipped)`));
3928
4393
  }
3929
4394
  const { run, results } = await runByFilter({
@@ -3935,7 +4400,7 @@ program2.command("run <url> [description]").description("Run test scenarios agai
3935
4400
  headed: opts.headed,
3936
4401
  parallel: parseInt(opts.parallel, 10),
3937
4402
  timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
3938
- projectId: opts.project
4403
+ projectId
3939
4404
  });
3940
4405
  if (opts.json || opts.output) {
3941
4406
  const jsonOutput = formatJSON(run, results);
@@ -4109,4 +4574,288 @@ program2.command("install-browser").description("Install Playwright Chromium bro
4109
4574
  process.exit(1);
4110
4575
  }
4111
4576
  });
4577
+ var projectCmd = program2.command("project").description("Manage test projects");
4578
+ projectCmd.command("create <name>").description("Create a new project").option("--path <path>", "Project path").option("-d, --description <text>", "Project description").option("--prefix <prefix>", "Scenario prefix", "TST").action((name, opts) => {
4579
+ try {
4580
+ const project = createProject({
4581
+ name,
4582
+ path: opts.path,
4583
+ description: opts.description
4584
+ });
4585
+ console.log(chalk2.green(`Created project ${chalk2.bold(project.name)} (${project.id})`));
4586
+ } catch (error) {
4587
+ console.error(chalk2.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
4588
+ process.exit(1);
4589
+ }
4590
+ });
4591
+ projectCmd.command("list").description("List all projects").action(() => {
4592
+ try {
4593
+ const projects = listProjects();
4594
+ if (projects.length === 0) {
4595
+ console.log(chalk2.dim("No projects found."));
4596
+ return;
4597
+ }
4598
+ console.log("");
4599
+ console.log(chalk2.bold(" Projects"));
4600
+ console.log("");
4601
+ console.log(` ${"ID".padEnd(38)} ${"Name".padEnd(24)} ${"Path".padEnd(30)} Created`);
4602
+ console.log(` ${"\u2500".repeat(38)} ${"\u2500".repeat(24)} ${"\u2500".repeat(30)} ${"\u2500".repeat(20)}`);
4603
+ for (const p of projects) {
4604
+ console.log(` ${p.id.padEnd(38)} ${p.name.padEnd(24)} ${(p.path ?? chalk2.dim("\u2014")).toString().padEnd(30)} ${p.createdAt}`);
4605
+ }
4606
+ console.log("");
4607
+ } catch (error) {
4608
+ console.error(chalk2.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
4609
+ process.exit(1);
4610
+ }
4611
+ });
4612
+ projectCmd.command("show <id>").description("Show project details").action((id) => {
4613
+ try {
4614
+ const project = getProject(id);
4615
+ if (!project) {
4616
+ console.error(chalk2.red(`Project not found: ${id}`));
4617
+ process.exit(1);
4618
+ }
4619
+ console.log("");
4620
+ console.log(chalk2.bold(` Project: ${project.name}`));
4621
+ console.log(` ID: ${project.id}`);
4622
+ console.log(` Path: ${project.path ?? chalk2.dim("none")}`);
4623
+ console.log(` Description: ${project.description ?? chalk2.dim("none")}`);
4624
+ console.log(` Created: ${project.createdAt}`);
4625
+ console.log(` Updated: ${project.updatedAt}`);
4626
+ console.log("");
4627
+ } catch (error) {
4628
+ console.error(chalk2.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
4629
+ process.exit(1);
4630
+ }
4631
+ });
4632
+ projectCmd.command("use <name>").description("Set active project (find or create)").action((name) => {
4633
+ try {
4634
+ const project = ensureProject(name, process.cwd());
4635
+ if (!existsSync5(CONFIG_DIR2)) {
4636
+ mkdirSync3(CONFIG_DIR2, { recursive: true });
4637
+ }
4638
+ let config = {};
4639
+ if (existsSync5(CONFIG_PATH2)) {
4640
+ try {
4641
+ config = JSON.parse(readFileSync2(CONFIG_PATH2, "utf-8"));
4642
+ } catch {}
4643
+ }
4644
+ config.activeProject = project.id;
4645
+ writeFileSync(CONFIG_PATH2, JSON.stringify(config, null, 2), "utf-8");
4646
+ console.log(chalk2.green(`Active project set to ${chalk2.bold(project.name)} (${project.id})`));
4647
+ } catch (error) {
4648
+ console.error(chalk2.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
4649
+ process.exit(1);
4650
+ }
4651
+ });
4652
+ var scheduleCmd = program2.command("schedule").description("Manage recurring test schedules");
4653
+ scheduleCmd.command("create <name>").description("Create a new schedule").requiredOption("--cron <expression>", "Cron expression").requiredOption("--url <url>", "Target URL").option("-t, --tag <tag>", "Tag filter (repeatable)", (val, acc) => {
4654
+ acc.push(val);
4655
+ return acc;
4656
+ }, []).option("-p, --priority <level>", "Priority filter").option("-m, --model <model>", "AI model to use").option("--parallel <n>", "Parallel browsers", "1").option("--headed", "Run in headed mode", false).option("--timeout <ms>", "Timeout in milliseconds").option("--project <id>", "Project ID").action((name, opts) => {
4657
+ try {
4658
+ const projectId = resolveProject(opts.project);
4659
+ const schedule = createSchedule({
4660
+ name,
4661
+ cronExpression: opts.cron,
4662
+ url: opts.url,
4663
+ scenarioFilter: {
4664
+ tags: opts.tag.length > 0 ? opts.tag : undefined,
4665
+ priority: opts.priority
4666
+ },
4667
+ model: opts.model,
4668
+ headed: opts.headed,
4669
+ parallel: parseInt(opts.parallel, 10),
4670
+ timeoutMs: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
4671
+ projectId
4672
+ });
4673
+ console.log(chalk2.green(`Created schedule ${chalk2.bold(schedule.name)} (${schedule.id})`));
4674
+ if (schedule.nextRunAt) {
4675
+ console.log(chalk2.dim(` Next run at: ${schedule.nextRunAt}`));
4676
+ }
4677
+ } catch (error) {
4678
+ console.error(chalk2.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
4679
+ process.exit(1);
4680
+ }
4681
+ });
4682
+ scheduleCmd.command("list").description("List schedules").option("--project <id>", "Filter by project ID").option("--enabled", "Show only enabled schedules").action((opts) => {
4683
+ try {
4684
+ const projectId = resolveProject(opts.project);
4685
+ const schedules = listSchedules({
4686
+ projectId,
4687
+ enabled: opts.enabled ? true : undefined
4688
+ });
4689
+ if (schedules.length === 0) {
4690
+ console.log(chalk2.dim("No schedules found."));
4691
+ return;
4692
+ }
4693
+ console.log("");
4694
+ console.log(chalk2.bold(" Schedules"));
4695
+ console.log("");
4696
+ console.log(` ${"Name".padEnd(20)} ${"Cron".padEnd(18)} ${"URL".padEnd(30)} ${"Enabled".padEnd(9)} ${"Next Run".padEnd(22)} Last Run`);
4697
+ console.log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(18)} ${"\u2500".repeat(30)} ${"\u2500".repeat(9)} ${"\u2500".repeat(22)} ${"\u2500".repeat(22)}`);
4698
+ for (const s of schedules) {
4699
+ const enabled = s.enabled ? chalk2.green("yes") : chalk2.red("no");
4700
+ const nextRun = s.nextRunAt ?? chalk2.dim("\u2014");
4701
+ const lastRun = s.lastRunAt ?? chalk2.dim("\u2014");
4702
+ console.log(` ${s.name.padEnd(20)} ${s.cronExpression.padEnd(18)} ${s.url.padEnd(30)} ${enabled.toString().padEnd(9)} ${nextRun.toString().padEnd(22)} ${lastRun}`);
4703
+ }
4704
+ console.log("");
4705
+ } catch (error) {
4706
+ console.error(chalk2.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
4707
+ process.exit(1);
4708
+ }
4709
+ });
4710
+ scheduleCmd.command("show <id>").description("Show schedule details").action((id) => {
4711
+ try {
4712
+ const schedule = getSchedule(id);
4713
+ if (!schedule) {
4714
+ console.error(chalk2.red(`Schedule not found: ${id}`));
4715
+ process.exit(1);
4716
+ }
4717
+ console.log("");
4718
+ console.log(chalk2.bold(` Schedule: ${schedule.name}`));
4719
+ console.log(` ID: ${schedule.id}`);
4720
+ console.log(` Cron: ${schedule.cronExpression}`);
4721
+ console.log(` URL: ${schedule.url}`);
4722
+ console.log(` Enabled: ${schedule.enabled ? chalk2.green("yes") : chalk2.red("no")}`);
4723
+ console.log(` Model: ${schedule.model ?? chalk2.dim("default")}`);
4724
+ console.log(` Headed: ${schedule.headed ? "yes" : "no"}`);
4725
+ console.log(` Parallel: ${schedule.parallel}`);
4726
+ console.log(` Timeout: ${schedule.timeoutMs ? `${schedule.timeoutMs}ms` : chalk2.dim("default")}`);
4727
+ console.log(` Project: ${schedule.projectId ?? chalk2.dim("none")}`);
4728
+ console.log(` Filter: ${JSON.stringify(schedule.scenarioFilter)}`);
4729
+ console.log(` Next run: ${schedule.nextRunAt ?? chalk2.dim("not scheduled")}`);
4730
+ console.log(` Last run: ${schedule.lastRunAt ?? chalk2.dim("never")}`);
4731
+ console.log(` Last run ID: ${schedule.lastRunId ?? chalk2.dim("none")}`);
4732
+ console.log(` Created: ${schedule.createdAt}`);
4733
+ console.log(` Updated: ${schedule.updatedAt}`);
4734
+ console.log("");
4735
+ } catch (error) {
4736
+ console.error(chalk2.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
4737
+ process.exit(1);
4738
+ }
4739
+ });
4740
+ scheduleCmd.command("enable <id>").description("Enable a schedule").action((id) => {
4741
+ try {
4742
+ const schedule = updateSchedule(id, { enabled: true });
4743
+ console.log(chalk2.green(`Enabled schedule ${chalk2.bold(schedule.name)}`));
4744
+ } catch (error) {
4745
+ console.error(chalk2.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
4746
+ process.exit(1);
4747
+ }
4748
+ });
4749
+ scheduleCmd.command("disable <id>").description("Disable a schedule").action((id) => {
4750
+ try {
4751
+ const schedule = updateSchedule(id, { enabled: false });
4752
+ console.log(chalk2.green(`Disabled schedule ${chalk2.bold(schedule.name)}`));
4753
+ } catch (error) {
4754
+ console.error(chalk2.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
4755
+ process.exit(1);
4756
+ }
4757
+ });
4758
+ scheduleCmd.command("delete <id>").description("Delete a schedule").action((id) => {
4759
+ try {
4760
+ const deleted = deleteSchedule(id);
4761
+ if (deleted) {
4762
+ console.log(chalk2.green(`Deleted schedule: ${id}`));
4763
+ } else {
4764
+ console.error(chalk2.red(`Schedule not found: ${id}`));
4765
+ process.exit(1);
4766
+ }
4767
+ } catch (error) {
4768
+ console.error(chalk2.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
4769
+ process.exit(1);
4770
+ }
4771
+ });
4772
+ scheduleCmd.command("run <id>").description("Manually trigger a schedule").option("--json", "Output results as JSON", false).action(async (id, opts) => {
4773
+ try {
4774
+ const schedule = getSchedule(id);
4775
+ if (!schedule) {
4776
+ console.error(chalk2.red(`Schedule not found: ${id}`));
4777
+ process.exit(1);
4778
+ return;
4779
+ }
4780
+ console.log(chalk2.blue(`Running schedule ${chalk2.bold(schedule.name)} against ${schedule.url}...`));
4781
+ const { run, results } = await runByFilter({
4782
+ url: schedule.url,
4783
+ tags: schedule.scenarioFilter.tags,
4784
+ priority: schedule.scenarioFilter.priority,
4785
+ scenarioIds: schedule.scenarioFilter.scenarioIds,
4786
+ model: schedule.model ?? undefined,
4787
+ headed: schedule.headed,
4788
+ parallel: schedule.parallel,
4789
+ timeout: schedule.timeoutMs ?? undefined,
4790
+ projectId: schedule.projectId ?? undefined
4791
+ });
4792
+ if (opts.json) {
4793
+ console.log(formatJSON(run, results));
4794
+ } else {
4795
+ console.log(formatTerminal(run, results));
4796
+ }
4797
+ process.exit(getExitCode(run));
4798
+ } catch (error) {
4799
+ console.error(chalk2.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
4800
+ process.exit(1);
4801
+ }
4802
+ });
4803
+ program2.command("daemon").description("Start the scheduler daemon").option("--interval <seconds>", "Check interval in seconds", "60").action(async (opts) => {
4804
+ try {
4805
+ const intervalMs = parseInt(opts.interval, 10) * 1000;
4806
+ console.log(chalk2.blue("Scheduler daemon started. Press Ctrl+C to stop."));
4807
+ console.log(chalk2.dim(` Check interval: ${opts.interval}s`));
4808
+ let running = true;
4809
+ const checkAndRun = async () => {
4810
+ while (running) {
4811
+ try {
4812
+ const schedules = listSchedules({ enabled: true });
4813
+ const now2 = new Date().toISOString();
4814
+ for (const schedule of schedules) {
4815
+ if (schedule.nextRunAt && schedule.nextRunAt <= now2) {
4816
+ console.log(chalk2.blue(`[${new Date().toISOString()}] Triggering schedule: ${schedule.name}`));
4817
+ try {
4818
+ const { run } = await runByFilter({
4819
+ url: schedule.url,
4820
+ tags: schedule.scenarioFilter.tags,
4821
+ priority: schedule.scenarioFilter.priority,
4822
+ scenarioIds: schedule.scenarioFilter.scenarioIds,
4823
+ model: schedule.model ?? undefined,
4824
+ headed: schedule.headed,
4825
+ parallel: schedule.parallel,
4826
+ timeout: schedule.timeoutMs ?? undefined,
4827
+ projectId: schedule.projectId ?? undefined
4828
+ });
4829
+ const statusColor = run.status === "passed" ? chalk2.green : chalk2.red;
4830
+ console.log(` ${statusColor(run.status)} \u2014 ${run.passed}/${run.total} passed`);
4831
+ updateSchedule(schedule.id, {});
4832
+ } catch (err) {
4833
+ console.error(chalk2.red(` Error running schedule ${schedule.name}: ${err instanceof Error ? err.message : String(err)}`));
4834
+ }
4835
+ }
4836
+ }
4837
+ } catch (err) {
4838
+ console.error(chalk2.red(`Daemon error: ${err instanceof Error ? err.message : String(err)}`));
4839
+ }
4840
+ await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
4841
+ }
4842
+ };
4843
+ process.on("SIGINT", () => {
4844
+ console.log(chalk2.yellow(`
4845
+ Shutting down scheduler daemon...`));
4846
+ running = false;
4847
+ process.exit(0);
4848
+ });
4849
+ process.on("SIGTERM", () => {
4850
+ console.log(chalk2.yellow(`
4851
+ Shutting down scheduler daemon...`));
4852
+ running = false;
4853
+ process.exit(0);
4854
+ });
4855
+ await checkAndRun();
4856
+ } catch (error) {
4857
+ console.error(chalk2.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
4858
+ process.exit(1);
4859
+ }
4860
+ });
4112
4861
  program2.parse();