@hasna/testers 0.0.38 → 0.0.40

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
@@ -16697,12 +16697,19 @@ var init_personas = __esm(() => {
16697
16697
  // src/lib/screenshotter.ts
16698
16698
  import { mkdirSync as mkdirSync7, existsSync as existsSync8, writeFileSync as writeFileSync2 } from "fs";
16699
16699
  import { join as join10 } from "path";
16700
- function slugify(text) {
16701
- return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
16700
+ function truncateSlug(slug, maxLength) {
16701
+ if (slug.length <= maxLength)
16702
+ return slug;
16703
+ const truncated = slug.slice(0, maxLength).replace(/-+$/g, "");
16704
+ return truncated || slug.slice(0, maxLength);
16705
+ }
16706
+ function slugify(text, maxLength) {
16707
+ const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
16708
+ return maxLength ? truncateSlug(slug, maxLength) : slug;
16702
16709
  }
16703
16710
  function generateFilename(stepNumber, action) {
16704
16711
  const padded = String(stepNumber).padStart(3, "0");
16705
- const slug = slugify(action);
16712
+ const slug = slugify(action, MAX_ACTION_SLUG_LENGTH);
16706
16713
  return `${padded}_${slug}.png`;
16707
16714
  }
16708
16715
  function formatDate(date) {
@@ -16716,7 +16723,8 @@ function getScreenshotDir(baseDir, runId, scenarioSlug, projectName, timestamp)
16716
16723
  const project = projectName ?? "default";
16717
16724
  const dateDir = formatDate(now2);
16718
16725
  const timeDir = `${formatTime(now2)}_${runId.slice(0, 8)}`;
16719
- return join10(baseDir, project, dateDir, timeDir, scenarioSlug);
16726
+ const safeScenarioSlug = slugify(scenarioSlug, MAX_SCENARIO_SLUG_LENGTH) || "scenario";
16727
+ return join10(baseDir, project, dateDir, timeDir, safeScenarioSlug);
16720
16728
  }
16721
16729
  function ensureDir(dirPath) {
16722
16730
  if (!existsSync8(dirPath)) {
@@ -16874,7 +16882,7 @@ class Screenshotter {
16874
16882
  };
16875
16883
  }
16876
16884
  }
16877
- var DEFAULT_BASE_DIR;
16885
+ var MAX_ACTION_SLUG_LENGTH = 80, MAX_SCENARIO_SLUG_LENGTH = 96, DEFAULT_BASE_DIR;
16878
16886
  var init_screenshotter = __esm(() => {
16879
16887
  init_paths();
16880
16888
  DEFAULT_BASE_DIR = join10(getTestersDir(), "screenshots");
@@ -16929,10 +16937,16 @@ function isCredentialReference(value) {
16929
16937
  var init_secrets_resolver = () => {};
16930
16938
 
16931
16939
  // src/lib/persona-auth.ts
16940
+ function isSessionCookie(cookieName) {
16941
+ return !/(?:csrf|xsrf)/i.test(cookieName);
16942
+ }
16932
16943
  function areCookiesFresh(persona) {
16933
16944
  if (!persona.auth?.cookies?.length)
16934
16945
  return false;
16935
16946
  const cookies = persona.auth.cookies;
16947
+ if (!cookies.some((cookie) => ("name" in cookie) && isSessionCookie(String(cookie.name)))) {
16948
+ return false;
16949
+ }
16936
16950
  const now2 = Date.now() / 1000;
16937
16951
  const hasFutureExpiry = cookies.some((c) => c.expires && c.expires > now2 + 60);
16938
16952
  if (hasFutureExpiry)
@@ -16974,6 +16988,7 @@ async function performLogin(page, persona, baseUrl) {
16974
16988
  const loginUrl = auth.loginPath.startsWith("http") ? auth.loginPath : `${baseUrl.replace(/\/$/, "")}${auth.loginPath}`;
16975
16989
  try {
16976
16990
  await page.goto(loginUrl, { timeout: 30000, waitUntil: "domcontentloaded" });
16991
+ await page.waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {});
16977
16992
  } catch (err) {
16978
16993
  return { success: false, method: "login", error: `Navigation to login page failed: ${err instanceof Error ? err.message : String(err)}` };
16979
16994
  }
@@ -17030,11 +17045,25 @@ async function performLogin(page, persona, baseUrl) {
17030
17045
  if (!passwordFilled) {
17031
17046
  return { success: false, method: "login", error: "Could not find password field on login page" };
17032
17047
  }
17048
+ await page.waitForFunction(() => {
17049
+ const submits = Array.from(document.querySelectorAll('button[type="submit"], input[type="submit"]'));
17050
+ return submits.length === 0 || submits.some((submit) => {
17051
+ const rect = submit.getBoundingClientRect();
17052
+ const visible = rect.width > 0 || rect.height > 0 || submit.getClientRects().length > 0;
17053
+ return visible && !submit.disabled;
17054
+ });
17055
+ }, null, { timeout: 5000 }).catch(() => {});
17033
17056
  let submitted = false;
17034
17057
  for (const sel of submitSelectors) {
17035
17058
  try {
17036
- const el = page.locator(sel).first();
17037
- if (await el.isVisible({ timeout: 2000 })) {
17059
+ const matches = page.locator(sel);
17060
+ const count = await matches.count().catch(() => 0);
17061
+ for (let i = 0;i < Math.min(count, 10); i++) {
17062
+ const el = matches.nth(i);
17063
+ if (!await el.isVisible({ timeout: 500 }).catch(() => false))
17064
+ continue;
17065
+ if (!await el.isEnabled({ timeout: 2000 }).catch(() => false))
17066
+ continue;
17038
17067
  await Promise.all([
17039
17068
  page.waitForNavigation({ timeout: 15000, waitUntil: "domcontentloaded" }).catch(() => {}),
17040
17069
  el.click({ timeout: 5000 })
@@ -17042,6 +17071,8 @@ async function performLogin(page, persona, baseUrl) {
17042
17071
  submitted = true;
17043
17072
  break;
17044
17073
  }
17074
+ if (submitted)
17075
+ break;
17045
17076
  } catch {}
17046
17077
  }
17047
17078
  if (!submitted) {
@@ -17058,6 +17089,11 @@ async function performLogin(page, persona, baseUrl) {
17058
17089
  const currentUrl = page.url();
17059
17090
  const isStillOnLogin = currentUrl.includes(auth.loginPath) || currentUrl.includes("/login") || currentUrl.includes("/signin") || currentUrl.includes("/auth");
17060
17091
  if (isStillOnLogin) {
17092
+ const cookies = await page.context().cookies().catch(() => []);
17093
+ const authCookies = cookies.filter((cookie) => isSessionCookie(cookie.name));
17094
+ if (authCookies.length > 0) {
17095
+ return { success: true, method: "login" };
17096
+ }
17061
17097
  let errorText = "";
17062
17098
  try {
17063
17099
  const errorEl = page.locator('[role="alert"], .error, .alert-error, [data-testid*="error"]').first();
@@ -93989,7 +94025,7 @@ import chalk6 from "chalk";
93989
94025
  // package.json
93990
94026
  var package_default = {
93991
94027
  name: "@hasna/testers",
93992
- version: "0.0.38",
94028
+ version: "0.0.40",
93993
94029
  description: "AI-powered QA testing CLI \u2014 spawns cheap AI agents to test web apps with headless browsers",
93994
94030
  type: "module",
93995
94031
  main: "dist/index.js",
@@ -97621,6 +97657,7 @@ program2.command("run [url] [description]").alias("test").description("Run test
97621
97657
  process.exit(2);
97622
97658
  }, overallTimeoutMs).unref();
97623
97659
  }
97660
+ const personaIdList = opts.persona ? opts.persona.split(",").map((s2) => s2.trim()).filter(Boolean) : undefined;
97624
97661
  if (!opts.dryRun && !opts.background) {
97625
97662
  const budgetResult = checkBudget(0);
97626
97663
  if (budgetResult.warning) {
@@ -97704,7 +97741,9 @@ program2.command("run [url] [description]").alias("test").description("Run test
97704
97741
  timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
97705
97742
  maxTurns: opts.maxTurns ? parseInt(opts.maxTurns, 10) : undefined,
97706
97743
  projectId,
97707
- engine: opts.browser
97744
+ engine: opts.browser,
97745
+ personaId: personaIdList?.[0],
97746
+ personaIds: personaIdList && personaIdList.length > 1 ? personaIdList : undefined
97708
97747
  });
97709
97748
  log(chalk6.green(`Run started in background: ${chalk6.bold(runId.slice(0, 8))}`));
97710
97749
  log(chalk6.dim(` Scenarios: ${scenarioCount}`));
@@ -97828,7 +97867,9 @@ program2.command("run [url] [description]").alias("test").description("Run test
97828
97867
  samples: parseInt(opts.samples ?? "1", 10),
97829
97868
  flakinessThreshold: parseFloat(opts.flakinessThreshold ?? "0.95"),
97830
97869
  a11y: opts.a11y ? typeof opts.a11y === "string" ? { level: opts.a11y } : true : undefined,
97831
- selfHeal: opts.selfHeal || undefined
97870
+ selfHeal: opts.selfHeal || undefined,
97871
+ personaId: personaIdList?.[0],
97872
+ personaIds: personaIdList && personaIdList.length > 1 ? personaIdList : undefined
97832
97873
  });
97833
97874
  if (opts.json || opts.output) {
97834
97875
  const jsonOutput = formatJSON(run3, results2);
@@ -97916,7 +97957,6 @@ program2.command("run [url] [description]").alias("test").description("Run test
97916
97957
  log(chalk6.yellow(" --diff: git diff failed. Running all scenarios."));
97917
97958
  }
97918
97959
  }
97919
- const personaIdList = opts.persona ? opts.persona.split(",").map((s2) => s2.trim()).filter(Boolean) : undefined;
97920
97960
  const { run: run2, results } = await runByFilter({
97921
97961
  url: url2,
97922
97962
  tags: opts.tag.length > 0 ? opts.tag : undefined,
package/dist/index.js CHANGED
@@ -13880,12 +13880,21 @@ init_browser_lightpanda();
13880
13880
  init_paths();
13881
13881
  import { mkdirSync as mkdirSync7, existsSync as existsSync8, writeFileSync as writeFileSync2 } from "fs";
13882
13882
  import { join as join10 } from "path";
13883
- function slugify(text) {
13884
- return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
13883
+ var MAX_ACTION_SLUG_LENGTH = 80;
13884
+ var MAX_SCENARIO_SLUG_LENGTH = 96;
13885
+ function truncateSlug(slug, maxLength) {
13886
+ if (slug.length <= maxLength)
13887
+ return slug;
13888
+ const truncated = slug.slice(0, maxLength).replace(/-+$/g, "");
13889
+ return truncated || slug.slice(0, maxLength);
13890
+ }
13891
+ function slugify(text, maxLength) {
13892
+ const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
13893
+ return maxLength ? truncateSlug(slug, maxLength) : slug;
13885
13894
  }
13886
13895
  function generateFilename(stepNumber, action) {
13887
13896
  const padded = String(stepNumber).padStart(3, "0");
13888
- const slug = slugify(action);
13897
+ const slug = slugify(action, MAX_ACTION_SLUG_LENGTH);
13889
13898
  return `${padded}_${slug}.png`;
13890
13899
  }
13891
13900
  function formatDate(date) {
@@ -13899,7 +13908,8 @@ function getScreenshotDir(baseDir, runId, scenarioSlug, projectName, timestamp)
13899
13908
  const project = projectName ?? "default";
13900
13909
  const dateDir = formatDate(now2);
13901
13910
  const timeDir = `${formatTime(now2)}_${runId.slice(0, 8)}`;
13902
- return join10(baseDir, project, dateDir, timeDir, scenarioSlug);
13911
+ const safeScenarioSlug = slugify(scenarioSlug, MAX_SCENARIO_SLUG_LENGTH) || "scenario";
13912
+ return join10(baseDir, project, dateDir, timeDir, safeScenarioSlug);
13903
13913
  }
13904
13914
  function ensureDir(dirPath) {
13905
13915
  if (!existsSync8(dirPath)) {
@@ -15486,10 +15496,16 @@ function isCredentialReference(value) {
15486
15496
 
15487
15497
  // src/lib/persona-auth.ts
15488
15498
  var COOKIE_MAX_AGE_MS = 60 * 60 * 1000;
15499
+ function isSessionCookie(cookieName) {
15500
+ return !/(?:csrf|xsrf)/i.test(cookieName);
15501
+ }
15489
15502
  function areCookiesFresh(persona) {
15490
15503
  if (!persona.auth?.cookies?.length)
15491
15504
  return false;
15492
15505
  const cookies = persona.auth.cookies;
15506
+ if (!cookies.some((cookie) => ("name" in cookie) && isSessionCookie(String(cookie.name)))) {
15507
+ return false;
15508
+ }
15493
15509
  const now2 = Date.now() / 1000;
15494
15510
  const hasFutureExpiry = cookies.some((c) => c.expires && c.expires > now2 + 60);
15495
15511
  if (hasFutureExpiry)
@@ -15531,6 +15547,7 @@ async function performLogin(page, persona, baseUrl) {
15531
15547
  const loginUrl = auth.loginPath.startsWith("http") ? auth.loginPath : `${baseUrl.replace(/\/$/, "")}${auth.loginPath}`;
15532
15548
  try {
15533
15549
  await page.goto(loginUrl, { timeout: 30000, waitUntil: "domcontentloaded" });
15550
+ await page.waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {});
15534
15551
  } catch (err) {
15535
15552
  return { success: false, method: "login", error: `Navigation to login page failed: ${err instanceof Error ? err.message : String(err)}` };
15536
15553
  }
@@ -15587,11 +15604,25 @@ async function performLogin(page, persona, baseUrl) {
15587
15604
  if (!passwordFilled) {
15588
15605
  return { success: false, method: "login", error: "Could not find password field on login page" };
15589
15606
  }
15607
+ await page.waitForFunction(() => {
15608
+ const submits = Array.from(document.querySelectorAll('button[type="submit"], input[type="submit"]'));
15609
+ return submits.length === 0 || submits.some((submit) => {
15610
+ const rect = submit.getBoundingClientRect();
15611
+ const visible = rect.width > 0 || rect.height > 0 || submit.getClientRects().length > 0;
15612
+ return visible && !submit.disabled;
15613
+ });
15614
+ }, null, { timeout: 5000 }).catch(() => {});
15590
15615
  let submitted = false;
15591
15616
  for (const sel of submitSelectors) {
15592
15617
  try {
15593
- const el = page.locator(sel).first();
15594
- if (await el.isVisible({ timeout: 2000 })) {
15618
+ const matches = page.locator(sel);
15619
+ const count = await matches.count().catch(() => 0);
15620
+ for (let i = 0;i < Math.min(count, 10); i++) {
15621
+ const el = matches.nth(i);
15622
+ if (!await el.isVisible({ timeout: 500 }).catch(() => false))
15623
+ continue;
15624
+ if (!await el.isEnabled({ timeout: 2000 }).catch(() => false))
15625
+ continue;
15595
15626
  await Promise.all([
15596
15627
  page.waitForNavigation({ timeout: 15000, waitUntil: "domcontentloaded" }).catch(() => {}),
15597
15628
  el.click({ timeout: 5000 })
@@ -15599,6 +15630,8 @@ async function performLogin(page, persona, baseUrl) {
15599
15630
  submitted = true;
15600
15631
  break;
15601
15632
  }
15633
+ if (submitted)
15634
+ break;
15602
15635
  } catch {}
15603
15636
  }
15604
15637
  if (!submitted) {
@@ -15615,6 +15648,11 @@ async function performLogin(page, persona, baseUrl) {
15615
15648
  const currentUrl = page.url();
15616
15649
  const isStillOnLogin = currentUrl.includes(auth.loginPath) || currentUrl.includes("/login") || currentUrl.includes("/signin") || currentUrl.includes("/auth");
15617
15650
  if (isStillOnLogin) {
15651
+ const cookies = await page.context().cookies().catch(() => []);
15652
+ const authCookies = cookies.filter((cookie) => isSessionCookie(cookie.name));
15653
+ if (authCookies.length > 0) {
15654
+ return { success: true, method: "login" };
15655
+ }
15618
15656
  let errorText = "";
15619
15657
  try {
15620
15658
  const errorEl = page.locator('[role="alert"], .error, .alert-error, [data-testid*="error"]').first();
@@ -1,5 +1,6 @@
1
1
  import type { Page } from "playwright";
2
2
  import type { Persona } from "../types/index.js";
3
+ export declare function isSessionCookie(cookieName: string): boolean;
3
4
  export interface LoginResult {
4
5
  success: boolean;
5
6
  method: "cookies" | "login" | "none";
@@ -1 +1 @@
1
- {"version":3,"file":"persona-auth.d.ts","sourceRoot":"","sources":["../../src/lib/persona-auth.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAMjD,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,SAAS,GAAG,OAAO,GAAG,MAAM,CAAC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAoND;;;GAGG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,IAAI,EACV,MAAM,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,EACvG,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,WAAW,CAAC,CAgCtB;AAED;;;;;;;;;GASG;AACH,wBAAsB,0BAA0B,CAC9C,IAAI,EAAE,IAAI,EACV,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,WAAW,CAAC,CAsBtB"}
1
+ {"version":3,"file":"persona-auth.d.ts","sourceRoot":"","sources":["../../src/lib/persona-auth.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAMjD,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAE3D;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,SAAS,GAAG,OAAO,GAAG,MAAM,CAAC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA+OD;;;GAGG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,IAAI,EACV,MAAM,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,EACvG,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,WAAW,CAAC,CAgCtB;AAED;;;;;;;;;GASG;AACH,wBAAsB,0BAA0B,CAC9C,IAAI,EAAE,IAAI,EACV,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,WAAW,CAAC,CAsBtB"}
@@ -1,5 +1,5 @@
1
1
  import type { Page } from "playwright";
2
- export declare function slugify(text: string): string;
2
+ export declare function slugify(text: string, maxLength?: number): string;
3
3
  export declare function generateFilename(stepNumber: number, action: string): string;
4
4
  /**
5
5
  * Build the screenshot directory for a run:
@@ -1 +1 @@
1
- {"version":3,"file":"screenshotter.d.ts","sourceRoot":"","sources":["../../src/lib/screenshotter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAOvC,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAK5C;AAED,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAI3E;AAUD;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,YAAY,EAAE,MAAM,EACpB,WAAW,CAAC,EAAE,MAAM,EACpB,SAAS,CAAC,EAAE,IAAI,GACf,MAAM,CAMR;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAI/C;AAID,UAAU,oBAAoB;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,UAAU,cAAc;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAuBD,wBAAgB,YAAY,CAC1B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,GAC5G,IAAI,CAON;AAED,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GACxH,IAAI,CAON;AAkCD,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IACxC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAU;IACnC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,YAAY,CAAO;gBAEf,OAAO,GAAE,oBAAyB;IASxC,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;IAmDpE,eAAe,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;IAiD5E,cAAc,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;CA6CpG"}
1
+ {"version":3,"file":"screenshotter.d.ts","sourceRoot":"","sources":["../../src/lib/screenshotter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAgBvC,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAMhE;AAED,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAI3E;AAUD;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,YAAY,EAAE,MAAM,EACpB,WAAW,CAAC,EAAE,MAAM,EACpB,SAAS,CAAC,EAAE,IAAI,GACf,MAAM,CAOR;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAI/C;AAID,UAAU,oBAAoB;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,UAAU,cAAc;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAuBD,wBAAgB,YAAY,CAC1B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,GAC5G,IAAI,CAON;AAED,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GACxH,IAAI,CAON;AAkCD,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IACxC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAU;IACnC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,YAAY,CAAO;gBAEf,OAAO,GAAE,oBAAyB;IASxC,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;IAmDpE,eAAe,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;IAiD5E,cAAc,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;CA6CpG"}
package/dist/mcp/index.js CHANGED
@@ -52,7 +52,7 @@ var package_default;
52
52
  var init_package = __esm(() => {
53
53
  package_default = {
54
54
  name: "@hasna/testers",
55
- version: "0.0.38",
55
+ version: "0.0.40",
56
56
  description: "AI-powered QA testing CLI \u2014 spawns cheap AI agents to test web apps with headless browsers",
57
57
  type: "module",
58
58
  main: "dist/index.js",
@@ -19805,12 +19805,19 @@ var init_personas = __esm(() => {
19805
19805
  // src/lib/screenshotter.ts
19806
19806
  import { mkdirSync as mkdirSync7, existsSync as existsSync8, writeFileSync as writeFileSync2 } from "fs";
19807
19807
  import { join as join10 } from "path";
19808
- function slugify(text) {
19809
- return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
19808
+ function truncateSlug(slug, maxLength) {
19809
+ if (slug.length <= maxLength)
19810
+ return slug;
19811
+ const truncated = slug.slice(0, maxLength).replace(/-+$/g, "");
19812
+ return truncated || slug.slice(0, maxLength);
19813
+ }
19814
+ function slugify(text, maxLength) {
19815
+ const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
19816
+ return maxLength ? truncateSlug(slug, maxLength) : slug;
19810
19817
  }
19811
19818
  function generateFilename(stepNumber, action) {
19812
19819
  const padded = String(stepNumber).padStart(3, "0");
19813
- const slug = slugify(action);
19820
+ const slug = slugify(action, MAX_ACTION_SLUG_LENGTH);
19814
19821
  return `${padded}_${slug}.png`;
19815
19822
  }
19816
19823
  function formatDate(date) {
@@ -19824,7 +19831,8 @@ function getScreenshotDir(baseDir, runId, scenarioSlug, projectName, timestamp)
19824
19831
  const project = projectName ?? "default";
19825
19832
  const dateDir = formatDate(now2);
19826
19833
  const timeDir = `${formatTime(now2)}_${runId.slice(0, 8)}`;
19827
- return join10(baseDir, project, dateDir, timeDir, scenarioSlug);
19834
+ const safeScenarioSlug = slugify(scenarioSlug, MAX_SCENARIO_SLUG_LENGTH) || "scenario";
19835
+ return join10(baseDir, project, dateDir, timeDir, safeScenarioSlug);
19828
19836
  }
19829
19837
  function ensureDir(dirPath) {
19830
19838
  if (!existsSync8(dirPath)) {
@@ -19982,7 +19990,7 @@ class Screenshotter {
19982
19990
  };
19983
19991
  }
19984
19992
  }
19985
- var DEFAULT_BASE_DIR;
19993
+ var MAX_ACTION_SLUG_LENGTH = 80, MAX_SCENARIO_SLUG_LENGTH = 96, DEFAULT_BASE_DIR;
19986
19994
  var init_screenshotter = __esm(() => {
19987
19995
  init_paths();
19988
19996
  DEFAULT_BASE_DIR = join10(getTestersDir(), "screenshots");
@@ -20037,10 +20045,16 @@ function isCredentialReference(value) {
20037
20045
  var init_secrets_resolver = () => {};
20038
20046
 
20039
20047
  // src/lib/persona-auth.ts
20048
+ function isSessionCookie(cookieName) {
20049
+ return !/(?:csrf|xsrf)/i.test(cookieName);
20050
+ }
20040
20051
  function areCookiesFresh(persona) {
20041
20052
  if (!persona.auth?.cookies?.length)
20042
20053
  return false;
20043
20054
  const cookies = persona.auth.cookies;
20055
+ if (!cookies.some((cookie) => ("name" in cookie) && isSessionCookie(String(cookie.name)))) {
20056
+ return false;
20057
+ }
20044
20058
  const now2 = Date.now() / 1000;
20045
20059
  const hasFutureExpiry = cookies.some((c) => c.expires && c.expires > now2 + 60);
20046
20060
  if (hasFutureExpiry)
@@ -20082,6 +20096,7 @@ async function performLogin(page, persona, baseUrl) {
20082
20096
  const loginUrl = auth.loginPath.startsWith("http") ? auth.loginPath : `${baseUrl.replace(/\/$/, "")}${auth.loginPath}`;
20083
20097
  try {
20084
20098
  await page.goto(loginUrl, { timeout: 30000, waitUntil: "domcontentloaded" });
20099
+ await page.waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {});
20085
20100
  } catch (err) {
20086
20101
  return { success: false, method: "login", error: `Navigation to login page failed: ${err instanceof Error ? err.message : String(err)}` };
20087
20102
  }
@@ -20138,11 +20153,25 @@ async function performLogin(page, persona, baseUrl) {
20138
20153
  if (!passwordFilled) {
20139
20154
  return { success: false, method: "login", error: "Could not find password field on login page" };
20140
20155
  }
20156
+ await page.waitForFunction(() => {
20157
+ const submits = Array.from(document.querySelectorAll('button[type="submit"], input[type="submit"]'));
20158
+ return submits.length === 0 || submits.some((submit) => {
20159
+ const rect = submit.getBoundingClientRect();
20160
+ const visible = rect.width > 0 || rect.height > 0 || submit.getClientRects().length > 0;
20161
+ return visible && !submit.disabled;
20162
+ });
20163
+ }, null, { timeout: 5000 }).catch(() => {});
20141
20164
  let submitted = false;
20142
20165
  for (const sel of submitSelectors) {
20143
20166
  try {
20144
- const el = page.locator(sel).first();
20145
- if (await el.isVisible({ timeout: 2000 })) {
20167
+ const matches = page.locator(sel);
20168
+ const count = await matches.count().catch(() => 0);
20169
+ for (let i = 0;i < Math.min(count, 10); i++) {
20170
+ const el = matches.nth(i);
20171
+ if (!await el.isVisible({ timeout: 500 }).catch(() => false))
20172
+ continue;
20173
+ if (!await el.isEnabled({ timeout: 2000 }).catch(() => false))
20174
+ continue;
20146
20175
  await Promise.all([
20147
20176
  page.waitForNavigation({ timeout: 15000, waitUntil: "domcontentloaded" }).catch(() => {}),
20148
20177
  el.click({ timeout: 5000 })
@@ -20150,6 +20179,8 @@ async function performLogin(page, persona, baseUrl) {
20150
20179
  submitted = true;
20151
20180
  break;
20152
20181
  }
20182
+ if (submitted)
20183
+ break;
20153
20184
  } catch {}
20154
20185
  }
20155
20186
  if (!submitted) {
@@ -20166,6 +20197,11 @@ async function performLogin(page, persona, baseUrl) {
20166
20197
  const currentUrl = page.url();
20167
20198
  const isStillOnLogin = currentUrl.includes(auth.loginPath) || currentUrl.includes("/login") || currentUrl.includes("/signin") || currentUrl.includes("/auth");
20168
20199
  if (isStillOnLogin) {
20200
+ const cookies = await page.context().cookies().catch(() => []);
20201
+ const authCookies = cookies.filter((cookie) => isSessionCookie(cookie.name));
20202
+ if (authCookies.length > 0) {
20203
+ return { success: true, method: "login" };
20204
+ }
20169
20205
  let errorText = "";
20170
20206
  try {
20171
20207
  const errorEl = page.locator('[role="alert"], .error, .alert-error, [data-testid*="error"]').first();
@@ -46910,7 +46910,7 @@ import { join as join14 } from "path";
46910
46910
  // package.json
46911
46911
  var package_default = {
46912
46912
  name: "@hasna/testers",
46913
- version: "0.0.38",
46913
+ version: "0.0.40",
46914
46914
  description: "AI-powered QA testing CLI \u2014 spawns cheap AI agents to test web apps with headless browsers",
46915
46915
  type: "module",
46916
46916
  main: "dist/index.js",
@@ -48303,12 +48303,21 @@ init_browser();
48303
48303
  init_paths();
48304
48304
  import { mkdirSync as mkdirSync5, existsSync as existsSync7, writeFileSync as writeFileSync2 } from "fs";
48305
48305
  import { join as join9 } from "path";
48306
- function slugify(text) {
48307
- return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
48306
+ var MAX_ACTION_SLUG_LENGTH = 80;
48307
+ var MAX_SCENARIO_SLUG_LENGTH = 96;
48308
+ function truncateSlug(slug, maxLength) {
48309
+ if (slug.length <= maxLength)
48310
+ return slug;
48311
+ const truncated = slug.slice(0, maxLength).replace(/-+$/g, "");
48312
+ return truncated || slug.slice(0, maxLength);
48313
+ }
48314
+ function slugify(text, maxLength) {
48315
+ const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
48316
+ return maxLength ? truncateSlug(slug, maxLength) : slug;
48308
48317
  }
48309
48318
  function generateFilename(stepNumber, action) {
48310
48319
  const padded = String(stepNumber).padStart(3, "0");
48311
- const slug = slugify(action);
48320
+ const slug = slugify(action, MAX_ACTION_SLUG_LENGTH);
48312
48321
  return `${padded}_${slug}.png`;
48313
48322
  }
48314
48323
  function formatDate(date) {
@@ -48322,7 +48331,8 @@ function getScreenshotDir(baseDir, runId, scenarioSlug, projectName, timestamp)
48322
48331
  const project = projectName ?? "default";
48323
48332
  const dateDir = formatDate(now2);
48324
48333
  const timeDir = `${formatTime(now2)}_${runId.slice(0, 8)}`;
48325
- return join9(baseDir, project, dateDir, timeDir, scenarioSlug);
48334
+ const safeScenarioSlug = slugify(scenarioSlug, MAX_SCENARIO_SLUG_LENGTH) || "scenario";
48335
+ return join9(baseDir, project, dateDir, timeDir, safeScenarioSlug);
48326
48336
  }
48327
48337
  function ensureDir(dirPath) {
48328
48338
  if (!existsSync7(dirPath)) {
@@ -48530,10 +48540,16 @@ function resolveCredential(value) {
48530
48540
 
48531
48541
  // src/lib/persona-auth.ts
48532
48542
  var COOKIE_MAX_AGE_MS = 60 * 60 * 1000;
48543
+ function isSessionCookie(cookieName) {
48544
+ return !/(?:csrf|xsrf)/i.test(cookieName);
48545
+ }
48533
48546
  function areCookiesFresh(persona) {
48534
48547
  if (!persona.auth?.cookies?.length)
48535
48548
  return false;
48536
48549
  const cookies = persona.auth.cookies;
48550
+ if (!cookies.some((cookie) => ("name" in cookie) && isSessionCookie(String(cookie.name)))) {
48551
+ return false;
48552
+ }
48537
48553
  const now2 = Date.now() / 1000;
48538
48554
  const hasFutureExpiry = cookies.some((c) => c.expires && c.expires > now2 + 60);
48539
48555
  if (hasFutureExpiry)
@@ -48575,6 +48591,7 @@ async function performLogin(page, persona, baseUrl) {
48575
48591
  const loginUrl = auth.loginPath.startsWith("http") ? auth.loginPath : `${baseUrl.replace(/\/$/, "")}${auth.loginPath}`;
48576
48592
  try {
48577
48593
  await page.goto(loginUrl, { timeout: 30000, waitUntil: "domcontentloaded" });
48594
+ await page.waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {});
48578
48595
  } catch (err) {
48579
48596
  return { success: false, method: "login", error: `Navigation to login page failed: ${err instanceof Error ? err.message : String(err)}` };
48580
48597
  }
@@ -48631,11 +48648,25 @@ async function performLogin(page, persona, baseUrl) {
48631
48648
  if (!passwordFilled) {
48632
48649
  return { success: false, method: "login", error: "Could not find password field on login page" };
48633
48650
  }
48651
+ await page.waitForFunction(() => {
48652
+ const submits = Array.from(document.querySelectorAll('button[type="submit"], input[type="submit"]'));
48653
+ return submits.length === 0 || submits.some((submit) => {
48654
+ const rect = submit.getBoundingClientRect();
48655
+ const visible = rect.width > 0 || rect.height > 0 || submit.getClientRects().length > 0;
48656
+ return visible && !submit.disabled;
48657
+ });
48658
+ }, null, { timeout: 5000 }).catch(() => {});
48634
48659
  let submitted = false;
48635
48660
  for (const sel of submitSelectors) {
48636
48661
  try {
48637
- const el = page.locator(sel).first();
48638
- if (await el.isVisible({ timeout: 2000 })) {
48662
+ const matches = page.locator(sel);
48663
+ const count = await matches.count().catch(() => 0);
48664
+ for (let i = 0;i < Math.min(count, 10); i++) {
48665
+ const el = matches.nth(i);
48666
+ if (!await el.isVisible({ timeout: 500 }).catch(() => false))
48667
+ continue;
48668
+ if (!await el.isEnabled({ timeout: 2000 }).catch(() => false))
48669
+ continue;
48639
48670
  await Promise.all([
48640
48671
  page.waitForNavigation({ timeout: 15000, waitUntil: "domcontentloaded" }).catch(() => {}),
48641
48672
  el.click({ timeout: 5000 })
@@ -48643,6 +48674,8 @@ async function performLogin(page, persona, baseUrl) {
48643
48674
  submitted = true;
48644
48675
  break;
48645
48676
  }
48677
+ if (submitted)
48678
+ break;
48646
48679
  } catch {}
48647
48680
  }
48648
48681
  if (!submitted) {
@@ -48659,6 +48692,11 @@ async function performLogin(page, persona, baseUrl) {
48659
48692
  const currentUrl = page.url();
48660
48693
  const isStillOnLogin = currentUrl.includes(auth.loginPath) || currentUrl.includes("/login") || currentUrl.includes("/signin") || currentUrl.includes("/auth");
48661
48694
  if (isStillOnLogin) {
48695
+ const cookies = await page.context().cookies().catch(() => []);
48696
+ const authCookies = cookies.filter((cookie) => isSessionCookie(cookie.name));
48697
+ if (authCookies.length > 0) {
48698
+ return { success: true, method: "login" };
48699
+ }
48662
48700
  let errorText = "";
48663
48701
  try {
48664
48702
  const errorEl = page.locator('[role="alert"], .error, .alert-error, [data-testid*="error"]').first();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/testers",
3
- "version": "0.0.38",
3
+ "version": "0.0.40",
4
4
  "description": "AI-powered QA testing CLI — spawns cheap AI agents to test web apps with headless browsers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",