@hasna/testers 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp/index.js CHANGED
@@ -4080,7 +4080,10 @@ 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
4084
4087
  };
4085
4088
  }
4086
4089
  function scheduleFromRow(row) {
@@ -4289,6 +4292,34 @@ var MIGRATIONS = [
4289
4292
  CREATE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
4290
4293
  CREATE INDEX IF NOT EXISTS idx_schedules_enabled ON schedules(enabled);
4291
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);
4292
4323
  `
4293
4324
  ];
4294
4325
  function applyMigrations(database) {
@@ -4686,9 +4717,9 @@ function createScreenshot(input) {
4686
4717
  const id = uuid();
4687
4718
  const timestamp = now();
4688
4719
  db2.query(`
4689
- INSERT INTO screenshots (id, result_id, step_number, action, file_path, width, height, timestamp)
4690
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
4691
- `).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);
4692
4723
  return getScreenshot(id);
4693
4724
  }
4694
4725
  function getScreenshot(id) {
@@ -4805,7 +4836,7 @@ async function closeBrowser(browser) {
4805
4836
  }
4806
4837
 
4807
4838
  // src/lib/screenshotter.ts
4808
- import { mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
4839
+ import { mkdirSync as mkdirSync2, existsSync as existsSync2, writeFileSync } from "fs";
4809
4840
  import { join as join2 } from "path";
4810
4841
  import { homedir as homedir2 } from "os";
4811
4842
  function slugify(text) {
@@ -4814,16 +4845,51 @@ function slugify(text) {
4814
4845
  function generateFilename(stepNumber, action) {
4815
4846
  const padded = String(stepNumber).padStart(3, "0");
4816
4847
  const slug = slugify(action);
4817
- return `${padded}-${slug}.png`;
4848
+ return `${padded}_${slug}.png`;
4849
+ }
4850
+ function formatDate(date) {
4851
+ return date.toISOString().slice(0, 10);
4852
+ }
4853
+ function formatTime(date) {
4854
+ return date.toISOString().slice(11, 19).replace(/:/g, "-");
4818
4855
  }
4819
- function getScreenshotDir(baseDir, runId, scenarioSlug) {
4820
- return join2(baseDir, runId, scenarioSlug);
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);
4821
4862
  }
4822
4863
  function ensureDir(dirPath) {
4823
4864
  if (!existsSync2(dirPath)) {
4824
4865
  mkdirSync2(dirPath, { recursive: true });
4825
4866
  }
4826
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
+ }
4827
4893
  var DEFAULT_BASE_DIR = join2(homedir2(), ".testers", "screenshots");
4828
4894
 
4829
4895
  class Screenshotter {
@@ -4831,15 +4897,20 @@ class Screenshotter {
4831
4897
  format;
4832
4898
  quality;
4833
4899
  fullPage;
4900
+ projectName;
4901
+ runTimestamp;
4834
4902
  constructor(options = {}) {
4835
4903
  this.baseDir = options.baseDir ?? DEFAULT_BASE_DIR;
4836
4904
  this.format = options.format ?? "png";
4837
4905
  this.quality = options.quality ?? 90;
4838
4906
  this.fullPage = options.fullPage ?? false;
4907
+ this.projectName = options.projectName ?? "default";
4908
+ this.runTimestamp = new Date;
4839
4909
  }
4840
4910
  async capture(page, options) {
4841
- const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug);
4842
- const filename = generateFilename(options.stepNumber, options.action);
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);
4843
4914
  const filePath = join2(dir, filename);
4844
4915
  ensureDir(dir);
4845
4916
  await page.screenshot({
@@ -4849,16 +4920,32 @@ class Screenshotter {
4849
4920
  quality: this.format === "jpeg" ? this.quality : undefined
4850
4921
  });
4851
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);
4852
4935
  return {
4853
4936
  filePath,
4854
4937
  width: viewport.width,
4855
4938
  height: viewport.height,
4856
- timestamp: new Date().toISOString()
4939
+ timestamp,
4940
+ description: options.description ?? null,
4941
+ pageUrl,
4942
+ thumbnailPath
4857
4943
  };
4858
4944
  }
4859
4945
  async captureFullPage(page, options) {
4860
- const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug);
4861
- const filename = generateFilename(options.stepNumber, options.action);
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);
4862
4949
  const filePath = join2(dir, filename);
4863
4950
  ensureDir(dir);
4864
4951
  await page.screenshot({
@@ -4868,16 +4955,32 @@ class Screenshotter {
4868
4955
  quality: this.format === "jpeg" ? this.quality : undefined
4869
4956
  });
4870
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);
4871
4970
  return {
4872
4971
  filePath,
4873
4972
  width: viewport.width,
4874
4973
  height: viewport.height,
4875
- timestamp: new Date().toISOString()
4974
+ timestamp,
4975
+ description: options.description ?? null,
4976
+ pageUrl,
4977
+ thumbnailPath
4876
4978
  };
4877
4979
  }
4878
4980
  async captureElement(page, selector, options) {
4879
- const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug);
4880
- const filename = generateFilename(options.stepNumber, options.action);
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);
4881
4984
  const filePath = join2(dir, filename);
4882
4985
  ensureDir(dir);
4883
4986
  await page.locator(selector).screenshot({
@@ -4886,11 +4989,25 @@ class Screenshotter {
4886
4989
  quality: this.format === "jpeg" ? this.quality : undefined
4887
4990
  });
4888
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
+ });
4889
5003
  return {
4890
5004
  filePath,
4891
5005
  width: viewport.width,
4892
5006
  height: viewport.height,
4893
- timestamp: new Date().toISOString()
5007
+ timestamp,
5008
+ description: options.description ?? null,
5009
+ pageUrl,
5010
+ thumbnailPath: null
4894
5011
  };
4895
5012
  }
4896
5013
  }
@@ -5673,7 +5790,10 @@ async function runSingleScenario(scenario, runId, options) {
5673
5790
  action: ss.action,
5674
5791
  filePath: ss.filePath,
5675
5792
  width: ss.width,
5676
- height: ss.height
5793
+ height: ss.height,
5794
+ description: ss.description,
5795
+ pageUrl: ss.pageUrl,
5796
+ thumbnailPath: ss.thumbnailPath
5677
5797
  });
5678
5798
  emit({ type: "screenshot:captured", screenshotPath: ss.filePath, scenarioId: scenario.id, runId });
5679
5799
  }
@@ -5771,6 +5891,79 @@ async function runByFilter(options) {
5771
5891
  }
5772
5892
  return runBatch(scenarios, options);
5773
5893
  }
5894
+ function startRunAsync(options) {
5895
+ const config = loadConfig();
5896
+ const model = resolveModel(options.model ?? config.defaultModel);
5897
+ let scenarios;
5898
+ if (options.scenarioIds && options.scenarioIds.length > 0) {
5899
+ const all = listScenarios({ projectId: options.projectId });
5900
+ scenarios = all.filter((s) => options.scenarioIds.includes(s.id) || options.scenarioIds.includes(s.shortId));
5901
+ } else {
5902
+ scenarios = listScenarios({
5903
+ projectId: options.projectId,
5904
+ tags: options.tags,
5905
+ priority: options.priority
5906
+ });
5907
+ }
5908
+ const parallel = options.parallel ?? 1;
5909
+ const run = createRun({
5910
+ url: options.url,
5911
+ model,
5912
+ headed: options.headed,
5913
+ parallel,
5914
+ projectId: options.projectId
5915
+ });
5916
+ if (scenarios.length === 0) {
5917
+ updateRun(run.id, { status: "passed", total: 0, finished_at: new Date().toISOString() });
5918
+ return { runId: run.id, scenarioCount: 0 };
5919
+ }
5920
+ updateRun(run.id, { status: "running", total: scenarios.length });
5921
+ (async () => {
5922
+ const results = [];
5923
+ try {
5924
+ if (parallel <= 1) {
5925
+ for (const scenario of scenarios) {
5926
+ const result = await runSingleScenario(scenario, run.id, options);
5927
+ results.push(result);
5928
+ }
5929
+ } else {
5930
+ const queue = [...scenarios];
5931
+ const running = [];
5932
+ const processNext = async () => {
5933
+ const scenario = queue.shift();
5934
+ if (!scenario)
5935
+ return;
5936
+ const result = await runSingleScenario(scenario, run.id, options);
5937
+ results.push(result);
5938
+ await processNext();
5939
+ };
5940
+ const workers = Math.min(parallel, scenarios.length);
5941
+ for (let i = 0;i < workers; i++) {
5942
+ running.push(processNext());
5943
+ }
5944
+ await Promise.all(running);
5945
+ }
5946
+ const passed = results.filter((r) => r.status === "passed").length;
5947
+ const failed = results.filter((r) => r.status === "failed" || r.status === "error").length;
5948
+ updateRun(run.id, {
5949
+ status: failed > 0 ? "failed" : "passed",
5950
+ passed,
5951
+ failed,
5952
+ total: scenarios.length,
5953
+ finished_at: new Date().toISOString()
5954
+ });
5955
+ emit({ type: "run:complete", runId: run.id });
5956
+ } catch (error) {
5957
+ const errorMsg = error instanceof Error ? error.message : String(error);
5958
+ updateRun(run.id, {
5959
+ status: "failed",
5960
+ finished_at: new Date().toISOString()
5961
+ });
5962
+ emit({ type: "run:complete", runId: run.id, error: errorMsg });
5963
+ }
5964
+ })();
5965
+ return { runId: run.id, scenarioCount: scenarios.length };
5966
+ }
5774
5967
  function estimateCost(model, tokens) {
5775
5968
  const costs = {
5776
5969
  "claude-haiku-4-5-20251001": 0.1,
@@ -6223,7 +6416,7 @@ class Scheduler {
6223
6416
 
6224
6417
  // src/mcp/index.ts
6225
6418
  var server = new McpServer({
6226
- name: "testers-mcp",
6419
+ name: "testers",
6227
6420
  version: "0.0.1"
6228
6421
  });
6229
6422
  server.tool("create_scenario", "Create a new test scenario", {
@@ -6337,18 +6530,15 @@ server.tool("run_scenarios", "Run test scenarios against a URL", {
6337
6530
  parallel: exports_external.number().optional().describe("Number of parallel workers")
6338
6531
  }, async ({ url, tags, scenarioIds, priority, model, headed, parallel }) => {
6339
6532
  try {
6340
- const { run, results } = await runByFilter({ url, tags, scenarioIds, priority, model, headed, parallel });
6341
- const passed = results.filter((r) => r.status === "passed").length;
6342
- const failed = results.filter((r) => r.status === "failed" || r.status === "error").length;
6343
- const skipped = results.filter((r) => r.status === "skipped").length;
6533
+ const { runId, scenarioCount } = startRunAsync({ url, tags, scenarioIds, priority, model, headed, parallel });
6344
6534
  const text = [
6345
- `Run ${run.id} \u2014 ${run.status}`,
6346
- `URL: ${run.url}`,
6347
- `Total: ${results.length} | Passed: ${passed} | Failed: ${failed} | Skipped: ${skipped}`,
6348
- `Model: ${run.model}`,
6349
- `Started: ${run.startedAt}`,
6350
- run.finishedAt ? `Finished: ${run.finishedAt}` : null
6351
- ].filter(Boolean).join(`
6535
+ `Run started: ${runId}`,
6536
+ `Scenarios: ${scenarioCount}`,
6537
+ `URL: ${url}`,
6538
+ `Status: running (async)`,
6539
+ ``,
6540
+ `Poll with get_run to check progress.`
6541
+ ].join(`
6352
6542
  `);
