@capagents/forgeqe 1.1.2 → 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/HOW_TO_USE.md +5 -5
- package/README.md +8 -4
- package/browserstack.example.yaml +14 -0
- package/docs/security-data-flow.html +154 -86
- package/docs/user-guide.html +305 -131
- package/forgeqe.example.yaml +12 -6
- package/package.json +4 -2
- package/py/forgeqe_engine/browser.py +78 -11
- package/py/forgeqe_engine/service.py +13 -0
- package/src/auth/signin.ts +2 -2
- package/src/bi.ts +338 -243
- package/src/capabilities/guide.ts +322 -140
- package/src/capabilities/registry.ts +3 -3
- package/src/cli/help.ts +16 -7
- package/src/cli/index.ts +51 -9
- package/src/config.ts +115 -12
- package/src/heal/loop.ts +3 -1
- package/src/init.ts +18 -0
- package/src/mcp/tools.ts +26 -4
- package/src/monitor/run.ts +3 -1
- package/src/run.ts +9 -2
- package/src/suite/run.ts +3 -1
- package/testmu.example.yaml +13 -0
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"
|
|
@@ -9,8 +9,30 @@ import { http } from "./cases/http.ts"
|
|
|
9
9
|
import { jiraApi } from "./cases/jira.ts"
|
|
10
10
|
import type { CaseResult, WriteBack } from "./cases/types.ts"
|
|
11
11
|
import type { AppConfig, BiConfig, BiDatasetConfig } from "./config.ts"
|
|
12
|
+
import { reportHtml } from "./mcp/reporttools.ts"
|
|
13
|
+
import { duration } from "./report/html.ts"
|
|
12
14
|
import { counts as suiteCounts } from "./suite/report.ts"
|
|
13
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
|
+
|
|
14
36
|
type BuildOverrides = { evidenceDir?: string; dataDir?: string; buildDir?: string }
|
|
15
37
|
type Paths = {
|
|
16
38
|
evidenceDir: string
|
|
@@ -18,7 +40,6 @@ type Paths = {
|
|
|
18
40
|
buildDir: string
|
|
19
41
|
sourceDir: string
|
|
20
42
|
pagesDir: string
|
|
21
|
-
externalDataDir: string
|
|
22
43
|
}
|
|
23
44
|
|
|
24
45
|
type SuiteFile = {
|
|
@@ -149,7 +170,10 @@ type ExternalDatasetRow = {
|
|
|
149
170
|
page: string
|
|
150
171
|
}
|
|
151
172
|
|
|
152
|
-
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[] }
|
|
153
177
|
|
|
154
178
|
export type BiSyncResult = {
|
|
155
179
|
evidenceDir: string
|
|
@@ -162,77 +186,132 @@ export type BiSyncResult = {
|
|
|
162
186
|
dataFiles: string[]
|
|
163
187
|
pageFiles: string[]
|
|
164
188
|
summaryFile: string
|
|
189
|
+
/** Things the user should know: a project ForgeQE did not create, a dataset that hit a limit. */
|
|
190
|
+
notes: string[]
|
|
165
191
|
}
|
|
166
192
|
|
|
167
|
-
|
|
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
|
|
168
201
|
|
|
169
|
-
export
|
|
170
|
-
|
|
171
|
-
await ensureEvidenceProject(paths.evidenceDir)
|
|
172
|
-
const files = writeManagedEvidenceFiles(cfg, paths)
|
|
173
|
-
return { evidenceDir: paths.evidenceDir, files }
|
|
202
|
+
export function setBiRunnerForTests(runner?: CommandRunner): void {
|
|
203
|
+
runnerOverride = runner
|
|
174
204
|
}
|
|
175
205
|
|
|
176
|
-
export async function
|
|
206
|
+
export async function initBiProject(cfg: AppConfig, overrides: BuildOverrides = {}): Promise<{ evidenceDir: string; files: string[]; notes: string[] }> {
|
|
177
207
|
const bi = cfg.bi || defaultBiConfig(cfg)
|
|
178
208
|
const paths = resolvePaths(bi, overrides)
|
|
179
|
-
|
|
180
|
-
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
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function syncBiData(cfg: AppConfig, overrides: BuildOverrides = {}): Promise<BiSyncResult> {
|
|
220
|
+
return (await syncData(cfg, overrides)).result
|
|
221
|
+
}
|
|
181
222
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 })
|
|
185
228
|
|
|
186
229
|
const suites = collectSuiteData(cfg)
|
|
187
230
|
const readiness = collectReadiness(cfg)
|
|
188
|
-
const external = await exportExternalDatasets({ ...cfg, bi },
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
...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 })),
|
|
200
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)]
|
|
201
261
|
|
|
202
262
|
const summaryFile = path.join(paths.buildDir, "summary.md")
|
|
203
|
-
writeFileSync(summaryFile, summaryMarkdown(
|
|
263
|
+
writeFileSync(summaryFile, summaryMarkdown(bi, paths, suites, readiness, external), "utf8")
|
|
204
264
|
|
|
205
265
|
return {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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,
|
|
216
284
|
}
|
|
217
285
|
}
|
|
218
286
|
|
|
219
|
-
export async function buildBi(cfg: AppConfig, overrides: BuildOverrides = {}): Promise<BiBuildResult> {
|
|
220
|
-
const synced = await
|
|
221
|
-
|
|
222
|
-
|
|
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
|
+
}
|
|
223
302
|
}
|
|
224
303
|
|
|
225
304
|
function resolvePaths(cfg: BiConfig, overrides: BuildOverrides): Paths {
|
|
226
305
|
const evidenceDir = path.resolve(overrides.evidenceDir || cfg.evidenceDir)
|
|
227
|
-
const
|
|
228
|
-
|
|
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))
|
|
229
309
|
return {
|
|
230
310
|
evidenceDir,
|
|
231
311
|
dataDir,
|
|
232
|
-
buildDir,
|
|
233
|
-
sourceDir
|
|
312
|
+
buildDir: path.resolve(overrides.buildDir || cfg.buildDir),
|
|
313
|
+
sourceDir,
|
|
234
314
|
pagesDir: path.join(evidenceDir, "pages"),
|
|
235
|
-
externalDataDir: path.join(dataDir, "external"),
|
|
236
315
|
}
|
|
237
316
|
}
|
|
238
317
|
|
|
@@ -241,231 +320,161 @@ function defaultBiConfig(cfg: AppConfig): BiConfig {
|
|
|
241
320
|
return {
|
|
242
321
|
title: "ForgeQE BI",
|
|
243
322
|
evidenceDir,
|
|
244
|
-
dataDir: path.join(evidenceDir, "sources",
|
|
323
|
+
dataDir: path.join(evidenceDir, "sources", SOURCE),
|
|
245
324
|
buildDir: path.join(cfg.export.outDir, "bi"),
|
|
246
325
|
datasets: [],
|
|
247
326
|
}
|
|
248
327
|
}
|
|
249
328
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
254
366
|
}
|
|
255
367
|
|
|
256
|
-
|
|
257
|
-
|
|
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[] {
|
|
258
370
|
mkdirSync(paths.sourceDir, { recursive: true })
|
|
259
371
|
mkdirSync(paths.pagesDir, { recursive: true })
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
["suites.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "suites.csv"))}', header = true)`],
|
|
267
|
-
["case_results.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "case_results.csv"))}', header = true)`],
|
|
268
|
-
["step_results.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "step_results.csv"))}', header = true)`],
|
|
269
|
-
["writebacks.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "writebacks.csv"))}', header = true)`],
|
|
270
|
-
["readiness.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "readiness.csv"))}', header = true)`],
|
|
271
|
-
["readiness_stories.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "readiness_stories.csv"))}', header = true)`],
|
|
272
|
-
["readiness_cases.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "readiness_cases.csv"))}', header = true)`],
|
|
273
|
-
["external_datasets.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "external_datasets.csv"))}', header = true)`],
|
|
274
|
-
]
|
|
275
|
-
for (const dataset of bi.datasets) {
|
|
276
|
-
managedQueries.push([`${dataset.id}.sql`, `select * from read_csv_auto('${rel(path.join(paths.externalDataDir, `${dataset.id}.csv`))}', header = true)`])
|
|
277
|
-
}
|
|
278
|
-
for (const [name, sql] of managedQueries) files.push(writeText(path.join(paths.sourceDir, name), `${sql}\n`))
|
|
279
|
-
|
|
280
|
-
files.push(writeText(path.join(paths.pagesDir, "home.md"), homePage({ ...cfg, bi })))
|
|
281
|
-
files.push(writeText(path.join(paths.pagesDir, "forgeqe-runs.md"), runsPage()))
|
|
282
|
-
files.push(writeText(path.join(paths.pagesDir, "forgeqe-readiness.md"), readinessPage()))
|
|
283
|
-
files.push(writeText(path.join(paths.pagesDir, "forgeqe-external.md"), externalPage()))
|
|
284
|
-
for (const dataset of bi.datasets) {
|
|
285
|
-
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}`))
|
|
286
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))
|
|
287
384
|
return files
|
|
288
385
|
}
|
|
289
386
|
|
|
290
|
-
function homePage(
|
|
291
|
-
const bi = cfg.bi || defaultBiConfig(cfg)
|
|
387
|
+
function homePage(bi: BiConfig, has: Set<string>): string {
|
|
292
388
|
const datasetLinks = bi.datasets.length
|
|
293
|
-
? bi.datasets.map((d) => `- [${d.title}](
|
|
389
|
+
? bi.datasets.map((d) => `- [${d.title}](/forgeqe-dataset-${d.id})`).join("\n")
|
|
294
390
|
: "- No external datasets are configured yet. Add them under `bi.datasets` in `forgeqe.yaml`."
|
|
295
391
|
return [
|
|
296
|
-
"
|
|
297
|
-
"",
|
|
298
|
-
"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.",
|
|
299
393
|
"",
|
|
300
394
|
"## Pages",
|
|
301
395
|
"",
|
|
302
|
-
"- [Suite and monitor history](
|
|
303
|
-
"- [Release readiness history](
|
|
304
|
-
"- [External datasets](
|
|
396
|
+
"- [Suite and monitor history](/forgeqe-runs)",
|
|
397
|
+
"- [Release readiness history](/forgeqe-readiness)",
|
|
398
|
+
"- [External datasets](/forgeqe-external)",
|
|
305
399
|
datasetLinks,
|
|
306
400
|
"",
|
|
307
|
-
|
|
308
|
-
"suite_summary",
|
|
401
|
+
query(has, "suite_summary",
|
|
309
402
|
`select
|
|
310
403
|
count(*) as suites,
|
|
311
404
|
coalesce(sum(total_cases), 0) as total_cases,
|
|
312
405
|
coalesce(sum(passed_cases), 0) as passed_cases,
|
|
313
406
|
coalesce(sum(failed_cases), 0) as failed_cases,
|
|
314
407
|
coalesce(sum(error_cases), 0) as error_cases
|
|
315
|
-
from
|
|
316
|
-
),
|
|
317
|
-
"",
|
|
318
|
-
"{% table data=\"suite_summary\" /%}",
|
|
408
|
+
from ${SOURCE}.suites`),
|
|
319
409
|
"",
|
|
320
410
|
"## Case results by source",
|
|
321
411
|
"",
|
|
322
|
-
|
|
323
|
-
"cases_by_source",
|
|
412
|
+
query(has, "cases_by_source",
|
|
324
413
|
`select
|
|
325
414
|
case_source,
|
|
326
415
|
count(*) as total_results,
|
|
327
416
|
sum(case when case_status = 'passed' then 1 else 0 end) as passed_results,
|
|
328
417
|
sum(case when case_status = 'failed' then 1 else 0 end) as failed_results,
|
|
329
418
|
sum(case when case_status = 'error' then 1 else 0 end) as error_results
|
|
330
|
-
from
|
|
419
|
+
from ${SOURCE}.case_results
|
|
331
420
|
group by 1
|
|
332
|
-
order by total_results desc, case_source
|
|
333
|
-
),
|
|
334
|
-
"",
|
|
335
|
-
"{% table data=\"cases_by_source\" /%}",
|
|
336
|
-
"",
|
|
337
|
-
"## External datasets",
|
|
338
|
-
"",
|
|
339
|
-
sqlBlock(
|
|
340
|
-
"external_datasets_home",
|
|
341
|
-
`select dataset_id, title, source_kind, row_count, refreshed_at, page
|
|
342
|
-
from forgeqe.external_datasets
|
|
343
|
-
order by title`,
|
|
344
|
-
),
|
|
345
|
-
"",
|
|
346
|
-
"{% table data=\"external_datasets_home\" /%}",
|
|
421
|
+
order by total_results desc, case_source`),
|
|
347
422
|
"",
|
|
348
423
|
].join("\n")
|
|
349
424
|
}
|
|
350
425
|
|
|
351
|
-
function runsPage(): string {
|
|
426
|
+
function runsPage(has: Set<string>): string {
|
|
352
427
|
return [
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
`select
|
|
358
|
-
started_at,
|
|
359
|
-
suite_scope,
|
|
360
|
-
suite_name,
|
|
361
|
-
total_cases,
|
|
362
|
-
passed_cases,
|
|
363
|
-
failed_cases,
|
|
364
|
-
error_cases,
|
|
365
|
-
stopped_cases,
|
|
366
|
-
duration_ms
|
|
367
|
-
from forgeqe.suites
|
|
368
|
-
order by started_at desc`,
|
|
369
|
-
),
|
|
370
|
-
"",
|
|
371
|
-
"{% 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`),
|
|
372
432
|
"",
|
|
373
433
|
"## Slowest case results",
|
|
374
434
|
"",
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
suite_id,
|
|
379
|
-
case_id,
|
|
380
|
-
case_title,
|
|
381
|
-
case_source,
|
|
382
|
-
case_status,
|
|
383
|
-
duration_ms,
|
|
384
|
-
tokens
|
|
385
|
-
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
|
|
386
438
|
order by duration_ms desc
|
|
387
|
-
limit 50
|
|
388
|
-
),
|
|
389
|
-
"",
|
|
390
|
-
"{% table data=\"slowest_cases\" /%}",
|
|
439
|
+
limit 50`),
|
|
391
440
|
"",
|
|
392
441
|
].join("\n")
|
|
393
442
|
}
|
|
394
443
|
|
|
395
|
-
function readinessPage(): string {
|
|
444
|
+
function readinessPage(has: Set<string>): string {
|
|
396
445
|
return [
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
`select
|
|
402
|
-
report_id,
|
|
403
|
-
scope,
|
|
404
|
-
verdict,
|
|
405
|
-
pass_rate,
|
|
406
|
-
stories,
|
|
407
|
-
cases,
|
|
408
|
-
open_bugs,
|
|
409
|
-
blockers,
|
|
410
|
-
risks
|
|
411
|
-
from forgeqe.readiness
|
|
412
|
-
order by report_id desc`,
|
|
413
|
-
),
|
|
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`),
|
|
414
450
|
"",
|
|
415
|
-
"
|
|
451
|
+
"## Stories in the readiness reports",
|
|
416
452
|
"",
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
`select
|
|
422
|
-
report_id,
|
|
423
|
-
story_id,
|
|
424
|
-
story_type,
|
|
425
|
-
story_title,
|
|
426
|
-
state,
|
|
427
|
-
area,
|
|
428
|
-
case_count
|
|
429
|
-
from forgeqe.readiness_stories
|
|
430
|
-
order by report_id desc, story_id`,
|
|
431
|
-
),
|
|
432
|
-
"",
|
|
433
|
-
"{% 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`),
|
|
434
457
|
"",
|
|
435
458
|
].join("\n")
|
|
436
459
|
}
|
|
437
460
|
|
|
438
|
-
function externalPage(): string {
|
|
461
|
+
function externalPage(has: Set<string>): string {
|
|
439
462
|
return [
|
|
440
|
-
"# External Datasets",
|
|
441
|
-
"",
|
|
442
463
|
"ForgeQE refreshes these datasets from Azure DevOps or Jira during `forgeqe bi sync` and `forgeqe bi build`.",
|
|
443
464
|
"",
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
order by title`,
|
|
449
|
-
),
|
|
450
|
-
"",
|
|
451
|
-
"{% 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`),
|
|
452
469
|
"",
|
|
453
470
|
].join("\n")
|
|
454
471
|
}
|
|
455
472
|
|
|
456
|
-
function datasetPage(dataset: BiDatasetConfig): string {
|
|
473
|
+
function datasetPage(dataset: BiDatasetConfig, has: Set<string>): string {
|
|
457
474
|
return [
|
|
458
|
-
`# ${dataset.title}`,
|
|
459
|
-
"",
|
|
460
475
|
`Source kind: \`${dataset.kind}\``,
|
|
461
476
|
"",
|
|
462
|
-
|
|
463
|
-
"",
|
|
464
|
-
"{% table data=\"dataset_stats\" /%}",
|
|
465
|
-
"",
|
|
466
|
-
sqlBlock("dataset_preview", `select * from forgeqe.${dataset.id} limit 100`),
|
|
467
|
-
"",
|
|
468
|
-
"{% table data=\"dataset_preview\" /%}",
|
|
477
|
+
query(has, "dataset_rows", `select * from ${SOURCE}.${dataset.id}`),
|
|
469
478
|
"",
|
|
470
479
|
].join("\n")
|
|
471
480
|
}
|
|
@@ -617,25 +626,20 @@ function collectReadiness(cfg: AppConfig): { summary: ReadinessRow[]; stories: R
|
|
|
617
626
|
return out
|
|
618
627
|
}
|
|
619
628
|
|
|
620
|
-
async function exportExternalDatasets(cfg: AppConfig,
|
|
629
|
+
async function exportExternalDatasets(cfg: AppConfig, notes: string[]): Promise<ExternalExport[]> {
|
|
621
630
|
const bi = cfg.bi || defaultBiConfig(cfg)
|
|
622
631
|
const exports: ExternalExport[] = []
|
|
623
632
|
for (const dataset of bi.datasets) {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
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}` },
|
|
629
|
-
files: [writeCsv(file, ADO_DATASET_HEADERS, rows)],
|
|
630
|
-
})
|
|
631
|
-
} else {
|
|
632
|
-
const rows = await fetchJiraRows(cfg, dataset)
|
|
633
|
-
const file = path.join(paths.externalDataDir, `${dataset.id}.csv`)
|
|
634
|
-
exports.push({
|
|
635
|
-
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}` },
|
|
636
|
-
files: [writeCsv(file, JIRA_DATASET_HEADERS, rows)],
|
|
637
|
-
})
|
|
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.`)
|
|
638
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
|
+
})
|
|
639
643
|
}
|
|
640
644
|
return exports
|
|
641
645
|
}
|
|
@@ -645,10 +649,10 @@ async function fetchAdoRows(cfg: AppConfig, dataset: Extract<BiDatasetConfig, {
|
|
|
645
649
|
const project = dataset.project || configured
|
|
646
650
|
const base = project ? `${org}/${encodeURIComponent(project)}` : org
|
|
647
651
|
const { data } = await http<{ workItems?: { id: number }[]; workItemRelations?: { source?: { id?: number } | null; target?: { id?: number } | null }[] }>(
|
|
648
|
-
`${base}/_apis/wit/wiql?api-version=7.1&$top
|
|
652
|
+
`${base}/_apis/wit/wiql?api-version=7.1&$top=${ADO_WIQL_MAX}`,
|
|
649
653
|
{ auth, body: { query: dataset.wiql } },
|
|
650
654
|
)
|
|
651
|
-
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")])]
|
|
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")])]
|
|
652
656
|
if (!ids.length) return []
|
|
653
657
|
const fields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.AssignedTo", "System.ChangedDate", "System.IterationPath", "System.TeamProject"]
|
|
654
658
|
const rows: Record<string, string | number>[] = []
|
|
@@ -764,22 +768,18 @@ function readJson<T>(file: string): T | null {
|
|
|
764
768
|
}
|
|
765
769
|
}
|
|
766
770
|
|
|
767
|
-
function sqlPath(file: string): string {
|
|
768
|
-
return file.split(path.sep).join("/").replace(/'/g, "''")
|
|
769
|
-
}
|
|
770
|
-
|
|
771
771
|
function sqlBlock(name: string, sql: string): string {
|
|
772
772
|
return `\`\`\`sql ${name}\n${sql}\n\`\`\``
|
|
773
773
|
}
|
|
774
774
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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 {
|
|
783
783
|
return [
|
|
784
784
|
`# ${bi.title}`,
|
|
785
785
|
"",
|
|
@@ -793,27 +793,122 @@ function summaryMarkdown(
|
|
|
793
793
|
`- Release readiness reports exported: ${readiness.summary.length}`,
|
|
794
794
|
`- External datasets exported: ${external.length}`,
|
|
795
795
|
"",
|
|
796
|
-
|
|
796
|
+
"## External datasets",
|
|
797
797
|
"",
|
|
798
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`."]),
|
|
799
799
|
"",
|
|
800
800
|
].join("\n")
|
|
801
801
|
}
|
|
802
802
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
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`)
|
|
808
903
|
await new Promise<void>((resolve, reject) => {
|
|
809
|
-
const child = spawn(
|
|
904
|
+
const child = spawn(bin, args, {
|
|
810
905
|
cwd,
|
|
811
906
|
stdio: "inherit",
|
|
812
907
|
shell: process.platform === "win32",
|
|
813
|
-
env: { ...process.env, DO_NOT_TRACK: "1", EVIDENCE_TELEMETRY_DISABLED: "1"
|
|
908
|
+
env: { ...process.env, DO_NOT_TRACK: "1", EVIDENCE_TELEMETRY_DISABLED: "1" },
|
|
814
909
|
})
|
|
815
910
|
child.on("error", reject)
|
|
816
|
-
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}`))))
|
|
817
912
|
})
|
|
818
913
|
}
|
|
819
914
|
|