@capagents/forgeqe 1.1.3 → 1.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.
@@ -15,9 +15,9 @@ import { describeRequirement } from "./status.ts"
15
15
  const root = path.resolve(import.meta.dir, "..", "..")
16
16
  export const GUIDE_FILE = path.join(root, "docs", "user-guide.html")
17
17
  const THEME_SOURCE = path.join(root, "docs", "security-data-flow.html")
18
- const GUIDE_LOGO_LIGHT_FILE = "/Users/oldguard/Documents/GitHub/test-agent-nexus/public/forge-qe-ai-logo.png"
19
- const GUIDE_LOGO_DARK_FILE = "/Users/oldguard/Documents/GitHub/test-agent-nexus/public/forge-qe-ai-logo-white.png"
20
- const PARTNER_LOGO_FILE = "/Users/oldguard/Documents/GitHub/test-agent-nexus/public/assets/capgemini-logo.svg"
18
+ const GUIDE_LOGO_LIGHT_FILE = path.join(root, "assets", "forgeqe-logo.png")
19
+ const GUIDE_LOGO_DARK_FILE = path.join(root, "assets", "forgeqe-logo-white.png")
20
+ const PARTNER_LOGO_FILE = path.join(root, "assets", "capgemini-logo.svg")
21
21
 
22
22
  /** Chapters that are not a capability area: which HOW_TO_USE sections they hold, in order. */
23
23
  const START: DocRef[] = ["What you need to run", "Quick start", "1. Install", "2. Scaffold config"].map((heading) => ({ doc: "HOW_TO_USE.md", heading }))
package/src/cli/help.ts CHANGED
@@ -38,9 +38,11 @@ Usage:
38
38
  forgeqe mcp [--repo DIR] [--profile name] [--url URL] [--headed] [--browser-provider local|browserstack|testmu]
39
39
  ForgeQE's tools as an MCP server on stdio, for other agents
40
40
  forgeqe mcp list the Azure DevOps, Jira and other MCP servers ForgeQE's agent gets
41
- forgeqe bi [build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR]
42
- export ForgeQE + ADO/Jira datasets into an Evidence project and build static BI HTML
43
- forgeqe bi init scaffold or refresh the managed Evidence project files
41
+ forgeqe bi [build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR] [--static | --evidence]
42
+ export ForgeQE + ADO/Jira datasets into an Evidence project and build a static
43
+ site; without Node.js or when Evidence fails, ForgeQE's own page (--static: always,
44
+ --evidence: fail instead)
45
+ forgeqe bi init scaffold the Evidence project and install its packages
44
46
  forgeqe bi sync refresh the CSV datasets and generated Evidence pages, without building
45
47
  forgeqe pr-tests [pr] [--base BRANCH] [--agent] [--run] [--post]
46
48
  the tests a pull request (or the commits since BRANCH) can break; --run runs