6353
6543
  return { content: [{ type: "text", text }] };
6354
6544
  } catch (error) {
@@ -6592,6 +6782,6 @@ async function main() {
6592
6782
  await server.connect(transport);
6593
6783
  }
6594
6784
  main().catch((error) => {
6595
- console.error("Failed to start testers-mcp:", error);
6785
+ console.error("Failed to start testers:", error);
6596
6786
  process.exit(1);
6597
6787
  });
@@ -77,7 +77,10 @@ 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
81
84
  };
82
85
  }
83
86
  function scheduleFromRow(row) {
@@ -279,6 +282,34 @@ var MIGRATIONS = [
279
282
  CREATE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
280
283
  CREATE INDEX IF NOT EXISTS idx_schedules_enabled ON schedules(enabled);
281
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);
282
313
  `
283
314
  ];
284
315
  function applyMigrations(database) {
@@ -679,9 +710,9 @@ function createScreenshot(input) {
679
710
  const id = uuid();
680
711
  const timestamp = now();
681
712
  db2.query(`
682
- INSERT INTO screenshots (id, result_id, step_number, action, file_path, width, height, timestamp)
683
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
684
- `).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);
685
716
  return getScreenshot(id);
686
717
  }
687
718
  function getScreenshot(id) {
@@ -739,7 +770,7 @@ async function closeBrowser(browser) {
739
770
  }
740
771
 
741
772
  // src/lib/screenshotter.ts
742
- import { mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
773
+ import { mkdirSync as mkdirSync2, existsSync as existsSync2, writeFileSync } from "fs";
743
774
  import { join as join2 } from "path";
744
775
  import { homedir as homedir2 } from "os";
745
776
  function slugify(text) {
@@ -748,16 +779,51 @@ function slugify(text) {
748
779
  function generateFilename(stepNumber, action) {
749
780
  const padded = String(stepNumber).padStart(3, "0");
750
781
  const slug = slugify(action);
751
- return `${padded}-${slug}.png`;
782
+ return `${padded}_${slug}.png`;
783
+ }
784
+ function formatDate(date) {
785
+ return date.toISOString().slice(0, 10);
752
786
  }
753
- function getScreenshotDir(baseDir, runId, scenarioSlug) {
754
- return join2(baseDir, runId, scenarioSlug);
787
+ function formatTime(date) {
788
+ return date.toISOString().slice(11, 19).replace(/:/g, "-");
789
+ }
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);
755
796
  }
756
797
  function ensureDir(dirPath) {
757
798
  if (!existsSync2(dirPath)) {
758
799
  mkdirSync2(dirPath, { recursive: true });
759
800
  }
760
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
+ }
761
827
  var DEFAULT_BASE_DIR = join2(homedir2(), ".testers", "screenshots");
762
828
 
763
829
  class Screenshotter {
@@ -765,15 +831,20 @@ class Screenshotter {
765
831
  format;
766
832
  quality;
767
833
  fullPage;
834
+ projectName;
835
+ runTimestamp;
768
836
  constructor(options = {}) {
769
837
  this.baseDir = options.baseDir ?? DEFAULT_BASE_DIR;
770
838
  this.format = options.format ?? "png";
771
839
  this.quality = options.quality ?? 90;
772
840
  this.fullPage = options.fullPage ?? false;
841
+ this.projectName = options.projectName ?? "default";
842
+ this.runTimestamp = new Date;
773
843
  }
774
844
  async capture(page, options) {
775
- const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug);
776
- const filename = generateFilename(options.stepNumber, options.action);
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);
777
848
  const filePath = join2(dir, filename);
778
849
  ensureDir(dir);
779
850
  await page.screenshot({
@@ -783,16 +854,32 @@ class Screenshotter {
783
854
  quality: this.format === "jpeg" ? this.quality : undefined
784
855
  });
785
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);
786
869
  return {
787
870
  filePath,
788
871
  width: viewport.width,
789
872
  height: viewport.height,
790
- timestamp: new Date().toISOString()
873
+ timestamp,
874
+ description: options.description ?? null,
875
+ pageUrl,
876
+ thumbnailPath
791
877
  };
792
878
  }
793
879
  async captureFullPage(page, options) {
794
- const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug);
795
- const filename = generateFilename(options.stepNumber, options.action);
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);
796
883
  const filePath = join2(dir, filename);
797
884
  ensureDir(dir);
798
885
  await page.screenshot({
@@ -802,16 +889,32 @@ class Screenshotter {
802
889
  quality: this.format === "jpeg" ? this.quality : undefined
803
890
  });
804
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);
805
904
  return {
806
905
  filePath,
807
906
  width: viewport.width,
808
907
  height: viewport.height,
809
- timestamp: new Date().toISOString()
908
+ timestamp,
909
+ description: options.description ?? null,
910
+ pageUrl,
911
+ thumbnailPath
810
912
  };
811
913
  }
812
914
  async captureElement(page, selector, options) {
813
- const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug);
814
- const filename = generateFilename(options.stepNumber, options.action);
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);
815
918
  const filePath = join2(dir, filename);
816
919
  ensureDir(dir);
817
920
  await page.locator(selector).screenshot({
@@ -820,11 +923,25 @@ class Screenshotter {
820
923
  quality: this.format === "jpeg" ? this.quality : undefined
821
924
  });
822
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
+ });
823
937
  return {
824
938
  filePath,
825
939
  width: viewport.width,
826
940
  height: viewport.height,
827
- timestamp: new Date().toISOString()
941
+ timestamp,
942
+ description: options.description ?? null,
943
+ pageUrl,
944
+ thumbnailPath: null
828
945
  };
829
946
  }
830
947
  }
@@ -1607,7 +1724,10 @@ async function runSingleScenario(scenario, runId, options) {
1607
1724
  action: ss.action,
1608
1725
  filePath: ss.filePath,
1609
1726
  width: ss.width,
1610
- height: ss.height
1727
+ height: ss.height,
1728
+ description: ss.description,
1729
+ pageUrl: ss.pageUrl,
1730
+ thumbnailPath: ss.thumbnailPath
1611
1731
  });
1612
1732
  emit({ type: "screenshot:captured", screenshotPath: ss.filePath, scenarioId: scenario.id, runId });
1613
1733
  }
@@ -79,6 +79,9 @@ export interface ScreenshotRow {
79
79
  width: number;
80
80
  height: number;
81
81
  timestamp: string;
82
+ description: string | null;
83
+ page_url: string | null;
84
+ thumbnail_path: string | null;
82
85
  }
83
86
  export interface ScheduleRow {
84
87
  id: string;
@@ -174,6 +177,9 @@ export interface Screenshot {
174
177
  width: number;
175
178
  height: number;
176
179
  timestamp: string;
180
+ description: string | null;
181
+ pageUrl: string | null;
182
+ thumbnailPath: string | null;
177
183
  }
178
184
  export interface Schedule {
179
185
  id: string;