@detiq/crawler 1.1.10 → 1.1.12

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/job-queue.js CHANGED
@@ -30,13 +30,14 @@ async function notifyAdmins(tenantId, data) {
30
30
  }
31
31
  }
32
32
  import { AppBrain, buildArtifactKey, getTenantStorageConfig, uploadScreenshotToTenantStorage, uploadFileToTenantStorage, notifyRunCompletion, recordLLMCall } from "@detiq/app-brain";
33
- import { generateTaxonomySkillFiles, groupScreensWithAI, generateExecutionPlan, refreshTestCasesSkillFile, refreshTestDataSkillFile, refreshTestScriptSkillFile, refreshRequirementsCoverageSkillFile, detectFieldsFromHtml, mergeIntoCatalog } from "@detiq/agents";
34
- import { crawlProject } from "./crawler.js";
33
+ import { generateTaxonomySkillFiles, groupScreensWithAI, generateExecutionPlan, refreshTestCasesSkillFile, refreshTestDataSkillFile, refreshTestScriptSkillFile, refreshRequirementsCoverageSkillFile, refreshTestExecutionHistorySkillFile, refreshVisualRegressionHistorySkillFile, refreshLocatorStrategyWithHealingData, refreshFieldCatalogSkillFile, detectFieldsFromHtml, mergeIntoCatalog } from "@detiq/agents";
34
+ import { crawlProject, domFallbackElements } from "./crawler.js";
35
35
  import { httpCrawl } from "./http-engine.js";
36
36
  import { hashContent, normalizeText } from "./incremental-diff.js";
37
37
  import { discoverProjectUrls } from "./sitemap.js";
38
- import { executeTestPlan } from "./test-executor.js";
38
+ import { executeExecutionStep, executeTestPlan } from "./test-executor.js";
39
39
  import { runNativeCrawl } from "./native-crawler.js";
40
+ import { elementsToPlaywrightLocators } from "./locator-extractor.js";
40
41
  import { jira, confluence, figma } from "@detiq/connectors";
41
42
  import { decrypt, config as platformConfig, dispatchWebhook, isFeatureEnabled, interpolatePlaceholders } from "@detiq/core";
42
43
  import { createHmac } from "node:crypto";
@@ -76,7 +77,7 @@ function makeJobName(type) {
76
77
  const now = /* @__PURE__ */ new Date();
77
78
  const date = now.toISOString().slice(0, 10);
78
79
  const ts = now.getTime();
79
- const label = type === "CRAWL" ? "Crawl" : type === "NATIVE_CRAWL" ? "NativeCrawl" : type === "GENERATE_TESTS" ? "GenerateTests" : type === "VISUAL_REGRESSION" ? "VisualRegression" : type === "AI_EDIT" ? "AIEdit" : type === "API_TESTS" ? "ApiTests" : type === "SECURITY_TESTS" ? "SecurityTests" : type === "INTEGRATION_TESTS" ? "IntegrationTests" : type === "GENERATE_TESTS_FROM_REQS" ? "GenerateTestsFromReqs" : type === "GENERATE_DATA" ? "GenerateData" : type === "GENERATE_SCRIPT" ? "GenerateScript" : type === "GENERATE_TEST_PLAN" ? "GenerateTestPlan" : type === "IMPORT_TESTS" ? "ImportTests" : type === "GENERATE_SCREEN_TESTS" ? "GenerateScreenTests" : type === "BATCH_GENERATE_ALIGNED" ? "BatchGenerateAligned" : type === "CREATE_PR" ? "CreatePR" : type === "NEW_PAGES_PIPELINE" ? "NewPagesPipeline" : type.charAt(0) + type.slice(1).toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
80
+ const label = type === "CRAWL" ? "Crawl" : type === "CAPTURE_STEPS" ? "CaptureSteps" : type === "NATIVE_CRAWL" ? "NativeCrawl" : type === "GENERATE_TESTS" ? "GenerateTests" : type === "VISUAL_REGRESSION" ? "VisualRegression" : type === "AI_EDIT" ? "AIEdit" : type === "API_TESTS" ? "ApiTests" : type === "SECURITY_TESTS" ? "SecurityTests" : type === "INTEGRATION_TESTS" ? "IntegrationTests" : type === "GENERATE_TESTS_FROM_REQS" ? "GenerateTestsFromReqs" : type === "GENERATE_DATA" ? "GenerateData" : type === "GENERATE_SCRIPT" ? "GenerateScript" : type === "GENERATE_TEST_PLAN" ? "GenerateTestPlan" : type === "IMPORT_TESTS" ? "ImportTests" : type === "GENERATE_SCREEN_TESTS" ? "GenerateScreenTests" : type === "BATCH_GENERATE_ALIGNED" ? "BatchGenerateAligned" : type === "CREATE_PR" ? "CreatePR" : type === "NEW_PAGES_PIPELINE" ? "NewPagesPipeline" : type.charAt(0) + type.slice(1).toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
80
81
  return `${label}_${date}_${ts}`;
81
82
  }
82
83
  const runningControllers = /* @__PURE__ */ new Map();
@@ -548,6 +549,7 @@ async function processJob(jobId, config, opts) {
548
549
  }