@@ -138,8 +140,9 @@ The report is report.html (step verdicts, timeline with screenshots, timings, en
138
140
  out/suites/<time>/; every run folder also has its own report.html.
139
141
 
140
142
  Static BI (bi: in forgeqe.yaml): \`forgeqe bi\` writes curated CSV datasets from ForgeQE's own suite and readiness
141
- reports plus optional Azure DevOps WIQL and Jira JQL datasets, scaffolds an Evidence project, and runs
142
- \`evidence build\` to produce static HTML in the build directory. No server stays up between runs.
143
+ reports plus optional Azure DevOps WIQL and Jira JQL datasets, scaffolds an Evidence project (@evidence-dev/evidence
144
+ 40.x, needs Node.js), and runs \`evidence sources\` and \`evidence build\` to produce a static site in the build directory;
145
+ when that cannot run, it writes ForgeQE's own self-contained page instead. No server stays up between runs.
143
146
 
144
147
  Healing (forgeqe heal): ForgeQE runs the tests (the command given, code.test_command, or one the agent finds).
145
148
  For each failing test, Scribe re-runs its flow in the real browser: when the flow still works, the test is out
package/src/cli/index.ts CHANGED
@@ -217,7 +217,7 @@ export async function main(argv: string[]): Promise<number> {
217
217
  const cfg = loadConfig(configOpts(flags))
218
218
  const trends = positionals[1] === "trends"
219
219
  const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
220
- const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
220
+ const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
221
221
  const out = (await TOOLS.find((t) => t.name === (trends ? "quality_trends" : "quality_report"))!.run(trends ? { page: positionals[2] } : { run_dir: positionals[1] }, ctx)) as { budget_exceeded?: unknown[]; pages?: { slower: unknown[]; newly_failing_rules: unknown[] }[] }
222
222
  return out.budget_exceeded?.length || out.pages?.some((p) => p.slower.length || p.newly_failing_rules.length) ? 1 : 0
223
223
  }
@@ -382,7 +382,7 @@ async function cmdApi(args: string[], flags: Flags): Promise<number> {
382
382
  const cfg = loadConfig(configOpts(flags))
383
383
  const action = args[0] || "list"
384
384
  const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
385
- const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
385
+ const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
386
386
  const tool = (name: string) => TOOLS.find((t) => t.name === name)!
387
387
  const csv = (name: string) => (strFlag(flags, name) || "").split(",").map((s) => s.trim()).filter(Boolean)
388
388
  switch (action) {
@@ -489,11 +489,16 @@ async function cmdBi(args: string[], flags: Flags): Promise<number> {
489
489
  dataDir: strFlag(flags, "data-dir"),
490
490
  buildDir: strFlag(flags, "build-dir") || strFlag(flags, "out"),
491
491
  }
492
+ const shown = (file: string) => path.relative(process.cwd(), file) || file
493
+ const printNotes = (notes: string[]) => {
494
+ for (const note of notes) console.log(`note ${note}`)
495
+ }
492
496
  switch (action) {
493
497
  case "init": {
494
498
  const out = await initBiProject(cfg, overrides)
495
499
  console.log(`Evidence project ${out.evidenceDir}`)
496
- for (const file of out.files) console.log(`generated ${path.relative(process.cwd(), file) || file}`)
500
+ for (const file of out.files) console.log(`generated ${shown(file)}`)
501
+ printNotes(out.notes)
497
502
  return 0
498
503
  }
499
504
  case "sync": {
@@ -505,23 +510,30 @@ async function cmdBi(args: string[], flags: Flags): Promise<number> {
505
510
  console.log(`Case results ${out.caseResults}`)
506
511
  console.log(`Readiness reports ${out.readinessReports}`)
507
512
  console.log(`External datasets ${out.externalDatasets}`)
508
- console.log(`Summary ${path.relative(process.cwd(), out.summaryFile) || out.summaryFile}`)
513
+ console.log(`Summary ${shown(out.summaryFile)}`)
514
+ printNotes(out.notes)
509
515
  return 0
510
516
  }
511
517
  case "build": {
512
- const out = await buildBi(cfg, overrides)
518
+ const out = await buildBi(cfg, overrides, boolFlag(flags, "static") ? "static" : boolFlag(flags, "evidence") ? "evidence" : "auto")
513
519
  console.log(`Evidence project ${out.evidenceDir}`)
514
520
  console.log(`Static site ${out.buildDir}`)
515
- console.log(`Index ${path.relative(process.cwd(), out.indexFile) || out.indexFile}`)
516
- console.log(`Summary ${path.relative(process.cwd(), out.summaryFile) || out.summaryFile}`)
521
+ console.log(`Index ${shown(out.indexFile)}`)
522
+ console.log(`Summary ${shown(out.summaryFile)}`)
517
523
  console.log(`Suite runs ${out.suites}`)
518
524
  console.log(`Case results ${out.caseResults}`)
519
525
  console.log(`Readiness reports ${out.readinessReports}`)
520
526
  console.log(`External datasets ${out.externalDatasets}`)
527
+ if (out.engine === "evidence") {
528
+ console.log("Built with Evidence. Serve the folder to view it (npx serve, or evidence preview): opened from disk it cannot load its data.")
529
+ } else {
530
+ console.log(`Built with ForgeQE's own page, without Evidence (${out.fallbackReason}). It opens straight from disk.`)
531
+ }
532
+ printNotes(out.notes)
521
533
  return 0
522
534
  }
523
535
  default:
524
- throw new Error("Usage: forgeqe bi [init|sync|build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR]")
536
+ throw new Error("Usage: forgeqe bi [init|sync|build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR] [--static | --evidence]")
525
537
  }
526
538
  }
527
539
 
package/src/config.ts CHANGED
@@ -63,20 +63,6 @@ export type MemoryConfig = {
63
63
  replay: ReplayMode
64
64
  }
65
65
 
