@detiq/crawler 0.1.3 → 0.1.4

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.
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { askTrackedWithImages, askTracked } from "@qip/agents";
3
- import { AppBrain } from "@qip/app-brain";
2
+ import { askTrackedWithImages, askTracked } from "@detiq/agents";
3
+ import { AppBrain } from "@detiq/app-brain";
4
4
  async function analyzeFailure(ctx) {
5
5
  try {
6
6
  let llmCfg;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/failure-analysis-agent.ts"],
4
- "sourcesContent": ["import { readFileSync } from 'node:fs';\nimport { askTrackedWithImages, askTracked } from '@qip/agents';\nimport type { LLMConfig } from '@qip/agents';\nimport { AppBrain } from '@qip/app-brain';\n\nexport interface FailureContext {\n tenantId?: string;\n error: string;\n stepDescription?: string;\n consoleLogs?: Array<{ type: string; text: string }>;\n networkErrors?: Array<{ url: string; status: number; method: string }>;\n screenshotPath?: string;\n}\n\nexport async function analyzeFailure(ctx: FailureContext): Promise<string | null> {\n try {\n let llmCfg: LLMConfig | undefined;\n if (ctx.tenantId) {\n llmCfg = await AppBrain.getTaskLLMConfig(ctx.tenantId, 'testRunner').catch(() => undefined) as LLMConfig | undefined ?? undefined;\n }\n if (!llmCfg) return null;\n\n const consoleSection = ctx.consoleLogs?.length\n ? `\\n\\nBrowser console (last ${ctx.consoleLogs.length} entries):\\n${ctx.consoleLogs.map(l => `[${l.type}] ${l.text}`).join('\\n')}`\n : '';\n\n const networkSection = ctx.networkErrors?.length\n ? `\\n\\nFailed network requests:\\n${ctx.networkErrors.map(n => `${n.method} ${n.url} \u2192 ${n.status}`).join('\\n')}`\n : '';\n\n const userText = `A Playwright automated test failed. Identify the root cause in 2-3 sentences. Be specific \u2014 name the failing element, API endpoint, or error type. Do not suggest fixes.\n\nTest step: ${ctx.stepDescription ?? 'unknown'}\nError: ${ctx.error}${consoleSection}${networkSection}`;\n\n if (ctx.screenshotPath) {\n try {\n const img = readFileSync(ctx.screenshotPath);\n const result = await askTrackedWithImages('', userText, [{ base64: img.toString('base64'), mediaType: 'image/png' }], llmCfg);\n return result.text || null;\n } catch { /* fall through to text-only */ }\n }\n\n const result = await askTracked('', userText, llmCfg, 256);\n return result.text || null;\n } catch {\n return null;\n }\n}\n"],
4
+ "sourcesContent": ["import { readFileSync } from 'node:fs';\nimport { askTrackedWithImages, askTracked } from '@detiq/agents';\nimport type { LLMConfig } from '@detiq/agents';\nimport { AppBrain } from '@detiq/app-brain';\n\nexport interface FailureContext {\n tenantId?: string;\n error: string;\n stepDescription?: string;\n consoleLogs?: Array<{ type: string; text: string }>;\n networkErrors?: Array<{ url: string; status: number; method: string }>;\n screenshotPath?: string;\n}\n\nexport async function analyzeFailure(ctx: FailureContext): Promise<string | null> {\n try {\n let llmCfg: LLMConfig | undefined;\n if (ctx.tenantId) {\n llmCfg = await AppBrain.getTaskLLMConfig(ctx.tenantId, 'testRunner').catch(() => undefined) as LLMConfig | undefined ?? undefined;\n }\n if (!llmCfg) return null;\n\n const consoleSection = ctx.consoleLogs?.length\n ? `\\n\\nBrowser console (last ${ctx.consoleLogs.length} entries):\\n${ctx.consoleLogs.map(l => `[${l.type}] ${l.text}`).join('\\n')}`\n : '';\n\n const networkSection = ctx.networkErrors?.length\n ? `\\n\\nFailed network requests:\\n${ctx.networkErrors.map(n => `${n.method} ${n.url} \u2192 ${n.status}`).join('\\n')}`\n : '';\n\n const userText = `A Playwright automated test failed. Identify the root cause in 2-3 sentences. Be specific \u2014 name the failing element, API endpoint, or error type. Do not suggest fixes.\n\nTest step: ${ctx.stepDescription ?? 'unknown'}\nError: ${ctx.error}${consoleSection}${networkSection}`;\n\n if (ctx.screenshotPath) {\n try {\n const img = readFileSync(ctx.screenshotPath);\n const result = await askTrackedWithImages('', userText, [{ base64: img.toString('base64'), mediaType: 'image/png' }], llmCfg);\n return result.text || null;\n } catch { /* fall through to text-only */ }\n }\n\n const result = await askTracked('', userText, llmCfg, 256);\n return result.text || null;\n } catch {\n return null;\n }\n}\n"],
5
5
  "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,sBAAsB,kBAAkB;AAEjD,SAAS,gBAAgB;AAWzB,eAAsB,eAAe,KAA6C;AAChF,MAAI;AACF,QAAI;AACJ,QAAI,IAAI,UAAU;AAChB,eAAS,MAAM,SAAS,iBAAiB,IAAI,UAAU,YAAY,EAAE,MAAM,MAAM,MAAS,KAA8B;AAAA,IAC1H;AACA,QAAI,CAAC,OAAQ,QAAO;AAEpB,UAAM,iBAAiB,IAAI,aAAa,SACpC;AAAA;AAAA,wBAA6B,IAAI,YAAY,MAAM;AAAA,EAAe,IAAI,YAAY,IAAI,OAAK,IAAI,EAAE,IAAI,KAAK,EAAE,IAAI,EAAE,EAAE,KAAK,IAAI,CAAC,KAC9H;AAEJ,UAAM,iBAAiB,IAAI,eAAe,SACtC;AAAA;AAAA;AAAA,EAAiC,IAAI,cAAc,IAAI,OAAK,GAAG,EAAE,MAAM,IAAI,EAAE,GAAG,WAAM,EAAE,MAAM,EAAE,EAAE,KAAK,IAAI,CAAC,KAC5G;AAEJ,UAAM,WAAW;AAAA;AAAA,aAER,IAAI,mBAAmB,SAAS;AAAA,SACpC,IAAI,KAAK,GAAG,cAAc,GAAG,cAAc;AAEhD,QAAI,IAAI,gBAAgB;AACtB,UAAI;AACF,cAAM,MAAM,aAAa,IAAI,cAAc;AAC3C,cAAMA,UAAS,MAAM,qBAAqB,IAAI,UAAU,CAAC,EAAE,QAAQ,IAAI,SAAS,QAAQ,GAAG,WAAW,YAAY,CAAC,GAAG,MAAM;AAC5H,eAAOA,QAAO,QAAQ;AAAA,MACxB,QAAQ;AAAA,MAAkC;AAAA,IAC5C;AAEA,UAAM,SAAS,MAAM,WAAW,IAAI,UAAU,QAAQ,GAAG;AACzD,WAAO,OAAO,QAAQ;AAAA,EACxB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;",
6
6
  "names": ["result"]
7
7
  }
@@ -1,4 +1,4 @@
1
- import { AppBrain } from "@qip/app-brain";
1
+ import { AppBrain } from "@detiq/app-brain";
2
2
  const ERROR_SELECTORS = [
3
3
  '[role="alert"]',
4
4
  '[aria-live="assertive"]',
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/interactive-probe.ts"],
4
- "sourcesContent": ["/**\n * Interactive Probe \u2014 Phase 2 Dynamic Observation\n *\n * The static crawl captures DOM structure at rest. This module runs AFTER the static crawl\n * to observe dynamic behaviour by actually interacting with the UI:\n *\n * 1. Empty submit \u2192 captures required-field validation error messages\n * 2. Invalid format \u2192 captures format validation errors (bad email, short password)\n * 3. Button clicks \u2192 captures modal/dialog contents\n * 4. Select changes \u2192 captures conditional reveals (show/hide logic)\n * 5. Success paths \u2192 captures toast/banner messages (where input is safe)\n *\n * Results are stored as KnowledgeChunks (sourceType='probe') and update the\n * ERROR_CATALOGUE + INTERACTION_PATTERNS skill files with real observed data\n * instead of LLM inference.\n *\n * Call signature: probeScreen(page, screenId, elements, opts)\n * Called from crawlProject() after static element extraction when opts.interactive=true.\n */\n\nimport type { Page } from 'playwright';\nimport { AppBrain } from '@qip/app-brain';\n\nexport interface ProbeElementFact {\n meaning: string;\n role: string;\n expectedData?: string;\n}\n\nexport interface ValidationError {\n field: string;\n trigger: 'empty_submit' | 'invalid_format' | 'boundary';\n errorText: string;\n}\n\nexport interface SuccessMessage {\n type: 'toast' | 'inline_success' | 'redirect';\n content: string;\n}\n\nexport interface ModalObservation {\n triggerElement: string;\n title: string;\n content: string;\n fields: string[];\n}\n\nexport interface ConditionalReveal {\n triggerElement: string;\n triggerValue: string;\n revealedText: string;\n}\n\nexport interface ProbeResult {\n screenId: string;\n url: string;\n validationErrors: ValidationError[];\n successMessages: SuccessMessage[];\n modals: ModalObservation[];\n conditionals: ConditionalReveal[];\n loadingPatterns: string[];\n}\n\n// \u2500\u2500 CSS selectors that reliably find error messages across frameworks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst ERROR_SELECTORS = [\n '[role=\"alert\"]',\n '[aria-live=\"assertive\"]',\n '[aria-live=\"polite\"]',\n '.invalid-feedback',\n '.field-error',\n '.error-message',\n '.form-error',\n '.validation-error',\n '[class*=\"error\" i]',\n '[class*=\"invalid\" i]',\n '[class*=\"Error\"]',\n 'p.text-red-500', 'p.text-red-600', 'p.text-destructive', 'p.text-danger',\n '[data-error]', '[data-invalid]',\n '.ant-form-item-explain-error', // Ant Design\n '.el-form-item__error', // Element UI\n '.chakra-form__error-message', // Chakra UI\n '.mantine-InputWrapper-error', // Mantine\n].join(', ');\n\n// \u2500\u2500 Toast / notification selectors \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst TOAST_SELECTORS = [\n '[role=\"status\"]',\n '[data-sonner-toast]',\n '[data-toast]',\n '[data-hot-toast]',\n '.Toastify__toast',\n '.toast-message',\n '.notification-body',\n '.notistack-snackbar',\n '[class*=\"toast\" i]',\n '[class*=\"Toast\"]',\n '[class*=\"snackbar\" i]',\n '[class*=\"notification\" i]',\n].join(', ');\n\n/** Wait up to `maxMs` for any selector to appear, then return its text content. */\nasync function waitForAny(page: Page, selector: string, maxMs = 2500): Promise<string[]> {\n try {\n await page.waitForSelector(selector, { timeout: maxMs, state: 'visible' });\n } catch { return []; }\n return page.$$eval(selector, (els) =>\n els.map((el) => (el as HTMLElement).innerText?.replace(/\\s+/g, ' ').trim()).filter(Boolean)\n );\n}\n\n/** Collect all currently-visible error messages. */\nasync function collectErrors(page: Page): Promise<string[]> {\n try {\n return page.$$eval(ERROR_SELECTORS, (els) =>\n els\n .filter((el) => {\n const style = window.getComputedStyle(el);\n return style.display !== 'none' && style.visibility !== 'hidden' && (el as HTMLElement).innerText?.trim().length > 0;\n })\n .map((el) => (el as HTMLElement).innerText?.replace(/\\s+/g, ' ').trim())\n .filter(Boolean)\n );\n } catch { return []; }\n}\n\n/** Collect any visible toast / notification content. */\nasync function collectToasts(page: Page): Promise<string[]> {\n try {\n return page.$$eval(TOAST_SELECTORS, (els) =>\n els\n .filter((el) => {\n const style = window.getComputedStyle(el);\n return style.display !== 'none' && (el as HTMLElement).innerText?.trim().length > 0;\n })\n .map((el) => (el as HTMLElement).innerText?.replace(/\\s+/g, ' ').trim())\n .filter(Boolean)\n );\n } catch { return []; }\n}\n\n/** Find the primary submit button on the page. */\nasync function findSubmitButton(page: Page): Promise<string | null> {\n return page.evaluate(() => {\n const SUBMIT_KW = /submit|send|save|create|confirm|continue|proceed|register|sign.?in|log.?in|apply|verify|search|next|finish|complete|order|buy|checkout/i;\n const candidates = Array.from(document.querySelectorAll('button, [role=\"button\"], input[type=\"submit\"]')) as HTMLElement[];\n const primary = candidates.find((el) => {\n const style = window.getComputedStyle(el);\n if (style.display === 'none' || style.visibility === 'hidden') return false;\n const text = el.innerText || (el as HTMLInputElement).value || el.getAttribute('aria-label') || '';\n return SUBMIT_KW.test(text);\n });\n if (!primary) return null;\n return primary.getAttribute('data-testid') || primary.getAttribute('aria-label') || primary.innerText?.trim().slice(0, 60);\n });\n}\n\n/** Find all modal-trigger buttons (non-submit buttons). */\nasync function findModalTriggers(page: Page): Promise<Array<{ selector: string; label: string }>> {\n return page.evaluate(() => {\n const SKIP = /submit|send|save|create|log.?in|sign.?in|register|continue|checkout|buy|order|search|close|cancel|dismiss/i;\n const NAV_PARENTS = new Set(['NAV', 'HEADER', 'FOOTER']);\n return Array.from(document.querySelectorAll('button, [role=\"button\"]'))\n .filter((el) => {\n const style = window.getComputedStyle(el as HTMLElement);\n if (style.display === 'none' || style.visibility === 'hidden') return false;\n const label = (el as HTMLElement).innerText?.trim() || el.getAttribute('aria-label') || '';\n if (!label || SKIP.test(label)) return false;\n // Skip nav chrome\n let p = el.parentElement;\n while (p) { if (NAV_PARENTS.has(p.tagName)) return false; p = p.parentElement; }\n return true;\n })\n .slice(0, 8) // probe at most 8 buttons per screen to keep it fast\n .map((el) => {\n const label = (el as HTMLElement).innerText?.trim() || el.getAttribute('aria-label') || 'button';\n return { selector: `[aria-label=\"${label}\"]`, label: label.slice(0, 60) };\n });\n });\n}\n\n/** Try to interact with select elements and observe conditional reveals. */\nasync function probeConditionals(page: Page): Promise<ConditionalReveal[]> {\n const results: ConditionalReveal[] = [];\n try {\n const selects = await page.$$('select');\n for (const sel of selects.slice(0, 5)) {\n const label = await sel.getAttribute('aria-label') || await sel.getAttribute('name') || 'select';\n const options = await sel.$$eval('option', (opts) => opts.slice(1, 3).map((o) => ({ value: o.value, text: o.textContent?.trim() })));\n for (const opt of options) {\n if (!opt.value) continue;\n const domBefore = await page.evaluate(() => document.body.innerText.length);\n await sel.selectOption(opt.value);\n await page.waitForTimeout(500);\n const domAfter = await page.evaluate(() => document.body.innerText.length);\n if (Math.abs(domAfter - domBefore) > 50) {\n const revealed = await page.evaluate(() => {\n // Grab text from any newly-visible sections\n return Array.from(document.querySelectorAll('[data-conditional],[data-show],[class*=\"conditional\"]'))\n .filter((el) => window.getComputedStyle(el).display !== 'none')\n .map((el) => (el as HTMLElement).innerText?.trim())\n .filter((t) => t && t.length > 10)\n .slice(0, 3)\n .join(' | ');\n });\n if (revealed) {\n results.push({ triggerElement: label, triggerValue: opt.text || opt.value, revealedText: revealed.slice(0, 200) });\n }\n }\n }\n }\n } catch { /* non-fatal */ }\n return results;\n}\n\n/**\n * Run interactive probing on a single screen page.\n * The page must already be navigated to the screen URL.\n * `elements` is the output of the static extractElements pass.\n */\nexport async function probeScreen(\n page: Page,\n screenId: string,\n url: string,\n elements: ProbeElementFact[],\n): Promise<ProbeResult> {\n const result: ProbeResult = {\n screenId, url,\n validationErrors: [], successMessages: [], modals: [], conditionals: [], loadingPatterns: [],\n };\n\n const formInputs = elements.filter((e) =>\n /input|text|email|password|tel|search|textarea|select/i.test(e.role)\n );\n const hasForm = formInputs.length > 0;\n\n // \u2500\u2500 Probe 1: Empty submit \u2192 capture required-field errors \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n if (hasForm) {\n try {\n const submitLabel = await findSubmitButton(page);\n if (submitLabel) {\n // Click submit with no data filled in\n const submitBtn = page.locator(`button:has-text(\"${submitLabel}\")`).first();\n await submitBtn.click({ timeout: 2000 });\n await page.waitForTimeout(1500);\n\n const errors = await collectErrors(page);\n for (const errorText of errors) {\n // Try to match error back to a field\n const matchedField = formInputs.find((el) =>\n errorText.toLowerCase().includes(el.meaning.toLowerCase().split(' ')[0]) ||\n el.meaning.toLowerCase().includes(errorText.toLowerCase().split(' ')[0])\n );\n result.validationErrors.push({\n field: matchedField?.meaning ?? 'unknown',\n trigger: 'empty_submit',\n errorText: errorText.slice(0, 150),\n });\n }\n\n // Check for loading state text during submit\n const loadingText = await page.evaluate(() => {\n const loading = Array.from(document.querySelectorAll('button, [aria-busy=\"true\"], [class*=\"loading\" i], [class*=\"spinner\" i]'))\n .map((el) => (el as HTMLElement).innerText?.trim())\n .filter((t) => t && /loading|saving|submitting|sending|processing/i.test(t));\n return loading.slice(0, 3);\n });\n result.loadingPatterns.push(...loadingText);\n }\n } catch { /* non-fatal \u2014 form probe failed, continue */ }\n }\n\n // \u2500\u2500 Probe 2: Invalid format inputs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n if (hasForm) {\n try {\n const emailField = formInputs.find((e) => e.expectedData === 'valid_email' || /email/i.test(e.meaning));\n const passwordField = formInputs.find((e) => e.expectedData === 'password' || /password/i.test(e.meaning));\n\n if (emailField) {\n const emailLocator = page.locator('input[type=\"email\"], input[name*=\"email\"], input[placeholder*=\"email\" i]').first();\n try {\n await emailLocator.fill('notanemail', { timeout: 1000 });\n await emailLocator.blur();\n await page.waitForTimeout(600);\n const errors = await collectErrors(page);\n for (const errorText of errors) {\n result.validationErrors.push({ field: emailField.meaning, trigger: 'invalid_format', errorText: errorText.slice(0, 150) });\n }\n } catch { /* locator not found */ }\n }\n\n if (passwordField) {\n const pwLocator = page.locator('input[type=\"password\"]').first();\n try {\n await pwLocator.fill('123', { timeout: 1000 });\n await pwLocator.blur();\n await page.waitForTimeout(600);\n const errors = await collectErrors(page);\n for (const errorText of errors) {\n if (!result.validationErrors.some((e) => e.errorText === errorText)) {\n result.validationErrors.push({ field: passwordField.meaning, trigger: 'invalid_format', errorText: errorText.slice(0, 150) });\n }\n }\n } catch { /* locator not found */ }\n }\n } catch { /* non-fatal */ }\n }\n\n // \u2500\u2500 Probe 3: Button clicks \u2192 discover modal/dialog contents \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n try {\n const triggers = await findModalTriggers(page);\n for (const trigger of triggers) {\n try {\n const btn = page.locator(`button:has-text(\"${trigger.label}\")`).first();\n const domBefore = await page.evaluate(() => document.body.innerText.length);\n\n await btn.click({ timeout: 1500 });\n await page.waitForTimeout(1000);\n\n // Check if a dialog/modal appeared\n const dialogText = await page.evaluate(() => {\n const DIALOG_SEL = '[role=\"dialog\"], [role=\"alertdialog\"], .modal, .Modal, [class*=\"modal\" i], [class*=\"dialog\" i], [class*=\"Dialog\"]';\n const dialogs = Array.from(document.querySelectorAll(DIALOG_SEL))\n .filter((el) => window.getComputedStyle(el).display !== 'none');\n if (dialogs.length === 0) return null;\n const d = dialogs[0] as HTMLElement;\n const title = d.querySelector('[role=\"heading\"], h1, h2, h3')?.textContent?.trim() || '';\n const content = d.innerText?.replace(/\\s+/g, ' ').trim().slice(0, 400);\n const fields = Array.from(d.querySelectorAll('input, select, textarea, button'))\n .map((f) => (f as HTMLElement).getAttribute('aria-label') || (f as HTMLElement).getAttribute('placeholder') || (f as HTMLInputElement).value || '')\n .filter((t) => t.trim().length > 0);\n return { title, content, fields };\n });\n\n if (dialogText && dialogText.content.length > 20) {\n result.modals.push({\n triggerElement: trigger.label,\n title: dialogText.title,\n content: dialogText.content,\n fields: dialogText.fields.slice(0, 10),\n });\n }\n\n // Dismiss: Escape key first, then close button\n await page.keyboard.press('Escape');\n await page.waitForTimeout(400);\n\n // Check if toast appeared after the click\n const toasts = await collectToasts(page);\n for (const t of toasts) {\n if (t.length > 5) result.successMessages.push({ type: 'toast', content: t.slice(0, 200) });\n }\n } catch { /* this button's probe failed, try next */ }\n }\n } catch { /* non-fatal */ }\n\n // \u2500\u2500 Probe 4: Select/radio \u2192 conditional reveals \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n try {\n const conditionals = await probeConditionals(page);\n result.conditionals.push(...conditionals);\n } catch { /* non-fatal */ }\n\n return result;\n}\n\n/** Phase 5: Convert ProbeResult to typed ProbeObservationFact rows for direct DB storage. */\nexport function probeResultToObservations(result: ProbeResult): Array<{\n elementMeaning: string; observationType: string; observedText: string; triggeredBy: string;\n}> {\n const obs: Array<{ elementMeaning: string; observationType: string; observedText: string; triggeredBy: string }> = [];\n for (const ve of result.validationErrors) {\n obs.push({ elementMeaning: ve.field, observationType: 'VALIDATION_ERROR', observedText: ve.errorText, triggeredBy: ve.trigger });\n }\n for (const sm of result.successMessages) {\n obs.push({ elementMeaning: '(page)', observationType: 'SUCCESS_TOAST', observedText: sm.content, triggeredBy: sm.type });\n }\n for (const m of result.modals) {\n obs.push({ elementMeaning: m.triggerElement, observationType: 'MODAL', observedText: [m.title, m.content].filter(Boolean).join(' \u2014 ').slice(0, 500), triggeredBy: `button_click:${m.triggerElement}` });\n }\n for (const c of result.conditionals) {\n obs.push({ elementMeaning: c.triggerElement, observationType: 'CONDITIONAL_REVEAL', observedText: c.revealedText, triggeredBy: `select_change:${c.triggerElement}:${c.triggerValue}` });\n }\n for (const lp of result.loadingPatterns) {\n obs.push({ elementMeaning: '(submit button)', observationType: 'LOADING_STATE', observedText: lp, triggeredBy: 'form_submit' });\n }\n return obs;\n}\n\n/**\n * Convert a ProbeResult into markdown to store as KnowledgeChunks\n * and update ERROR_CATALOGUE + INTERACTION_PATTERNS skill files.\n */\nexport function probeResultToMarkdown(result: ProbeResult): {\n errorCatalogueChunk: string;\n interactionPatternsChunk: string;\n} {\n const lines: string[] = [`## Probe Observations \u2014 ${result.url}\\n`];\n\n if (result.validationErrors.length > 0) {\n lines.push('### Validation Errors (observed)');\n for (const ve of result.validationErrors) {\n lines.push(`- **${ve.field}** (${ve.trigger}): \"${ve.errorText}\"`);\n }\n }\n\n if (result.successMessages.length > 0) {\n lines.push('\\n### Success / Feedback Messages (observed)');\n for (const sm of result.successMessages) {\n lines.push(`- ${sm.type}: \"${sm.content}\"`);\n }\n }\n\n if (result.modals.length > 0) {\n lines.push('\\n### Modal / Dialog Contents (observed)');\n for (const m of result.modals) {\n lines.push(`- **Trigger:** ${m.triggerElement}`);\n if (m.title) lines.push(` - Title: ${m.title}`);\n lines.push(` - Content: ${m.content.slice(0, 200)}`);\n if (m.fields.length > 0) lines.push(` - Fields: ${m.fields.join(', ')}`);\n }\n }\n\n if (result.conditionals.length > 0) {\n lines.push('\\n### Conditional UI (observed)');\n for (const c of result.conditionals) {\n lines.push(`- **${c.triggerElement}** = \"${c.triggerValue}\" reveals: ${c.revealedText}`);\n }\n }\n\n if (result.loadingPatterns.length > 0) {\n lines.push('\\n### Loading State Text (observed)');\n for (const lp of result.loadingPatterns) lines.push(`- \"${lp}\"`);\n }\n\n const full = lines.join('\\n');\n\n const errorSection = result.validationErrors.length > 0\n ? `## Real Validation Errors \u2014 ${result.url}\\n` +\n result.validationErrors.map((e) => `- **${e.field}**: \"${e.errorText}\"`).join('\\n')\n : '';\n\n const interactionSection = [\n result.successMessages.length > 0\n ? `## Success Messages \u2014 ${result.url}\\n` + result.successMessages.map((s) => `- ${s.type}: \"${s.content}\"`).join('\\n')\n : '',\n result.loadingPatterns.length > 0\n ? `## Loading Patterns \u2014 ${result.url}\\n` + result.loadingPatterns.map((lp) => `- \"${lp}\"`).join('\\n')\n : '',\n result.modals.length > 0\n ? `## Modal Contents \u2014 ${result.url}\\n` + result.modals.map((m) => `### ${m.triggerElement}\\n${m.content}`).join('\\n')\n : '',\n ].filter(Boolean).join('\\n\\n');\n\n return {\n errorCatalogueChunk: errorSection,\n interactionPatternsChunk: interactionSection,\n };\n}\n\n/**\n * Run interactive probing for all screens discovered in a crawl session.\n * Called from crawlProject() when opts.interactive = true.\n */\nexport async function probeAllScreens(\n page: any,\n tenantId: string,\n projectId: string,\n discovered: Array<{ url: string; screenId?: string; elements?: any[] }>,\n onProgress?: (pct: number, step: string) => Promise<void>,\n): Promise<void> {\n const probeable = discovered.filter((d) => d.screenId && d.url && !d.url.includes('#'));\n if (probeable.length === 0) return;\n\n const errorCatalogueChunks: string[] = [];\n const interactionChunks: string[] = [];\n\n for (let i = 0; i < probeable.length; i++) {\n const screen = probeable[i];\n const pct = 88 + Math.round((i / probeable.length) * 10);\n await onProgress?.(pct, `Probing screen ${i + 1}/${probeable.length}: ${screen.url}`);\n\n try {\n // Navigate to screen\n if (page.url() !== screen.url) {\n await page.goto(screen.url, { waitUntil: 'domcontentloaded' });\n await page.waitForTimeout(1500); // SPA hydration\n }\n\n const result = await probeScreen(page, screen.screenId!, screen.url, screen.elements ?? []);\n\n // Phase 5: save structured probe observations (replaces per-re-probe)\n const observations = probeResultToObservations(result);\n if (observations.length > 0) {\n await AppBrain.saveProbeObservations(tenantId, projectId, screen.screenId!, observations).catch(console.warn);\n }\n\n // Store raw probe result as KnowledgeChunk (kept for BM25 / oversized projects)\n const md = probeResultToMarkdown(result);\n if (md.errorCatalogueChunk) errorCatalogueChunks.push(md.errorCatalogueChunk);\n if (md.interactionPatternsChunk) interactionChunks.push(md.interactionPatternsChunk);\n\n const probeMarkdown = [md.errorCatalogueChunk, md.interactionPatternsChunk].filter(Boolean).join('\\n\\n');\n if (probeMarkdown.length > 50) {\n await AppBrain.saveKnowledgeChunk(tenantId, projectId, 'probe', screen.screenId!, probeMarkdown, `Probe \u2014 ${screen.url}`).catch(console.warn);\n }\n\n if (result.validationErrors.length > 0) {\n console.log(`[probe] ${screen.url}: ${result.validationErrors.length} validation errors, ${result.modals.length} modals`);\n }\n } catch (err) {\n console.warn(`[probe] \u2717 ${screen.url}:`, err instanceof Error ? err.message : err);\n }\n }\n\n // Update ERROR_CATALOGUE skill file with all real observed errors\n if (errorCatalogueChunks.length > 0) {\n const combined = `# Error Catalogue (Real Observed \u2014 Interactive Probe)\\n\\n${errorCatalogueChunks.join('\\n\\n')}`;\n await AppBrain.upsertSkillFile(tenantId, projectId, 'ERROR_CATALOGUE', combined, {\n summary: `Real validation error messages observed via interactive probe across ${errorCatalogueChunks.length} screens`,\n scope: 'PROJECT',\n }).catch(console.warn);\n }\n\n // Update INTERACTION_PATTERNS skill file with real observed patterns\n if (interactionChunks.length > 0) {\n const combined = `# Interaction Patterns (Real Observed \u2014 Interactive Probe)\\n\\n${interactionChunks.join('\\n\\n')}`;\n await AppBrain.upsertSkillFile(tenantId, projectId, 'INTERACTION_PATTERNS', combined, {\n summary: `Real loading states, success messages, and modal contents observed via interactive probe`,\n scope: 'PROJECT',\n }).catch(console.warn);\n }\n\n await onProgress?.(98, 'Interactive probe complete');\n console.log(`[probe] Probed ${probeable.length} screens. Errors: ${errorCatalogueChunks.length}, Interactions: ${interactionChunks.length}`);\n}\n\n// \u2500\u2500 Keyboard Navigation Probe \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface FocusableElement {\n tag: string;\n role: string | null;\n ariaLabel: string | null;\n text: string | null;\n hasFocusIndicator: boolean;\n tabIndex: number | null;\n}\n\nexport interface KeyboardNavResult {\n focusableCount: number;\n elementsWithNoFocusIndicator: number;\n sequence: FocusableElement[];\n issues: string[];\n}\n\n/**\n * Probe keyboard navigation accessibility by pressing Tab up to 50 times and\n * recording each focused element's properties and focus-indicator visibility.\n * Stops early if focus wraps back to the start or hits the same element twice.\n */\nexport async function probeKeyboardNav(page: Page): Promise<KeyboardNavResult> {\n const sequence: FocusableElement[] = [];\n const issues: string[] = [];\n const seenKeys = new Set<string>();\n let firstKey: string | null = null;\n\n for (let i = 0; i < 50; i++) {\n await page.keyboard.press('Tab');\n\n const el = await page.evaluate(() => {\n const active = document.activeElement as HTMLElement | null;\n if (!active || active === document.body) return null;\n const outline = window.getComputedStyle(active).outline;\n return {\n tag: active.tagName.toLowerCase(),\n role: active.getAttribute('role'),\n ariaLabel: active.getAttribute('aria-label'),\n text: (active.innerText ?? '').trim().slice(0, 60) || null,\n outline,\n tabIndex: active.tabIndex ?? null,\n };\n });\n\n if (!el) break;\n\n const hasFocusIndicator =\n el.outline !== 'none' &&\n el.outline !== '' &&\n !el.outline.startsWith('0px') &&\n !el.outline.startsWith('rgba(0, 0, 0, 0)') &&\n !el.outline.startsWith('rgba(0,0,0,0)');\n\n const focusable: FocusableElement = {\n tag: el.tag,\n role: el.role,\n ariaLabel: el.ariaLabel,\n text: el.text,\n hasFocusIndicator,\n tabIndex: el.tabIndex,\n };\n\n const key = `${el.tag}|${el.text ?? ''}|${el.role ?? ''}`;\n\n if (i === 0) {\n firstKey = key;\n } else if (key === firstKey || seenKeys.has(key)) {\n // Focus wrapped back to start or hit a duplicate \u2014 stop probing\n break;\n }\n\n seenKeys.add(key);\n sequence.push(focusable);\n\n if (!hasFocusIndicator) {\n issues.push(`Element #${i + 1} (${el.tag}) has no visible focus indicator`);\n }\n }\n\n return {\n focusableCount: sequence.length,\n elementsWithNoFocusIndicator: issues.length,\n sequence,\n issues,\n };\n}\n"],
4
+ "sourcesContent": ["/**\n * Interactive Probe \u2014 Phase 2 Dynamic Observation\n *\n * The static crawl captures DOM structure at rest. This module runs AFTER the static crawl\n * to observe dynamic behaviour by actually interacting with the UI:\n *\n * 1. Empty submit \u2192 captures required-field validation error messages\n * 2. Invalid format \u2192 captures format validation errors (bad email, short password)\n * 3. Button clicks \u2192 captures modal/dialog contents\n * 4. Select changes \u2192 captures conditional reveals (show/hide logic)\n * 5. Success paths \u2192 captures toast/banner messages (where input is safe)\n *\n * Results are stored as KnowledgeChunks (sourceType='probe') and update the\n * ERROR_CATALOGUE + INTERACTION_PATTERNS skill files with real observed data\n * instead of LLM inference.\n *\n * Call signature: probeScreen(page, screenId, elements, opts)\n * Called from crawlProject() after static element extraction when opts.interactive=true.\n */\n\nimport type { Page } from 'playwright';\nimport { AppBrain } from '@detiq/app-brain';\n\nexport interface ProbeElementFact {\n meaning: string;\n role: string;\n expectedData?: string;\n}\n\nexport interface ValidationError {\n field: string;\n trigger: 'empty_submit' | 'invalid_format' | 'boundary';\n errorText: string;\n}\n\nexport interface SuccessMessage {\n type: 'toast' | 'inline_success' | 'redirect';\n content: string;\n}\n\nexport interface ModalObservation {\n triggerElement: string;\n title: string;\n content: string;\n fields: string[];\n}\n\nexport interface ConditionalReveal {\n triggerElement: string;\n triggerValue: string;\n revealedText: string;\n}\n\nexport interface ProbeResult {\n screenId: string;\n url: string;\n validationErrors: ValidationError[];\n successMessages: SuccessMessage[];\n modals: ModalObservation[];\n conditionals: ConditionalReveal[];\n loadingPatterns: string[];\n}\n\n// \u2500\u2500 CSS selectors that reliably find error messages across frameworks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst ERROR_SELECTORS = [\n '[role=\"alert\"]',\n '[aria-live=\"assertive\"]',\n '[aria-live=\"polite\"]',\n '.invalid-feedback',\n '.field-error',\n '.error-message',\n '.form-error',\n '.validation-error',\n '[class*=\"error\" i]',\n '[class*=\"invalid\" i]',\n '[class*=\"Error\"]',\n 'p.text-red-500', 'p.text-red-600', 'p.text-destructive', 'p.text-danger',\n '[data-error]', '[data-invalid]',\n '.ant-form-item-explain-error', // Ant Design\n '.el-form-item__error', // Element UI\n '.chakra-form__error-message', // Chakra UI\n '.mantine-InputWrapper-error', // Mantine\n].join(', ');\n\n// \u2500\u2500 Toast / notification selectors \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst TOAST_SELECTORS = [\n '[role=\"status\"]',\n '[data-sonner-toast]',\n '[data-toast]',\n '[data-hot-toast]',\n '.Toastify__toast',\n '.toast-message',\n '.notification-body',\n '.notistack-snackbar',\n '[class*=\"toast\" i]',\n '[class*=\"Toast\"]',\n '[class*=\"snackbar\" i]',\n '[class*=\"notification\" i]',\n].join(', ');\n\n/** Wait up to `maxMs` for any selector to appear, then return its text content. */\nasync function waitForAny(page: Page, selector: string, maxMs = 2500): Promise<string[]> {\n try {\n await page.waitForSelector(selector, { timeout: maxMs, state: 'visible' });\n } catch { return []; }\n return page.$$eval(selector, (els) =>\n els.map((el) => (el as HTMLElement).innerText?.replace(/\\s+/g, ' ').trim()).filter(Boolean)\n );\n}\n\n/** Collect all currently-visible error messages. */\nasync function collectErrors(page: Page): Promise<string[]> {\n try {\n return page.$$eval(ERROR_SELECTORS, (els) =>\n els\n .filter((el) => {\n const style = window.getComputedStyle(el);\n return style.display !== 'none' && style.visibility !== 'hidden' && (el as HTMLElement).innerText?.trim().length > 0;\n })\n .map((el) => (el as HTMLElement).innerText?.replace(/\\s+/g, ' ').trim())\n .filter(Boolean)\n );\n } catch { return []; }\n}\n\n/** Collect any visible toast / notification content. */\nasync function collectToasts(page: Page): Promise<string[]> {\n try {\n return page.$$eval(TOAST_SELECTORS, (els) =>\n els\n .filter((el) => {\n const style = window.getComputedStyle(el);\n return style.display !== 'none' && (el as HTMLElement).innerText?.trim().length > 0;\n })\n .map((el) => (el as HTMLElement).innerText?.replace(/\\s+/g, ' ').trim())\n .filter(Boolean)\n );\n } catch { return []; }\n}\n\n/** Find the primary submit button on the page. */\nasync function findSubmitButton(page: Page): Promise<string | null> {\n return page.evaluate(() => {\n const SUBMIT_KW = /submit|send|save|create|confirm|continue|proceed|register|sign.?in|log.?in|apply|verify|search|next|finish|complete|order|buy|checkout/i;\n const candidates = Array.from(document.querySelectorAll('button, [role=\"button\"], input[type=\"submit\"]')) as HTMLElement[];\n const primary = candidates.find((el) => {\n const style = window.getComputedStyle(el);\n if (style.display === 'none' || style.visibility === 'hidden') return false;\n const text = el.innerText || (el as HTMLInputElement).value || el.getAttribute('aria-label') || '';\n return SUBMIT_KW.test(text);\n });\n if (!primary) return null;\n return primary.getAttribute('data-testid') || primary.getAttribute('aria-label') || primary.innerText?.trim().slice(0, 60);\n });\n}\n\n/** Find all modal-trigger buttons (non-submit buttons). */\nasync function findModalTriggers(page: Page): Promise<Array<{ selector: string; label: string }>> {\n return page.evaluate(() => {\n const SKIP = /submit|send|save|create|log.?in|sign.?in|register|continue|checkout|buy|order|search|close|cancel|dismiss/i;\n const NAV_PARENTS = new Set(['NAV', 'HEADER', 'FOOTER']);\n return Array.from(document.querySelectorAll('button, [role=\"button\"]'))\n .filter((el) => {\n const style = window.getComputedStyle(el as HTMLElement);\n if (style.display === 'none' || style.visibility === 'hidden') return false;\n const label = (el as HTMLElement).innerText?.trim() || el.getAttribute('aria-label') || '';\n if (!label || SKIP.test(label)) return false;\n // Skip nav chrome\n let p = el.parentElement;\n while (p) { if (NAV_PARENTS.has(p.tagName)) return false; p = p.parentElement; }\n return true;\n })\n .slice(0, 8) // probe at most 8 buttons per screen to keep it fast\n .map((el) => {\n const label = (el as HTMLElement).innerText?.trim() || el.getAttribute('aria-label') || 'button';\n return { selector: `[aria-label=\"${label}\"]`, label: label.slice(0, 60) };\n });\n });\n}\n\n/** Try to interact with select elements and observe conditional reveals. */\nasync function probeConditionals(page: Page): Promise<ConditionalReveal[]> {\n const results: ConditionalReveal[] = [];\n try {\n const selects = await page.$$('select');\n for (const sel of selects.slice(0, 5)) {\n const label = await sel.getAttribute('aria-label') || await sel.getAttribute('name') || 'select';\n const options = await sel.$$eval('option', (opts) => opts.slice(1, 3).map((o) => ({ value: o.value, text: o.textContent?.trim() })));\n for (const opt of options) {\n if (!opt.value) continue;\n const domBefore = await page.evaluate(() => document.body.innerText.length);\n await sel.selectOption(opt.value);\n await page.waitForTimeout(500);\n const domAfter = await page.evaluate(() => document.body.innerText.length);\n if (Math.abs(domAfter - domBefore) > 50) {\n const revealed = await page.evaluate(() => {\n // Grab text from any newly-visible sections\n return Array.from(document.querySelectorAll('[data-conditional],[data-show],[class*=\"conditional\"]'))\n .filter((el) => window.getComputedStyle(el).display !== 'none')\n .map((el) => (el as HTMLElement).innerText?.trim())\n .filter((t) => t && t.length > 10)\n .slice(0, 3)\n .join(' | ');\n });\n if (revealed) {\n results.push({ triggerElement: label, triggerValue: opt.text || opt.value, revealedText: revealed.slice(0, 200) });\n }\n }\n }\n }\n } catch { /* non-fatal */ }\n return results;\n}\n\n/**\n * Run interactive probing on a single screen page.\n * The page must already be navigated to the screen URL.\n * `elements` is the output of the static extractElements pass.\n */\nexport async function probeScreen(\n page: Page,\n screenId: string,\n url: string,\n elements: ProbeElementFact[],\n): Promise<ProbeResult> {\n const result: ProbeResult = {\n screenId, url,\n validationErrors: [], successMessages: [], modals: [], conditionals: [], loadingPatterns: [],\n };\n\n const formInputs = elements.filter((e) =>\n /input|text|email|password|tel|search|textarea|select/i.test(e.role)\n );\n const hasForm = formInputs.length > 0;\n\n // \u2500\u2500 Probe 1: Empty submit \u2192 capture required-field errors \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n if (hasForm) {\n try {\n const submitLabel = await findSubmitButton(page);\n if (submitLabel) {\n // Click submit with no data filled in\n const submitBtn = page.locator(`button:has-text(\"${submitLabel}\")`).first();\n await submitBtn.click({ timeout: 2000 });\n await page.waitForTimeout(1500);\n\n const errors = await collectErrors(page);\n for (const errorText of errors) {\n // Try to match error back to a field\n const matchedField = formInputs.find((el) =>\n errorText.toLowerCase().includes(el.meaning.toLowerCase().split(' ')[0]) ||\n el.meaning.toLowerCase().includes(errorText.toLowerCase().split(' ')[0])\n );\n result.validationErrors.push({\n field: matchedField?.meaning ?? 'unknown',\n trigger: 'empty_submit',\n errorText: errorText.slice(0, 150),\n });\n }\n\n // Check for loading state text during submit\n const loadingText = await page.evaluate(() => {\n const loading = Array.from(document.querySelectorAll('button, [aria-busy=\"true\"], [class*=\"loading\" i], [class*=\"spinner\" i]'))\n .map((el) => (el as HTMLElement).innerText?.trim())\n .filter((t) => t && /loading|saving|submitting|sending|processing/i.test(t));\n return loading.slice(0, 3);\n });\n result.loadingPatterns.push(...loadingText);\n }\n } catch { /* non-fatal \u2014 form probe failed, continue */ }\n }\n\n // \u2500\u2500 Probe 2: Invalid format inputs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n if (hasForm) {\n try {\n const emailField = formInputs.find((e) => e.expectedData === 'valid_email' || /email/i.test(e.meaning));\n const passwordField = formInputs.find((e) => e.expectedData === 'password' || /password/i.test(e.meaning));\n\n if (emailField) {\n const emailLocator = page.locator('input[type=\"email\"], input[name*=\"email\"], input[placeholder*=\"email\" i]').first();\n try {\n await emailLocator.fill('notanemail', { timeout: 1000 });\n await emailLocator.blur();\n await page.waitForTimeout(600);\n const errors = await collectErrors(page);\n for (const errorText of errors) {\n result.validationErrors.push({ field: emailField.meaning, trigger: 'invalid_format', errorText: errorText.slice(0, 150) });\n }\n } catch { /* locator not found */ }\n }\n\n if (passwordField) {\n const pwLocator = page.locator('input[type=\"password\"]').first();\n try {\n await pwLocator.fill('123', { timeout: 1000 });\n await pwLocator.blur();\n await page.waitForTimeout(600);\n const errors = await collectErrors(page);\n for (const errorText of errors) {\n if (!result.validationErrors.some((e) => e.errorText === errorText)) {\n result.validationErrors.push({ field: passwordField.meaning, trigger: 'invalid_format', errorText: errorText.slice(0, 150) });\n }\n }\n } catch { /* locator not found */ }\n }\n } catch { /* non-fatal */ }\n }\n\n // \u2500\u2500 Probe 3: Button clicks \u2192 discover modal/dialog contents \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n try {\n const triggers = await findModalTriggers(page);\n for (const trigger of triggers) {\n try {\n const btn = page.locator(`button:has-text(\"${trigger.label}\")`).first();\n const domBefore = await page.evaluate(() => document.body.innerText.length);\n\n await btn.click({ timeout: 1500 });\n await page.waitForTimeout(1000);\n\n // Check if a dialog/modal appeared\n const dialogText = await page.evaluate(() => {\n const DIALOG_SEL = '[role=\"dialog\"], [role=\"alertdialog\"], .modal, .Modal, [class*=\"modal\" i], [class*=\"dialog\" i], [class*=\"Dialog\"]';\n const dialogs = Array.from(document.querySelectorAll(DIALOG_SEL))\n .filter((el) => window.getComputedStyle(el).display !== 'none');\n if (dialogs.length === 0) return null;\n const d = dialogs[0] as HTMLElement;\n const title = d.querySelector('[role=\"heading\"], h1, h2, h3')?.textContent?.trim() || '';\n const content = d.innerText?.replace(/\\s+/g, ' ').trim().slice(0, 400);\n const fields = Array.from(d.querySelectorAll('input, select, textarea, button'))\n .map((f) => (f as HTMLElement).getAttribute('aria-label') || (f as HTMLElement).getAttribute('placeholder') || (f as HTMLInputElement).value || '')\n .filter((t) => t.trim().length > 0);\n return { title, content, fields };\n });\n\n if (dialogText && dialogText.content.length > 20) {\n result.modals.push({\n triggerElement: trigger.label,\n title: dialogText.title,\n content: dialogText.content,\n fields: dialogText.fields.slice(0, 10),\n });\n }\n\n // Dismiss: Escape key first, then close button\n await page.keyboard.press('Escape');\n await page.waitForTimeout(400);\n\n // Check if toast appeared after the click\n const toasts = await collectToasts(page);\n for (const t of toasts) {\n if (t.length > 5) result.successMessages.push({ type: 'toast', content: t.slice(0, 200) });\n }\n } catch { /* this button's probe failed, try next */ }\n }\n } catch { /* non-fatal */ }\n\n // \u2500\u2500 Probe 4: Select/radio \u2192 conditional reveals \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n try {\n const conditionals = await probeConditionals(page);\n result.conditionals.push(...conditionals);\n } catch { /* non-fatal */ }\n\n return result;\n}\n\n/** Phase 5: Convert ProbeResult to typed ProbeObservationFact rows for direct DB storage. */\nexport function probeResultToObservations(result: ProbeResult): Array<{\n elementMeaning: string; observationType: string; observedText: string; triggeredBy: string;\n}> {\n const obs: Array<{ elementMeaning: string; observationType: string; observedText: string; triggeredBy: string }> = [];\n for (const ve of result.validationErrors) {\n obs.push({ elementMeaning: ve.field, observationType: 'VALIDATION_ERROR', observedText: ve.errorText, triggeredBy: ve.trigger });\n }\n for (const sm of result.successMessages) {\n obs.push({ elementMeaning: '(page)', observationType: 'SUCCESS_TOAST', observedText: sm.content, triggeredBy: sm.type });\n }\n for (const m of result.modals) {\n obs.push({ elementMeaning: m.triggerElement, observationType: 'MODAL', observedText: [m.title, m.content].filter(Boolean).join(' \u2014 ').slice(0, 500), triggeredBy: `button_click:${m.triggerElement}` });\n }\n for (const c of result.conditionals) {\n obs.push({ elementMeaning: c.triggerElement, observationType: 'CONDITIONAL_REVEAL', observedText: c.revealedText, triggeredBy: `select_change:${c.triggerElement}:${c.triggerValue}` });\n }\n for (const lp of result.loadingPatterns) {\n obs.push({ elementMeaning: '(submit button)', observationType: 'LOADING_STATE', observedText: lp, triggeredBy: 'form_submit' });\n }\n return obs;\n}\n\n/**\n * Convert a ProbeResult into markdown to store as KnowledgeChunks\n * and update ERROR_CATALOGUE + INTERACTION_PATTERNS skill files.\n */\nexport function probeResultToMarkdown(result: ProbeResult): {\n errorCatalogueChunk: string;\n interactionPatternsChunk: string;\n} {\n const lines: string[] = [`## Probe Observations \u2014 ${result.url}\\n`];\n\n if (result.validationErrors.length > 0) {\n lines.push('### Validation Errors (observed)');\n for (const ve of result.validationErrors) {\n lines.push(`- **${ve.field}** (${ve.trigger}): \"${ve.errorText}\"`);\n }\n }\n\n if (result.successMessages.length > 0) {\n lines.push('\\n### Success / Feedback Messages (observed)');\n for (const sm of result.successMessages) {\n lines.push(`- ${sm.type}: \"${sm.content}\"`);\n }\n }\n\n if (result.modals.length > 0) {\n lines.push('\\n### Modal / Dialog Contents (observed)');\n for (const m of result.modals) {\n lines.push(`- **Trigger:** ${m.triggerElement}`);\n if (m.title) lines.push(` - Title: ${m.title}`);\n lines.push(` - Content: ${m.content.slice(0, 200)}`);\n if (m.fields.length > 0) lines.push(` - Fields: ${m.fields.join(', ')}`);\n }\n }\n\n if (result.conditionals.length > 0) {\n lines.push('\\n### Conditional UI (observed)');\n for (const c of result.conditionals) {\n lines.push(`- **${c.triggerElement}** = \"${c.triggerValue}\" reveals: ${c.revealedText}`);\n }\n }\n\n if (result.loadingPatterns.length > 0) {\n lines.push('\\n### Loading State Text (observed)');\n for (const lp of result.loadingPatterns) lines.push(`- \"${lp}\"`);\n }\n\n const full = lines.join('\\n');\n\n const errorSection = result.validationErrors.length > 0\n ? `## Real Validation Errors \u2014 ${result.url}\\n` +\n result.validationErrors.map((e) => `- **${e.field}**: \"${e.errorText}\"`).join('\\n')\n : '';\n\n const interactionSection = [\n result.successMessages.length > 0\n ? `## Success Messages \u2014 ${result.url}\\n` + result.successMessages.map((s) => `- ${s.type}: \"${s.content}\"`).join('\\n')\n : '',\n result.loadingPatterns.length > 0\n ? `## Loading Patterns \u2014 ${result.url}\\n` + result.loadingPatterns.map((lp) => `- \"${lp}\"`).join('\\n')\n : '',\n result.modals.length > 0\n ? `## Modal Contents \u2014 ${result.url}\\n` + result.modals.map((m) => `### ${m.triggerElement}\\n${m.content}`).join('\\n')\n : '',\n ].filter(Boolean).join('\\n\\n');\n\n return {\n errorCatalogueChunk: errorSection,\n interactionPatternsChunk: interactionSection,\n };\n}\n\n/**\n * Run interactive probing for all screens discovered in a crawl session.\n * Called from crawlProject() when opts.interactive = true.\n */\nexport async function probeAllScreens(\n page: any,\n tenantId: string,\n projectId: string,\n discovered: Array<{ url: string; screenId?: string; elements?: any[] }>,\n onProgress?: (pct: number, step: string) => Promise<void>,\n): Promise<void> {\n const probeable = discovered.filter((d) => d.screenId && d.url && !d.url.includes('#'));\n if (probeable.length === 0) return;\n\n const errorCatalogueChunks: string[] = [];\n const interactionChunks: string[] = [];\n\n for (let i = 0; i < probeable.length; i++) {\n const screen = probeable[i];\n const pct = 88 + Math.round((i / probeable.length) * 10);\n await onProgress?.(pct, `Probing screen ${i + 1}/${probeable.length}: ${screen.url}`);\n\n try {\n // Navigate to screen\n if (page.url() !== screen.url) {\n await page.goto(screen.url, { waitUntil: 'domcontentloaded' });\n await page.waitForTimeout(1500); // SPA hydration\n }\n\n const result = await probeScreen(page, screen.screenId!, screen.url, screen.elements ?? []);\n\n // Phase 5: save structured probe observations (replaces per-re-probe)\n const observations = probeResultToObservations(result);\n if (observations.length > 0) {\n await AppBrain.saveProbeObservations(tenantId, projectId, screen.screenId!, observations).catch(console.warn);\n }\n\n // Store raw probe result as KnowledgeChunk (kept for BM25 / oversized projects)\n const md = probeResultToMarkdown(result);\n if (md.errorCatalogueChunk) errorCatalogueChunks.push(md.errorCatalogueChunk);\n if (md.interactionPatternsChunk) interactionChunks.push(md.interactionPatternsChunk);\n\n const probeMarkdown = [md.errorCatalogueChunk, md.interactionPatternsChunk].filter(Boolean).join('\\n\\n');\n if (probeMarkdown.length > 50) {\n await AppBrain.saveKnowledgeChunk(tenantId, projectId, 'probe', screen.screenId!, probeMarkdown, `Probe \u2014 ${screen.url}`).catch(console.warn);\n }\n\n if (result.validationErrors.length > 0) {\n console.log(`[probe] ${screen.url}: ${result.validationErrors.length} validation errors, ${result.modals.length} modals`);\n }\n } catch (err) {\n console.warn(`[probe] \u2717 ${screen.url}:`, err instanceof Error ? err.message : err);\n }\n }\n\n // Update ERROR_CATALOGUE skill file with all real observed errors\n if (errorCatalogueChunks.length > 0) {\n const combined = `# Error Catalogue (Real Observed \u2014 Interactive Probe)\\n\\n${errorCatalogueChunks.join('\\n\\n')}`;\n await AppBrain.upsertSkillFile(tenantId, projectId, 'ERROR_CATALOGUE', combined, {\n summary: `Real validation error messages observed via interactive probe across ${errorCatalogueChunks.length} screens`,\n scope: 'PROJECT',\n }).catch(console.warn);\n }\n\n // Update INTERACTION_PATTERNS skill file with real observed patterns\n if (interactionChunks.length > 0) {\n const combined = `# Interaction Patterns (Real Observed \u2014 Interactive Probe)\\n\\n${interactionChunks.join('\\n\\n')}`;\n await AppBrain.upsertSkillFile(tenantId, projectId, 'INTERACTION_PATTERNS', combined, {\n summary: `Real loading states, success messages, and modal contents observed via interactive probe`,\n scope: 'PROJECT',\n }).catch(console.warn);\n }\n\n await onProgress?.(98, 'Interactive probe complete');\n console.log(`[probe] Probed ${probeable.length} screens. Errors: ${errorCatalogueChunks.length}, Interactions: ${interactionChunks.length}`);\n}\n\n// \u2500\u2500 Keyboard Navigation Probe \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface FocusableElement {\n tag: string;\n role: string | null;\n ariaLabel: string | null;\n text: string | null;\n hasFocusIndicator: boolean;\n tabIndex: number | null;\n}\n\nexport interface KeyboardNavResult {\n focusableCount: number;\n elementsWithNoFocusIndicator: number;\n sequence: FocusableElement[];\n issues: string[];\n}\n\n/**\n * Probe keyboard navigation accessibility by pressing Tab up to 50 times and\n * recording each focused element's properties and focus-indicator visibility.\n * Stops early if focus wraps back to the start or hits the same element twice.\n */\nexport async function probeKeyboardNav(page: Page): Promise<KeyboardNavResult> {\n const sequence: FocusableElement[] = [];\n const issues: string[] = [];\n const seenKeys = new Set<string>();\n let firstKey: string | null = null;\n\n for (let i = 0; i < 50; i++) {\n await page.keyboard.press('Tab');\n\n const el = await page.evaluate(() => {\n const active = document.activeElement as HTMLElement | null;\n if (!active || active === document.body) return null;\n const outline = window.getComputedStyle(active).outline;\n return {\n tag: active.tagName.toLowerCase(),\n role: active.getAttribute('role'),\n ariaLabel: active.getAttribute('aria-label'),\n text: (active.innerText ?? '').trim().slice(0, 60) || null,\n outline,\n tabIndex: active.tabIndex ?? null,\n };\n });\n\n if (!el) break;\n\n const hasFocusIndicator =\n el.outline !== 'none' &&\n el.outline !== '' &&\n !el.outline.startsWith('0px') &&\n !el.outline.startsWith('rgba(0, 0, 0, 0)') &&\n !el.outline.startsWith('rgba(0,0,0,0)');\n\n const focusable: FocusableElement = {\n tag: el.tag,\n role: el.role,\n ariaLabel: el.ariaLabel,\n text: el.text,\n hasFocusIndicator,\n tabIndex: el.tabIndex,\n };\n\n const key = `${el.tag}|${el.text ?? ''}|${el.role ?? ''}`;\n\n if (i === 0) {\n firstKey = key;\n } else if (key === firstKey || seenKeys.has(key)) {\n // Focus wrapped back to start or hit a duplicate \u2014 stop probing\n break;\n }\n\n seenKeys.add(key);\n sequence.push(focusable);\n\n if (!hasFocusIndicator) {\n issues.push(`Element #${i + 1} (${el.tag}) has no visible focus indicator`);\n }\n }\n\n return {\n focusableCount: sequence.length,\n elementsWithNoFocusIndicator: issues.length,\n sequence,\n issues,\n };\n}\n"],
5
5
  "mappings": "AAqBA,SAAS,gBAAgB;AA2CzB,MAAM,kBAAkB;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAAkB;AAAA,EAAkB;AAAA,EAAsB;AAAA,EAC1D;AAAA,EAAgB;AAAA,EAChB;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AACF,EAAE,KAAK,IAAI;AAGX,MAAM,kBAAkB;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,EAAE,KAAK,IAAI;AAGX,eAAe,WAAW,MAAY,UAAkB,QAAQ,MAAyB;AACvF,MAAI;AACF,UAAM,KAAK,gBAAgB,UAAU,EAAE,SAAS,OAAO,OAAO,UAAU,CAAC;AAAA,EAC3E,QAAQ;AAAE,WAAO,CAAC;AAAA,EAAG;AACrB,SAAO,KAAK;AAAA,IAAO;AAAA,IAAU,CAAC,QAC5B,IAAI,IAAI,CAAC,OAAQ,GAAmB,WAAW,QAAQ,QAAQ,GAAG,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO;AAAA,EAC5F;AACF;AAGA,eAAe,cAAc,MAA+B;AAC1D,MAAI;AACF,WAAO,KAAK;AAAA,MAAO;AAAA,MAAiB,CAAC,QACnC,IACG,OAAO,CAAC,OAAO;AACd,cAAM,QAAQ,OAAO,iBAAiB,EAAE;AACxC,eAAO,MAAM,YAAY,UAAU,MAAM,eAAe,YAAa,GAAmB,WAAW,KAAK,EAAE,SAAS;AAAA,MACrH,CAAC,EACA,IAAI,CAAC,OAAQ,GAAmB,WAAW,QAAQ,QAAQ,GAAG,EAAE,KAAK,CAAC,EACtE,OAAO,OAAO;AAAA,IACnB;AAAA,EACF,QAAQ;AAAE,WAAO,CAAC;AAAA,EAAG;AACvB;AAGA,eAAe,cAAc,MAA+B;AAC1D,MAAI;AACF,WAAO,KAAK;AAAA,MAAO;AAAA,MAAiB,CAAC,QACnC,IACG,OAAO,CAAC,OAAO;AACd,cAAM,QAAQ,OAAO,iBAAiB,EAAE;AACxC,eAAO,MAAM,YAAY,UAAW,GAAmB,WAAW,KAAK,EAAE,SAAS;AAAA,MACpF,CAAC,EACA,IAAI,CAAC,OAAQ,GAAmB,WAAW,QAAQ,QAAQ,GAAG,EAAE,KAAK,CAAC,EACtE,OAAO,OAAO;AAAA,IACnB;AAAA,EACF,QAAQ;AAAE,WAAO,CAAC;AAAA,EAAG;AACvB;AAGA,eAAe,iBAAiB,MAAoC;AAClE,SAAO,KAAK,SAAS,MAAM;AACzB,UAAM,YAAY;AAClB,UAAM,aAAa,MAAM,KAAK,SAAS,iBAAiB,+CAA+C,CAAC;AACxG,UAAM,UAAU,WAAW,KAAK,CAAC,OAAO;AACtC,YAAM,QAAQ,OAAO,iBAAiB,EAAE;AACxC,UAAI,MAAM,YAAY,UAAU,MAAM,eAAe,SAAU,QAAO;AACtE,YAAM,OAAO,GAAG,aAAc,GAAwB,SAAS,GAAG,aAAa,YAAY,KAAK;AAChG,aAAO,UAAU,KAAK,IAAI;AAAA,IAC5B,CAAC;AACD,QAAI,CAAC,QAAS,QAAO;AACrB,WAAO,QAAQ,aAAa,aAAa,KAAK,QAAQ,aAAa,YAAY,KAAK,QAAQ,WAAW,KAAK,EAAE,MAAM,GAAG,EAAE;AAAA,EAC3H,CAAC;AACH;AAGA,eAAe,kBAAkB,MAAiE;AAChG,SAAO,KAAK,SAAS,MAAM;AACzB,UAAM,OAAO;AACb,UAAM,cAAc,oBAAI,IAAI,CAAC,OAAO,UAAU,QAAQ,CAAC;AACvD,WAAO,MAAM,KAAK,SAAS,iBAAiB,yBAAyB,CAAC,EACnE,OAAO,CAAC,OAAO;AACd,YAAM,QAAQ,OAAO,iBAAiB,EAAiB;AACvD,UAAI,MAAM,YAAY,UAAU,MAAM,eAAe,SAAU,QAAO;AACtE,YAAM,QAAS,GAAmB,WAAW,KAAK,KAAK,GAAG,aAAa,YAAY,KAAK;AACxF,UAAI,CAAC,SAAS,KAAK,KAAK,KAAK,EAAG,QAAO;AAEvC,UAAI,IAAI,GAAG;AACX,aAAO,GAAG;AAAE,YAAI,YAAY,IAAI,EAAE,OAAO,EAAG,QAAO;AAAO,YAAI,EAAE;AAAA,MAAe;AAC/E,aAAO;AAAA,IACT,CAAC,EACA,MAAM,GAAG,CAAC,EACV,IAAI,CAAC,OAAO;AACX,YAAM,QAAS,GAAmB,WAAW,KAAK,KAAK,GAAG,aAAa,YAAY,KAAK;AACxF,aAAO,EAAE,UAAU,gBAAgB,KAAK,MAAM,OAAO,MAAM,MAAM,GAAG,EAAE,EAAE;AAAA,IAC1E,CAAC;AAAA,EACL,CAAC;AACH;AAGA,eAAe,kBAAkB,MAA0C;AACzE,QAAM,UAA+B,CAAC;AACtC,MAAI;AACF,UAAM,UAAU,MAAM,KAAK,GAAG,QAAQ;AACtC,eAAW,OAAO,QAAQ,MAAM,GAAG,CAAC,GAAG;AACrC,YAAM,QAAQ,MAAM,IAAI,aAAa,YAAY,KAAK,MAAM,IAAI,aAAa,MAAM,KAAK;AACxF,YAAM,UAAU,MAAM,IAAI,OAAO,UAAU,CAAC,SAAS,KAAK,MAAM,GAAG,CAAC,EAAE,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,MAAM,EAAE,aAAa,KAAK,EAAE,EAAE,CAAC;AACnI,iBAAW,OAAO,SAAS;AACzB,YAAI,CAAC,IAAI,MAAO;AAChB,cAAM,YAAY,MAAM,KAAK,SAAS,MAAM,SAAS,KAAK,UAAU,MAAM;AAC1E,cAAM,IAAI,aAAa,IAAI,KAAK;AAChC,cAAM,KAAK,eAAe,GAAG;AAC7B,cAAM,WAAW,MAAM,KAAK,SAAS,MAAM,SAAS,KAAK,UAAU,MAAM;AACzE,YAAI,KAAK,IAAI,WAAW,SAAS,IAAI,IAAI;AACvC,gBAAM,WAAW,MAAM,KAAK,SAAS,MAAM;AAEzC,mBAAO,MAAM,KAAK,SAAS,iBAAiB,uDAAuD,CAAC,EACjG,OAAO,CAAC,OAAO,OAAO,iBAAiB,EAAE,EAAE,YAAY,MAAM,EAC7D,IAAI,CAAC,OAAQ,GAAmB,WAAW,KAAK,CAAC,EACjD,OAAO,CAAC,MAAM,KAAK,EAAE,SAAS,EAAE,EAChC,MAAM,GAAG,CAAC,EACV,KAAK,KAAK;AAAA,UACf,CAAC;AACD,cAAI,UAAU;AACZ,oBAAQ,KAAK,EAAE,gBAAgB,OAAO,cAAc,IAAI,QAAQ,IAAI,OAAO,cAAc,SAAS,MAAM,GAAG,GAAG,EAAE,CAAC;AAAA,UACnH;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF,QAAQ;AAAA,EAAkB;AAC1B,SAAO;AACT;AAOA,eAAsB,YACpB,MACA,UACA,KACA,UACsB;AACtB,QAAM,SAAsB;AAAA,IAC1B;AAAA,IAAU;AAAA,IACV,kBAAkB,CAAC;AAAA,IAAG,iBAAiB,CAAC;AAAA,IAAG,QAAQ,CAAC;AAAA,IAAG,cAAc,CAAC;AAAA,IAAG,iBAAiB,CAAC;AAAA,EAC7F;AAEA,QAAM,aAAa,SAAS;AAAA,IAAO,CAAC,MAClC,wDAAwD,KAAK,EAAE,IAAI;AAAA,EACrE;AACA,QAAM,UAAU,WAAW,SAAS;AAGpC,MAAI,SAAS;AACX,QAAI;AACF,YAAM,cAAc,MAAM,iBAAiB,IAAI;AAC/C,UAAI,aAAa;AAEf,cAAM,YAAY,KAAK,QAAQ,oBAAoB,WAAW,IAAI,EAAE,MAAM;AAC1E,cAAM,UAAU,MAAM,EAAE,SAAS,IAAK,CAAC;AACvC,cAAM,KAAK,eAAe,IAAI;AAE9B,cAAM,SAAS,MAAM,cAAc,IAAI;AACvC,mBAAW,aAAa,QAAQ;AAE9B,gBAAM,eAAe,WAAW;AAAA,YAAK,CAAC,OACpC,UAAU,YAAY,EAAE,SAAS,GAAG,QAAQ,YAAY,EAAE,MAAM,GAAG,EAAE,CAAC,CAAC,KACvE,GAAG,QAAQ,YAAY,EAAE,SAAS,UAAU,YAAY,EAAE,MAAM,GAAG,EAAE,CAAC,CAAC;AAAA,UACzE;AACA,iBAAO,iBAAiB,KAAK;AAAA,YAC3B,OAAO,cAAc,WAAW;AAAA,YAChC,SAAS;AAAA,YACT,WAAW,UAAU,MAAM,GAAG,GAAG;AAAA,UACnC,CAAC;AAAA,QACH;AAGA,cAAM,cAAc,MAAM,KAAK,SAAS,MAAM;AAC5C,gBAAM,UAAU,MAAM,KAAK,SAAS,iBAAiB,wEAAwE,CAAC,EAC3H,IAAI,CAAC,OAAQ,GAAmB,WAAW,KAAK,CAAC,EACjD,OAAO,CAAC,MAAM,KAAK,gDAAgD,KAAK,CAAC,CAAC;AAC7E,iBAAO,QAAQ,MAAM,GAAG,CAAC;AAAA,QAC3B,CAAC;AACD,eAAO,gBAAgB,KAAK,GAAG,WAAW;AAAA,MAC5C;AAAA,IACF,QAAQ;AAAA,IAAgD;AAAA,EAC1D;AAGA,MAAI,SAAS;AACX,QAAI;AACF,YAAM,aAAa,WAAW,KAAK,CAAC,MAAM,EAAE,iBAAiB,iBAAiB,SAAS,KAAK,EAAE,OAAO,CAAC;AACtG,YAAM,gBAAgB,WAAW,KAAK,CAAC,MAAM,EAAE,iBAAiB,cAAc,YAAY,KAAK,EAAE,OAAO,CAAC;AAEzG,UAAI,YAAY;AACd,cAAM,eAAe,KAAK,QAAQ,0EAA0E,EAAE,MAAM;AACpH,YAAI;AACF,gBAAM,aAAa,KAAK,cAAc,EAAE,SAAS,IAAK,CAAC;AACvD,gBAAM,aAAa,KAAK;AACxB,gBAAM,KAAK,eAAe,GAAG;AAC7B,gBAAM,SAAS,MAAM,cAAc,IAAI;AACvC,qBAAW,aAAa,QAAQ;AAC9B,mBAAO,iBAAiB,KAAK,EAAE,OAAO,WAAW,SAAS,SAAS,kBAAkB,WAAW,UAAU,MAAM,GAAG,GAAG,EAAE,CAAC;AAAA,UAC3H;AAAA,QACF,QAAQ;AAAA,QAA0B;AAAA,MACpC;AAEA,UAAI,eAAe;AACjB,cAAM,YAAY,KAAK,QAAQ,wBAAwB,EAAE,MAAM;AAC/D,YAAI;AACF,gBAAM,UAAU,KAAK,OAAO,EAAE,SAAS,IAAK,CAAC;AAC7C,gBAAM,UAAU,KAAK;AACrB,gBAAM,KAAK,eAAe,GAAG;AAC7B,gBAAM,SAAS,MAAM,cAAc,IAAI;AACvC,qBAAW,aAAa,QAAQ;AAC9B,gBAAI,CAAC,OAAO,iBAAiB,KAAK,CAAC,MAAM,EAAE,cAAc,SAAS,GAAG;AACnE,qBAAO,iBAAiB,KAAK,EAAE,OAAO,cAAc,SAAS,SAAS,kBAAkB,WAAW,UAAU,MAAM,GAAG,GAAG,EAAE,CAAC;AAAA,YAC9H;AAAA,UACF;AAAA,QACF,QAAQ;AAAA,QAA0B;AAAA,MACpC;AAAA,IACF,QAAQ;AAAA,IAAkB;AAAA,EAC5B;AAGA,MAAI;AACF,UAAM,WAAW,MAAM,kBAAkB,IAAI;AAC7C,eAAW,WAAW,UAAU;AAC9B,UAAI;AACF,cAAM,MAAM,KAAK,QAAQ,oBAAoB,QAAQ,KAAK,IAAI,EAAE,MAAM;AACtE,cAAM,YAAY,MAAM,KAAK,SAAS,MAAM,SAAS,KAAK,UAAU,MAAM;AAE1E,cAAM,IAAI,MAAM,EAAE,SAAS,KAAK,CAAC;AACjC,cAAM,KAAK,eAAe,GAAI;AAG9B,cAAM,aAAa,MAAM,KAAK,SAAS,MAAM;AAC3C,gBAAM,aAAa;AACnB,gBAAM,UAAU,MAAM,KAAK,SAAS,iBAAiB,UAAU,CAAC,EAC7D,OAAO,CAAC,OAAO,OAAO,iBAAiB,EAAE,EAAE,YAAY,MAAM;AAChE,cAAI,QAAQ,WAAW,EAAG,QAAO;AACjC,gBAAM,IAAI,QAAQ,CAAC;AACnB,gBAAM,QAAQ,EAAE,cAAc,8BAA8B,GAAG,aAAa,KAAK,KAAK;AACtF,gBAAM,UAAU,EAAE,WAAW,QAAQ,QAAQ,GAAG,EAAE,KAAK,EAAE,MAAM,GAAG,GAAG;AACrE,gBAAM,SAAS,MAAM,KAAK,EAAE,iBAAiB,iCAAiC,CAAC,EAC5E,IAAI,CAAC,MAAO,EAAkB,aAAa,YAAY,KAAM,EAAkB,aAAa,aAAa,KAAM,EAAuB,SAAS,EAAE,EACjJ,OAAO,CAAC,MAAM,EAAE,KAAK,EAAE,SAAS,CAAC;AACpC,iBAAO,EAAE,OAAO,SAAS,OAAO;AAAA,QAClC,CAAC;AAED,YAAI,cAAc,WAAW,QAAQ,SAAS,IAAI;AAChD,iBAAO,OAAO,KAAK;AAAA,YACjB,gBAAgB,QAAQ;AAAA,YACxB,OAAO,WAAW;AAAA,YAClB,SAAS,WAAW;AAAA,YACpB,QAAQ,WAAW,OAAO,MAAM,GAAG,EAAE;AAAA,UACvC,CAAC;AAAA,QACH;AAGA,cAAM,KAAK,SAAS,MAAM,QAAQ;AAClC,cAAM,KAAK,eAAe,GAAG;AAG7B,cAAM,SAAS,MAAM,cAAc,IAAI;AACvC,mBAAW,KAAK,QAAQ;AACtB,cAAI,EAAE,SAAS,EAAG,QAAO,gBAAgB,KAAK,EAAE,MAAM,SAAS,SAAS,EAAE,MAAM,GAAG,GAAG,EAAE,CAAC;AAAA,QAC3F;AAAA,MACF,QAAQ;AAAA,MAA6C;AAAA,IACvD;AAAA,EACF,QAAQ;AAAA,EAAkB;AAG1B,MAAI;AACF,UAAM,eAAe,MAAM,kBAAkB,IAAI;AACjD,WAAO,aAAa,KAAK,GAAG,YAAY;AAAA,EAC1C,QAAQ;AAAA,EAAkB;AAE1B,SAAO;AACT;AAGO,SAAS,0BAA0B,QAEvC;AACD,QAAM,MAA6G,CAAC;AACpH,aAAW,MAAM,OAAO,kBAAkB;AACxC,QAAI,KAAK,EAAE,gBAAgB,GAAG,OAAO,iBAAiB,oBAAoB,cAAc,GAAG,WAAW,aAAa,GAAG,QAAQ,CAAC;AAAA,EACjI;AACA,aAAW,MAAM,OAAO,iBAAiB;AACvC,QAAI,KAAK,EAAE,gBAAgB,UAAU,iBAAiB,iBAAiB,cAAc,GAAG,SAAS,aAAa,GAAG,KAAK,CAAC;AAAA,EACzH;AACA,aAAW,KAAK,OAAO,QAAQ;AAC7B,QAAI,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,iBAAiB,SAAS,cAAc,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,OAAO,EAAE,KAAK,UAAK,EAAE,MAAM,GAAG,GAAG,GAAG,aAAa,gBAAgB,EAAE,cAAc,GAAG,CAAC;AAAA,EACxM;AACA,aAAW,KAAK,OAAO,cAAc;AACnC,QAAI,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,iBAAiB,sBAAsB,cAAc,EAAE,cAAc,aAAa,iBAAiB,EAAE,cAAc,IAAI,EAAE,YAAY,GAAG,CAAC;AAAA,EACxL;AACA,aAAW,MAAM,OAAO,iBAAiB;AACvC,QAAI,KAAK,EAAE,gBAAgB,mBAAmB,iBAAiB,iBAAiB,cAAc,IAAI,aAAa,cAAc,CAAC;AAAA,EAChI;AACA,SAAO;AACT;AAMO,SAAS,sBAAsB,QAGpC;AACA,QAAM,QAAkB,CAAC,gCAA2B,OAAO,GAAG;AAAA,CAAI;AAElE,MAAI,OAAO,iBAAiB,SAAS,GAAG;AACtC,UAAM,KAAK,kCAAkC;AAC7C,eAAW,MAAM,OAAO,kBAAkB;AACxC,YAAM,KAAK,OAAO,GAAG,KAAK,OAAO,GAAG,OAAO,OAAO,GAAG,SAAS,GAAG;AAAA,IACnE;AAAA,EACF;AAEA,MAAI,OAAO,gBAAgB,SAAS,GAAG;AACrC,UAAM,KAAK,8CAA8C;AACzD,eAAW,MAAM,OAAO,iBAAiB;AACvC,YAAM,KAAK,KAAK,GAAG,IAAI,MAAM,GAAG,OAAO,GAAG;AAAA,IAC5C;AAAA,EACF;AAEA,MAAI,OAAO,OAAO,SAAS,GAAG;AAC5B,UAAM,KAAK,0CAA0C;AACrD,eAAW,KAAK,OAAO,QAAQ;AAC7B,YAAM,KAAK,kBAAkB,EAAE,cAAc,EAAE;AAC/C,UAAI,EAAE,MAAO,OAAM,KAAK,cAAc,EAAE,KAAK,EAAE;AAC/C,YAAM,KAAK,gBAAgB,EAAE,QAAQ,MAAM,GAAG,GAAG,CAAC,EAAE;AACpD,UAAI,EAAE,OAAO,SAAS,EAAG,OAAM,KAAK,eAAe,EAAE,OAAO,KAAK,IAAI,CAAC,EAAE;AAAA,IAC1E;AAAA,EACF;AAEA,MAAI,OAAO,aAAa,SAAS,GAAG;AAClC,UAAM,KAAK,iCAAiC;AAC5C,eAAW,KAAK,OAAO,cAAc;AACnC,YAAM,KAAK,OAAO,EAAE,cAAc,SAAS,EAAE,YAAY,cAAc,EAAE,YAAY,EAAE;AAAA,IACzF;AAAA,EACF;AAEA,MAAI,OAAO,gBAAgB,SAAS,GAAG;AACrC,UAAM,KAAK,qCAAqC;AAChD,eAAW,MAAM,OAAO,gBAAiB,OAAM,KAAK,MAAM,EAAE,GAAG;AAAA,EACjE;AAEA,QAAM,OAAO,MAAM,KAAK,IAAI;AAE5B,QAAM,eAAe,OAAO,iBAAiB,SAAS,IAClD,oCAA+B,OAAO,GAAG;AAAA,IACzC,OAAO,iBAAiB,IAAI,CAAC,MAAM,OAAO,EAAE,KAAK,QAAQ,EAAE,SAAS,GAAG,EAAE,KAAK,IAAI,IAClF;AAEJ,QAAM,qBAAqB;AAAA,IACzB,OAAO,gBAAgB,SAAS,IAC5B,8BAAyB,OAAO,GAAG;AAAA,IAAO,OAAO,gBAAgB,IAAI,CAAC,MAAM,KAAK,EAAE,IAAI,MAAM,EAAE,OAAO,GAAG,EAAE,KAAK,IAAI,IACpH;AAAA,IACJ,OAAO,gBAAgB,SAAS,IAC5B,8BAAyB,OAAO,GAAG;AAAA,IAAO,OAAO,gBAAgB,IAAI,CAAC,OAAO,MAAM,EAAE,GAAG,EAAE,KAAK,IAAI,IACnG;AAAA,IACJ,OAAO,OAAO,SAAS,IACnB,4BAAuB,OAAO,GAAG;AAAA,IAAO,OAAO,OAAO,IAAI,CAAC,MAAM,OAAO,EAAE,cAAc;AAAA,EAAK,EAAE,OAAO,EAAE,EAAE,KAAK,IAAI,IACnH;AAAA,EACN,EAAE,OAAO,OAAO,EAAE,KAAK,MAAM;AAE7B,SAAO;AAAA,IACL,qBAAqB;AAAA,IACrB,0BAA0B;AAAA,EAC5B;AACF;AAMA,eAAsB,gBACpB,MACA,UACA,WACA,YACA,YACe;AACf,QAAM,YAAY,WAAW,OAAO,CAAC,MAAM,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE,IAAI,SAAS,GAAG,CAAC;AACtF,MAAI,UAAU,WAAW,EAAG;AAE5B,QAAM,uBAAiC,CAAC;AACxC,QAAM,oBAA8B,CAAC;AAErC,WAAS,IAAI,GAAG,IAAI,UAAU,QAAQ,KAAK;AACzC,UAAM,SAAS,UAAU,CAAC;AAC1B,UAAM,MAAM,KAAK,KAAK,MAAO,IAAI,UAAU,SAAU,EAAE;AACvD,UAAM,aAAa,KAAK,kBAAkB,IAAI,CAAC,IAAI,UAAU,MAAM,KAAK,OAAO,GAAG,EAAE;AAEpF,QAAI;AAEF,UAAI,KAAK,IAAI,MAAM,OAAO,KAAK;AAC7B,cAAM,KAAK,KAAK,OAAO,KAAK,EAAE,WAAW,mBAAmB,CAAC;AAC7D,cAAM,KAAK,eAAe,IAAI;AAAA,MAChC;AAEA,YAAM,SAAS,MAAM,YAAY,MAAM,OAAO,UAAW,OAAO,KAAK,OAAO,YAAY,CAAC,CAAC;AAG1F,YAAM,eAAe,0BAA0B,MAAM;AACrD,UAAI,aAAa,SAAS,GAAG;AAC3B,cAAM,SAAS,sBAAsB,UAAU,WAAW,OAAO,UAAW,YAAY,EAAE,MAAM,QAAQ,IAAI;AAAA,MAC9G;AAGA,YAAM,KAAK,sBAAsB,MAAM;AACvC,UAAI,GAAG,oBAAqB,sBAAqB,KAAK,GAAG,mBAAmB;AAC5E,UAAI,GAAG,yBAA0B,mBAAkB,KAAK,GAAG,wBAAwB;AAEnF,YAAM,gBAAgB,CAAC,GAAG,qBAAqB,GAAG,wBAAwB,EAAE,OAAO,OAAO,EAAE,KAAK,MAAM;AACvG,UAAI,cAAc,SAAS,IAAI;AAC7B,cAAM,SAAS,mBAAmB,UAAU,WAAW,SAAS,OAAO,UAAW,eAAe,gBAAW,OAAO,GAAG,EAAE,EAAE,MAAM,QAAQ,IAAI;AAAA,MAC9I;AAEA,UAAI,OAAO,iBAAiB,SAAS,GAAG;AACtC,gBAAQ,IAAI,WAAW,OAAO,GAAG,KAAK,OAAO,iBAAiB,MAAM,uBAAuB,OAAO,OAAO,MAAM,SAAS;AAAA,MAC1H;AAAA,IACF,SAAS,KAAK;AACZ,cAAQ,KAAK,kBAAa,OAAO,GAAG,KAAK,eAAe,QAAQ,IAAI,UAAU,GAAG;AAAA,IACnF;AAAA,EACF;AAGA,MAAI,qBAAqB,SAAS,GAAG;AACnC,UAAM,WAAW;AAAA;AAAA,EAA4D,qBAAqB,KAAK,MAAM,CAAC;AAC9G,UAAM,SAAS,gBAAgB,UAAU,WAAW,mBAAmB,UAAU;AAAA,MAC/E,SAAS,wEAAwE,qBAAqB,MAAM;AAAA,MAC5G,OAAO;AAAA,IACT,CAAC,EAAE,MAAM,QAAQ,IAAI;AAAA,EACvB;AAGA,MAAI,kBAAkB,SAAS,GAAG;AAChC,UAAM,WAAW;AAAA;AAAA,EAAiE,kBAAkB,KAAK,MAAM,CAAC;AAChH,UAAM,SAAS,gBAAgB,UAAU,WAAW,wBAAwB,UAAU;AAAA,MACpF,SAAS;AAAA,MACT,OAAO;AAAA,IACT,CAAC,EAAE,MAAM,QAAQ,IAAI;AAAA,EACvB;AAEA,QAAM,aAAa,IAAI,4BAA4B;AACnD,UAAQ,IAAI,kBAAkB,UAAU,MAAM,qBAAqB,qBAAqB,MAAM,mBAAmB,kBAAkB,MAAM,EAAE;AAC7I;AAyBA,eAAsB,iBAAiB,MAAwC;AAC7E,QAAM,WAA+B,CAAC;AACtC,QAAM,SAAmB,CAAC;AAC1B,QAAM,WAAW,oBAAI,IAAY;AACjC,MAAI,WAA0B;AAE9B,WAAS,IAAI,GAAG,IAAI,IAAI,KAAK;AAC3B,UAAM,KAAK,SAAS,MAAM,KAAK;AAE/B,UAAM,KAAK,MAAM,KAAK,SAAS,MAAM;AACnC,YAAM,SAAS,SAAS;AACxB,UAAI,CAAC,UAAU,WAAW,SAAS,KAAM,QAAO;AAChD,YAAM,UAAU,OAAO,iBAAiB,MAAM,EAAE;AAChD,aAAO;AAAA,QACL,KAAK,OAAO,QAAQ,YAAY;AAAA,QAChC,MAAM,OAAO,aAAa,MAAM;AAAA,QAChC,WAAW,OAAO,aAAa,YAAY;AAAA,QAC3C,OAAO,OAAO,aAAa,IAAI,KAAK,EAAE,MAAM,GAAG,EAAE,KAAK;AAAA,QACtD;AAAA,QACA,UAAU,OAAO,YAAY;AAAA,MAC/B;AAAA,IACF,CAAC;AAED,QAAI,CAAC,GAAI;AAET,UAAM,oBACJ,GAAG,YAAY,UACf,GAAG,YAAY,MACf,CAAC,GAAG,QAAQ,WAAW,KAAK,KAC5B,CAAC,GAAG,QAAQ,WAAW,kBAAkB,KACzC,CAAC,GAAG,QAAQ,WAAW,eAAe;AAExC,UAAM,YAA8B;AAAA,MAClC,KAAK,GAAG;AAAA,MACR,MAAM,GAAG;AAAA,MACT,WAAW,GAAG;AAAA,MACd,MAAM,GAAG;AAAA,MACT;AAAA,MACA,UAAU,GAAG;AAAA,IACf;AAEA,UAAM,MAAM,GAAG,GAAG,GAAG,IAAI,GAAG,QAAQ,EAAE,IAAI,GAAG,QAAQ,EAAE;AAEvD,QAAI,MAAM,GAAG;AACX,iBAAW;AAAA,IACb,WAAW,QAAQ,YAAY,SAAS,IAAI,GAAG,GAAG;AAEhD;AAAA,IACF;AAEA,aAAS,IAAI,GAAG;AAChB,aAAS,KAAK,SAAS;AAEvB,QAAI,CAAC,mBAAmB;AACtB,aAAO,KAAK,YAAY,IAAI,CAAC,KAAK,GAAG,GAAG,kCAAkC;AAAA,IAC5E;AAAA,EACF;AAEA,SAAO;AAAA,IACL,gBAAgB,SAAS;AAAA,IACzB,8BAA8B,OAAO;AAAA,IACrC;AAAA,IACA;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
package/dist/job-queue.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
- import { withTenant, prismaAdmin } from "@qip/database";
3
- import { crawlJobsTotal, crawlJobDurationSeconds } from "@qip/core";
2
+ import { withTenant, prismaAdmin } from "@detiq/database";
3
+ import { crawlJobsTotal, crawlJobDurationSeconds } from "@detiq/core";
4
4
  import { logger } from "./logger.js";
5
5
  async function notifyAdmins(tenantId, data) {
6
6
  try {
@@ -29,16 +29,16 @@ async function notifyAdmins(tenantId, data) {
29
29
  console.error("[notify] failed:", e);
30
30
  }
31
31
  }
32
- import { AppBrain, buildArtifactKey, getTenantStorageConfig, uploadScreenshotToTenantStorage, uploadFileToTenantStorage, notifyRunCompletion, recordLLMCall } from "@qip/app-brain";
33
- import { generateTaxonomySkillFiles, groupScreensWithAI, generateExecutionPlan, refreshTestCasesSkillFile, refreshTestDataSkillFile, refreshTestScriptSkillFile, refreshRequirementsCoverageSkillFile } from "@qip/agents";
32
+ import { AppBrain, buildArtifactKey, getTenantStorageConfig, uploadScreenshotToTenantStorage, uploadFileToTenantStorage, notifyRunCompletion, recordLLMCall } from "@detiq/app-brain";
33
+ import { generateTaxonomySkillFiles, groupScreensWithAI, generateExecutionPlan, refreshTestCasesSkillFile, refreshTestDataSkillFile, refreshTestScriptSkillFile, refreshRequirementsCoverageSkillFile } from "@detiq/agents";
34
34
  import { crawlProject } 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
38
  import { executeTestPlan } from "./test-executor.js";
39
39
  import { runNativeCrawl } from "./native-crawler.js";
40
- import { jira, confluence, figma } from "@qip/connectors";
41
- import { decrypt, config as platformConfig, dispatchWebhook, isFeatureEnabled, interpolatePlaceholders } from "@qip/core";
40
+ import { jira, confluence, figma } from "@detiq/connectors";
41
+ import { decrypt, config as platformConfig, dispatchWebhook, isFeatureEnabled, interpolatePlaceholders } from "@detiq/core";
42
42
  import { createHmac } from "node:crypto";
43
43
  import fs from "node:fs/promises";
44
44
  import path from "node:path";
@@ -234,7 +234,7 @@ async function enqueueJob(type, config) {
234
234
  async function getJob(tenantId, jobId) {
235
235
  return withTenant(
236
236
  tenantId,
237
- (tx) => tx.platformJob.findUnique({ where: { id: jobId } })
237
+ (tx) => tx.platformJob.findFirst({ where: { id: jobId, tenantId } })
238
238
  );
239
239
  }
240
240
  async function listJobs(tenantId, projectId) {
@@ -640,7 +640,7 @@ async function processApiTestsJob(jobId, config) {
640
640
  (tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } })
641
641
  );
642
642
  try {
643
- const { generateApiTests, refreshCoverageMatrix, refreshTestCaseInventory } = await import("@qip/agents");
643
+ const { generateApiTests, refreshCoverageMatrix, refreshTestCaseInventory } = await import("@detiq/agents");
644
644
  await updateJobProgress(tenantId, jobId, 20, "Loading API contracts and endpoints\u2026");
645
645
  const result = await generateApiTests({ tenantId, projectId, apiEndpoints: config.apiEndpoints });
646
646
  await updateJobProgress(tenantId, jobId, 90, `Generated ${result.cases?.length ?? 0} API test cases`);
@@ -675,7 +675,7 @@ async function processSecurityTestsJob(jobId, config) {
675
675
  (tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } })
676
676
  );
677
677
  try {
678
- const { generateSecurityTests, refreshCoverageMatrix, refreshTestCaseInventory } = await import("@qip/agents");
678
+ const { generateSecurityTests, refreshCoverageMatrix, refreshTestCaseInventory } = await import("@detiq/agents");
679
679
  await updateJobProgress(tenantId, jobId, 20, "Mapping OWASP attack surface\u2026");
680
680
  const result = await generateSecurityTests({ tenantId, projectId, apiEndpoints: config.apiEndpoints });
681
681
  await updateJobProgress(tenantId, jobId, 90, `Generated ${result.cases?.length ?? 0} security test cases`);
@@ -710,7 +710,7 @@ async function processIntegrationTestsJob(jobId, config) {
710
710
  (tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } })
711
711
  );
712
712
  try {
713
- const { generateIntegrationTests, refreshCoverageMatrix, refreshTestCaseInventory } = await import("@qip/agents");
713
+ const { generateIntegrationTests, refreshCoverageMatrix, refreshTestCaseInventory } = await import("@detiq/agents");
714
714
  await updateJobProgress(tenantId, jobId, 20, "Analysing API contracts and data models\u2026");
715
715
  const result = await generateIntegrationTests({ tenantId, projectId, apiEndpoints: config.apiEndpoints });
716
716
  await updateJobProgress(tenantId, jobId, 90, `Generated ${result.cases?.length ?? 0} integration test cases`);
@@ -946,7 +946,7 @@ async function processGenerateTestsFromReqsJob(jobId, config) {
946
946
  const { tenantId, projectId, reqIds, suiteId, suiteName } = config;
947
947
  try {
948
948
  await updateJobProgress(tenantId, jobId, 5, "Loading requirements");
949
- const { askTracked, getTaskAgentLLMConfig } = await import("@qip/agents");
949
+ const { askTracked, getTaskAgentLLMConfig } = await import("@detiq/agents");
950
950
  const llmCfg = await getTaskAgentLLMConfig(tenantId, "testGeneration");
951
951
  const all = await AppBrain.listRequirements(tenantId, projectId, {});
952
952
  const reqs = reqIds?.length ? all.filter((r) => reqIds.includes(r.reqId)) : all.filter((r) => r.status === "unbuilt" || r.status === "pending");
@@ -1238,6 +1238,15 @@ async function processDataRetentionPurge() {
1238
1238
  auditTotal += result.count;
1239
1239
  }
1240
1240
  logger.info({ deleted: auditTotal, tenants: tenants.length }, "[data-retention] AuditLog purged");
1241
+ const SESSION_RETENTION_DAYS = 365;
1242
+ const sessionCutoff = new Date(Date.now() - SESSION_RETENTION_DAYS * 24 * 60 * 6e4);
1243
+ const sessionResult = await prismaAdmin.authSession.deleteMany({
1244
+ where: {
1245
+ createdAt: { lt: sessionCutoff },
1246
+ revokedAt: { not: null }
1247
+ }
1248
+ });
1249
+ logger.info({ deleted: sessionResult.count }, "[data-retention] AuthSession purged");
1241
1250
  }
1242
1251
  async function processDiscoverJob(jobId, config, opts) {
1243
1252
  const { tenantId, projectId } = config;
@@ -1544,7 +1553,7 @@ async function processImportTestsJob(jobId, config) {
1544
1553
  }
1545
1554
  const format = detectFormat(filename, kind, content);
1546
1555
  await updateJobProgress(tenantId, jobId, 15, `Parsing ${filename}`);
1547
- const { parseTestCases, labelTestCasesBatch } = await import("@qip/agents");
1556
+ const { parseTestCases, labelTestCasesBatch } = await import("@detiq/agents");
1548
1557
  let res = await parseTestCases(content, format, tenantId, artifact.columnMapping ?? void 0, void 0, void 0, sourcePlatform);
1549
1558
  if (res.cases.length === 0 && format !== "AI_EXTRACT") {
1550
1559
  await updateJobProgress(tenantId, jobId, 25, "Retrying with AI extraction");
@@ -1623,7 +1632,7 @@ async function processImportTestsJob(jobId, config) {
1623
1632
  if (casesNeedingData.length > 0) {
1624
1633
  const cap = Math.min(casesNeedingData.length, 20);
1625
1634
  await updateJobProgress(tenantId, jobId, 90, `Generating test data for ${cap} cases`);
1626
- const { generateTestData, refreshTestDataSkillFile: refreshTestDataSkillFile2 } = await import("@qip/agents");
1635
+ const { generateTestData, refreshTestDataSkillFile: refreshTestDataSkillFile2 } = await import("@detiq/agents");
1627
1636
  for (let i = 0; i < cap; i++) {
1628
1637
  const c = casesNeedingData[i];
1629
1638
  await generateTestData({ tenantId, projectId, testCaseId: c.id, jobId }).catch((e) => {
@@ -1665,7 +1674,7 @@ async function processGenerateScreenTestsJob(jobId, config) {
1665
1674
  tenantId,
1666
1675
  (tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } })
1667
1676
  );
1668
- const { DualContextOrchestrator } = await import("@qip/agents");
1677
+ const { DualContextOrchestrator } = await import("@detiq/agents");
1669
1678
  await updateJobProgress(tenantId, jobId, 10, "Generating test cases for screen");
1670
1679
  const crawlCfg = await AppBrain.getCrawlConfig(tenantId, projectId).catch(() => null);
1671
1680
  const deviceProfiles = crawlCfg?.deviceProfiles ?? ["DESKTOP"];
@@ -1679,7 +1688,7 @@ async function processGenerateScreenTestsJob(jobId, config) {
1679
1688
  (tx) => tx.gitHubRepoConnection.findFirst({ where: { id: repoConnectionId, tenantId } })
1680
1689
  );
1681
1690
  if (conn?.styleProfile) {
1682
- const { generateScriptAligned, mapWithConcurrency, SCRIPT_GEN_CONCURRENCY } = await import("@qip/agents");
1691
+ const { generateScriptAligned, mapWithConcurrency, SCRIPT_GEN_CONCURRENCY } = await import("@detiq/agents");
1683
1692
  const cases = await withTenant(
1684
1693
  tenantId,
1685
1694
  (tx) => tx.testCase.findMany({ where: { tenantId, projectId, screenId, status: "APPROVED" }, select: { id: true }, take: 50 })
@@ -1744,13 +1753,13 @@ async function processRepoScanJob(jobId, config) {
1744
1753
  extractTestIds,
1745
1754
  extractStyleProfile,
1746
1755
  buildPageObjectInventory
1747
- } = await import("@qip/agents");
1756
+ } = await import("@detiq/agents");
1748
1757
  const conn = await withTenant(
1749
1758
  tenantId,
1750
1759
  (tx) => tx.gitHubRepoConnection.findFirst({ where: { id: repoConnectionId, tenantId } })
1751
1760
  );
1752
1761
  if (!conn) throw new Error(`RepoConnection ${repoConnectionId} not found`);
1753
- const { decrypt: decrypt2 } = await import("@qip/core");
1762
+ const { decrypt: decrypt2 } = await import("@detiq/core");
1754
1763
  const token = decrypt2(conn.tokenEnc);
1755
1764
  await withTenant(
1756
1765
  tenantId,
@@ -1827,7 +1836,7 @@ async function processRepoScanJob(jobId, config) {
1827
1836
  } catch {
1828
1837
  }
1829
1838
  await updateJobProgress(tenantId, jobId, 85, "Running coverage mapping");
1830
- const { buildCoverageMap } = await import("@qip/agents");
1839
+ const { buildCoverageMap } = await import("@detiq/agents");
1831
1840
  const testCases = await withTenant(
1832
1841
  tenantId,
1833
1842
  (tx) => tx.testCase.findMany({ where: { projectId, tenantId, status: { not: "RETIRED" } }, select: { id: true, title: true, ref: true } })
@@ -1886,7 +1895,7 @@ async function processRepoScanJob(jobId, config) {
1886
1895
  })
1887
1896
  );
1888
1897
  try {
1889
- const { generateRepoSkillFiles } = await import("@qip/agents");
1898
+ const { generateRepoSkillFiles } = await import("@detiq/agents");
1890
1899
  await generateRepoSkillFiles(tenantId, projectId, {
1891
1900
  repoFullName: conn.repoFullName,
1892
1901
  framework,
@@ -1930,14 +1939,14 @@ async function processBatchGenerateAlignedJob(jobId, config) {
1930
1939
  (tx) => tx.gitHubRepoConnection.findFirst({ where: { id: repoConnectionId, tenantId } })
1931
1940
  );
1932
1941
  if (!conn) throw new Error(`RepoConnection ${repoConnectionId} not found`);
1933
- const { decrypt: decrypt2 } = await import("@qip/core");
1942
+ const { decrypt: decrypt2 } = await import("@detiq/core");
1934
1943
  const {
1935
1944
  generateScriptAligned,
1936
1945
  deriveRepoTargetPath,
1937
1946
  validateGeneratedScript,
1938
1947
  mapWithConcurrency,
1939
1948
  SCRIPT_GEN_CONCURRENCY
1940
- } = await import("@qip/agents");
1949
+ } = await import("@detiq/agents");
1941
1950
  const styleProfile = conn.styleProfile;
1942
1951
  const pageObjectInventory = conn.pageObjectInventory ?? [];
1943
1952
  const framework = conn.detectedFramework ?? "playwright-ts";
@@ -2045,9 +2054,9 @@ async function processCreatePRJob(jobId, config) {
2045
2054
  (tx) => tx.gitHubRepoConnection.findFirst({ where: { id: repoConnectionId, tenantId } })
2046
2055
  );
2047
2056
  if (!conn) throw new Error(`RepoConnection ${repoConnectionId} not found`);
2048
- const { decrypt: decrypt2 } = await import("@qip/core");
2057
+ const { decrypt: decrypt2 } = await import("@detiq/core");
2049
2058
  const token = decrypt2(conn.tokenEnc);
2050
- const { pushScriptsAsPR } = await import("@qip/agents");
2059
+ const { pushScriptsAsPR } = await import("@detiq/agents");
2051
2060
  const framework = conn.detectedFramework ?? "playwright-ts";
2052
2061
  let scripts;
2053
2062
  if (testScriptIds && testScriptIds.length > 0) {