549
550
  if (fieldDetectAdded > 0 || detectLimit > 0) {
550
551
  logger.info({ screens: detectLimit, added: fieldDetectAdded, projectId }, "[field-detect] catalog populated from crawl");
552
+ refreshFieldCatalogSkillFile(tenantId, projectId).catch(console.error);
551
553
  }
552
554
  } catch (fdErr) {
553
555
  logger.warn({ err: fdErr }, "[field-detect] post-crawl field detection failed");
@@ -1234,6 +1236,7 @@ async function processRunTestSuiteJob(jobId, config) {
1234
1236
  { passed, failed, total: testCases.length },
1235
1237
  runResults
1236
1238
  ).catch(console.error);
1239
+ refreshTestExecutionHistorySkillFile(tenantId, projectId).catch(console.error);
1237
1240
  } catch (err) {
1238
1241
  const msg = String(err?.message ?? err).slice(0, 500);
1239
1242
  logger.error({ jobId, testRunId, err: msg }, "processRunTestSuiteJob failed");
@@ -1273,7 +1276,11 @@ async function processGenerateSkillFilesJob(jobId, config) {
1273
1276
  refreshTestCasesSkillFile(tenantId, projectId),
1274
1277
  refreshTestDataSkillFile(tenantId, projectId),
1275
1278
  refreshTestScriptSkillFile(tenantId, projectId),
1276
- refreshRequirementsCoverageSkillFile(tenantId, projectId)
1279
+ refreshRequirementsCoverageSkillFile(tenantId, projectId),
1280
+ refreshTestExecutionHistorySkillFile(tenantId, projectId),
1281
+ refreshVisualRegressionHistorySkillFile(tenantId, projectId),
1282
+ refreshLocatorStrategyWithHealingData(tenantId, projectId),
1283
+ refreshFieldCatalogSkillFile(tenantId, projectId)
1277
1284
  ]);
1278
1285
  await withTenant(
1279
1286
  tenantId,
@@ -1803,8 +1810,241 @@ async function processGenerateScreenTestsJob(jobId, config) {
1803
1810
  });
1804
1811
  }
1805
1812
  }
