Files
bee/audit/internal/webui/page_topo_test.go
T
Mikhail ChusavitinandClaude Sonnet 5 41f683de2b webui/topo: attach memory DIMMs to their CPU column in the topology diagram
Memory used to render as one unattached row below the whole diagram
regardless of which socket it belonged to. schema.HardwareMemory has no
NUMANode field (unlike PCIe devices), so CPU affinity is instead read out of
the DIMM's own Locator string: either a CPU number encoded directly in it
("CPU0_DIMM_A1"), or — when the Locator has no CPU number of its own, e.g.
"DIMM000(A)" — a node number from Bank Locator ("_Node1_Channel0_Dimm0"),
read from the persisted dmidecode-type17.txt techdump the same way the
NVLink card already reads extra techdump for visualization only.

DIMMs that can't be attached to a column via either heuristic still fall
back to the old unattached "Memory" row so nothing silently disappears.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 17:15:06 +03:00

594 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package webui
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"bee/audit/internal/schema"
)
func TestTopoPageNoAuditDataGracefulFallback(t *testing.T) {
handler := NewHandler(HandlerOptions{})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/topo", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "No audit data") {
t.Fatalf("topo page missing no-audit-data fallback: %s", body)
}
}
func TestTopoPageRendersCPUAndDegradedPCIeLink(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.json")
socket := 0
numaNode := 0
gen3, gen4 := "Gen3", "Gen4"
deviceClass := "VideoController"
model := "NVIDIA H100 80GB HBM3"
cpuModel := "Intel Xeon 6530"
okStatus := "OK"
ingest := schema.HardwareIngestRequest{
CollectedAt: "2026-03-15T00:00:00Z",
Hardware: schema.HardwareSnapshot{
CPUs: []schema.HardwareCPU{
{
HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus},
Socket: &socket,
Model: &cpuModel,
},
},
PCIeDevices: []schema.HardwarePCIeDevice{
{
DeviceClass: &deviceClass,
Model: &model,
NUMANode: &numaNode,
LinkSpeed: &gen3,
MaxLinkSpeed: &gen4,
},
},
},
}
data, err := json.Marshal(ingest)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
handler := NewHandler(HandlerOptions{AuditPath: path})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/topo", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "CPU 0") {
t.Fatalf("topo page missing CPU 0 box: %s", body)
}
if !strings.Contains(body, "GPU") {
t.Fatalf("topo page missing GPU box: %s", body)
}
if !strings.Contains(body, "var(--warn-fg)") {
t.Fatalf("topo page missing degraded-link warn edge color: %s", body)
}
}
func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.json")
okStatus := "OK"
watt := 3000
var psus []schema.HardwarePowerSupply
for i := 0; i < 6; i++ {
slot := strconv.Itoa(i)
psus = append(psus, schema.HardwarePowerSupply{
HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus},
Slot: &slot,
WattageW: &watt,
})
}
ingest := schema.HardwareIngestRequest{
CollectedAt: "2026-03-15T00:00:00Z",
Hardware: schema.HardwareSnapshot{
Firmware: []schema.HardwareFirmwareRecord{
{DeviceName: "BIOS", Version: "2.1.0"},
{DeviceName: "BMC", Version: "5.17.00"},
},
PowerSupplies: psus,
},
}
data, err := json.Marshal(ingest)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
handler := NewHandler(HandlerOptions{AuditPath: path})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/topo", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
body := rec.Body.String()
// Firmware row must render every record (BIOS + BMC, not just BMC).
if !strings.Contains(body, "BIOS") || !strings.Contains(body, "BMC") {
t.Fatalf("topo page missing BIOS/BMC firmware boxes: %s", body)
}
// All 6 PSUs must be represented, grouped into one stacked card with a
// count rather than 6 separate boxes.
if !strings.Contains(body, "Power Supplies ×6") {
t.Fatalf("topo page missing grouped Power Supplies x6 card: %s", body)
}
if strings.Count(body, `onclick="openComponentDetail(&#39;psu&#39;)"`) != 1 &&
strings.Count(body, `onclick="openComponentDetail('psu')"`) != 1 {
t.Fatalf("expected exactly one clickable PSU group card, not one per PSU: %s", body)
}
// Firmware/PSU rows must be flex-wrap HTML (arbitrary count, no overlap),
// not absolutely-positioned SVG rects sharing fixed x/y coordinates.
if !strings.Contains(body, "flex-wrap:wrap") {
t.Fatalf("topo page missing flex-wrap layout for firmware/PSU rows: %s", body)
}
// Firmware row must come before the PSU row.
if strings.Index(body, "BIOS") > strings.Index(body, "Power Supplies") {
t.Fatalf("firmware row should render before PSU row: %s", body)
}
}
func TestTopoPageLinkedFromNav(t *testing.T) {
handler := NewHandler(HandlerOptions{})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
if !strings.Contains(rec.Body.String(), `href="/topo"`) {
t.Fatalf("nav missing /topo link")
}
}
func TestParseGPUPairAdjacencyRealTwoGPUDump(t *testing.T) {
// Real system/nvidia-smi-topo.txt from a support bundle for this exact
// server: two H100s directly bridged (NV17), spanning two NUMA nodes.
input := "\tGPU0\tGPU1\tNIC0\tNIC1\tCPU Affinity\tNUMA Affinity\tGPU NUMA ID\n" +
"GPU0\t X \tNV17\tSYS\tSYS\t0-23,48-71\t0\t\tN/A\n" +
"GPU1\tNV17\t X \tNODE\tNODE\t24-47,72-95\t1\t\tN/A\n" +
"NIC0\tSYS\tNODE\t X \tPIX\t\t\t\n" +
"NIC1\tSYS\tNODE\tPIX\t X \t\t\t\n"
pairs := parseGPUPairAdjacency(input)
if len(pairs) != 1 {
t.Fatalf("pairs=%d want 1 (%#v)", len(pairs), pairs)
}
if pairs[0].GPUA != 0 || pairs[0].GPUB != 1 || pairs[0].NVLinks != 17 {
t.Fatalf("pair=%#v want {0,1,17}", pairs[0])
}
}
func TestParseGPUPairAdjacencyDoesNotChainUnrelatedGPUs(t *testing.T) {
// 4 GPUs: only (0,1) and (2,3) are actually bonded. GPU1 and GPU2 must
// NOT get an edge just because they're adjacent in the layout.
input := "\tGPU0\tGPU1\tGPU2\tGPU3\n" +
"GPU0\t X \tNV18\tSYS\tSYS\n" +
"GPU1\tNV18\t X \tSYS\tSYS\n" +
"GPU2\tSYS\tSYS\t X \tNV18\n" +
"GPU3\tSYS\tSYS\tNV18\t X \n"
pairs := parseGPUPairAdjacency(input)
if len(pairs) != 2 {
t.Fatalf("pairs=%d want 2 (%#v)", len(pairs), pairs)
}
want := map[[2]int]bool{{0, 1}: true, {2, 3}: true}
for _, p := range pairs {
if !want[[2]int{p.GPUA, p.GPUB}] {
t.Fatalf("unexpected pair %#v (GPU1-GPU2 chaining bug?)", p)
}
}
}
func TestParseGPUPairAdjacencyANSIUnderlinedHeader(t *testing.T) {
// Real techdump capture: nvidia-smi underlines the header row with ANSI
// escapes even when writing to a file, so the header line starts with
// ESC[4m, not "GPU0".
input := "\x1b[4m\tGPU0\tGPU1\tGPU2\tGPU3\tNIC0\tNIC1\tCPU Affinity\x1b[0m\n" +
"GPU0\t X \tNV18\tPIX\tPIX\tNODE\tNODE\t0-31,64-95\t0\n" +
"GPU1\tNV18\t X \tPIX\tPIX\tNODE\tNODE\t0-31,64-95\t0\n" +
"GPU2\tPIX\tPIX\t X \tNV18\tNODE\tNODE\t0-31,64-95\t0\n" +
"GPU3\tPIX\tPIX\tNV18\t X \tNODE\tNODE\t0-31,64-95\t0\n" +
"NIC0\tNODE\tNODE\tNODE\tNODE\t X \tPIX\n" +
"NIC1\tNODE\tNODE\tNODE\tNODE\tPIX\t X \n"
pairs := parseGPUPairAdjacency(input)
if len(pairs) != 2 {
t.Fatalf("pairs=%d want 2 (%#v)", len(pairs), pairs)
}
want := map[[2]int]bool{{0, 1}: true, {2, 3}: true}
for _, p := range pairs {
if !want[[2]int{p.GPUA, p.GPUB}] || p.NVLinks != 18 {
t.Fatalf("unexpected pair %#v", p)
}
}
}
func TestParseGPUPairAdjacencyEmptyOnNoMatrix(t *testing.T) {
if pairs := parseGPUPairAdjacency("no gpus here"); pairs != nil {
t.Fatalf("pairs=%v want nil", pairs)
}
}
func TestPcieGenRank(t *testing.T) {
if pcieGenRank("Gen5") <= pcieGenRank("Gen4") {
t.Fatalf("Gen5 should rank higher than Gen4")
}
if pcieGenRank("bogus") != 0 {
t.Fatalf("unparseable gen should rank 0")
}
}
func TestTopoEdgeColorVar(t *testing.T) {
gen3, gen4 := "Gen3", "Gen4"
degraded := schema.HardwarePCIeDevice{LinkSpeed: &gen3, MaxLinkSpeed: &gen4}
if got := topoEdgeColorVar(degraded); got != "var(--warn-fg)" {
t.Fatalf("degraded color=%q want warn", got)
}
full := schema.HardwarePCIeDevice{LinkSpeed: &gen4, MaxLinkSpeed: &gen4}
if got := topoEdgeColorVar(full); got != "var(--ok-fg)" {
t.Fatalf("full-speed color=%q want ok", got)
}
unknown := schema.HardwarePCIeDevice{}
if got := topoEdgeColorVar(unknown); got != "var(--muted)" {
t.Fatalf("unknown color=%q want muted", got)
}
}
func TestBuildSocketIndex(t *testing.T) {
s0, s1 := 0, 1
cpus := []schema.HardwareCPU{{Socket: &s1}, {Socket: &s0}}
idx := buildSocketIndex(cpus)
if idx[0] != 1 || idx[1] != 0 {
t.Fatalf("idx=%#v want {0:1, 1:0}", idx)
}
}
func TestBuildSocketIndexOneIndexedSocketDesignation(t *testing.T) {
// dmidecode "Socket Designation" is frequently 1-indexed ("CPU1", "CPU2")
// while Linux NUMA nodes are always 0-indexed. NUMA node 0 must still
// resolve to the first CPU in Socket order, not fall through to the
// "unknown" column (the bug reported against the /topo page).
s1, s2 := 1, 2
cpus := []schema.HardwareCPU{{Socket: &s1}, {Socket: &s2}}
idx := buildSocketIndex(cpus)
if idx[0] != 0 || idx[1] != 1 {
t.Fatalf("idx=%#v want {0:0, 1:1}", idx)
}
}
func TestIsRAIDControllerClass(t *testing.T) {
if !isRAIDControllerClass("StorageController") || !isRAIDControllerClass("MassStorageController") {
t.Fatalf("expected known RAID/storage classes to match")
}
if isRAIDControllerClass("VideoController") {
t.Fatalf("GPU class should not match RAID classifier")
}
}
func TestIsNICDeviceClassDev(t *testing.T) {
class := "EthernetController"
nic := schema.HardwarePCIeDevice{DeviceClass: &class}
if !isNICDeviceClassDev(nic) {
t.Fatalf("expected EthernetController to classify as NIC")
}
withMac := schema.HardwarePCIeDevice{MacAddresses: []string{"aa:bb:cc:dd:ee:ff"}}
if !isNICDeviceClassDev(withMac) {
t.Fatalf("expected device with MAC address to classify as NIC")
}
other := schema.HardwarePCIeDevice{}
if isNICDeviceClassDev(other) {
t.Fatalf("expected empty device to not classify as NIC")
}
}
func TestClassifyTopoSeverityNilIsUnknown(t *testing.T) {
if sev := classifyTopoSeverity(nil); sev != 0 {
t.Fatalf("nil status severity=%d want 0 (unknown)", sev)
}
ok := "OK"
if sev := classifyTopoSeverity(&ok); sev != 1 {
t.Fatalf("OK status severity=%d want 1", sev)
}
warn := "Warning"
if sev := classifyTopoSeverity(&warn); sev != 2 {
t.Fatalf("Warning status severity=%d want 2", sev)
}
crit := "Critical"
if sev := classifyTopoSeverity(&crit); sev != 3 {
t.Fatalf("Critical status severity=%d want 3", sev)
}
}
func TestTopoStatusTallyLine(t *testing.T) {
var t1 topoStatusTally
t1.add(1)
if got := t1.line(); got != "OK" {
t.Fatalf("single-OK line=%q want %q", got, "OK")
}
var t2 topoStatusTally
t2.add(1)
t2.add(1)
t2.add(1)
t2.add(2)
if got := t2.line(); got != "1 Warning, 3 OK" {
t.Fatalf("mixed line=%q want %q", got, "1 Warning, 3 OK")
}
}
func TestTopoMainDiagramGroupsSameKindSameColumnIntoOneStackedCard(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.json")
socket := 0
numaNode := 0
deviceClass := "VideoController"
model := "NVIDIA H100 80GB HBM3"
okStatus := "OK"
var gpus []schema.HardwarePCIeDevice
for i := 0; i < 4; i++ {
gpus = append(gpus, schema.HardwarePCIeDevice{
HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus},
DeviceClass: &deviceClass,
Model: &model,
NUMANode: &numaNode,
})
}
ingest := schema.HardwareIngestRequest{
CollectedAt: "2026-03-15T00:00:00Z",
Hardware: schema.HardwareSnapshot{
CPUs: []schema.HardwareCPU{
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket},
},
PCIeDevices: gpus,
},
}
data, err := json.Marshal(ingest)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
handler := NewHandler(HandlerOptions{AuditPath: path})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/topo", nil))
body := rec.Body.String()
if !strings.Contains(body, "GPU ×4") {
t.Fatalf("expected one grouped GPU x4 card, got: %s", body)
}
if strings.Count(body, "openComponentDetail('gpu')") != 1 {
t.Fatalf("expected exactly one clickable GPU card, not one per GPU: %s", body)
}
if !strings.Contains(body, "4 OK") {
t.Fatalf("expected group status line '4 OK': %s", body)
}
}
func TestDimmRawNodeFromCPULocatorPrefix(t *testing.T) {
slot := "CPU1_DIMM_A1"
mem := schema.HardwareMemory{Slot: &slot}
node, ok := dimmRawNode(mem, nil)
if !ok || node != 1 {
t.Fatalf("dimmRawNode() = (%d, %v), want (1, true)", node, ok)
}
}
func TestDimmRawNodeFromBankLocatorFallback(t *testing.T) {
// Boards whose Locator carries no CPU number of its own (e.g.
// "DIMM000(A)") still need to be attached to a column via the node
// number in Bank Locator, read separately from techdump.
slot := "DIMM100(A)"
mem := schema.HardwareMemory{Slot: &slot}
bankNodes := map[string]int{"DIMM100(A)": 2}
node, ok := dimmRawNode(mem, bankNodes)
if !ok || node != 2 {
t.Fatalf("dimmRawNode() = (%d, %v), want (2, true)", node, ok)
}
}
func TestDimmRawNodeUnmatched(t *testing.T) {
slot := "SOMETHING_UNRECOGNIZED"
mem := schema.HardwareMemory{Slot: &slot}
if _, ok := dimmRawNode(mem, nil); ok {
t.Fatalf("expected no match for an unrecognized locator")
}
if _, ok := dimmRawNode(schema.HardwareMemory{}, nil); ok {
t.Fatalf("expected no match when Slot is nil")
}
}
func TestParseDIMMBankLocatorNodes(t *testing.T) {
// Real dmidecode -t 17 shape: "Locator" precedes "Bank Locator" within
// each "Memory Device" section.
raw := `Handle 0x0017, DMI type 17, 92 bytes
Memory Device
Size: 64 GB
Locator: DIMM000(A)
Bank Locator: _Node1_Channel0_Dimm0
Type: DDR5
Handle 0x0018, DMI type 17, 92 bytes
Memory Device
Size: No Module Installed
Locator: DIMM001(I)
Bank Locator: _Node1_Channel0_Dimm1
Handle 0x0019, DMI type 17, 92 bytes
Memory Device
Size: 64 GB
Locator: DIMM100(A)
Bank Locator: _Node2_Channel0_Dimm0
`
got := parseDIMMBankLocatorNodes(raw)
want := map[string]int{"DIMM000(A)": 1, "DIMM001(I)": 1, "DIMM100(A)": 2}
if len(got) != len(want) {
t.Fatalf("got=%#v want=%#v", got, want)
}
for k, v := range want {
if got[k] != v {
t.Fatalf("got[%q]=%d want %d (full: %#v)", k, got[k], v, got)
}
}
}
func TestBuildMemoryColumnIndex(t *testing.T) {
// Node numbers observed on real boards are not guaranteed 0-based (Bank
// Locator "NodeN" has been seen starting at 1) — this must rank by
// order, not treat the raw value as a column index.
idx := buildMemoryColumnIndex([]int{1, 2, 1, 2})
if idx[1] != 0 || idx[2] != 1 {
t.Fatalf("idx=%#v want {1:0, 2:1}", idx)
}
}
// TestTopoMainDiagramAttachesMemoryToItsCPUColumn is the regression test for
// the actual feature request: memory used to render as one unattached
// "MEMORY" row below the whole diagram regardless of which CPU it belonged
// to. DIMMs whose Locator carries a CPU number must now render as their own
// box directly under that CPU, wired to it with an edge, the same as
// GPU/NIC/RAID — and must NOT also show up in the leftover flex row.
func TestTopoMainDiagramAttachesMemoryToItsCPUColumn(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.json")
socket0, socket1 := 0, 1
okStatus := "OK"
slot0, slot1 := "CPU0_DIMM_A1", "CPU1_DIMM_A1"
sizeMB := 98304
ingest := schema.HardwareIngestRequest{
CollectedAt: "2026-03-15T00:00:00Z",
Hardware: schema.HardwareSnapshot{
CPUs: []schema.HardwareCPU{
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket0},
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket1},
},
Memory: []schema.HardwareMemory{
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Slot: &slot0, SizeMB: &sizeMB},
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Slot: &slot1, SizeMB: &sizeMB},
},
},
}
data, err := json.Marshal(ingest)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
handler := NewHandler(HandlerOptions{AuditPath: path})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/topo", nil))
body := rec.Body.String()
if strings.Count(body, `>Memory<`) != 2 {
t.Fatalf("expected 2 attached Memory boxes (one per CPU column), got body: %s", body)
}
if strings.Contains(body, "Memory</div>") {
t.Fatalf("both DIMMs matched a CPU column — the leftover unattached Memory row must not render: %s", body)
}
if strings.Count(body, "openComponentDetail('memory')") != 2 {
t.Fatalf("expected 2 clickable Memory boxes, got body: %s", body)
}
}
// TestTopoMainDiagramUnattachableMemoryFallsBackToFlexRow guards that a DIMM
// whose Locator can't be parsed into a CPU/node number still shows up
// somewhere (the old unattached row) instead of silently disappearing.
func TestTopoMainDiagramUnattachableMemoryFallsBackToFlexRow(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.json")
socket0 := 0
okStatus := "OK"
slot := "SOMETHING_UNRECOGNIZED"
sizeMB := 32768
ingest := schema.HardwareIngestRequest{
CollectedAt: "2026-03-15T00:00:00Z",
Hardware: schema.HardwareSnapshot{
CPUs: []schema.HardwareCPU{
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Socket: &socket0},
},
Memory: []schema.HardwareMemory{
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, Slot: &slot, SizeMB: &sizeMB},
},
},
}
data, err := json.Marshal(ingest)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
handler := NewHandler(HandlerOptions{AuditPath: path})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/topo", nil))
body := rec.Body.String()
if !strings.Contains(body, "Memory</div>") {
t.Fatalf("unattachable DIMM should still appear in the leftover flex row: %s", body)
}
}
func TestParseTopoNVLinkStatus(t *testing.T) {
input := `GPU 0: NVIDIA H100 80GB HBM3 (UUID: GPU-a59f6931-c099-8fba-a0b3-08469d86f140)
Link 0: 26.562 GB/s
Link 15: <inactive>
GPU 1: NVIDIA H100 80GB HBM3 (UUID: GPU-603fe750-0516-9db5-86ec-ea61af3fce35)
Link 0: 26.562 GB/s
`
got := parseTopoNVLinkStatus(input)
if len(got[0]) != 2 || got[0][1].Active {
t.Fatalf("gpu0=%#v want link15 inactive", got[0])
}
if len(got[1]) != 1 || got[1][0].SpeedGBs == nil || *got[1][0].SpeedGBs != 26.562 {
t.Fatalf("gpu1=%#v want link0 26.562 GB/s", got[1])
}
}
func TestParseTopoNVLinkErrors(t *testing.T) {
input := `GPU 0: NVIDIA H100 80GB HBM3 (UUID: GPU-a59f6931-c099-8fba-a0b3-08469d86f140)
Link 0: Replay Errors: 0
Link 0: Recovery Errors: 0
Link 0: CRC Errors: 0
Link 1: Replay Errors: 3
Link 1: Recovery Errors: 1
Link 1: CRC Errors: 2
`
got := parseTopoNVLinkErrors(input)
c := got[0][1]
if c[0] != 3 || c[1] != 1 || c[2] != 2 {
t.Fatalf("link1 counters=%#v want {3,1,2}", c)
}
}