@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 +51 -11
- package/dist/index.js +44 -6
- package/dist/lib/persona-auth.d.ts +1 -0
- package/dist/lib/persona-auth.d.ts.map +1 -1
- package/dist/lib/screenshotter.d.ts +1 -1
- package/dist/lib/screenshotter.d.ts.map +1 -1
- package/dist/mcp/index.js +44 -8
- package/dist/server/index.js +45 -7
- package/package.json +1 -1
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
|
|
16701
|
-
|
|
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
|
-
|
|
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
|
|
17037
|
-
|
|
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.
|
|
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
|
-
|
|
13884
|
-
|
|
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
|
-
|
|
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
|
|
15594
|
-
|
|
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 +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;
|
|
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;
|
|
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.
|
|
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
|
|
19809
|
-
|
|
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
|
-
|
|
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
|
|
20145
|
-
|
|
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();
|
package/dist/server/index.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
48307
|
-
|
|
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
|
-
|
|
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
|
|
48638
|
-
|
|
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();
|