1813
+ function captureStepText(step) {
1814
+ if (typeof step === "string") return step;
1815
+ if (!step || typeof step !== "object") return "";
1816
+ const obj = step;
1817
+ return [obj.action, obj.text, obj.label, obj.data, obj.expected].filter((v) => typeof v === "string" && v.trim().length > 0).join(" -> ");
1818
+ }
1819
+ function firstQuoted(text) {
1820
+ return (text.match(/"([^"]+)"/)?.[1] ?? text.match(/'([^']+)'/)?.[1])?.trim();
1821
+ }
1822
+ function fallbackExecutionPlan(rawSteps) {
1823
+ const plan = [];
1824
+ for (const raw of rawSteps) {
1825
+ const text = captureStepText(raw).trim();
1826
+ if (!text) continue;
1827
+ const stepNumber = plan.length + 1;
1828
+ const url = text.match(/https?:\/\/[^\s"']+/)?.[0];
1829
+ if (/^navigate\b|^go to\b|^open\b/i.test(text) && url) {
1830
+ plan.push({ stepNumber, description: text, action: "navigate", url });
1831
+ continue;
1832
+ }
1833
+ const urlContains = text.match(/verify url contains:\s*["']?([^"']+)["']?/i);
1834
+ if (urlContains) {
1835
+ plan.push({ stepNumber, description: text, action: "assert_url", value: urlContains[1].trim() });
1836
+ continue;
1837
+ }
1838
+ const inputMatch = text.match(/(?:enter|type|fill)\s+["']([^"']+)["']\s+(?:in|into)\s+(.+?)(?:\s+field|\s+input|\s+box|\s+area)?(?:\s*→.*)?$/i);
1839
+ if (inputMatch) {
1840
+ const selector = inputMatch[2].replace(/^(the\s+)?/i, "").replace(/\s+(field|input|box|area)$/i, "").trim();
1841
+ plan.push({ stepNumber, description: text, action: "fill", selectorStrategy: "label", selector, value: inputMatch[1].trim() });
1842
+ continue;
1843
+ }
1844
+ const quoted = firstQuoted(text);
1845
+ if (/^(click|tap|navigate menu|check|select)\b/i.test(text) && quoted) {
1846
+ plan.push({ stepNumber, description: text, action: "click", selectorStrategy: "text", selector: quoted });
1847
+ continue;
1848
+ }
1849
+ if (/^(verify|assert|expect)\b/i.test(text) && quoted) {
1850
+ plan.push({ stepNumber, description: text, action: "assert_visible", selectorStrategy: "text", selector: quoted });
1851
+ continue;
1852
+ }
1853
+ if (/execute script/i.test(text)) {
1854
+ const script = text.split(":").slice(1).join(":").replace(/\s*→.*$/, "").trim();
1855
+ if (script) plan.push({ stepNumber, description: text, action: "script", value: script });
1856
+ }
1857
+ }
1858
+ return plan;
1859
+ }
1860
+ function screenNameFromUrl(url, fallback) {
1861
+ try {
1862
+ const u = new URL(url);
1863
+ return u.pathname.replace(/\/+$/, "").split("/").filter(Boolean).pop() || u.hostname || fallback;
1864
+ } catch {
1865
+ return fallback;
1866
+ }
1867
+ }
1868
+ async function processCaptureStepsJob(jobId, config) {
1869
+ const { tenantId, projectId, testCaseId } = config;
1870
+ const screenshotDir = config.screenshotDir || platformConfig.screenshotDir || process.env.SCREENSHOT_DIR || "/tmp/zeta-screenshots";
1871
+ await fs.mkdir(screenshotDir, { recursive: true });
1872
+ await withTenant(
1873
+ tenantId,
1874
+ (tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date(), progress: 2 } })
1875
+ );
1876
+ let browser;
1877
+ try {
1878
+ await updateJobProgress(tenantId, jobId, 5, "Loading test case");
1879
+ const [tc, crawlCfg, tenantStorageCfg] = await Promise.all([
1880
+ AppBrain.getTestCaseForExecution(tenantId, testCaseId),
1881
+ AppBrain.getCrawlConfig(tenantId, projectId).catch(() => null),
1882
+ getTenantStorageConfig(tenantId).catch(() => null)
1883
+ ]);
1884
+ if (!tc) throw new Error("Test case not found");
1885
+ const rawSteps = Array.isArray(tc.steps) ? tc.steps : [];
1886
+ const stepTexts = rawSteps.map(captureStepText).filter(Boolean);
1887
+ if (stepTexts.length === 0) throw new Error("Test case has no steps to capture");
1888
+ const baseUrl = config.baseUrl || tc?.screen?.project?.appUrl || crawlCfg?.appUrl || "";
1889
+ let plan = await generateExecutionPlan(tenantId, { ...tc, steps: stepTexts }, baseUrl).catch(() => []);
1890
+ plan = Array.isArray(plan) ? plan.filter(Boolean).map((step, idx) => ({ ...step, stepNumber: idx + 1 })) : [];
1891
+ if (plan.length === 0) plan = fallbackExecutionPlan(rawSteps);
1892
+ if (plan.length === 0) throw new Error("Could not generate execution plan from steps");
1893
+ const { chromium } = await import("playwright");
1894
+ browser = await chromium.launch({
1895
+ headless: true,
1896
+ ...process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}
1897
+ });
1898
+ let parsedStorageState;
1899
+ try {
1900
+ parsedStorageState = config.storageState ? JSON.parse(config.storageState) : crawlCfg?.storageState ? JSON.parse(crawlCfg.storageState) : void 0;
1901
+ } catch {
1902
+ parsedStorageState = void 0;
1903
+ }
1904
+ const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, ...parsedStorageState ? { storageState: parsedStorageState } : {} });
1905
+ const page = await context.newPage();
1906
+ const aiHealingEnabled = await isFeatureEnabled(tenantId, "AI_SELF_HEALING").catch(() => false);
1907
+ const stepResults = [];
1908
+ const screens = [];
1909
+ const locators = [];
1910
+ const seenLocatorExpressions = /* @__PURE__ */ new Set();
1911
+ const seenScreenKeys = /* @__PURE__ */ new Set();
1912
+ let lastStateKey = "";
1913
+ for (let i = 0; i < plan.length; i++) {
1914
+ const step = plan[i];
1915
+ const description = step.description || stepTexts[i] || `Step ${i + 1}`;
1916
+ const stepNumber = i + 1;
1917
+ const result = { stepNumber, description, status: "FAILED" };
1918
+ await withTenant(
1919
+ tenantId,
1920
+ (tx) => tx.platformJob.update({
1921
+ where: { id: jobId },
1922
+ data: {
1923
+ progress: Math.min(90, Math.round(i / Math.max(plan.length, 1) * 90)),
1924
+ result: { currentStep: { stepNumber, description } },
1925
+ heartbeatAt: /* @__PURE__ */ new Date()
1926
+ }
1927
+ })
1928
+ ).catch(() => {
1929
+ });
1930
+ try {
1931
+ const heal = await executeExecutionStep(page, step, aiHealingEnabled ? { healCtx: { tenantId, projectId } } : {});
1932
+ if (heal.healed) {
1933
+ result.healed = true;
1934
+ result.healTier = heal.healedVia;
1935
+ result.healReasoning = heal.healReasoning;
1936
+ }
1937
+ await page.waitForLoadState("networkidle", { timeout: parseInt(process.env.TEST_NETWORK_IDLE_TIMEOUT_MS ?? "2000", 10) }).catch(() => {
1938
+ });
1939
+ result.status = result.healed ? "HEALED" : "PASSED";
1940
+ } catch (err) {
1941
+ result.status = step.optional ? "SKIPPED" : "FAILED";
1942
+ result.error = String(err?.message ?? err).slice(0, 300);
1943
+ }
1944
+ const currentUrl = page.url();
1945
+ result.url = currentUrl;
1946
+ const ssFilename = `${testCaseId}-capture-step-${stepNumber}${result.status === "FAILED" ? "-err" : ""}.png`;
1947
+ const ssPath = path.join(screenshotDir, ssFilename);
1948
+ await page.screenshot({ path: ssPath, fullPage: false }).catch(() => {
1949
+ });
1950
+ result.screenshotPath = ssPath;
1951
+ let cloudUrl;
1952
+ if (tenantStorageCfg) {
1953
+ const key = buildArtifactKey(tenantId, projectId, "screenshots", ssFilename, tenantStorageCfg.prefix);
1954
+ cloudUrl = await uploadScreenshotToTenantStorage(tenantStorageCfg, ssPath, key).catch(() => void 0) ?? void 0;
1955
+ if (cloudUrl) result.screenshotUrl = cloudUrl;
1956
+ }
1957
+ const elements = await domFallbackElements(page).catch(() => []);
1958
+ result.elementsCount = elements.length;
1959
+ const stateHash = hashContent(elements.map((el) => `${el.role}:${el.meaning}:${el.notes ?? ""}`).join("|"));
1960
+ const screenKey = `${currentUrl || "about:blank"}:${stateHash}`;
1961
+ const shouldPersistScreen = currentUrl && currentUrl !== "about:blank" && (i === 0 || screenKey !== lastStateKey || !seenScreenKeys.has(screenKey));
1962
+ lastStateKey = screenKey;
1963
+ if (shouldPersistScreen) {
1964
+ const screenName = screenNameFromUrl(currentUrl, tc.title ?? `Step ${stepNumber}`);
1965
+ const screen = await AppBrain.saveScreen(tenantId, projectId, screenName, currentUrl, jobId, i === 0 ? "ENTRY_POINT" : "LINK_FOLLOW").catch(() => null);
1966
+ if (screen?.id) {
1967
+ result.screenId = screen.id;
1968
+ await AppBrain.saveScreenshot(tenantId, projectId, screen.id, ssPath, screen.version ?? 1, "DESKTOP", cloudUrl).catch(() => {
1969
+ });
1970
+ for (const el of elements) {
1971
+ await AppBrain.saveElement(
1972
+ tenantId,
1973
+ projectId,
1974
+ screen.id,
1975
+ el.meaning,
1976
+ el.role,
1977
+ el.expectedData,
1978
+ el.notes,
1979
+ void 0,
1980
+ true,
1981
+ el.boundingRect,
1982
+ el.ariaState,
1983
+ el.parentLandmark
1984
+ ).catch(() => {
1985
+ });
1986
+ }
1987
+ for (const loc of elementsToPlaywrightLocators(elements)) {
1988
+ if (seenLocatorExpressions.has(loc.expression)) continue;
1989
+ seenLocatorExpressions.add(loc.expression);
1990
+ locators.push(loc);
1991
+ }
1992
+ if (!seenScreenKeys.has(screenKey)) {
1993
+ screens.push({ stepNumber, screenId: screen.id, url: currentUrl, screenshotPath: ssPath, screenshotUrl: cloudUrl, isNewScreen: true });
1994
+ seenScreenKeys.add(screenKey);
1995
+ }
1996
+ }
1997
+ }
1998
+ stepResults.push(result);
1999
+ if (result.status === "FAILED") break;
2000
+ }
2001
+ if (locators.length > 0) {
2002
+ await AppBrain.saveLocatorsJson(tenantId, testCaseId, locators).catch(() => {
2003
+ });
2004
+ }
2005
+ const firstScreen = screens[0];
2006
+ await withTenant(
2007
+ tenantId,
2008
+ (tx) => tx.testCase.update({
2009
+ where: { id: testCaseId },
2010
+ data: {
2011
+ ...firstScreen?.screenId ? { screenId: firstScreen.screenId } : {},
2012
+ captureStepScreenshots: stepResults
2013
+ }
2014
+ })
2015
+ ).catch(() => {
2016
+ });
2017
+ const finalResult = {
2018
+ stepResults,
2019
+ screens,
2020
+ locators,
2021
+ totalSteps: plan.length,
2022
+ capturedScreens: screens.length
2023
+ };
2024
+ await withTenant(
2025
+ tenantId,
2026
+ (tx) => tx.platformJob.update({
2027
+ where: { id: jobId },
2028
+ data: { status: "DONE", progress: 100, result: finalResult, finishedAt: /* @__PURE__ */ new Date(), heartbeatAt: /* @__PURE__ */ new Date() }
2029
+ })
2030
+ );
2031
+ } catch (err) {
2032
+ const error = String(err?.message ?? err);
2033
+ await withTenant(
2034
+ tenantId,
2035
+ (tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error, finishedAt: /* @__PURE__ */ new Date(), heartbeatAt: /* @__PURE__ */ new Date() } })
2036
+ ).catch(() => {
2037
+ });
2038
+ logger.error({ jobId, testCaseId, err: error }, "processCaptureStepsJob failed");
2039
+ throw err;
2040
+ } finally {
2041
+ await browser?.close?.().catch(() => {
2042
+ });
2043
+ }
2044
+ }
1806
2045
  jobDispatchTable = {
1807
2046
  CRAWL: processJob,
2047
+ CAPTURE_STEPS: processCaptureStepsJob,
1808
2048
  DISCOVER: processDiscoverJob,
1809
2049
  MONITOR: processMonitorJob,
1810
2050
  NATIVE_CRAWL: processNativeCrawlJob,
@@ -1833,7 +2073,8 @@ async function processRepoScanJob(jobId, config) {
1833
2073
  detectFramework,
1834
2074
  extractTestIds,
1835
2075
  extractStyleProfile,
1836
- buildPageObjectInventory
2076
+ buildPageObjectInventory,
2077
+ resolveFixtureAliasesFromImports
1837
2078
  } = await import("@detiq/agents");
1838
2079
  const conn = await withTenant(
1839
2080
  tenantId,
@@ -1848,6 +2089,9 @@ async function processRepoScanJob(jobId, config) {
1848
2089
  );
1849
2090
  await updateJobProgress(tenantId, jobId, 15, "Fetching repo tree");
1850
2091
  const treeResult = await fetchRepoTree(token, conn.repoFullName, "HEAD");
2092
+ if (treeResult.truncated) {
2093
+ console.warn(`[repo-scan] ${conn.repoFullName}: tree truncated (>100k blobs) \u2014 detection may be incomplete`);
2094
+ }
1851
2095
  const frameworks = detectFramework(treeResult.paths);
1852
2096
  const primary = frameworks[0];
1853
2097
  await updateJobProgress(tenantId, jobId, 30, `Detected framework: ${primary?.framework ?? "unknown"}`);
@@ -1865,6 +2109,22 @@ async function processRepoScanJob(jobId, config) {
1865
2109
  return p.endsWith(".spec.ts") || p.endsWith(".spec.js") || p.endsWith(".test.ts") || p.endsWith(".test.js") || p.endsWith("_test.py") || p.includes("/test_");
1866
2110
  }).slice(0, 500);
1867
2111
  await updateJobProgress(tenantId, jobId, 40, `Found ${testPaths.length} test files`);
2112
+ if (testPaths.length === 0) {
2113
+ logger.warn({ jobId, repoConnectionId, framework }, "[repo-scan] no test files matched glob \u2014 scan complete with 0 results");
2114
+ await withTenant(
2115
+ tenantId,
2116
+ (tx) => tx.gitHubRepoConnection.update({
2117
+ where: { id: repoConnectionId },
2118
+ data: { scanStatus: "DONE", testFileCount: 0, extractedIdCount: 0, lastScannedAt: /* @__PURE__ */ new Date(), lastSyncAt: /* @__PURE__ */ new Date() }
2119
+ })
2120
+ );
2121
+ await updateJobProgress(tenantId, jobId, 100, "Done: 0 test files found");
2122
+ await withTenant(
2123
+ tenantId,
2124
+ (tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { testFileCount: 0, extractedIdCount: 0 } } })
2125
+ );
2126
+ return;
2127
+ }
1868
2128
  const allExtracted = [];
