dsh-ensp 0.7.0 → 0.8.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.
@@ -117,8 +117,12 @@ export const EXPECTATIONS = {
117
117
  { id: 'ipv6-route', on: 'R1', command: 'display ipv6 routing-table', expect: { minLines: 3 } },
118
118
  ],
119
119
  'vrrp-basics': [
120
- { id: 'vrrp-master', on: 'R1', command: 'display vrrp', expect: { contains: 'Master' } },
121
- { id: 'vrrp-backup', on: 'R2', command: 'display vrrp', expect: { contains: 'Backup' } },
120
+ // The blueprint is SW1/SW2/ACC/PC1 with SW1 as the master (.252) and SW2 as the
121
+ // backup (.253). These used to say R1/R2, which no longer resolves to anything
122
+ // wrong-looking now that resolveDevice keeps to the same device family, but the
123
+ // right names are the switches themselves.
124
+ { id: 'vrrp-master', on: 'SW1', command: 'display vrrp', expect: { contains: 'Master' } },
125
+ { id: 'vrrp-backup', on: 'SW2', command: 'display vrrp', expect: { contains: 'Backup' } },
122
126
  ],
123
127
  'ospfv3-ipv6': [
124
128
  { id: 'ospfv3-neighbour', on: 'R1', command: 'display ospfv3 peer', expect: { contains: 'Full' } },
@@ -128,8 +132,11 @@ export const EXPECTATIONS = {
128
132
  { id: 'rp-set', on: 'R1', command: 'display pim rp-info', expect: { minLines: 2 } },
129
133
  ],
130
134
  'mpls-basic': [
131
- { id: 'mpls-enabled', on: 'PE1', command: 'display mpls ldp session', expect: { minLines: 2 } },
132
- { id: 'ldp-peer', on: 'PE1', command: 'display mpls ldp peer', expect: { minLines: 2 } },
135
+ // The blueprint is four routers R1..R4 with no PE among them, so asking PE1 for
136
+ // its LDP sessions was asking a device that does not exist. LDP runs on every
137
+ // hop here; the first router's peer and session tables are the evidence.
138
+ { id: 'ldp-session', on: 'R1', command: 'display mpls ldp session', expect: { minLines: 2 } },
139
+ { id: 'ldp-peer', on: 'R1', command: 'display mpls ldp peer', expect: { minLines: 2 } },
133
140
  ],
134
141
  'mpls-l3vpn': [
135
142
  { id: 'vpnv4-peer', on: 'PE1', command: 'display bgp vpnv4 all peer', expect: { contains: 'Established' } },
@@ -153,17 +160,172 @@ export const EXPECTATIONS = {
153
160
  'network-management': [
154
161
  { id: 'snmp-agent', on: 'R1', command: 'display snmp-agent sys-info', expect: { minLines: 2 } },
155
162
  ],
163
+
164
+ // `ppp-hdlc` was previously smoke only, and the blueprint put the two links on GE
165
+ // ports where `link-protocol ppp` is rejected outright. Both checks below were run
166
+ // against a corrected blueprint on the Router model with real Serial ports.
167
+ 'ppp-hdlc': [
168
+ // The Serial ports ship with PPP as their encapsulation, so the HDLC side is the
169
+ // one that changes: blank reads `PPP`, after `link-protocol hdlc` it reads
170
+ // `nonstandard HDLC`.
171
+ { id: 'hdlc-encapsulation', on: 'R1', command: 'display interface Serial0/0/1', expect: { contains: 'nonstandard HDLC' } },
172
+ // CHAP leaves a line in the running configuration; a blank interface has none.
173
+ { id: 'chap-authentication', on: 'R1', command: 'display current-configuration interface Serial0/0/0', expect: { contains: 'ppp authentication-mode chap' } },
174
+ ],
175
+
176
+ // `arp-security` on S5700: `display arp anti-attack configuration all` answers with
177
+ // fifty lines whether or not anything is configured, and the only line that changes
178
+ // is the rate-limit switch inside the Global configuration block.
179
+ 'arp-security': [
180
+ { id: 'rate-limit-enabled', on: 'SW1', command: 'display arp anti-attack configuration all', expect: { contains: 'arp anti-attack rate-limit enable' } },
181
+ ],
182
+
183
+ // `spine-leaf-vxlan` cannot assert its overlay half: eNSP's S5700 answers
184
+ // `Unrecognized command` to `display nve`, `display bridge-domain` and
185
+ // `display vxlan vni`, and there is no `it does not support VXLAN` to configure.
186
+ // The underlay half does work, so these three check that instead.
187
+ 'spine-leaf-vxlan': [
188
+ { id: 'underlay-ospf', on: 'LEAF1', command: 'display ospf brief', expect: { contains: 'OSPF Process' } },
189
+ // A blank switch has no LoopBack0 at all and answers `Wrong parameter`.
190
+ { id: 'vtep-loopback', on: 'LEAF1', command: 'display interface LoopBack0', expect: { contains: 'current state' } },
191
+ // A blank switch reports one VLAN; any leaf carrying a tenant segment reports more.
192
+ { id: 'tenant-vlans', on: 'LEAF1', command: 'display vlan', expect: { notContains: 'The total number of vlans is : 1' } },
193
+ ],
194
+
195
+ // The scenarios below used to fall through to smoke checks, which reported a pass
196
+ // without reading anything. Every assertion here was picked by running its command
197
+ // twice on a real device of the same model, once blank and once after configuring
198
+ // that feature, and keeping only the ones that fail on the blank run and pass on
199
+ // the configured one. A `contains` aimed at a table header fails that test and was
200
+ // dropped unless the header itself only appears once the feature exists.
201
+ //
202
+ // What these prove is that the feature was configured. They do not prove that
203
+ // neighbours converged, which needs the whole topology up and is what the curated
204
+ // scenarios with `Established` or `Full` assertions check.
205
+ 'mstp-load-balance': [
206
+ // A blank switch reports the revision level as 0 and its region name as its own
207
+ // MAC, so both readings survive a wrong answer unless the default is excluded.
208
+ { id: 'region-configured', on: 'CORE1', command: 'display stp region-configuration', expect: { matches: /Revision level\s*:\s*[1-9]/ } },
209
+ // A blank switch answers `Info: No instance information is available.`; mapping
210
+ // VLANs to instances replaces that with the instance table.
211
+ { id: 'instance-mapped', on: 'CORE1', command: 'display stp instance 1 brief', expect: { notContains: 'No instance information' } },
212
+ // The blank table has a header and no rows. A forwarding port is a row.
213
+ { id: 'port-forwarding', on: 'CORE1', command: 'display stp brief', expect: { contains: 'FORWARDING' } },
214
+ ],
215
+ 'port-isolation': [
216
+ // Blank: the command answers with nothing but its own echo. Configured: it lists
217
+ // the ports carrying `port-isolate enable`.
218
+ { id: 'isolate-group', on: 'SW1', command: 'display port-isolate group all', expect: { contains: 'isolate group' } },
219
+ ],
220
+ 'ospf-lsa-types': [
221
+ { id: 'process-running', on: 'R1', command: 'display ospf brief', expect: { contains: 'OSPF Process' } },
222
+ { id: 'lsdb-present', on: 'R1', command: 'display ospf lsdb', expect: { contains: 'Link State Database' } },
223
+ ],
224
+ 'ospf-virtual-link': [
225
+ { id: 'process-running', on: 'R1', command: 'display ospf brief', expect: { contains: 'OSPF Process' } },
226
+ ],
227
+ 'ospf-troubleshooting': [
228
+ // A peer pair appears here as soon as both sides are configured, whatever state
229
+ // it is in. `Full` would need the whole lab up, which this scenario is about
230
+ // diagnosing rather than reaching.
231
+ { id: 'peer-discovered', on: 'R1', command: 'display ospf peer', expect: { contains: 'Router ID' } },
232
+ { id: 'interface-in-area', on: 'R1', command: 'display ospf interface', expect: { contains: 'Area:' } },
233
+ ],
234
+ 'bgp-attributes': [
235
+ { id: 'peer-configured', on: 'R1', command: 'display bgp peer', expect: { contains: 'Local AS number' } },
236
+ ],
237
+ 'bgp-aggregation': [
238
+ { id: 'peer-configured', on: 'R1', command: 'display bgp peer', expect: { contains: 'Local AS number' } },
239
+ ],
240
+ 'bgp-troubleshooting': [
241
+ { id: 'peer-configured', on: 'R1', command: 'display bgp peer', expect: { contains: 'Local AS number' } },
242
+ { id: 'peer-detail', on: 'R1', command: 'display bgp peer verbose', expect: { contains: 'BGP Peer is' } },
243
+ ],
244
+ 'route-policy-pbr': [
245
+ { id: 'prefix-list', on: 'R1', command: 'display ip ip-prefix', expect: { contains: 'Prefix-list' } },
246
+ { id: 'route-policy', on: 'R1', command: 'display route-policy', expect: { contains: 'Route-policy' } },
247
+ ],
248
+ 'ipsec-vpn': [
249
+ { id: 'policy-defined', on: 'R1', command: 'display ipsec policy', expect: { contains: 'IPSec policy' } },
250
+ ],
251
+ 'bfd-fast-convergence': [
252
+ { id: 'session-configured', on: 'R1', command: 'display bfd session all', expect: { contains: 'PeerIpAddr' } },
253
+ { id: 'session-detail', on: 'R1', command: 'display bfd session all verbose', expect: { contains: 'Session MIndex' } },
254
+ ],
255
+ 'dual-homed-isp': [
256
+ { id: 'bfd-configured', on: 'R1', command: 'display bfd session all', expect: { contains: 'PeerIpAddr' } },
257
+ ],
258
+ 'ipsec-gre-tunnel': [
259
+ { id: 'ospf-peer', on: 'R1', command: 'display ospf peer', expect: { contains: 'Router ID' } },
260
+ ],
261
+ // The AC ships with no capwap source, which reads `Source interface : -`. Pointing
262
+ // it at a Vlanif is the first real step of both WLAN scenarios and the only
263
+ // reading here that differs before and after.
264
+ 'wlan-basic': [
265
+ { id: 'capwap-source', on: 'AC', command: 'display capwap configuration', expect: { matches: /Source interface\s*:\s*vlanif/i } },
266
+ ],
267
+ 'wlan-roaming': [
268
+ { id: 'capwap-source', on: 'AC', command: 'display capwap configuration', expect: { matches: /Source interface\s*:\s*vlanif/i } },
269
+ ],
270
+
271
+ // The campus scenario needs a USG6000V for its edge firewall, which this machine
272
+ // does not have, so its full lab cannot be built here. Its switches can be, and
273
+ // the two checks below were measured on a pair of S5700s the same way as the rest:
274
+ // blank first, then after configuring that feature. The VRRP check gates on the
275
+ // group existing rather than on its state, because a single configured router
276
+ // stays in Initialize until a peer answers.
277
+ 'campus-network': [
278
+ // A blank switch reports one VLAN; a core carrying the user segments reports more.
279
+ { id: 'user-vlans', on: 'CORE1', command: 'display vlan', expect: { notContains: 'The total number of vlans is : 1' } },
280
+ // A blank switch answers `Info: No VRRP backup group is configured with a virtual IPv4 address.`
281
+ { id: 'gateway-vrrp', on: 'CORE1', command: 'display vrrp', expect: { contains: 'Master IP' } },
282
+ ],
156
283
  }
157
284
 
158
285
  /** Longest useful slice of a command's output kept for a report. */
159
286
  const SNIPPET = 400
160
287
 
288
+ /** A line that is nothing but a VRP prompt, in whatever view the device sits. */
289
+ const PROMPT_ONLY = /^[<\[][\w~./:-]*[>\]]$/
290
+
291
+ /**
292
+ * Strip what the console always echoes back, so assertions see device data only.
293
+ *
294
+ * A reply for a command that printed nothing is the command itself followed by
295
+ * the prompt. Counting lines without removing those two made `minLines: 2`
296
+ * satisfied by silence, which is how thirteen curated checks came to pass on a
297
+ * router nobody had configured.
298
+ *
299
+ * @param {string} output raw reply from the console
300
+ * @param {string} [command] the command that produced it
301
+ * @returns {string}
302
+ */
303
+ export function withoutEcho(output, command) {
304
+ const lines = String(output || '')
305
+ .replace(/\r\n?/g, '\n')
306
+ .split('\n')
307
+ while (lines.length && lines[0].trim() === '') lines.shift()
308
+ if (command && lines.length && lines[0].trim() === command.trim()) lines.shift()
309
+ while (lines.length && (lines[lines.length - 1].trim() === '' || PROMPT_ONLY.test(lines[lines.length - 1].trim()))) {
310
+ lines.pop()
311
+ }
312
+ return lines.join('\n')
313
+ }
314
+
161
315
  /**
162
316
  * Decide one assertion against a command's output.
163
- * @returns {{pass: boolean, why: string}}
317
+ *
318
+ * An empty `expect` means nobody wrote an assertion, which is not the same thing
319
+ * as a passing one. It returns `pass: null` so a report can say "unverified"
320
+ * instead of counting it as a success.
321
+ *
322
+ * @param {string} output raw reply from the console
323
+ * @param {object} [expect]
324
+ * @param {string} [command] the command that produced the output, used to drop its echo
325
+ * @returns {{pass: boolean|null, asserted: boolean, why: string}}
164
326
  */
165
- export function evaluateAssertion(output, expect = {}) {
166
- const text = String(output || '')
327
+ export function evaluateAssertion(output, expect = {}, command) {
328
+ const text = withoutEcho(output, command)
167
329
  const checks = []
168
330
  if (expect.contains !== undefined) {
169
331
  checks.push({ ok: text.includes(expect.contains), why: `contains "${expect.contains}"` })
@@ -179,10 +341,13 @@ export function evaluateAssertion(output, expect = {}) {
179
341
  const n = text.split('\n').filter((l) => l.trim()).length
180
342
  checks.push({ ok: n >= expect.minLines, why: `at least ${expect.minLines} non-empty lines (got ${n})` })
181
343
  }
182
- if (!checks.length) return { pass: true, why: 'no assertion (command must merely succeed)' }
344
+ if (!checks.length) {
345
+ return { pass: null, asserted: false, why: 'no assertion: this command only had to run' }
346
+ }
183
347
  const failed = checks.filter((c) => !c.ok)
184
348
  return {
185
349
  pass: failed.length === 0,
350
+ asserted: true,
186
351
  why: (failed.length ? failed : checks).map((c) => (c.ok ? '' : 'not ') + c.why).join('; '),
187
352
  }
188
353
  }
@@ -192,26 +357,34 @@ export function evaluateAssertion(output, expect = {}) {
192
357
  *
193
358
  * Curated plans name devices the way the shipped blueprints do: R1, R2, R3. A lab
194
359
  * built from somebody else's topology calls them RA/RB/RC, LSW1/LSW2, or anything
195
- * at all. Reporting `no console port for device "R1"` on a healthy lab is worse
196
- * than useless, so fall back to the device that plays the same role: the number in
197
- * the name picks the position (R1 first, R2 second), and a name without a number
198
- * picks the first console device.
360
+ * at all, so a name that is not there gets mapped onto the device playing the same
361
+ * role: same leading letters, position taken from the trailing number.
362
+ *
363
+ * The mapping is deliberately restricted to devices of the same kind. An earlier
364
+ * version fell back to plain position, which turned `R1` into `ACC` on a lab whose
365
+ * switches are SW1/SW2/ACC/PC1 and then reported the result as a passing check of
366
+ * the VRRP master. When no device of that kind exists the name is returned
367
+ * unchanged so the caller reports a missing device instead of a wrong one.
199
368
  */
200
369
  export function resolveDevice(on, available = []) {
201
370
  const names = [...available].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }))
202
371
  if (!names.length) return on
203
372
  if (names.includes(on)) return on
373
+ const prefix = /^[A-Za-z]+/.exec(on)?.[0]?.toUpperCase()
374
+ const family = prefix ? names.filter((n) => n.toUpperCase().startsWith(prefix)) : []
375
+ if (!family.length) return on
204
376
  const numbered = /(\d+)$/.exec(on)
205
377
  const index = numbered ? Math.max(0, Number(numbered[1]) - 1) : 0
206
- return names[Math.min(index, names.length - 1)]
378
+ return family[Math.min(index, family.length - 1)]
207
379
  }
208
380
 
209
381
  /**
210
382
  * Build the acceptance plan for a scenario.
211
383
  *
212
384
  * Curated expectations win. Otherwise the scenario's `steps[].verify` commands
213
- * become smoke checks: the command has to run without a VRP error, which still
214
- * catches a lab that was never configured.
385
+ * become smoke checks: the command has to run without a VRP error. Smoke checks
386
+ * carry `asserted: false` because no output pattern was written for them, so a
387
+ * report can count them separately instead of calling them passes.
215
388
  */
216
389
  export function planFor(id, { devices } = {}) {
217
390
  const scenario = SCENARIOS[id]
@@ -222,24 +395,38 @@ export function planFor(id, { devices } = {}) {
222
395
  scenario: id,
223
396
  title: scenario.title,
224
397
  source: 'curated',
225
- checks: curated.map((c) => ({ ...c, on: resolveDevice(c.on, devices), kind: 'assert' })),
398
+ checks: curated.map((c) => ({ ...c, on: resolveDevice(c.on, devices), kind: 'assert', asserted: true })),
226
399
  }
227
400
  }
228
- const first = (devices && devices.length && devices[0]) || scenario.devices?.split(/[、,,\s]+/)[0]
229
- const commands = [...new Set((scenario.steps || []).map((s) => s.verify).filter(Boolean))]
230
- const checks = commands.flatMap((raw) =>
231
- String(raw)
232
- .split('/')
233
- .map((c) => c.trim())
234
- .filter(Boolean)
235
- .map((command, i) => ({
236
- id: `smoke-${i + 1}`,
237
- on: first,
238
- command,
239
- kind: 'smoke',
240
- expect: {},
241
- })),
242
- )
401
+ // A scenario's `devices` field is prose ("3~4 台路由器"), never device names, so
402
+ // without a running topology there is nobody to run the command against. Leave
403
+ // `on` undefined and let the runner report that instead of inventing a name.
404
+ const first = devices && devices.length ? devices[0] : undefined
405
+ // Several steps put two commands in one verify string separated by " / ". Slashes
406
+ // inside an interface name are part of the command, so only a slash surrounded by
407
+ // spaces is a separator. Dedupe after splitting, because two different steps can
408
+ // both end up asking for the same command.
409
+ const commands = [
410
+ ...new Set(
411
+ (scenario.steps || [])
412
+ .map((s) => s.verify)
413
+ .filter(Boolean)
414
+ .flatMap((raw) =>
415
+ String(raw)
416
+ .split(/\s+\/\s+/)
417
+ .map((c) => c.trim())
418
+ .filter(Boolean),
419
+ ),
420
+ ),
421
+ ]
422
+ const checks = commands.map((command, i) => ({
423
+ id: `smoke-${i + 1}`,
424
+ on: first,
425
+ command,
426
+ kind: 'smoke',
427
+ asserted: false,
428
+ expect: {},
429
+ }))
243
430
  return { scenario: id, title: scenario.title, source: 'smoke', checks }
244
431
  }
245
432
 
@@ -284,14 +471,17 @@ export async function runAcceptance(options = {}) {
284
471
  const plan = planFor(id, { devices: parsed ? [...ports.keys()] : [...(ports?.keys() ?? [])] })
285
472
  const results = []
286
473
  for (const check of plan.checks) {
287
- const port = ports.get(check.on)
474
+ const port = check.on === undefined ? undefined : ports.get(check.on)
288
475
  onProgress?.({ phase: 'check', id: check.id, device: check.on })
289
476
  if (!port) {
290
477
  results.push({
291
478
  ...check,
292
479
  device: check.on,
293
480
  pass: false,
294
- why: `no console port for device "${check.on}"`,
481
+ why:
482
+ check.on === undefined
483
+ ? 'no device to run this on: the plan was built without a topology'
484
+ : `no console port for device "${check.on}"`,
295
485
  snippet: '',
296
486
  })
297
487
  continue
@@ -310,18 +500,19 @@ export async function runAcceptance(options = {}) {
310
500
  const unreachable = res?.ok === false
311
501
  const errored = /(^|\n)\s*(Error|Unrecognized command|Incomplete command|Wrong parameter|Too many parameters)/.test(output)
312
502
  let verdict = unreachable
313
- ? { pass: false, why: `console unreachable: ${res.error || 'no answer'}` }
503
+ ? { pass: false, asserted: true, why: `console unreachable: ${res.error || 'no answer'}` }
314
504
  : errored
315
- ? { pass: false, why: 'VRP rejected the command' }
316
- : evaluateAssertion(output, check.expect)
505
+ ? { pass: false, asserted: true, why: 'VRP rejected the command' }
506
+ : evaluateAssertion(output, check.expect, check.command)
317
507
 
318
508
  // Routing protocols converge after the command returns, not before it. A single
319
509
  // snapshot taken two seconds after `ensp apply` reports "not contains Full" for a
320
510
  // session that reaches Full ten seconds later, which reads as a broken lab. Retry
321
- // the assertion itself, never a rejected command and never a dead console.
511
+ // the assertion itself, never a rejected command and never a dead console, and
512
+ // never a check that had no assertion to start with.
322
513
  let waited = 0
323
514
  let lastOutput = output
324
- while (!verdict.pass && !unreachable && !errored && waited < waitMs) {
515
+ while (verdict.pass === false && !unreachable && !errored && waited < waitMs) {
325
516
  onProgress?.({ phase: 'retry', id: check.id, device: check.on, waited, why: verdict.why })
326
517
  const nap = Math.min(intervalMs, waitMs - waited)
327
518
  await new Promise((r) => setTimeout(r, nap))
@@ -330,53 +521,67 @@ export async function runAcceptance(options = {}) {
330
521
  try {
331
522
  again = await query({ port, command: check.command, timeoutMs })
332
523
  } catch (err) {
333
- verdict = { pass: false, why: `command failed: ${err.message}` }
524
+ verdict = { pass: false, asserted: true, why: `command failed: ${err.message}` }
334
525
  break
335
526
  }
336
527
  if (again?.ok === false) {
337
- verdict = { pass: false, why: `console unreachable: ${again.error || 'no answer'}` }
528
+ verdict = { pass: false, asserted: true, why: `console unreachable: ${again.error || 'no answer'}` }
338
529
  break
339
530
  }
340
531
  lastOutput = again?.output || ''
341
- verdict = evaluateAssertion(lastOutput, check.expect)
532
+ verdict = evaluateAssertion(lastOutput, check.expect, check.command)
342
533
  }
343
534
  results.push({
344
535
  ...check,
345
536
  device: check.on,
346
537
  pass: verdict.pass,
538
+ asserted: verdict.asserted,
347
539
  why: verdict.why,
348
540
  waitedMs: waited,
349
541
  snippet: lastOutput.trim().slice(0, SNIPPET),
350
542
  })
351
543
  }
352
- const failed = results.filter((r) => !r.pass)
544
+ const failed = results.filter((r) => r.pass === false)
545
+ const unverified = results.filter((r) => r.pass === null)
353
546
  return {
354
547
  scenario: plan.scenario,
355
548
  title: plan.title,
356
549
  source: plan.source,
357
- ok: failed.length === 0,
550
+ // `ok` answers "did anything contradict the intended state". A lab that was
551
+ // never asserted against did not contradict it, but it was not verified either,
552
+ // so it reports null rather than a green light.
553
+ ok: failed.length ? false : unverified.length ? null : true,
554
+ verified: results.length - unverified.length,
555
+ unverified: unverified.length,
358
556
  total: results.length,
359
- passed: results.length - failed.length,
557
+ passed: results.length - failed.length - unverified.length,
558
+ failed: failed.length,
360
559
  results,
361
560
  }
362
561
  }
363
562
 
364
563
  /** Render an acceptance report for a terminal. */
365
564
  export function formatAcceptance(report) {
366
- const lines = [`${report.scenario} — ${report.title}`, `(${report.source} checks)`]
565
+ const lines = [`${report.scenario} · ${report.title}`, `(${report.source} checks)`]
367
566
  for (const r of report.results) {
567
+ const mark = r.pass === true ? 'pass' : r.pass === null ? 'skip' : 'FAIL'
368
568
  lines.push(
369
- `[${r.pass ? 'pass' : 'FAIL'}] ${r.id} on ${r.on}: ${r.command}` +
370
- (r.pass && r.waitedMs ? ` (converged after ${Math.round(r.waitedMs / 1000)}s)` : '') +
371
- (r.pass ? '' : `\n ${r.why}`) +
569
+ `[${mark}] ${r.id} on ${r.on}: ${r.command}` +
570
+ (r.pass === true && r.waitedMs ? ` (converged after ${Math.round(r.waitedMs / 1000)}s)` : '') +
571
+ (r.pass === true ? '' : `\n ${r.why}`) +
372
572
  (process.env.ENSP_VERBOSE && r.snippet ? `\n ${r.snippet.replace(/\n/g, '\n ')}` : ''),
373
573
  )
374
574
  }
375
575
  lines.push('')
376
- lines.push(
377
- report.ok
378
- ? `accepted: ${report.passed}/${report.total} checks passed`
379
- : `rejected: ${report.total - report.passed}/${report.total} checks failed`,
380
- )
576
+ if (report.ok === true) {
577
+ lines.push(`accepted: ${report.passed}/${report.total} checks passed`)
578
+ } else if (report.ok === false) {
579
+ lines.push(`rejected: ${report.failed}/${report.total} checks failed`)
580
+ } else {
581
+ lines.push(
582
+ `unverified: ${report.unverified}/${report.total} checks had no assertion, ` +
583
+ `${report.passed} passed. Nothing contradicted the expected state, but no output was checked either.`,
584
+ )
585
+ }
381
586
  return lines.join('\n')
382
587
  }
@@ -37,6 +37,17 @@ import { serialiseTopology } from './topology.mjs'
37
37
  // import. The authoritative definitions live in models.mjs.
38
38
  export { MODEL_SPECS, ICON, vrpToFlatIndex, flatIndexToVrp } from './models.mjs'
39
39
 
40
+ /**
41
+ * True when a VRP port name belongs to the serial family.
42
+ *
43
+ * eNSP draws a serial line as `lineName="Serial"`. Wire a Serial port with the
44
+ * default Copper media and the topology renders, the console answers, but the
45
+ * line never comes up, so `link-protocol ppp` looks broken when it is not.
46
+ */
47
+ function isSerialPort(port) {
48
+ return /^Serial/i.test(String(port ?? ''))
49
+ }
50
+
40
51
  /**
41
52
  * `settings` blob eNSP writes for a simulated PC. Empty for every other model.
42
53
  * Without it eNSP still shows the PC but with no address, which breaks the
@@ -127,7 +138,10 @@ export class TopologyBuilder {
127
138
  dstId: b.id,
128
139
  srcIndex: ai,
129
140
  dstIndex: bi,
130
- media: options.media ?? 'Copper',
141
+ // A Serial port drawn as a Copper cable makes eNSP render the link and never
142
+ // bring the line up, so the media follows the port family unless the caller
143
+ // overrides it. Serial links carry `lineName="Serial"` in the file.
144
+ media: options.media ?? (isSerialPort(aPort) || isSerialPort(bPort) ? 'Serial' : 'Copper'),
131
145
  })
132
146
  return this
133
147
  }