diff --git a/CHANGELOG.md b/CHANGELOG.md index 0584b73..3507a9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.3.0 — 2026-11-05 + +### Added + +- **Faults from missing lines**: 10 new built-in faults fire when a line that + good boards print is gone: eMMC/SD card, SATA, NVMe, Ethernet driver and + link, USB device, PCIe link, RTC, SPI flash, CAN. A line that only changed a + value does not count: the DUT log must have fewer matching lines than the + golden log. +- `boot-incomplete`: the DUT log stops before the end of the golden log. The + lines it never got to are reported once, not as one fault per driver. +- `sata-link`: COMRESET failures, link down, reduced link speed. +- Rule files: `match = "missing"` or `"unreached"` (default `"new"`), and + `unless = [ids]` to silence a `missing` rule that another fault explains. +- Python API: `Rule.match`, `Rule.unless`, `Fault.log`. +- JSON output: faults have `log` (`dut` or `golden`), the log their line + numbers refer to. + +### Changed + +- Reports name the log of a fault's lines (`golden line 11`, `dut line 62`). + ## 0.2.0 — 2026-10-29 ### Added diff --git a/README.md b/README.md index a833d08..0b360b3 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ every kernel timestamp differs. boot-log-diff: board model, U-Boot and kernel version, plus your own - **flags error-looking lines** — new lines with *error*, *fail*, *timeout*, *unable*, *not found* and the like are marked with `!` -- **diagnoses known faults** — 23 built-in patterns for DRAM, eMMC/SD, NAND, +- **diagnoses known faults** — 35 built-in patterns for DRAM, eMMC/SD, NAND, SPI flash, Ethernet PHY, USB, I2C, PCIe, supplies, temperature, file system and kernel crashes, each with what to check on the board; add your own - **fits into CI and test scripts** — exit status 0/1/2, JSON, JUnit XML and @@ -168,6 +168,15 @@ memory ECC errors, missing firmware and CPU stalls. `boot-log-diff --help` lists their ids; the definitions are in [`faults.toml`](src/boot_log_diff/faults.toml). +Some faults print nothing at all: a driver that no longer loads just leaves a +gap. So there are also faults for lines that are *gone* (`match = "missing"`): +eMMC/SD card, SATA, NVMe, Ethernet driver and link, USB device, PCIe link, RTC, +SPI flash and CAN. They fire only if the DUT log has fewer matching lines than +the golden log, so a link at a lower speed is not a missing link. If a more +specific fault was found (`unless`), the missing line does not get a second +diagnosis. And if the log stops early, the lines it never got to are reported +once as `boot-incomplete` (`match = "unreached"`), not as one fault per driver. + Add what you know about your own boards in a TOML file: ```toml @@ -185,6 +194,14 @@ title = "Ethernet PHY not found" pattern = 'unable to connect to phy' diagnosis = "On rev C boards: R212 (PHY reset pull-up) is often cracked." severity = "warning" + +[[fault]] +id = "fpga-driver-missing" +title = "FPGA driver no longer loads" +match = "missing" # a golden line that is gone from the DUT log +pattern = 'fpga_manager fpga\d+: .* registered' +unless = ["fpga-config"] # that fault already explains it +diagnosis = "Compare the device tree and check that the FPGA module is in the image." ``` ```console @@ -247,8 +264,8 @@ from `load_rules(path)` or `parse_rules(text)`; pass them as - The error flag and the known faults are pattern matches, not an understanding of the message. A diagnosis says where to look first; it is not a verdict. -- Known faults only look at new lines. A fault that shows as a *missing* - line (a driver that no longer probes) is in the diff, without a diagnosis. +- A missing-line fault needs a rule that knows the line. Other missing lines + are in the diff, without a diagnosis. - One golden log per comparison. If healthy boards differ among themselves (other eMMC vendor, optional module), mask or ignore that line. diff --git a/pyproject.toml b/pyproject.toml index 71167d5..830076c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "boot-log-diff" -version = "0.2.0" +version = "0.3.0" description = "Compare the serial boot log of an embedded board with a golden log, ignoring boot-to-boot noise, and diagnose known faults" readme = "README.md" license = "MIT" diff --git a/src/boot_log_diff/__init__.py b/src/boot_log_diff/__init__.py index b863977..6561210 100644 --- a/src/boot_log_diff/__init__.py +++ b/src/boot_log_diff/__init__.py @@ -4,7 +4,7 @@ from .faults import BUILTIN_RULES, Fault, Rule, load_rules, parse_rules from .normalize import BUILTIN_MASKS, Mask, Normalizer -__version__ = "0.2.0" +__version__ = "0.3.0" __all__ = [ "BUILTIN_FACTS", diff --git a/src/boot_log_diff/compare.py b/src/boot_log_diff/compare.py index e4b6b96..138e683 100644 --- a/src/boot_log_diff/compare.py +++ b/src/boot_log_diff/compare.py @@ -6,9 +6,9 @@ from collections import Counter from collections.abc import Iterable from dataclasses import dataclass, field -from difflib import SequenceMatcher +from difflib import SequenceMatcher, get_close_matches -from .faults import BUILTIN_RULES, Fault, Rule, find_faults, unique_rules +from .faults import BUILTIN_RULES, Fault, Rule, find_faults, find_missing, find_unreached, unique_rules from .normalize import Normalizer, clean # Words that make a new line worth a second look. @@ -146,6 +146,18 @@ def _take_moved(hunks: list[Hunk]) -> list[tuple[Line, Line]]: return moved +def _unreached(tail: Hunk | None) -> list[Line]: + """The golden lines at the end of the log that the DUT never printed. + + Empty if the DUT log merely has another last line, such as a host name + that differs from the golden board's. + """ + if tail is None: + return [] + added = [line.normalized for line in tail.added] + return [line for line in tail.removed if not get_close_matches(line.normalized, added, n=1, cutoff=0.6)] + + def compare( golden: str, dut: str, @@ -161,7 +173,11 @@ def compare( With `strict_order` they show up as missing and new lines instead. New DUT lines are matched against `rules` (known faults); a line counts - for the first rule that matches. Pass `rules=()` to skip that. + for the first rule that matches. Golden lines gone from the DUT log are + matched against the rules with `match = "missing"`. If the DUT log stopped + early, the lines it never got to go to the `match = "unreached"` rules + instead: they say nothing about the drivers that print them. + Pass `rules=()` to skip all of that. """ normalizer = normalizer or Normalizer() facts = list(facts) @@ -187,7 +203,14 @@ def compare( for tag, i1, i2, j1, j2 in matcher.get_opcodes() if tag != "equal" ] + # A last hunk that reaches the end of the golden log: the DUT may have stopped there. + tail = hunks[-1] if hunks and hunks[-1].removed and matcher.get_opcodes()[-1][0] != "equal" else None moved = [] if strict_order else _take_moved(hunks) rules = unique_rules(rules) faults = find_faults((line for hunk in hunks for line in hunk.added), rules) + unreached = _unreached(tail) + skip = {id(line) for line in tail.removed} if unreached else set() + removed = (line for hunk in hunks for line in hunk.removed if id(line) not in skip) + faults += find_missing(removed, golden_lines, dut_lines, rules, (fault.rule.id for fault in faults)) + faults += find_unreached(unreached, rules) return Result(fact_diffs, hunks, moved, faults, [fact.name for fact in facts], rules) diff --git a/src/boot_log_diff/faults.py b/src/boot_log_diff/faults.py index 27ee136..b7e6367 100644 --- a/src/boot_log_diff/faults.py +++ b/src/boot_log_diff/faults.py @@ -18,18 +18,27 @@ from .compare import Line SEVERITIES = ("error", "warning") -_FIELDS = {"id", "title", "pattern", "diagnosis", "severity"} +MATCHES = ("new", "missing", "unreached") +_FIELDS = {"id", "title", "pattern", "diagnosis", "severity", "match", "unless"} @dataclass(frozen=True) class Rule: - """A known fault: lines matching `pattern` get `title` and `diagnosis`.""" + """A known fault: lines matching `pattern` get `title` and `diagnosis`. + + `match` says which lines the pattern is applied to: "new" lines of the DUT + log, golden lines "missing" from the DUT log, or golden lines "unreached" + because the DUT log stopped early. A "missing" rule stays silent if a fault + listed in `unless` was found, because that fault already explains the line. + """ id: str title: str pattern: re.Pattern[str] diagnosis: str severity: str = "error" + match: str = "new" + unless: tuple[str, ...] = () @dataclass @@ -39,6 +48,11 @@ class Fault: rule: Rule lines: list[Line] = field(default_factory=list) + @property + def log(self) -> str: + """The log the line numbers refer to: "dut" or "golden".""" + return "dut" if self.rule.match == "new" else "golden" + def parse_rules(text: str, source: str = "") -> list[Rule]: """Read rules from TOML text with one [[fault]] table per rule.""" @@ -61,12 +75,28 @@ def parse_rules(text: str, source: str = "") -> list[Rule]: severity = entry.get("severity", "error") if severity not in SEVERITIES: raise ValueError(f"{where} ({entry['id']}): severity must be error or warning") + match = entry.get("match", "new") + if match not in MATCHES: + raise ValueError(f"{where} ({entry['id']}): match must be new, missing or unreached") + unless = entry.get("unless", []) + if not isinstance(unless, list) or not all(isinstance(item, str) for item in unless): + raise ValueError(f"{where} ({entry['id']}): unless must be a list of fault ids") + if unless and match != "missing": + raise ValueError(f"{where} ({entry['id']}): unless only works with match = \"missing\"") try: pattern = re.compile(entry["pattern"], re.IGNORECASE) except re.error as exc: raise ValueError(f"{where} ({entry['id']}): pattern: {exc}") from None rules.append( - Rule(entry["id"], entry["title"], pattern, " ".join(entry["diagnosis"].split()), severity) + Rule( + entry["id"], + entry["title"], + pattern, + " ".join(entry["diagnosis"].split()), + severity, + match, + tuple(unless), + ) ) return rules @@ -95,13 +125,13 @@ def unique_rules(rules: Iterable[Rule]) -> list[Rule]: def find_faults(lines: Iterable[Line], rules: Iterable[Rule]) -> list[Fault]: - """Match each line against the rules; a line counts for the first rule only. + """Match each new DUT line against the "new" rules; a line counts for the first only. Of several rules with the same id only the first is used, so own rules placed before the built-in ones replace them. Faults are returned in the order they first show up in the log. """ - rules = unique_rules(rules) + rules = [rule for rule in unique_rules(rules) if rule.match == "new"] faults = {rule.id: Fault(rule) for rule in rules} for line in lines: for rule in rules: @@ -109,3 +139,50 @@ def find_faults(lines: Iterable[Line], rules: Iterable[Rule]) -> list[Fault]: faults[rule.id].lines.append(line) break return sorted((fault for fault in faults.values() if fault.lines), key=lambda f: f.lines[0].number) + + +def _count(lines: list[Line], rule: Rule) -> int: + return sum(1 for line in lines if rule.pattern.search(line.text)) + + +def find_missing( + removed: Iterable[Line], + golden: Iterable[Line], + dut: Iterable[Line], + rules: Iterable[Rule], + found: Iterable[str] = (), +) -> list[Fault]: + """Match golden lines gone from the DUT log against the "missing" rules. + + A rule fires only if the DUT log has fewer matching lines than the golden + log. A line that merely changed a value (a link at a lower speed) still + matches, so it does not count as a device that went away. Rules are checked + in order and see the faults of earlier rules through `unless`. + """ + removed, golden, dut = list(removed), list(golden), list(dut) + found = set(found) + taken: set[int] = set() + faults = [] + for rule in unique_rules(rules): + if rule.match != "missing" or found.intersection(rule.unless): + continue + if _count(golden, rule) <= _count(dut, rule): + continue + lines = [line for line in removed if id(line) not in taken and rule.pattern.search(line.text)] + if lines: + taken.update(id(line) for line in lines) + faults.append(Fault(rule, lines)) + found.add(rule.id) + return faults + + +def find_unreached(lines: Iterable[Line], rules: Iterable[Rule]) -> list[Fault]: + """Match golden lines the DUT log never got to; each rule keeps the first one.""" + lines = list(lines) + faults = [] + for rule in unique_rules(rules): + if rule.match == "unreached": + first = next((line for line in lines if rule.pattern.search(line.text)), None) + if first is not None: + faults.append(Fault(rule, [first])) + return faults diff --git a/src/boot_log_diff/faults.toml b/src/boot_log_diff/faults.toml index 816e309..6dd9d91 100644 --- a/src/boot_log_diff/faults.toml +++ b/src/boot_log_diff/faults.toml @@ -5,8 +5,15 @@ # in the golden log is not a fault of this board. # # Fields: id, title, pattern, diagnosis, severity ("error" or "warning", -# default "error"). A line counts for the first rule that matches, so more -# specific rules come first. +# default "error"), match, unless. A line counts for the first rule that +# matches, so more specific rules come first. +# +# match = "new" (default) looks at new DUT lines. match = "missing" looks at +# golden lines gone from the DUT log and fires only if the DUT log has fewer +# lines matching the pattern than the golden log. unless = [ids] silences a +# "missing" rule when one of these faults was found: it is the better +# explanation. match = "unreached" looks at golden lines the DUT log never got +# to because it stopped early; "missing" rules do not see those lines. [[fault]] id = "ddr-training" @@ -220,3 +227,139 @@ diagnosis = """ A CPU did not respond for seconds. A driver waiting for hardware that never answers (a missing or hung device on a bus) or a software deadlock. The lines before the stall usually name the device.""" + +[[fault]] +id = "sata-link" +title = "SATA link problems" +pattern = 'ata\d+(?:\.\d+)?: (?:COMRESET failed|softreset failed|SATA link down|limiting SATA link speed|failed command)' +diagnosis = """ +The SATA host and the drive do not get or keep a stable link. Check the drive +and its seating first (M.2 latch, cable), then the drive supply. A link that +falls back to a lower speed usually means a marginal signal path, a bad +cable or a failing drive. Swap the drive to tell board and drive apart.""" + +# Lines that healthy boards print and this one does not. + +[[fault]] +id = "mmc-card-missing" +title = "eMMC/SD card no longer detected" +match = "missing" +pattern = 'mmc\d+: new .*card at address' +unless = ["mmc-timeout", "mmc-crc"] +diagnosis = """ +The controller came up but never found the card, and did not report an error +either. For an SD slot: no card, or a broken card-detect. For eMMC: check +VCC/VCCQ, the CLK/CMD lines and the solder joints under the eMMC.""" + +[[fault]] +id = "sata-link-missing" +title = "SATA drive no longer detected" +match = "missing" +pattern = 'ata\d+(?:\.\d+)?: SATA link up' +unless = ["sata-link"] +diagnosis = """ +No SATA link came up on this port. Drive missing or not seated, no drive +supply, or a damaged connector. If the whole ata lines are gone, the driver +did not load: then compare the kernel configuration and the BIOS SATA mode.""" + +[[fault]] +id = "nvme-missing" +title = "NVMe drive no longer detected" +match = "missing" +pattern = 'nvme nvme\d+: (?:\d+/\d+/\d+ default/read/poll queues|pci function)' +unless = ["pcie-link"] +diagnosis = """ +The NVMe drive did not show up. Check that it is seated in the M.2 slot, its +3.3 V supply and PERST#. If other PCIe devices are gone too, look at the PCIe +reference clock and the root port.""" + +[[fault]] +id = "eth-driver-missing" +title = "Ethernet driver or PHY no longer reports" +match = "missing" +pattern = 'attached PHY driver|eth\d+: registered PHC' +unless = ["eth-phy"] +diagnosis = """ +The Ethernet interface did not come up the way it does on a good board, and +no error says why. On a board with the same image this points to the MAC or +PHY not being detected: check the PHY supply, reset and reference clock. A +different image or device tree can cause it too, so compare the kernel fact.""" + +[[fault]] +id = "eth-link-missing" +title = "Ethernet link no longer comes up" +match = "missing" +severity = "warning" +pattern = '\b(?:eth|en[a-z]*)\d\S*:? (?:NIC )?link is up' +unless = ["eth-phy", "eth-no-link", "eth-driver-missing"] +diagnosis = """ +No link was reported before the log ended. Check the cable and the test +fixture first. If those are good: magnetics, the connector, and the PHY +supply. The link can also simply come up later than the capture stopped.""" + +[[fault]] +id = "usb-device-missing" +title = "USB device no longer detected" +match = "missing" +pattern = 'usb \d+-[\d.]+: new .*USB device number' +unless = ["usb-enumeration", "usb-overcurrent"] +diagnosis = """ +Fewer USB devices enumerated than on a good board, without an error. Nothing +was seen on the port: check VBUS on the port, the connector, D+/D- and the +device itself. For an on-board hub, check its supply and reset.""" + +[[fault]] +id = "pcie-link-missing" +title = "PCIe link no longer reported" +match = "missing" +pattern = 'pcie.*\blink up\b' +unless = ["pcie-link"] +diagnosis = """ +The PCIe link was not reported as up. Check the card or M.2 module and its +seating, the reference clock, the endpoint supply and PERST#.""" + +[[fault]] +id = "rtc-missing" +title = "RTC no longer detected" +match = "missing" +pattern = '\brtc\S*\s.*registered as rtc\d' +unless = ["i2c-nack"] +diagnosis = """ +The real-time clock did not register. Check its supply and backup battery, the +crystal and the I2C or SPI lines to the chip. Without it the board starts with +a wrong date, which can break certificates and log timestamps.""" + +[[fault]] +id = "spi-flash-missing" +title = "SPI flash no longer detected" +match = "missing" +pattern = 'spi-nor spi\d+\.\d+: .*\(\d+ kbytes\)' +unless = ["spi-flash"] +diagnosis = """ +The SPI NOR flash was not detected, and no error says why. Check the flash +supply, chip select and clock, and the solder joints of the chip.""" + +[[fault]] +id = "can-missing" +title = "CAN interface no longer detected" +match = "missing" +pattern = 'registering netdev can\d|\bcan\d.*successfully initialized' +unless = ["i2c-nack"] +diagnosis = """ +The CAN interface did not register. For an external controller on SPI: check +its supply, oscillator, chip select and interrupt line. For a controller +inside the SoC: compare the device tree of this image with the golden one.""" + +# The log itself. + +[[fault]] +id = "boot-incomplete" +title = "Boot log stops early" +match = "unreached" +pattern = '\S' +diagnosis = """ +The log ends before the point a good board reaches; the line shown is the +first one this board did not print. Look at the last lines of the DUT log: +that is where it hung, reset or lost power. Lines after this point are not +reported one by one, because they say nothing about their drivers. Also make +sure the capture did not simply stop too early.""" diff --git a/src/boot_log_diff/report.py b/src/boot_log_diff/report.py index 97691c5..2269dde 100644 --- a/src/boot_log_diff/report.py +++ b/src/boot_log_diff/report.py @@ -38,7 +38,7 @@ def paint(code: str, text: str) -> str: out += ["", "Known faults:"] for fault in result.faults: label = paint(_BOLD_RED if fault.rule.severity == "error" else _YELLOW, f"[{fault.rule.severity}]") - out.append(f" {label} {fault.rule.title} ({fault.rule.id}), {_dut_lines(fault)}") + out.append(f" {label} {fault.rule.title} ({fault.rule.id}), {_fault_lines(fault)}") out += textwrap.wrap(fault.rule.diagnosis, 76, initial_indent=" ", subsequent_indent=" ") for hunk in result.hunks: @@ -65,9 +65,9 @@ def paint(code: str, text: str) -> str: return "\n".join(out) + "\n" -def _dut_lines(fault: Fault) -> str: +def _fault_lines(fault: Fault) -> str: numbers = [str(line.number) for line in fault.lines] - return ("dut line " if len(numbers) == 1 else "dut lines ") + ", ".join(numbers) + return f"{fault.log} line{'' if len(numbers) == 1 else 's'} " + ", ".join(numbers) def _line(line: Line) -> dict[str, object]: @@ -87,6 +87,7 @@ def render_json(result: Result, golden_name: str, dut_name: str) -> str: "title": fault.rule.title, "severity": fault.rule.severity, "diagnosis": fault.rule.diagnosis, + "log": fault.log, "lines": [_line(line) for line in fault.lines], } for fault in result.faults @@ -159,7 +160,7 @@ def case(classname: str, name: str, message: str | None = None, kind: str = "", if fault is None: case("faults", rule.id) else: - lines = "\n".join(f"dut line {line.number}: {line.text}" for line in fault.lines) + lines = "\n".join(f"{fault.log} line {line.number}: {line.text}" for line in fault.lines) case("faults", rule.id, rule.title, rule.severity, f"{rule.diagnosis}\n\n{lines}") suite.set("tests", str(len(suite.findall("testcase")))) @@ -241,7 +242,7 @@ def render_html(result: Result, golden_name: str, dut_name: str, version: str = out.append("

