@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.
- package/README.md +6 -5
- package/browserstack.example.yaml +1 -1
- package/forgeqe.example.yaml +7 -13
- package/package.json +1 -2
- package/py/forgeqe_engine/browser.py +78 -11
- package/py/forgeqe_engine/service.py +12 -0
- package/src/bi.ts +382 -302
- package/src/capabilities/guide.ts +3 -3
- package/src/cli/help.ts +8 -5
- package/src/cli/index.ts +20 -8
- package/src/config.ts +18 -38
- package/src/run.ts +5 -0
- package/testmu.example.yaml +3 -4
- package/src/cache.ts +0 -92
package/src/bi.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process"
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
|
|
5
5
|
import type { Readiness } from "./cases/readiness.ts"
|
|
@@ -7,11 +7,32 @@ import { adoSettings } from "./cases/ado.ts"
|
|
|
7
7
|
import { FAILURE_ACTION } from "./cases/failure.ts"
|
|
8
8
|
import { http } from "./cases/http.ts"
|
|
9
9
|
import { jiraApi } from "./cases/jira.ts"
|
|
10
|
-
import { cachedJson } from "./cache.ts"
|
|
11
10
|
import type { CaseResult, WriteBack } from "./cases/types.ts"
|
|
12
11
|
import type { AppConfig, BiConfig, BiDatasetConfig } from "./config.ts"
|
|
12
|
+
import { reportHtml } from "./mcp/reporttools.ts"
|
|
13
|
+
import { duration } from "./report/html.ts"
|
|
13
14
|
import { counts as suiteCounts } from "./suite/report.ts"
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* forgeqe bi: CSV datasets from ForgeQE's own reports (and optional ADO/Jira queries), rendered as a static site.
|
|
18
|
+
* The site is built with the open-source Evidence line (@evidence-dev/evidence 40.x, CSV source, pinned below):
|
|
19
|
+
* the current Evidence Studio CLI has no local build and keeps data in its cloud. When Node/npm is missing or the
|
|
20
|
+
* Evidence build fails, ForgeQE writes its own self-contained page from the same data instead.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The legacy Evidence packages the generated project installs; exact versions so builds are repeatable. */
|
|
24
|
+
const EVIDENCE_PACKAGES = {
|
|
25
|
+
"@evidence-dev/core-components": "5.4.2",
|
|
26
|
+
"@evidence-dev/csv": "1.0.16",
|
|
27
|
+
"@evidence-dev/evidence": "40.1.8",
|
|
28
|
+
}
|
|
29
|
+
/** Evidence names each CSV's table after its file, under this source: forgeqe.suites, forgeqe.<dataset id>. */
|
|
30
|
+
const SOURCE = "forgeqe"
|
|
31
|
+
/** In the front matter of every page ForgeQE owns; a page without it is the team's and is never overwritten. */
|
|
32
|
+
const MANAGED = "# Managed by forgeqe bi: regenerated on every sync."
|
|
33
|
+
/** ADO's WIQL limit: a query that returns this many items was probably cut off. */
|
|
34
|
+
const ADO_WIQL_MAX = 20000
|
|
35
|
+
|
|
15
36
|
type BuildOverrides = { evidenceDir?: string; dataDir?: string; buildDir?: string }
|
|
16
37
|
type Paths = {
|
|
17
38
|
evidenceDir: string
|
|
@@ -19,7 +40,6 @@ type Paths = {
|
|
|
19
40
|
buildDir: string
|
|
20
41
|
sourceDir: string
|
|
21
42
|
pagesDir: string
|
|
22
|
-
externalDataDir: string
|
|
23
43
|
}
|
|
24
44
|
|
|
25
45
|
type SuiteFile = {
|
|
@@ -150,7 +170,10 @@ type ExternalDatasetRow = {
|
|
|
150
170
|
page: string
|
|
151
171
|
}
|
|
152
172
|
|
|
153
|
-
type ExternalExport = { row: ExternalDatasetRow;
|
|
173
|
+
type ExternalExport = { row: ExternalDatasetRow; headers: readonly string[]; rows: Record<string, unknown>[] }
|
|
174
|
+
|
|
175
|
+
type SuiteData = { suites: SuiteRow[]; cases: CaseRow[]; steps: StepRow[]; writebacks: WriteBackRow[] }
|
|
176
|
+
type ReadinessData = { summary: ReadinessRow[]; stories: ReadinessStoryRow[]; cases: ReadinessCaseRow[] }
|
|
154
177
|
|
|
155
178
|
export type BiSyncResult = {
|
|
156
179
|
evidenceDir: string
|
|
@@ -163,77 +186,132 @@ export type BiSyncResult = {
|
|
|
163
186
|
dataFiles: string[]
|
|
164
187
|
pageFiles: string[]
|
|
165
188
|
summaryFile: string
|
|
189
|
+
/** Things the user should know: a project ForgeQE did not create, a dataset that hit a limit. */
|
|
190
|
+
notes: string[]
|
|
166
191
|
}
|
|
167
192
|
|
|
168
|
-
|
|
193
|
+
/** evidence: the Evidence site was built; static: ForgeQE's own page, because Evidence could not build (or was not asked). */
|
|
194
|
+
export type BiEngine = "auto" | "evidence" | "static"
|
|
195
|
+
|
|
196
|
+
export type BiBuildResult = BiSyncResult & { indexFile: string; engine: "evidence" | "static"; fallbackReason?: string }
|
|
197
|
+
|
|
198
|
+
/** Runs one command (npm, npx) in a folder; tests replace it. */
|
|
199
|
+
type CommandRunner = (command: string, args: string[], cwd: string) => Promise<void>
|
|
200
|
+
let runnerOverride: CommandRunner | undefined
|
|
169
201
|
|
|
170
|
-
export
|
|
171
|
-
|
|
172
|
-
await ensureEvidenceProject(paths.evidenceDir)
|
|
173
|
-
const files = writeManagedEvidenceFiles(cfg, paths)
|
|
174
|
-
return { evidenceDir: paths.evidenceDir, files }
|
|
202
|
+
export function setBiRunnerForTests(runner?: CommandRunner): void {
|
|
203
|
+
runnerOverride = runner
|
|
175
204
|
}
|
|
176
205
|
|
|
177
|
-
export async function
|
|
206
|
+
export async function initBiProject(cfg: AppConfig, overrides: BuildOverrides = {}): Promise<{ evidenceDir: string; files: string[]; notes: string[] }> {
|
|
178
207
|
const bi = cfg.bi || defaultBiConfig(cfg)
|
|
179
208
|
const paths = resolvePaths(bi, overrides)
|
|
180
|
-
|
|
181
|
-
const
|
|
209
|
+
const notes: string[] = []
|
|
210
|
+
const files = [...scaffoldEvidenceProject(paths, notes), ...writeManagedEvidenceFiles(bi, paths, new Set())]
|
|
211
|
+
try {
|
|
212
|
+
await installEvidence(paths)
|
|
213
|
+
} catch (err) {
|
|
214
|
+
notes.push(`Evidence packages not installed (${errorText(err)}); forgeqe bi build will try again, or write ForgeQE's own page.`)
|
|
215
|
+
}
|
|
216
|
+
return { evidenceDir: paths.evidenceDir, files, notes }
|
|
217
|
+
}
|
|
182
218
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
219
|
+
export async function syncBiData(cfg: AppConfig, overrides: BuildOverrides = {}): Promise<BiSyncResult> {
|
|
220
|
+
return (await syncData(cfg, overrides)).result
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function syncData(cfg: AppConfig, overrides: BuildOverrides): Promise<{ result: BiSyncResult; bi: BiConfig; paths: Paths; suites: SuiteData; readiness: ReadinessData; external: ExternalExport[] }> {
|
|
224
|
+
const bi = cfg.bi || defaultBiConfig(cfg)
|
|
225
|
+
const paths = resolvePaths(bi, overrides)
|
|
226
|
+
const notes: string[] = []
|
|
227
|
+
for (const dir of [paths.dataDir, paths.sourceDir, paths.buildDir]) mkdirSync(dir, { recursive: true })
|
|
186
228
|
|
|
187
229
|
const suites = collectSuiteData(cfg)
|
|
188
230
|
const readiness = collectReadiness(cfg)
|
|
189
|
-
const external = await exportExternalDatasets({ ...cfg, bi },
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
...external.
|
|
231
|
+
const external = await exportExternalDatasets({ ...cfg, bi }, notes)
|
|
232
|
+
|
|
233
|
+
const tables: { name: string; headers: readonly string[]; rows: Record<string, unknown>[] }[] = [
|
|
234
|
+
{ name: "suites", headers: SUITE_HEADERS, rows: suites.suites },
|
|
235
|
+
{ name: "case_results", headers: CASE_HEADERS, rows: suites.cases },
|
|
236
|
+
{ name: "step_results", headers: STEP_HEADERS, rows: suites.steps },
|
|
237
|
+
{ name: "writebacks", headers: WRITEBACK_HEADERS, rows: suites.writebacks },
|
|
238
|
+
{ name: "readiness", headers: READINESS_HEADERS, rows: readiness.summary },
|
|
239
|
+
{ name: "readiness_stories", headers: READINESS_STORY_HEADERS, rows: readiness.stories },
|
|
240
|
+
{ name: "readiness_cases", headers: READINESS_CASE_HEADERS, rows: readiness.cases },
|
|
241
|
+
{ name: "external_datasets", headers: EXTERNAL_HEADERS, rows: external.map((e) => e.row) },
|
|
242
|
+
...external.map((e) => ({ name: e.row.dataset_id, headers: e.headers, rows: e.rows })),
|
|
201
243
|
]
|
|
244
|
+
// Evidence's CSV source reads every CSV in its own folder (a data_dir elsewhere is copied there), and it cannot
|
|
245
|
+
// build over a table with no rows: empty tables stay out of that folder, and their page sections say "None yet.".
|
|
246
|
+
const separate = path.resolve(paths.dataDir) !== path.resolve(paths.sourceDir)
|
|
247
|
+
const dataFiles: string[] = []
|
|
248
|
+
const has = new Set<string>()
|
|
249
|
+
for (const t of tables) {
|
|
250
|
+
const file = path.join(paths.dataDir, `${t.name}.csv`)
|
|
251
|
+
const inSource = path.join(paths.sourceDir, `${t.name}.csv`)
|
|
252
|
+
if (t.rows.length) has.add(t.name)
|
|
253
|
+
if (t.rows.length || separate) dataFiles.push(writeCsv(file, t.headers, t.rows))
|
|
254
|
+
else rmSync(file, { force: true })
|
|
255
|
+
if (separate) {
|
|
256
|
+
if (t.rows.length) copyFileSync(file, inSource)
|
|
257
|
+
else rmSync(inSource, { force: true })
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const pageFiles = [...scaffoldEvidenceProject(paths, notes), ...writeManagedEvidenceFiles(bi, paths, has)]
|
|
202
261
|
|
|
203
262
|
const summaryFile = path.join(paths.buildDir, "summary.md")
|
|
204
|
-
writeFileSync(summaryFile, summaryMarkdown(
|
|
263
|
+
writeFileSync(summaryFile, summaryMarkdown(bi, paths, suites, readiness, external), "utf8")
|
|
205
264
|
|
|
206
265
|
return {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
266
|
+
result: {
|
|
267
|
+
evidenceDir: paths.evidenceDir,
|
|
268
|
+
dataDir: paths.dataDir,
|
|
269
|
+
buildDir: paths.buildDir,
|
|
270
|
+
suites: suites.suites.length,
|
|
271
|
+
caseResults: suites.cases.length,
|
|
272
|
+
readinessReports: readiness.summary.length,
|
|
273
|
+
externalDatasets: external.length,
|
|
274
|
+
dataFiles,
|
|
275
|
+
pageFiles,
|
|
276
|
+
summaryFile,
|
|
277
|
+
notes,
|
|
278
|
+
},
|
|
279
|
+
bi,
|
|
280
|
+
paths,
|
|
281
|
+
suites,
|
|
282
|
+
readiness,
|
|
283
|
+
external,
|
|
217
284
|
}
|
|
218
285
|
}
|
|
219
286
|
|
|
220
|
-
export async function buildBi(cfg: AppConfig, overrides: BuildOverrides = {}): Promise<BiBuildResult> {
|
|
221
|
-
const synced = await
|
|
222
|
-
|
|
223
|
-
|
|
287
|
+
export async function buildBi(cfg: AppConfig, overrides: BuildOverrides = {}, engine: BiEngine = "auto"): Promise<BiBuildResult> {
|
|
288
|
+
const synced = await syncData(cfg, overrides)
|
|
289
|
+
const indexFile = path.join(synced.paths.buildDir, "index.html")
|
|
290
|
+
const writeStatic = (reason: string): BiBuildResult => {
|
|
291
|
+
writeFileSync(indexFile, staticReport(synced.bi, synced.paths, synced.suites, synced.readiness, synced.external, reason), "utf8")
|
|
292
|
+
return { ...synced.result, indexFile, engine: "static", fallbackReason: reason }
|
|
293
|
+
}
|
|
294
|
+
if (engine === "static") return writeStatic("asked for with --static")
|
|
295
|
+
try {
|
|
296
|
+
await buildWithEvidence(synced.paths)
|
|
297
|
+
return { ...synced.result, indexFile, engine: "evidence" }
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if (engine === "evidence") throw err
|
|
300
|
+
return writeStatic(errorText(err))
|
|
301
|
+
}
|
|
224
302
|
}
|
|
225
303
|
|
|
226
304
|
function resolvePaths(cfg: BiConfig, overrides: BuildOverrides): Paths {
|
|
227
305
|
const evidenceDir = path.resolve(overrides.evidenceDir || cfg.evidenceDir)
|
|
228
|
-
const
|
|
229
|
-
|
|
306
|
+
const sourceDir = path.join(evidenceDir, "sources", SOURCE)
|
|
307
|
+
// A new evidence dir from the command line carries its own data folder unless one is given too.
|
|
308
|
+
const dataDir = path.resolve(overrides.dataDir || (overrides.evidenceDir ? sourceDir : cfg.dataDir))
|
|
230
309
|
return {
|
|
231
310
|
evidenceDir,
|
|
232
311
|
dataDir,
|
|
233
|
-
buildDir,
|
|
234
|
-
sourceDir
|
|
312
|
+
buildDir: path.resolve(overrides.buildDir || cfg.buildDir),
|
|
313
|
+
sourceDir,
|
|
235
314
|
pagesDir: path.join(evidenceDir, "pages"),
|
|
236
|
-
externalDataDir: path.join(dataDir, "external"),
|
|
237
315
|
}
|
|
238
316
|
}
|
|
239
317
|
|
|
@@ -242,231 +320,161 @@ function defaultBiConfig(cfg: AppConfig): BiConfig {
|
|
|
242
320
|
return {
|
|
243
321
|
title: "ForgeQE BI",
|
|
244
322
|
evidenceDir,
|
|
245
|
-
dataDir: path.join(evidenceDir, "sources",
|
|
323
|
+
dataDir: path.join(evidenceDir, "sources", SOURCE),
|
|
246
324
|
buildDir: path.join(cfg.export.outDir, "bi"),
|
|
247
325
|
datasets: [],
|
|
248
326
|
}
|
|
249
327
|
}
|
|
250
328
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
329
|
+
/** The Evidence project's own files, written only when missing so a team's changes to them are kept. */
|
|
330
|
+
function scaffoldEvidenceProject(paths: Paths, notes: string[]): string[] {
|
|
331
|
+
const files: string[] = []
|
|
332
|
+
const missing = (name: string) => !existsSync(path.join(paths.evidenceDir, name))
|
|
333
|
+
const pkg = {
|
|
334
|
+
name: "forgeqe-bi",
|
|
335
|
+
version: "0.0.1",
|
|
336
|
+
private: true,
|
|
337
|
+
type: "module",
|
|
338
|
+
scripts: { sources: "evidence sources", build: "evidence build", dev: "evidence dev", preview: "evidence preview" },
|
|
339
|
+
engines: { node: ">=18.0.0" },
|
|
340
|
+
dependencies: EVIDENCE_PACKAGES,
|
|
341
|
+
// Evidence 40.1.8 peers on TypeScript 5.4.2 exactly; without this pin npm resolves 7.x and refuses to install.
|
|
342
|
+
devDependencies: { typescript: "5.4.2" },
|
|
343
|
+
}
|
|
344
|
+
if (missing("package.json")) files.push(writeText(path.join(paths.evidenceDir, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`))
|
|
345
|
+
if (missing(".npmrc")) files.push(writeText(path.join(paths.evidenceDir, ".npmrc"), "loglevel=error\naudit=false\nfund=false\n"))
|
|
346
|
+
if (missing(".gitignore")) files.push(writeText(path.join(paths.evidenceDir, ".gitignore"), "node_modules/\nbuild/\n.evidence/template/\n"))
|
|
347
|
+
const configFile = path.join(paths.evidenceDir, "evidence.config.yaml")
|
|
348
|
+
if (!existsSync(configFile)) {
|
|
349
|
+
files.push(
|
|
350
|
+
writeText(
|
|
351
|
+
configFile,
|
|
352
|
+
[
|
|
353
|
+
"plugins:",
|
|
354
|
+
" components:",
|
|
355
|
+
' "@evidence-dev/core-components": {}',
|
|
356
|
+
" datasources:",
|
|
357
|
+
' "@evidence-dev/csv": {}',
|
|
358
|
+
"",
|
|
359
|
+
].join("\n"),
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
} else if (!readFileSync(configFile, "utf8").includes("@evidence-dev/csv")) {
|
|
363
|
+
notes.push(`${configFile} does not load the @evidence-dev/csv source; add it under plugins.datasources so Evidence reads ForgeQE's CSV files.`)
|
|
364
|
+
}
|
|
365
|
+
return files
|
|
255
366
|
}
|
|
256
367
|
|
|
257
|
-
|
|
258
|
-
|
|
368
|
+
/** has: the tables with rows; Evidence cannot build a page over an empty table, so those sections say so instead. */
|
|
369
|
+
function writeManagedEvidenceFiles(bi: BiConfig, paths: Paths, has: Set<string>): string[] {
|
|
259
370
|
mkdirSync(paths.sourceDir, { recursive: true })
|
|
260
371
|
mkdirSync(paths.pagesDir, { recursive: true })
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
["suites.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "suites.csv"))}', header = true)`],
|
|
268
|
-
["case_results.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "case_results.csv"))}', header = true)`],
|
|
269
|
-
["step_results.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "step_results.csv"))}', header = true)`],
|
|
270
|
-
["writebacks.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "writebacks.csv"))}', header = true)`],
|
|
271
|
-
["readiness.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "readiness.csv"))}', header = true)`],
|
|
272
|
-
["readiness_stories.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "readiness_stories.csv"))}', header = true)`],
|
|
273
|
-
["readiness_cases.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "readiness_cases.csv"))}', header = true)`],
|
|
274
|
-
["external_datasets.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "external_datasets.csv"))}', header = true)`],
|
|
275
|
-
]
|
|
276
|
-
for (const dataset of bi.datasets) {
|
|
277
|
-
managedQueries.push([`${dataset.id}.sql`, `select * from read_csv_auto('${rel(path.join(paths.externalDataDir, `${dataset.id}.csv`))}', header = true)`])
|
|
278
|
-
}
|
|
279
|
-
for (const [name, sql] of managedQueries) files.push(writeText(path.join(paths.sourceDir, name), `${sql}\n`))
|
|
280
|
-
|
|
281
|
-
files.push(writeText(path.join(paths.pagesDir, "home.md"), homePage({ ...cfg, bi })))
|
|
282
|
-
files.push(writeText(path.join(paths.pagesDir, "forgeqe-runs.md"), runsPage()))
|
|
283
|
-
files.push(writeText(path.join(paths.pagesDir, "forgeqe-readiness.md"), readinessPage()))
|
|
284
|
-
files.push(writeText(path.join(paths.pagesDir, "forgeqe-external.md"), externalPage()))
|
|
285
|
-
for (const dataset of bi.datasets) {
|
|
286
|
-
files.push(writeText(path.join(paths.pagesDir, `forgeqe-dataset-${dataset.id}.md`), datasetPage(dataset)))
|
|
372
|
+
const files = [writeText(path.join(paths.sourceDir, "connection.yaml"), `name: ${SOURCE}\ntype: csv\noptions: {}\n`)]
|
|
373
|
+
// Evidence reads front matter only at the top of a page, so the marker is a YAML comment there; title names the page in the menu.
|
|
374
|
+
const page = (name: string, title: string, body: string) => {
|
|
375
|
+
const file = path.join(paths.pagesDir, name)
|
|
376
|
+
if (existsSync(file) && !readFileSync(file, "utf8").split("\n", 3).includes(MANAGED)) return
|
|
377
|
+
files.push(writeText(file, `---\n${MANAGED}\ntitle: ${JSON.stringify(title)}\n---\n\n${body}`))
|
|
287
378
|
}
|
|
379
|
+
page("index.md", bi.title, homePage(bi, has))
|
|
380
|
+
page("forgeqe-runs.md", "Suite and monitor history", runsPage(has))
|
|
381
|
+
page("forgeqe-readiness.md", "Release readiness history", readinessPage(has))
|
|
382
|
+
page("forgeqe-external.md", "External datasets", externalPage(has))
|
|
383
|
+
for (const dataset of bi.datasets) page(`forgeqe-dataset-${dataset.id}.md`, dataset.title, datasetPage(dataset, has))
|
|
288
384
|
return files
|
|
289
385
|
}
|
|
290
386
|
|
|
291
|
-
function homePage(
|
|
292
|
-
const bi = cfg.bi || defaultBiConfig(cfg)
|
|
387
|
+
function homePage(bi: BiConfig, has: Set<string>): string {
|
|
293
388
|
const datasetLinks = bi.datasets.length
|
|
294
|
-
? bi.datasets.map((d) => `- [${d.title}](
|
|
389
|
+
? bi.datasets.map((d) => `- [${d.title}](/forgeqe-dataset-${d.id})`).join("\n")
|
|
295
390
|
: "- No external datasets are configured yet. Add them under `bi.datasets` in `forgeqe.yaml`."
|
|
296
391
|
return [
|
|
297
|
-
"
|
|
298
|
-
"",
|
|
299
|
-
"This static site is generated by `forgeqe bi build`. ForgeQE exports CSV datasets, Evidence queries them with DuckDB, and the build writes static HTML with no always-on backend.",
|
|
392
|
+
"Generated by `forgeqe bi build` from ForgeQE's suite, monitor and release-readiness reports.",
|
|
300
393
|
"",
|
|
301
394
|
"## Pages",
|
|
302
395
|
"",
|
|
303
|
-
"- [Suite and monitor history](
|
|
304
|
-
"- [Release readiness history](
|
|
305
|
-
"- [External datasets](
|
|
396
|
+
"- [Suite and monitor history](/forgeqe-runs)",
|
|
397
|
+
"- [Release readiness history](/forgeqe-readiness)",
|
|
398
|
+
"- [External datasets](/forgeqe-external)",
|
|
306
399
|
datasetLinks,
|
|
307
400
|
"",
|
|
308
|
-
|
|
309
|
-
"suite_summary",
|
|
401
|
+
query(has, "suite_summary",
|
|
310
402
|
`select
|
|
311
403
|
count(*) as suites,
|
|
312
404
|
coalesce(sum(total_cases), 0) as total_cases,
|
|
313
405
|
coalesce(sum(passed_cases), 0) as passed_cases,
|
|
314
406
|
coalesce(sum(failed_cases), 0) as failed_cases,
|
|
315
407
|
coalesce(sum(error_cases), 0) as error_cases
|
|
316
|
-
from
|
|
317
|
-
),
|
|
318
|
-
"",
|
|
319
|
-
"{% table data=\"suite_summary\" /%}",
|
|
408
|
+
from ${SOURCE}.suites`),
|
|
320
409
|
"",
|
|
321
410
|
"## Case results by source",
|
|
322
411
|
"",
|
|
323
|
-
|
|
324
|
-
"cases_by_source",
|
|
412
|
+
query(has, "cases_by_source",
|
|
325
413
|
`select
|
|
326
414
|
case_source,
|
|
327
415
|
count(*) as total_results,
|
|
328
416
|
sum(case when case_status = 'passed' then 1 else 0 end) as passed_results,
|
|
329
417
|
sum(case when case_status = 'failed' then 1 else 0 end) as failed_results,
|
|
330
418
|
sum(case when case_status = 'error' then 1 else 0 end) as error_results
|
|
331
|
-
from
|
|
419
|
+
from ${SOURCE}.case_results
|
|
332
420
|
group by 1
|
|
333
|
-
order by total_results desc, case_source
|
|
334
|
-
),
|
|
335
|
-
"",
|
|
336
|
-
"{% table data=\"cases_by_source\" /%}",
|
|
337
|
-
"",
|
|
338
|
-
"## External datasets",
|
|
339
|
-
"",
|
|
340
|
-
sqlBlock(
|
|
341
|
-
"external_datasets_home",
|
|
342
|
-
`select dataset_id, title, source_kind, row_count, refreshed_at, page
|
|
343
|
-
from forgeqe.external_datasets
|
|
344
|
-
order by title`,
|
|
345
|
-
),
|
|
346
|
-
"",
|
|
347
|
-
"{% table data=\"external_datasets_home\" /%}",
|
|
421
|
+
order by total_results desc, case_source`),
|
|
348
422
|
"",
|
|
349
423
|
].join("\n")
|
|
350
424
|
}
|
|
351
425
|
|
|
352
|
-
function runsPage(): string {
|
|
426
|
+
function runsPage(has: Set<string>): string {
|
|
353
427
|
return [
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
`select
|
|
359
|
-
started_at,
|
|
360
|
-
suite_scope,
|
|
361
|
-
suite_name,
|
|
362
|
-
total_cases,
|
|
363
|
-
passed_cases,
|
|
364
|
-
failed_cases,
|
|
365
|
-
error_cases,
|
|
366
|
-
stopped_cases,
|
|
367
|
-
duration_ms
|
|
368
|
-
from forgeqe.suites
|
|
369
|
-
order by started_at desc`,
|
|
370
|
-
),
|
|
371
|
-
"",
|
|
372
|
-
"{% table data=\"suite_timeline\" /%}",
|
|
428
|
+
query(has, "suite_timeline",
|
|
429
|
+
`select started_at, suite_scope, suite_name, total_cases, passed_cases, failed_cases, error_cases, stopped_cases, duration_ms
|
|
430
|
+
from ${SOURCE}.suites
|
|
431
|
+
order by started_at desc`),
|
|
373
432
|
"",
|
|
374
433
|
"## Slowest case results",
|
|
375
434
|
"",
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
suite_id,
|
|
380
|
-
case_id,
|
|
381
|
-
case_title,
|
|
382
|
-
case_source,
|
|
383
|
-
case_status,
|
|
384
|
-
duration_ms,
|
|
385
|
-
tokens
|
|
386
|
-
from forgeqe.case_results
|
|
435
|
+
query(has, "slowest_cases",
|
|
436
|
+
`select suite_id, case_id, case_title, case_source, case_status, duration_ms, tokens
|
|
437
|
+
from ${SOURCE}.case_results
|
|
387
438
|
order by duration_ms desc
|
|
388
|
-
limit 50
|
|
389
|
-
),
|
|
390
|
-
"",
|
|
391
|
-
"{% table data=\"slowest_cases\" /%}",
|
|
439
|
+
limit 50`),
|
|
392
440
|
"",
|
|
393
441
|
].join("\n")
|
|
394
442
|
}
|
|
395
443
|
|
|
396
|
-
function readinessPage(): string {
|
|
444
|
+
function readinessPage(has: Set<string>): string {
|
|
397
445
|
return [
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
`select
|
|
403
|
-
report_id,
|
|
404
|
-
scope,
|
|
405
|
-
verdict,
|
|
406
|
-
pass_rate,
|
|
407
|
-
stories,
|
|
408
|
-
cases,
|
|
409
|
-
open_bugs,
|
|
410
|
-
blockers,
|
|
411
|
-
risks
|
|
412
|
-
from forgeqe.readiness
|
|
413
|
-
order by report_id desc`,
|
|
414
|
-
),
|
|
446
|
+
query(has, "readiness_summary",
|
|
447
|
+
`select report_id, scope, verdict, pass_rate, stories, cases, open_bugs, blockers, risks
|
|
448
|
+
from ${SOURCE}.readiness
|
|
449
|
+
order by report_id desc`),
|
|
415
450
|
"",
|
|
416
|
-
"
|
|
451
|
+
"## Stories in the readiness reports",
|
|
417
452
|
"",
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
`select
|
|
423
|
-
report_id,
|
|
424
|
-
story_id,
|
|
425
|
-
story_type,
|
|
426
|
-
story_title,
|
|
427
|
-
state,
|
|
428
|
-
area,
|
|
429
|
-
case_count
|
|
430
|
-
from forgeqe.readiness_stories
|
|
431
|
-
order by report_id desc, story_id`,
|
|
432
|
-
),
|
|
433
|
-
"",
|
|
434
|
-
"{% table data=\"readiness_story_table\" /%}",
|
|
453
|
+
query(has, "readiness_story_table",
|
|
454
|
+
`select report_id, story_id, story_type, story_title, state, area, case_count
|
|
455
|
+
from ${SOURCE}.readiness_stories
|
|
456
|
+
order by report_id desc, story_id`),
|
|
435
457
|
"",
|
|
436
458
|
].join("\n")
|
|
437
459
|
}
|
|
438
460
|
|
|
439
|
-
function externalPage(): string {
|
|
461
|
+
function externalPage(has: Set<string>): string {
|
|
440
462
|
return [
|
|
441
|
-
"# External Datasets",
|
|
442
|
-
"",
|
|
443
463
|
"ForgeQE refreshes these datasets from Azure DevOps or Jira during `forgeqe bi sync` and `forgeqe bi build`.",
|
|
444
464
|
"",
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
order by title`,
|
|
450
|
-
),
|
|
451
|
-
"",
|
|
452
|
-
"{% table data=\"external_datasets_table\" /%}",
|
|
465
|
+
query(has, "external_datasets_table",
|
|
466
|
+
`select dataset_id, title, source_kind, row_count, refreshed_at
|
|
467
|
+
from ${SOURCE}.external_datasets
|
|
468
|
+
order by title`),
|
|
453
469
|
"",
|
|
454
470
|
].join("\n")
|
|
455
471
|
}
|
|
456
472
|
|
|
457
|
-
function datasetPage(dataset: BiDatasetConfig): string {
|
|
473
|
+
function datasetPage(dataset: BiDatasetConfig, has: Set<string>): string {
|
|
458
474
|
return [
|
|
459
|
-
`# ${dataset.title}`,
|
|
460
|
-
"",
|
|
461
475
|
`Source kind: \`${dataset.kind}\``,
|
|
462
476
|
"",
|
|
463
|
-
|
|
464
|
-
"",
|
|
465
|
-
"{% table data=\"dataset_stats\" /%}",
|
|
466
|
-
"",
|
|
467
|
-
sqlBlock("dataset_preview", `select * from forgeqe.${dataset.id} limit 100`),
|
|
468
|
-
"",
|
|
469
|
-
"{% table data=\"dataset_preview\" /%}",
|
|
477
|
+
query(has, "dataset_rows", `select * from ${SOURCE}.${dataset.id}`),
|
|
470
478
|
"",
|
|
471
479
|
].join("\n")
|
|
472
480
|
}
|
|
@@ -618,25 +626,20 @@ function collectReadiness(cfg: AppConfig): { summary: ReadinessRow[]; stories: R
|
|
|
618
626
|
return out
|
|
619
627
|
}
|
|
620
628
|
|
|
621
|
-
async function exportExternalDatasets(cfg: AppConfig,
|
|
629
|
+
async function exportExternalDatasets(cfg: AppConfig, notes: string[]): Promise<ExternalExport[]> {
|
|
622
630
|
const bi = cfg.bi || defaultBiConfig(cfg)
|
|
623
631
|
const exports: ExternalExport[] = []
|
|
624
632
|
for (const dataset of bi.datasets) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
row: { dataset_id: dataset.id, title: dataset.title, source_kind: dataset.kind, row_count: rows.length, refreshed_at: new Date().toISOString(), page: `forgeqe-dataset-${dataset.id}` },
|
|
630
|
-
files: [writeCsv(file, ADO_DATASET_HEADERS, rows)],
|
|
631
|
-
})
|
|
632
|
-
} else {
|
|
633
|
-
const rows = await fetchJiraRows(cfg, dataset)
|
|
634
|
-
const file = path.join(paths.externalDataDir, `${dataset.id}.csv`)
|
|
635
|
-
exports.push({
|
|
636
|
-
row: { dataset_id: dataset.id, title: dataset.title, source_kind: dataset.kind, row_count: rows.length, refreshed_at: new Date().toISOString(), page: `forgeqe-dataset-${dataset.id}` },
|
|
637
|
-
files: [writeCsv(file, JIRA_DATASET_HEADERS, rows)],
|
|
638
|
-
})
|
|
633
|
+
const headers = dataset.kind === "ado_wiql" ? ADO_DATASET_HEADERS : JIRA_DATASET_HEADERS
|
|
634
|
+
const rows: Record<string, unknown>[] = dataset.kind === "ado_wiql" ? await fetchAdoRows(cfg, dataset) : await fetchJiraRows(cfg, dataset)
|
|
635
|
+
if (dataset.kind === "ado_wiql" && rows.length >= ADO_WIQL_MAX) {
|
|
636
|
+
notes.push(`bi.datasets.${dataset.id}: Azure DevOps returns at most ${ADO_WIQL_MAX} work items per query, so this dataset is probably incomplete; narrow its WIQL.`)
|
|
639
637
|
}
|
|
638
|
+
exports.push({
|
|
639
|
+
row: { dataset_id: dataset.id, title: dataset.title, source_kind: dataset.kind, row_count: rows.length, refreshed_at: new Date().toISOString(), page: `forgeqe-dataset-${dataset.id}` },
|
|
640
|
+
headers,
|
|
641
|
+
rows,
|
|
642
|
+
})
|
|
640
643
|
}
|
|
641
644
|
return exports
|
|
642
645
|
}
|
|
@@ -644,71 +647,57 @@ async function exportExternalDatasets(cfg: AppConfig, paths: Paths): Promise<Ext
|
|
|
644
647
|
async function fetchAdoRows(cfg: AppConfig, dataset: Extract<BiDatasetConfig, { kind: "ado_wiql" }>): Promise<Record<string, string | number>[]> {
|
|
645
648
|
const { auth, org, project: configured } = adoSettings(cfg.sources)
|
|
646
649
|
const project = dataset.project || configured
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
iteration: String(item.fields["System.IterationPath"] || ""),
|
|
673
|
-
})
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
return rows
|
|
677
|
-
},
|
|
678
|
-
})
|
|
650
|
+
const base = project ? `${org}/${encodeURIComponent(project)}` : org
|
|
651
|
+
const { data } = await http<{ workItems?: { id: number }[]; workItemRelations?: { source?: { id?: number } | null; target?: { id?: number } | null }[] }>(
|
|
652
|
+
`${base}/_apis/wit/wiql?api-version=7.1&$top=${ADO_WIQL_MAX}`,
|
|
653
|
+
{ auth, body: { query: dataset.wiql } },
|
|
654
|
+
)
|
|
655
|
+
const ids = [...new Set([...(data.workItems || []).map((w) => w.id), ...(data.workItemRelations || []).flatMap((r) => [r.source?.id, r.target?.id]).filter((id): id is number => typeof id === "number")])]
|
|
656
|
+
if (!ids.length) return []
|
|
657
|
+
const fields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.AssignedTo", "System.ChangedDate", "System.IterationPath", "System.TeamProject"]
|
|
658
|
+
const rows: Record<string, string | number>[] = []
|
|
659
|
+
for (let i = 0; i < ids.length; i += 200) {
|
|
660
|
+
const batch = await http<{ value?: { id: number; fields: Record<string, unknown> }[] }>(`${org}/_apis/wit/workitemsbatch?api-version=7.1`, { auth, body: { ids: ids.slice(i, i + 200), fields } })
|
|
661
|
+
for (const item of batch.data.value || []) {
|
|
662
|
+
rows.push({
|
|
663
|
+
id: item.id,
|
|
664
|
+
project: String(item.fields["System.TeamProject"] || project || ""),
|
|
665
|
+
type: String(item.fields["System.WorkItemType"] || ""),
|
|
666
|
+
title: String(item.fields["System.Title"] || ""),
|
|
667
|
+
state: String(item.fields["System.State"] || ""),
|
|
668
|
+
assigned_to: displayName(item.fields["System.AssignedTo"]),
|
|
669
|
+
changed_at: String(item.fields["System.ChangedDate"] || ""),
|
|
670
|
+
iteration: String(item.fields["System.IterationPath"] || ""),
|
|
671
|
+
})
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return rows
|
|
679
675
|
}
|
|
680
676
|
|
|
681
677
|
async function fetchJiraRows(cfg: AppConfig, dataset: Extract<BiDatasetConfig, { kind: "jira_jql" }>): Promise<Record<string, string>[]> {
|
|
682
678
|
const jira = jiraApi(cfg.sources)
|
|
683
679
|
const fields = "summary,status,statuscategory,issuetype,priority,assignee,reporter,labels,components,parent,created,updated"
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
startAt += page.length
|
|
706
|
-
if (!page.length || startAt >= Number(data.total || 0)) break
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
return rows
|
|
710
|
-
},
|
|
711
|
-
})
|
|
680
|
+
const rows: Record<string, string>[] = []
|
|
681
|
+
if (jira.cloud) {
|
|
682
|
+
let next: string | undefined
|
|
683
|
+
do {
|
|
684
|
+
const q = new URLSearchParams({ jql: dataset.jql, fields, maxResults: "100", ...(next ? { nextPageToken: next } : {}) })
|
|
685
|
+
const { data } = await http<{ issues?: any[]; nextPageToken?: string; isLast?: boolean }>(`${jira.api}/search/jql?${q}`, { auth: jira.auth })
|
|
686
|
+
for (const issue of data.issues || []) rows.push(jiraRow(jira.url, issue))
|
|
687
|
+
next = data.isLast === false || data.nextPageToken ? data.nextPageToken : undefined
|
|
688
|
+
} while (next)
|
|
689
|
+
} else {
|
|
690
|
+
let startAt = 0
|
|
691
|
+
for (;;) {
|
|
692
|
+
const q = new URLSearchParams({ jql: dataset.jql, fields, maxResults: "100", startAt: String(startAt) })
|
|
693
|
+
const { data } = await http<{ issues?: any[]; total?: number }>(`${jira.api}/search?${q}`, { auth: jira.auth })
|
|
694
|
+
const page = data.issues || []
|
|
695
|
+
for (const issue of page) rows.push(jiraRow(jira.url, issue))
|
|
696
|
+
startAt += page.length
|
|
697
|
+
if (!page.length || startAt >= Number(data.total || 0)) break
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return rows
|
|
712
701
|
}
|
|
713
702
|
|
|
714
703
|
function jiraRow(baseUrl: string, issue: Record<string, any>): Record<string, string> {
|
|
@@ -779,22 +768,18 @@ function readJson<T>(file: string): T | null {
|
|
|
779
768
|
}
|
|
780
769
|
}
|
|
781
770
|
|
|
782
|
-
function sqlPath(file: string): string {
|
|
783
|
-
return file.split(path.sep).join("/").replace(/'/g, "''")
|
|
784
|
-
}
|
|
785
|
-
|
|
786
771
|
function sqlBlock(name: string, sql: string): string {
|
|
787
772
|
return `\`\`\`sql ${name}\n${sql}\n\`\`\``
|
|
788
773
|
}
|
|
789
774
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
775
|
+
/** A query and its table on an Evidence page, or "None yet." while the table it reads has no rows. */
|
|
776
|
+
function query(has: Set<string>, name: string, sql: string): string {
|
|
777
|
+
const table = sql.match(new RegExp(`from ${SOURCE}\\.(\\w+)`))?.[1]
|
|
778
|
+
if (table && !has.has(table)) return "None yet."
|
|
779
|
+
return `${sqlBlock(name, sql)}\n\n<DataTable data={${name}} rows=25/>`
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function summaryMarkdown(bi: BiConfig, paths: Paths, suites: SuiteData, readiness: ReadinessData, external: ExternalExport[]): string {
|
|
798
783
|
return [
|
|
799
784
|
`# ${bi.title}`,
|
|
800
785
|
"",
|
|
@@ -808,27 +793,122 @@ function summaryMarkdown(
|
|
|
808
793
|
`- Release readiness reports exported: ${readiness.summary.length}`,
|
|
809
794
|
`- External datasets exported: ${external.length}`,
|
|
810
795
|
"",
|
|
811
|
-
|
|
796
|
+
"## External datasets",
|
|
812
797
|
"",
|
|
813
798
|
...(external.length ? external.map((d) => `- ${d.row.title} (\`${d.row.dataset_id}\`): ${d.row.row_count} row${d.row.row_count === 1 ? "" : "s"}`) : ["- None configured. Add datasets under `bi.datasets` in `forgeqe.yaml`."]),
|
|
814
799
|
"",
|
|
815
800
|
].join("\n")
|
|
816
801
|
}
|
|
817
802
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
const
|
|
803
|
+
/** ForgeQE's own page from the same data, through the shared report page (reportHtml): no Node, no server. */
|
|
804
|
+
function staticReport(bi: BiConfig, paths: Paths, suites: SuiteData, readiness: ReadinessData, external: ExternalExport[], reason: string): string {
|
|
805
|
+
const passed = suites.cases.filter((c) => c.case_status === "passed").length
|
|
806
|
+
const failed = suites.cases.filter((c) => c.case_status === "failed").length
|
|
807
|
+
const errors = suites.cases.filter((c) => c.case_status === "error").length
|
|
808
|
+
const bySource = new Map<string, { total: number; passed: number; failed: number; errors: number }>()
|
|
809
|
+
for (const c of suites.cases) {
|
|
810
|
+
const s = bySource.get(c.case_source) || { total: 0, passed: 0, failed: 0, errors: 0 }
|
|
811
|
+
s.total++
|
|
812
|
+
if (c.case_status === "passed") s.passed++
|
|
813
|
+
if (c.case_status === "failed") s.failed++
|
|
814
|
+
if (c.case_status === "error") s.errors++
|
|
815
|
+
bySource.set(c.case_source, s)
|
|
816
|
+
}
|
|
817
|
+
const newestFirst = [...suites.suites].reverse().slice(0, 100)
|
|
818
|
+
const slowest = [...suites.cases].sort((a, b) => b.duration_ms - a.duration_ms).slice(0, 50)
|
|
819
|
+
const markdown = [
|
|
820
|
+
`Built by ForgeQE without Evidence (${reason}). The CSV datasets are in \`${paths.dataDir}\`.`,
|
|
821
|
+
"",
|
|
822
|
+
"## Summary",
|
|
823
|
+
"",
|
|
824
|
+
`- Suite runs: ${suites.suites.length}`,
|
|
825
|
+
`- Case results: ${suites.cases.length} (${passed} passed, ${failed} failed, ${errors} error${errors === 1 ? "" : "s"})`,
|
|
826
|
+
`- Release readiness reports: ${readiness.summary.length}`,
|
|
827
|
+
`- External datasets: ${external.length}`,
|
|
828
|
+
"",
|
|
829
|
+
"## Case results by source",
|
|
830
|
+
"",
|
|
831
|
+
mdTable(
|
|
832
|
+
["Source", "Results", "Passed", "Failed", "Errors"],
|
|
833
|
+
[...bySource].sort((a, b) => b[1].total - a[1].total).map(([source, s]) => [source, s.total, s.passed, s.failed, s.errors]),
|
|
834
|
+
),
|
|
835
|
+
"",
|
|
836
|
+
"## Suite and monitor history",
|
|
837
|
+
"",
|
|
838
|
+
mdTable(
|
|
839
|
+
["Started", "Scope", "Suite", "Cases", "Passed", "Failed", "Errors", "Stopped", "Duration"],
|
|
840
|
+
newestFirst.map((s) => [s.started_at, s.suite_scope, s.suite_name, s.total_cases, s.passed_cases, s.failed_cases, s.error_cases, s.stopped_cases, duration(s.duration_ms)]),
|
|
841
|
+
),
|
|
842
|
+
"",
|
|
843
|
+
"## Slowest case results",
|
|
844
|
+
"",
|
|
845
|
+
mdTable(
|
|
846
|
+
["Suite", "Case", "Title", "Source", "Status", "Duration", "Tokens"],
|
|
847
|
+
slowest.map((c) => [c.suite_id, c.case_id, c.case_title, c.case_source, c.case_status, duration(c.duration_ms), c.tokens]),
|
|
848
|
+
),
|
|
849
|
+
"",
|
|
850
|
+
"## Release readiness",
|
|
851
|
+
"",
|
|
852
|
+
mdTable(
|
|
853
|
+
["Report", "Scope", "Verdict", "Pass rate", "Stories", "Cases", "Open bugs", "Blockers"],
|
|
854
|
+
[...readiness.summary].reverse().map((r) => [r.report_id, r.scope, r.verdict, r.pass_rate === "" ? "" : `${r.pass_rate}%`, r.stories, r.cases, r.open_bugs, r.blockers]),
|
|
855
|
+
),
|
|
856
|
+
"",
|
|
857
|
+
"## External datasets",
|
|
858
|
+
"",
|
|
859
|
+
...(external.length
|
|
860
|
+
? external.flatMap((d) => [
|
|
861
|
+
`### ${d.row.title}`,
|
|
862
|
+
"",
|
|
863
|
+
`\`${d.row.dataset_id}\` · ${d.row.source_kind} · ${d.row.row_count} row${d.row.row_count === 1 ? "" : "s"}${d.rows.length > 100 ? ", first 100 shown" : ""}`,
|
|
864
|
+
"",
|
|
865
|
+
mdTable(d.headers, d.rows.slice(0, 100).map((row) => d.headers.map((h) => row[h]))),
|
|
866
|
+
"",
|
|
867
|
+
])
|
|
868
|
+
: ["None configured. Add datasets under `bi.datasets` in `forgeqe.yaml`.", ""]),
|
|
869
|
+
].join("\n")
|
|
870
|
+
return reportHtml(bi.title, markdown, new Date())
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function mdTable(headers: readonly string[], rows: unknown[][]): string {
|
|
874
|
+
if (!rows.length) return "None yet."
|
|
875
|
+
const cell = (v: unknown) => (v === undefined || v === null ? "" : String(v)).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ")
|
|
876
|
+
return [`| ${headers.map(cell).join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`, ...rows.map((r) => `| ${r.map(cell).join(" | ")} |`)].join("\n")
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function errorText(err: unknown): string {
|
|
880
|
+
return err instanceof Error ? err.message : String(err)
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** npm install in the Evidence project, once: skipped while its packages are there. */
|
|
884
|
+
async function installEvidence(paths: Paths): Promise<void> {
|
|
885
|
+
if (existsSync(path.join(paths.evidenceDir, "node_modules", "@evidence-dev", "evidence", "package.json"))) return
|
|
886
|
+
await run("npm", ["install", "--no-audit", "--no-fund"], paths.evidenceDir)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** evidence sources (CSV → the site's data), then evidence build:strict, then the site copied to the build directory. */
|
|
890
|
+
async function buildWithEvidence(paths: Paths): Promise<void> {
|
|
891
|
+
await installEvidence(paths)
|
|
892
|
+
await run("npx", ["--no-install", "evidence", "sources"], paths.evidenceDir)
|
|
893
|
+
await run("npx", ["--no-install", "evidence", "build:strict"], paths.evidenceDir)
|
|
894
|
+
const site = path.join(paths.evidenceDir, "build")
|
|
895
|
+
if (!existsSync(path.join(site, "index.html"))) throw new Error(`Evidence finished without writing ${path.join(site, "index.html")}`)
|
|
896
|
+
if (path.resolve(site) !== path.resolve(paths.buildDir)) cpSync(site, paths.buildDir, { recursive: true, force: true })
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
async function run(command: string, args: string[], cwd: string): Promise<void> {
|
|
900
|
+
if (runnerOverride) return runnerOverride(command, args, cwd)
|
|
901
|
+
const bin = Bun.which(command)
|
|
902
|
+
if (!bin) throw new Error(`${command} not found; install Node.js 18 or later to build the Evidence site`)
|
|
823
903
|
await new Promise<void>((resolve, reject) => {
|
|
824
|
-
const child = spawn(
|
|
904
|
+
const child = spawn(bin, args, {
|
|
825
905
|
cwd,
|
|
826
906
|
stdio: "inherit",
|
|
827
907
|
shell: process.platform === "win32",
|
|
828
|
-
env: { ...process.env, DO_NOT_TRACK: "1", EVIDENCE_TELEMETRY_DISABLED: "1"
|
|
908
|
+
env: { ...process.env, DO_NOT_TRACK: "1", EVIDENCE_TELEMETRY_DISABLED: "1" },
|
|
829
909
|
})
|
|
830
910
|
child.on("error", reject)
|
|
831
|
-
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(
|
|
911
|
+
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`${[command, ...args].join(" ")} exited with code ${code}`))))
|
|
832
912
|
})
|
|
833
913
|
}
|
|
834
914
|
|