66
- export type CacheConfig = {
67
- enabled: boolean
68
- /** Redis connection string, e.g. redis://127.0.0.1:6379. */
69
- redisUrl: string
70
- /** Prefix for every Redis key this ForgeQE workspace writes. */
71
- keyPrefix: string
72
- /** Default TTL for cached JSON payloads, in seconds. */
73
- defaultTtlSeconds: number
74
- /** TTL for BI external dataset exports, in seconds. */
75
- biTtlSeconds: number
76
- /** Socket connect timeout for Redis, in milliseconds. */
77
- connectTimeoutMs: number
78
- }
79
-
80
66
  function runtimeBin(value: unknown, base: string): string | undefined {
81
67
  if (!value) return undefined
82
68
  const s = String(value)
@@ -348,8 +334,6 @@ export type AppConfig = {
348
334
  configPath?: string
349
335
  /** `mcp:` in forgeqe.yaml: more MCP servers for ForgeQE's agent, by name. */
350
336
  mcp?: Record<string, ExtraMcpServer>
351
- /** Optional Redis-backed cache for deterministic external data fetches. */
352
- cache?: CacheConfig
353
337
  activeProfile: string
354
338
  profiles: Record<string, LlmProfile>
355
339
  llm: LlmProfile
@@ -410,22 +394,6 @@ function domainConfig(raw: unknown): DomainConfig | undefined {
410
394
  return { industry, regulations, ...(d.notes ? { notes: String(d.notes).trim() } : {}) }
411
395
  }
412
396
 
413
- function cacheConfig(raw: unknown): CacheConfig | undefined {
414
- const c = asRecord(raw)
415
- const asked = raw !== undefined || Boolean(process.env.FORGEQE_REDIS_URL)
416
- if (!asked || c.enabled === false) return undefined
417
- const redisUrl = String(c.redis_url || c.url || process.env.FORGEQE_REDIS_URL || "").trim()
418
- if (!redisUrl) throw new Error('cache.redis_url is required when cache is enabled. Example: cache: { redis_url: "redis://127.0.0.1:6379" }')
419
- return {
420
- enabled: true,
421
- redisUrl,
422
- keyPrefix: String(c.key_prefix || "forgeqe").trim() || "forgeqe",
423
- defaultTtlSeconds: positiveInt(c.default_ttl_seconds, 3600, "cache.default_ttl_seconds"),
424
- biTtlSeconds: positiveInt(c.bi_ttl_seconds, 900, "cache.bi_ttl_seconds"),
425
- connectTimeoutMs: positiveInt(c.connect_timeout_ms, 3000, "cache.connect_timeout_ms"),
426
- }
427
- }
428
-
429
397
  export function replayMode(raw: unknown): ReplayMode {
430
398
  const mode = String(raw || "verify").toLowerCase()
431
399
  if (mode === "trust" || mode === "off") return mode
@@ -471,12 +439,12 @@ const REMOTE_BROWSER_DEFAULTS: Record<RemoteBrowserProvider, Pick<BrowserRemoteC
471
439
  browserstack: {
472
440
  usernameEnv: "BROWSERSTACK_USERNAME",
473
441
  accessKeyEnv: "BROWSERSTACK_ACCESS_KEY",
474
- endpoint: "wss://cdp.browserstack.com/playwright",
442
+ endpoint: "wss://cdp.browserstack.com/puppeteer",
475
443
  },
476
444
  testmu: {
477
445
  usernameEnv: "LT_USERNAME",
478
446
  accessKeyEnv: "LT_ACCESS_KEY",
479
- endpoint: "wss://cdp.lambdatest.com/playwright",
447
+ endpoint: "wss://cdp.lambdatest.com/puppeteer",
480
448
  },
481
449
  }
482
450
 
@@ -545,6 +513,10 @@ function browserRemoteConfig(raw: Record<string, unknown>, provider: BrowserProv
545
513
  const defaults = REMOTE_BROWSER_DEFAULTS[provider]
546
514
  const browser = String(remote.browser || "").trim()
547
515
  if (!browser) throw new Error(`${where}.remote.browser is required when browser.provider is ${provider}.`)
516
+ // The engine drives the browser over the Chrome DevTools Protocol, so the grid must hand back a Chromium browser.
517
+ if (!/chrome|edge|chromium/i.test(browser) || /^(playwright|pw)-/i.test(browser)) {
518
+ throw new Error(`${where}.remote.browser is "${browser}": ForgeQE drives remote browsers over the Chrome DevTools Protocol, so use chrome or edge.`)
519
+ }
548
520
  const browserVersion = String(remote.browser_version || remote.browserVersion || "latest").trim() || "latest"
549
521
  const os = String(remote.os || "").trim() || undefined
550
522
  const osVersion = String(remote.os_version || remote.osVersion || "").trim() || undefined
@@ -555,12 +527,16 @@ function browserRemoteConfig(raw: Record<string, unknown>, provider: BrowserProv
555
527
  if (provider === "testmu" && !platform && !os) {
556
528
  throw new Error(`${where}.remote.platform is required when browser.provider is testmu, or set ${where}.remote.os (and optionally os_version).`)
557
529
  }
530
+ const endpoint = String(remote.endpoint || defaults.endpoint).trim().replace(/\/+$/, "")
531
+ if (/\/playwright$/i.test(endpoint)) {
532
+ throw new Error(`${where}.remote.endpoint ${endpoint} speaks Playwright's own protocol; ForgeQE needs the CDP endpoint (${defaults.endpoint}).`)
533
+ }
558
534
  const extra = objectMap(remote.extra ?? remote.capabilities, `${where}.remote.extra`)
559
535
  return {
560
536
  provider,
561
537
  usernameEnv: String(remote.username_env || remote.usernameEnv || defaults.usernameEnv),
562
538
  accessKeyEnv: String(remote.access_key_env || remote.accessKeyEnv || defaults.accessKeyEnv),
563
- endpoint: String(remote.endpoint || defaults.endpoint).trim().replace(/\/+$/, ""),
539
+ endpoint,
564
540
  browser,
565
541
  browserVersion,
566
542
  ...(os ? { os } : {}),
@@ -779,7 +755,6 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
779
755
  const config: AppConfig = {
780
756
  configPath,
781
757
  mcp: extraMcpServers(yaml.mcp, base),
782
- cache: cacheConfig(yaml.cache),
783
758
  activeProfile,
784
759
  profiles,
785
760
  llm: profiles[activeProfile] || { ...NO_PROFILE },
@@ -1076,9 +1051,12 @@ function apiConfig(a: Record<string, unknown>): ApiConfig {
1076
1051
  }
1077
1052
  }
1078
1053
 
1054
+ /** The tables forgeqe bi always exports; a dataset id may not reuse one. */
1055
+ export const BI_BUILTIN_TABLES = ["suites", "case_results", "step_results", "writebacks", "readiness", "readiness_stories", "readiness_cases", "external_datasets"]
1056
+
1079
1057
  function biConfig(b: Record<string, unknown>, base: string, outDir: string, configPath: string | undefined): BiConfig {
1080
1058
  const evidenceDir = path.resolve(base, String(b.evidence_dir || path.join(".", "evidence")))
1081
- const defaultDataDir = path.join(evidenceDir, "sources", "forgeqe", "data")
1059
+ const defaultDataDir = path.join(evidenceDir, "sources", "forgeqe")
1082
1060
  const title = String(b.title || "ForgeQE BI").trim() || "ForgeQE BI"
1083
1061
  const datasets = Object.entries(asRecord(b.datasets)).map(([id, value]) => biDataset(id, asRecord(value)))
1084
1062
  return {
@@ -1091,7 +1069,9 @@ function biConfig(b: Record<string, unknown>, base: string, outDir: string, conf
1091
1069
  }
1092
1070
 
1093
1071
  function biDataset(id: string, raw: Record<string, unknown>): BiDatasetConfig {
1094
- if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`bi.datasets.${id}: ids may only use letters, digits, - and _.`)
1072
+ // The id is the dataset's table name in Evidence's SQL (forgeqe.<id>), so it must be a plain identifier.
1073
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(id)) throw new Error(`bi.datasets.${id}: ids may only use letters, digits and _, and must not start with a digit.`)
1074
+ if (BI_BUILTIN_TABLES.includes(id)) throw new Error(`bi.datasets.${id}: ${id} is one of ForgeQE's own BI tables; choose another id.`)
1095
1075
  const title = String(raw.title || id.replace(/[-_]+/g, " ").trim()).trim()
1096
1076
  const kind = String(raw.kind || "").trim().toLowerCase()
1097
1077
  if (kind === "ado_wiql") {
package/src/run.ts CHANGED
@@ -181,6 +181,11 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
181
181
 
182
182
  const missing = missingKey(opts.config.llm)
183
183
  if (missing) throw new Error(missing)
184
+ // A remote grid's keys are checked here, before the engine starts, rather than failing inside it.
185
+ if (browser.remote) {
186
+ const unset = [browser.remote.usernameEnv, browser.remote.accessKeyEnv].filter((name) => !process.env[name]?.trim())
187
+ if (unset.length) throw new Error(`${browser.provider}: set ${unset.join(" and ")} in the environment, or save them with "forgeqe auth login ${browser.provider}".`)
188
+ }
184
189
  const log = (text: string) => opts.onEvent?.({ type: "log", level: "info", text })
185
190
  const python = await ensureEngine(log)
186
191
  // The browser agent's added instructions: forgeqe.yaml's, prompts/browser-extra.md, and the product knowledge that matches the goal.
@@ -1,6 +1,6 @@
1
1
  provider: testmu
2
2
  remote:
3
- browser: pw-chromium
3
+ browser: Chrome
4
4
  browser_version: latest
5
5
  platform: Windows 11
6
6
  username_env: LT_USERNAME
@@ -9,6 +9,5 @@ remote:
9
9
  build: nightly
10
10
  name: checkout smoke
11
11
  local: false
12
- extra:
13
- "lt:options":
14
- console: true
12
+ extra: # merged into LT:Options, so keys go straight here
13
+ console: true
package/src/cache.ts DELETED
@@ -1,92 +0,0 @@
1
- import { createHash } from "node:crypto"
2
-
3
- import { createClient } from "redis"
4
-
5
- import type { CacheConfig } from "./config.ts"
6
-
7
- type CacheAdapter = {
8
- get(key: string): Promise<string | null>
9
- set(key: string, value: string, ttlSeconds: number): Promise<void>
10
- }
11
-
12
- let factoryOverride: ((config: CacheConfig) => Promise<CacheAdapter | null>) | undefined
13
- const adapters = new Map<string, Promise<CacheAdapter | null>>()
14
-
15
- function keyFor(config: CacheConfig): string {
16
- return `${config.redisUrl}\n${config.keyPrefix}\n${config.connectTimeoutMs}`
17
- }
18
-
19
- function fullKey(config: CacheConfig, namespace: string, parts: unknown[]): string {
20
- const digest = createHash("sha256").update(JSON.stringify(parts)).digest("hex")
21
- return `${config.keyPrefix}:${namespace}:${digest}`
22
- }
23
-
24
- async function connectRedis(config: CacheConfig): Promise<CacheAdapter | null> {
25
- const client = createClient({
26
- url: config.redisUrl,
27
- socket: { connectTimeout: config.connectTimeoutMs },
28
- })
29
- client.on("error", () => {})
30
- try {
31
- await client.connect()
32
- } catch {
33
- try {
34
- client.disconnect()
35
- } catch {}
36
- return null
37
- }
38
- return {
39
- async get(key) {
40
- try {
41
- return await client.get(key)
42
- } catch {
43
- return null
44
- }
45
- },
46
- async set(key, value, ttlSeconds) {
47
- try {
48
- await client.set(key, value, { EX: ttlSeconds })
49
- } catch {}
50
- },
51
- }
52
- }
53
-
54
- async function adapter(config: CacheConfig | undefined): Promise<CacheAdapter | null> {
55
- if (!config?.enabled || !config.redisUrl) return null
56
- const id = keyFor(config)
57
- if (!adapters.has(id)) {
58
- adapters.set(id, (factoryOverride || connectRedis)(config))
59
- }
60
- return adapters.get(id)!
61
- }
62
-
63
- export async function cachedJson<T>(
64
- config: CacheConfig | undefined,
65
- opts: {
66
- namespace: string
67
- keyParts: unknown[]
68
- ttlSeconds?: number
69
- load: () => Promise<T>
70
- },
71
- ): Promise<T> {
72
- const ttlSeconds = opts.ttlSeconds || config?.defaultTtlSeconds || 3600
73
- const store = await adapter(config)
74
- if (!store) return opts.load()
75
- const key = fullKey(config!, opts.namespace, opts.keyParts)
76
- const hit = await store.get(key)
77
- if (hit !== null) {
78
- try {
79
- return JSON.parse(hit) as T
80
- } catch {
81
- // Bad JSON should not break the current run; reload and overwrite it.
82
- }
83
- }
84
- const value = await opts.load()
85
- await store.set(key, JSON.stringify(value), ttlSeconds)
86
- return value
87
- }
88
-
89
- export function setCacheFactoryForTests(factory?: (config: CacheConfig) => Promise<CacheAdapter | null>): void {
90
- factoryOverride = factory
91
- adapters.clear()
92
- }