1869
2129
  const styleSampleFiles = [];
1870
2130
  let processed = 0;
@@ -1872,7 +2132,13 @@ async function processRepoScanJob(jobId, config) {
1872
2132
  for (const filePath of testPaths) {
1873
2133
  const content = await fetchFileContent(token, conn.repoFullName, filePath);
1874
2134
  if (!content) continue;
1875
- const extracted = extractTestIds(content, filePath, framework);
2135
+ const extraAliases = await resolveFixtureAliasesFromImports(
2136
+ content,
2137
+ token,
2138
+ conn.repoFullName,
2139
+ filePath
2140
+ ).catch(() => /* @__PURE__ */ new Set());
2141
+ const extracted = extractTestIds(content, filePath, framework, extraAliases);
1876
2142
  allExtracted.push(...extracted);
1877
2143
  await withTenant(
1878
2144
  tenantId,
@@ -1895,6 +2161,214 @@ async function processRepoScanJob(jobId, config) {
1895
2161
  await updateJobProgress(tenantId, jobId, 40 + Math.floor(processed / testPaths.length * 40), `Processed ${processed}/${testPaths.length} files`);
1896
2162
  }
1897
2163
  }
2164
+ const configPaths = {};
2165
+ try {
2166
+ const pathSet = new Set(treeResult.paths);
2167
+ const lang = framework.includes("java") ? "java" : framework.includes("py") || framework === "pytest" ? "python" : "js";
2168
+ const configFilesToFetch = [];
2169
+ const tryAdd = (candidates, type) => {
2170
+ for (const c of candidates) {
2171
+ if (pathSet.has(c)) {
2172
+ configFilesToFetch.push({ path: c, type, dirPrefix: "" });
2173
+ break;
2174
+ }
2175
+ }
2176
+ };
2177
+ const scanTree = (pattern, type) => {
2178
+ if (configFilesToFetch.some((f) => f.type === type)) return;
2179
+ const all = treeResult.paths.filter((p) => pattern.test(p)).sort((a, b) => a.split("/").length - b.split("/").length);
2180
+ if (all[0]) {
2181
+ const lastSlash = all[0].lastIndexOf("/");
2182
+ const dirPrefix = lastSlash > 0 ? all[0].slice(0, lastSlash + 1) : "";
2183
+ configFilesToFetch.push({ path: all[0], type, dirPrefix });
2184
+ }
2185
+ };
2186
+ if (lang === "js") {
2187
+ if (framework.startsWith("playwright")) {
2188
+ tryAdd(["playwright.config.ts", "playwright.config.js"], "playwright");
2189
+ scanTree(/(?:^|\/)playwright\.config\.(ts|js|mts)$/, "playwright");
2190
+ }
2191
+ if (framework.startsWith("cypress")) {
2192
+ tryAdd(["cypress.config.ts", "cypress.config.js"], "cypress");
2193
+ scanTree(/(?:^|\/)cypress\.config\.(ts|js)$/, "cypress");
2194
+ }
2195
+ if (framework === "jest-ts" || framework === "jest-js") {
2196
+ tryAdd(["jest.config.ts", "jest.config.js", "jest.config.mjs"], "jest");
2197
+ scanTree(/(?:^|\/)jest\.config\.(ts|js|mjs|cjs)$/, "jest");
2198
+ tryAdd(["package.json"], "package-json");
2199
+ }
2200
+ if (framework === "webdriverio") {
2201
+ tryAdd(["wdio.conf.ts", "wdio.conf.js", "wdio.config.ts", "wdio.config.js"], "wdio");
2202
+ scanTree(/(?:^|\/)wdio\.(?:conf|config)\.(ts|js|mjs)$/, "wdio");
2203
+ }
2204
+ if (framework === "cucumber" || framework === "gherkin") {
2205
+ tryAdd([".cucumber.js", ".cucumber.cjs", "cucumber.json", ".cucumber.yaml", ".cucumber.yml"], "cucumber");
2206
+ tryAdd(["package.json"], "package-json");
2207
+ }
2208
+ tryAdd(["tsconfig.json"], "tsconfig");
2209
+ scanTree(/(?:^|\/)tsconfig(?:\.base)?\.json$/, "tsconfig");
2210
+ } else if (lang === "python") {
2211
+ tryAdd(["pytest.ini"], "pytest-ini");
2212
+ scanTree(/(?:^|\/)pytest\.ini$/, "pytest-ini");
2213
+ tryAdd(["pyproject.toml"], "pyproject-toml");
2214
+ scanTree(/(?:^|\/)pyproject\.toml$/, "pyproject-toml");
2215
+ tryAdd(["setup.cfg"], "setup-cfg");
2216
+ } else if (lang === "java") {
2217
+ tryAdd(["pom.xml"], "pom-xml");
2218
+ scanTree(/(?:^|\/)pom\.xml$/, "pom-xml");
2219
+ tryAdd(["build.gradle.kts", "build.gradle"], "build-gradle");
2220
+ scanTree(/(?:^|\/)build\.gradle(?:\.kts)?$/, "build-gradle");
2221
+ tryAdd(["testng.xml", "src/test/resources/testng.xml"], "testng-xml");
2222
+ }
2223
+ const normDir = (s) => s.replace(/^\.\//, "").replace(/\/$/, "");
2224
+ const globRoot = (pattern) => {
2225
+ const root = pattern.replace(/\/\*\*.*$/, "").replace(/\/\*[^*].*$/, "");
2226
+ return root && !root.startsWith("*") && !root.startsWith("{") ? normDir(root) : null;
2227
+ };
2228
+ const fetched = await Promise.all(
2229
+ configFilesToFetch.map(async ({ path: cfgPath, type, dirPrefix }) => {
2230
+ const content = await fetchFileContent(token, conn.repoFullName, cfgPath).catch(() => null);
2231
+ if (!content) return null;
2232
+ const r = {};
2233
+ if (type === "playwright") {
2234
+ const m = content.match(/\btestDir\s*:\s*['"`]([^'"`]+)['"`]/);
2235
+ if (m) r.testDir = normDir(m[1]);
2236
+ const bm = content.match(/\bbaseURL\s*:\s*['"`]([^'"`]+)['"`]/);
2237
+ if (bm) r.baseURL = bm[1];
2238
+ } else if (type === "cypress") {
2239
+ const sm = content.match(/\bspecPattern\s*:\s*['"`]([^'"`]+)['"`]/);
2240
+ if (sm) {
2241
+ const gr = globRoot(sm[1]);
2242
+ if (gr) r.testDir = gr;
2243
+ }
2244
+ const fm = content.match(/\bfixturesFolder\s*:\s*['"`]([^'"`]+)['"`]/);
2245
+ if (fm) r.fixturesFolder = normDir(fm[1]);
2246
+ const bm = content.match(/\bbaseUrl\s*:\s*['"`]([^'"`]+)['"`]/);
2247
+ if (bm) r.baseURL = bm[1];
2248
+ } else if (type === "jest") {
2249
+ const rm = content.match(/\broots\s*:\s*\[([^\]]+)\]/);
2250
+ if (rm) {
2251
+ const fr = rm[1].match(/['"`]([^'"`]+)['"`]/)?.[1];
2252
+ if (fr) r.testDir = fr.replace(/<rootDir>\/?/, "").replace(/\/$/, "") || "src";
2253
+ }
2254
+ if (!r.testDir) {
2255
+ const tm = content.match(/\btestMatch\s*:\s*\[([^\]]+)\]/);
2256
+ if (tm) {
2257
+ const fp = tm[1].match(/['"`]([^'"`]+)['"`]/)?.[1];
2258
+ if (fp) {
2259
+ const gr = globRoot(fp.replace(/<rootDir>\/?/, ""));
2260
+ if (gr) r.testDir = gr;
2261
+ }
2262
+ }
2263
+ }
2264
+ const mm = content.match(/\bmoduleNameMapper\s*:\s*\{([^}]+)\}/s);
2265
+ if (mm) {
2266
+ const map = {};
2267
+ for (const [, k, v] of mm[1].matchAll(/['"`]([^'"`]+)['"`]\s*:\s*['"`]([^'"`]+)['"`]/g)) map[k] = v;
2268
+ if (Object.keys(map).length) r.jestModuleNameMapper = map;
2269
+ }
2270
+ } else if (type === "package-json") {
2271
+ try {
2272
+ const p = JSON.parse(content);
2273
+ const j = p?.jest ?? {};
2274
+ if (Array.isArray(j.roots) && j.roots[0]) r.testDir = String(j.roots[0]).replace(/<rootDir>\/?/, "").replace(/\/$/, "") || "src";
2275
+ if (!r.testDir && Array.isArray(j.testMatch) && j.testMatch[0]) {
2276
+ const gr = globRoot(String(j.testMatch[0]).replace(/<rootDir>\/?/, ""));
2277
+ if (gr) r.testDir = gr;
2278
+ }
2279
+ if (j.moduleNameMapper && typeof j.moduleNameMapper === "object") r.jestModuleNameMapper = j.moduleNameMapper;
2280
+ } catch {
2281
+ }
2282
+ } else if (type === "tsconfig") {
2283
+ try {
2284
+ const stripped = content.replace(/\/\/[^\n]*/g, "").replace(/,(\s*[}\]])/g, "$1");
2285
+ const p = JSON.parse(stripped);
2286
+ const opts = p?.compilerOptions ?? {};
2287
+ if (opts.paths && typeof opts.paths === "object") r.tsconfigPaths = opts.paths;
2288
+ if (typeof opts.baseUrl === "string") r.tsconfigBaseUrl = opts.baseUrl;
2289
+ } catch {
2290
+ }
2291
+ } else if (type === "pytest-ini") {
2292
+ const m = content.match(/^\s*testpaths\s*=\s*(.+)$/m);
2293
+ if (m) {
2294
+ const first = m[1].trim().split(/\s+/)[0];
2295
+ if (first) r.testDir = normDir(first);
2296
+ }
2297
+ } else if (type === "pyproject-toml") {
2298
+ const m = content.match(/testpaths\s*=\s*\[([^\]]+)\]/);
2299
+ if (m) {
2300
+ const first = m[1].match(/['"]([^'"]+)['"]/)?.[1];
2301
+ if (first) r.testDir = normDir(first);
2302
+ }
2303
+ if (!r.testDir) {
2304
+ const m2 = content.match(/testpaths\s*=\s*['"]([^'"]+)['"]/);
2305
+ if (m2) r.testDir = normDir(m2[1]);
2306
+ }
2307
+ } else if (type === "setup-cfg") {
2308
+ const m = content.match(/^\s*testpaths\s*=\s*(.+)$/m);
2309
+ if (m) {
2310
+ const first = m[1].trim().split(/[\s,]+/)[0];
2311
+ if (first) r.testDir = normDir(first);
2312
+ }
2313
+ } else if (type === "pom-xml") {
2314
+ const m = content.match(/<testSourceDirectory>\s*([^<]+)\s*<\/testSourceDirectory>/);
2315
+ if (m) r.javaTestSourceDir = normDir(m[1].trim());
2316
+ } else if (type === "build-gradle") {
2317
+ const m = content.match(/srcDir[s]?\s*[=([]?\s*['"`]([^'"`]+)['"`]/) ?? content.match(/srcDir\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/);
2318
+ if (m) r.javaTestSourceDir = normDir(m[1]);
2319
+ } else if (type === "wdio") {
2320
+ const sm = content.match(/\bspecs\s*:\s*\[([^\]]+)\]/);
2321
+ if (sm) {
2322
+ const fp = sm[1].match(/['"`]([^'"`]+)['"`]/)?.[1];
2323
+ if (fp) {
2324
+ const gr = globRoot(fp);
2325
+ if (gr) r.testDir = gr;
2326
+ }
2327
+ }
2328
+ const bm = content.match(/\bbaseUrl\s*:\s*['"`]([^'"`]+)['"`]/);
2329
+ if (bm) r.baseURL = bm[1];
2330
+ } else if (type === "cucumber") {
2331
+ const pm = content.match(/\bpaths\s*:\s*\[([^\]]+)\]/);
2332
+ if (pm) {
2333
+ const fp = pm[1].match(/['"`]([^'"`]+)['"`]/)?.[1];
2334
+ if (fp) {
2335
+ const gr = globRoot(fp);
2336
+ if (gr) r.testDir = gr;
2337
+ }
2338
+ }
2339
+ if (!r.testDir) {
2340
+ try {
2341
+ const p = JSON.parse(content);
2342
+ const paths = p?.default?.paths ?? p?.paths;
2343
+ if (Array.isArray(paths) && paths[0]) {
2344
+ const gr = globRoot(String(paths[0]));
2345
+ if (gr) r.testDir = gr;
2346
+ }
2347
+ } catch {
2348
+ }
2349
+ }
2350
+ }
2351
+ if (dirPrefix && r.testDir && !r.testDir.startsWith(dirPrefix)) {
2352
+ r.testDir = dirPrefix + r.testDir;
2353
+ }
2354
+ if (dirPrefix && r.javaTestSourceDir && !r.javaTestSourceDir.startsWith(dirPrefix)) {
2355
+ r.javaTestSourceDir = dirPrefix + r.javaTestSourceDir;
2356
+ }
2357
+ return r;
2358
+ })
2359
+ );
2360
+ for (const parsed of fetched) {
2361
+ if (!parsed) continue;
2362
+ if (parsed.testDir && !configPaths.testDir) configPaths.testDir = parsed.testDir;
2363
+ if (parsed.fixturesFolder && !configPaths.fixturesFolder) configPaths.fixturesFolder = parsed.fixturesFolder;
2364
+ if (parsed.baseURL && !configPaths.baseURL) configPaths.baseURL = parsed.baseURL;
2365
+ if (parsed.tsconfigPaths && !configPaths.tsconfigPaths) configPaths.tsconfigPaths = parsed.tsconfigPaths;
2366
+ if (parsed.tsconfigBaseUrl && !configPaths.tsconfigBaseUrl) configPaths.tsconfigBaseUrl = parsed.tsconfigBaseUrl;
2367
+ if (parsed.javaTestSourceDir && !configPaths.javaTestSourceDir) configPaths.javaTestSourceDir = parsed.javaTestSourceDir;
2368
+ if (parsed.jestModuleNameMapper && !configPaths.jestModuleNameMapper) configPaths.jestModuleNameMapper = parsed.jestModuleNameMapper;
2369
+ }
2370
+ } catch {
2371
+ }
1898
2372
  let styleProfile = conn.styleProfile ?? null;
1899
2373
  if (styleSampleFiles.length > 0) {
1900
2374
  try {
@@ -1902,11 +2376,23 @@ async function processRepoScanJob(jobId, config) {
1902
2376
  const testPathSet = new Set(testPaths);
1903
2377
  const sourceFilePaths = language === "typescript" || language === "javascript" ? treeResult.paths.filter((p) => (p.endsWith(".ts") || p.endsWith(".js")) && !testPathSet.has(p) && !p.endsWith(".d.ts") && !p.includes("node_modules/") && !p.includes("dist/") && !p.includes(".config.")).slice(0, 60) : [];
1904
2378
  const allPathsForProfile = [...testPaths, ...mainSourcePaths, ...sourceFilePaths].slice(0, 200);
1905
- const freshProfile = await extractStyleProfile(styleSampleFiles, framework, language, tenantId, projectId, jobId, allPathsForProfile);
2379
+ const freshProfile = await extractStyleProfile(styleSampleFiles, framework, language, tenantId, projectId, jobId, allPathsForProfile, treeResult.paths);
1906
2380
  if (freshProfile) styleProfile = freshProfile;
1907
2381
  } catch {
1908
2382
  }
1909
2383
  }
2384
+ if (styleProfile && Object.keys(configPaths).length > 0) {
2385
+ if (configPaths.testDir) styleProfile.configTestDir = configPaths.testDir;
2386
+ if (configPaths.javaTestSourceDir) {
2387
+ styleProfile.configTestDir = configPaths.javaTestSourceDir;
2388
+ if (!styleProfile.testLocation) styleProfile.testLocation = configPaths.javaTestSourceDir;
2389
+ }
2390
+ if (configPaths.fixturesFolder) styleProfile.configFixturesFolder = configPaths.fixturesFolder;
2391
+ if (configPaths.baseURL) styleProfile.configBaseURL = configPaths.baseURL;
2392
+ if (configPaths.tsconfigPaths) styleProfile.tsconfigPaths = configPaths.tsconfigPaths;
2393
+ if (configPaths.tsconfigBaseUrl) styleProfile.tsconfigBaseUrl = configPaths.tsconfigBaseUrl;
2394
+ if (configPaths.jestModuleNameMapper) styleProfile.jestModuleNameMapper = configPaths.jestModuleNameMapper;
2395
+ }
1910
2396
  let pageObjectInventory = conn.pageObjectInventory ?? [];
1911
2397
  try {
1912
2398
  const freshInventory = await buildPageObjectInventory(token, conn.repoFullName, treeResult.paths, framework);
@@ -1985,7 +2471,9 @@ async function processRepoScanJob(jobId, config) {
1985
2471
  styleProfile: resolvedStyleProfile,
1986
2472
  pageObjectInventory,
1987
2473
  testPaths,
1988
- extractedTestCount: allExtracted.length
2474
+ extractedTestCount: allExtracted.length,
2475
+ treeWasTruncated: treeResult.truncated,
2476
+ extractedTests: allExtracted
1989
2477
  });
1990
2478
  } catch (e) {
1991
2479
  logger.warn({ jobId, repoConnectionId, err: e?.message }, "[repo-scan] repo skill files generation failed (non-fatal)");
@@ -1997,7 +2485,8 @@ async function processRepoScanJob(jobId, config) {
1997
2485
  );
1998
2486
  aggregateJobTokens(tenantId, jobId, projectId, jobStart).catch(console.error);
1999
2487
  } catch (err) {
2000
- const msg = String(err?.message ?? err);
2488
+ const cause = err?.cause?.message ?? (typeof err?.cause === "string" ? err.cause : "");
2489
+ const msg = `${String(err?.message ?? err)}${cause ? ` \u2014 ${cause}` : ""}`;
2001
2490
  logger.error({ jobId, repoConnectionId, err: msg }, "[repo-scan] failed");
2002
2491
  await withTenant(
2003
2492
  tenantId,