@hasna/testers 0.0.32 → 0.0.34
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/LICENSE +2 -1
- package/README.md +11 -0
- package/dashboard/dist/assets/index-kezQIIQ1.js +49 -0
- package/dashboard/dist/index.html +1 -1
- package/dist/cli/index.js +69872 -2606
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/sessions.d.ts +36 -0
- package/dist/db/sessions.d.ts.map +1 -0
- package/dist/db/workflows.d.ts +10 -0
- package/dist/db/workflows.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1353 -30
- package/dist/lib/ai-client.d.ts +2 -0
- package/dist/lib/ai-client.d.ts.map +1 -1
- package/dist/lib/browser.d.ts.map +1 -1
- package/dist/lib/open-projects.d.ts +14 -0
- package/dist/lib/open-projects.d.ts.map +1 -0
- package/dist/lib/repo-discovery.d.ts +102 -0
- package/dist/lib/repo-discovery.d.ts.map +1 -0
- package/dist/lib/repo-executor.d.ts +56 -0
- package/dist/lib/repo-executor.d.ts.map +1 -0
- package/dist/lib/runner.d.ts.map +1 -1
- package/dist/lib/todos-connector.d.ts +15 -1
- package/dist/lib/todos-connector.d.ts.map +1 -1
- package/dist/lib/workflow-agent.d.ts +32 -0
- package/dist/lib/workflow-agent.d.ts.map +1 -0
- package/dist/lib/workflow-runner.d.ts +27 -0
- package/dist/lib/workflow-runner.d.ts.map +1 -0
- package/dist/mcp/http.d.ts +19 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/index.js +85700 -23461
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/sdk/index.d.ts +8 -1
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/server/index.js +46044 -15024
- package/dist/types/index.d.ts +69 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +8 -4
- package/dashboard/dist/assets/index-D52SWwDa.js +0 -49
package/dist/index.js
CHANGED
|
@@ -9748,6 +9748,7 @@ function resetDatabase() {
|
|
|
9748
9748
|
database.exec("DELETE FROM auth_presets");
|
|
9749
9749
|
database.exec("DELETE FROM environments");
|
|
9750
9750
|
database.exec("DELETE FROM schedules");
|
|
9751
|
+
database.exec("DELETE FROM testing_workflows");
|
|
9751
9752
|
database.exec("DELETE FROM api_check_results");
|
|
9752
9753
|
database.exec("DELETE FROM api_checks");
|
|
9753
9754
|
database.exec("DELETE FROM runs");
|
|
@@ -9756,6 +9757,7 @@ function resetDatabase() {
|
|
|
9756
9757
|
database.exec("DELETE FROM agents");
|
|
9757
9758
|
database.exec("DELETE FROM scan_issues");
|
|
9758
9759
|
database.exec("DELETE FROM projects");
|
|
9760
|
+
database.exec("DELETE FROM sessions");
|
|
9759
9761
|
}
|
|
9760
9762
|
function resolvePartialId(table, partialId) {
|
|
9761
9763
|
const database = getDatabase();
|
|
@@ -10171,6 +10173,46 @@ ALTER TABLE runs ADD COLUMN pr_base_branch TEXT;
|
|
|
10171
10173
|
ALTER TABLE runs ADD COLUMN pr_commit_sha TEXT;
|
|
10172
10174
|
ALTER TABLE runs ADD COLUMN pr_url TEXT;
|
|
10173
10175
|
ALTER TABLE runs ADD COLUMN gh_app_installation_id TEXT;
|
|
10176
|
+
`,
|
|
10177
|
+
`
|
|
10178
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
10179
|
+
id TEXT PRIMARY KEY,
|
|
10180
|
+
tab_id INTEGER NOT NULL,
|
|
10181
|
+
url TEXT,
|
|
10182
|
+
title TEXT,
|
|
10183
|
+
entries TEXT NOT NULL DEFAULT '[]',
|
|
10184
|
+
entry_count INTEGER NOT NULL DEFAULT 0,
|
|
10185
|
+
error_count INTEGER NOT NULL DEFAULT 0,
|
|
10186
|
+
console_count INTEGER NOT NULL DEFAULT 0,
|
|
10187
|
+
nav_count INTEGER NOT NULL DEFAULT 0,
|
|
10188
|
+
status TEXT NOT NULL DEFAULT 'exported' CHECK(status IN ('live','saved','exported')),
|
|
10189
|
+
start_time TEXT NOT NULL,
|
|
10190
|
+
end_time TEXT,
|
|
10191
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
10192
|
+
);
|
|
10193
|
+
|
|
10194
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_tab ON sessions(tab_id);
|
|
10195
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
|
10196
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at DESC);
|
|
10197
|
+
`,
|
|
10198
|
+
`
|
|
10199
|
+
CREATE TABLE IF NOT EXISTS testing_workflows (
|
|
10200
|
+
id TEXT PRIMARY KEY,
|
|
10201
|
+
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
10202
|
+
name TEXT NOT NULL,
|
|
10203
|
+
description TEXT,
|
|
10204
|
+
scenario_filter TEXT NOT NULL DEFAULT '{}',
|
|
10205
|
+
persona_ids TEXT NOT NULL DEFAULT '[]',
|
|
10206
|
+
goal TEXT,
|
|
10207
|
+
execution TEXT NOT NULL DEFAULT '{"target":"local"}',
|
|
10208
|
+
settings TEXT NOT NULL DEFAULT '{}',
|
|
10209
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
10210
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
10211
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
10212
|
+
);
|
|
10213
|
+
|
|
10214
|
+
CREATE INDEX IF NOT EXISTS idx_testing_workflows_project ON testing_workflows(project_id);
|
|
10215
|
+
CREATE INDEX IF NOT EXISTS idx_testing_workflows_enabled ON testing_workflows(enabled);
|
|
10174
10216
|
`
|
|
10175
10217
|
];
|
|
10176
10218
|
});
|
|
@@ -11297,7 +11339,8 @@ async function getPage(browser, options) {
|
|
|
11297
11339
|
}
|
|
11298
11340
|
const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
|
|
11299
11341
|
try {
|
|
11300
|
-
|
|
11342
|
+
const page = await getBrowserPage(browser, { viewport, userAgent: options?.userAgent, locale: options?.locale });
|
|
11343
|
+
return page;
|
|
11301
11344
|
} catch (error) {
|
|
11302
11345
|
const message = error instanceof Error ? error.message : String(error);
|
|
11303
11346
|
throw new BrowserError(`Failed to create page: ${message}`);
|
|
@@ -11640,6 +11683,41 @@ var init_healer = __esm(() => {
|
|
|
11640
11683
|
|
|
11641
11684
|
// src/lib/ai-client.ts
|
|
11642
11685
|
import Anthropic2 from "@anthropic-ai/sdk";
|
|
11686
|
+
import {
|
|
11687
|
+
click as browserClick,
|
|
11688
|
+
fill as browserFill,
|
|
11689
|
+
clickRef,
|
|
11690
|
+
typeRef,
|
|
11691
|
+
fillRef,
|
|
11692
|
+
selectRef,
|
|
11693
|
+
checkRef,
|
|
11694
|
+
hoverRef,
|
|
11695
|
+
getPageInfo,
|
|
11696
|
+
elementExists,
|
|
11697
|
+
getText,
|
|
11698
|
+
getUrl,
|
|
11699
|
+
getTitle,
|
|
11700
|
+
extractTable,
|
|
11701
|
+
getAriaSnapshot,
|
|
11702
|
+
crawl as browserCrawl,
|
|
11703
|
+
addInterceptRule,
|
|
11704
|
+
clearInterceptRules,
|
|
11705
|
+
startHAR,
|
|
11706
|
+
getPerformanceMetrics,
|
|
11707
|
+
startCoverage
|
|
11708
|
+
} from "@hasna/browser";
|
|
11709
|
+
async function takeSnapshot(page, _sessionId) {
|
|
11710
|
+
const tree = await getAriaSnapshot(page);
|
|
11711
|
+
return {
|
|
11712
|
+
tree,
|
|
11713
|
+
snapshot: tree,
|
|
11714
|
+
refs: [],
|
|
11715
|
+
interactive_count: 0
|
|
11716
|
+
};
|
|
11717
|
+
}
|
|
11718
|
+
async function extractStructuredData(page) {
|
|
11719
|
+
return getPageInfo(page);
|
|
11720
|
+
}
|
|
11643
11721
|
function resolveModel2(nameOrPreset) {
|
|
11644
11722
|
if (nameOrPreset in MODEL_MAP) {
|
|
11645
11723
|
return MODEL_MAP[nameOrPreset];
|
|
@@ -11679,7 +11757,18 @@ async function executeTool(page, screenshotter, toolName, toolInput, context) {
|
|
|
11679
11757
|
case "click": {
|
|
11680
11758
|
const selector = toolInput.selector;
|
|
11681
11759
|
try {
|
|
11682
|
-
await page
|
|
11760
|
+
const healResult = await browserClick(page, selector, { selfHeal: true });
|
|
11761
|
+
const screenshot = await screenshotter.capture(page, {
|
|
11762
|
+
runId: context.runId,
|
|
11763
|
+
scenarioSlug: context.scenarioSlug,
|
|
11764
|
+
stepNumber: context.stepNumber,
|
|
11765
|
+
action: "click"
|
|
11766
|
+
});
|
|
11767
|
+
const healNote = healResult.healed ? ` [self-healed via ${healResult.method}]` : "";
|
|
11768
|
+
return {
|
|
11769
|
+
result: `Clicked element: ${selector}${healNote}`,
|
|
11770
|
+
screenshot
|
|
11771
|
+
};
|
|
11683
11772
|
} catch (clickErr) {
|
|
11684
11773
|
const errMsg = clickErr instanceof Error ? clickErr.message : String(clickErr);
|
|
11685
11774
|
if (errMsg.includes("not found") || errMsg.includes("No element") || errMsg.includes("waiting for selector")) {
|
|
@@ -11688,29 +11777,23 @@ async function executeTool(page, screenshotter, toolName, toolInput, context) {
|
|
|
11688
11777
|
const heal = await healSelector2({ page, failedSelector: selector, intent: `click the element matching "${selector}"` });
|
|
11689
11778
|
if (heal.healed && heal.newSelector) {
|
|
11690
11779
|
await page.click(heal.newSelector);
|
|
11691
|
-
const
|
|
11692
|
-
return { result: `Clicked element: ${heal.newSelector} [healed from "${selector}" \u2014 ${heal.reasoning}]`, screenshot
|
|
11780
|
+
const screenshot = await screenshotter.capture(page, { runId: context.runId, scenarioSlug: context.scenarioSlug, stepNumber: context.stepNumber, action: "click" });
|
|
11781
|
+
return { result: `Clicked element: ${heal.newSelector} [AI-healed from "${selector}" \u2014 ${heal.reasoning}]`, screenshot };
|
|
11693
11782
|
}
|
|
11694
11783
|
}
|
|
11695
11784
|
}
|
|
11696
11785
|
throw clickErr;
|
|
11697
11786
|
}
|
|
11698
|
-
const screenshot = await screenshotter.capture(page, {
|
|
11699
|
-
runId: context.runId,
|
|
11700
|
-
scenarioSlug: context.scenarioSlug,
|
|
11701
|
-
stepNumber: context.stepNumber,
|
|
11702
|
-
action: "click"
|
|
11703
|
-
});
|
|
11704
|
-
return {
|
|
11705
|
-
result: `Clicked element: ${selector}`,
|
|
11706
|
-
screenshot
|
|
11707
|
-
};
|
|
11708
11787
|
}
|
|
11709
11788
|
case "fill": {
|
|
11710
11789
|
const selector = toolInput.selector;
|
|
11711
11790
|
const value = toolInput.value;
|
|
11712
11791
|
try {
|
|
11713
|
-
await page
|
|
11792
|
+
const healResult = await browserFill(page, selector, value, undefined, true);
|
|
11793
|
+
const healNote = healResult.healed ? ` [self-healed via ${healResult.method}]` : "";
|
|
11794
|
+
return {
|
|
11795
|
+
result: `Filled "${selector}" with value${healNote}`
|
|
11796
|
+
};
|
|
11714
11797
|
} catch (fillErr) {
|
|
11715
11798
|
const errMsg = fillErr instanceof Error ? fillErr.message : String(fillErr);
|
|
11716
11799
|
if (errMsg.includes("not found") || errMsg.includes("No element") || errMsg.includes("waiting for selector")) {
|
|
@@ -11719,15 +11802,12 @@ async function executeTool(page, screenshotter, toolName, toolInput, context) {
|
|
|
11719
11802
|
const heal = await healSelector2({ page, failedSelector: selector, intent: `fill the input field "${selector}" with "${value}"` });
|
|
11720
11803
|
if (heal.healed && heal.newSelector) {
|
|
11721
11804
|
await page.fill(heal.newSelector, value);
|
|
11722
|
-
return { result: `Filled "${heal.newSelector}" with value [healed from "${selector}"]` };
|
|
11805
|
+
return { result: `Filled "${heal.newSelector}" with value [AI-healed from "${selector}"]` };
|
|
11723
11806
|
}
|
|
11724
11807
|
}
|
|
11725
11808
|
}
|
|
11726
11809
|
throw fillErr;
|
|
11727
11810
|
}
|
|
11728
|
-
return {
|
|
11729
|
-
result: `Filled "${selector}" with value`
|
|
11730
|
-
};
|
|
11731
11811
|
}
|
|
11732
11812
|
case "select_option": {
|
|
11733
11813
|
const selector = toolInput.selector;
|
|
@@ -11915,6 +11995,279 @@ async function executeTool(page, screenshotter, toolName, toolInput, context) {
|
|
|
11915
11995
|
result: `Test ${status}: ${reasoning}`
|
|
11916
11996
|
};
|
|
11917
11997
|
}
|
|
11998
|
+
case "browser_snapshot": {
|
|
11999
|
+
const snapshot = await takeSnapshot(page, context.sessionId);
|
|
12000
|
+
return {
|
|
12001
|
+
result: snapshot.tree
|
|
12002
|
+
};
|
|
12003
|
+
}
|
|
12004
|
+
case "browser_click_ref": {
|
|
12005
|
+
const ref = toolInput.ref;
|
|
12006
|
+
await takeSnapshot(page, context.sessionId);
|
|
12007
|
+
await clickRef(page, context.sessionId, ref);
|
|
12008
|
+
const screenshot = await screenshotter.capture(page, {
|
|
12009
|
+
runId: context.runId,
|
|
12010
|
+
scenarioSlug: context.scenarioSlug,
|
|
12011
|
+
stepNumber: context.stepNumber,
|
|
12012
|
+
action: "click_ref"
|
|
12013
|
+
});
|
|
12014
|
+
return {
|
|
12015
|
+
result: `Clicked ref: ${ref}`,
|
|
12016
|
+
screenshot
|
|
12017
|
+
};
|
|
12018
|
+
}
|
|
12019
|
+
case "browser_type_ref": {
|
|
12020
|
+
const ref = toolInput.ref;
|
|
12021
|
+
const text = toolInput.text;
|
|
12022
|
+
const clear = toolInput.clear;
|
|
12023
|
+
await takeSnapshot(page, context.sessionId);
|
|
12024
|
+
await typeRef(page, context.sessionId, ref, text, { clear: clear ?? true });
|
|
12025
|
+
return {
|
|
12026
|
+
result: `Typed into ref ${ref}: "${text}"`
|
|
12027
|
+
};
|
|
12028
|
+
}
|
|
12029
|
+
case "browser_fill_ref": {
|
|
12030
|
+
const ref = toolInput.ref;
|
|
12031
|
+
const value = toolInput.value;
|
|
12032
|
+
await takeSnapshot(page, context.sessionId);
|
|
12033
|
+
await fillRef(page, context.sessionId, ref, value);
|
|
12034
|
+
return {
|
|
12035
|
+
result: `Filled ref ${ref} with value`
|
|
12036
|
+
};
|
|
12037
|
+
}
|
|
12038
|
+
case "browser_select_ref": {
|
|
12039
|
+
const ref = toolInput.ref;
|
|
12040
|
+
const value = toolInput.value;
|
|
12041
|
+
await takeSnapshot(page, context.sessionId);
|
|
12042
|
+
const selected = await selectRef(page, context.sessionId, ref, value);
|
|
12043
|
+
return {
|
|
12044
|
+
result: `Selected "${selected.join(", ")}" in ref ${ref}`
|
|
12045
|
+
};
|
|
12046
|
+
}
|
|
12047
|
+
case "browser_check_ref": {
|
|
12048
|
+
const ref = toolInput.ref;
|
|
12049
|
+
const checked = toolInput.checked;
|
|
12050
|
+
await takeSnapshot(page, context.sessionId);
|
|
12051
|
+
await checkRef(page, context.sessionId, ref, checked);
|
|
12052
|
+
return {
|
|
12053
|
+
result: `${checked ? "Checked" : "Unchecked"} ref: ${ref}`
|
|
12054
|
+
};
|
|
12055
|
+
}
|
|
12056
|
+
case "browser_hover_ref": {
|
|
12057
|
+
const ref = toolInput.ref;
|
|
12058
|
+
await takeSnapshot(page, context.sessionId);
|
|
12059
|
+
await hoverRef(page, context.sessionId, ref);
|
|
12060
|
+
const screenshot = await screenshotter.capture(page, {
|
|
12061
|
+
runId: context.runId,
|
|
12062
|
+
scenarioSlug: context.scenarioSlug,
|
|
12063
|
+
stepNumber: context.stepNumber,
|
|
12064
|
+
action: "hover_ref"
|
|
12065
|
+
});
|
|
12066
|
+
return {
|
|
12067
|
+
result: `Hovered ref: ${ref}`,
|
|
12068
|
+
screenshot
|
|
12069
|
+
};
|
|
12070
|
+
}
|
|
12071
|
+
case "browser_check": {
|
|
12072
|
+
const [info, snapshot] = await Promise.all([
|
|
12073
|
+
getPageInfo(page),
|
|
12074
|
+
takeSnapshot(page, context.sessionId)
|
|
12075
|
+
]);
|
|
12076
|
+
const parts = [
|
|
12077
|
+
`URL: ${info.url}`,
|
|
12078
|
+
`Title: ${info.title}`,
|
|
12079
|
+
info.meta_description ? `Description: ${info.meta_description}` : null,
|
|
12080
|
+
`Links: ${info.links_count} | Images: ${info.images_count} | Forms: ${info.forms_count}`,
|
|
12081
|
+
`Text length: ${info.text_length} | Console errors: ${info.has_console_errors}`,
|
|
12082
|
+
`Viewport: ${info.viewport.width}x${info.viewport.height}`,
|
|
12083
|
+
``,
|
|
12084
|
+
`Interactive elements: ${snapshot.interactive_count}`,
|
|
12085
|
+
``,
|
|
12086
|
+
snapshot.tree
|
|
12087
|
+
].filter(Boolean);
|
|
12088
|
+
return { result: parts.join(`
|
|
12089
|
+
`) };
|
|
12090
|
+
}
|
|
12091
|
+
case "browser_assert": {
|
|
12092
|
+
const assertionType = toolInput.assertion_type;
|
|
12093
|
+
const selector = toolInput.selector;
|
|
12094
|
+
const expected = toolInput.expected;
|
|
12095
|
+
const sessionId = context.sessionId ?? "default";
|
|
12096
|
+
switch (assertionType) {
|
|
12097
|
+
case "element_exists": {
|
|
12098
|
+
if (!selector)
|
|
12099
|
+
return { result: "Error: selector required for element_exists assertion" };
|
|
12100
|
+
const result = await elementExists(page, selector);
|
|
12101
|
+
const pass = result.exists;
|
|
12102
|
+
return { result: pass ? `PASS: element "${selector}" exists (${result.count} match${result.count !== 1 ? "es" : ""}, visible: ${result.visible})` : `FAIL: element "${selector}" not found` };
|
|
12103
|
+
}
|
|
12104
|
+
case "text_contains": {
|
|
12105
|
+
const text = selector ? await getText(page, selector) : await getText(page);
|
|
12106
|
+
const pass = expected !== undefined && text.includes(expected);
|
|
12107
|
+
return { result: pass ? `PASS: text contains "${expected}"` : `FAIL: text does not contain "${expected}". Found: "${text.slice(0, 200)}"` };
|
|
12108
|
+
}
|
|
12109
|
+
case "url_matches": {
|
|
12110
|
+
const url = await getUrl(page);
|
|
12111
|
+
const pattern = expected ?? "";
|
|
12112
|
+
const pass = new RegExp(pattern).test(url);
|
|
12113
|
+
return { result: pass ? `PASS: URL matches /${pattern}/` : `FAIL: URL "${url}" does not match /${pattern}/` };
|
|
12114
|
+
}
|
|
12115
|
+
case "title_contains": {
|
|
12116
|
+
const title = await getTitle(page);
|
|
12117
|
+
const pass = expected !== undefined && title.includes(expected);
|
|
12118
|
+
return { result: pass ? `PASS: title contains "${expected}"` : `FAIL: title "${title}" does not contain "${expected}"` };
|
|
12119
|
+
}
|
|
12120
|
+
default:
|
|
12121
|
+
return { result: `Unknown assertion type: ${assertionType}` };
|
|
12122
|
+
}
|
|
12123
|
+
}
|
|
12124
|
+
case "browser_extract": {
|
|
12125
|
+
const mode = toolInput.mode;
|
|
12126
|
+
const selector = toolInput.selector;
|
|
12127
|
+
switch (mode) {
|
|
12128
|
+
case "structured": {
|
|
12129
|
+
const data = await extractStructuredData(page);
|
|
12130
|
+
return { result: JSON.stringify(data, null, 2) };
|
|
12131
|
+
}
|
|
12132
|
+
case "table": {
|
|
12133
|
+
if (!selector)
|
|
12134
|
+
return { result: "Error: selector required for table extraction" };
|
|
12135
|
+
const rows = await extractTable(page, selector);
|
|
12136
|
+
return { result: JSON.stringify(rows, null, 2) };
|
|
12137
|
+
}
|
|
12138
|
+
case "text": {
|
|
12139
|
+
const text = selector ? await getText(page, selector) : await getText(page);
|
|
12140
|
+
return { result: text };
|
|
12141
|
+
}
|
|
12142
|
+
case "aria": {
|
|
12143
|
+
const aria = await getAriaSnapshot(page);
|
|
12144
|
+
return { result: aria };
|
|
12145
|
+
}
|
|
12146
|
+
default:
|
|
12147
|
+
return { result: `Unknown extract mode: ${mode}` };
|
|
12148
|
+
}
|
|
12149
|
+
}
|
|
12150
|
+
case "browser_crawl": {
|
|
12151
|
+
const url = toolInput.url;
|
|
12152
|
+
const maxDepth = toolInput.max_depth ?? 2;
|
|
12153
|
+
const maxPages = toolInput.max_pages ?? 20;
|
|
12154
|
+
const result = await browserCrawl(url, { maxDepth, maxPages });
|
|
12155
|
+
return { result: JSON.stringify(result, null, 2) };
|
|
12156
|
+
}
|
|
12157
|
+
case "browser_intercept": {
|
|
12158
|
+
const action = toolInput.action;
|
|
12159
|
+
const pattern = toolInput.pattern;
|
|
12160
|
+
const interceptAction = toolInput.intercept_action;
|
|
12161
|
+
const statusCode = toolInput.status_code;
|
|
12162
|
+
const body = toolInput.body;
|
|
12163
|
+
const sessionId = context.sessionId ?? "default";
|
|
12164
|
+
switch (action) {
|
|
12165
|
+
case "block": {
|
|
12166
|
+
if (!pattern)
|
|
12167
|
+
return { result: "Error: pattern required for block action" };
|
|
12168
|
+
await addInterceptRule(page, { pattern, action: "block" });
|
|
12169
|
+
return { result: `Blocked requests matching: ${pattern}` };
|
|
12170
|
+
}
|
|
12171
|
+
case "modify": {
|
|
12172
|
+
if (!pattern)
|
|
12173
|
+
return { result: "Error: pattern required for modify action" };
|
|
12174
|
+
await addInterceptRule(page, { pattern, action: "modify", response: { status: statusCode ?? 200, body: body ?? "" } });
|
|
12175
|
+
return { result: `Modified requests matching: ${pattern} \u2192 status ${statusCode ?? 200}` };
|
|
12176
|
+
}
|
|
12177
|
+
case "log": {
|
|
12178
|
+
if (!pattern)
|
|
12179
|
+
return { result: "Error: pattern required for log action" };
|
|
12180
|
+
await addInterceptRule(page, { pattern, action: "log" });
|
|
12181
|
+
return { result: `Logging requests matching: ${pattern}` };
|
|
12182
|
+
}
|
|
12183
|
+
case "clear": {
|
|
12184
|
+
await clearInterceptRules(page);
|
|
12185
|
+
return { result: "Cleared all intercept rules" };
|
|
12186
|
+
}
|
|
12187
|
+
case "har_start": {
|
|
12188
|
+
const harCapture = startHAR(page);
|
|
12189
|
+
activeHARs.set(sessionId, harCapture);
|
|
12190
|
+
return { result: "HAR capture started" };
|
|
12191
|
+
}
|
|
12192
|
+
case "har_stop": {
|
|
12193
|
+
const harCapture = activeHARs.get(sessionId);
|
|
12194
|
+
if (!harCapture)
|
|
12195
|
+
return { result: "Error: no active HAR capture for this session" };
|
|
12196
|
+
const har = harCapture.stop();
|
|
12197
|
+
activeHARs.delete(sessionId);
|
|
12198
|
+
const entryCount = har.log.entries.length;
|
|
12199
|
+
return { result: `HAR capture stopped: ${entryCount} entries captured
|
|
12200
|
+
${JSON.stringify(har, null, 2)}` };
|
|
12201
|
+
}
|
|
12202
|
+
default:
|
|
12203
|
+
return { result: `Unknown intercept action: ${action}` };
|
|
12204
|
+
}
|
|
12205
|
+
}
|
|
12206
|
+
case "browser_performance": {
|
|
12207
|
+
const mode = toolInput.mode;
|
|
12208
|
+
const sessionId = context.sessionId ?? "default";
|
|
12209
|
+
switch (mode) {
|
|
12210
|
+
case "metrics": {
|
|
12211
|
+
const metrics = await getPerformanceMetrics(page);
|
|
12212
|
+
return { result: JSON.stringify(metrics, null, 2) };
|
|
12213
|
+
}
|
|
12214
|
+
case "deep": {
|
|
12215
|
+
const deep = await getPerformanceMetrics(page);
|
|
12216
|
+
return { result: JSON.stringify(deep, null, 2) };
|
|
12217
|
+
}
|
|
12218
|
+
case "coverage_start": {
|
|
12219
|
+
const session = await startCoverage(page);
|
|
12220
|
+
activeCoverage.set(sessionId, session);
|
|
12221
|
+
return { result: "Coverage tracking started" };
|
|
12222
|
+
}
|
|
12223
|
+
case "coverage_stop": {
|
|
12224
|
+
const session = activeCoverage.get(sessionId);
|
|
12225
|
+
if (!session)
|
|
12226
|
+
return { result: "Error: no active coverage session" };
|
|
12227
|
+
const result = await session.stop();
|
|
12228
|
+
activeCoverage.delete(sessionId);
|
|
12229
|
+
return { result: JSON.stringify(result, null, 2) };
|
|
12230
|
+
}
|
|
12231
|
+
default:
|
|
12232
|
+
return { result: `Unknown performance mode: ${mode}` };
|
|
12233
|
+
}
|
|
12234
|
+
}
|
|
12235
|
+
case "browser_a11y": {
|
|
12236
|
+
const level = toolInput.level ?? "AA";
|
|
12237
|
+
const snapshot = await page.accessibility.snapshot();
|
|
12238
|
+
if (!snapshot)
|
|
12239
|
+
return { result: "Error: could not capture accessibility tree" };
|
|
12240
|
+
const issues = [];
|
|
12241
|
+
const checkNode = (node, path = []) => {
|
|
12242
|
+
const label = node.name ?? "";
|
|
12243
|
+
const role = node.role ?? "";
|
|
12244
|
+
const nodePath = [...path, `${role}${label ? ` "${label}"` : ""}`];
|
|
12245
|
+
if (role === "img" && !label) {
|
|
12246
|
+
issues.push(`[A] Image missing alt text at ${nodePath.join(" > ")}`);
|
|
12247
|
+
}
|
|
12248
|
+
if (["button", "link", "textbox", "checkbox", "radio", "combobox", "menuitem"].includes(role) && !label) {
|
|
12249
|
+
issues.push(`[A] ${role} missing accessible name at ${nodePath.join(" > ")}`);
|
|
12250
|
+
}
|
|
12251
|
+
if (["textbox", "combobox", "slider", "spinbutton"].includes(role) && !label) {
|
|
12252
|
+
issues.push(`[AA] Form field (${role}) missing label at ${nodePath.join(" > ")}`);
|
|
12253
|
+
}
|
|
12254
|
+
if (role.startsWith("heading") && !label) {
|
|
12255
|
+
issues.push(`[AAA] Empty heading at ${nodePath.join(" > ")}`);
|
|
12256
|
+
}
|
|
12257
|
+
for (const child of node.children ?? []) {
|
|
12258
|
+
checkNode(child, nodePath);
|
|
12259
|
+
}
|
|
12260
|
+
};
|
|
12261
|
+
checkNode(snapshot);
|
|
12262
|
+
const maxLevel = level === "A" ? 0 : level === "AA" ? 1 : 2;
|
|
12263
|
+
const levelMap = { 0: ["A"], 1: ["A", "AA"], 2: ["A", "AA", "AAA"] };
|
|
12264
|
+
const applicable = levelMap[maxLevel] ?? ["A", "AA"];
|
|
12265
|
+
const filtered = issues.filter((i) => applicable.some((l) => i.includes(`[${l}]`)));
|
|
12266
|
+
const summary = filtered.length === 0 ? `No a11y issues found at WCAG ${level} level` : `${filtered.length} a11y issue${filtered.length > 1 ? "s" : ""} found at WCAG ${level} level:
|
|
12267
|
+
${filtered.join(`
|
|
12268
|
+
`)}`;
|
|
12269
|
+
return { result: summary };
|
|
12270
|
+
}
|
|
11918
12271
|
default:
|
|
11919
12272
|
return { result: `Unknown tool: ${toolName}` };
|
|
11920
12273
|
}
|
|
@@ -11931,6 +12284,7 @@ async function runAgentLoop(options) {
|
|
|
11931
12284
|
screenshotter,
|
|
11932
12285
|
model,
|
|
11933
12286
|
runId,
|
|
12287
|
+
sessionId,
|
|
11934
12288
|
maxTurns = 30,
|
|
11935
12289
|
onStep,
|
|
11936
12290
|
persona,
|
|
@@ -11955,17 +12309,23 @@ Instructions: ${persona.instructions}` : "",
|
|
|
11955
12309
|
"You are an expert QA testing agent. Your job is to thoroughly test web application scenarios.",
|
|
11956
12310
|
"You have browser tools to navigate, interact with, and inspect web pages.",
|
|
11957
12311
|
"",
|
|
11958
|
-
"Strategy:",
|
|
11959
|
-
"1.
|
|
11960
|
-
"2.
|
|
11961
|
-
"3.
|
|
12312
|
+
"Strategy (snapshot \u2192 ref \u2192 act):",
|
|
12313
|
+
"1. Navigate to the target page, then call browser_snapshot to get an accessibility tree with element refs (@e0, @e1, ...)",
|
|
12314
|
+
"2. Use ref-based tools (browser_click_ref, browser_type_ref, browser_fill_ref, etc.) to interact with elements by their ref IDs \u2014 this is more reliable than CSS selectors",
|
|
12315
|
+
"3. After actions that change page state, call browser_snapshot again to see the updated tree",
|
|
11962
12316
|
"4. Use wait_for or wait_for_navigation after actions that trigger page loads",
|
|
11963
12317
|
"5. Take screenshots after every meaningful state change",
|
|
11964
12318
|
"6. Use assert_text and assert_visible to verify expected outcomes",
|
|
11965
12319
|
"7. When done testing, call report_result with detailed pass/fail reasoning",
|
|
11966
12320
|
"",
|
|
12321
|
+
"When to use CSS-selector tools vs ref-based tools:",
|
|
12322
|
+
"- Prefer ref-based tools (browser_click_ref, etc.) \u2014 they resolve via the accessibility snapshot and self-heal on DOM changes",
|
|
12323
|
+
"- Use CSS-selector tools (click, fill) only when you need to target elements by a known stable selector",
|
|
12324
|
+
"- Both click and fill have built-in self-healing: if the selector breaks, they try alternative strategies automatically",
|
|
12325
|
+
"- If built-in healing fails, the AI healer kicks in as a deeper fallback",
|
|
12326
|
+
"",
|
|
11967
12327
|
"Tips:",
|
|
11968
|
-
"-
|
|
12328
|
+
"- Call browser_snapshot before interacting \u2014 it gives you the current refs and interactive element count",
|
|
11969
12329
|
"- If a click triggers navigation, use wait_for_navigation after",
|
|
11970
12330
|
"- For forms, fill all fields before submitting",
|
|
11971
12331
|
"- Check for error messages after form submissions",
|
|
@@ -12049,7 +12409,7 @@ Instructions: ${persona.instructions}` : "",
|
|
|
12049
12409
|
if (onStep) {
|
|
12050
12410
|
onStep({ type: "tool_call", toolName: toolBlock.name, toolInput, stepNumber });
|
|
12051
12411
|
}
|
|
12052
|
-
const execResult = await executeTool(page, screenshotter, toolBlock.name, toolInput, { runId, scenarioSlug, stepNumber, a11y });
|
|
12412
|
+
const execResult = await executeTool(page, screenshotter, toolBlock.name, toolInput, { runId, scenarioSlug, stepNumber, sessionId, a11y });
|
|
12053
12413
|
if (onStep) {
|
|
12054
12414
|
onStep({ type: "tool_result", toolName: toolBlock.name, toolResult: execResult.result, stepNumber });
|
|
12055
12415
|
}
|
|
@@ -12203,9 +12563,11 @@ function createClientForModel(model, apiKey) {
|
|
|
12203
12563
|
}
|
|
12204
12564
|
return createClient(apiKey);
|
|
12205
12565
|
}
|
|
12206
|
-
var BROWSER_TOOLS;
|
|
12566
|
+
var activeHARs, activeCoverage, BROWSER_TOOLS;
|
|
12207
12567
|
var init_ai_client = __esm(() => {
|
|
12208
12568
|
init_types();
|
|
12569
|
+
activeHARs = new Map;
|
|
12570
|
+
activeCoverage = new Map;
|
|
12209
12571
|
BROWSER_TOOLS = [
|
|
12210
12572
|
{
|
|
12211
12573
|
name: "navigate",
|
|
@@ -12508,6 +12870,218 @@ var init_ai_client = __esm(() => {
|
|
|
12508
12870
|
},
|
|
12509
12871
|
required: ["status", "reasoning"]
|
|
12510
12872
|
}
|
|
12873
|
+
},
|
|
12874
|
+
{
|
|
12875
|
+
name: "browser_snapshot",
|
|
12876
|
+
description: "Take an ARIA accessibility snapshot of the current page. Returns a tree of interactive elements with refs (e.g. @e0, @e1) that can be used with browser_*_ref tools. Use this before interacting with elements to discover their refs.",
|
|
12877
|
+
input_schema: {
|
|
12878
|
+
type: "object",
|
|
12879
|
+
properties: {}
|
|
12880
|
+
}
|
|
12881
|
+
},
|
|
12882
|
+
{
|
|
12883
|
+
name: "browser_click_ref",
|
|
12884
|
+
description: "Click an element by its snapshot ref (e.g. @e0). More reliable than CSS selectors because it uses ARIA role-based resolution.",
|
|
12885
|
+
input_schema: {
|
|
12886
|
+
type: "object",
|
|
12887
|
+
properties: {
|
|
12888
|
+
ref: { type: "string", description: "Element ref from snapshot (e.g. @e0)." }
|
|
12889
|
+
},
|
|
12890
|
+
required: ["ref"]
|
|
12891
|
+
}
|
|
12892
|
+
},
|
|
12893
|
+
{
|
|
12894
|
+
name: "browser_type_ref",
|
|
12895
|
+
description: "Type text into an element by its snapshot ref. Optionally clears existing text first.",
|
|
12896
|
+
input_schema: {
|
|
12897
|
+
type: "object",
|
|
12898
|
+
properties: {
|
|
12899
|
+
ref: { type: "string", description: "Element ref from snapshot (e.g. @e0)." },
|
|
12900
|
+
text: { type: "string", description: "Text to type." },
|
|
12901
|
+
clear: { type: "boolean", description: "Clear existing text before typing (default: false)." }
|
|
12902
|
+
},
|
|
12903
|
+
required: ["ref", "text"]
|
|
12904
|
+
}
|
|
12905
|
+
},
|
|
12906
|
+
{
|
|
12907
|
+
name: "browser_fill_ref",
|
|
12908
|
+
description: "Fill an input element by its snapshot ref. Replaces existing content.",
|
|
12909
|
+
input_schema: {
|
|
12910
|
+
type: "object",
|
|
12911
|
+
properties: {
|
|
12912
|
+
ref: { type: "string", description: "Element ref from snapshot (e.g. @e0)." },
|
|
12913
|
+
value: { type: "string", description: "Value to fill." }
|
|
12914
|
+
},
|
|
12915
|
+
required: ["ref", "value"]
|
|
12916
|
+
}
|
|
12917
|
+
},
|
|
12918
|
+
{
|
|
12919
|
+
name: "browser_select_ref",
|
|
12920
|
+
description: "Select an option in a dropdown element by its snapshot ref.",
|
|
12921
|
+
input_schema: {
|
|
12922
|
+
type: "object",
|
|
12923
|
+
properties: {
|
|
12924
|
+
ref: { type: "string", description: "Element ref from snapshot (e.g. @e0)." },
|
|
12925
|
+
value: { type: "string", description: "Option value to select." }
|
|
12926
|
+
},
|
|
12927
|
+
required: ["ref", "value"]
|
|
12928
|
+
}
|
|
12929
|
+
},
|
|
12930
|
+
{
|
|
12931
|
+
name: "browser_check_ref",
|
|
12932
|
+
description: "Check or uncheck a checkbox/radio element by its snapshot ref.",
|
|
12933
|
+
input_schema: {
|
|
12934
|
+
type: "object",
|
|
12935
|
+
properties: {
|
|
12936
|
+
ref: { type: "string", description: "Element ref from snapshot (e.g. @e0)." },
|
|
12937
|
+
checked: { type: "boolean", description: "True to check, false to uncheck." }
|
|
12938
|
+
},
|
|
12939
|
+
required: ["ref", "checked"]
|
|
12940
|
+
}
|
|
12941
|
+
},
|
|
12942
|
+
{
|
|
12943
|
+
name: "browser_hover_ref",
|
|
12944
|
+
description: "Hover over an element by its snapshot ref.",
|
|
12945
|
+
input_schema: {
|
|
12946
|
+
type: "object",
|
|
12947
|
+
properties: {
|
|
12948
|
+
ref: { type: "string", description: "Element ref from snapshot (e.g. @e0)." }
|
|
12949
|
+
},
|
|
12950
|
+
required: ["ref"]
|
|
12951
|
+
}
|
|
12952
|
+
},
|
|
12953
|
+
{
|
|
12954
|
+
name: "browser_check",
|
|
12955
|
+
description: "Get a comprehensive page orientation: page info (URL, title, meta, link/image/form counts, console errors, viewport) plus an ARIA accessibility snapshot with interactive element refs. Use this to understand the current page state before testing.",
|
|
12956
|
+
input_schema: {
|
|
12957
|
+
type: "object",
|
|
12958
|
+
properties: {}
|
|
12959
|
+
}
|
|
12960
|
+
},
|
|
12961
|
+
{
|
|
12962
|
+
name: "browser_assert",
|
|
12963
|
+
description: "Run an assertion against the current page state. Supports: element_exists (check selector exists/visible), text_contains (check page text includes substring), url_matches (check URL matches pattern), title_contains (check page title includes substring). Returns pass/fail with details.",
|
|
12964
|
+
input_schema: {
|
|
12965
|
+
type: "object",
|
|
12966
|
+
properties: {
|
|
12967
|
+
assertion: {
|
|
12968
|
+
type: "string",
|
|
12969
|
+
enum: ["element_exists", "text_contains", "url_matches", "title_contains"],
|
|
12970
|
+
description: "The type of assertion to run."
|
|
12971
|
+
},
|
|
12972
|
+
selector: {
|
|
12973
|
+
type: "string",
|
|
12974
|
+
description: "CSS selector (required for element_exists)."
|
|
12975
|
+
},
|
|
12976
|
+
expected: {
|
|
12977
|
+
type: "string",
|
|
12978
|
+
description: "Expected value (text substring, URL pattern, or title substring)."
|
|
12979
|
+
},
|
|
12980
|
+
visible: {
|
|
12981
|
+
type: "boolean",
|
|
12982
|
+
description: "For element_exists: also require the element to be visible (default true)."
|
|
12983
|
+
}
|
|
12984
|
+
},
|
|
12985
|
+
required: ["assertion"]
|
|
12986
|
+
}
|
|
12987
|
+
},
|
|
12988
|
+
{
|
|
12989
|
+
name: "browser_extract",
|
|
12990
|
+
description: "Extract structured data from the current page. Modes: 'structured' (tables, lists, JSON-LD, OpenGraph, meta), 'table' (a specific HTML table), 'text' (page or element text), 'aria' (accessibility snapshot). Returns extracted data as JSON.",
|
|
12991
|
+
input_schema: {
|
|
12992
|
+
type: "object",
|
|
12993
|
+
properties: {
|
|
12994
|
+
mode: {
|
|
12995
|
+
type: "string",
|
|
12996
|
+
enum: ["structured", "table", "text", "aria"],
|
|
12997
|
+
description: "Extraction mode: 'structured' for auto-detected tables/lists/metadata, 'table' for a specific table selector, 'text' for page/element text, 'aria' for accessibility tree."
|
|
12998
|
+
},
|
|
12999
|
+
selector: {
|
|
13000
|
+
type: "string",
|
|
13001
|
+
description: "CSS selector for targeted extraction (required for 'table' mode, optional for 'text')."
|
|
13002
|
+
}
|
|
13003
|
+
},
|
|
13004
|
+
required: ["mode"]
|
|
13005
|
+
}
|
|
13006
|
+
},
|
|
13007
|
+
{
|
|
13008
|
+
name: "browser_crawl",
|
|
13009
|
+
description: "Crawl a website starting from a URL. Discovers linked pages within the same domain, up to a configurable depth. Returns a list of found URLs with their status codes and titles.",
|
|
13010
|
+
input_schema: {
|
|
13011
|
+
type: "object",
|
|
13012
|
+
properties: {
|
|
13013
|
+
url: {
|
|
13014
|
+
type: "string",
|
|
13015
|
+
description: "Starting URL to crawl from."
|
|
13016
|
+
},
|
|
13017
|
+
maxDepth: {
|
|
13018
|
+
type: "number",
|
|
13019
|
+
description: "Maximum crawl depth (default 2)."
|
|
13020
|
+
},
|
|
13021
|
+
maxPages: {
|
|
13022
|
+
type: "number",
|
|
13023
|
+
description: "Maximum number of pages to visit (default 20)."
|
|
13024
|
+
}
|
|
13025
|
+
},
|
|
13026
|
+
required: ["url"]
|
|
13027
|
+
}
|
|
13028
|
+
},
|
|
13029
|
+
{
|
|
13030
|
+
name: "browser_intercept",
|
|
13031
|
+
description: "Intercept network requests on the current page. Actions: 'block' (block matching requests), 'modify' (rewrite response), 'log' (log matching requests), 'clear' (remove all rules), 'har_start' (start HAR recording), 'har_stop' (stop and return HAR).",
|
|
13032
|
+
input_schema: {
|
|
13033
|
+
type: "object",
|
|
13034
|
+
properties: {
|
|
13035
|
+
action: {
|
|
13036
|
+
type: "string",
|
|
13037
|
+
enum: ["block", "modify", "log", "clear", "har_start", "har_stop"],
|
|
13038
|
+
description: "Intercept action to perform."
|
|
13039
|
+
},
|
|
13040
|
+
pattern: {
|
|
13041
|
+
type: "string",
|
|
13042
|
+
description: "URL pattern to match (required for block/modify/log)."
|
|
13043
|
+
},
|
|
13044
|
+
response: {
|
|
13045
|
+
type: "object",
|
|
13046
|
+
description: "Custom response for 'modify' action.",
|
|
13047
|
+
properties: {
|
|
13048
|
+
status: { type: "number" },
|
|
13049
|
+
body: { type: "string" },
|
|
13050
|
+
headers: { type: "object" }
|
|
13051
|
+
}
|
|
13052
|
+
}
|
|
13053
|
+
},
|
|
13054
|
+
required: ["action"]
|
|
13055
|
+
}
|
|
13056
|
+
},
|
|
13057
|
+
{
|
|
13058
|
+
name: "browser_performance",
|
|
13059
|
+
description: "Measure page performance. Modes: 'metrics' (Web Vitals: FCP, LCP, CLS, TTFB), 'deep' (full resource breakdown, third-party analysis, DOM complexity, main thread blocking, memory), 'coverage_start' (start JS/CSS coverage), 'coverage_stop' (stop and report unused code).",
|
|
13060
|
+
input_schema: {
|
|
13061
|
+
type: "object",
|
|
13062
|
+
properties: {
|
|
13063
|
+
mode: {
|
|
13064
|
+
type: "string",
|
|
13065
|
+
enum: ["metrics", "deep", "coverage_start", "coverage_stop"],
|
|
13066
|
+
description: "Performance measurement mode."
|
|
13067
|
+
}
|
|
13068
|
+
},
|
|
13069
|
+
required: ["mode"]
|
|
13070
|
+
}
|
|
13071
|
+
},
|
|
13072
|
+
{
|
|
13073
|
+
name: "browser_a11y",
|
|
13074
|
+
description: "Run an accessibility audit on the current page using the ARIA accessibility tree. Detects missing labels, roles, focus issues, and other common a11y problems. Returns a list of issues with severity and element details.",
|
|
13075
|
+
input_schema: {
|
|
13076
|
+
type: "object",
|
|
13077
|
+
properties: {
|
|
13078
|
+
level: {
|
|
13079
|
+
type: "string",
|
|
13080
|
+
enum: ["A", "AA", "AAA"],
|
|
13081
|
+
description: "WCAG conformance level to check against (default AA)."
|
|
13082
|
+
}
|
|
13083
|
+
}
|
|
13084
|
+
}
|
|
12511
13085
|
}
|
|
12512
13086
|
];
|
|
12513
13087
|
});
|
|
@@ -15085,17 +15659,17 @@ function resolveTodosDbPath() {
|
|
|
15085
15659
|
return envPath;
|
|
15086
15660
|
return join12(homedir10(), ".todos", "todos.db");
|
|
15087
15661
|
}
|
|
15088
|
-
function connectToTodos() {
|
|
15662
|
+
function connectToTodos(options = {}) {
|
|
15089
15663
|
const dbPath = resolveTodosDbPath();
|
|
15090
15664
|
if (!existsSync10(dbPath)) {
|
|
15091
15665
|
throw new TodosConnectionError(`Todos database not found at ${dbPath}. Install @hasna/todos or set TODOS_DB_PATH.`);
|
|
15092
15666
|
}
|
|
15093
|
-
const db2 = new Database3(dbPath, { readonly: true });
|
|
15667
|
+
const db2 = new Database3(dbPath, { readonly: options.readonly ?? true });
|
|
15094
15668
|
db2.exec("PRAGMA foreign_keys = ON");
|
|
15095
15669
|
return db2;
|
|
15096
15670
|
}
|
|
15097
15671
|
function pullTasks(options = {}) {
|
|
15098
|
-
const db2 = connectToTodos();
|
|
15672
|
+
const db2 = connectToTodos({ readonly: true });
|
|
15099
15673
|
try {
|
|
15100
15674
|
let query = "SELECT id, short_id, title, description, status, priority, tags, project_id FROM tasks WHERE 1=1";
|
|
15101
15675
|
const params = [];
|
|
@@ -15199,7 +15773,7 @@ async function createFailureTasks(run, failedResults, scenarios) {
|
|
|
15199
15773
|
return { created: 0, skipped: 0 };
|
|
15200
15774
|
let db2 = null;
|
|
15201
15775
|
try {
|
|
15202
|
-
db2 = connectToTodos();
|
|
15776
|
+
db2 = connectToTodos({ readonly: false });
|
|
15203
15777
|
} catch {
|
|
15204
15778
|
return { created: 0, skipped: 0 };
|
|
15205
15779
|
}
|
|
@@ -15498,6 +16072,7 @@ async function runSingleScenario(scenario, runId, options) {
|
|
|
15498
16072
|
screenshotter,
|
|
15499
16073
|
model,
|
|
15500
16074
|
runId,
|
|
16075
|
+
sessionId: result.id,
|
|
15501
16076
|
maxTurns: effectiveOptions.minimal ? 10 : 30,
|
|
15502
16077
|
a11y: effectiveOptions.a11y,
|
|
15503
16078
|
persona: persona ? {
|
|
@@ -17370,6 +17945,681 @@ async function startWatcher(options) {
|
|
|
17370
17945
|
process.on("SIGTERM", cleanup);
|
|
17371
17946
|
await new Promise(() => {});
|
|
17372
17947
|
}
|
|
17948
|
+
// src/lib/repo-discovery.ts
|
|
17949
|
+
init_paths();
|
|
17950
|
+
import { existsSync as existsSync13, readFileSync as readFileSync5, readdirSync as readdirSync3, statSync, writeFileSync as writeFileSync4, mkdirSync as mkdirSync10, unlinkSync } from "fs";
|
|
17951
|
+
import { createHash } from "crypto";
|
|
17952
|
+
import { join as join15, resolve as resolve2, relative as relative2 } from "path";
|
|
17953
|
+
function getCacheDir() {
|
|
17954
|
+
const testersDir = getTestersDir();
|
|
17955
|
+
const cacheDir = join15(testersDir, "repo-index");
|
|
17956
|
+
if (!existsSync13(cacheDir)) {
|
|
17957
|
+
mkdirSync10(cacheDir, { recursive: true });
|
|
17958
|
+
}
|
|
17959
|
+
return cacheDir;
|
|
17960
|
+
}
|
|
17961
|
+
function pathHash(repoPath) {
|
|
17962
|
+
return createHash("sha256").update(repoPath).digest("hex").slice(0, 16);
|
|
17963
|
+
}
|
|
17964
|
+
function getCachePath(repoPath) {
|
|
17965
|
+
return join15(getCacheDir(), `${pathHash(repoPath)}.json`);
|
|
17966
|
+
}
|
|
17967
|
+
function isCacheStale(cached, repoPath) {
|
|
17968
|
+
for (const spec of cached.specs) {
|
|
17969
|
+
const fullPath = join15(repoPath, spec.file);
|
|
17970
|
+
if (!existsSync13(fullPath))
|
|
17971
|
+
return true;
|
|
17972
|
+
try {
|
|
17973
|
+
const stat = statSync(fullPath);
|
|
17974
|
+
if (stat.mtimeMs !== spec.mtimeMs)
|
|
17975
|
+
return true;
|
|
17976
|
+
} catch {
|
|
17977
|
+
return true;
|
|
17978
|
+
}
|
|
17979
|
+
}
|
|
17980
|
+
if (cached.configPath) {
|
|
17981
|
+
const configFullPath = join15(repoPath, cached.configPath);
|
|
17982
|
+
if (!existsSync13(configFullPath))
|
|
17983
|
+
return true;
|
|
17984
|
+
try {
|
|
17985
|
+
const stat = statSync(configFullPath);
|
|
17986
|
+
const age = Date.now() - new Date(cached.snapshotAt).getTime();
|
|
17987
|
+
if (age > 3600000)
|
|
17988
|
+
return true;
|
|
17989
|
+
} catch {
|
|
17990
|
+
return true;
|
|
17991
|
+
}
|
|
17992
|
+
}
|
|
17993
|
+
return false;
|
|
17994
|
+
}
|
|
17995
|
+
function loadCache(repoPath) {
|
|
17996
|
+
const cachePath = getCachePath(repoPath);
|
|
17997
|
+
if (!existsSync13(cachePath))
|
|
17998
|
+
return null;
|
|
17999
|
+
try {
|
|
18000
|
+
const raw = JSON.parse(readFileSync5(cachePath, "utf-8"));
|
|
18001
|
+
return raw;
|
|
18002
|
+
} catch {
|
|
18003
|
+
return null;
|
|
18004
|
+
}
|
|
18005
|
+
}
|
|
18006
|
+
function saveCache(snapshot) {
|
|
18007
|
+
const cachePath = getCachePath(snapshot.repoPath);
|
|
18008
|
+
writeFileSync4(cachePath, JSON.stringify(snapshot, null, 2), "utf-8");
|
|
18009
|
+
}
|
|
18010
|
+
function detectPackageManager(repoPath) {
|
|
18011
|
+
const result = {
|
|
18012
|
+
npm: existsSync13(join15(repoPath, "package-lock.json")),
|
|
18013
|
+
yarn: existsSync13(join15(repoPath, "yarn.lock")),
|
|
18014
|
+
pnpm: existsSync13(join15(repoPath, "pnpm-lock.yaml")),
|
|
18015
|
+
bun: existsSync13(join15(repoPath, "bun.lockb")) || existsSync13(join15(repoPath, "bun.lock")),
|
|
18016
|
+
preferred: "npm"
|
|
18017
|
+
};
|
|
18018
|
+
if (result.bun)
|
|
18019
|
+
result.preferred = "bun";
|
|
18020
|
+
else if (result.pnpm)
|
|
18021
|
+
result.preferred = "pnpm";
|
|
18022
|
+
else if (result.yarn)
|
|
18023
|
+
result.preferred = "yarn";
|
|
18024
|
+
else
|
|
18025
|
+
result.preferred = "npm";
|
|
18026
|
+
return result;
|
|
18027
|
+
}
|
|
18028
|
+
function detectDevScripts(repoPath) {
|
|
18029
|
+
const pkgPath = join15(repoPath, "package.json");
|
|
18030
|
+
if (!existsSync13(pkgPath)) {
|
|
18031
|
+
return { dev: null, test: null, seed: null, build: null };
|
|
18032
|
+
}
|
|
18033
|
+
let scripts;
|
|
18034
|
+
try {
|
|
18035
|
+
const pkg = JSON.parse(readFileSync5(pkgPath, "utf-8"));
|
|
18036
|
+
scripts = pkg.scripts ?? {};
|
|
18037
|
+
} catch {
|
|
18038
|
+
return { dev: null, test: null, seed: null, build: null };
|
|
18039
|
+
}
|
|
18040
|
+
const dev = scripts.dev ?? scripts.start ?? scripts["dev:server"] ?? null;
|
|
18041
|
+
const test = scripts.test ?? scripts["test:e2e"] ?? scripts["test:playwright"] ?? scripts.e2e ?? null;
|
|
18042
|
+
const seed = scripts.seed ?? scripts["db:seed"] ?? scripts.seedDb ?? null;
|
|
18043
|
+
const build = scripts.build ?? scripts["build:test"] ?? null;
|
|
18044
|
+
return { dev, test, seed, build };
|
|
18045
|
+
}
|
|
18046
|
+
function findPlaywrightConfig(repoPath) {
|
|
18047
|
+
const candidates = [
|
|
18048
|
+
"playwright.config.ts",
|
|
18049
|
+
"playwright.config.js",
|
|
18050
|
+
"playwright.config.mjs",
|
|
18051
|
+
"playwright.config.cjs",
|
|
18052
|
+
"playwright-ct.config.ts",
|
|
18053
|
+
"playwright-ct.config.js"
|
|
18054
|
+
];
|
|
18055
|
+
for (const name of candidates) {
|
|
18056
|
+
if (existsSync13(join15(repoPath, name)))
|
|
18057
|
+
return name;
|
|
18058
|
+
}
|
|
18059
|
+
return null;
|
|
18060
|
+
}
|
|
18061
|
+
function extractTestGlobPatterns(configPath, repoPath) {
|
|
18062
|
+
if (!configPath) {
|
|
18063
|
+
return ["**/*.spec.ts", "**/*.spec.js", "**/*.test.ts", "**/*.test.js", "**/e2e/**/*.ts", "**/e2e/**/*.js", "**/tests/**/*.ts", "**/tests/**/*.js"];
|
|
18064
|
+
}
|
|
18065
|
+
const fullPath = join15(repoPath, configPath);
|
|
18066
|
+
let content;
|
|
18067
|
+
try {
|
|
18068
|
+
content = readFileSync5(fullPath, "utf-8");
|
|
18069
|
+
} catch {
|
|
18070
|
+
return ["**/*.spec.ts", "**/*.test.ts"];
|
|
18071
|
+
}
|
|
18072
|
+
const patterns = [];
|
|
18073
|
+
const testDirMatch = content.match(/testDir\s*[:=]\s*['"`]([^'"`]+)['"`]/);
|
|
18074
|
+
const testDir = testDirMatch?.[1];
|
|
18075
|
+
const testMatchArray = content.match(/testMatch\s*[:=]\s*\[([^\]]+)\]/);
|
|
18076
|
+
if (testMatchArray) {
|
|
18077
|
+
const items = testMatchArray[1].match(/['"`]([^'"`]+)['"`]/g);
|
|
18078
|
+
if (items) {
|
|
18079
|
+
for (const item of items) {
|
|
18080
|
+
patterns.push(item.replace(/['"`]/g, ""));
|
|
18081
|
+
}
|
|
18082
|
+
}
|
|
18083
|
+
}
|
|
18084
|
+
const testMatchSingle = content.match(/testMatch\s*[:=]\s*['"`]([^'"`]+)['"`]/);
|
|
18085
|
+
if (testMatchSingle) {
|
|
18086
|
+
patterns.push(testMatchSingle[1]);
|
|
18087
|
+
}
|
|
18088
|
+
if (testDir && patterns.length === 0) {
|
|
18089
|
+
patterns.push(`${testDir}/**/*.spec.ts`, `${testDir}/**/*.test.ts`, `${testDir}/**/*.spec.js`, `${testDir}/**/*.test.js`);
|
|
18090
|
+
}
|
|
18091
|
+
if (patterns.length === 0) {
|
|
18092
|
+
patterns.push("**/*.spec.ts", "**/*.test.ts", "**/*.spec.js", "**/*.test.js");
|
|
18093
|
+
}
|
|
18094
|
+
return patterns;
|
|
18095
|
+
}
|
|
18096
|
+
function approximateTestCount(filePath) {
|
|
18097
|
+
let content;
|
|
18098
|
+
try {
|
|
18099
|
+
content = readFileSync5(filePath, "utf-8");
|
|
18100
|
+
} catch {
|
|
18101
|
+
return 0;
|
|
18102
|
+
}
|
|
18103
|
+
const testCalls = (content.match(/(?:^|\n)\s*(?:test|it)\(/gm) || []).length;
|
|
18104
|
+
return testCalls || 0;
|
|
18105
|
+
}
|
|
18106
|
+
function findSpecFiles(repoPath, globPatterns) {
|
|
18107
|
+
const specs = [];
|
|
18108
|
+
const seen = new Set;
|
|
18109
|
+
for (const pattern of globPatterns) {
|
|
18110
|
+
const dirsToSearch = ["", ".", "tests", "e2e", "test", "__tests__", "specs", "src"];
|
|
18111
|
+
for (const dir of dirsToSearch) {
|
|
18112
|
+
const searchDir = dir ? join15(repoPath, dir) : repoPath;
|
|
18113
|
+
if (!existsSync13(searchDir))
|
|
18114
|
+
continue;
|
|
18115
|
+
try {
|
|
18116
|
+
const files = walkDir(searchDir);
|
|
18117
|
+
for (const file of files) {
|
|
18118
|
+
const relativePath = relative2(repoPath, file);
|
|
18119
|
+
if (seen.has(relativePath))
|
|
18120
|
+
continue;
|
|
18121
|
+
if (matchesGlob(relativePath, pattern)) {
|
|
18122
|
+
seen.add(relativePath);
|
|
18123
|
+
const content = readFileSync5(file, "utf-8");
|
|
18124
|
+
const contentHash = createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
18125
|
+
const stat = statSync(file);
|
|
18126
|
+
specs.push({
|
|
18127
|
+
file: relativePath,
|
|
18128
|
+
fromGlob: pattern,
|
|
18129
|
+
testCount: approximateTestCount(file),
|
|
18130
|
+
mtimeMs: stat.mtimeMs,
|
|
18131
|
+
contentHash
|
|
18132
|
+
});
|
|
18133
|
+
}
|
|
18134
|
+
}
|
|
18135
|
+
} catch {}
|
|
18136
|
+
}
|
|
18137
|
+
}
|
|
18138
|
+
specs.sort((a, b) => a.file.localeCompare(b.file));
|
|
18139
|
+
return specs;
|
|
18140
|
+
}
|
|
18141
|
+
function walkDir(dir) {
|
|
18142
|
+
const results = [];
|
|
18143
|
+
try {
|
|
18144
|
+
const entries = readdirSync3(dir, { withFileTypes: true });
|
|
18145
|
+
for (const entry of entries) {
|
|
18146
|
+
const fullPath = join15(dir, entry.name);
|
|
18147
|
+
if (entry.isDirectory()) {
|
|
18148
|
+
if (entry.name === "node_modules" || entry.name === ".git")
|
|
18149
|
+
continue;
|
|
18150
|
+
results.push(...walkDir(fullPath));
|
|
18151
|
+
} else if (entry.isFile()) {
|
|
18152
|
+
results.push(fullPath);
|
|
18153
|
+
}
|
|
18154
|
+
}
|
|
18155
|
+
} catch {}
|
|
18156
|
+
return results;
|
|
18157
|
+
}
|
|
18158
|
+
function matchesGlob(filePath, pattern) {
|
|
18159
|
+
let regex = pattern.replace(/\./g, "\\.").replace(/\*\*\//g, "(.+/)?").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]");
|
|
18160
|
+
regex = "^" + regex + "$";
|
|
18161
|
+
return new RegExp(regex).test(filePath);
|
|
18162
|
+
}
|
|
18163
|
+
function detectSuggestedUrl(repoPath) {
|
|
18164
|
+
const pkgPath = join15(repoPath, "package.json");
|
|
18165
|
+
if (!existsSync13(pkgPath))
|
|
18166
|
+
return null;
|
|
18167
|
+
try {
|
|
18168
|
+
const pkg = JSON.parse(readFileSync5(pkgPath, "utf-8"));
|
|
18169
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
18170
|
+
if ("next" in deps)
|
|
18171
|
+
return "http://localhost:3000";
|
|
18172
|
+
if ("vite" in deps)
|
|
18173
|
+
return "http://localhost:5173";
|
|
18174
|
+
if (Object.keys(deps).some((d) => d.startsWith("@remix-run")))
|
|
18175
|
+
return "http://localhost:3000";
|
|
18176
|
+
if ("nuxt" in deps)
|
|
18177
|
+
return "http://localhost:3000";
|
|
18178
|
+
if (Object.keys(deps).some((d) => d.startsWith("@angular")))
|
|
18179
|
+
return "http://localhost:4200";
|
|
18180
|
+
} catch {}
|
|
18181
|
+
return null;
|
|
18182
|
+
}
|
|
18183
|
+
function checkPlaywrightBrowserInstalled(repoPath) {
|
|
18184
|
+
const cacheDir = join15(repoPath, "node_modules", ".cache", "ms-playwright");
|
|
18185
|
+
if (existsSync13(cacheDir))
|
|
18186
|
+
return true;
|
|
18187
|
+
const globalCache = join15(repoPath, ".cache", "ms-playwright");
|
|
18188
|
+
if (existsSync13(globalCache))
|
|
18189
|
+
return true;
|
|
18190
|
+
return false;
|
|
18191
|
+
}
|
|
18192
|
+
function getInstallCommand(pm) {
|
|
18193
|
+
switch (pm.preferred) {
|
|
18194
|
+
case "npm":
|
|
18195
|
+
return "npm install";
|
|
18196
|
+
case "yarn":
|
|
18197
|
+
return "yarn install";
|
|
18198
|
+
case "pnpm":
|
|
18199
|
+
return "pnpm install";
|
|
18200
|
+
case "bun":
|
|
18201
|
+
return "bun install";
|
|
18202
|
+
}
|
|
18203
|
+
}
|
|
18204
|
+
function getPlaywrightInstallCommand(pm) {
|
|
18205
|
+
return "npx playwright install";
|
|
18206
|
+
}
|
|
18207
|
+
function discoverRepo(opts) {
|
|
18208
|
+
const repoPath = resolve2(opts.repoPath);
|
|
18209
|
+
if (!opts.refresh) {
|
|
18210
|
+
const cached = loadCache(repoPath);
|
|
18211
|
+
if (cached && !isCacheStale(cached, repoPath)) {
|
|
18212
|
+
return cached;
|
|
18213
|
+
}
|
|
18214
|
+
}
|
|
18215
|
+
const configPath = findPlaywrightConfig(repoPath);
|
|
18216
|
+
let configRaw = null;
|
|
18217
|
+
if (configPath) {
|
|
18218
|
+
try {
|
|
18219
|
+
configRaw = readFileSync5(join15(repoPath, configPath), "utf-8");
|
|
18220
|
+
} catch {
|
|
18221
|
+
configRaw = null;
|
|
18222
|
+
}
|
|
18223
|
+
}
|
|
18224
|
+
const globPatterns = extractTestGlobPatterns(configPath, repoPath);
|
|
18225
|
+
const specs = findSpecFiles(repoPath, globPatterns);
|
|
18226
|
+
const packageManager = detectPackageManager(repoPath);
|
|
18227
|
+
const devScripts = detectDevScripts(repoPath);
|
|
18228
|
+
const playwrightInstalled = existsSync13(join15(repoPath, "node_modules", "playwright")) || existsSync13(join15(repoPath, "node_modules", "@playwright", "test"));
|
|
18229
|
+
const browsersInstalled = checkPlaywrightBrowserInstalled(repoPath);
|
|
18230
|
+
const configExists = configPath !== null;
|
|
18231
|
+
const specsFound = specs.length > 0;
|
|
18232
|
+
const issues = [];
|
|
18233
|
+
if (!configExists)
|
|
18234
|
+
issues.push("No Playwright config file found");
|
|
18235
|
+
if (!playwrightInstalled)
|
|
18236
|
+
issues.push("Playwright is not installed (node_modules/playwright missing)");
|
|
18237
|
+
if (!browsersInstalled)
|
|
18238
|
+
issues.push("Playwright browsers not installed (run `npx playwright install`)");
|
|
18239
|
+
if (!specsFound)
|
|
18240
|
+
issues.push("No spec files found");
|
|
18241
|
+
const ready = issues.length === 0;
|
|
18242
|
+
const runPm = (script) => {
|
|
18243
|
+
const name = script.split(" ")[0];
|
|
18244
|
+
const pm = packageManager.preferred;
|
|
18245
|
+
if (pm === "npm")
|
|
18246
|
+
return `npm run ${name}`;
|
|
18247
|
+
return `${pm} run ${name}`;
|
|
18248
|
+
};
|
|
18249
|
+
const prep = {
|
|
18250
|
+
installCmd: playwrightInstalled ? null : getInstallCommand(packageManager),
|
|
18251
|
+
installBrowsersCmd: browsersInstalled ? null : getPlaywrightInstallCommand(packageManager),
|
|
18252
|
+
startDevCmd: devScripts.dev ? runPm(devScripts.dev) : null,
|
|
18253
|
+
buildCmd: devScripts.build ? runPm(devScripts.build) : null,
|
|
18254
|
+
seedCmd: devScripts.seed ? runPm(devScripts.seed) : null
|
|
18255
|
+
};
|
|
18256
|
+
const suggestedUrl = opts.baseUrl ?? detectSuggestedUrl(repoPath);
|
|
18257
|
+
const workingDir = opts.workingDir ?? repoPath;
|
|
18258
|
+
const specHashes = specs.map((s) => s.contentHash).join(",");
|
|
18259
|
+
const cacheKey = createHash("sha256").update(`${repoPath}:${specHashes}:${configRaw ?? ""}`).digest("hex").slice(0, 16);
|
|
18260
|
+
const snapshot = {
|
|
18261
|
+
repoPath,
|
|
18262
|
+
configPath,
|
|
18263
|
+
configRaw,
|
|
18264
|
+
specs,
|
|
18265
|
+
totalTests: specs.reduce((sum, s) => sum + s.testCount, 0),
|
|
18266
|
+
packageManager,
|
|
18267
|
+
devScripts,
|
|
18268
|
+
readiness: {
|
|
18269
|
+
playwrightInstalled,
|
|
18270
|
+
browsersInstalled,
|
|
18271
|
+
configExists,
|
|
18272
|
+
specsFound,
|
|
18273
|
+
ready,
|
|
18274
|
+
issues
|
|
18275
|
+
},
|
|
18276
|
+
prep,
|
|
18277
|
+
suggestedUrl,
|
|
18278
|
+
workingDir,
|
|
18279
|
+
snapshotAt: new Date().toISOString(),
|
|
18280
|
+
cacheKey
|
|
18281
|
+
};
|
|
18282
|
+
saveCache(snapshot);
|
|
18283
|
+
return snapshot;
|
|
18284
|
+
}
|
|
18285
|
+
function clearDiscoveryCache(repoPath) {
|
|
18286
|
+
const cacheDir = getCacheDir();
|
|
18287
|
+
if (!existsSync13(cacheDir))
|
|
18288
|
+
return;
|
|
18289
|
+
if (repoPath) {
|
|
18290
|
+
const cachePath = getCachePath(repoPath);
|
|
18291
|
+
if (existsSync13(cachePath)) {
|
|
18292
|
+
unlinkSync(cachePath);
|
|
18293
|
+
}
|
|
18294
|
+
} else {
|
|
18295
|
+
for (const file of readdirSync3(cacheDir)) {
|
|
18296
|
+
if (file.endsWith(".json")) {
|
|
18297
|
+
unlinkSync(join15(cacheDir, file));
|
|
18298
|
+
}
|
|
18299
|
+
}
|
|
18300
|
+
}
|
|
18301
|
+
}
|
|
18302
|
+
function getDiscoveryCacheInfo(repoPath) {
|
|
18303
|
+
const cachePath = getCachePath(repoPath);
|
|
18304
|
+
if (!existsSync13(cachePath))
|
|
18305
|
+
return null;
|
|
18306
|
+
const cached = loadCache(repoPath);
|
|
18307
|
+
if (!cached)
|
|
18308
|
+
return null;
|
|
18309
|
+
return {
|
|
18310
|
+
cached: true,
|
|
18311
|
+
stale: isCacheStale(cached, repoPath),
|
|
18312
|
+
path: cachePath
|
|
18313
|
+
};
|
|
18314
|
+
}
|
|
18315
|
+
// src/lib/repo-executor.ts
|
|
18316
|
+
init_runs();
|
|
18317
|
+
init_database();
|
|
18318
|
+
init_paths();
|
|
18319
|
+
import { execSync as execSync2 } from "child_process";
|
|
18320
|
+
import { existsSync as existsSync14, mkdirSync as mkdirSync11, writeFileSync as writeFileSync5 } from "fs";
|
|
18321
|
+
import { join as join16 } from "path";
|
|
18322
|
+
function resolvePlaywrightCmd(repoPath) {
|
|
18323
|
+
const localPw = join16(repoPath, "node_modules", ".bin", "playwright");
|
|
18324
|
+
if (existsSync14(localPw)) {
|
|
18325
|
+
return [localPw, "test"];
|
|
18326
|
+
}
|
|
18327
|
+
return ["npx", "playwright", "test"];
|
|
18328
|
+
}
|
|
18329
|
+
function buildPlaywrightArgs(specFiles, extraArgs = []) {
|
|
18330
|
+
const args = [];
|
|
18331
|
+
if (specFiles.length > 0) {
|
|
18332
|
+
args.push(...specFiles);
|
|
18333
|
+
}
|
|
18334
|
+
args.push("--reporter", "json");
|
|
18335
|
+
if (extraArgs.length > 0) {
|
|
18336
|
+
args.push(...extraArgs);
|
|
18337
|
+
}
|
|
18338
|
+
return args;
|
|
18339
|
+
}
|
|
18340
|
+
function runPlaywright(repoPath, workingDir, specFiles, extraArgs, timeoutMs) {
|
|
18341
|
+
const cmd = resolvePlaywrightCmd(repoPath);
|
|
18342
|
+
const args = buildPlaywrightArgs(specFiles, extraArgs, workingDir);
|
|
18343
|
+
const startTime = Date.now();
|
|
18344
|
+
try {
|
|
18345
|
+
const result = execSync2(`${cmd.join(" ")} ${args.join(" ")}`, {
|
|
18346
|
+
cwd: workingDir,
|
|
18347
|
+
encoding: "utf-8",
|
|
18348
|
+
timeout: timeoutMs,
|
|
18349
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
18350
|
+
env: { ...process.env, CI: "1" }
|
|
18351
|
+
}).toString();
|
|
18352
|
+
return {
|
|
18353
|
+
exitCode: 0,
|
|
18354
|
+
stdout: result,
|
|
18355
|
+
stderr: "",
|
|
18356
|
+
durationMs: Date.now() - startTime
|
|
18357
|
+
};
|
|
18358
|
+
} catch (err) {
|
|
18359
|
+
const stdout = err.stdout?.toString() ?? "";
|
|
18360
|
+
const stderr = err.stderr?.toString() ?? "";
|
|
18361
|
+
const exitCode = err.status ?? err.code ?? -1;
|
|
18362
|
+
return {
|
|
18363
|
+
exitCode: typeof exitCode === "number" ? exitCode : -1,
|
|
18364
|
+
stdout,
|
|
18365
|
+
stderr,
|
|
18366
|
+
durationMs: Date.now() - startTime
|
|
18367
|
+
};
|
|
18368
|
+
}
|
|
18369
|
+
}
|
|
18370
|
+
function parsePlaywrightJsonOutput(stdout, stderr) {
|
|
18371
|
+
const testResults = [];
|
|
18372
|
+
try {
|
|
18373
|
+
const obj = JSON.parse(stdout);
|
|
18374
|
+
if (obj.suites) {
|
|
18375
|
+
for (const suite of obj.suites) {
|
|
18376
|
+
collectTestsFromSuite(suite, testResults);
|
|
18377
|
+
}
|
|
18378
|
+
}
|
|
18379
|
+
if (obj.tests && Array.isArray(obj.tests)) {
|
|
18380
|
+
for (const test of obj.tests) {
|
|
18381
|
+
testResults.push({
|
|
18382
|
+
name: test.title || test.name || "unknown test",
|
|
18383
|
+
status: test.outcome === "expected" ? "passed" : test.outcome === "skipped" ? "skipped" : "failed"
|
|
18384
|
+
});
|
|
18385
|
+
}
|
|
18386
|
+
}
|
|
18387
|
+
} catch {
|
|
18388
|
+
const lines = stdout.split(`
|
|
18389
|
+
`);
|
|
18390
|
+
for (const line of lines) {
|
|
18391
|
+
if (!line.trim())
|
|
18392
|
+
continue;
|
|
18393
|
+
try {
|
|
18394
|
+
const obj = JSON.parse(line);
|
|
18395
|
+
if (obj.suites) {
|
|
18396
|
+
for (const suite of obj.suites) {
|
|
18397
|
+
collectTestsFromSuite(suite, testResults);
|
|
18398
|
+
}
|
|
18399
|
+
}
|
|
18400
|
+
if (obj.tests && Array.isArray(obj.tests)) {
|
|
18401
|
+
for (const test of obj.tests) {
|
|
18402
|
+
testResults.push({
|
|
18403
|
+
name: test.title || test.name || "unknown test",
|
|
18404
|
+
status: test.outcome === "expected" ? "passed" : test.outcome === "skipped" ? "skipped" : "failed"
|
|
18405
|
+
});
|
|
18406
|
+
}
|
|
18407
|
+
}
|
|
18408
|
+
} catch {}
|
|
18409
|
+
}
|
|
18410
|
+
}
|
|
18411
|
+
if (testResults.length === 0) {
|
|
18412
|
+
const passed = (stdout.match(/\u2713/g) || []).length;
|
|
18413
|
+
const failed = (stdout.match(/\u2717/g) || []).length;
|
|
18414
|
+
if (passed > 0 || failed > 0) {
|
|
18415
|
+
testResults.push({ name: `${passed} passed, ${failed} failed`, status: failed > 0 ? "failed" : "passed" });
|
|
18416
|
+
} else {
|
|
18417
|
+
testResults.push({ name: "suite", status: stdout.includes("Error") ? "failed" : "passed" });
|
|
18418
|
+
}
|
|
18419
|
+
}
|
|
18420
|
+
return testResults;
|
|
18421
|
+
}
|
|
18422
|
+
function collectTestsFromSuite(suite, results) {
|
|
18423
|
+
if (suite.specs) {
|
|
18424
|
+
for (const spec of suite.specs) {
|
|
18425
|
+
const title = spec.title || spec.name || "unknown test";
|
|
18426
|
+
if (spec.tests && Array.isArray(spec.tests)) {
|
|
18427
|
+
for (const t of spec.tests) {
|
|
18428
|
+
results.push({
|
|
18429
|
+
name: title,
|
|
18430
|
+
status: t.outcome === "expected" ? "passed" : t.outcome === "skipped" ? "skipped" : "failed"
|
|
18431
|
+
});
|
|
18432
|
+
}
|
|
18433
|
+
}
|
|
18434
|
+
}
|
|
18435
|
+
}
|
|
18436
|
+
if (suite.suites) {
|
|
18437
|
+
for (const sub of suite.suites) {
|
|
18438
|
+
collectTestsFromSuite(sub, results);
|
|
18439
|
+
}
|
|
18440
|
+
}
|
|
18441
|
+
}
|
|
18442
|
+
function determineSpecStatus(exitCode, testResults) {
|
|
18443
|
+
if (exitCode === null)
|
|
18444
|
+
return "error";
|
|
18445
|
+
if (exitCode === 0)
|
|
18446
|
+
return "passed";
|
|
18447
|
+
if (testResults.length > 0 && testResults.every((t) => t.status === "passed"))
|
|
18448
|
+
return "passed";
|
|
18449
|
+
if (exitCode > 128)
|
|
18450
|
+
return "error";
|
|
18451
|
+
return "failed";
|
|
18452
|
+
}
|
|
18453
|
+
function runSingleSpec(repoPath, workingDir, spec, extraArgs, timeoutMs) {
|
|
18454
|
+
const result = runPlaywright(repoPath, workingDir, [spec.file], extraArgs, timeoutMs);
|
|
18455
|
+
const testResults = parsePlaywrightJsonOutput(result.stdout, result.stderr);
|
|
18456
|
+
const status = determineSpecStatus(result.exitCode, testResults);
|
|
18457
|
+
return {
|
|
18458
|
+
specFile: spec.file,
|
|
18459
|
+
status,
|
|
18460
|
+
exitCode: result.exitCode,
|
|
18461
|
+
stdout: result.stdout,
|
|
18462
|
+
stderr: result.stderr,
|
|
18463
|
+
durationMs: result.durationMs,
|
|
18464
|
+
testResults,
|
|
18465
|
+
error: result.exitCode !== 0 && result.stderr ? result.stderr.slice(0, 2000) : undefined
|
|
18466
|
+
};
|
|
18467
|
+
}
|
|
18468
|
+
async function runRepoTests(opts) {
|
|
18469
|
+
const { snapshot } = opts;
|
|
18470
|
+
const specFiles = opts.specFiles ?? snapshot.specs.map((s) => s.file);
|
|
18471
|
+
const timeout = opts.timeout ?? 300000;
|
|
18472
|
+
const workingDir = opts.snapshot.workingDir;
|
|
18473
|
+
const repoPath = snapshot.repoPath;
|
|
18474
|
+
const url = opts.url ?? snapshot.suggestedUrl ?? "http://localhost:3000";
|
|
18475
|
+
const run = createRun({
|
|
18476
|
+
projectId: opts.projectId,
|
|
18477
|
+
url,
|
|
18478
|
+
model: opts.model ?? "repo-native",
|
|
18479
|
+
headed: false,
|
|
18480
|
+
parallel: 1,
|
|
18481
|
+
metadata: {
|
|
18482
|
+
runType: "repo-native",
|
|
18483
|
+
repoPath,
|
|
18484
|
+
configPath: snapshot.configPath,
|
|
18485
|
+
cacheKey: snapshot.cacheKey,
|
|
18486
|
+
label: opts.label
|
|
18487
|
+
}
|
|
18488
|
+
});
|
|
18489
|
+
const specResults = [];
|
|
18490
|
+
const startTime = Date.now();
|
|
18491
|
+
for (const specFile of specFiles) {
|
|
18492
|
+
const spec = snapshot.specs.find((s) => s.file === specFile);
|
|
18493
|
+
if (!spec)
|
|
18494
|
+
continue;
|
|
18495
|
+
const result = runSingleSpec(repoPath, workingDir, spec, opts.extraArgs ?? [], timeout);
|
|
18496
|
+
specResults.push(result);
|
|
18497
|
+
const resultId = uuid();
|
|
18498
|
+
const timestamp = now();
|
|
18499
|
+
const db2 = getDatabase();
|
|
18500
|
+
db2.exec("PRAGMA foreign_keys = OFF");
|
|
18501
|
+
try {
|
|
18502
|
+
const reasoning = result.status === "passed" ? "All tests passed" : (result.error ?? "").slice(0, 500) || null;
|
|
18503
|
+
const errorStr = result.status !== "passed" ? result.error ?? null : null;
|
|
18504
|
+
db2.query(`
|
|
18505
|
+
INSERT INTO results (id, run_id, scenario_id, status, reasoning, error, steps_completed, steps_total, duration_ms, model, tokens_used, cost_cents, metadata, created_at, persona_id, persona_name)
|
|
18506
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, NULL, NULL)
|
|
18507
|
+
`).run(resultId, run.id, "__repo__", result.status, reasoning, errorStr, result.testResults.filter((t) => t.status === "passed").length, result.testResults.length || 1, result.durationMs, "repo-native", JSON.stringify({
|
|
18508
|
+
specFile: result.specFile,
|
|
18509
|
+
exitCode: result.exitCode,
|
|
18510
|
+
testResults: result.testResults
|
|
18511
|
+
}), timestamp);
|
|
18512
|
+
} finally {
|
|
18513
|
+
db2.exec("PRAGMA foreign_keys = ON");
|
|
18514
|
+
}
|
|
18515
|
+
const resultRecord = { id: resultId };
|
|
18516
|
+
if (result.stdout || result.stderr) {
|
|
18517
|
+
const reportersDir = join16(getTestersDir(), "repo-run-output");
|
|
18518
|
+
mkdirSync11(reportersDir, { recursive: true });
|
|
18519
|
+
const outputFile = join16(reportersDir, `${resultRecord.id}.log`);
|
|
18520
|
+
writeFileSync5(outputFile, `=== stdout ===
|
|
18521
|
+
${result.stdout}
|
|
18522
|
+
|
|
18523
|
+
=== stderr ===
|
|
18524
|
+
${result.stderr}
|
|
18525
|
+
`);
|
|
18526
|
+
}
|
|
18527
|
+
}
|
|
18528
|
+
const durationMs = Date.now() - startTime;
|
|
18529
|
+
const passed = specResults.filter((r) => r.status === "passed").length;
|
|
18530
|
+
const failed = specResults.filter((r) => r.status === "failed").length;
|
|
18531
|
+
const skipped = specResults.filter((r) => r.status === "skipped").length;
|
|
18532
|
+
const errored = specResults.filter((r) => r.status === "error").length;
|
|
18533
|
+
const status = failed > 0 || errored > 0 ? "failed" : "passed";
|
|
18534
|
+
const runMeta = run.metadata ?? {};
|
|
18535
|
+
updateRun(run.id, {
|
|
18536
|
+
status,
|
|
18537
|
+
total: specResults.length,
|
|
18538
|
+
passed,
|
|
18539
|
+
failed: failed + errored,
|
|
18540
|
+
metadata: JSON.stringify({
|
|
18541
|
+
...runMeta,
|
|
18542
|
+
specResults: specResults.map((r) => ({
|
|
18543
|
+
specFile: r.specFile,
|
|
18544
|
+
status: r.status,
|
|
18545
|
+
exitCode: r.exitCode,
|
|
18546
|
+
testCount: r.testResults.length,
|
|
18547
|
+
durationMs: r.durationMs
|
|
18548
|
+
}))
|
|
18549
|
+
})
|
|
18550
|
+
});
|
|
18551
|
+
return {
|
|
18552
|
+
runId: run.id,
|
|
18553
|
+
specResults,
|
|
18554
|
+
total: specResults.length,
|
|
18555
|
+
passed,
|
|
18556
|
+
failed,
|
|
18557
|
+
skipped,
|
|
18558
|
+
errored,
|
|
18559
|
+
durationMs,
|
|
18560
|
+
status
|
|
18561
|
+
};
|
|
18562
|
+
}
|
|
18563
|
+
function runPrep(snapshot, steps) {
|
|
18564
|
+
const { prep } = snapshot;
|
|
18565
|
+
const results = [];
|
|
18566
|
+
for (const step of steps) {
|
|
18567
|
+
let cmd = null;
|
|
18568
|
+
switch (step) {
|
|
18569
|
+
case "install":
|
|
18570
|
+
cmd = prep.installCmd;
|
|
18571
|
+
break;
|
|
18572
|
+
case "browsers":
|
|
18573
|
+
cmd = prep.installBrowsersCmd;
|
|
18574
|
+
break;
|
|
18575
|
+
case "dev":
|
|
18576
|
+
cmd = prep.startDevCmd;
|
|
18577
|
+
break;
|
|
18578
|
+
case "build":
|
|
18579
|
+
cmd = prep.buildCmd;
|
|
18580
|
+
break;
|
|
18581
|
+
case "seed":
|
|
18582
|
+
cmd = prep.seedCmd;
|
|
18583
|
+
break;
|
|
18584
|
+
}
|
|
18585
|
+
if (!cmd) {
|
|
18586
|
+
results.push({ cmd: step, success: true, output: "Not needed (already satisfied)", durationMs: 0 });
|
|
18587
|
+
continue;
|
|
18588
|
+
}
|
|
18589
|
+
const startTime = Date.now();
|
|
18590
|
+
try {
|
|
18591
|
+
const output = execSync2(cmd, {
|
|
18592
|
+
cwd: snapshot.workingDir,
|
|
18593
|
+
encoding: "utf-8",
|
|
18594
|
+
timeout: 120000,
|
|
18595
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
18596
|
+
env: { ...processSyncEnv() }
|
|
18597
|
+
});
|
|
18598
|
+
results.push({
|
|
18599
|
+
cmd,
|
|
18600
|
+
success: true,
|
|
18601
|
+
output: output.slice(0, 1000),
|
|
18602
|
+
durationMs: Date.now() - startTime
|
|
18603
|
+
});
|
|
18604
|
+
} catch (err) {
|
|
18605
|
+
const stdout = err.stdout?.toString() ?? "";
|
|
18606
|
+
const stderr = err.stderr?.toString() ?? "";
|
|
18607
|
+
results.push({
|
|
18608
|
+
cmd,
|
|
18609
|
+
success: false,
|
|
18610
|
+
output: (stderr || stdout).slice(0, 1000),
|
|
18611
|
+
durationMs: Date.now() - startTime
|
|
18612
|
+
});
|
|
18613
|
+
}
|
|
18614
|
+
}
|
|
18615
|
+
return {
|
|
18616
|
+
steps: results,
|
|
18617
|
+
allSucceeded: results.every((r) => r.success)
|
|
18618
|
+
};
|
|
18619
|
+
}
|
|
18620
|
+
function processSyncEnv() {
|
|
18621
|
+
return { ...process.env, CI: "1", FORCE_COLOR: "0" };
|
|
18622
|
+
}
|
|
17373
18623
|
// src/lib/prod-debug.ts
|
|
17374
18624
|
var UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i;
|
|
17375
18625
|
var SENSITIVE_PARAM_RE = /token|secret|key|password|code|state|cookie|session|grant|credential|auth|jwt|access/i;
|
|
@@ -17869,6 +19119,68 @@ async function postGitHubComment(run, results, options) {
|
|
|
17869
19119
|
return false;
|
|
17870
19120
|
}
|
|
17871
19121
|
}
|
|
19122
|
+
// src/db/sessions.ts
|
|
19123
|
+
init_database();
|
|
19124
|
+
function createSession(input) {
|
|
19125
|
+
const db2 = getDatabase();
|
|
19126
|
+
const id = input.sessionId ?? uuid();
|
|
19127
|
+
const timestamp = now();
|
|
19128
|
+
db2.query(`
|
|
19129
|
+
INSERT INTO sessions (id, tab_id, url, title, entries, entry_count, error_count, console_count, nav_count, status, start_time, end_time, created_at)
|
|
19130
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
19131
|
+
`).run(id, input.tabId, input.url ?? null, input.title ?? null, input.entries, input.entryCount, input.errorCount ?? 0, input.consoleCount ?? 0, input.navCount ?? 0, input.status, input.startTime, input.endTime ?? null, timestamp);
|
|
19132
|
+
return getSession(id);
|
|
19133
|
+
}
|
|
19134
|
+
function getSession(id) {
|
|
19135
|
+
const db2 = getDatabase();
|
|
19136
|
+
let row = db2.query("SELECT * FROM sessions WHERE id = ?").get(id);
|
|
19137
|
+
if (row)
|
|
19138
|
+
return sessionFromRow(row);
|
|
19139
|
+
const fullId = resolvePartialId("sessions", id);
|
|
19140
|
+
if (fullId) {
|
|
19141
|
+
row = db2.query("SELECT * FROM sessions WHERE id = ?").get(fullId);
|
|
19142
|
+
if (row)
|
|
19143
|
+
return sessionFromRow(row);
|
|
19144
|
+
}
|
|
19145
|
+
return null;
|
|
19146
|
+
}
|
|
19147
|
+
function listSessions(limit = 50, offset = 0) {
|
|
19148
|
+
const db2 = getDatabase();
|
|
19149
|
+
const rows = db2.query("SELECT * FROM sessions ORDER BY created_at DESC LIMIT ? OFFSET ?").all(limit, offset);
|
|
19150
|
+
return rows.map(sessionFromRow);
|
|
19151
|
+
}
|
|
19152
|
+
function deleteSession(id) {
|
|
19153
|
+
const db2 = getDatabase();
|
|
19154
|
+
const result = db2.query("DELETE FROM sessions WHERE id = ?").run(id);
|
|
19155
|
+
return result.changes > 0;
|
|
19156
|
+
}
|
|
19157
|
+
function searchSessions(query, limit = 20) {
|
|
19158
|
+
const db2 = getDatabase();
|
|
19159
|
+
const rows = db2.query("SELECT * FROM sessions WHERE url LIKE ? OR title LIKE ? ORDER BY created_at DESC LIMIT ?").all(`%${query}%`, `%${query}%`, limit);
|
|
19160
|
+
return rows.map(sessionFromRow);
|
|
19161
|
+
}
|
|
19162
|
+
function countSessions() {
|
|
19163
|
+
const db2 = getDatabase();
|
|
19164
|
+
const row = db2.query("SELECT COUNT(*) as count FROM sessions").get();
|
|
19165
|
+
return row.count;
|
|
19166
|
+
}
|
|
19167
|
+
function sessionFromRow(row) {
|
|
19168
|
+
return {
|
|
19169
|
+
id: row.id,
|
|
19170
|
+
tabId: row.tab_id,
|
|
19171
|
+
url: row.url,
|
|
19172
|
+
title: row.title,
|
|
19173
|
+
entries: JSON.parse(row.entries),
|
|
19174
|
+
entryCount: row.entry_count,
|
|
19175
|
+
errorCount: row.error_count,
|
|
19176
|
+
consoleCount: row.console_count,
|
|
19177
|
+
navCount: row.nav_count,
|
|
19178
|
+
status: row.status,
|
|
19179
|
+
startTime: row.start_time,
|
|
19180
|
+
endTime: row.end_time,
|
|
19181
|
+
createdAt: row.created_at
|
|
19182
|
+
};
|
|
19183
|
+
}
|
|
17872
19184
|
export {
|
|
17873
19185
|
writeScenarioMeta,
|
|
17874
19186
|
writeRunMeta,
|
|
@@ -17886,11 +19198,14 @@ export {
|
|
|
17886
19198
|
slugify,
|
|
17887
19199
|
shouldRunAt,
|
|
17888
19200
|
shortUuid,
|
|
19201
|
+
searchSessions,
|
|
17889
19202
|
screenshotFromRow,
|
|
17890
19203
|
scheduleFromRow,
|
|
17891
19204
|
scenarioFromRow,
|
|
17892
19205
|
runSmoke,
|
|
17893
19206
|
runSingleScenario,
|
|
19207
|
+
runRepoTests,
|
|
19208
|
+
runPrep,
|
|
17894
19209
|
runFromRow,
|
|
17895
19210
|
runByFilter,
|
|
17896
19211
|
runBatch,
|
|
@@ -17919,6 +19234,7 @@ export {
|
|
|
17919
19234
|
loadConfig,
|
|
17920
19235
|
listWebhooks,
|
|
17921
19236
|
listTemplateNames,
|
|
19237
|
+
listSessions,
|
|
17922
19238
|
listScreenshots,
|
|
17923
19239
|
listSchedules,
|
|
17924
19240
|
listScenarios,
|
|
@@ -17941,6 +19257,7 @@ export {
|
|
|
17941
19257
|
getTransitiveDependencies,
|
|
17942
19258
|
getTemplate,
|
|
17943
19259
|
getStarterScenarios,
|
|
19260
|
+
getSession,
|
|
17944
19261
|
getScreenshotsByResult,
|
|
17945
19262
|
getScreenshotDir,
|
|
17946
19263
|
getScreenshot,
|
|
@@ -17958,6 +19275,7 @@ export {
|
|
|
17958
19275
|
getFlow,
|
|
17959
19276
|
getExitCode,
|
|
17960
19277
|
getEnabledSchedules,
|
|
19278
|
+
getDiscoveryCacheInfo,
|
|
17961
19279
|
getDependents,
|
|
17962
19280
|
getDependencies,
|
|
17963
19281
|
getDefaultConfig,
|
|
@@ -17989,15 +19307,18 @@ export {
|
|
|
17989
19307
|
ensurePersonaAuthenticated,
|
|
17990
19308
|
ensureDir,
|
|
17991
19309
|
dispatchWebhooks,
|
|
19310
|
+
discoverRepo,
|
|
17992
19311
|
diffRuns,
|
|
17993
19312
|
detectFramework,
|
|
17994
19313
|
deleteWebhook,
|
|
19314
|
+
deleteSession,
|
|
17995
19315
|
deleteSchedule,
|
|
17996
19316
|
deleteScenario,
|
|
17997
19317
|
deleteRun,
|
|
17998
19318
|
deleteFlow,
|
|
17999
19319
|
deleteAuthPreset,
|
|
18000
19320
|
createWebhook,
|
|
19321
|
+
createSession,
|
|
18001
19322
|
createScreenshot,
|
|
18002
19323
|
createSchedule,
|
|
18003
19324
|
createScenario,
|
|
@@ -18008,10 +19329,12 @@ export {
|
|
|
18008
19329
|
createFlow,
|
|
18009
19330
|
createClient,
|
|
18010
19331
|
createAuthPreset,
|
|
19332
|
+
countSessions,
|
|
18011
19333
|
connectToTodos,
|
|
18012
19334
|
closeLightpanda,
|
|
18013
19335
|
closeDatabase,
|
|
18014
19336
|
closeBrowser,
|
|
19337
|
+
clearDiscoveryCache,
|
|
18015
19338
|
checkBudget,
|
|
18016
19339
|
agentFromRow,
|
|
18017
19340
|
addDependency,
|