@hasna/testers 0.0.13 → 0.0.14
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 +823 -1
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/scan-issues.d.ts +29 -0
- package/dist/db/scan-issues.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/lib/health-scan.d.ts +22 -0
- package/dist/lib/health-scan.d.ts.map +1 -0
- package/dist/lib/scanners/console.d.ts +12 -0
- package/dist/lib/scanners/console.d.ts.map +1 -0
- package/dist/lib/scanners/links.d.ts +12 -0
- package/dist/lib/scanners/links.d.ts.map +1 -0
- package/dist/lib/scanners/network.d.ts +15 -0
- package/dist/lib/scanners/network.d.ts.map +1 -0
- package/dist/lib/scanners/performance.d.ts +19 -0
- package/dist/lib/scanners/performance.d.ts.map +1 -0
- package/dist/mcp/index.js +1243 -435
- package/dist/server/index.js +23 -0
- package/dist/types/index.d.ts +54 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -2214,6 +2214,24 @@ function scheduleFromRow(row) {
|
|
|
2214
2214
|
updatedAt: row.updated_at
|
|
2215
2215
|
};
|
|
2216
2216
|
}
|
|
2217
|
+
function scanIssueFromRow(row) {
|
|
2218
|
+
return {
|
|
2219
|
+
id: row.id,
|
|
2220
|
+
fingerprint: row.fingerprint,
|
|
2221
|
+
type: row.type,
|
|
2222
|
+
severity: row.severity,
|
|
2223
|
+
pageUrl: row.page_url,
|
|
2224
|
+
message: row.message,
|
|
2225
|
+
detail: row.detail ? JSON.parse(row.detail) : null,
|
|
2226
|
+
status: row.status,
|
|
2227
|
+
occurrenceCount: row.occurrence_count,
|
|
2228
|
+
firstSeenAt: row.first_seen_at,
|
|
2229
|
+
lastSeenAt: row.last_seen_at,
|
|
2230
|
+
resolvedAt: row.resolved_at,
|
|
2231
|
+
todoTaskId: row.todo_task_id,
|
|
2232
|
+
projectId: row.project_id
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2217
2235
|
function flowFromRow(row) {
|
|
2218
2236
|
return {
|
|
2219
2237
|
id: row.id,
|
|
@@ -2531,6 +2549,29 @@ var init_database = __esm(() => {
|
|
|
2531
2549
|
`,
|
|
2532
2550
|
`
|
|
2533
2551
|
ALTER TABLE runs ADD COLUMN is_baseline INTEGER NOT NULL DEFAULT 0;
|
|
2552
|
+
`,
|
|
2553
|
+
`
|
|
2554
|
+
CREATE TABLE IF NOT EXISTS scan_issues (
|
|
2555
|
+
id TEXT PRIMARY KEY,
|
|
2556
|
+
fingerprint TEXT NOT NULL UNIQUE,
|
|
2557
|
+
type TEXT NOT NULL,
|
|
2558
|
+
severity TEXT NOT NULL DEFAULT 'medium',
|
|
2559
|
+
page_url TEXT NOT NULL,
|
|
2560
|
+
message TEXT NOT NULL,
|
|
2561
|
+
detail TEXT,
|
|
2562
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
2563
|
+
occurrence_count INTEGER NOT NULL DEFAULT 1,
|
|
2564
|
+
first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
2565
|
+
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
2566
|
+
resolved_at TEXT,
|
|
2567
|
+
todo_task_id TEXT,
|
|
2568
|
+
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL
|
|
2569
|
+
);
|
|
2570
|
+
|
|
2571
|
+
CREATE INDEX IF NOT EXISTS idx_scan_issues_fingerprint ON scan_issues(fingerprint);
|
|
2572
|
+
CREATE INDEX IF NOT EXISTS idx_scan_issues_status ON scan_issues(status);
|
|
2573
|
+
CREATE INDEX IF NOT EXISTS idx_scan_issues_type ON scan_issues(type);
|
|
2574
|
+
CREATE INDEX IF NOT EXISTS idx_scan_issues_project ON scan_issues(project_id);
|
|
2534
2575
|
`
|
|
2535
2576
|
];
|
|
2536
2577
|
});
|
|
@@ -5743,6 +5784,628 @@ var init_agents = __esm(() => {
|
|
|
5743
5784
|
init_database();
|
|
5744
5785
|
});
|
|
5745
5786
|
|
|
5787
|
+
// src/lib/scanners/console.ts
|
|
5788
|
+
var exports_console = {};
|
|
5789
|
+
__export(exports_console, {
|
|
5790
|
+
scanConsoleErrors: () => scanConsoleErrors
|
|
5791
|
+
});
|
|
5792
|
+
async function scanConsoleErrors(options) {
|
|
5793
|
+
const { url, pages, headed = false, timeoutMs = 15000 } = options;
|
|
5794
|
+
const start = Date.now();
|
|
5795
|
+
const scannedPages = [];
|
|
5796
|
+
const issues = [];
|
|
5797
|
+
const pageUrls = pages?.length ? pages.map((p) => p.startsWith("http") ? p : `${url.replace(/\/$/, "")}${p}`) : [url];
|
|
5798
|
+
const browser = await launchBrowser({ headless: !headed });
|
|
5799
|
+
try {
|
|
5800
|
+
for (const pageUrl of pageUrls) {
|
|
5801
|
+
const page = await getPage(browser, {});
|
|
5802
|
+
const entries = [];
|
|
5803
|
+
page.on("console", (msg) => {
|
|
5804
|
+
if (msg.type() === "error") {
|
|
5805
|
+
entries.push({ type: "console.error", text: msg.text() });
|
|
5806
|
+
}
|
|
5807
|
+
});
|
|
5808
|
+
page.on("pageerror", (err) => {
|
|
5809
|
+
entries.push({ type: "uncaught", text: err.message, location: err.stack?.split(`
|
|
5810
|
+
`)[1]?.trim() });
|
|
5811
|
+
});
|
|
5812
|
+
try {
|
|
5813
|
+
await page.goto(pageUrl, { timeout: timeoutMs, waitUntil: "domcontentloaded" });
|
|
5814
|
+
await page.waitForTimeout(1500);
|
|
5815
|
+
scannedPages.push(pageUrl);
|
|
5816
|
+
} catch {
|
|
5817
|
+
entries.push({ type: "navigation", text: `Failed to navigate to ${pageUrl}` });
|
|
5818
|
+
scannedPages.push(pageUrl);
|
|
5819
|
+
} finally {
|
|
5820
|
+
await page.close();
|
|
5821
|
+
}
|
|
5822
|
+
for (const entry of entries) {
|
|
5823
|
+
if (isIgnoredConsoleError(entry.text))
|
|
5824
|
+
continue;
|
|
5825
|
+
const severity = classifyConsoleSeverity(entry.text);
|
|
5826
|
+
issues.push({
|
|
5827
|
+
type: "console_error",
|
|
5828
|
+
severity,
|
|
5829
|
+
pageUrl,
|
|
5830
|
+
message: entry.text.slice(0, 500),
|
|
5831
|
+
detail: {
|
|
5832
|
+
errorType: entry.type,
|
|
5833
|
+
location: entry.location ?? null
|
|
5834
|
+
}
|
|
5835
|
+
});
|
|
5836
|
+
}
|
|
5837
|
+
}
|
|
5838
|
+
} finally {
|
|
5839
|
+
await closeBrowser(browser);
|
|
5840
|
+
}
|
|
5841
|
+
return {
|
|
5842
|
+
url,
|
|
5843
|
+
pages: scannedPages,
|
|
5844
|
+
scannedAt: new Date().toISOString(),
|
|
5845
|
+
durationMs: Date.now() - start,
|
|
5846
|
+
issues
|
|
5847
|
+
};
|
|
5848
|
+
}
|
|
5849
|
+
function isIgnoredConsoleError(text) {
|
|
5850
|
+
return IGNORED_PATTERNS.some((p) => p.test(text));
|
|
5851
|
+
}
|
|
5852
|
+
function classifyConsoleSeverity(text) {
|
|
5853
|
+
const lower = text.toLowerCase();
|
|
5854
|
+
if (/uncaught|unhandled|typeerror|referenceerror|cannot read|is not a function|hydrat/i.test(lower))
|
|
5855
|
+
return "critical";
|
|
5856
|
+
if (/error|failed|exception/i.test(lower))
|
|
5857
|
+
return "high";
|
|
5858
|
+
if (/warning|warn|deprecated/i.test(lower))
|
|
5859
|
+
return "medium";
|
|
5860
|
+
return "low";
|
|
5861
|
+
}
|
|
5862
|
+
var IGNORED_PATTERNS;
|
|
5863
|
+
var init_console = __esm(() => {
|
|
5864
|
+
init_browser();
|
|
5865
|
+
IGNORED_PATTERNS = [
|
|
5866
|
+
/Download the React DevTools/i,
|
|
5867
|
+
/\[HMR\]/,
|
|
5868
|
+
/\[vite\]/i,
|
|
5869
|
+
/favicon\.ico/i,
|
|
5870
|
+
/Content Security Policy/i
|
|
5871
|
+
];
|
|
5872
|
+
});
|
|
5873
|
+
|
|
5874
|
+
// src/db/scan-issues.ts
|
|
5875
|
+
var exports_scan_issues = {};
|
|
5876
|
+
__export(exports_scan_issues, {
|
|
5877
|
+
upsertScanIssue: () => upsertScanIssue,
|
|
5878
|
+
setScanIssueTodoTaskId: () => setScanIssueTodoTaskId,
|
|
5879
|
+
resolveScanIssue: () => resolveScanIssue,
|
|
5880
|
+
listScanIssues: () => listScanIssues,
|
|
5881
|
+
getScanIssue: () => getScanIssue,
|
|
5882
|
+
fingerprintIssue: () => fingerprintIssue
|
|
5883
|
+
});
|
|
5884
|
+
function fingerprintIssue(issue) {
|
|
5885
|
+
let pagePattern = issue.pageUrl;
|
|
5886
|
+
try {
|
|
5887
|
+
pagePattern = new URL(issue.pageUrl).pathname;
|
|
5888
|
+
} catch {}
|
|
5889
|
+
const raw = `${issue.type}::${issue.message.slice(0, 200)}::${pagePattern}`;
|
|
5890
|
+
let hash = 5381;
|
|
5891
|
+
for (let i = 0;i < raw.length; i++) {
|
|
5892
|
+
hash = (hash << 5) + hash ^ raw.charCodeAt(i);
|
|
5893
|
+
hash = hash >>> 0;
|
|
5894
|
+
}
|
|
5895
|
+
return `${issue.type}-${hash.toString(16).padStart(8, "0")}`;
|
|
5896
|
+
}
|
|
5897
|
+
function upsertScanIssue(issue, projectId) {
|
|
5898
|
+
const db2 = getDatabase();
|
|
5899
|
+
const fingerprint = fingerprintIssue(issue);
|
|
5900
|
+
const timestamp = now();
|
|
5901
|
+
const existing = db2.query("SELECT * FROM scan_issues WHERE fingerprint = ?").get(fingerprint);
|
|
5902
|
+
if (!existing) {
|
|
5903
|
+
const id = uuid();
|
|
5904
|
+
db2.query(`
|
|
5905
|
+
INSERT INTO scan_issues (id, fingerprint, type, severity, page_url, message, detail, status, occurrence_count, first_seen_at, last_seen_at, project_id)
|
|
5906
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'open', 1, ?, ?, ?)
|
|
5907
|
+
`).run(id, fingerprint, issue.type, issue.severity, issue.pageUrl, issue.message, issue.detail ? JSON.stringify(issue.detail) : null, timestamp, timestamp, projectId ?? null);
|
|
5908
|
+
const row = db2.query("SELECT * FROM scan_issues WHERE id = ?").get(id);
|
|
5909
|
+
return { issue: scanIssueFromRow(row), outcome: "new" };
|
|
5910
|
+
}
|
|
5911
|
+
const wasResolved = existing.status === "resolved";
|
|
5912
|
+
const newStatus = wasResolved ? "regressed" : "open";
|
|
5913
|
+
db2.query(`
|
|
5914
|
+
UPDATE scan_issues
|
|
5915
|
+
SET occurrence_count = occurrence_count + 1,
|
|
5916
|
+
last_seen_at = ?,
|
|
5917
|
+
status = ?,
|
|
5918
|
+
resolved_at = CASE WHEN ? = 'regressed' THEN NULL ELSE resolved_at END,
|
|
5919
|
+
severity = ?,
|
|
5920
|
+
page_url = ?,
|
|
5921
|
+
message = ?,
|
|
5922
|
+
detail = ?
|
|
5923
|
+
WHERE fingerprint = ?
|
|
5924
|
+
`).run(timestamp, newStatus, newStatus, issue.severity, issue.pageUrl, issue.message, issue.detail ? JSON.stringify(issue.detail) : existing.detail, fingerprint);
|
|
5925
|
+
const updated = db2.query("SELECT * FROM scan_issues WHERE fingerprint = ?").get(fingerprint);
|
|
5926
|
+
return {
|
|
5927
|
+
issue: scanIssueFromRow(updated),
|
|
5928
|
+
outcome: wasResolved ? "regressed" : "existing"
|
|
5929
|
+
};
|
|
5930
|
+
}
|
|
5931
|
+
function resolveScanIssue(id) {
|
|
5932
|
+
const db2 = getDatabase();
|
|
5933
|
+
const result = db2.query("UPDATE scan_issues SET status = 'resolved', resolved_at = ? WHERE id = ?").run(now(), id);
|
|
5934
|
+
return result.changes > 0;
|
|
5935
|
+
}
|
|
5936
|
+
function setScanIssueTodoTaskId(id, todoTaskId) {
|
|
5937
|
+
const db2 = getDatabase();
|
|
5938
|
+
db2.query("UPDATE scan_issues SET todo_task_id = ? WHERE id = ?").run(todoTaskId, id);
|
|
5939
|
+
}
|
|
5940
|
+
function listScanIssues(opts = {}) {
|
|
5941
|
+
const db2 = getDatabase();
|
|
5942
|
+
const conditions = ["1=1"];
|
|
5943
|
+
const params = [];
|
|
5944
|
+
if (opts.status) {
|
|
5945
|
+
conditions.push("status = ?");
|
|
5946
|
+
params.push(opts.status);
|
|
5947
|
+
}
|
|
5948
|
+
if (opts.type) {
|
|
5949
|
+
conditions.push("type = ?");
|
|
5950
|
+
params.push(opts.type);
|
|
5951
|
+
}
|
|
5952
|
+
if (opts.projectId) {
|
|
5953
|
+
conditions.push("project_id = ?");
|
|
5954
|
+
params.push(opts.projectId);
|
|
5955
|
+
}
|
|
5956
|
+
const limitClause = opts.limit ? ` LIMIT ${opts.limit}` : "";
|
|
5957
|
+
const rows = db2.query(`SELECT * FROM scan_issues WHERE ${conditions.join(" AND ")} ORDER BY last_seen_at DESC${limitClause}`).all(...params);
|
|
5958
|
+
return rows.map(scanIssueFromRow);
|
|
5959
|
+
}
|
|
5960
|
+
function getScanIssue(id) {
|
|
5961
|
+
const db2 = getDatabase();
|
|
5962
|
+
const row = db2.query("SELECT * FROM scan_issues WHERE id = ?").get(id);
|
|
5963
|
+
return row ? scanIssueFromRow(row) : null;
|
|
5964
|
+
}
|
|
5965
|
+
var init_scan_issues = __esm(() => {
|
|
5966
|
+
init_types();
|
|
5967
|
+
init_database();
|
|
5968
|
+
});
|
|
5969
|
+
|
|
5970
|
+
// src/lib/scanners/network.ts
|
|
5971
|
+
var exports_network = {};
|
|
5972
|
+
__export(exports_network, {
|
|
5973
|
+
scanNetworkErrors: () => scanNetworkErrors
|
|
5974
|
+
});
|
|
5975
|
+
async function scanNetworkErrors(options) {
|
|
5976
|
+
const { url, pages, headed = false, timeoutMs = 15000 } = options;
|
|
5977
|
+
const start = Date.now();
|
|
5978
|
+
const scannedPages = [];
|
|
5979
|
+
const issues = [];
|
|
5980
|
+
const pageUrls = pages?.length ? pages.map((p) => p.startsWith("http") ? p : `${url.replace(/\/$/, "")}${p}`) : [url];
|
|
5981
|
+
const browser = await launchBrowser({ headless: !headed });
|
|
5982
|
+
try {
|
|
5983
|
+
for (const pageUrl of pageUrls) {
|
|
5984
|
+
const page = await getPage(browser, {});
|
|
5985
|
+
const entries = [];
|
|
5986
|
+
page.on("response", (resp) => {
|
|
5987
|
+
const reqUrl = resp.url();
|
|
5988
|
+
const status = resp.status();
|
|
5989
|
+
if (shouldIgnoreUrl(reqUrl))
|
|
5990
|
+
return;
|
|
5991
|
+
if (status >= 400) {
|
|
5992
|
+
entries.push({ url: reqUrl, status, method: resp.request().method(), type: resp.request().resourceType(), failed: false });
|
|
5993
|
+
}
|
|
5994
|
+
});
|
|
5995
|
+
page.on("requestfailed", (req) => {
|
|
5996
|
+
const reqUrl = req.url();
|
|
5997
|
+
if (shouldIgnoreUrl(reqUrl))
|
|
5998
|
+
return;
|
|
5999
|
+
entries.push({
|
|
6000
|
+
url: reqUrl,
|
|
6001
|
+
status: 0,
|
|
6002
|
+
method: req.method(),
|
|
6003
|
+
type: req.resourceType(),
|
|
6004
|
+
failed: true,
|
|
6005
|
+
failureText: req.failure()?.errorText
|
|
6006
|
+
});
|
|
6007
|
+
});
|
|
6008
|
+
try {
|
|
6009
|
+
await page.goto(pageUrl, { timeout: timeoutMs, waitUntil: "domcontentloaded" });
|
|
6010
|
+
await page.waitForTimeout(1000);
|
|
6011
|
+
scannedPages.push(pageUrl);
|
|
6012
|
+
} catch {
|
|
6013
|
+
scannedPages.push(pageUrl);
|
|
6014
|
+
} finally {
|
|
6015
|
+
await page.close();
|
|
6016
|
+
}
|
|
6017
|
+
for (const entry of entries) {
|
|
6018
|
+
const severity = classifyNetworkSeverity(entry);
|
|
6019
|
+
const message = entry.failed ? `Request failed: ${entry.method} ${entry.url} \u2014 ${entry.failureText ?? "unknown"}` : `HTTP ${entry.status}: ${entry.method} ${entry.url}`;
|
|
6020
|
+
issues.push({
|
|
6021
|
+
type: "network_error",
|
|
6022
|
+
severity,
|
|
6023
|
+
pageUrl,
|
|
6024
|
+
message: message.slice(0, 500),
|
|
6025
|
+
detail: {
|
|
6026
|
+
requestUrl: entry.url,
|
|
6027
|
+
status: entry.status,
|
|
6028
|
+
method: entry.method,
|
|
6029
|
+
resourceType: entry.type,
|
|
6030
|
+
failureText: entry.failureText ?? null
|
|
6031
|
+
}
|
|
6032
|
+
});
|
|
6033
|
+
}
|
|
6034
|
+
}
|
|
6035
|
+
} finally {
|
|
6036
|
+
await closeBrowser(browser);
|
|
6037
|
+
}
|
|
6038
|
+
return { url, pages: scannedPages, scannedAt: new Date().toISOString(), durationMs: Date.now() - start, issues };
|
|
6039
|
+
}
|
|
6040
|
+
function shouldIgnoreUrl(reqUrl) {
|
|
6041
|
+
return IGNORE_URL_PATTERNS.some((p) => p.test(reqUrl));
|
|
6042
|
+
}
|
|
6043
|
+
function classifyNetworkSeverity(entry) {
|
|
6044
|
+
if (entry.failed) {
|
|
6045
|
+
if (entry.failureText?.includes("CORS") || entry.failureText?.includes("blocked"))
|
|
6046
|
+
return "critical";
|
|
6047
|
+
return "high";
|
|
6048
|
+
}
|
|
6049
|
+
if (entry.status >= 500)
|
|
6050
|
+
return "critical";
|
|
6051
|
+
if (entry.status === 401 || entry.status === 403)
|
|
6052
|
+
return "high";
|
|
6053
|
+
if (entry.status >= 400)
|
|
6054
|
+
return "medium";
|
|
6055
|
+
return "low";
|
|
6056
|
+
}
|
|
6057
|
+
var IGNORE_URL_PATTERNS;
|
|
6058
|
+
var init_network = __esm(() => {
|
|
6059
|
+
init_browser();
|
|
6060
|
+
IGNORE_URL_PATTERNS = [
|
|
6061
|
+
/favicon\.ico/i,
|
|
6062
|
+
/\.woff2?$/i,
|
|
6063
|
+
/fonts\.googleapis\.com/i,
|
|
6064
|
+
/analytics\.(google|segment)/i,
|
|
6065
|
+
/hotjar\.com/i,
|
|
6066
|
+
/sentry\.io/i
|
|
6067
|
+
];
|
|
6068
|
+
});
|
|
6069
|
+
|
|
6070
|
+
// src/lib/scanners/links.ts
|
|
6071
|
+
var exports_links = {};
|
|
6072
|
+
__export(exports_links, {
|
|
6073
|
+
scanBrokenLinks: () => scanBrokenLinks
|
|
6074
|
+
});
|
|
6075
|
+
async function scanBrokenLinks(options) {
|
|
6076
|
+
const { url, maxPages = 30, headed = false, timeoutMs = 12000 } = options;
|
|
6077
|
+
const start = Date.now();
|
|
6078
|
+
const issues = [];
|
|
6079
|
+
const rootOrigin = (() => {
|
|
6080
|
+
try {
|
|
6081
|
+
return new URL(url).origin;
|
|
6082
|
+
} catch {
|
|
6083
|
+
return url;
|
|
6084
|
+
}
|
|
6085
|
+
})();
|
|
6086
|
+
const visited = new Set;
|
|
6087
|
+
const queue = [{ pageUrl: url, sourceUrl: "" }];
|
|
6088
|
+
const browser = await launchBrowser({ headless: !headed });
|
|
6089
|
+
try {
|
|
6090
|
+
while (queue.length > 0 && visited.size < maxPages) {
|
|
6091
|
+
const { pageUrl, sourceUrl } = queue.shift();
|
|
6092
|
+
const normalised = normaliseUrl(pageUrl);
|
|
6093
|
+
if (visited.has(normalised))
|
|
6094
|
+
continue;
|
|
6095
|
+
visited.add(normalised);
|
|
6096
|
+
const page = await getPage(browser, {});
|
|
6097
|
+
let status = 200;
|
|
6098
|
+
let finalUrl = pageUrl;
|
|
6099
|
+
page.on("response", (resp) => {
|
|
6100
|
+
if (resp.url() === pageUrl || resp.url() === normalised) {
|
|
6101
|
+
status = resp.status();
|
|
6102
|
+
}
|
|
6103
|
+
});
|
|
6104
|
+
let hrefs = [];
|
|
6105
|
+
try {
|
|
6106
|
+
const response = await page.goto(pageUrl, { timeout: timeoutMs, waitUntil: "domcontentloaded" });
|
|
6107
|
+
if (response) {
|
|
6108
|
+
status = response.status();
|
|
6109
|
+
finalUrl = response.url();
|
|
6110
|
+
}
|
|
6111
|
+
hrefs = await page.evaluate(() => Array.from(document.querySelectorAll("a[href]")).map((a) => a.href).filter(Boolean));
|
|
6112
|
+
} catch (err) {
|
|
6113
|
+
status = 0;
|
|
6114
|
+
} finally {
|
|
6115
|
+
await page.close();
|
|
6116
|
+
}
|
|
6117
|
+
if (status === 404) {
|
|
6118
|
+
issues.push({
|
|
6119
|
+
type: "broken_link",
|
|
6120
|
+
severity: "high",
|
|
6121
|
+
pageUrl: sourceUrl || url,
|
|
6122
|
+
message: `404 Not Found: ${pageUrl}`,
|
|
6123
|
+
detail: { brokenUrl: pageUrl, sourceUrl, status }
|
|
6124
|
+
});
|
|
6125
|
+
} else if (status === 0) {
|
|
6126
|
+
issues.push({
|
|
6127
|
+
type: "broken_link",
|
|
6128
|
+
severity: "medium",
|
|
6129
|
+
pageUrl: sourceUrl || url,
|
|
6130
|
+
message: `Navigation failed: ${pageUrl}`,
|
|
6131
|
+
detail: { brokenUrl: pageUrl, sourceUrl, status }
|
|
6132
|
+
});
|
|
6133
|
+
}
|
|
6134
|
+
for (const href of hrefs) {
|
|
6135
|
+
try {
|
|
6136
|
+
const linkUrl = new URL(href);
|
|
6137
|
+
if (linkUrl.origin === rootOrigin) {
|
|
6138
|
+
const clean = `${linkUrl.origin}${linkUrl.pathname}`;
|
|
6139
|
+
if (!visited.has(clean)) {
|
|
6140
|
+
queue.push({ pageUrl: clean, sourceUrl: finalUrl });
|
|
6141
|
+
}
|
|
6142
|
+
}
|
|
6143
|
+
} catch {}
|
|
6144
|
+
}
|
|
6145
|
+
}
|
|
6146
|
+
} finally {
|
|
6147
|
+
await closeBrowser(browser);
|
|
6148
|
+
}
|
|
6149
|
+
return {
|
|
6150
|
+
url,
|
|
6151
|
+
pages: Array.from(visited),
|
|
6152
|
+
scannedAt: new Date().toISOString(),
|
|
6153
|
+
durationMs: Date.now() - start,
|
|
6154
|
+
issues
|
|
6155
|
+
};
|
|
6156
|
+
}
|
|
6157
|
+
function normaliseUrl(rawUrl) {
|
|
6158
|
+
try {
|
|
6159
|
+
const u = new URL(rawUrl);
|
|
6160
|
+
return `${u.origin}${u.pathname}`;
|
|
6161
|
+
} catch {
|
|
6162
|
+
return rawUrl;
|
|
6163
|
+
}
|
|
6164
|
+
}
|
|
6165
|
+
var init_links = __esm(() => {
|
|
6166
|
+
init_browser();
|
|
6167
|
+
});
|
|
6168
|
+
|
|
6169
|
+
// src/lib/scanners/performance.ts
|
|
6170
|
+
var exports_performance = {};
|
|
6171
|
+
__export(exports_performance, {
|
|
6172
|
+
scanPerformance: () => scanPerformance
|
|
6173
|
+
});
|
|
6174
|
+
async function scanPerformance(options) {
|
|
6175
|
+
const { url, pages, headed = false, timeoutMs = 20000, thresholds: customThresholds } = options;
|
|
6176
|
+
const thresholds = { ...DEFAULT_THRESHOLDS, ...customThresholds };
|
|
6177
|
+
const start = Date.now();
|
|
6178
|
+
const scannedPages = [];
|
|
6179
|
+
const issues = [];
|
|
6180
|
+
const pageUrls = pages?.length ? pages.map((p) => p.startsWith("http") ? p : `${url.replace(/\/$/, "")}${p}`) : [url];
|
|
6181
|
+
const browser = await launchBrowser({ headless: !headed });
|
|
6182
|
+
try {
|
|
6183
|
+
for (const pageUrl of pageUrls) {
|
|
6184
|
+
const page = await getPage(browser, {});
|
|
6185
|
+
let transferBytes = 0;
|
|
6186
|
+
let requestCount = 0;
|
|
6187
|
+
page.on("response", async (resp) => {
|
|
6188
|
+
requestCount++;
|
|
6189
|
+
try {
|
|
6190
|
+
const headers = resp.headers();
|
|
6191
|
+
const contentLength = parseInt(headers["content-length"] ?? "0", 10);
|
|
6192
|
+
if (!isNaN(contentLength))
|
|
6193
|
+
transferBytes += contentLength;
|
|
6194
|
+
} catch {}
|
|
6195
|
+
});
|
|
6196
|
+
try {
|
|
6197
|
+
await page.goto(pageUrl, { timeout: timeoutMs, waitUntil: "load" });
|
|
6198
|
+
scannedPages.push(pageUrl);
|
|
6199
|
+
const metrics = await page.evaluate((pUrl) => {
|
|
6200
|
+
const nav = performance.getEntriesByType("navigation")[0];
|
|
6201
|
+
const paintEntries = performance.getEntriesByType("paint");
|
|
6202
|
+
const fpEntry = paintEntries.find((e) => e.name === "first-paint");
|
|
6203
|
+
const fcpEntry = paintEntries.find((e) => e.name === "first-contentful-paint");
|
|
6204
|
+
let lcpMs = null;
|
|
6205
|
+
try {
|
|
6206
|
+
const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
|
|
6207
|
+
if (lcpEntries.length > 0) {
|
|
6208
|
+
lcpMs = lcpEntries[lcpEntries.length - 1].startTime;
|
|
6209
|
+
}
|
|
6210
|
+
} catch {}
|
|
6211
|
+
return {
|
|
6212
|
+
pageUrl: pUrl,
|
|
6213
|
+
loadTimeMs: nav ? Math.round(nav.loadEventEnd - nav.startTime) : 0,
|
|
6214
|
+
domContentLoadedMs: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : 0,
|
|
6215
|
+
firstPaintMs: fpEntry ? Math.round(fpEntry.startTime) : fcpEntry ? Math.round(fcpEntry.startTime) : null,
|
|
6216
|
+
lcpMs,
|
|
6217
|
+
requestCount: 0,
|
|
6218
|
+
transferBytes: 0
|
|
6219
|
+
};
|
|
6220
|
+
}, pageUrl);
|
|
6221
|
+
metrics.requestCount = requestCount;
|
|
6222
|
+
metrics.transferBytes = transferBytes;
|
|
6223
|
+
if (metrics.loadTimeMs > thresholds.loadTimeMs) {
|
|
6224
|
+
issues.push({
|
|
6225
|
+
type: "performance",
|
|
6226
|
+
severity: metrics.loadTimeMs > thresholds.loadTimeMs * 2 ? "critical" : "high",
|
|
6227
|
+
pageUrl,
|
|
6228
|
+
message: `Slow page load: ${metrics.loadTimeMs}ms (threshold: ${thresholds.loadTimeMs}ms)`,
|
|
6229
|
+
detail: { ...metrics }
|
|
6230
|
+
});
|
|
6231
|
+
}
|
|
6232
|
+
if (metrics.domContentLoadedMs > thresholds.domContentLoadedMs) {
|
|
6233
|
+
issues.push({
|
|
6234
|
+
type: "performance",
|
|
6235
|
+
severity: "medium",
|
|
6236
|
+
pageUrl,
|
|
6237
|
+
message: `Slow DOMContentLoaded: ${metrics.domContentLoadedMs}ms (threshold: ${thresholds.domContentLoadedMs}ms)`,
|
|
6238
|
+
detail: { ...metrics }
|
|
6239
|
+
});
|
|
6240
|
+
}
|
|
6241
|
+
if (metrics.lcpMs !== null && metrics.lcpMs > thresholds.lcpMs) {
|
|
6242
|
+
issues.push({
|
|
6243
|
+
type: "performance",
|
|
6244
|
+
severity: metrics.lcpMs > thresholds.lcpMs * 2 ? "critical" : "high",
|
|
6245
|
+
pageUrl,
|
|
6246
|
+
message: `Poor LCP: ${Math.round(metrics.lcpMs)}ms (threshold: ${thresholds.lcpMs}ms)`,
|
|
6247
|
+
detail: { ...metrics }
|
|
6248
|
+
});
|
|
6249
|
+
}
|
|
6250
|
+
} catch (err) {
|
|
6251
|
+
issues.push({
|
|
6252
|
+
type: "performance",
|
|
6253
|
+
severity: "medium",
|
|
6254
|
+
pageUrl,
|
|
6255
|
+
message: `Performance scan failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
6256
|
+
detail: {}
|
|
6257
|
+
});
|
|
6258
|
+
} finally {
|
|
6259
|
+
await page.close();
|
|
6260
|
+
}
|
|
6261
|
+
}
|
|
6262
|
+
} finally {
|
|
6263
|
+
await closeBrowser(browser);
|
|
6264
|
+
}
|
|
6265
|
+
return { url, pages: scannedPages, scannedAt: new Date().toISOString(), durationMs: Date.now() - start, issues };
|
|
6266
|
+
}
|
|
6267
|
+
var DEFAULT_THRESHOLDS;
|
|
6268
|
+
var init_performance = __esm(() => {
|
|
6269
|
+
init_browser();
|
|
6270
|
+
DEFAULT_THRESHOLDS = {
|
|
6271
|
+
loadTimeMs: 5000,
|
|
6272
|
+
domContentLoadedMs: 3000,
|
|
6273
|
+
lcpMs: 2500
|
|
6274
|
+
};
|
|
6275
|
+
});
|
|
6276
|
+
|
|
6277
|
+
// src/lib/health-scan.ts
|
|
6278
|
+
var exports_health_scan = {};
|
|
6279
|
+
__export(exports_health_scan, {
|
|
6280
|
+
runHealthScan: () => runHealthScan
|
|
6281
|
+
});
|
|
6282
|
+
async function runHealthScan(options) {
|
|
6283
|
+
const {
|
|
6284
|
+
url,
|
|
6285
|
+
pages,
|
|
6286
|
+
projectId,
|
|
6287
|
+
headed = false,
|
|
6288
|
+
timeoutMs = 15000,
|
|
6289
|
+
scanners = ["console", "network", "links"],
|
|
6290
|
+
maxPages = 20
|
|
6291
|
+
} = options;
|
|
6292
|
+
const start = Date.now();
|
|
6293
|
+
const results = [];
|
|
6294
|
+
if (scanners.includes("console")) {
|
|
6295
|
+
results.push(await scanConsoleErrors({ url, pages, headed, timeoutMs }));
|
|
6296
|
+
}
|
|
6297
|
+
if (scanners.includes("network")) {
|
|
6298
|
+
results.push(await scanNetworkErrors({ url, pages, headed, timeoutMs }));
|
|
6299
|
+
}
|
|
6300
|
+
if (scanners.includes("links")) {
|
|
6301
|
+
results.push(await scanBrokenLinks({ url, maxPages, headed, timeoutMs }));
|
|
6302
|
+
}
|
|
6303
|
+
if (scanners.includes("performance")) {
|
|
6304
|
+
results.push(await scanPerformance({ url, pages, headed, timeoutMs }));
|
|
6305
|
+
}
|
|
6306
|
+
const allIssues = results.flatMap((r) => r.issues);
|
|
6307
|
+
let newCount = 0;
|
|
6308
|
+
let regressedCount = 0;
|
|
6309
|
+
let existingCount = 0;
|
|
6310
|
+
const newAndRegressed = [];
|
|
6311
|
+
for (const issue of allIssues) {
|
|
6312
|
+
const { issue: persisted, outcome } = upsertScanIssue(issue, projectId);
|
|
6313
|
+
if (outcome === "new") {
|
|
6314
|
+
newCount++;
|
|
6315
|
+
newAndRegressed.push({ issue, persistedId: persisted.id });
|
|
6316
|
+
} else if (outcome === "regressed") {
|
|
6317
|
+
regressedCount++;
|
|
6318
|
+
newAndRegressed.push({ issue, persistedId: persisted.id });
|
|
6319
|
+
} else
|
|
6320
|
+
existingCount++;
|
|
6321
|
+
}
|
|
6322
|
+
await createTodoTasksForIssues(newAndRegressed, url, projectId);
|
|
6323
|
+
await notifyHealthScan(url, { new: newCount, regressed: regressedCount, existing: existingCount, total: allIssues.length });
|
|
6324
|
+
return {
|
|
6325
|
+
url,
|
|
6326
|
+
scannedAt: new Date().toISOString(),
|
|
6327
|
+
durationMs: Date.now() - start,
|
|
6328
|
+
totalIssues: allIssues.length,
|
|
6329
|
+
newIssues: newCount,
|
|
6330
|
+
regressedIssues: regressedCount,
|
|
6331
|
+
existingIssues: existingCount,
|
|
6332
|
+
results
|
|
6333
|
+
};
|
|
6334
|
+
}
|
|
6335
|
+
async function createTodoTasksForIssues(items, url, _projectId) {
|
|
6336
|
+
const todosProjectId = process.env["TESTERS_TODOS_PROJECT_ID"];
|
|
6337
|
+
if (!todosProjectId || items.length === 0)
|
|
6338
|
+
return;
|
|
6339
|
+
let db2 = null;
|
|
6340
|
+
try {
|
|
6341
|
+
db2 = connectToTodos();
|
|
6342
|
+
} catch {
|
|
6343
|
+
return;
|
|
6344
|
+
}
|
|
6345
|
+
try {
|
|
6346
|
+
for (const { issue, persistedId } of items) {
|
|
6347
|
+
const title = `BUG: [scan] ${issue.type.replace(/_/g, " ")}: ${issue.message.slice(0, 80)}`;
|
|
6348
|
+
const id = crypto.randomUUID();
|
|
6349
|
+
const now2 = new Date().toISOString();
|
|
6350
|
+
const description = [
|
|
6351
|
+
`Health scan detected a ${issue.type.replace(/_/g, " ")} issue.`,
|
|
6352
|
+
``,
|
|
6353
|
+
`**URL:** ${url}`,
|
|
6354
|
+
`**Page:** ${issue.pageUrl}`,
|
|
6355
|
+
`**Severity:** ${issue.severity}`,
|
|
6356
|
+
`**Message:** ${issue.message}`,
|
|
6357
|
+
issue.detail ? `**Detail:**
|
|
6358
|
+
\`\`\`json
|
|
6359
|
+
${JSON.stringify(issue.detail, null, 2)}
|
|
6360
|
+
\`\`\`` : null
|
|
6361
|
+
].filter(Boolean).join(`
|
|
6362
|
+
`);
|
|
6363
|
+
try {
|
|
6364
|
+
db2.query(`
|
|
6365
|
+
INSERT INTO tasks (id, short_id, title, description, status, priority, tags, project_id, version, created_at, updated_at)
|
|
6366
|
+
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, 1, ?, ?)
|
|
6367
|
+
`).run(id, `SCAN-${id.slice(0, 6)}`, title, description, issue.severity, JSON.stringify(["bug", "scan", issue.type, "auto-created"]), todosProjectId, now2, now2);
|
|
6368
|
+
setScanIssueTodoTaskId(persistedId, id);
|
|
6369
|
+
} catch {}
|
|
6370
|
+
}
|
|
6371
|
+
} finally {
|
|
6372
|
+
db2.close();
|
|
6373
|
+
}
|
|
6374
|
+
}
|
|
6375
|
+
async function notifyHealthScan(url, counts) {
|
|
6376
|
+
const baseUrl = process.env["TESTERS_CONVERSATIONS_URL"];
|
|
6377
|
+
const space = process.env["TESTERS_CONVERSATIONS_SPACE"];
|
|
6378
|
+
if (!baseUrl || !space)
|
|
6379
|
+
return;
|
|
6380
|
+
if (counts.new === 0 && counts.regressed === 0)
|
|
6381
|
+
return;
|
|
6382
|
+
const icon = counts.new + counts.regressed > 0 ? "\uD83D\uDEA8" : "\u2705";
|
|
6383
|
+
const message = [
|
|
6384
|
+
`${icon} **Health scan** \u2014 ${url}`,
|
|
6385
|
+
``,
|
|
6386
|
+
`**New issues:** ${counts.new}`,
|
|
6387
|
+
`**Regressed:** ${counts.regressed}`,
|
|
6388
|
+
`**Known (skipped):** ${counts.existing}`,
|
|
6389
|
+
`**Total found:** ${counts.total}`
|
|
6390
|
+
].join(`
|
|
6391
|
+
`);
|
|
6392
|
+
try {
|
|
6393
|
+
await fetch(`${baseUrl.replace(/\/$/, "")}/api/spaces/${encodeURIComponent(space)}/messages`, {
|
|
6394
|
+
method: "POST",
|
|
6395
|
+
headers: { "Content-Type": "application/json" },
|
|
6396
|
+
body: JSON.stringify({ content: message, from: "testers-health-scan" })
|
|
6397
|
+
});
|
|
6398
|
+
} catch {}
|
|
6399
|
+
}
|
|
6400
|
+
var init_health_scan = __esm(() => {
|
|
6401
|
+
init_console();
|
|
6402
|
+
init_network();
|
|
6403
|
+
init_links();
|
|
6404
|
+
init_performance();
|
|
6405
|
+
init_scan_issues();
|
|
6406
|
+
init_todos_connector();
|
|
6407
|
+
});
|
|
6408
|
+
|
|
5746
6409
|
// node_modules/commander/esm.mjs
|
|
5747
6410
|
var import__ = __toESM(require_commander(), 1);
|
|
5748
6411
|
var {
|
|
@@ -5764,7 +6427,7 @@ import chalk6 from "chalk";
|
|
|
5764
6427
|
// package.json
|
|
5765
6428
|
var package_default = {
|
|
5766
6429
|
name: "@hasna/testers",
|
|
5767
|
-
version: "0.0.
|
|
6430
|
+
version: "0.0.14",
|
|
5768
6431
|
description: "AI-powered QA testing CLI \u2014 spawns cheap AI agents to test web apps with headless browsers",
|
|
5769
6432
|
type: "module",
|
|
5770
6433
|
main: "dist/index.js",
|
|
@@ -9431,6 +10094,165 @@ agentCmd.command("list").description("List all registered agents").action(() =>
|
|
|
9431
10094
|
process.exit(1);
|
|
9432
10095
|
}
|
|
9433
10096
|
});
|
|
10097
|
+
var scanCmd = program2.command("scan").description("Scan a running app for runtime issues");
|
|
10098
|
+
var SCAN_COMMON_OPTIONS = (cmd) => cmd.option("--project <id>", "Project ID for issue tracking").option("--headed", "Run browser in headed mode", false).option("--timeout <ms>", "Navigation timeout per page in ms", "15000").option("--json", "Output results as JSON", false);
|
|
10099
|
+
SCAN_COMMON_OPTIONS(scanCmd.command("console <url>").description("Collect JS/React console errors and uncaught exceptions").option("-p, --page <path>", "Page path to visit (repeatable)", (v, acc) => {
|
|
10100
|
+
acc.push(v);
|
|
10101
|
+
return acc;
|
|
10102
|
+
}, [])).action(async (url, opts) => {
|
|
10103
|
+
try {
|
|
10104
|
+
const { scanConsoleErrors: scanConsoleErrors2 } = await Promise.resolve().then(() => (init_console(), exports_console));
|
|
10105
|
+
const { upsertScanIssue: upsertScanIssue2 } = await Promise.resolve().then(() => (init_scan_issues(), exports_scan_issues));
|
|
10106
|
+
const result = await scanConsoleErrors2({ url, pages: opts.page, headed: opts.headed, timeoutMs: parseInt(opts.timeout) });
|
|
10107
|
+
result.issues.forEach((i) => upsertScanIssue2(i, opts.project));
|
|
10108
|
+
printScanResult(result, opts.json);
|
|
10109
|
+
} catch (e) {
|
|
10110
|
+
logError(chalk6.red(e instanceof Error ? e.message : String(e)));
|
|
10111
|
+
process.exit(1);
|
|
10112
|
+
}
|
|
10113
|
+
});
|
|
10114
|
+
SCAN_COMMON_OPTIONS(scanCmd.command("network <url>").description("Detect failed API calls, 5xx, 404s, CORS errors").option("-p, --page <path>", "Page path to visit (repeatable)", (v, acc) => {
|
|
10115
|
+
acc.push(v);
|
|
10116
|
+
return acc;
|
|
10117
|
+
}, [])).action(async (url, opts) => {
|
|
10118
|
+
try {
|
|
10119
|
+
const { scanNetworkErrors: scanNetworkErrors2 } = await Promise.resolve().then(() => (init_network(), exports_network));
|
|
10120
|
+
const { upsertScanIssue: upsertScanIssue2 } = await Promise.resolve().then(() => (init_scan_issues(), exports_scan_issues));
|
|
10121
|
+
const result = await scanNetworkErrors2({ url, pages: opts.page, headed: opts.headed, timeoutMs: parseInt(opts.timeout) });
|
|
10122
|
+
result.issues.forEach((i) => upsertScanIssue2(i, opts.project));
|
|
10123
|
+
printScanResult(result, opts.json);
|
|
10124
|
+
} catch (e) {
|
|
10125
|
+
logError(chalk6.red(e instanceof Error ? e.message : String(e)));
|
|
10126
|
+
process.exit(1);
|
|
10127
|
+
}
|
|
10128
|
+
});
|
|
10129
|
+
SCAN_COMMON_OPTIONS(scanCmd.command("links <url>").description("Crawl app and find broken links / 404s").option("--max-pages <n>", "Max pages to crawl", "30")).action(async (url, opts) => {
|
|
10130
|
+
try {
|
|
10131
|
+
const { scanBrokenLinks: scanBrokenLinks2 } = await Promise.resolve().then(() => (init_links(), exports_links));
|
|
10132
|
+
const { upsertScanIssue: upsertScanIssue2 } = await Promise.resolve().then(() => (init_scan_issues(), exports_scan_issues));
|
|
10133
|
+
const result = await scanBrokenLinks2({ url, maxPages: parseInt(opts.maxPages), headed: opts.headed, timeoutMs: parseInt(opts.timeout) });
|
|
10134
|
+
result.issues.forEach((i) => upsertScanIssue2(i, opts.project));
|
|
10135
|
+
printScanResult(result, opts.json);
|
|
10136
|
+
} catch (e) {
|
|
10137
|
+
logError(chalk6.red(e instanceof Error ? e.message : String(e)));
|
|
10138
|
+
process.exit(1);
|
|
10139
|
+
}
|
|
10140
|
+
});
|
|
10141
|
+
SCAN_COMMON_OPTIONS(scanCmd.command("perf <url>").description("Measure page load time, LCP, DOMContentLoaded").option("-p, --page <path>", "Page path to visit (repeatable)", (v, acc) => {
|
|
10142
|
+
acc.push(v);
|
|
10143
|
+
return acc;
|
|
10144
|
+
}, []).option("--lcp-threshold <ms>", "LCP threshold in ms (default 2500)", "2500").option("--load-threshold <ms>", "Load time threshold in ms (default 5000)", "5000")).action(async (url, opts) => {
|
|
10145
|
+
try {
|
|
10146
|
+
const { scanPerformance: scanPerformance2 } = await Promise.resolve().then(() => (init_performance(), exports_performance));
|
|
10147
|
+
const { upsertScanIssue: upsertScanIssue2 } = await Promise.resolve().then(() => (init_scan_issues(), exports_scan_issues));
|
|
10148
|
+
const result = await scanPerformance2({
|
|
10149
|
+
url,
|
|
10150
|
+
pages: opts.page,
|
|
10151
|
+
headed: opts.headed,
|
|
10152
|
+
timeoutMs: parseInt(opts.timeout),
|
|
10153
|
+
thresholds: { lcpMs: parseInt(opts.lcpThreshold), loadTimeMs: parseInt(opts.loadThreshold) }
|
|
10154
|
+
});
|
|
10155
|
+
result.issues.forEach((i) => upsertScanIssue2(i, opts.project));
|
|
10156
|
+
printScanResult(result, opts.json);
|
|
10157
|
+
} catch (e) {
|
|
10158
|
+
logError(chalk6.red(e instanceof Error ? e.message : String(e)));
|
|
10159
|
+
process.exit(1);
|
|
10160
|
+
}
|
|
10161
|
+
});
|
|
10162
|
+
SCAN_COMMON_OPTIONS(scanCmd.command("all <url>").description("Run all scanners: console, network, links, performance").option("-p, --page <path>", "Page path to visit (repeatable)", (v, acc) => {
|
|
10163
|
+
acc.push(v);
|
|
10164
|
+
return acc;
|
|
10165
|
+
}, []).option("--max-pages <n>", "Max pages for link crawl", "20").option("--skip <scanner>", "Skip a scanner: console|network|links|perf (repeatable)", (v, acc) => {
|
|
10166
|
+
acc.push(v);
|
|
10167
|
+
return acc;
|
|
10168
|
+
}, [])).action(async (url, opts) => {
|
|
10169
|
+
try {
|
|
10170
|
+
const { runHealthScan: runHealthScan2 } = await Promise.resolve().then(() => (init_health_scan(), exports_health_scan));
|
|
10171
|
+
const skip = new Set(opts.skip);
|
|
10172
|
+
const scanners = ["console", "network", "links", "performance"].filter((s) => !skip.has(s) && !skip.has(s === "performance" ? "perf" : s));
|
|
10173
|
+
log(chalk6.bold(` Health scan: ${url}`));
|
|
10174
|
+
log(chalk6.dim(` Scanners: ${scanners.join(", ")}`));
|
|
10175
|
+
log("");
|
|
10176
|
+
const summary = await runHealthScan2({
|
|
10177
|
+
url,
|
|
10178
|
+
pages: opts.page,
|
|
10179
|
+
projectId: opts.project,
|
|
10180
|
+
scanners,
|
|
10181
|
+
maxPages: parseInt(opts.maxPages),
|
|
10182
|
+
headed: opts.headed,
|
|
10183
|
+
timeoutMs: parseInt(opts.timeout)
|
|
10184
|
+
});
|
|
10185
|
+
if (opts.json) {
|
|
10186
|
+
log(JSON.stringify(summary, null, 2));
|
|
10187
|
+
return;
|
|
10188
|
+
}
|
|
10189
|
+
log(chalk6.bold(" Results"));
|
|
10190
|
+
log(chalk6.dim(` ${"\u2500".repeat(50)}`));
|
|
10191
|
+
log(` Total issues: ${chalk6.bold(String(summary.totalIssues))}`);
|
|
10192
|
+
log(` New issues: ${summary.newIssues > 0 ? chalk6.red(String(summary.newIssues)) : chalk6.green("0")}`);
|
|
10193
|
+
log(` Regressed: ${summary.regressedIssues > 0 ? chalk6.yellow(String(summary.regressedIssues)) : chalk6.green("0")}`);
|
|
10194
|
+
log(` Known (skipped): ${chalk6.dim(String(summary.existingIssues))}`);
|
|
10195
|
+
log(` Duration: ${(summary.durationMs / 1000).toFixed(1)}s`);
|
|
10196
|
+
log("");
|
|
10197
|
+
for (const result of summary.results) {
|
|
10198
|
+
if (result.issues.length > 0)
|
|
10199
|
+
printScanResult(result, false);
|
|
10200
|
+
}
|
|
10201
|
+
if (summary.newIssues + summary.regressedIssues > 0)
|
|
10202
|
+
process.exit(1);
|
|
10203
|
+
} catch (e) {
|
|
10204
|
+
logError(chalk6.red(e instanceof Error ? e.message : String(e)));
|
|
10205
|
+
process.exit(1);
|
|
10206
|
+
}
|
|
10207
|
+
});
|
|
10208
|
+
scanCmd.command("issues").description("List tracked scan issues").option("--status <status>", "Filter by status: open|resolved|regressed").option("--type <type>", "Filter by type: console_error|network_error|broken_link|performance").option("--project <id>", "Filter by project ID").option("--limit <n>", "Max results", "50").action((opts) => {
|
|
10209
|
+
try {
|
|
10210
|
+
const { listScanIssues: listScanIssues2 } = (init_scan_issues(), __toCommonJS(exports_scan_issues));
|
|
10211
|
+
const issues = listScanIssues2({ status: opts.status, type: opts.type, projectId: opts.project, limit: parseInt(opts.limit) });
|
|
10212
|
+
if (issues.length === 0) {
|
|
10213
|
+
log(chalk6.dim("No scan issues found."));
|
|
10214
|
+
return;
|
|
10215
|
+
}
|
|
10216
|
+
for (const i of issues) {
|
|
10217
|
+
const statusColor = i.status === "open" ? chalk6.red : i.status === "regressed" ? chalk6.yellow : chalk6.green;
|
|
10218
|
+
log(` ${statusColor(i.status.padEnd(10))} ${chalk6.cyan(i.type.padEnd(16))} ${chalk6.bold(i.severity.padEnd(8))} ${i.message.slice(0, 60)} ${chalk6.dim(i.pageUrl)}`);
|
|
10219
|
+
}
|
|
10220
|
+
} catch (e) {
|
|
10221
|
+
logError(chalk6.red(e instanceof Error ? e.message : String(e)));
|
|
10222
|
+
process.exit(1);
|
|
10223
|
+
}
|
|
10224
|
+
});
|
|
10225
|
+
scanCmd.command("resolve <id>").description("Mark a scan issue as resolved").action((id) => {
|
|
10226
|
+
try {
|
|
10227
|
+
const { resolveScanIssue: resolveScanIssue2 } = (init_scan_issues(), __toCommonJS(exports_scan_issues));
|
|
10228
|
+
const ok = resolveScanIssue2(id);
|
|
10229
|
+
if (!ok) {
|
|
10230
|
+
logError(chalk6.red(`Scan issue not found: ${id}`));
|
|
10231
|
+
process.exit(1);
|
|
10232
|
+
}
|
|
10233
|
+
log(chalk6.green(`Resolved scan issue: ${id}`));
|
|
10234
|
+
} catch (e) {
|
|
10235
|
+
logError(chalk6.red(e instanceof Error ? e.message : String(e)));
|
|
10236
|
+
process.exit(1);
|
|
10237
|
+
}
|
|
10238
|
+
});
|
|
10239
|
+
function printScanResult(result, asJson) {
|
|
10240
|
+
if (asJson) {
|
|
10241
|
+
log(JSON.stringify(result, null, 2));
|
|
10242
|
+
return;
|
|
10243
|
+
}
|
|
10244
|
+
log(chalk6.bold(` ${result.url}`) + chalk6.dim(` \u2014 ${result.pages.length} page(s) scanned in ${(result.durationMs / 1000).toFixed(1)}s`));
|
|
10245
|
+
if (result.issues.length === 0) {
|
|
10246
|
+
log(chalk6.green(" \u2713 No issues found"));
|
|
10247
|
+
} else {
|
|
10248
|
+
for (const issue of result.issues) {
|
|
10249
|
+
const sev = issue.severity === "critical" ? chalk6.bgRed.white(` ${issue.severity} `) : issue.severity === "high" ? chalk6.red(issue.severity) : issue.severity === "medium" ? chalk6.yellow(issue.severity) : chalk6.dim(issue.severity);
|
|
10250
|
+
log(` ${sev} ${issue.message.slice(0, 80)}`);
|
|
10251
|
+
log(chalk6.dim(` ${issue.pageUrl}`));
|
|
10252
|
+
}
|
|
10253
|
+
}
|
|
10254
|
+
log("");
|
|
10255
|
+
}
|
|
9434
10256
|
program2.command("doctor").description("Check system setup and configuration").action(async () => {
|
|
9435
10257
|
let allPassed = true;
|
|
9436
10258
|
const hasApiKey = Boolean(process.env["ANTHROPIC_API_KEY"]);
|