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.
- package/README.en.md +86 -16
- package/README.md +85 -15
- package/docs/ENSP-FORMAT.md +8 -8
- package/docs/SCENARIOS.md +332 -286
- package/docs/UI-ANCHORS.md +21 -0
- package/docs/plugin-detail.en.md +2 -0
- package/docs/plugin-detail.zh.md +2 -0
- package/package.json +2 -4
- package/scripts/gen-scenarios-doc.mjs +7 -3
- package/scripts/survey-topologies.mjs +15 -6
- package/src/cli/ensp.mjs +252 -28
- package/src/core/acceptance.mjs +258 -53
- package/src/core/build.mjs +15 -1
- package/src/core/compat.mjs +249 -17
- package/src/core/console.mjs +4 -0
- package/src/core/doctor.mjs +9 -12
- package/src/core/docx.mjs +359 -0
- package/src/core/index.mjs +28 -9
- package/src/core/labconfig.mjs +98 -0
- package/src/core/labs.mjs +12 -12
- package/src/core/launcher.mjs +96 -37
- package/src/core/models.mjs +27 -18
- package/src/core/ooxml.mjs +430 -0
- package/src/core/paths.mjs +59 -10
- package/src/core/presets.mjs +9 -5
- package/src/core/report.mjs +411 -0
- package/src/dsh/index.mjs +626 -652
- package/src/mcp/server.mjs +213 -356
- package/src/tools.mjs +271 -0
- package/screenshots.json +0 -3
- package/scripts/gen-knowledge.mjs +0 -196
- package/src/core/knowledge.json +0 -959
- package/src/core/knowledge.mjs +0 -103
package/src/core/acceptance.mjs
CHANGED
|
@@ -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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
*
|
|
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 =
|
|
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)
|
|
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
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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
|
|
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
|
|
214
|
-
*
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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:
|
|
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 (
|
|
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) =>
|
|
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
|
|
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}
|
|
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
|
-
`[${
|
|
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
|
-
|
|
377
|
-
report.
|
|
378
|
-
|
|
379
|
-
|
|
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
|
}
|
package/src/core/build.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|