terminfo.dev 1.0.0 → 1.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminfo.dev",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Test your terminal's feature support and contribute to terminfo.dev",
5
5
  "keywords": [
6
6
  "ansi",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "silvery": "^0.4.3"
36
+ "commander": "^14.0.0"
37
37
  },
38
38
  "engines": {
39
39
  "node": ">=23.6.0"
package/src/index.ts CHANGED
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * terminfo CLI — test your terminal's feature support.
3
+ * terminfo.dev CLI — can your terminal do that?
4
4
  *
5
- * Runs probes against the real terminal you're using (not a headless library),
6
- * shows results, and optionally submits them to terminfo.dev.
5
+ * Test your terminal's feature support and contribute results to terminfo.dev.
7
6
  *
8
7
  * @example
9
8
  * ```bash
10
- * npx terminfo # Run all probes
11
- * npx terminfo --json # Output JSON results
12
- * npx terminfo --submit # Submit results to terminfo.dev
9
+ * npx terminfo.dev # Show terminal info + help
10
+ * npx terminfo.dev probe # Run all probes
11
+ * npx terminfo.dev submit # Run probes + submit results
13
12
  * ```
14
13
  */
15
14
 
15
+ import { Command } from "commander"
16
16
  import { readFileSync } from "node:fs"
17
17
  import { dirname, join } from "node:path"
18
18
  import { fileURLToPath } from "node:url"
@@ -25,11 +25,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
25
25
 
26
26
  /** Load feature slugs from features.json for OSC 8 hyperlinks */
27
27
  function loadFeatureSlugs(): Record<string, string> {
28
- // Try repo-local path first, then npm-installed path
29
- const candidates = [
30
- join(__dirname, "..", "..", "features.json"), // repo: cli/src/ -> features.json
31
- join(__dirname, "..", "..", "..", "features.json"), // npm: node_modules/terminfo.dev/src/ -> features.json
32
- ]
28
+ const candidates = [join(__dirname, "..", "..", "features.json"), join(__dirname, "..", "..", "..", "features.json")]
33
29
  for (const path of candidates) {
34
30
  try {
35
31
  const raw = JSON.parse(readFileSync(path, "utf-8"))
@@ -41,42 +37,33 @@ function loadFeatureSlugs(): Record<string, string> {
41
37
  return slugs
42
38
  } catch {}
43
39
  }
44
- return {} // fallback: featureSlug() will use id.replaceAll(".", "-")
40
+ return {}
45
41
  }
46
42
 
47
- interface ResultEntry {
48
- terminal: string
49
- terminalVersion: string
50
- os: string
51
- osVersion: string
52
- source: "community"
53
- generated: string
43
+ /** OSC 8 hyperlink */
44
+ function link(url: string, text: string): string {
45
+ return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
46
+ }
47
+
48
+ interface ProbeResults {
49
+ terminal: ReturnType<typeof detectTerminal>
54
50
  results: Record<string, boolean>
55
51
  notes: Record<string, string>
56
52
  responses: Record<string, string>
53
+ passed: number
54
+ total: number
57
55
  }
58
56
 
59
- async function main() {
60
- const args = process.argv.slice(2)
61
- const jsonMode = args.includes("--json")
62
- const submitMode = args.includes("--submit")
63
-
64
- // Detect terminal
57
+ async function runProbes(): Promise<ProbeResults> {
65
58
  const terminal = detectTerminal()
66
-
67
59
  const results: Record<string, boolean> = {}
68
60
  const notes: Record<string, string> = {}
69
61
  const responses: Record<string, string> = {}
70
62
  let passed = 0
71
- let failed = 0
72
63
 
73
64
  await withRawMode(async () => {
74
- // Enter alt screen inside raw mode so all probe output stays contained
75
- process.stdout.write("\x1b[?1049h") // alt screen
76
- process.stdout.write("\x1b[2J\x1b[H") // clear + home
77
-
65
+ process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H")
78
66
  for (const probe of ALL_PROBES) {
79
- // Clear screen before each probe to prevent leaking output
80
67
  process.stdout.write("\x1b[2J\x1b[H")
81
68
  try {
82
69
  const result = await probe.run()
@@ -84,40 +71,36 @@ async function main() {
84
71
  if (result.note) notes[probe.id] = result.note
85
72
  if (result.response) responses[probe.id] = result.response
86
73
  if (result.pass) passed++
87
- else failed++
88
74
  } catch (err) {
89
75
  results[probe.id] = false
90
76
  notes[probe.id] = `error: ${err instanceof Error ? err.message : String(err)}`
91
- failed++
92
77
  }
93
78
  }
94
-
95
- // Exit alt screen while still in raw mode
96
79
  process.stdout.write("\x1b[?1049l")
97
80
  })
98
81
 
99
- const total = ALL_PROBES.length
100
- const pct = Math.round((passed / total) * 100)
101
-
102
- const entry: ResultEntry = {
103
- terminal: terminal.name,
104
- terminalVersion: terminal.version,
105
- os: terminal.os,
106
- osVersion: terminal.osVersion,
107
- source: "community",
108
- generated: new Date().toISOString(),
109
- results,
110
- notes,
111
- responses,
112
- }
82
+ return { terminal, results, notes, responses, passed, total: ALL_PROBES.length }
83
+ }
113
84
 
114
- if (jsonMode) {
115
- console.log(JSON.stringify(entry, null, 2))
116
- return
117
- }
85
+ function printHeader(terminal: ReturnType<typeof detectTerminal>) {
86
+ const siteLink = link("https://terminfo.dev", "terminfo.dev")
87
+ console.log(`\x1b[1m${siteLink}\x1b[0m — can your terminal do that?\n`)
88
+ console.log(` Terminal: \x1b[1m${terminal.name}\x1b[0m${terminal.version ? ` ${terminal.version}` : ""}`)
89
+ console.log(` Platform: ${terminal.os} ${terminal.osVersion}`)
90
+ console.log(
91
+ ` Probes: ${ALL_PROBES.length} features across ${new Set(ALL_PROBES.map((p) => p.id.split(".")[0])).size} categories`,
92
+ )
93
+ console.log(` Website: ${link("https://terminfo.dev", "https://terminfo.dev")}`)
94
+ }
118
95
 
119
- // Build category data for report
96
+ function printResults(data: ProbeResults) {
97
+ const { passed, total } = data
98
+ const pct = Math.round((passed / total) * 100)
120
99
  const slugs = loadFeatureSlugs()
100
+
101
+ printHeader(data.terminal)
102
+ console.log(` Score: \x1b[1m${passed}/${total} (${pct}%)\x1b[0m\n`)
103
+
121
104
  const categories = new Map<string, Array<{ id: string; name: string; pass: boolean; note?: string }>>()
122
105
  for (const probe of ALL_PROBES) {
123
106
  const cat = probe.id.split(".")[0]!
@@ -125,38 +108,105 @@ async function main() {
125
108
  categories.get(cat)!.push({
126
109
  id: probe.id,
127
110
  name: probe.name,
128
- pass: results[probe.id] ?? false,
129
- note: notes[probe.id],
111
+ pass: data.results[probe.id] ?? false,
112
+ note: data.notes[probe.id],
130
113
  })
131
114
  }
132
115
 
133
- // Render with silvery
134
- const { renderReport } = await import("./report.tsx")
135
- const output = await renderReport({
136
- terminal: terminal.name,
137
- terminalVersion: terminal.version,
138
- os: terminal.os,
139
- osVersion: terminal.osVersion,
140
- probeCount: total,
141
- categoryCount: new Set(ALL_PROBES.map(p => p.id.split(".")[0])).size,
142
- passed,
143
- total,
144
- categories,
145
- slugs,
146
- submitMode,
116
+ for (const [cat, probes] of categories) {
117
+ const catPassed = probes.filter((p) => p.pass).length
118
+ const color = catPassed === probes.length ? "\x1b[32m" : catPassed > 0 ? "\x1b[33m" : "\x1b[31m"
119
+ const catLink = link(`https://terminfo.dev/${cat}`, cat)
120
+ console.log(`${color}${catLink}\x1b[0m (${catPassed}/${probes.length})`)
121
+ for (const p of probes) {
122
+ const icon = p.pass ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m"
123
+ const note = p.note ? ` \x1b[2m— ${p.note}\x1b[0m` : ""
124
+ const slug = slugs[p.id] ?? p.id.replaceAll(".", "-")
125
+ const fCat = p.id.split(".")[0]!
126
+ const featureLink = link(`https://terminfo.dev/${fCat}/${slug}`, p.name)
127
+ console.log(` ${icon} ${featureLink}${note}`)
128
+ }
129
+ }
130
+ }
131
+
132
+ // ── CLI ──
133
+
134
+ const program = new Command()
135
+ .name("terminfo.dev")
136
+ .description("Can your terminal do that? — test terminal feature support and contribute to terminfo.dev")
137
+ .version("1.1.0")
138
+
139
+ program
140
+ .command("probe")
141
+ .description("Run all probes against your terminal and display results")
142
+ .option("--json", "Output results as JSON")
143
+ .action(async (opts) => {
144
+ const data = await runProbes()
145
+
146
+ if (opts.json) {
147
+ console.log(
148
+ JSON.stringify(
149
+ {
150
+ terminal: data.terminal.name,
151
+ terminalVersion: data.terminal.version,
152
+ os: data.terminal.os,
153
+ osVersion: data.terminal.osVersion,
154
+ source: "community",
155
+ generated: new Date().toISOString(),
156
+ results: data.results,
157
+ notes: data.notes,
158
+ responses: data.responses,
159
+ },
160
+ null,
161
+ 2,
162
+ ),
163
+ )
164
+ return
165
+ }
166
+
167
+ printResults(data)
168
+ console.log(`\n\x1b[2mContribute these results: \x1b[0m\x1b[1mnpx terminfo.dev submit\x1b[0m`)
147
169
  })
148
- console.log(output)
149
170
 
150
- if (submitMode) {
171
+ program
172
+ .command("submit")
173
+ .description("Run all probes and submit results to terminfo.dev via GitHub issue")
174
+ .action(async () => {
175
+ const data = await runProbes()
176
+ printResults(data)
177
+
151
178
  console.log(`\nSubmitting results to terminfo.dev...`)
152
- const url = await submitResults(entry)
179
+ const url = await submitResults({
180
+ terminal: data.terminal.name,
181
+ terminalVersion: data.terminal.version,
182
+ os: data.terminal.os,
183
+ osVersion: data.terminal.osVersion,
184
+ results: data.results,
185
+ notes: data.notes,
186
+ responses: data.responses,
187
+ generated: new Date().toISOString(),
188
+ })
153
189
  if (url) {
154
- console.log(`\x1b[32m✓ Issue created:\x1b[0m ${url}`)
190
+ console.log(`\x1b[32m✓ Issue created:\x1b[0m ${link(url, url)}`)
155
191
  }
156
- }
157
- }
192
+ })
158
193
 
159
- main().catch((err) => {
160
- console.error(err)
161
- process.exit(1)
194
+ // Default action: show terminal info + help
195
+ program.action(() => {
196
+ const terminal = detectTerminal()
197
+ printHeader(terminal)
198
+ console.log(``)
199
+ console.log(`\x1b[2mTest your terminal against ${ALL_PROBES.length} features from the ECMA-48,`)
200
+ console.log(`VT100/VT510, xterm, and Kitty specifications. Results can be`)
201
+ console.log(`submitted to the community database at terminfo.dev.\x1b[0m`)
202
+ console.log(``)
203
+ console.log(`Commands:`)
204
+ console.log(` \x1b[1mprobe\x1b[0m Run all probes and display results`)
205
+ console.log(` \x1b[1msubmit\x1b[0m Run probes and submit to terminfo.dev`)
206
+ console.log(``)
207
+ console.log(`Options:`)
208
+ console.log(` \x1b[1m--json\x1b[0m Output results as JSON (with probe command)`)
209
+ console.log(` \x1b[1m--help\x1b[0m Show this help`)
162
210
  })
211
+
212
+ program.parse()
package/src/report.tsx CHANGED
@@ -49,18 +49,35 @@ function Header({ terminal, terminalVersion, os, osVersion, probeCount, category
49
49
  <Text dimColor>Score</Text>
50
50
  </Box>
51
51
  <Box flexDirection="column">
52
- <Text bold>{terminal}{terminalVersion ? ` ${terminalVersion}` : ""}</Text>
53
- <Text>{os} {osVersion}</Text>
54
- <Text>{probeCount} features, {categoryCount} categories</Text>
55
- <Text bold color={pct === 100 ? "green" : pct >= 90 ? "yellow" : "red"}>{passed}/{total} ({pct}%)</Text>
52
+ <Text bold>
53
+ {terminal}
54
+ {terminalVersion ? ` ${terminalVersion}` : ""}
55
+ </Text>
56
+ <Text>
57
+ {os} {osVersion}
58
+ </Text>
59
+ <Text>
60
+ {probeCount} features, {categoryCount} categories
61
+ </Text>
62
+ <Text bold color={pct === 100 ? "green" : pct >= 90 ? "yellow" : "red"}>
63
+ {passed}/{total} ({pct}%)
64
+ </Text>
56
65
  </Box>
57
66
  </Box>
58
67
  </Box>
59
68
  )
60
69
  }
61
70
 
62
- function CategorySection({ name, probes, slugs }: { name: string; probes: ProbeResult[]; slugs: Record<string, string> }) {
63
- const catPassed = probes.filter(p => p.pass).length
71
+ function CategorySection({
72
+ name,
73
+ probes,
74
+ slugs,
75
+ }: {
76
+ name: string
77
+ probes: ProbeResult[]
78
+ slugs: Record<string, string>
79
+ }) {
80
+ const catPassed = probes.filter((p) => p.pass).length
64
81
  const allPassed = catPassed === probes.length
65
82
  const catUrl = `https://terminfo.dev/${name}`
66
83
 
@@ -69,7 +86,7 @@ function CategorySection({ name, probes, slugs }: { name: string; probes: ProbeR
69
86
  <Text color={allPassed ? "green" : catPassed > 0 ? "yellow" : "red"}>
70
87
  {osc8(catUrl, name)} ({catPassed}/{probes.length})
71
88
  </Text>
72
- {probes.map(p => {
89
+ {probes.map((p) => {
73
90
  const slug = slugs[p.id] ?? p.id.replaceAll(".", "-")
74
91
  const url = featureUrl(p.id, slug)
75
92
  const icon = p.pass ? "✓" : "✗"
@@ -90,7 +107,7 @@ function Footer({ submitMode }: { submitMode: boolean }) {
90
107
  return (
91
108
  <Box flexDirection="column" marginTop={1}>
92
109
  <Text dimColor>Submit: npx terminfo.dev --submit</Text>
93
- <Text dimColor>JSON: npx terminfo.dev --json</Text>
110
+ <Text dimColor>JSON: npx terminfo.dev --json</Text>
94
111
  </Box>
95
112
  )
96
113
  }
@@ -107,7 +124,9 @@ function Report(props: ReportProps & { slugs: Record<string, string>; submitMode
107
124
  )
108
125
  }
109
126
 
110
- export async function renderReport(props: ReportProps & { slugs: Record<string, string>; submitMode: boolean }): Promise<string> {
127
+ export async function renderReport(
128
+ props: ReportProps & { slugs: Record<string, string>; submitMode: boolean },
129
+ ): Promise<string> {
111
130
  const width = process.stdout.columns ?? 80
112
131
  return renderString(<Report {...props} />, { width })
113
132
  }
package/src/submit.ts CHANGED
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * Submit results to terminfo.dev via GitHub issue.
3
- *
4
- * Creates an issue on beorn/terminfo.dev with the JSON results
5
- * attached as a code block. Maintainers review and merge into
6
- * the results database.
7
3
  */
8
4
 
5
+ import { writeFileSync, unlinkSync } from "node:fs"
6
+ import { tmpdir } from "node:os"
7
+ import { join } from "node:path"
8
+ import { execFileSync } from "node:child_process"
9
+ import { createInterface } from "node:readline"
10
+
9
11
  const REPO = "beorn/terminfo.dev"
10
- const ISSUE_LABEL = "community-results"
11
12
 
12
- interface SubmitResult {
13
+ interface SubmitData {
13
14
  terminal: string
14
15
  terminalVersion: string
15
16
  os: string
@@ -20,11 +21,42 @@ interface SubmitResult {
20
21
  generated: string
21
22
  }
22
23
 
23
- /**
24
- * Submit results by creating a GitHub issue.
25
- * Requires `gh` CLI to be installed and authenticated.
26
- */
27
- export async function submitResults(data: SubmitResult): Promise<string | null> {
24
+ async function prompt(question: string, defaultValue?: string): Promise<string> {
25
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
26
+ const suffix = defaultValue ? ` [${defaultValue}]` : ""
27
+ return new Promise((resolve) => {
28
+ rl.question(`${question}${suffix}: `, (answer) => {
29
+ rl.close()
30
+ resolve(answer.trim() || defaultValue || "")
31
+ })
32
+ })
33
+ }
34
+
35
+ export async function submitResults(data: SubmitData): Promise<string | null> {
36
+ // Confirm/fill terminal info
37
+ console.log(`\n\x1b[1mConfirm submission details:\x1b[0m`)
38
+ data.terminal = await prompt(" Terminal name", data.terminal)
39
+ data.terminalVersion = await prompt(" Terminal version", data.terminalVersion || undefined)
40
+ data.os = await prompt(" Operating system", data.os)
41
+
42
+ if (!data.terminalVersion) {
43
+ console.log(`\x1b[33m ⚠ No version specified — results will be less useful\x1b[0m`)
44
+ }
45
+
46
+ // Check for duplicates
47
+ if (hasGhCli()) {
48
+ const existing = checkDuplicate(data.terminal, data.terminalVersion, data.os)
49
+ if (existing) {
50
+ console.log(`\n\x1b[33m⚠ A submission already exists for ${data.terminal}${data.terminalVersion ? ` ${data.terminalVersion}` : ""} on ${data.os}:\x1b[0m`)
51
+ console.log(` ${existing}`)
52
+ const proceed = await prompt(" Submit anyway? (y/N)", "N")
53
+ if (proceed.toLowerCase() !== "y") {
54
+ console.log(`Skipped.`)
55
+ return null
56
+ }
57
+ }
58
+ }
59
+
28
60
  const passed = Object.values(data.results).filter(Boolean).length
29
61
  const total = Object.keys(data.results).length
30
62
  const pct = Math.round((passed / total) * 100)
@@ -41,10 +73,12 @@ export async function submitResults(data: SubmitResult): Promise<string | null>
41
73
  | Score | ${passed}/${total} (${pct}%) |
42
74
  | Generated | ${data.generated} |
43
75
 
44
- ### Results
76
+ ### Summary
77
+
78
+ ${formatSummary(data)}
45
79
 
46
80
  <details>
47
- <summary>Full JSON (click to expand)</summary>
81
+ <summary>Full JSON</summary>
48
82
 
49
83
  \`\`\`json
50
84
  ${JSON.stringify(data, null, 2)}
@@ -52,55 +86,66 @@ ${JSON.stringify(data, null, 2)}
52
86
 
53
87
  </details>
54
88
 
55
- ### Summary
56
-
57
- ${formatSummary(data)}
58
-
59
89
  ---
60
- *Submitted via \`npx terminfo\`*`
61
-
62
- // Try gh CLI first
63
- if (await hasGhCli()) {
64
- try {
65
- const { execSync } = await import("node:child_process")
66
- const result = execSync(
67
- `gh issue create --repo ${REPO} --title ${JSON.stringify(title)} --body ${JSON.stringify(body)} --label ${ISSUE_LABEL}`,
68
- { encoding: "utf-8", timeout: 30000 },
69
- )
70
- const url = result.trim()
71
- return url
72
- } catch (err) {
73
- console.error(`\x1b[31mFailed to create issue via gh CLI\x1b[0m`)
74
- console.error(err instanceof Error ? err.message : String(err))
75
- return null
76
- }
90
+ *Submitted via \`npx terminfo.dev submit\`*`
91
+
92
+ if (!hasGhCli()) {
93
+ const filename = `terminfo-${data.terminal}-${data.os}-${Date.now()}.json`
94
+ writeFileSync(filename, JSON.stringify(data, null, 2))
95
+ console.log(`\n\x1b[33mgh CLI not found. Results saved to ${filename}\x1b[0m`)
96
+ console.log(`To submit: https://github.com/${REPO}/issues/new`)
97
+ return null
77
98
  }
78
99
 
79
- // Fallback: write to file and give instructions
80
- const { writeFileSync } = await import("node:fs")
81
- const filename = `terminfo-${data.terminal}-${data.os}-${Date.now()}.json`
82
- writeFileSync(filename, JSON.stringify(data, null, 2))
83
- console.log(`\n\x1b[33mgh CLI not found. Results saved to ${filename}\x1b[0m`)
84
- console.log(`To submit manually:`)
85
- console.log(` 1. Go to https://github.com/${REPO}/issues/new`)
86
- console.log(` 2. Title: ${title}`)
87
- console.log(` 3. Paste the contents of ${filename}`)
88
- return null
100
+ const bodyFile = join(tmpdir(), `terminfo-submit-${Date.now()}.md`)
101
+ try {
102
+ writeFileSync(bodyFile, body)
103
+ const result = execFileSync("gh", [
104
+ "issue", "create",
105
+ "--repo", REPO,
106
+ "--title", title,
107
+ "--body-file", bodyFile,
108
+ ], { encoding: "utf-8", timeout: 30000 })
109
+ return result.trim()
110
+ } catch (err) {
111
+ console.error(`\x1b[31mFailed to create issue\x1b[0m`)
112
+ console.error(err instanceof Error ? err.message : String(err))
113
+ return null
114
+ } finally {
115
+ try { unlinkSync(bodyFile) } catch {}
116
+ }
89
117
  }
90
118
 
91
- async function hasGhCli(): Promise<boolean> {
119
+ function hasGhCli(): boolean {
92
120
  try {
93
- const { execSync } = await import("node:child_process")
94
- execSync("gh --version", { stdio: "ignore", timeout: 5000 })
121
+ execFileSync("gh", ["--version"], { stdio: "ignore", timeout: 5000 })
95
122
  return true
96
123
  } catch {
97
124
  return false
98
125
  }
99
126
  }
100
127
 
101
- function formatSummary(data: SubmitResult): string {
102
- const categories = new Map<string, { pass: number; fail: number; failList: string[] }>()
128
+ function checkDuplicate(terminal: string, version: string, os: string): string | null {
129
+ try {
130
+ const search = `[census] ${terminal}${version ? ` ${version}` : ""} on ${os}`
131
+ const result = execFileSync("gh", [
132
+ "issue", "list",
133
+ "--repo", REPO,
134
+ "--search", search,
135
+ "--state", "all",
136
+ "--limit", "1",
137
+ "--json", "url,title",
138
+ "--jq", ".[0] | .title + \" \" + .url",
139
+ ], { encoding: "utf-8", timeout: 10000 })
140
+ const trimmed = result.trim()
141
+ return trimmed || null
142
+ } catch {
143
+ return null
144
+ }
145
+ }
103
146
 
147
+ function formatSummary(data: SubmitData): string {
148
+ const categories = new Map<string, { pass: number; fail: number; failList: string[] }>()
104
149
  for (const [id, pass] of Object.entries(data.results)) {
105
150
  const cat = id.split(".")[0]!
106
151
  if (!categories.has(cat)) categories.set(cat, { pass: 0, fail: 0, failList: [] })
@@ -112,14 +157,11 @@ function formatSummary(data: SubmitResult): string {
112
157
  entry.failList.push(note ? `- \`${id}\`: ${note}` : `- \`${id}\``)
113
158
  }
114
159
  }
115
-
116
160
  const lines: string[] = []
117
161
  for (const [cat, { pass, fail, failList }] of categories) {
118
- const total = pass + fail
119
162
  const icon = fail === 0 ? "✅" : "⚠️"
120
- lines.push(`${icon} **${cat}**: ${pass}/${total}`)
163
+ lines.push(`${icon} **${cat}**: ${pass}/${pass + fail}`)
121
164
  if (failList.length > 0) lines.push(...failList)
122
165
  }
123
-
124
166
  return lines.join("\n")
125
167
  }