Known faults

") for fault in result.faults: rule = fault.rule - lines = "".join(f"
{line.number}: {e(line.text)}
" for line in fault.lines) + lines = "".join(f"
{fault.log} line {line.number}: {e(line.text)}
" for line in fault.lines) out.append( f'

{e(rule.title)} ' f'{e(rule.id)} · {rule.severity}

' diff --git a/tests/test_faults.py b/tests/test_faults.py index afbc579..277242b 100644 --- a/tests/test_faults.py +++ b/tests/test_faults.py @@ -30,6 +30,8 @@ def ids(text): ("mmc-timeout", "[ 4.612001] mmc1: error -110 whilst initialising SD card"), ("mmc-crc", "[ 2.100000] mmc2: Got data interrupt 0x00200000 even though no data operation was in progress. CRC error"), ("mmc-io-error", "[ 9.100000] blk_update_request: I/O error, dev mmcblk2, sector 34816"), + ("sata-link", "[ 1.204511] ata1: COMRESET failed (errno=-16)"), + ("sata-link", "[ 11.418871] ata1: limiting SATA link speed to 3.0 Gbps"), ("eth-phy", "[ 1.313120] fec 30be0000.ethernet eth0: Unable to connect to phy"), ("eth-phy", "Could not get PHY for ethernet@30be0000: addr 7"), ("eth-no-link", "[ 12.000000] fec 30be0000.ethernet eth0: Link is Down"), diff --git a/tests/test_missing_faults.py b/tests/test_missing_faults.py new file mode 100644 index 0000000..aef8a65 --- /dev/null +++ b/tests/test_missing_faults.py @@ -0,0 +1,189 @@ +import json +import re +import xml.etree.ElementTree as ET + +import pytest + +from boot_log_diff import BUILTIN_RULES, compare, parse_rules +from boot_log_diff.report import render_html, render_json, render_junit, render_text + +GOLDEN = """\ +U-Boot 2023.04 +[ 1.100000] mmc2: new HS400 Enhanced strobe MMC card at address 0001 +[ 1.204511] ata1: SATA link up 6.0 Gbps (SStatus 133 SControl 300) +[ 1.300000] nvme nvme0: 8/0/0 default/read/poll queues +[ 1.312003] Micrel KSZ9031 Gigabit PHY 30be0000.ethernet-1:07: attached PHY driver +[ 1.402119] rtc-pcf85063 1-0051: registered as rtc0 +[ 1.511280] usb 1-1: new high-speed USB device number 2 using ci_hdrc +[ 1.511900] usb 1-2: new high-speed USB device number 3 using ci_hdrc +[ 1.600000] imx6q-pcie 33800000.pcie: Link up +[ 1.650000] spi-nor spi0.0: w25q256 (32768 Kbytes) +[ 1.702345] flexcan 308c0000.can: registering netdev can0 +[ 3.120345] fec 30be0000.ethernet eth0: Link is Up - 1Gbps/Full - flow control rx/tx +board login: +""" + + +def fault_ids(golden, dut, **kwargs): + return [fault.rule.id for fault in compare(golden, dut, **kwargs).faults] + + +def without(fragment, text=GOLDEN): + return "".join(line for line in text.splitlines(keepends=True) if fragment not in line) + + +@pytest.mark.parametrize( + "fragment, rule_id", + [ + ("mmc2: new", "mmc-card-missing"), + ("SATA link up", "sata-link-missing"), + ("nvme nvme0", "nvme-missing"), + ("attached PHY driver", "eth-driver-missing"), + ("Link is Up", "eth-link-missing"), + ("usb 1-2", "usb-device-missing"), + ("Link up", "pcie-link-missing"), + ("registered as rtc0", "rtc-missing"), + ("spi-nor", "spi-flash-missing"), + ("flexcan", "can-missing"), + ], +) +def test_builtin_rule_fires_when_the_line_is_gone(fragment, rule_id): + assert fault_ids(GOLDEN, without(fragment)) == [rule_id] + + +def test_missing_fault_points_at_the_golden_line(): + fault = compare(GOLDEN, without("flexcan")).faults[0] + assert [line.number for line in fault.lines] == [11] + assert fault.log == "golden" + + +def test_same_log_has_no_faults(): + assert fault_ids(GOLDEN, GOLDEN) == [] + + +def test_changed_value_is_not_a_missing_device(): + dut = GOLDEN.replace("Link is Up - 1Gbps/Full", "Link is Up - 100Mbps/Full") + assert fault_ids(GOLDEN, dut) == [] + + +def test_one_of_two_usb_devices_gone_counts(): + assert fault_ids(GOLDEN, without("usb 1-1")) == ["usb-device-missing"] + + +def test_moved_line_is_not_missing(): + lines = GOLDEN.splitlines(keepends=True) + lines[9], lines[10] = lines[10], lines[9] + assert fault_ids(GOLDEN, "".join(lines)) == [] + assert fault_ids(GOLDEN, "".join(lines), strict_order=True) == [] + + +def test_line_also_missing_from_golden_does_not_fire(): + assert fault_ids(without("flexcan"), without("flexcan")) == [] + + +def test_specific_fault_explains_the_missing_line(): + dut = GOLDEN.replace( + "[ 1.312003] Micrel KSZ9031 Gigabit PHY 30be0000.ethernet-1:07: attached PHY driver", + "[ 1.313120] fec 30be0000.ethernet eth0: Unable to connect to phy", + ) + dut = without("Link is Up", dut) + assert fault_ids(GOLDEN, dut) == ["eth-phy"] + + +def test_boot_stopping_reports_one_fault_not_every_driver(): + dut = "".join(GOLDEN.splitlines(keepends=True)[:3]) + faults = compare(GOLDEN, dut).faults + assert [fault.rule.id for fault in faults] == ["boot-incomplete"] + assert [line.number for line in faults[0].lines] == [4] + + +def test_boot_stopping_keeps_the_new_line_fault(): + dut = "".join(GOLDEN.splitlines(keepends=True)[:2]) + "[ 2.0] ata1: COMRESET failed (errno=-16)\n" + assert fault_ids(GOLDEN, dut) == ["sata-link", "boot-incomplete"] + + +def test_changed_last_line_is_not_a_stopped_boot(): + dut = GOLDEN.replace("board login:", "board2 login:") + assert fault_ids(GOLDEN, dut) == [] + + +def test_empty_dut_log_is_a_stopped_boot(): + assert fault_ids(GOLDEN, "") == ["boot-incomplete"] + + +def test_ignored_golden_line_does_not_fire(): + from boot_log_diff import Normalizer + + normalizer = Normalizer(ignore=[re.compile("flexcan")]) + assert fault_ids(GOLDEN, without("flexcan"), normalizer=normalizer) == [] + + +def test_own_missing_rule_with_unless(): + rules = parse_rules( + """ +[[fault]] +id = "fpga-missing" +title = "FPGA did not load" +match = "missing" +pattern = 'flexcan' +unless = ["kernel-panic"] +diagnosis = "Check the FPGA." +""" + ) + assert rules[0].match == "missing" + assert rules[0].unless == ("kernel-panic",) + assert fault_ids(GOLDEN, without("flexcan"), rules=rules) == ["fpga-missing"] + + +def test_own_rule_replaces_builtin_missing_rule_by_id(): + rules = parse_rules( + """ +[[fault]] +id = "can-missing" +title = "No CAN on this variant" +match = "missing" +pattern = 'flexcan' +severity = "warning" +diagnosis = "Expected." +""" + ) + faults = compare(GOLDEN, without("flexcan"), rules=rules + list(BUILTIN_RULES)).faults + assert [(f.rule.id, f.rule.severity) for f in faults] == [("can-missing", "warning")] + + +@pytest.mark.parametrize( + "extra, message", + [ + ("match = 'gone'\n", "match must be"), + ("unless = ['a']\n", "unless"), + ("match = 'missing'\nunless = 'a'\n", "unless"), + ("match = 'unreached'\nunless = ['a']\n", "unless"), + ], +) +def test_parse_errors(extra, message): + text = f"[[fault]]\nid = 'x'\ntitle = 't'\npattern = 'p'\ndiagnosis = 'd'\n{extra}" + with pytest.raises(ValueError, match=message): + parse_rules(text, "rules.toml") + + +def test_reports_name_the_golden_log(): + result = compare(GOLDEN, without("flexcan")) + text = render_text(result, "g", "d") + assert "(can-missing), golden line 11" in text + + data = json.loads(render_json(result, "g", "d")) + fault = data["faults"][0] + assert (fault["log"], fault["lines"][0]["line"]) == ("golden", 11) + + assert "golden line 11:" in render_html(result, "g", "d") + + root = ET.fromstring(render_junit(result, "g", "d")) + failure = [case for case in root.iter("testcase") if case.get("name") == "can-missing"][0].find("failure") + assert "golden line 11:" in failure.text + + +def test_new_line_fault_still_names_the_dut_log(): + dut = GOLDEN.replace("board login:", "Kernel panic - not syncing\nboard login:") + result = compare(GOLDEN, dut) + assert "(kernel-panic), dut line 13" in render_text(result, "g", "d") + assert json.loads(render_json(result, "g", "d"))["faults"][0]["log"] == "dut"